Playing with modbus
When a friend asked me to help extract data from their solar inverter (Wechselrichter), I thought it would be straightforward. The German manufacturer had locked down their system tighter than a vault, exposing minimal information through their proprietary software. After some digging, I discovered Modbus was available but completely undocumented. What followed was a journey into the world of industrial communication protocols, reverse engineering, and the art of making uncooperative hardware talk.
What is Modbus?
Modbus is an industrial communication protocol developed in 1979 that has become a de facto standard for connecting industrial devices. It's simple, robust, and widely supported - which is probably why it's still everywhere today, even in modern solar inverters.
The protocol works on a master-slave model where:
- Master (your computer) sends requests
- Slave (the inverter) responds with data
- Communication happens over serial (RTU) or TCP/IP networks
- Data is organized in registers that hold different values
Prerequisites
Before diving into Modbus communication, you'll need these tools. I'm focusing on Linux/Unix tools, but Windows equivalents exist.
-
Modbus CLI Tools
sudo apt-get install libmodbus-dev # Or install modbus-utils for basic CLI tools pip install pymodbus[cli] -
Python and PyModbus (for scripting)
pip install pymodbus -
Network scanning tools (for TCP Modbus)
sudo apt-get install nmap -
Serial tools (for RTU Modbus)
sudo apt-get install minicom screen -
Optional: Wireshark (for protocol analysis)
sudo apt-get install wireshark
Key Concepts
Modbus Data Model
Modbus organizes data into four types of registers:
| Type | Access | Size | Purpose |
|---|---|---|---|
| Coils (0x) | Read/Write | 1 bit | Digital outputs (relays, switches) |
| Discrete Inputs (1x) | Read-only | 1 bit | Digital inputs (sensors, switches) |
| Input Registers (3x) | Read-only | 16 bit | Analog inputs (measurements) |
| Holding Registers (4x) | Read/Write | 16 bit | Configuration, setpoints |
Function Codes
Modbus uses function codes to specify operations:
0x01- Read Coils0x02- Read Discrete Inputs0x03- Read Holding Registers0x04- Read Input Registers0x05- Write Single Coil0x06- Write Single Register
Register Addressing
This is where it gets tricky. Modbus has different addressing conventions:
- Protocol addresses: 0-based (0, 1, 2, ...)
- Data model addresses: 1-based (1, 2, 3, ...)
- Register references: Type prefix + address (30001, 40001, ...)
Most tools use protocol addresses (0-based), but documentation often uses data model addresses.
The Investigation Process
Step 1: Network Discovery
First, I needed to find the inverter on the network. Most modern inverters support Modbus TCP on port 502.
# Scan for devices with open Modbus port
nmap -p 502 192.168.1.0/24
# Check if specific IP responds
nmap -p 502 192.168.1.100
Step 2: Initial Connection Test
Once I found the IP, I tested basic connectivity:
# Test connection using modbus CLI
modbus read 192.168.1.100 502 1 0 10
# Or using Python for a quick test
python3 -c "
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient('192.168.1.100')
result = client.read_holding_registers(0, 10, slave=1)
print(result.registers if not result.isError() else 'Error')
client.close()
"
Step 3: Register Discovery
This was the real detective work. With no documentation, I had to map out what each register contained by reading ranges and correlating with the inverter's display.
# Read ranges of registers to find data
for i in {0..100..10}; do
echo "Registers $i-$((i+9)):"
modbus read 192.168.1.100 502 1 $i 10
sleep 1
done
Step 4: Data Interpretation
The raw register values needed interpretation. Solar inverters typically store:
- Power values: Often in watts or kilowatts
- Voltage/Current: Usually with scaling factors
- Energy counters: Accumulated values
- Status codes: Bit flags or enumerated values
I created a mapping table by observing the inverter display and correlating with register values:
# Example register mapping discovered through testing
REGISTER_MAP = {
40001: {"name": "AC_Power", "unit": "W", "scale": 1},
40002: {"name": "AC_Voltage_L1", "unit": "V", "scale": 0.1},
40003: {"name": "AC_Current_L1", "unit": "A", "scale": 0.01},
40010: {"name": "DC_Voltage", "unit": "V", "scale": 0.1},
40011: {"name": "DC_Current", "unit": "A", "scale": 0.01},
40020: {"name": "Total_Energy", "unit": "kWh", "scale": 0.1},
}
The Python Solution
After mapping the registers, I created a Python script to read and interpret the data:
#!/usr/bin/env python3
from pymodbus.client import ModbusTcpClient
import time
import json
from datetime import datetime
class InverterReader:
def __init__(self, host, port=502, slave_id=1):
self.client = ModbusTcpClient(host, port=port)
self.slave_id = slave_id
# Register mapping discovered through investigation
self.registers = {
0: {"name": "AC_Power", "unit": "W", "scale": 1},
1: {"name": "AC_Voltage_L1", "unit": "V", "scale": 0.1},
2: {"name": "AC_Current_L1", "unit": "A", "scale": 0.01},
9: {"name": "DC_Voltage", "unit": "V", "scale": 0.1},
10: {"name": "DC_Current", "unit": "A", "scale": 0.01},
19: {"name": "Total_Energy", "unit": "kWh", "scale": 0.1},
30: {"name": "Inverter_Temperature", "unit": "°C", "scale": 0.1},
}
def connect(self):
"""Establish connection to the inverter"""
return self.client.connect()
def read_register(self, address):
"""Read a single holding register"""
try:
result = self.client.read_holding_registers(address, 1, slave=self.slave_id)
if result.isError():
return None
return result.registers[0]
except Exception as e:
print(f"Error reading register {address}: {e}")
return None
def read_all_data(self):
"""Read all mapped registers and return formatted data"""
data = {
"timestamp": datetime.now().isoformat(),
"readings": {}
}
for address, config in self.registers.items():
raw_value = self.read_register(address)
if raw_value is not None:
# Apply scaling factor
scaled_value = raw_value * config["scale"]
data["readings"][config["name"]] = {
"value": scaled_value,
"unit": config["unit"],
"raw": raw_value
}
return data
def monitor(self, interval=30):
"""Continuously monitor and log data"""
print("Starting inverter monitoring...")
while True:
try:
data = self.read_all_data()
# Print current readings
print(f"\n--- {data['timestamp']} ---")
for name, reading in data["readings"].items():
print(f"{name}: {reading['value']} {reading['unit']}")
# Optionally save to file
with open("inverter_data.json", "a") as f:
f.write(json.dumps(data) + "\n")
time.sleep(interval)
except KeyboardInterrupt:
print("\nMonitoring stopped by user")
break
except Exception as e:
print(f"Error during monitoring: {e}")
time.sleep(5) # Wait before retrying
def close(self):
"""Close the connection"""
self.client.close()
# Usage example
if __name__ == "__main__":
inverter = InverterReader("192.168.1.100")
if inverter.connect():
print("Connected to inverter successfully!")
# Read current data
current_data = inverter.read_all_data()
print(json.dumps(current_data, indent=2))
# Start monitoring (uncomment to run)
# inverter.monitor(interval=60)
else:
print("Failed to connect to inverter")
inverter.close()
Lessons Learned
1. Register Discovery is Detective Work
Without documentation, finding the right registers requires patience and systematic testing. I found it helpful to:
- Compare register values with the inverter's display
- Test during different conditions (day/night, load changes)
- Look for patterns in consecutive registers
- Check for common Modbus conventions used by other manufacturers
2. Data Scaling Matters
Raw register values often need scaling. A register showing 2350 might actually
represent 235.0V or 23.50A. The scaling factor is usually a power of 10
(0.1, 0.01, etc.).
3. Error Handling is Critical
Industrial networks can be noisy. Always implement:
- Connection retry logic
- Register read timeouts
- Data validation (sanity checks)
- Graceful degradation when some registers fail
4. Documentation Everything
Keep detailed notes of your discoveries. Create a register map with:
- Register addresses
- Data types and scaling
- Valid ranges
- Update frequencies
- Any quirks or special behavior
Beyond the Basics
Once you have basic communication working, you can extend the system:
- Database logging: Store historical data in InfluxDB or SQLite
- Web dashboard: Create a real-time monitoring interface
- Alerts: Notify when values exceed thresholds
- Integration: Connect with home automation systems
- Analysis: Generate reports on energy production and efficiency
The key is starting simple and building up complexity as needed.
Conclusion
Working with undocumented Modbus devices taught me that persistence pays off. What started as a favor for a friend became a deep dive into industrial protocols and reverse engineering. The combination of CLI tools for discovery and Python for automation proved powerful for taming an uncooperative German inverter.
The beauty of Modbus is its simplicity. Once you understand the basics, you can communicate with thousands of industrial devices. The challenge is often not the protocol itself, but discovering what each device actually exposes through its registers.