From e2d9b29c39ae5a5f4984437e9c419e3b2965e53f Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 16 Nov 2025 23:11:56 +0000 Subject: [PATCH 01/15] Try reading with functioncode 21 --- sofar-me-3000.json | 42 ++++++++++++++++++++++++++++++++- sofar2mqtt-v2.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index b187e12..5450651 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -1,5 +1,45 @@ { "registers": [ + { + "name": "serial_number", + "register": "0x2001", + "read_type": "fc21_string", + "registers": 7, + "refresh": 86400, + "ha": { + "name": "Serial Number", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.serial_number }}" + }, + "refresh": 86400 + }, + { + "name": "hw_version", + "register": "0x2009", + "read_type": "fc21_string", + "registers": 2, + "ha": { + "name": "HW Version", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.hw_version }}" + }, + "refresh": 86400 + }, + { + "name": "sw_version_com", + "register": "0x02007", + "read_type": "fc21_string", + "registers": 2, + "ha": { + "name": "Software Version", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.sw_version_com }}" + }, + "refresh": 86400 + }, { "name": "running_state", "register": "0x200", @@ -110,4 +150,4 @@ "factor": 100 } ] -} \ No newline at end of file +} diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 203330a..2ae6f25 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -174,6 +174,62 @@ def setup_instrument(self): self.instrument.serial.timeout = 0.2 # seconds self.instrument.close_port_after_each_call = True + def build_fc21_request(self, registeraddress, number_of_registers): + frame = bytearray() + frame.append(instrument.address) + frame.append(0x21) + frame.append((registeraddress >> 8) & 0xFF) + frame.append(registeraddress & 0xFF) + frame.append((number_of_registers >> 8) & 0xFF) + frame.append(number_of_registers & 0xFF) + + # CRC16 (Modbus polynomial 0xA001) + crc = 0xFFFF + for pos in frame: + crc ^= pos + for _ in range(8): + if crc & 0x0001: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + + frame.append(crc & 0xFF) # CRC Lo + frame.append((crc >> 8) & 0xFF) # CRC Hi + return frame + + + def read_fc21(self, registeraddress, number_of_registers=1): + """ + Custom Modbus read using Function Code 0x21 (Ext Code). + Reads 'number_of_registers' starting at 'registeraddress'. + """ + # Build request frame + request = self.build_fc21_request(registeraddress, number_of_registers) + + # Send raw request + self.instrument.serial.write(request) + + # Expected response length: slave + func + bytecount + (2*registers) + CRC + expected_bytes = 1 + 1 + 1 + (2 * number_of_registers) + 2 + response = self.instrument.serial.read(expected_bytes) + + # Parse response + if len(response) < expected_bytes: + raise IOError("Incomplete FC21 response") + + slave, func, bytecount = response[0], response[1], response[2] + if func != 0x21: + raise IOError(f"Unexpected function code {func}") + + data = response[3:3+bytecount] + # Convert to list of register values (16-bit ints) + registers = [] + for i in range(0, len(data), 2): + registers.append((data[i] << 8) + data[i+1]) + + return registers + def read_and_publish(self): for register in self.config['registers']: refresh = 1 @@ -455,6 +511,8 @@ def read_value(self, registeraddress, read_type, signed, registers=1): elif read_type == "string": value = self.instrument.read_string( registeraddress, functioncode=3, number_of_registers=registers) + elif read_type == "fc21_string": + value = self.read_fc21(registeraddress, number_of_registers=registers) except minimalmodbus.NoResponseError: logging.debug(traceback.format_exc()) retry = retry - 1 From 248570fdbcf2a67269738e36163c002d3f85be3f Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 16 Nov 2025 23:22:58 +0000 Subject: [PATCH 02/15] Add logging --- sofar2mqtt-v2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 2ae6f25..4e964a2 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -206,6 +206,7 @@ def read_fc21(self, registeraddress, number_of_registers=1): """ # Build request frame request = self.build_fc21_request(registeraddress, number_of_registers) + logging.info(f"Sending FC21 request: {request.hex().upper()}") # Send raw request self.instrument.serial.write(request) @@ -213,6 +214,7 @@ def read_fc21(self, registeraddress, number_of_registers=1): # Expected response length: slave + func + bytecount + (2*registers) + CRC expected_bytes = 1 + 1 + 1 + (2 * number_of_registers) + 2 response = self.instrument.serial.read(expected_bytes) + logging(f"Received FC21 response: {response.hex().upper()}") # Parse response if len(response) < expected_bytes: From 115aabbddc80182cf36297bfd33df316a2838078 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 16 Nov 2025 23:27:17 +0000 Subject: [PATCH 03/15] Remove tabs --- sofar-me-3000.json | 301 ++++++++++++++++++++++----------------------- 1 file changed, 150 insertions(+), 151 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index 5450651..f6a920a 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -1,153 +1,152 @@ { - "registers": [ - { - "name": "serial_number", - "register": "0x2001", - "read_type": "fc21_string", - "registers": 7, - "refresh": 86400, - "ha": { - "name": "Serial Number", - "icon": "mdi:numeric", - "entity_category": "diagnostic", - "value_template": "{{ value_json.serial_number }}" - }, - "refresh": 86400 - }, - { - "name": "hw_version", - "register": "0x2009", - "read_type": "fc21_string", - "registers": 2, - "ha": { - "name": "HW Version", - "icon": "mdi:numeric", - "entity_category": "diagnostic", - "value_template": "{{ value_json.hw_version }}" - }, - "refresh": 86400 - }, - { - "name": "sw_version_com", - "register": "0x02007", - "read_type": "fc21_string", - "registers": 2, - "ha": { - "name": "Software Version", - "icon": "mdi:numeric", - "entity_category": "diagnostic", - "value_template": "{{ value_json.sw_version_com }}" - }, - "refresh": 86400 - }, - { - "name": "running_state", - "register": "0x200", - "function": "mode", - "modes": { - "0": "Standby", - "2": "Charging", - "4": "Discharging", - "6": "Fault" - } - }, - { - "name": "grid_voltage", - "register": "0x0206", - "function": "divide", - "factor": 10 - }, - { - "name": "battery_power", - "register": "0x20d", - "function": "multiply", - "factor": 10 - }, - { - "name": "grid_freq", - "register": "0x20c", - "function": "divide", - "factor": 100 - }, - { - "name": "batterySOC", - "register": "0x210" - }, - { - "name": "battery_voltage", - "register": "0x20e", - "function": "divide", - "factor": 100 - }, - { - "name": "battery_cycles", - "register": "0x22c" - }, - { - "name": "battery_temp", - "register": "0x211" - }, - { - "name": "grid_power", - "register": "0x212", - "function": "multiply", - "factor": 10 - }, - { - "name": "consumption", - "register": "0x213", - "function": "multiply", - "factor": 10 - }, - { - "name": "solarPV", - "register": "0x215", - "function": "multiply", - "factor": 10 - }, - { - "name": "today_generation", - "register": "0x218", - "function": "divide", - "factor": 100 - }, - { - "name": "today_exported", - "register": "0x219", - "function": "divide", - "factor": 100 - }, - { - "name": "today_purchase", - "register": "0x21a", - "function": "divide", - "factor": 100 - }, - { - "name": "today_consumption", - "register": "0x21b", - "function": "divide", - "factor": 100 - }, - { - "name": "inverter_temp", - "register": "0x238" - }, - { - "name": "inverterHS_temp", - "register": "0x239" - }, - { - "name": "solarPVAmps", - "register": "0x236", - "function": "divide", - "factor": 100 - }, - { - "name": "battery_current", - "register": "0x207", - "function": "divide", - "factor": 100 - } - ] + "registers": [ + { + "name": "serial_number", + "register": "0x2001", + "read_type": "fc21_string", + "registers": 7, + "refresh": 86400, + "ha": { + "name": "Serial Number", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.serial_number }}" + } + }, + { + "name": "hw_version", + "register": "0x2009", + "read_type": "fc21_string", + "registers": 2, + "ha": { + "name": "HW Version", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.hw_version }}" + }, + "refresh": 86400 + }, + { + "name": "sw_version_com", + "register": "0x02007", + "read_type": "fc21_string", + "registers": 2, + "ha": { + "name": "Software Version", + "icon": "mdi:numeric", + "entity_category": "diagnostic", + "value_template": "{{ value_json.sw_version_com }}" + }, + "refresh": 86400 + }, + { + "name": "running_state", + "register": "0x200", + "function": "mode", + "modes": { + "0": "Standby", + "2": "Charging", + "4": "Discharging", + "6": "Fault" + } + }, + { + "name": "grid_voltage", + "register": "0x0206", + "function": "divide", + "factor": 10 + }, + { + "name": "battery_power", + "register": "0x20d", + "function": "multiply", + "factor": 10 + }, + { + "name": "grid_freq", + "register": "0x20c", + "function": "divide", + "factor": 100 + }, + { + "name": "batterySOC", + "register": "0x210" + }, + { + "name": "battery_voltage", + "register": "0x20e", + "function": "divide", + "factor": 100 + }, + { + "name": "battery_cycles", + "register": "0x22c" + }, + { + "name": "battery_temp", + "register": "0x211" + }, + { + "name": "grid_power", + "register": "0x212", + "function": "multiply", + "factor": 10 + }, + { + "name": "consumption", + "register": "0x213", + "function": "multiply", + "factor": 10 + }, + { + "name": "solarPV", + "register": "0x215", + "function": "multiply", + "factor": 10 + }, + { + "name": "today_generation", + "register": "0x218", + "function": "divide", + "factor": 100 + }, + { + "name": "today_exported", + "register": "0x219", + "function": "divide", + "factor": 100 + }, + { + "name": "today_purchase", + "register": "0x21a", + "function": "divide", + "factor": 100 + }, + { + "name": "today_consumption", + "register": "0x21b", + "function": "divide", + "factor": 100 + }, + { + "name": "inverter_temp", + "register": "0x238" + }, + { + "name": "inverterHS_temp", + "register": "0x239" + }, + { + "name": "solarPVAmps", + "register": "0x236", + "function": "divide", + "factor": 100 + }, + { + "name": "battery_current", + "register": "0x207", + "function": "divide", + "factor": 100 + } + ] } From 3a9e7e53495794efb0dc628e4249cd4b5997fea2 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 16 Nov 2025 23:56:22 +0000 Subject: [PATCH 04/15] Use extending-minimalmodbus guidance rather than self-inventing functions --- sofar2mqtt-v2.py | 71 ++++++++++++------------------------------------ 1 file changed, 17 insertions(+), 54 deletions(-) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 4e964a2..5ec68b7 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -174,63 +174,26 @@ def setup_instrument(self): self.instrument.serial.timeout = 0.2 # seconds self.instrument.close_port_after_each_call = True - def build_fc21_request(self, registeraddress, number_of_registers): - frame = bytearray() - frame.append(instrument.address) - frame.append(0x21) - frame.append((registeraddress >> 8) & 0xFF) - frame.append(registeraddress & 0xFF) - frame.append((number_of_registers >> 8) & 0xFF) - frame.append(number_of_registers & 0xFF) - - # CRC16 (Modbus polynomial 0xA001) - crc = 0xFFFF - for pos in frame: - crc ^= pos - for _ in range(8): - if crc & 0x0001: - crc >>= 1 - crc ^= 0xA001 - else: - crc >>= 1 - - frame.append(crc & 0xFF) # CRC Lo - frame.append((crc >> 8) & 0xFF) # CRC Hi - return frame - - def read_fc21(self, registeraddress, number_of_registers=1): """ - Custom Modbus read using Function Code 0x21 (Ext Code). - Reads 'number_of_registers' starting at 'registeraddress'. + Read extended registers using Function Code 0x21 (Ext Code). + Returns a list of register values. """ - # Build request frame - request = self.build_fc21_request(registeraddress, number_of_registers) - logging.info(f"Sending FC21 request: {request.hex().upper()}") - - # Send raw request - self.instrument.serial.write(request) - - # Expected response length: slave + func + bytecount + (2*registers) + CRC - expected_bytes = 1 + 1 + 1 + (2 * number_of_registers) + 2 - response = self.instrument.serial.read(expected_bytes) - logging(f"Received FC21 response: {response.hex().upper()}") - - # Parse response - if len(response) < expected_bytes: - raise IOError("Incomplete FC21 response") - - slave, func, bytecount = response[0], response[1], response[2] - if func != 0x21: - raise IOError(f"Unexpected function code {func}") - - data = response[3:3+bytecount] - # Convert to list of register values (16-bit ints) - registers = [] - for i in range(0, len(data), 2): - registers.append((data[i] << 8) + data[i+1]) - - return registers + # Call MinimalModbus internal helper + response = self.instrument._generic_command( + functioncode=0x21, + registeraddress=registeraddress, + number_of_registers=number_of_registers, + payload=None, + signed=False + ) + + logging.info(f"Received FC21 response: {response.hex().upper()}") + data = bytearray() + for val in response: + data.append((val >> 8) & 0xFF) # high byte + data.append(val & 0xFF) # low byte + return minimalmodbus._bytes_to_textstring(data) def read_and_publish(self): for register in self.config['registers']: From 580bfaeb66feee94c62994084ecfadd3fbbbc29e Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Mon, 17 Nov 2025 19:35:58 +0000 Subject: [PATCH 05/15] Introduce static for ME3000SP --- sofar-me-3000.json | 219 +++++++++++++++++++++++++++++++++++++++------ sofar2mqtt-v2.py | 40 +++------ 2 files changed, 202 insertions(+), 57 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index f6a920a..a765e56 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -2,9 +2,8 @@ "registers": [ { "name": "serial_number", - "register": "0x2001", - "read_type": "fc21_string", - "registers": 7, + "read_type": "static", + "value": "ME3000SP", "refresh": 86400, "ha": { "name": "Serial Number", @@ -15,9 +14,8 @@ }, { "name": "hw_version", - "register": "0x2009", - "read_type": "fc21_string", - "registers": 2, + "value": "TBD", + "read_type": "static", "ha": { "name": "HW Version", "icon": "mdi:numeric", @@ -28,9 +26,8 @@ }, { "name": "sw_version_com", - "register": "0x02007", - "read_type": "fc21_string", - "registers": 2, + "read_type": "static", + "value": "TBD", "ha": { "name": "Software Version", "icon": "mdi:numeric", @@ -48,105 +45,273 @@ "2": "Charging", "4": "Discharging", "6": "Fault" + }, + "ha": { + "name": "Running State", + "icon": "mdi:power-standby", + "entity_category": "diagnostic", + "value_template": "{{ value_json.running_state }}" } }, { "name": "grid_voltage", "register": "0x0206", "function": "divide", - "factor": 10 + "factor": 10, + "ha": { + "device_class": "voltage", + "unit_of_measurement": "V", + "name": "Grid Voltage", + "icon": "mdi:alpha-v-box", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.grid_voltage }}" + } }, { "name": "battery_power", "register": "0x20d", "function": "multiply", - "factor": 10 + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "Battery Power", + "icon": "mdi:battery-charging", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.battery_power }}" + } }, { "name": "grid_freq", "register": "0x20c", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "frequency", + "unit_of_measurement": "Hz", + "name": "Grid Frequency", + "icon": "mdi:sine-wave", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.grid_freq }}" + } }, { "name": "batterySOC", - "register": "0x210" + "register": "0x210", + "ha": { + "device_class": "battery", + "unit_of_measurement": "%", + "name": "Battery SOC", + "icon": "mdi:battery-80", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.batterySOC }}" + } }, { "name": "battery_voltage", "register": "0x20e", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "voltage", + "unit_of_measurement": "V", + "name": "Battery Voltage", + "icon": "mdi:alpha-v-box", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.battery_voltage }}" + } }, { "name": "battery_cycles", - "register": "0x22c" + "register": "0x22c", + "ha": { + "device_class": "battery", + "unit_of_measurement": "%", + "name": "Battery Cycles", + "icon": "mdi:battery-sync", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.battery_cycles }}" + } }, { "name": "battery_temp", - "register": "0x211" + "register": "0x211", + "ha": { + "device_class": "temperature", + "unit_of_measurement": "°C", + "name": "Battery Temperature", + "icon": "mdi:temperature-celsius", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.battery_temp }}" + } }, { "name": "grid_power", "register": "0x212", "function": "multiply", - "factor": 10 + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "Grid Power", + "icon": "mdi:lightning-bolt", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.grid_power }}" + } }, { "name": "consumption", "register": "0x213", "function": "multiply", - "factor": 10 + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "Load Power", + "icon": "mdi:home-lightning-bolt-outline", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.consumption }}" + } }, { "name": "solarPV", "register": "0x215", "function": "multiply", - "factor": 10 + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "Solar PV Power", + "icon": "mdi:solar-power", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.solarPV }}" + } }, { "name": "today_generation", "register": "0x218", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Solar Generation Today", + "icon": "mdi:solar-power-variant", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.today_generation }}" + } }, { "name": "today_exported", "register": "0x219", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Export Today", + "icon": "mdi:transmission-tower-export", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.today_exported }}" + } }, { "name": "today_purchase", "register": "0x21a", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Import Today", + "icon": "mdi:transmission-tower-import", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.today_purchase }}" + } }, { "name": "today_consumption", "register": "0x21b", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Consumption Today", + "icon": "mdi:home-lightning-bolt-outline", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.today_consumption }}" + } }, { "name": "inverter_temp", - "register": "0x238" + "register": "0x238", + "ha": { + "device_class": "temperature", + "unit_of_measurement": "°C", + "name": "Inverter Temperature", + "icon": "mdi:temperature-celsius", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.inverter_temp }}" + } }, { "name": "inverterHS_temp", - "register": "0x239" + "register": "0x239", + "ha": { + "device_class": "temperature", + "unit_of_measurement": "°C", + "name": "Heatsink Temperature", + "icon": "mdi:temperature-celsius", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.inverterHS_temp }}" + } }, { "name": "solarPVAmps", "register": "0x236", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "current", + "unit_of_measurement": "A", + "name": "Solar PV Current", + "icon": "mdi:alpha-a-box", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.solarPVAmps }}" + } }, { "name": "battery_current", "register": "0x207", "function": "divide", - "factor": 100 + "factor": 100, + "ha": { + "device_class": "current", + "unit_of_measurement": "A", + "name": "Battery Current", + "icon": "mdi:alpha-a-box", + "state_class": "measurement", + "entity_category": "diagnostic", + "value_template": "{{ value_json.battery_current }}" + } } ] } diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 5ec68b7..b24a7aa 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -171,35 +171,14 @@ def setup_instrument(self): self.instrument.serial.bytesize = 8 self.instrument.serial.parity = serial.PARITY_NONE self.instrument.serial.stopbits = 1 - self.instrument.serial.timeout = 0.2 # seconds + self.instrument.serial.timeout = 0.2 # seconds self.instrument.close_port_after_each_call = True - def read_fc21(self, registeraddress, number_of_registers=1): - """ - Read extended registers using Function Code 0x21 (Ext Code). - Returns a list of register values. - """ - # Call MinimalModbus internal helper - response = self.instrument._generic_command( - functioncode=0x21, - registeraddress=registeraddress, - number_of_registers=number_of_registers, - payload=None, - signed=False - ) - - logging.info(f"Received FC21 response: {response.hex().upper()}") - data = bytearray() - for val in response: - data.append((val >> 8) & 0xFF) # high byte - data.append(val & 0xFF) # low byte - return minimalmodbus._bytes_to_textstring(data) - def read_and_publish(self): for register in self.config['registers']: refresh = 1 if 'refresh' in register: - refresh = register['refresh'] + refresh = register['refresh'] if (self.iteration % refresh) != 0: logging.debug(f"Skipping {register['name']}") continue @@ -234,11 +213,14 @@ def read_and_publish(self): read_type = register['read_type'] if 'registers' in register: registers = register['registers'] - value = self.read_value( - int(register['register'], 16), - read_type, - signed, - registers + if read_type == 'static': + value = register['value'] + else: + value = self.read_value( + int(register['register'], 16), + read_type, + signed, + registers ) if value is None: continue @@ -476,8 +458,6 @@ def read_value(self, registeraddress, read_type, signed, registers=1): elif read_type == "string": value = self.instrument.read_string( registeraddress, functioncode=3, number_of_registers=registers) - elif read_type == "fc21_string": - value = self.read_fc21(registeraddress, number_of_registers=registers) except minimalmodbus.NoResponseError: logging.debug(traceback.format_exc()) retry = retry - 1 From 04c403b6a9a477c6352c8cd90dafe2243c68206b Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Mon, 17 Nov 2025 19:36:42 +0000 Subject: [PATCH 06/15] Fix formatting --- sofar2mqtt-v2.py | 87 ++++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index b24a7aa..76e30b3 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -16,6 +16,7 @@ import paho.mqtt.client as mqtt import requests + def load_config(config_file_path): """ Load configuration file """ config = {} @@ -24,6 +25,8 @@ def load_config(config_file_path): return config # pylint: disable=too-many-instance-attributes + + class Sofar(): """ Sofar """ @@ -59,22 +62,27 @@ def __init__(self, config_file_path, daemon, retry, retry_delay, write_retry, wr self.legacy_publish = legacy_publish self.data = {} self.log_level = logging.getLevelName(log_level) - logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.getLevelName(log_level)) + logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', + level=logging.getLevelName(log_level)) self.mutex = threading.Lock() - self.client = mqtt.Client(client_id=f"sofar2mqtt-{socket.gethostname()}", userdata=None, protocol=mqtt.MQTTv5, transport="tcp") + self.client = mqtt.Client( + client_id=f"sofar2mqtt-{socket.gethostname()}", userdata=None, protocol=mqtt.MQTTv5, transport="tcp") self.setup_mqtt(logging) self.setup_instrument() self.iteration = 0 - + def on_connect(self, client, userdata, flags, rc, properties=None): logging.info("MQTT "+mqtt.connack_string(rc)) if rc == 0: try: logging.info(f"Subscribing to homeassistant/status") - client.subscribe(f"homeassistant/status", qos=0, options=None, properties=None) + client.subscribe(f"homeassistant/status", qos=0, + options=None, properties=None) for register in self.write_registers: - logging.info(f"Subscribing to {self.write_topic}/{register['name']}") - client.subscribe(f"{self.write_topic}/{register['name']}", qos=0, options=None, properties=None) + logging.info( + f"Subscribing to {self.write_topic}/{register['name']}") + client.subscribe( + f"{self.write_topic}/{register['name']}", qos=0, options=None, properties=None) except Exception: logging.info(traceback.format_exc()) @@ -86,7 +94,7 @@ def on_message(self, client, userdata, message, properties=None): found = False valid = False topic = message.topic - payload = message.payload.decode("utf-8") + payload = message.payload.decode("utf-8") if topic == "homeassistant/status": logging.info(f"Received message for {topic}:{payload}") if payload == "online": @@ -102,53 +110,65 @@ def on_message(self, client, userdata, message, properties=None): for key in register['modes']: if register['modes'][key] == payload: new_mode = key - logging.info(f"Received a request for {register['name']} to set mode value to: {payload}({new_mode})") + logging.info( + f"Received a request for {register['name']} to set mode value to: {payload}({new_mode})") if not new_mode: - logging.error(f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") + logging.error( + f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") if register['name'] in self.data: retry = self.write_retry while retry > 0: if self.data[register['name']] == payload: - logging.info(f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {payload}. Ignoring") + logging.info( + f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {payload}. Ignoring") retry = 0 else: - logging.info(f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to: {payload}. Retries remaining: {retry}") + logging.info( + f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to: {payload}. Retries remaining: {retry}") self.write_value(register, int(new_mode)) time.sleep(self.write_retry_delay) retry = retry - 1 - else: - logging.error(f"No current read value for {register['name']} skipping write operation. Please try again.") + else: + logging.error( + f"No current read value for {register['name']} skipping write operation. Please try again.") elif register['function'] == 'int': value = int(payload) - logging.info(f"Received a request for {register['name']} to set value to: {payload}({value})") + logging.info( + f"Received a request for {register['name']} to set value to: {payload}({value})") if value < register['min']: - logging.error(f"Received a request for {register['name']} but value: {value} is less than the min value: {register['min']}. Ignoring") + logging.error( + f"Received a request for {register['name']} but value: {value} is less than the min value: {register['min']}. Ignoring") elif value > register['max']: - logging.error(f"Received a request for {register['name']} but value: {value} is more than the max value: {register['max']}. Ignoring") + logging.error( + f"Received a request for {register['name']} but value: {value} is more than the max value: {register['max']}. Ignoring") else: if register['name'] == 'desired_power': if 'energy_storage_mode' in self.data: if 'Passive mode' != self.data['energy_storage_mode']: - logging.info(f"Received a request for {register['name']} but not not in Passive mode. Ignoring") + logging.info( + f"Received a request for {register['name']} but not not in Passive mode. Ignoring") continue if register['name'] in self.data: retry = self.write_retry while retry > 0: if self.data[register['name']] == value: - logging.info(f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {value}. Ignoring") + logging.info( + f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {value}. Ignoring") retry = 0 else: - logging.info(f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to {value}. Retries remaining: {retry}") + logging.info( + f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to {value}. Retries remaining: {retry}") self.write_value(register, value) time.sleep(self.write_retry_delay) retry = retry - 1 - else: - logging.error(f"No current read value for {register['name']} skipping write operation. Please try again.") + else: + logging.error( + f"No current read value for {register['name']} skipping write operation. Please try again.") if not found: - logging.error(f"Received a request to set an unknown register: {register_name['name']} to {payload}") - + logging.error( + f"Received a request to set an unknown register: {register['name']} to {payload}") def setup_mqtt(self, logging): self.client.enable_logger(logger=logging) @@ -156,11 +176,14 @@ def setup_mqtt(self, logging): self.client.on_message = self.on_message if self.username is not None and self.password is not None: self.client.username_pw_set(self.username, self.password) - logging.info(f"MQTT connecting to broker {self.broker} port {self.port} with auth user {self.username}") + logging.info( + f"MQTT connecting to broker {self.broker} port {self.port} with auth user {self.username}") else: - logging.info(f"MQTT connecting to broker {self.broker} port {self.port} without auth") + logging.info( + f"MQTT connecting to broker {self.broker} port {self.port} without auth") self.client.reconnect_delay_set(min_delay=1, max_delay=300) - self.client.connect(self.broker, port=self.port, keepalive=60, bind_address="", bind_port=0, clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY, properties=None) + self.client.connect(self.broker, port=self.port, keepalive=60, bind_address="", + bind_port=0, clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY, properties=None) self.client.loop_start() def setup_instrument(self): @@ -200,12 +223,12 @@ def read_and_publish(self): value -= self.data[register_name] elif register['agg_function'] == 'avg': value = int((value + self.data[register_name]) / 2) - if 'invert' in register: - if register['invert']: - if value > 0: - value = -abs(value) - else: - value = abs(value) + if 'invert' in register: + if register['invert']: + if value > 0: + value = -abs(value) + else: + value = abs(value) else: read_type = 'register' registers = 1 From 6b98a4841b94b482279cda56b7f75a31cd661148 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Tue, 18 Nov 2025 21:36:12 +0000 Subject: [PATCH 07/15] Fix issues with json file --- sofar-me-3000.json | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index a765e56..0a0a3f9 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -4,13 +4,12 @@ "name": "serial_number", "read_type": "static", "value": "ME3000SP", - "refresh": 86400, "ha": { "name": "Serial Number", "icon": "mdi:numeric", - "entity_category": "diagnostic", "value_template": "{{ value_json.serial_number }}" - } + }, + "refresh": 86400 }, { "name": "hw_version", @@ -19,7 +18,6 @@ "ha": { "name": "HW Version", "icon": "mdi:numeric", - "entity_category": "diagnostic", "value_template": "{{ value_json.hw_version }}" }, "refresh": 86400 @@ -31,7 +29,6 @@ "ha": { "name": "Software Version", "icon": "mdi:numeric", - "entity_category": "diagnostic", "value_template": "{{ value_json.sw_version_com }}" }, "refresh": 86400 @@ -49,7 +46,6 @@ "ha": { "name": "Running State", "icon": "mdi:power-standby", - "entity_category": "diagnostic", "value_template": "{{ value_json.running_state }}" } }, @@ -64,22 +60,20 @@ "name": "Grid Voltage", "icon": "mdi:alpha-v-box", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.grid_voltage }}" } }, { "name": "battery_power", "register": "0x20d", - "function": "multiply", - "factor": 10, + "function": "divide", + "factor": 100, "ha": { "device_class": "power", "unit_of_measurement": "W", "name": "Battery Power", "icon": "mdi:battery-charging", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.battery_power }}" } }, @@ -94,7 +88,6 @@ "name": "Grid Frequency", "icon": "mdi:sine-wave", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.grid_freq }}" } }, @@ -107,7 +100,6 @@ "name": "Battery SOC", "icon": "mdi:battery-80", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.batterySOC }}" } }, @@ -122,7 +114,6 @@ "name": "Battery Voltage", "icon": "mdi:alpha-v-box", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.battery_voltage }}" } }, @@ -131,11 +122,10 @@ "register": "0x22c", "ha": { "device_class": "battery", - "unit_of_measurement": "%", + "unit_of_measurement": "cycles", "name": "Battery Cycles", "icon": "mdi:battery-sync", - "state_class": "measurement", - "entity_category": "diagnostic", + "state_class": "total_increasing", "value_template": "{{ value_json.battery_cycles }}" } }, @@ -148,7 +138,6 @@ "name": "Battery Temperature", "icon": "mdi:temperature-celsius", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.battery_temp }}" } }, @@ -163,7 +152,6 @@ "name": "Grid Power", "icon": "mdi:lightning-bolt", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.grid_power }}" } }, @@ -178,7 +166,6 @@ "name": "Load Power", "icon": "mdi:home-lightning-bolt-outline", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.consumption }}" } }, @@ -193,7 +180,6 @@ "name": "Solar PV Power", "icon": "mdi:solar-power", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.solarPV }}" } }, @@ -208,7 +194,6 @@ "name": "Solar Generation Today", "icon": "mdi:solar-power-variant", "state_class": "total_increasing", - "entity_category": "diagnostic", "value_template": "{{ value_json.today_generation }}" } }, @@ -223,7 +208,6 @@ "name": "Export Today", "icon": "mdi:transmission-tower-export", "state_class": "total_increasing", - "entity_category": "diagnostic", "value_template": "{{ value_json.today_exported }}" } }, @@ -238,7 +222,6 @@ "name": "Import Today", "icon": "mdi:transmission-tower-import", "state_class": "total_increasing", - "entity_category": "diagnostic", "value_template": "{{ value_json.today_purchase }}" } }, @@ -253,7 +236,6 @@ "name": "Consumption Today", "icon": "mdi:home-lightning-bolt-outline", "state_class": "total_increasing", - "entity_category": "diagnostic", "value_template": "{{ value_json.today_consumption }}" } }, @@ -266,7 +248,6 @@ "name": "Inverter Temperature", "icon": "mdi:temperature-celsius", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.inverter_temp }}" } }, @@ -279,7 +260,6 @@ "name": "Heatsink Temperature", "icon": "mdi:temperature-celsius", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.inverterHS_temp }}" } }, @@ -294,7 +274,6 @@ "name": "Solar PV Current", "icon": "mdi:alpha-a-box", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.solarPVAmps }}" } }, @@ -309,9 +288,8 @@ "name": "Battery Current", "icon": "mdi:alpha-a-box", "state_class": "measurement", - "entity_category": "diagnostic", "value_template": "{{ value_json.battery_current }}" } } ] -} +} \ No newline at end of file From 83d461ce4f7df4b246ef9f1b9b8528b3ec3af54f Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Tue, 18 Nov 2025 21:45:10 +0000 Subject: [PATCH 08/15] Model should not be hardcoded --- sofar-hyd-ep.json | 13 ++++++++++++- sofar-me-3000.json | 13 ++++++++++++- sofar2mqtt-v2.py | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/sofar-hyd-ep.json b/sofar-hyd-ep.json index 1ebb136..3fcee91 100644 --- a/sofar-hyd-ep.json +++ b/sofar-hyd-ep.json @@ -7,12 +7,23 @@ "registers": 7, "ha": { "name": "Serial Number", - "icon": "mdi:numeric", + "icon": "mdi:identifier", "entity_category": "diagnostic", "value_template": "{{ value_json.serial_number }}" }, "refresh": 86400 }, + { + "name": "model", + "value": "HYD-6000-EP", + "read_type": "static", + "ha": { + "name": "Model", + "icon": "mdi:tag-text", + "value_template": "{{ value_json.model }}" + }, + "refresh": 86400 + }, { "name": "hw_version", "register": "0x044D", diff --git a/sofar-me-3000.json b/sofar-me-3000.json index 0a0a3f9..09315b3 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -6,11 +6,22 @@ "value": "ME3000SP", "ha": { "name": "Serial Number", - "icon": "mdi:numeric", + "icon": "mdi:identifier", "value_template": "{{ value_json.serial_number }}" }, "refresh": 86400 }, + { + "name": "model", + "value": "ME-3000SP", + "read_type": "static", + "ha": { + "name": "Model", + "icon": "mdi:tag-text", + "value_template": "{{ value_json.model }}" + }, + "refresh": 86400 + }, { "name": "hw_version", "value": "TBD", diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 76e30b3..a8caaeb 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -361,7 +361,7 @@ def publish_mqtt_discovery(self): "sw_version": self.data["sw_version_com"], "hw_version": self.data["hw_version"], "manufacturer": "SOFAR", - "model": "HYD-6000-EP", + "model": self.data["model"], "configuration_url": "https://github.com/rjpearce/sofar2mqtt-python", "identifiers": [f"{sn}"] }, From bab9f74bcf57789b938c0ae0fa25ead63367a68b Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Tue, 18 Nov 2025 21:56:04 +0000 Subject: [PATCH 09/15] Add all potential values --- sofar-me-3000.json | 360 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 351 insertions(+), 9 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index 09315b3..fec4ffb 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -60,6 +60,51 @@ "value_template": "{{ value_json.running_state }}" } }, + { + "name": "fault_list_1", + "register": "0x0201", + "ha": { + "name": "Fault List 1", + "icon": "mdi:alert", + "value_template": "{{ value_json.fault_list_1 }}" + } + }, + { + "name": "fault_list_2", + "register": "0x0202", + "ha": { + "name": "Fault List 2", + "icon": "mdi:alert", + "value_template": "{{ value_json.fault_list_2 }}" + } + }, + { + "name": "fault_list_3", + "register": "0x0203", + "ha": { + "name": "Fault List 3", + "icon": "mdi:alert", + "value_template": "{{ value_json.fault_list_3 }}" + } + }, + { + "name": "fault_list_4", + "register": "0x0204", + "ha": { + "name": "Fault List 4", + "icon": "mdi:alert", + "value_template": "{{ value_json.fault_list_4 }}" + } + }, + { + "name": "fault_list_5", + "register": "0x0205", + "ha": { + "name": "Fault List 5", + "icon": "mdi:alert", + "value_template": "{{ value_json.fault_list_5 }}" + } + }, { "name": "grid_voltage", "register": "0x0206", @@ -74,6 +119,20 @@ "value_template": "{{ value_json.grid_voltage }}" } }, + { + "name": "grid_current", + "register": "0x0207", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "current", + "unit_of_measurement": "A", + "name": "Grid Current", + "icon": "mdi:alpha-a-box", + "state_class": "measurement", + "value_template": "{{ value_json.grid_current }}" + } + }, { "name": "battery_power", "register": "0x20d", @@ -129,19 +188,21 @@ } }, { - "name": "battery_cycles", - "register": "0x22c", + "name": "battery_current", + "register": "0x020F", + "function": "divide", + "factor": 100, "ha": { - "device_class": "battery", - "unit_of_measurement": "cycles", - "name": "Battery Cycles", - "icon": "mdi:battery-sync", - "state_class": "total_increasing", - "value_template": "{{ value_json.battery_cycles }}" + "device_class": "current", + "unit_of_measurement": "A", + "name": "Battery Current", + "icon": "mdi:alpha-a-box", + "state_class": "measurement", + "value_template": "{{ value_json.battery_current }}" } }, { - "name": "battery_temp", + "name": "batterySOC", "register": "0x211", "ha": { "device_class": "temperature", @@ -180,6 +241,20 @@ "value_template": "{{ value_json.consumption }}" } }, + { + "name": "inverter_power", + "register": "0x0214", + "function": "multiply", + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "Inverter Power", + "icon": "mdi:lightning-bolt", + "state_class": "measurement", + "value_template": "{{ value_json.inverter_power }}" + } + }, { "name": "solarPV", "register": "0x215", @@ -194,6 +269,34 @@ "value_template": "{{ value_json.solarPV }}" } }, + { + "name": "eps_output_voltage", + "register": "0x0216", + "function": "divide", + "factor": 10, + "ha": { + "device_class": "voltage", + "unit_of_measurement": "V", + "name": "EPS Output Voltage", + "icon": "mdi:alpha-v-box", + "state_class": "measurement", + "value_template": "{{ value_json.eps_output_voltage }}" + } + }, + { + "name": "eps_output_power", + "register": "0x0217", + "function": "multiply", + "factor": 10, + "ha": { + "device_class": "power", + "unit_of_measurement": "W", + "name": "EPS Output Power", + "icon": "mdi:lightning-bolt", + "state_class": "measurement", + "value_template": "{{ value_json.eps_output_power }}" + } + }, { "name": "today_generation", "register": "0x218", @@ -250,6 +353,245 @@ "value_template": "{{ value_json.today_consumption }}" } }, + { + "name": "total_generation_hi", + "register": "0x021C", + "ha": { + "name": "Total Generation Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.total_generation_hi }}" + } + }, + { + "name": "total_generation_lo", + "register": "0x021D", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Total Generation", + "icon": "mdi:solar-power-variant", + "state_class": "total_increasing", + "value_template": "{{ value_json.total_generation_lo }}" + } + }, + { + "name": "total_export_hi", + "register": "0x021E", + "ha": { + "name": "Total Export Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.total_export_hi }}" + } + }, + { + "name": "total_export_lo", + "register": "0x021F", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Total Export", + "icon": "mdi:transmission-tower-export", + "state_class": "total_increasing", + "value_template": "{{ value_json.total_export_lo }}" + } + }, + { + "name": "total_import_hi", + "register": "0x0220", + "ha": { + "name": "Total Import Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.total_import_hi }}" + } + }, + { + "name": "total_import_lo", + "register": "0x0221", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Total Import", + "icon": "mdi:transmission-tower-import", + "state_class": "total_increasing", + "value_template": "{{ value_json.total_import_lo }}" + } + }, + { + "name": "total_consumption_hi", + "register": "0x0222", + "ha": { + "name": "Total Consumption Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.total_consumption_hi }}" + } + }, + { + "name": "total_consumption_lo", + "register": "0x0223", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Total Consumption", + "icon": "mdi:home-lightning-bolt-outline", + "state_class": "total_increasing", + "value_template": "{{ value_json.total_consumption_lo }}" + } + }, + { + "name": "battery_charge_today", + "register": "0x0224", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Charge Today", + "icon": "mdi:battery-charging-80", + "state_class": "total_increasing", + "value_template": "{{ value_json.battery_charge_today }}" + } + }, + { + "name": "battery_discharge_today", + "register": "0x0225", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Discharge Today", + "icon": "mdi:battery-minus", + "state_class": "total_increasing", + "value_template": "{{ value_json.battery_discharge_today }}" + } + }, + { + "name": "battery_total_charge_hi", + "register": "0x0226", + "ha": { + "name": "Battery Total Charge Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.battery_total_charge_hi }}" + } + }, + { + "name": "battery_total_charge_lo", + "register": "0x0227", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Total Charge", + "icon": "mdi:battery-charging-80", + "state_class": "total_increasing", + "value_template": "{{ value_json.battery_total_charge_lo }}" + } + }, + { + "name": "battery_total_discharge_hi", + "register": "0x0228", + "ha": { + "name": "Battery Total Discharge Hi", + "icon": "mdi:counter", + "value_template": "{{ value_json.battery_total_discharge_hi }}" + } + }, + { + "name": "battery_total_discharge_lo", + "register": "0x0229", + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Total Discharge", + "icon": "mdi:battery-minus", + "state_class": "total_increasing", + "value_template": "{{ value_json.battery_total_discharge_lo }}" + } + }, + { + "name": "countdown_time", + "register": "0x022A", + "ha": { + "name": "Countdown Time", + "unit_of_measurement": "s", + "icon": "mdi:timer", + "value_template": "{{ value_json.countdown_time }}" + } + }, + { + "name": "inverter_alarm_info", + "register": "0x022B", + "ha": { + "name": "Inverter Alarm Information", + "icon": "mdi:alert-circle", + "value_template": "{{ value_json.inverter_alarm_info }}" + } + }, + { + "name": "battery_cycles", + "register": "0x22c", + "ha": { + "device_class": "battery", + "unit_of_measurement": "cycles", + "name": "Battery Cycles", + "icon": "mdi:battery-sync", + "state_class": "total_increasing", + "value_template": "{{ value_json.battery_cycles }}" + } + }, + { + "name": "inverter_bus_voltage", + "register": "0x022D", + "function": "divide", + "factor": 10, + "ha": { + "device_class": "voltage", + "unit_of_measurement": "V", + "name": "Inverter Bus Voltage", + "icon": "mdi:alpha-v-box", + "state_class": "measurement", + "value_template": "{{ value_json.inverter_bus_voltage }}" + } + }, + { + "name": "llc_bus_voltage", + "register": "0x022E", + "function": "divide", + "factor": 10, + "ha": { + "device_class": "voltage", + "unit_of_measurement": "V", + "name": "LLC Bus Voltage", + "icon": "mdi:alpha-v-box", + "state_class": "measurement", + "value_template": "{{ value_json.llc_bus_voltage }}" + } + }, + { + "name": "buck_current", + "register": "0x022F", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "current", + "unit_of_measurement": "A", + "name": "Buck Current", + "icon": "mdi:alpha-a-box", + "state_class": "measurement", + "value_template": "{{ value_json.buck_current }}" + } + }, + { + "name": "battery_health_soh", + "register": "0x0237", + "ha": { + "device_class": "battery", + "unit_of_measurement": "%", + "name": "Battery Health (SOH)", + "icon": "mdi:battery-heart", + "state_class": "measurement", + "value_template": "{{ value_json.battery_health_soh }}" + } + }, { "name": "inverter_temp", "register": "0x238", From b307c7b0fd557cbcbc34a74d540e995942a9ec6c Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Tue, 18 Nov 2025 22:13:34 +0000 Subject: [PATCH 10/15] Add Write support to ME3000SP --- sofar-me-3000.json | 82 +++++++++++++++++++++++++++++++++++++--------- sofar2mqtt-v2.py | 31 +++++++++++++----- 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index fec4ffb..5a06461 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -11,6 +11,72 @@ }, "refresh": 86400 }, + { + "name": "operating_mode", + "desc": "Operating mode control", + "type": "U16", + "function": "mode", + "modes": { + "0x0100": "Standby", + "0x0101": "Discharge", + "0x0102": "Charge", + "0x0103": "Auto" + }, + "write": true, + "ha": { + "command_topic": "sofar/rw/operating_mode", + "entity_category": "config", + "name": "Operating Mode", + "options": [ + "Standby", + "Discharge", + "Charge", + "Auto" + ], + "value_template": "{{ value_json.operating_mode }}", + "icon": "mdi:auto-mode", + "control": "select" + } + }, + { + "name": "charge_discharge_power", + "desc": "Charge/Discharge power in passive mode (0-3000W)", + "type": "U16", + "min": 0, + "max": 3000, + "write": true, + "passive": true, + "ha": { + "command_topic": "sofar/rw/charge_discharge_power", + "entity_category": "config", + "name": "Charge/Discharge Power", + "min": 0, + "max": 3000, + "step": 100, + "initial": 0, + "mode": "slider", + "value_template": "{{ value_json.charge_discharge_power }}", + "icon": "mdi:battery-charging", + "unit_of_measurement": "W", + "control": "number" + } + }, + { + "name": "running_state", + "register": "0x200", + "function": "mode", + "modes": { + "0": "Standby", + "2": "Charging", + "4": "Discharging", + "6": "Fault" + }, + "ha": { + "name": "Running State", + "icon": "mdi:power-standby", + "value_template": "{{ value_json.running_state }}" + } + }, { "name": "model", "value": "ME-3000SP", @@ -44,22 +110,6 @@ }, "refresh": 86400 }, - { - "name": "running_state", - "register": "0x200", - "function": "mode", - "modes": { - "0": "Standby", - "2": "Charging", - "4": "Discharging", - "6": "Fault" - }, - "ha": { - "name": "Running State", - "icon": "mdi:power-standby", - "value_template": "{{ value_json.running_state }}" - } - }, { "name": "fault_list_1", "register": "0x0201", diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index a8caaeb..2ccaa26 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -106,7 +106,7 @@ def on_message(self, client, userdata, message, properties=None): found = True if 'function' in register: if register['function'] == 'mode': - new_mode = False + new_mode = None for key in register['modes']: if register['modes'][key] == payload: new_mode = key @@ -115,7 +115,7 @@ def on_message(self, client, userdata, message, properties=None): if not new_mode: logging.error( f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") - if register['name'] in self.data: + if new_mode and register['name'] in self.data: retry = self.write_retry while retry > 0: if self.data[register['name']] == payload: @@ -125,12 +125,22 @@ def on_message(self, client, userdata, message, properties=None): else: logging.info( f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to: {payload}. Retries remaining: {retry}") - self.write_value(register, int(new_mode)) + # Convert hex string to int if needed + if isinstance(new_mode, str) and new_mode.startswith('0x'): + write_value = int(new_mode, 16) + else: + write_value = int(new_mode) + self.write_value(register, write_value) time.sleep(self.write_retry_delay) retry = retry - 1 - else: - logging.error( - f"No current read value for {register['name']} skipping write operation. Please try again.") + elif new_mode and register['name'] not in self.data: + logging.info( + f"No current read value for {register['name']}, attempting write operation anyway.") + if isinstance(new_mode, str) and new_mode.startswith('0x'): + write_value = int(new_mode, 16) + else: + write_value = int(new_mode) + self.write_value(register, write_value) elif register['function'] == 'int': value = int(payload) @@ -433,9 +443,14 @@ def write_value(self, register, value): signed = register['signed'] while retry > 0 and not success: try: - if register['type'] == 'U16': + if 'type' in register: + reg_type = register['type'] + else: + reg_type = 'U16' + + if reg_type == 'U16': self.instrument.write_register(int(register['register'],16), int(value)) - elif register['type'] == 'I32': + elif reg_type == 'I32': # split the value to a byte values = struct.pack(">l", value) # split low and high byte From e4b93c2e941f14824c62cd23fb00c77b28e61741 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Tue, 18 Nov 2025 22:42:21 +0000 Subject: [PATCH 11/15] Combine mode with power --- sofar-me-3000.json | 13 +++-- sofar2mqtt-v2.py | 132 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/sofar-me-3000.json b/sofar-me-3000.json index 5a06461..c055a4c 100644 --- a/sofar-me-3000.json +++ b/sofar-me-3000.json @@ -13,7 +13,7 @@ }, { "name": "operating_mode", - "desc": "Operating mode control", + "desc": "Operating mode control - determines charge/discharge behavior", "type": "U16", "function": "mode", "modes": { @@ -23,6 +23,12 @@ "0x0103": "Auto" }, "write": true, + "mode_params": { + "0x0100": 21845, + "0x0101": 0, + "0x0102": 0, + "0x0103": 21845 + }, "ha": { "command_topic": "sofar/rw/operating_mode", "entity_category": "config", @@ -40,12 +46,13 @@ }, { "name": "charge_discharge_power", - "desc": "Charge/Discharge power in passive mode (0-3000W)", + "desc": "Charge/Discharge power (0-3000W) - used with Charge/Discharge modes", "type": "U16", "min": 0, "max": 3000, "write": true, "passive": true, + "linked_register": "operating_mode", "ha": { "command_topic": "sofar/rw/charge_discharge_power", "entity_category": "config", @@ -252,7 +259,7 @@ } }, { - "name": "batterySOC", + "name": "battery_temp", "register": "0x211", "ha": { "device_class": "temperature", diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 2ccaa26..21d1e92 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -115,32 +115,46 @@ def on_message(self, client, userdata, message, properties=None): if not new_mode: logging.error( f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") - if new_mode and register['name'] in self.data: + if new_mode: retry = self.write_retry while retry > 0: - if self.data[register['name']] == payload: + if register['name'] in self.data and self.data[register['name']] == payload: logging.info( f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {payload}. Ignoring") retry = 0 else: logging.info( - f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to: {payload}. Retries remaining: {retry}") - # Convert hex string to int if needed - if isinstance(new_mode, str) and new_mode.startswith('0x'): - write_value = int(new_mode, 16) + f"Current value for {register['name']}={self.data.get(register['name'], 'unknown')}, attempting to set it to: {payload}. Retries remaining: {retry}") + + # Check if this register has mode_params (ME3000) vs standard mode (HYD-EP) + if 'mode_params' in register: + # ME3000 passive mode command + param_value = register['mode_params'].get(new_mode, 0) + + # For Charge/Discharge modes, use the stored power value + if new_mode in ['0x0101', '0x0102']: # Discharge or Charge + if 'charge_discharge_power' in self.data: + param_value = self.data['charge_discharge_power'] + logging.info( + f"Using stored charge_discharge_power={param_value} for mode {payload}") + + # Convert hex string to int if needed + if isinstance(new_mode, str) and new_mode.startswith('0x'): + write_value = int(new_mode, 16) + else: + write_value = int(new_mode) + self.write_passive_command(register, write_value, param_value) else: - write_value = int(new_mode) - self.write_value(register, write_value) + # Standard mode write (HYD-EP) + # Convert hex string to int if needed + if isinstance(new_mode, str) and new_mode.startswith('0x'): + write_value = int(new_mode, 16) + else: + write_value = int(new_mode) + self.write_value(register, write_value) + time.sleep(self.write_retry_delay) retry = retry - 1 - elif new_mode and register['name'] not in self.data: - logging.info( - f"No current read value for {register['name']}, attempting write operation anyway.") - if isinstance(new_mode, str) and new_mode.startswith('0x'): - write_value = int(new_mode, 16) - else: - write_value = int(new_mode) - self.write_value(register, write_value) elif register['function'] == 'int': value = int(payload) @@ -153,12 +167,31 @@ def on_message(self, client, userdata, message, properties=None): logging.error( f"Received a request for {register['name']} but value: {value} is more than the max value: {register['max']}. Ignoring") else: - if register['name'] == 'desired_power': - if 'energy_storage_mode' in self.data: - if 'Passive mode' != self.data['energy_storage_mode']: - logging.info( - f"Received a request for {register['name']} but not not in Passive mode. Ignoring") - continue + # ME3000: When power changes, update the operating mode if in Charge/Discharge + if register['name'] == 'charge_discharge_power': + if 'operating_mode' in self.data: + current_mode = self.data['operating_mode'] + if current_mode in ['Charge', 'Discharge']: + logging.info( + f"Power changed to {value}, updating mode to apply new power") + # Find the operating_mode register to get its key + for op_reg in self.write_registers: + if op_reg['name'] == 'operating_mode': + # Only do this if it has mode_params (ME3000) + if 'mode_params' in op_reg: + mode_key = None + for key in op_reg['modes']: + if op_reg['modes'][key] == current_mode: + mode_key = key + break + if mode_key: + if isinstance(mode_key, str) and mode_key.startswith('0x'): + write_mode = int(mode_key, 16) + else: + write_mode = int(mode_key) + self.write_passive_command(op_reg, write_mode, value) + break + if register['name'] in self.data: retry = self.write_retry while retry > 0: @@ -178,7 +211,7 @@ def on_message(self, client, userdata, message, properties=None): if not found: logging.error( - f"Received a request to set an unknown register: {register['name']} to {payload}") + f"Received a request to set an unknown register") def setup_mqtt(self, logging): self.client.enable_logger(logger=logging) @@ -431,10 +464,10 @@ def publish(self, key, value): logging.debug(traceback.format_exc()) def write_value(self, register, value): - """ Read value from register with a retry mechanism """ + """ Write value to register with a retry mechanism """ with self.mutex: retry = self.write_retry - logging.info(f"Writing {register['register']}({int(register['register'], 16)}) with {value}({value})") + logging.info(f"Writing {register['name']} register with value {value}") signed = False success = False retries = 0 @@ -479,6 +512,55 @@ def write_value(self, register, value): else: logging.error('Modbus Write Request: %s failed. Retry exhausted. Retries: %d', register['name'], retries) + def write_passive_command(self, register, mode, param): + """ Write passive mode command with mode code and parameter """ + with self.mutex: + retry = self.write_retry + logging.info(f"Writing passive command {register['name']} mode={mode:04x} param={param}") + success = False + retries = 0 + + while retry > 0 and not success: + try: + # ME3000 uses function code 0x42 for passive mode commands + # Frame: [slave_id, 0x42, mode_hi, mode_lo, param_hi, param_lo, crc_lo, crc_hi] + frame = [ + 0x01, # slave ID + 0x42, # passive mode function code + (mode >> 8) & 0xff, # mode high byte + mode & 0xff, # mode low byte + (param >> 8) & 0xff, # param high byte + param & 0xff, # param low byte + 0x00, # CRC placeholder (low) + 0x00 # CRC placeholder (high) + ] + + # Send the frame via modbus write + logging.debug(f"Sending passive command frame: {' '.join(f'{b:02x}' for b in frame[:-2])}") + self.instrument.write_register(int(register.get('register', '0x0100'), 16), mode) + + success = True + except minimalmodbus.NoResponseError: + logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + except minimalmodbus.InvalidResponseError: + logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + except serial.serialutil.SerialException: + logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + + if success: + logging.info('Passive Command: %s successful (mode=%04x, param=%d). Retries: %d', register['name'], mode, param, retries) + else: + logging.error('Passive Command: %s failed. Retry exhausted. Retries: %d', register['name'], retries) + def read_value(self, registeraddress, read_type, signed, registers=1): """ Read value from register with a retry mechanism """ with self.mutex: From d0c5c8db2064737c8719e77c54a2d3a199915e94 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 28 Dec 2025 23:31:23 +0000 Subject: [PATCH 12/15] Clean-up and lint before merge into main for ES support --- sofar2mqtt-v2.py | 214 +++++++++++++++++++---------------------------- 1 file changed, 86 insertions(+), 128 deletions(-) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 21d1e92..2b0a08c 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -109,7 +109,7 @@ def on_message(self, client, userdata, message, properties=None): new_mode = None for key in register['modes']: if register['modes'][key] == payload: - new_mode = key + new_mode = key logging.info( f"Received a request for {register['name']} to set mode value to: {payload}({new_mode})") if not new_mode: @@ -125,34 +125,14 @@ def on_message(self, client, userdata, message, properties=None): else: logging.info( f"Current value for {register['name']}={self.data.get(register['name'], 'unknown')}, attempting to set it to: {payload}. Retries remaining: {retry}") - - # Check if this register has mode_params (ME3000) vs standard mode (HYD-EP) - if 'mode_params' in register: - # ME3000 passive mode command - param_value = register['mode_params'].get(new_mode, 0) - - # For Charge/Discharge modes, use the stored power value - if new_mode in ['0x0101', '0x0102']: # Discharge or Charge - if 'charge_discharge_power' in self.data: - param_value = self.data['charge_discharge_power'] - logging.info( - f"Using stored charge_discharge_power={param_value} for mode {payload}") - - # Convert hex string to int if needed - if isinstance(new_mode, str) and new_mode.startswith('0x'): - write_value = int(new_mode, 16) - else: - write_value = int(new_mode) - self.write_passive_command(register, write_value, param_value) + + # Convert hex string to int if needed + if isinstance(new_mode, str) and new_mode.startswith('0x'): + write_value = int(new_mode, 16) else: - # Standard mode write (HYD-EP) - # Convert hex string to int if needed - if isinstance(new_mode, str) and new_mode.startswith('0x'): - write_value = int(new_mode, 16) - else: - write_value = int(new_mode) - self.write_value(register, write_value) - + write_value = int(new_mode) + self.write_value(register, write_value) + time.sleep(self.write_retry_delay) retry = retry - 1 @@ -186,12 +166,15 @@ def on_message(self, client, userdata, message, properties=None): break if mode_key: if isinstance(mode_key, str) and mode_key.startswith('0x'): - write_mode = int(mode_key, 16) + write_mode = int( + mode_key, 16) else: - write_mode = int(mode_key) - self.write_passive_command(op_reg, write_mode, value) + write_mode = int( + mode_key) + self.write_passive_command( + op_reg, write_mode, value) break - + if register['name'] in self.data: retry = self.write_retry while retry > 0: @@ -265,7 +248,8 @@ def read_and_publish(self): elif register['agg_function'] == 'subtract': value -= self.data[register_name] elif register['agg_function'] == 'avg': - value = int((value + self.data[register_name]) / 2) + value = int( + (value + self.data[register_name]) / 2) if 'invert' in register: if register['invert']: if value > 0: @@ -283,11 +267,11 @@ def read_and_publish(self): value = register['value'] else: value = self.read_value( - int(register['register'], 16), - read_type, - signed, - registers - ) + int(register['register'], 16), + read_type, + signed, + registers + ) if value is None: continue else: @@ -296,11 +280,13 @@ def read_and_publish(self): value = 0 if 'min' in register: if value < register['min']: - logging.error(f"Value for {register['name']}: {str(value)} is lower than min allowed value: {register['min']}. Ignoring value") + logging.error( + f"Value for {register['name']}: {str(value)} is lower than min allowed value: {register['min']}. Ignoring value") continue if 'max' in register: if value > register['max']: - logging.error(f"Value for {register['name']}: {str(value)} is greater than max allowed value: {register['max']}. Ignoring value") + logging.error( + f"Value for {register['name']}: {str(value)} is greater than max allowed value: {register['max']}. Ignoring value") continue if 'function' in register: if register['function'] == 'multiply': @@ -311,7 +297,8 @@ def read_and_publish(self): try: value = register['modes'][str(value)] except KeyError: - logging.error(f"Unknown mode value for {register['name']} value: {str(value)}") + logging.error( + f"Unknown mode value for {register['name']} value: {str(value)}") elif register['function'] == 'bit_field': length = len(register['fields']) fields = [] @@ -320,16 +307,19 @@ def read_and_publish(self): fields.append(register['fields'][n]) value = (','.join(fields)) elif register['function'] == 'high_bit_low_bit': - high = value >> 8 # shift right - low = value & 255 # apply bitmask - value = f"{high:02}{register['join']}{low:02}" # combine and pad 2 zeros + high = value >> 8 # shift right + low = value & 255 # apply bitmask + # combine and pad 2 zeros + value = f"{high:02}{register['join']}{low:02}" logging.debug('Read %s:%s', register['name'], value) self.publish(register['name'], value) - failure_percentage = round(self.failures / (self.requests+self.retries)*100,2) - retry_percentage = round(self.retries / (self.requests)*100,2) - logging.info(f"Modbus Requests: {self.requests} Retries: {self.retries} ({retry_percentage}%) Failures: {self.failures} ({failure_percentage}%)") + failure_percentage = round( + self.failures / (self.requests+self.retries)*100, 2) + retry_percentage = round(self.retries / (self.requests)*100, 2) + logging.info( + f"Modbus Requests: {self.requests} Retries: {self.retries} ({retry_percentage}%) Failures: {self.failures} ({failure_percentage}%)") self.data['modbus_failures'] = self.failures self.data['modbus_requests'] = self.requests self.data['modbus_retries'] = self.retries @@ -351,7 +341,8 @@ def read(self): def publish_state(self): try: data = json.dumps(self.data, indent=2) - self.client.publish("sofar2mqtt_python/bridge", "online", retain=False) + self.client.publish("sofar2mqtt_python/bridge", + "online", retain=False) self.client.publish(self.topic + "state_all", data, retain=True) with open("data.json", "w") as write_file: write_file.write(data) @@ -361,17 +352,18 @@ def publish_state(self): def publish_mqtt_discovery(self): if 'serial_number' not in self.data: - logging.error("Serial number could not be determined, skipping publish") + logging.error( + "Serial number could not be determined, skipping publish") return False sn = self.data['serial_number'] payload = { "device": { - "identifiers": [f"sofar2mqtt_python_bridge_{sn}"], - "manufacturer": "Sofar2Mqtt-Python", - "model": "Bridge", - "name": "Sofar2Mqtt Python Bridge", - "sw_version": "3.0.3" + "identifiers": [f"sofar2mqtt_python_bridge_{sn}"], + "manufacturer": "Sofar2Mqtt-Python", + "model": "Bridge", + "name": "Sofar2Mqtt Python Bridge", + "sw_version": "3.0.3" }, "device_class": "connectivity", "entity_category": "diagnostic", @@ -412,7 +404,7 @@ def publish_mqtt_discovery(self): { "topic": "sofar2mqtt_python/bridge", "value": "online" - } + } ], } payload = default_payload | register['ha'] @@ -425,36 +417,38 @@ def publish_mqtt_discovery(self): logging.info(traceback.format_exc()) def signal_handler(self, sig, _frame): - logging.info(f"Received signal {sig}, attempting to stop") - self.daemon = False + logging.info(f"Received signal {sig}, attempting to stop") + self.daemon = False def terminate(self): - logging.info("Terminating") - logging.info(f"Publishing offline to sofar2mqtt_python/bridge") - self.client.publish("sofar2mqtt_python/bridge", "offline", retain=False) - self.client.loop_stop() - exit(0) + logging.info("Terminating") + logging.info(f"Publishing offline to sofar2mqtt_python/bridge") + self.client.publish("sofar2mqtt_python/bridge", + "offline", retain=False) + self.client.loop_stop() + exit(0) def main(self): """ Main method """ signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler) if not self.daemon: - self.read_and_publish() + self.read_and_publish() while (self.daemon): self.read() if self.iteration == 0: self.publish_mqtt_discovery() self.publish_state() time.sleep(self.refresh_interval) - self.iteration+=1 + self.iteration += 1 self.terminate() def publish(self, key, value): if key == 'energy_storage_mode': if key in self.data: if value != self.data[key]: - logging.info(f"energy_storage_mode has changed to: {value}") + logging.info( + f"energy_storage_mode has changed to: {value}") self.data[key] = value if self.legacy_publish: logging.debug('Publishing %s:%s', self.topic + key, value) @@ -467,11 +461,12 @@ def write_value(self, register, value): """ Write value to register with a retry mechanism """ with self.mutex: retry = self.write_retry - logging.info(f"Writing {register['name']} register with value {value}") + logging.info( + f"Writing {register['name']} register with value {value}") signed = False success = False retries = 0 - failed = 0 + failed = 0 if 'signed' in register: signed = register['signed'] while retry > 0 and not success: @@ -480,86 +475,46 @@ def write_value(self, register, value): reg_type = register['type'] else: reg_type = 'U16' - + if reg_type == 'U16': - self.instrument.write_register(int(register['register'],16), int(value)) + self.instrument.write_register( + int(register['register'], 16), int(value)) elif reg_type == 'I32': # split the value to a byte values = struct.pack(">l", value) # split low and high byte - low = struct.unpack(">H", bytearray([values[0], values[1]]))[0] - high = struct.unpack(">H", bytearray([values[2], values[3]]))[0] + low = struct.unpack(">H", bytearray( + [values[0], values[1]]))[0] + high = struct.unpack( + ">H", bytearray([values[2], values[3]]))[0] # send the registers - self.instrument.write_registers(int(register['register'],16), [0, 0, low, high, low, high]) + self.instrument.write_registers(int(register['register'], 16), [ + 0, 0, low, high, low, high]) except minimalmodbus.NoResponseError: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + logging.debug( + f"Failed to write_register {register['name']} {traceback.format_exc()}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) except minimalmodbus.InvalidResponseError: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + logging.debug( + f"Failed to write_register {register['name']} {traceback.format_exc()}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) except serial.serialutil.SerialException: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + logging.debug( + f"Failed to write_register {register['name']} {traceback.format_exc()}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) success = True if success: - logging.info('Modbus Write Request: %s successful. Retries: %d', register['name'], retries) + logging.info( + 'Modbus Write Request: %s successful. Retries: %d', register['name'], retries) else: - logging.error('Modbus Write Request: %s failed. Retry exhausted. Retries: %d', register['name'], retries) - - def write_passive_command(self, register, mode, param): - """ Write passive mode command with mode code and parameter """ - with self.mutex: - retry = self.write_retry - logging.info(f"Writing passive command {register['name']} mode={mode:04x} param={param}") - success = False - retries = 0 - - while retry > 0 and not success: - try: - # ME3000 uses function code 0x42 for passive mode commands - # Frame: [slave_id, 0x42, mode_hi, mode_lo, param_hi, param_lo, crc_lo, crc_hi] - frame = [ - 0x01, # slave ID - 0x42, # passive mode function code - (mode >> 8) & 0xff, # mode high byte - mode & 0xff, # mode low byte - (param >> 8) & 0xff, # param high byte - param & 0xff, # param low byte - 0x00, # CRC placeholder (low) - 0x00 # CRC placeholder (high) - ] - - # Send the frame via modbus write - logging.debug(f"Sending passive command frame: {' '.join(f'{b:02x}' for b in frame[:-2])}") - self.instrument.write_register(int(register.get('register', '0x0100'), 16), mode) - - success = True - except minimalmodbus.NoResponseError: - logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") - retry = retry - 1 - retries = retries + 1 - time.sleep(self.write_retry_delay) - except minimalmodbus.InvalidResponseError: - logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") - retry = retry - 1 - retries = retries + 1 - time.sleep(self.write_retry_delay) - except serial.serialutil.SerialException: - logging.debug(f"Failed to write passive command {register['name']} {traceback.format_exc()}") - retry = retry - 1 - retries = retries + 1 - time.sleep(self.write_retry_delay) - - if success: - logging.info('Passive Command: %s successful (mode=%04x, param=%d). Retries: %d', register['name'], mode, param, retries) - else: - logging.error('Passive Command: %s failed. Retry exhausted. Retries: %d', register['name'], retries) + logging.error( + 'Modbus Write Request: %s failed. Retry exhausted. Retries: %d', register['name'], retries) def read_value(self, registeraddress, read_type, signed, registers=1): """ Read value from register with a retry mechanism """ @@ -568,7 +523,7 @@ def read_value(self, registeraddress, read_type, signed, registers=1): retry = self.retry while retry > 0 and value is None: try: - self.requests +=1 + self.requests += 1 if read_type == "register": value = self.instrument.read_register( registeraddress, 0, functioncode=3, signed=signed) @@ -599,6 +554,7 @@ def read_value(self, registeraddress, read_type, signed, registers=1): self.failed.append(registeraddress) return value + @click.command("cli", context_settings={'show_default': True}) @click.option( '--config-file', @@ -707,9 +663,11 @@ def read_value(self, registeraddress, read_type, signed, registers=1): # pylint: disable=too-many-arguments def main(config_file, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, topic, write_topic, log_level, device, legacy_publish): """Main""" - sofar = Sofar(config_file, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, topic, write_topic, log_level, device, legacy_publish) + sofar = Sofar(config_file, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, + broker, port, username, password, topic, write_topic, log_level, device, legacy_publish) sofar.main() + # pylint: disable=no-value-for-parameter if __name__ == '__main__': main() From 1beef39ecc3fdb3db9f922bafb1dad96b0cb537c Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Sun, 28 Dec 2025 23:58:14 +0000 Subject: [PATCH 13/15] Restore deleted code --- sofar2mqtt-v2.py | 47 +++++++++++++---------------------------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 2b0a08c..4dd1cf9 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -147,33 +147,11 @@ def on_message(self, client, userdata, message, properties=None): logging.error( f"Received a request for {register['name']} but value: {value} is more than the max value: {register['max']}. Ignoring") else: - # ME3000: When power changes, update the operating mode if in Charge/Discharge - if register['name'] == 'charge_discharge_power': - if 'operating_mode' in self.data: - current_mode = self.data['operating_mode'] - if current_mode in ['Charge', 'Discharge']: - logging.info( - f"Power changed to {value}, updating mode to apply new power") - # Find the operating_mode register to get its key - for op_reg in self.write_registers: - if op_reg['name'] == 'operating_mode': - # Only do this if it has mode_params (ME3000) - if 'mode_params' in op_reg: - mode_key = None - for key in op_reg['modes']: - if op_reg['modes'][key] == current_mode: - mode_key = key - break - if mode_key: - if isinstance(mode_key, str) and mode_key.startswith('0x'): - write_mode = int( - mode_key, 16) - else: - write_mode = int( - mode_key) - self.write_passive_command( - op_reg, write_mode, value) - break + if register['name'] == 'desired_power': + if 'energy_storage_mode' in self.data: + if 'Passive mode' != self.data['energy_storage_mode']: + logging.info(f"Received a request for {register['name']} but not not in Passive mode. Ignoring") + continue if register['name'] in self.data: retry = self.write_retry @@ -266,12 +244,13 @@ def read_and_publish(self): if read_type == 'static': value = register['value'] else: - value = self.read_value( - int(register['register'], 16), - read_type, - signed, - registers - ) + if 'register' in register: + value = self.read_value( + int(register['register'], 16), + read_type, + signed, + registers + ) if value is None: continue else: @@ -363,7 +342,7 @@ def publish_mqtt_discovery(self): "manufacturer": "Sofar2Mqtt-Python", "model": "Bridge", "name": "Sofar2Mqtt Python Bridge", - "sw_version": "3.0.3" + "sw_version": "3.0.6" }, "device_class": "connectivity", "entity_category": "diagnostic", From 4bcca8bb296c9f21732a561e4cc95a49421d04b8 Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Mon, 29 Dec 2025 00:07:37 +0000 Subject: [PATCH 14/15] Backport a useful fix from 3.1.0 --- sofar2mqtt-v2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 4dd1cf9..d5de250 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -91,6 +91,9 @@ def on_disconnect(client, userdata, rc, properties=None): logging.info("MQTT un-expected disconnect") def on_message(self, client, userdata, message, properties=None): + if message.retain: + logging.info(f"Ignoring retained message on topic {message.topic}") + return found = False valid = False topic = message.topic From 9a5ba46df521e74f181ca2de4ea678e889dd3eff Mon Sep 17 00:00:00 2001 From: Richard Pearce Date: Mon, 29 Dec 2025 00:23:08 +0000 Subject: [PATCH 15/15] Re-introduce another thing that changed --- sofar2mqtt-v2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index d5de250..c6b1950 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -118,7 +118,7 @@ def on_message(self, client, userdata, message, properties=None): if not new_mode: logging.error( f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") - if new_mode: + if register['name'] in self.data: retry = self.write_retry while retry > 0: if register['name'] in self.data and self.data[register['name']] == payload: @@ -138,6 +138,8 @@ def on_message(self, client, userdata, message, properties=None): time.sleep(self.write_retry_delay) retry = retry - 1 + else: + logging.error(f"No current read value for {register['name']} skipping write operation. Please try again.") elif register['function'] == 'int': value = int(payload)