Modbus RTU Over USB: Five Lessons From a Year of Polling Solar Inverters
One of the FutureGrid edge devices talks to solar inverters over Modbus RTU - a protocol from 1979 that still powers most industrial equipment. RS-485 wiring, 9600 baud, holding registers. It sounds simple until you actually have to make it reliable on a Raspberry Pi running 24/7 in someone's garage.
Here are five things I learned the hard way.
1. Your USB-RS485 Adapter Matters More Than You Think
Not all USB-to-RS485 adapters are equal. I tested two common chips:
- FTDI FT232R (vendor
0403:6001) - stable, deterministic timing - CH340 (vendor
1a86:7523) - cheaper, but needs special handling
The CH340 would occasionally corrupt responses when reading multiple register blocks in sequence. The fix was counterintuitive: close and reopen the serial port between every block read. I call it "reconnect-per-read."
# Pseudocode - the reconnect-per-read pattern
for block in register_blocks:
client = open_serial(port, baudrate=9600)
data = client.read_holding_registers(block.start, block.count)
client.close()
await asyncio.sleep(0.5) # give the bus breathing room
That 0.5-second inter-block delay is not optional. Without it, you get fragmented responses - especially if there's a CAN bus on the same device (some hybrid inverters multiplex CAN and Modbus internally).
2. pymodbus AsyncModbusSerialClient Will Burn Your CPU
I initially used pymodbus's async serial client because - why wouldn't you in an async application? Turns out AsyncModbusSerialClient.connect() busy-spins when the device is unresponsive:
# top output at 3 AM when the inverter was in standby
PID %CPU COMMAND
1234 98.7 python3 collector
The async transport layer polls the serial port in a tight loop with no backoff when there's nothing to read. This is fine over TCP, but over serial with a sleeping inverter it pegs the CPU at 100% and starves every other coroutine in the event loop.
Fix: Use the synchronous ModbusSerialClient wrapped in asyncio.to_thread():
def _sync_read(self, address: int, count: int) -> list[int]:
"""Runs in a thread pool - can block without killing the event loop."""
result = self._client.read_holding_registers(address, count)
if result.isError():
raise ModbusException(str(result))
return list(result.registers)
async def read_holding_registers(self, address: int, count: int) -> list[int]:
return await asyncio.to_thread(self._sync_read, address, count)
Boring, but it works. The thread blocks, the event loop stays free, CPU sits at 2%.
3. Not All Function Codes Work on All Devices
The Modbus spec defines FC3 (read holding registers) and FC4 (read input registers) as two separate address spaces. Many devices implement both. Some don't.
I spent a full day debugging why FC4 returned IllegalFunction on a three-phase hybrid inverter. The answer was in a forum post from 2019: this particular inverter family only implements FC3. Everything - configuration, telemetry, identification - lives in holding registers.
Lesson: Don't assume FC4 works. Start with FC3 and test FC4 explicitly. Your driver abstraction should let each device declare which function codes it supports.
4. Register Block Size Has a Hidden Limit
The Modbus RTU spec allows reading up to 125 registers in one request. In practice, many devices choke well before that. I found a sweet spot around 12 registers per block for the devices I work with.
Larger blocks (20+) would return valid CRC but scrambled data - the device's internal bus couldn't marshal that many registers in one response frame. There's no error, no exception code. Just wrong numbers.
The only way to find the limit is empirical: start small, increase until data gets weird, back off by 20%.
Block 500-511: ✓ (12 registers, clean)
Block 500-519: ✓ (20 registers, looks ok)
Block 500-529: ✗ (30 registers, register 520+ is garbage)
5. Inverters Behave Differently at Night
Solar inverters in standby mode (no PV production, battery idle) are a different beast:
- Response times go from ~200ms to 19 seconds per read
- Serial buffers fill with
0xFFpadding bytes - Some registers return stale values, others return zero
This means your polling loop needs to handle a cycle that takes 2 minutes instead of 10 seconds - without assuming the device is dead. I use a simple heuristic: if the battery voltage register reads exactly 0.0V, the BMS is disconnected, and all battery metrics get dropped rather than stored as misleading zeros.
# A 48V LiFePO4 battery always reads >40V when connected
if metrics.get("battery_voltage", -1) == 0:
for key in BATTERY_METRICS:
metrics.pop(key, None)
The dashboard shows "--" instead of "0 kW" - which is what the operator actually expects to see.
Bonus: The Wiring Trap
Some hybrid inverters have two RJ45 ports that look identical. One is for the battery management system (CAN bus), the other is for monitoring (Modbus RS-485). If you plug your USB adapter into the wrong port, everything appears to work - you get a serial connection, you can send requests - but every response comes back as IllegalFunction (0x01).
The reason: when a CAN battery is connected to the BMS port, the device disables Modbus on that port entirely. The Modbus-only port continues to work fine. There is nothing in the documentation that tells you this. I found it by unplugging cables one at a time.
What I'd Do Differently
If I started over, I'd build a register discovery tool that probes FC3/FC4 across the full address space (0-65535) in small blocks, maps out which addresses respond, and generates a device profile automatically. Would have saved me weeks of reading PDFs and forum posts.
I'd also invest earlier in a device identification protocol - reading serial numbers, firmware versions, and rated capacities from the device on first connection, rather than requiring manual configuration. Most Modbus devices expose this information somewhere in their register map; you just have to find it.
Modbus is old, cranky, and underspecified. But it's everywhere in energy infrastructure, and once you learn its quirks, it's remarkably reliable. The protocol isn't the problem - it's the implementations.
This is part of an ongoing series about building FutureGrid, a smart energy monitoring platform. Next up: MQTT over mTLS through nginx stream proxies - and the reconnect race condition that took three days to find.