mensi.ch

Playing around with a ZX-SW82TS-L2P Network Switch

Having seeing some cool looking Chinese network switches on ServeTheHome, I've been checking out the new models every once in a while. This one has caught my attention and I got one:

You can find the exact same looking switch under multiple brands - I bought mine from Horaco. It is a managed 2.5G PoE switch with 2 SFP+ 10G cages. The label on the device itself states the model as "ZX-SW82TS-L2P". If we open it and go look at the PCB, we can also find "S1300WP-8GT-2S+ V1.03", which seems to match the scheme found in other popular Chinese switches, like for example the hasivo S1100WP-8XGT-SE. At the time of writing, hasivo has an Aliexpress listing with that model number and an identical looking device (apart from the color and branding).

The Web UI

The web UI looks surprisingly powerful, but in my case was all Chinese without any option to change. I asked the seller for an English firmware and got the response that one can switch the language via the console. It turns out that the switch also listens to telnet, so you can do this:

$ telnet 192.168.0.1
User Access Verification

Username:admin
Password:
Switch1>en
Switch1#factory language only-English
Switch1#reboot
Do you wish to reboot the switch? [Y/N]:

Logging in with the default admin/admin and the default IP address as printed on the label. Once the switch came back from reboot, the Web UI was indeed switched to English, yay!

The Console Port

The Switch comes with an RJ45 console port - this seems to be a Cisco/HP style RS232 RJ45 though I think only ground, RX and TX are connected. If we connect a USB to Console cable during startup, we get U-Boot logs:

U-Boot 2011.12.(3.6.8.55120) (Jul 11 2024 - 14:44:22)

Board: RTL9300 CPU:800MHz LX:175MHz DDR:600MHz
DRAM:  512 MB
SPI-F: MXIC/C22019/MMIO32-4/ModeC 1x32 MB (plr_flash_info @ 83f9a094)
Loading 65536B env. variables from offset 0xe0000
Net:   Net Initialization Skipped
No ethernet found.                                                                                        0
## Booting image from partition ... 0
## Booting kernel from Legacy Image at 81000000 ...
Image Name:   RTK_SDK
Created:      2025-04-27  13:18:11 UTC
Image Type:   MIPS Linux Kernel Image (lzma compressed)
Data Size:    6300104 Bytes = 6 MB
Load Address: 80000000
Entry Point:  803169a0
Verifying Checksum ... OK
Uncompressing Kernel Image ... OK

Starting kernel ...

console [ttyS0] enabled
bootconsole [early0] disabled
Calibrating delay loop... 531.66 BogoMIPS (lpj=2658304)
pid_max: default: 32768 minimum: 301
Mount-cache hash table entries: 1024 (order: 0, 4096 bytes)
Mountpoint-cache hash table entries: 1024 (order: 0, 4096 bytes)
SCSI subsystem initialized
usbcore: registered new interface driver usbfs
usbcore: registered new interface driver hub
usbcore: registered new device driver usb
TCP established hash table entries: 2048 (order: 1, 8192 bytes)
TCP bind hash table entries: 2048 (order: 3, 40960 bytes)
TCP: Hash tables configured (established 2048 bind 2048)
TCP: reno registered
UDP hash table entries: 256 (order: 1, 12288 bytes)
UDP-Lite hash table entries: 256 (order: 1, 12288 bytes)
futex hash table entries: 256 (order: 0, 7168 bytes)
ntfs: driver 2.1.31 [Flags: R/W].
jffs2: version 2.2. (NAND) (SUMMARY)  © 2001-2006 Red Hat, Inc.
msgmni has been set to 489
random: modprobe urandom read with 1 bits of entropy available
io scheduler noop registered
io scheduler deadline registered
io scheduler cfq registered (default)
Serial: 8250/16550 driver, 1 ports, IRQ sharing disabled
serial8250: ttyS0 at MMIO 0x0 (irq = 47, base_baud = 10764700) is a 16550A
SCSI Media Changer driver v0.25
RTK_SPI_FLASH_MIO driver is bypassed
RTK_NORSFG3 driver is used
=================================================================
init_luna_nor_spi_map: flash map at 0xb4000000
SPI NOR driver probe...
MXIC/C22019/MMIO32-4/ModeC add SPI NOR partition
MTD partitions obtained from built-in array
Creating 7 MTD partitions on "rtk_norsf_g3":
0x000000000000-0x0000000e0000 : "LOADER"
0x0000000e0000-0x0000000f0000 : "BDINFO"
0x0000000f0000-0x000000100000 : "SYSINFO"
0x000000100000-0x000000200000 : "JFFS2 CFG"
0x000000200000-0x000000300000 : "JFFS2 LOG"
0x000000300000-0x000000a00000 : "RUNTIME"
0x000000a00000-0x000002000000 : "RUNTIME2"
=================================================================
ehci_hcd: USB 2.0 'Enhanced' Host Controller (EHCI) Driver
rtk_gen1_hcd_cs_init: rtk_gen1_hcd_cs_init()!
rtk_gen1_hcd_cs_init: register rtk_gen1_ehci ok!
usb_phy_configure_process: usb_phy_configure_process()!
rtk_gen1-ehci rtk_gen1-ehci: Realtek On-Chip EHCI Host Controller
rtk_gen1-ehci rtk_gen1-ehci: new USB bus registered, assigned bus number 1
rtk_gen1-ehci rtk_gen1-ehci: irq 28, io mem 0x18021000
rtk_gen1-ehci rtk_gen1-ehci: USB 2.0 started, EHCI 1.00
hub 1-0:1.0: USB hub found
hub 1-0:1.0: 1 port detected
ohci_hcd: USB 1.1 'Open' Host Controller (OHCI) Driver
ohci-platform: OHCI generic platform driver
usbcore: registered new interface driver uas
usbcore: registered new interface driver usb-storage
TCP: cubic registered
Freeing unused kernel memory: 4964K (80427000 - 80900000)
init started:  BusyBox v1.00 (2025.04.27-05:16+0000) multi-call binary
Starting pid 26, console : '/etc/rc'
RTCORE LKM Insert...
    TC util init (isr)

Starting pid 62, console /dev/ttyS0: '/bin/sh'
Decompressing /mnt/switch.tar.gz ...
switch
switch/kernel
switch/kernel/kernel_rtl9312_7v6
switch/kernel/9310-sdk3.6.11
switch/kernel/kernel_9301_version.txt
switch/other
switch/other/certificate.pem.crt
switch/other/private.pem.key
switch/other/do_next
switch/other/AmazonRootCA1.pem
switch/module
switch/module/system_gpio.ko
switch/module/inos_ifksdk.ko
switch/module/i2c-ddm.ko
switch/module/reset.ko
switch/module/i2c-poe.ko
switch/module/i2c-rtc.ko
switch/module/i2c-encyt.ko
switch/app
switch/app/ospf6d
switch/app/ripngd
switch/app/mstpd
switch/app/snmpd
switch/app/ripd
switch/app/nsm
switch/app/lacpd
switch/app/webfiles.tar
switch/app/ospfd
switch/app/bgpd
switch/app/openssh.tar.gz
switch/app/imi
switch/app/pimd
openssh
openssh/sbin
openssh/sbin/sshd
openssh/libexec
openssh/libexec/sshd-session
openssh/ssh
openssh/ssh/ssh_host_rsa_key.pub
openssh/ssh/ssh_host_ed25519_key.pub
openssh/ssh/ssh_config
openssh/ssh/ssh_host_ecdsa_key.pub
openssh/ssh/ssh_host_ecdsa_key
openssh/ssh/ssh_host_rsa_key
openssh/ssh/sshd_config
openssh/ssh/ssh_host_ed25519_key
openssh/lib
openssh/lib/libcrypto.so.1.1
openssh/lib/libz.so.1
openssh/lib/libz.so.1.3.1
openssh/passwd
openssh/bin
openssh/bin/ssh
Start to run IFKSDK ...
Start to load DDM module ...
Start to load PoE module ...
Start to load encyt module ...
Start to load GPIO module ...
Start to load reset module ...
Start to load RTC module ...
Start to run MSTP ...
Start to run LACP ...
Layer 2 switch loading ...
Start to load configuration ...

ManagedSwitch 6.9.0
Creation date:May  7 2025 16:25:44



User Access Verification



Username:

Getting the Firmware

In the past, I was usually lucky enough to find a firmware update somewhere I could further poke at. In this case, I could find very little information about this device and no firmware update files at all. So let's have a look inside!

Looking at the PCB, the SPI flash chip immediately jumps out due to the two different possible footprints:

Searching for the IC marking reveals that this is likely a 25-series SPI flash chip from Macronix, the MX25L25645G (32MiB). This matches what we saw in the U-Boot log: SPI-F: MXIC/C22019/MMIO32-4/ModeC 1x32 MB.

Since I've never done this before, let's try desoldering the chip and dumping it! To get it off, I taped the area around the chip with Kapton tape to prevent accidentally blowing away components. I then used a hot-air station around 290°C and heated up the chip's legs. Once the solder started to look liquid, I carefully lifted the chip with tweezers.

Then, I used an 8-pin SOP clamp and a CH341A programmer to dump the memory with flashrom:

$ flashrom -p ch341a_spi -r firmware.bin -VV

which worked flawlessly and gave me a 32MiB file as expected.

Unpacking the Flash Dump

I started off with binwalk to get an idea what the dump contains:

$ binwalk firmware.bin
---------------------------------------------------------------------------
DECIMAL    HEXADECIMAL   DESCRIPTION
---------------------------------------------------------------------------
867884     0xD3E2C       CRC32 polynomial table, little endian
1048576    0x100000      JFFS2 filesystem, big endian, nodes: 589, total
                        size: 2093068 bytes
3145792    0x300040      LZMA compressed data, properties: 0x5D,
                        dictionary size: 67108864 bytes, compressed size:
                        6300104 bytes, uncompressed size: -1 bytes
10485760   0xA00000      JFFS2 filesystem, big endian, nodes: 21788, total
                        size: 23068672 bytes
---------------------------------------------------------------------------

Now this is not what I expected based on the U-Boot output we saw earlier:

MTD partitions obtained from built-in array
Creating 7 MTD partitions on "rtk_norsf_g3":
0x000000000000-0x0000000e0000 : "LOADER"
0x0000000e0000-0x0000000f0000 : "BDINFO"
0x0000000f0000-0x000000100000 : "SYSINFO"
0x000000100000-0x000000200000 : "JFFS2 CFG"
0x000000200000-0x000000300000 : "JFFS2 LOG"
0x000000300000-0x000000a00000 : "RUNTIME"
0x000000a00000-0x000002000000 : "RUNTIME2"

As it turns out, MTD does not have a partition table (like it says, loading from built-in array) so binwalk has no way of finding the MTD partitions and is instead looking for recognizable filesystems and such.

Since we already know from U-Boot where the partitions are, we can just extract them ourselves. As I was doing this on a Windows machine, here's some Powershell to achieve this:

$bytes = gc .\firmware.bin -Encoding Byte -Raw
sc .\firmware\LOADER.bin ([byte[]]($bytes | select -First 0xe0000)) -Encoding Byte
sc .\firmware\BDINFO.bin ([byte[]]($bytes | select -Skip 0xe0000 -First 0x10000)) -Encoding Byte
sc .\firmware\SYSINFO.bin ([byte[]]($bytes | select -Skip 0xf0000 -First 0x10000)) -Encoding Byte
sc .\firmware\JFFS2_CFG.bin ([byte[]]($bytes | select -Skip 0x100000 -First 0x100000)) -Encoding Byte
sc .\firmware\JFFS2_LOG.bin ([byte[]]($bytes | select -Skip 0x200000 -First 0x100000)) -Encoding Byte
sc .\firmware\RUNTIME.bin ([byte[]]($bytes | select -Skip 0x300000 -First 0x700000)) -Encoding Byte
sc .\firmware\RUNTIME2.bin ([byte[]]($bytes | select -Skip 0xa00000 -First 0x1600000)) -Encoding Byte

Which was horribly slow but seemed to do the trick. dd on Linux would probably have been much faster.

The U-Boot env

From the U-Boot log, we know that the BDINFO.bin file should contain the U-Boot environment. Looking at it in a hex editor looks like there's 4 bytes of something and then \0-byte separated key-value pairs. Let's whip up some silly Python to parse it:

f = open('BDINFO.bin', 'rb')
d = f.read()
for var in d[4:].split(b'\0'):
    if var == b'':
        continue
    print(var.decode('ascii'))

running it gives us:

baudrate=38400
boardmodel=RTL9302D_2x8224_2XGE
bootargs=console=ttyS0,38400n8
bootcmd=boota
bootdelay=3
bootmsg=1
ethaddr=00:E0:4C:00:00:00
ipaddr=192.168.1.1
ledModeInitSkip=1
serverip=192.168.1.111
stderr=serial
stdin=serial
stdout=serial

which looks somewhat interesting, as we potentially have 3 seconds to then use the tftboot command to boot from 192.168.1.111. So far I didn't have any luck interrupting u-boot though.

The main JFFS2 Filesystem

RUNTIME2.bin is the largest partition with around 23 MB and contains a JFFS2 filesystem. To extract it, we can use the jefferson PyPI package.

As we've already seen in the U-Boot log, there's a large switch.tar.gzfile. The script doing the decompression is likely the file do, as it contains:

echo "Decompressing /mnt/switch.tar.gz ..."
tar zxvf /mnt/switch.tar.gz -C /tmp/

which matches the log output and the verbose decompression that follows. We can of course also do the same to look at the contents. Intriguingly, tar tells us it is ignoring some extra data at the end. This could hint at some sort of checksum or signature mechanism for firmware updates.

After extraction, do executes /tmp/switch/other/do_next which matches more of the log output we've seen. One interesting thing it does is unpack openssh.tar.gz so presumably SSH can somehow be turned on.

The rest of the script is loading some kernel modules and starting some daemons. The custom ones seem to be /tmp/switch/app/imi and /tmp/switch/app/nsm.

We can also find all the Web UI endpoints, which seem to all be ELF binaries run via CGI - but for example looking at upgradeImg.do, it mostly seems to do its thing by issuing console commands to 127.0.0.1:2700 so it seems likely the Web UI is basically a frontend to the console.

All binaries seem unstripped, so understanding them should be somewhat easier.

The imi daemon

Based on the strings in this binary, it might be the main console server. Loading it into Ghidra, one thing in the main function immediately jumps out: It seems to expect some sort of authentication code on /dev/i2c-encyt.

Some more observations around the startup:

  • In imi_openssh_start: The SSH port seems to be controllable in /mnt/switch_info/swinfo.txt with an ssh_port: line but defaults to 22. The sshd seems to be started unconditionally.

  • In http_sock_init: The console open to the webinterface seems to be listening on 0.0.0.0:2700

  • There seems to be some sort of low level management interface in imi_ztpm_handle_bpdu looking for magic Ethernet frames.

Since port 22 and 2700 do not seem open on the running device, I assume there must be some additional mechanism or packet filtering involved.

Switching gears a bit, let's look at references to switch.tar.gz. The function writeUpgradeFile looks interesting. It first subtracts 0x1c from the filesize and calls imi_switch_file_additional_region_check. This function checks that these last 0x1c bytes at the end of the file start with 0x90abcdef. If we go look at the switch.tar.gz we extracted, it does indeed have that magic number at the expected position. The next 4 bytes are then checked against a number in string form that depends on the version of the kernel file in /tmp/switch/kernel/. In my case it's "9312". Finally, with suffix being these last files as uint32_t[], the filesize must be equal to suffix[3] + suffix[4] + suffix[5] + 0x1c.

Another check is done on suffix[2]: It is also interpreted as a string and used for another file existence check. In my case it is "7v6\0" and the resulting file /tmp/switch/kernel/kernel_rtl9312_7v6.

suffix[3] seems to contain a length from the start and just before that is an MD5. For the extracted example, this points just to this suffix, so the MD5 should be before 0x90abcdef. The MD5 computation is a bit silly though since strlen is used to determine the amount of bytes to be hashed. Here, the first \0 already appears after the first 3 bytes of switch.tar.gz, so only these 3 bytes are hashed. The hash indeed matches with what is stored at the end.

patch.tar.gz

There's also a patch feature where a patch.tar.gz can be downloaded from TFTP. The last 16 bytes are the MD5 of the file with the length again determined with strlen. Inside, a patch/patch.txt is expected, with the first two lines being:

  • # This is a patch.txt file.
  • cp /home/patch/patch.txt /mnt/patch.txt

The rest of the file is read line by line and every line is passed to system(). So let's give it a quick try to see if we can execute commands on the switch. First, I installed and started tftpd-hpa on an Ubuntu machine. We know from the web UI that ping seems to be installed, so let's have it ping our machine with this:

# This is a patch.txt file.
cp /home/patch/patch.txt /mnt/patch.txt
# Ping
ping 192.168.1.123

Then, we can create an archive:

$ tar -czf patch.tar.gz patch/patch.txt

It's important that the patch.txt is in a patch subdirectory. Now we need to append the magic strlen MD5, which we can do with a bit of Python:

import hashlib

with open("patch.tar.gz", "rb") as f:
data = f.read().split(b'\0')[0]
hash = hashlib.md5(data).digest()

with open("patch.tar.gz", "ab") as f:
f.write(hash)

Running this gives us the final file we can put into /srv/tftp/ to make it available over TFTP. Now connect to the switch over telnet and run (assuming the TFTP server is at 192.168.1.123):

Switch>en
Switch#download patch 192.168.1.123 patch.tar.gz
Do you wish to continue? [Y/N]: Y
Downloading file. Don't shutdown the switch. Please wait for a moment ...
Total data bytes sent/received: 203.
Decompressing file. Don't shutdown the switch. Please wait for a moment ...
Updating files. Don't shutdown the switch. Please wait for a moment ...

At this point, it will hang due to the infinite pings, but you should see those with tcpdump arriving at the TFTP host. Yay!

Getting SSH access

As we've seen in the do_next script and openssh.tar.gz, we do have an sshd binary available in /tmp/openssh/sbin/sshd and an SSH client in /tmp/openssh/bin/ssh. At first, I tried to just run sshd on a different port - but that wasn't accessible from the outside. Running on the standard port 22 also didn't work - on the serial console I could see that the port was already in use.

I then tried starting sshd on a different port, then connecting back to my TFTP host with a ssh key file I shipped within patch.tar.gz and opening a reverse tunnel with the -R option. Doing this while actually running the client in the background with an & at the end worked... Just to reveal that they did some custom vty logic to basically provide the CLI on any terminal. I didn't have much luck with the -T option trying to bypass PTY allocation :(

Building a custom binary

Back when I was messing with a Rigol oscilloscope, I experimented with cross-compiling little rust binaries and running them on the scope. We can try doing the same here. So let's get the toolchain:

$ sudo apt install gcc-mips-linux-gnu gcc-multilib-mips-linux-gnu
$ rustup target install mips-unknown-linux-gnu
$ cargo new rshell
$ cd rshell

This time, we will set the linker in .cargo/config.toml:

[target.mips-unknown-linux-gnu]
linker = "mips-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crt-static"]

And apply the same optimization settings to get a small binary in Cargo.toml:

[package]
name = "rshell"
version = "0.1.0"
edition = "2024"

[dependencies]

[profile.release]
lto = true
strip = "symbols"
codegen-units = 1
opt-level = "z"

And finally compile:

$ cargo build --target=mips-unknown-linux-gnu -Zbuild-std --release

And we get a ~1M binary in target/mips-unknown-linux-gnu/release/rshell. However, running this via patch.tar.gz just prints "Illegal Instruction" on the serial console. So it turns out the MIPS core used in these Realtek chips might be Lexra cores that do not fully support MIPS :(

Back to basics: C hello world

The RTL93xx chip is also used in other switches, for example the Zyxel XGS1210 series where some OpenWRT support seems to exist. I didn't really find anything hinting at requiring the RTL SDK toolchain to compile for these, so I'm wondering whether GCC actually avoids the problematic instructions while Rust doesn't. So let's give it a try with C and the MIPS GCC cross compiler we already installed.

#include <stdio.h>

int main() {
  printf("Hello World!");
  return 0;
}

and compile with

$ mips-linux-gnu-gcc -o hello main.c -static

sadly that runs into the same Illegal Instruction :(

Now the problem here is that I don't necessarily know the right search terms, so trying to find more information had a tendency to just end up with a million browser tabs. But luckily it's 2025 and tools like Gemini 2.5 Pro exist. Gemini proposed that this particular core does not have hardware floating point support, and that could also be a problem. After going back and forth a couple of times, the proposal was to build a MUSL softfloat toolchain:

$ git clone https://github.com/richfelker/musl-cross-make.git
$ cd musl-cross-make/
$ make TARGET=mips-linux-muslsf
$ make TARGET=mips-linux-muslsf install
$ export PATH=`pwd`/output/bin:$PATH
$ export LIBRARY_PATH=`pwd`/output/lib

We can then go back to our source code and compile our hello world:

$ mips-linux-muslsf-gcc -march=34kc -static main.c -o hello

And packaging this as a patch.tar.gz and running it on the switch actually works and prints Hello World! on the serial console, yay!

Getting a shell

We can use something like this to get a shell on the switch. In my experimentation it seemed like while packets egressing from the switch worked fine for any port, the responses didn't make it to the binary. However, if we have our machine listen on the telnet port 23 and have the reverse shell binary connect to it, we can get a root shell on the switch.

Further Work

As far as I've seen, the other binaries and kernel modules in the firmware are all unstripped with nice symbols - so it should be in the realm of the possible to get something like OpenWRT working. Though that requires a lot more work of course.


Custom firmware for an Athom Smart Plug

I got a couple of Athom Smart Plugs to measure the power consumption of some appliances. They come with either Tasmota or ESPHome pre-flashed. Mine came with ESPHome - but my main goal is to just have them report data via MQTT so I can get it into my InfluxDB + Grafana stack. ESPHome seems to have a philosophy where pretty much only the Wifi credentials are configurable, but something like setting an MQTT server doesn't seem possible.

So I also tried Tasmota by just flashing it via the web-based firmware upgrade which worked without issues. Tasmota however seemed to publish new values to MQTT very infrequently and didn't seem to have any options to change that.

Luckily, it looks like Athom publishes their ESPHome configs on Github, so we don't have to start from scratch.

Customizing the config

Since I only want to publish data to MQTT, I don't need the Homeassistant API, so I removed the api: and dashboard_import: sections. Instead, I added a section for MQTT:

mqtt:
  topic_prefix: smart_plug/some_appliance
  broker: 192.168.1.2
  port: 1883

The broker: IP address is the address of my MQTT broker and topic_prefix: is prepended to the MQTT topics for the measured data.

I also wanted that the relay is always turned on - imagine for example if you use this to measure the power consumption of your freezer - you don't want to accidentally turn it off. So I turned the switch: into an output: like this:

output:
  - platform: gpio
    pin: GPIO12
    id: relay

The power button on the side can also be used to toggle the relay, so I made it toggle the blue LED instead:

    on_multi_click:
      - timing:
        - ON for at most 1s
        - OFF for at least 0.2s
        then:
          - light.toggle: blue_led
      - timing:
        - ON for at least 4s
        then:
          - button.press: Reset

There's also an overcurrent protection. While one could argue that that's a good thing, your circuits in the house should be properly fused and this should be quite redundant. So I simply removed this block:

      on_value_range:
        - above: ${current_limit}
          then:
            - switch.turn_off: relay

Finally, we need to turn our output on when the plug boots. To do so, just append this to the esphome: section:

  on_boot:
    - priority: 90
      then:
      - output.turn_on: relay

Compiling the firmware

ESPHome provides a docker container with all the prerequisites for compiling ESPHome, so we can be lazy and just use that:

$ docker pull ghcr.io/esphome/esphome
$ docker run --rm -v "${PWD}":/config -it ghcr.io/esphome/esphome compile athom-smart-plug-v2.yaml

The compiled firmware will end up in .esphome/build/athom-smart-plug-v2/.pioenvs/athom-smart-plug-v2/firmware.bin

You can flash it via the web UI of your smart plug. In case the firmware is too large, just flash Tasmota's tasmota-minimal.bin firmware first so you have more space available to flash your custom ESPHome firmware.

MQTT data

The topics published to MQTT will look something like this:

smart_plug/some_appliance/sensor/voltage/state 233.4
smart_plug/some_appliance/sensor/current/state 0.00
smart_plug/some_appliance/sensor/power/state 0.0
smart_plug/some_appliance/sensor/total_energy/state 0.000
smart_plug/some_appliance/sensor/total_daily_energy/state 0.000

and more - use mosquitto_sub or your favorite MQTT client to listen to smart_plug/#.

Telegraf

I'm using Telegraf to subscribe to MQTT and push the data into InfluxDB. The config for it looks like this:

[[inputs.mqtt_consumer]]
  servers = ["tcp://192.168.1.2:1883"]
  topics = [
    "smart_plug/+/sensor/power/state",
    "smart_plug/+/sensor/voltage/state",
    "smart_plug/+/sensor/power_factor/state",
    "smart_plug/+/sensor/current/state",
    "smart_plug/+/sensor/apparent_power/state",
    "smart_plug/+/sensor/total_energy/state",
  ]
  data_format="value"
  data_type="float"

  [[inputs.mqtt_consumer.topic_parsing]]
    topic = "smart_plug/+/sensor/+/state"
    measurement = "_/_/_/measurement/_"
    tags = "_/location/_/_/_"

Getting the topic_parsing right was a bit tricky since the documentation on it seemed a bit sparse. The idea is to use measurement and tags to extract parts of the topic to be either the _measurement or a tag for the stored timeseries data.

Final Thoughts

During my research for this project, I found some threads where people were complaining about Athom, basically accusing them of just using Open Source projects like ESPHome or Tasmota and shifting the support burden to those projects instead of supporting the products themselves.

I personally prefer if customizing the behavior of bought hardware is this easy. Instead of spending endless hours on reverse engineering, I was just able to customize their config, compile and flash it in a short amount of time.

On the other hand, it feels like both Tasmota and ESPHome are not terribly user friendly. I don't have much of an issue myself, but I wouldn't want to see any non-techy person trying to do this. So my conclusion would be that I'd want more userfriendlyness in Tasmota/ESPHome rather than trying to make higher barriers for their use.


Creating an USB interface for SunnyBoy Inverters

Previously, I looked at the data interface of a SunnyBoy inverter. Since we now know the pinout, we can attempt at building our own interface board. To make our life simple, it would be nice to get it to be USB, so we don't have to mess around with RS485 or RS232 adapters.

Considerations for the USB Interface

USB uses differential signalling at a speed at depends on the version. While the inverter itself uses a relatively slow baud rate, our electrical interface still needs to be good enough for USB. If we want to use the existing screw terminals to connect the wires, USB3 is not going to work, so we want to aim at an UART to USB converter IC that uses as old or slow of an USB version as possible.

The popular cheap option - the CH340 series - is an USB full speed device, so USB 1.1 with 12 Mbps. Maybe we can get away with the screw terminals. Otherwise we have to use a proper USB connector.

Since USB also provides 5V power, we do not need an isolated DC-DC power supply. Instead, we can just use the power provided by USB.

Isolation

The manufacturer-designed PiggyBack modules use a combination of ESD protection diodes, resistors and optocouplers to isolate the data signals. Another option is to use dedicated digital isolation ICs. We however have to be a bit careful about what part we select. The cheap, widely available SOIC-8 parts often have weak or no input protection and a 560V rating for the working voltage. They tend to have a higher peak isolation voltage rating, but can only survive that for short amounts of time. We need more than 600V isolation sustained.

There are also higher rated parts with integrated ESD protection, for example the TI ISO7721DWR. It comes in a wider package to also increase the creepage distances around the body of the part. It only needs one external part: A 0.1 uF capacitor on the power pins for each side.

Circuit

So, given the choice of digital isolator and TTL-to-USB converter IC, we can put together a simple circuit based on the recommended external parts in their respective datasheets:

Since the ISO7721DWR already has ESD protection integrated, we do not need external resistors or ESD diode arrays. A 100nF decoupling capacitor is recommended on each side though.

Likewise, the CH340N includes an oscillator and all required resistors for USB, so there we also get away with just adding some power supply decoupling.

PCB

Due to the low part count, the PCB is quite trivial:

The only slightly noteworthy thing is that the ground planes on the back have a nice separation gap between them. Otherwise we'd be circumventing the isolation the ISO7721DWR provides. Even with the solder mask, the isolation should be good enough for 1000V.

Getting the Boards built

I like the integration between lcsc.com, easyeda.com and jlcpcb.com for quick prototypes - you get the symbols and footprints directly via the part-number and ordering PCBs with or without assembly is just a few clicks. So I used them here as well - you could of course achieve the same with KiCad and some other manufacturer.

At the time of writing, 5 PCBs cost $3.10 and parts for 4 boards $10.69. Shipping and handling for both (old customers get a discount on LCSC when also ordering from JLCPCB) came out to $11.56. This is quite a bit less than what even a single second-hand RS485 piggy back sells for.

I also ordered some insulating silicone tube and cable glands which came out to ~$5 per inverter.

Testing

Warning!

If you're following along and want to install a similar board, please keep in mind that the inverter has mains voltage and the full PV string voltage present. This can be hundreds of volts DC. Disconnect the inverter completely and let capacitors drain. If in doubt, hire a certified electrician to do the work.

Also, keep in mind that the design presented in this article is the work of an amateur and has not been tested or certified by any authority or lab. I cannot guarantee that it is safe and does not burn down your house.

With the parts soldered, we're ready to test if the gamble with USB 1.1 over the screw terminals worked out. While just chopping off one end of an old USB A to mini or micro USB cable would be enough, I'd recommend putting ferrules on the exposed ends. Otherwise the screw terminals can easily break the thin strands for the D+ and D- signal wires.

USB can be tested even with the inverter turned off - since the required power is provided via USB as well. And it indeed looks like the CH340N works just fine hooked up to a Raspberry Pi.

With everything closed up and the inverter powered up, we can try to read out some values. SMA provides an open source library to interface with their devices called YASDI. After building the library with the instructions supplied with the SDK, we can use the yasdishell executable to test. For that, we first need to create a yasdi.ini config file:

[DriverModules]
Driver0=yasdi_drv_serial

[COM1]
Device=/dev/ttyUSB0
Media=RS232
Baudrate=1200
Protocol=SMANet

[Misc]
DebugOutput=/dev/stdout

It does not seem to make much of a difference whether we use RS232 or RS485 as the media. In yasdishell, scan for devices and get spot values as per the built-in help.

Conclusion

With this, we have a working, simple way of getting stats out of old inverters. To process the data further, one can for example use github.com/pkwagner/yasdi2mqtt to publish measurements to MQTT. I'm then using my InfluxDB-Telegraf-Grafana stack I use for other things to also store and visualize the inverter data.