Skip to content

Commit 461e14f

Browse files
committed
Fix SPH TL3 register mappings and add debug logging for Modbus server
- Added missing handlers for three-phase AC power registers (ac_power_r_high/low, etc.) - Added async_getValues override for pymodbus 3.x compatibility - Implemented register storage update approach - Added comprehensive debug logging - Identified pymodbus 3.x addressing quirk (off-by-one issue being investigated) Currently investigating off-by-one offset in register addressing that causes values to be shifted. The emulator correctly calculates values but they appear at wrong register addresses when read via Modbus TCP.
1 parent 69d408a commit 461e14f

5 files changed

Lines changed: 294 additions & 8 deletions

File tree

emulator/modbus_server.py

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,44 @@ def __init__(self, simulator, register_type: str, address_range: tuple):
2727
"""
2828
self.simulator = simulator
2929
self.register_type = register_type
30+
self.address_range = address_range
3031

3132
# Initialize with zeros
3233
initial_values = [0] * address_range[1]
33-
super().__init__(address_range[0], initial_values)
34+
super().__init__(0, initial_values)
35+
logger.info(f"DynamicModbusDataBlock initialized: type={register_type}, address=0, count={address_range[1]}")
36+
37+
def update_from_simulator(self):
38+
"""Update internal storage from simulator values."""
39+
# Get registers from model
40+
if self.register_type == 'input':
41+
registers = self.simulator.model.get_input_registers()
42+
else:
43+
registers = self.simulator.model.get_holding_registers()
3444

35-
def getValues(self, address, count=1):
36-
"""Get register values from simulator.
45+
# Update each mapped register
46+
for addr, reg_def in registers.items():
47+
value = self.simulator.get_register_value(self.register_type, addr)
48+
if value is not None:
49+
# WORKAROUND: pymodbus 3.x has an off-by-one quirk where reading address N
50+
# returns the value stored at address N+1. To compensate, store value for
51+
# register N at address N-1.
52+
store_addr = addr - 1
53+
if store_addr < 0:
54+
continue # Skip register 0
55+
56+
# Debug logging for key registers
57+
if addr in [38, 39, 1011, 1012, 1013, 1014]:
58+
logger.info(f" Setting register {addr} to {value} (storing at {store_addr})")
59+
# Use parent's setValues to update internal storage
60+
result = super().setValues(store_addr, [value])
61+
if addr in [38, 39, 1011, 1012, 1013, 1014]:
62+
# Verify it was set correctly
63+
verify = super().getValues(store_addr, 1)
64+
logger.info(f" Verify: getValues({store_addr}) returns {verify}")
65+
66+
def _get_values_from_simulator(self, address, count):
67+
"""Helper to get values from simulator.
3768
3869
Args:
3970
address: Starting register address
@@ -51,6 +82,44 @@ def getValues(self, address, count=1):
5182
values.append(value)
5283
return values
5384

85+
def getValues(self, address, count=1):
86+
"""Get register values from simulator (sync).
87+
88+
Args:
89+
address: Starting register address
90+
count: Number of registers to read
91+
92+
Returns:
93+
List of register values
94+
"""
95+
return self._get_values_from_simulator(address, count)
96+
97+
async def async_getValues(self, address, count=1):
98+
"""Get register values from simulator (async).
99+
100+
Args:
101+
address: Starting register address
102+
count: Number of registers to read
103+
104+
Returns:
105+
List of register values
106+
"""
107+
return self._get_values_from_simulator(address, count)
108+
109+
def validate(self, address, count=1):
110+
"""Validate the request and return the address range.
111+
112+
Args:
113+
address: Starting address
114+
count: Number of registers
115+
116+
Returns:
117+
Validation result
118+
"""
119+
# Let the parent handle validation
120+
result = super().validate(address, count)
121+
return result
122+
54123
def setValues(self, address, values):
55124
"""Set register values (for holding registers).
56125
@@ -101,8 +170,8 @@ def __init__(self, simulator, port: int = 502, slave_id: int = 1):
101170
holding_range = (0, 100)
102171

103172
# Create dynamic data blocks
104-
input_block = DynamicModbusDataBlock(simulator, 'input', input_range)
105-
holding_block = DynamicModbusDataBlock(simulator, 'holding', holding_range)
173+
self.input_block = DynamicModbusDataBlock(simulator, 'input', input_range)
174+
self.holding_block = DynamicModbusDataBlock(simulator, 'holding', holding_range)
106175

107176
# Discrete and coil blocks (not used, but required)
108177
discrete_block = ModbusSequentialDataBlock(0, [0] * 100)
@@ -112,10 +181,14 @@ def __init__(self, simulator, port: int = 502, slave_id: int = 1):
112181
self.device_context = ModbusDeviceContext(
113182
di=discrete_block, # Discrete Inputs
114183
co=coil_block, # Coils
115-
hr=holding_block, # Holding Registers
116-
ir=input_block # Input Registers
184+
hr=self.holding_block, # Holding Registers
185+
ir=self.input_block # Input Registers
117186
)
118187

188+
# Initial update
189+
self.input_block.update_from_simulator()
190+
self.holding_block.update_from_simulator()
191+
119192
# Create server context
120193
self.server_context = ModbusServerContext(
121194
devices={slave_id: self.device_context},
@@ -141,6 +214,9 @@ def update_callback():
141214
"""Callback to update simulator values."""
142215
if self.running:
143216
self.simulator.update()
217+
# Update Modbus register storage
218+
self.input_block.update_from_simulator()
219+
self.holding_block.update_from_simulator()
144220
threading.Timer(2.0, update_callback).start()
145221

146222
# Start update timer

emulator/simulator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,17 @@ def _map_register_to_value(self, reg_name: str, reg_def: Dict[str, Any]) -> int:
564564
phase = reg_name.split('_')[-1]
565565
power = self.values['ac_power'] / 3 # Distribute across phases
566566
return int(power / scale)
567+
# Three-phase AC power (32-bit pairs)
568+
elif reg_name in ['ac_power_r_high', 'ac_power_s_high', 'ac_power_t_high']:
569+
combined_scale = reg_def.get('combined_scale', 0.1)
570+
power = self.values['ac_power'] / 3 # Distribute across phases
571+
power_raw = int(power / combined_scale)
572+
return (power_raw >> 16) & 0xFFFF
573+
elif reg_name in ['ac_power_r_low', 'ac_power_s_low', 'ac_power_t_low']:
574+
combined_scale = reg_def.get('combined_scale', 0.1)
575+
power = self.values['ac_power'] / 3 # Distribute across phases
576+
power_raw = int(power / combined_scale)
577+
return power_raw & 0xFFFF
567578
elif reg_name in ['ac_voltage_rs', 'ac_voltage_st', 'ac_voltage_tr']:
568579
phases = reg_name.split('_')[-1]
569580
return int(self.values['voltages'][f'ac_{phases}'] / scale)

growatt_emulator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
# Configure logging
3535
logging.basicConfig(
36-
level=logging.WARNING,
36+
level=logging.INFO,
3737
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
3838
)
3939
logger = logging.getLogger(__name__)

test_modbus_server.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env python3
2+
"""Test Modbus server to verify it's serving the correct values"""
3+
4+
import sys
5+
import time
6+
import logging
7+
sys.path.insert(0, '.')
8+
9+
# Enable logging
10+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
11+
12+
from pymodbus.client import ModbusTcpClient
13+
from emulator.models import InverterModel
14+
from emulator.simulator import InverterSimulator
15+
from emulator.modbus_server import ModbusEmulatorServer
16+
17+
# Create SPH TL3 emulator
18+
model = InverterModel('sph_tl3_3000_10000')
19+
sim = InverterSimulator(model, 5020)
20+
sim.solar_irradiance = 800
21+
sim.house_load = 2000
22+
sim.battery_override = 5000 # Force 5000W charging
23+
sim.update()
24+
25+
# Start Modbus server
26+
server = ModbusEmulatorServer(sim, port=5020, slave_id=1)
27+
server.start()
28+
time.sleep(2) # Give server time to start
29+
30+
print("=" * 80)
31+
print("MODBUS SERVER TEST - SPH TL3")
32+
print("=" * 80)
33+
print()
34+
35+
# Connect as a Modbus client
36+
client = ModbusTcpClient('localhost', port=5020)
37+
if not client.connect():
38+
print("Failed to connect to Modbus server")
39+
server.stop()
40+
sys.exit(1)
41+
42+
print("Connected to Modbus server on port 5020")
43+
print()
44+
45+
try:
46+
# Test battery charge power registers (1011-1012)
47+
print("Reading battery charge power (input registers 1011-1012):")
48+
result = client.read_input_registers(1011, count=2, device_id=1)
49+
if not result.isError():
50+
print(f" Register 1011 (charge_power_high): {result.registers[0]} (0x{result.registers[0]:04X})")
51+
print(f" Register 1012 (charge_power_low): {result.registers[1]} (0x{result.registers[1]:04X})")
52+
combined = (result.registers[0] << 16) | result.registers[1]
53+
power_w = combined * 0.1
54+
print(f" Combined: {combined} (0x{combined:08X}) -> {power_w:.1f}W")
55+
else:
56+
print(f" Error: {result}")
57+
print()
58+
59+
# Test battery voltage and SOC (1013-1014)
60+
print("Reading battery voltage/SOC (input registers 1013-1014):")
61+
result = client.read_input_registers(1013, count=2, device_id=1)
62+
if not result.isError():
63+
print(f" Register 1013 (battery_voltage): {result.registers[0]} (0x{result.registers[0]:04X}) -> {result.registers[0] * 0.1:.1f}V")
64+
print(f" Register 1014 (battery_soc): {result.registers[1]} -> {result.registers[1]}%")
65+
else:
66+
print(f" Error: {result}")
67+
print()
68+
69+
# Test AC phase R registers (38-41)
70+
print("Reading AC Phase R (input registers 38-41):")
71+
result = client.read_input_registers(38, count=4, device_id=1)
72+
if not result.isError():
73+
print(f" Register 38 (ac_voltage_r): {result.registers[0]} (0x{result.registers[0]:04X}) -> {result.registers[0] * 0.1:.1f}V")
74+
print(f" Register 39 (ac_current_r): {result.registers[1]} (0x{result.registers[1]:04X}) -> {result.registers[1] * 0.1:.1f}A")
75+
print(f" Register 40 (ac_power_r_high): {result.registers[2]} (0x{result.registers[2]:04X})")
76+
print(f" Register 41 (ac_power_r_low): {result.registers[3]} (0x{result.registers[3]:04X})")
77+
combined = (result.registers[2] << 16) | result.registers[3]
78+
power_w = combined * 0.1
79+
print(f" Combined power: {combined} (0x{combined:08X}) -> {power_w:.1f}W")
80+
else:
81+
print(f" Error: {result}")
82+
print()
83+
84+
# Test with offset addressing (like HA might be doing)
85+
print("Testing if HA is using different addressing:")
86+
print(" Trying to read register 1010-1011 instead of 1011-1012:")
87+
result = client.read_input_registers(1010, count=2, device_id=1)
88+
if not result.isError():
89+
print(f" Register 1010: {result.registers[0]} (0x{result.registers[0]:04X})")
90+
print(f" Register 1011: {result.registers[1]} (0x{result.registers[1]:04X})")
91+
combined = (result.registers[0] << 16) | result.registers[1]
92+
print(f" Combined: {combined} (0x{combined:08X})")
93+
print()
94+
95+
print(" Trying to read register 1012-1013 instead of 1011-1012:")
96+
result = client.read_input_registers(1012, count=2, device_id=1)
97+
if not result.isError():
98+
print(f" Register 1012: {result.registers[0]} (0x{result.registers[0]:04X})")
99+
print(f" Register 1013: {result.registers[1]} (0x{result.registers[1]:04X})")
100+
combined = (result.registers[0] << 16) | result.registers[1]
101+
print(f" Combined: {combined} (0x{combined:08X})")
102+
print()
103+
104+
except Exception as e:
105+
print(f"Error: {e}")
106+
import traceback
107+
traceback.print_exc()
108+
109+
finally:
110+
client.close()
111+
server.stop()
112+
print("Test complete")

test_registers.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env python3
2+
"""Test script to verify register values"""
3+
4+
import sys
5+
sys.path.insert(0, '.')
6+
7+
from emulator.models import InverterModel
8+
from emulator.simulator import InverterSimulator
9+
10+
# Create SPH TL3 simulator
11+
model = InverterModel('sph_tl3_3000_10000')
12+
sim = InverterSimulator(model, 502)
13+
14+
# Set up some test conditions
15+
sim.solar_irradiance = 800
16+
sim.house_load = 2000
17+
sim.battery_override = 5000 # Force 5000W charging
18+
19+
# Update to calculate values
20+
sim.update()
21+
22+
print("=" * 80)
23+
print("REGISTER VALUE TEST - SPH TL3")
24+
print("=" * 80)
25+
print()
26+
27+
# Test battery charge power
28+
print("Battery Charge Power Test:")
29+
print(f" Internal battery_power value: {sim.values['battery_power']:.1f}W")
30+
print()
31+
32+
# Read registers
33+
reg_1011 = sim.get_register_value('input', 1011)
34+
reg_1012 = sim.get_register_value('input', 1012)
35+
reg_1013 = sim.get_register_value('input', 1013)
36+
reg_1014 = sim.get_register_value('input', 1014)
37+
38+
print(f" Register 1011 (charge_power_high): {reg_1011} (0x{reg_1011:04X})")
39+
print(f" Register 1012 (charge_power_low): {reg_1012} (0x{reg_1012:04X})")
40+
print(f" Register 1013 (battery_voltage): {reg_1013} (0x{reg_1013:04X}) -> {reg_1013 * 0.1:.1f}V")
41+
print(f" Register 1014 (battery_soc): {reg_1014} -> {reg_1014}%")
42+
print()
43+
44+
# Reconstruct 32-bit value
45+
if reg_1011 is not None and reg_1012 is not None:
46+
combined = (reg_1011 << 16) | reg_1012
47+
power_w = combined * 0.1
48+
print(f" Combined 32-bit value: {combined} (0x{combined:08X})")
49+
print(f" Scaled power: {power_w:.1f}W")
50+
print()
51+
52+
# Test AC phase registers
53+
print("AC Phase R Test:")
54+
print(f" Internal voltage: {sim.values['voltages']['ac_r']:.1f}V")
55+
print(f" Internal current: {sim.values['currents']['ac_r']:.1f}A")
56+
print(f" Internal power: {sim.values['ac_power'] / 3:.1f}W")
57+
print()
58+
59+
reg_38 = sim.get_register_value('input', 38)
60+
reg_39 = sim.get_register_value('input', 39)
61+
reg_40 = sim.get_register_value('input', 40)
62+
reg_41 = sim.get_register_value('input', 41)
63+
64+
print(f" Register 38 (ac_voltage_r): {reg_38} (0x{reg_38:04X}) -> {reg_38 * 0.1:.1f}V")
65+
print(f" Register 39 (ac_current_r): {reg_39} (0x{reg_39:04X}) -> {reg_39 * 0.1:.1f}A")
66+
print(f" Register 40 (ac_power_r_high): {reg_40} (0x{reg_40:04X})")
67+
print(f" Register 41 (ac_power_r_low): {reg_41} (0x{reg_41:04X})")
68+
print()
69+
70+
if reg_40 is not None and reg_41 is not None:
71+
combined = (reg_40 << 16) | reg_41
72+
power_w = combined * 0.1
73+
print(f" Combined 32-bit value: {combined} (0x{combined:08X})")
74+
print(f" Scaled power: {power_w:.1f}W")
75+
print()
76+
77+
# Check register definitions
78+
print("Register Definitions from Model:")
79+
input_regs = model.get_input_registers()
80+
print(f" Register 1011: {input_regs.get(1011)}")
81+
print(f" Register 1012: {input_regs.get(1012)}")
82+
print(f" Register 1013: {input_regs.get(1013)}")
83+
print()
84+
print(f" Register 38: {input_regs.get(38)}")
85+
print(f" Register 39: {input_regs.get(39)}")
86+
print(f" Register 40: {input_regs.get(40)}")
87+
print(f" Register 41: {input_regs.get(41)}")

0 commit comments

Comments
 (0)