From b168e2da4378d6a48a3fd50ec150ce54bb69348c Mon Sep 17 00:00:00 2001 From: Joerg Date: Sat, 10 Oct 2026 15:42:48 +0200 Subject: [PATCH] Replace MQTT output with direct InfluxDB writes Polls now cover meter 1, battery 1 and storage control blocks, plus inverter event registers for off-grid detection (vendor event bit 0). Register layout validated by probing a SE10K-RWB48BFN4 with fw 0004.0025.0011; battery and storage floats are word-swapped on this device. Failed Influx writes spool to disk and replay on the next success (Influx reached over WAN). Modbus blocks that fail to read are skipped for that cycle; the flaky gateway must not kill the daemon. Battery lifetime counters at 0xE176/0xE17A hang the device and are never read. BREAKING CHANGE: config.yaml 'mqtt:' section replaced by 'influx:', Home Assistant discovery removed. --- .gitignore | 1 + README.md | 37 +++ config.yaml-dist | 13 +- requirements.txt | 5 +- solaredge_modbus_mqtt.py | 102 +++---- solaredge_modbus_mqtt/__init__.py | 341 ++++++++++++++++++------ solaredge_modbus_mqtt/test_SolarData.py | 261 +++++++++++++----- 7 files changed, 544 insertions(+), 216 deletions(-) diff --git a/.gitignore b/.gitignore index a49dc09..0d50492 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ *.pyc config.yaml +spool.txt diff --git a/README.md b/README.md index 652f293..b0dcd54 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,43 @@ cp config.yaml-dist /etc/solaredge_modbus_mqtt/config.yaml docker run --detach --name=solaredge_modbus_mqtt --restart=always --volume=/etc/solaredge_modbus_mqtt/config.yaml:/app/config.yaml marijnkoesen/solaredge_modbus_mqtt:latest ``` +### Configuration + +```yaml +modbus: + host: 192.168.178.28 # inverter (SunSpec over Modbus TCP) + port: 1502 + timeout: 1 + unit: 1 + +influx: + url: http://influx.example.com:8086 + db: solaredge + user: ~ # optional basic-auth user with WRITE on the db + pass: ~ + measurement: solaredge + +poll_interval: 3 +``` + +The daemon polls the inverter and writes one line-protocol point per cycle directly +to InfluxDB. If InfluxDB is unreachable, points are spooled to `spool.txt` and +replayed on the next successful write (capped at 20000 lines). Mount a volume for +`/app/spool.txt` if backlog across container restarts matters. + +`model` and `serialnumber` are written as tags; everything else as fields. + +### Off-grid / fail-over + +- `offgrid` — boolean, true while the inverter runs islanded (off-grid) — vendor event bit +- `error_code` — inverter vendor error bitmask (0 = none), LCD-style codes +- `storage_control_mode_text` — e.g. `Backup Only`, `Maximize Self Consumption` +- `battery1_*` — SOE/SOH, power, voltage, temperatures, status of battery 1 + +Register addresses were validated by probing a SE10K-RWB48BFN4 (fw 0004.0025.0011) +with one battery and one meter. Blocks that fail to read (flaky gateway) are +skipped for that cycle; the fields are simply absent from the point. + ### Uninstall diff --git a/config.yaml-dist b/config.yaml-dist index 3776f86..c025f2e 100644 --- a/config.yaml-dist +++ b/config.yaml-dist @@ -1,15 +1,14 @@ modbus: - host: 127.0.0.1 + host: 192.168.178.28 port: 1502 timeout: 1 unit: 1 -mqtt: - host: 127.0.0.1 - port: 1883 +influx: + url: http://influx.example.com:8086 + db: solaredge user: ~ pass: ~ - auto_discovery_topic: homeassistant/sensor/solaredge_inverter - state_topic: my_home/garage/sensor/solaredge_inverter + measurement: solaredge -poll_interval: 3 \ No newline at end of file +poll_interval: 3 diff --git a/requirements.txt b/requirements.txt index c7cab64..0e43af4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ -paho-mqtt>=1.5.0 -solaredge_modbus>=0.2.0 -pyyaml>=5.3 \ No newline at end of file +pymodbus>=2.0,<3.0 +pyyaml>=5.3 diff --git a/solaredge_modbus_mqtt.py b/solaredge_modbus_mqtt.py index a7e3b19..87ebde2 100755 --- a/solaredge_modbus_mqtt.py +++ b/solaredge_modbus_mqtt.py @@ -1,17 +1,54 @@ #!/usr/bin/env python3 import argparse -import json +import base64 import os -import solaredge_modbus import sys import time -import paho.mqtt.client as mqtt +import urllib.parse +import urllib.request + import yaml -from solaredge_modbus_mqtt import SolarData -from solaredge_modbus_mqtt import MyInverter from pymodbus.exceptions import ConnectionException +from solaredge_modbus_mqtt import MyInverter +from solaredge_modbus_mqtt import SolarData +from solaredge_modbus_mqtt import to_line_protocol + +SPOOL_FILE = 'spool.txt' +SPOOL_MAX_LINES = 20000 # ~16h of backlog at a 3s poll interval + + +def send_to_influx(lines, influx): + url = '{0}/write?db={1}&precision=ns'.format( + influx['url'].rstrip('/'), + urllib.parse.quote(influx['db'], safe='') + ) + request = urllib.request.Request(url, data='\n'.join(lines).encode('utf-8'), method='POST') + if influx.get('user'): + credentials = '{0}:{1}'.format(influx['user'], influx.get('pass') or '') + token = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') + request.add_header('Authorization', 'Basic ' + token) + urllib.request.urlopen(request, timeout=5) + + +def publish(line, influx): + '''Write one line to InfluxDB. On failure, spool it to disk and replay the + spool together with the next successful write (capped at SPOOL_MAX_LINES).''' + try: + backlog = [] + if os.path.exists(SPOOL_FILE): + with open(SPOOL_FILE, 'r') as f: + backlog = f.read().splitlines()[-SPOOL_MAX_LINES:] + send_to_influx(backlog + [line], influx) + if backlog: + os.remove(SPOOL_FILE) + except Exception as err: + print('InfluxDB write failed, spooling line: {0}'.format(err)) + with open(SPOOL_FILE, 'a') as f: + f.write(line + '\n') + + if __name__ == '__main__': argparser = argparse.ArgumentParser() argparser.add_argument('--config', type=str, default=os.environ.get('CONFIG_FILE', 'config.yaml'), help='Path to the config.yaml') @@ -21,15 +58,8 @@ if __name__ == '__main__': try: config = yaml.safe_load(stream) except yaml.YAMLError as exc: - print(exc) - - try: - mqtt = mqtt.Client(client_id='', clean_session=True, userdata=None, protocol=mqtt.MQTTv311, transport='tcp') - mqtt.username_pw_set(config['mqtt']['user'], config['mqtt']['pass']) - mqtt.connect(config['mqtt']['host'], config['mqtt']['port'], 60) - except OSError as err: - print('Cannot connect to MQTT on {0}:{1}, error: {2}'.format(config['mqtt']['host'], config['mqtt']['port'], err)) - sys.exit(1) + print(exc) + sys.exit(1) inverter = MyInverter( host=config['modbus']['host'], @@ -38,45 +68,15 @@ if __name__ == '__main__': unit=config['modbus']['unit'] ) - run = True - publishedAutoDiscovery = False - while run: + while True: try: raw = inverter.read() solar_data = SolarData(raw) + line = to_line_protocol(solar_data.data, config['influx'].get('measurement', 'solaredge')) + if line: + publish(line, config['influx']) except ConnectionException as err: - print('Cannot connect to SolarEdge Modbus server on {0}:{1}, error: {2}'.format(config['modbus']['host'], config['modbus']['port'], err)) - sys.exit(2) + print('Cannot connect to SolarEdge Modbus server on {0}:{1}, error: {2}'.format( + config['modbus']['host'], config['modbus']['port'], err)) - mqtt.loop() - - if (publishedAutoDiscovery == False): - for k, v in solar_data.data.items(): - if k in ['model', 'version', 'serialnumber']: - # These are not numbers/measurements, and should not be autodiscovered as a sensor - continue - - message = { - 'name': 'SolarEdge ' + k.replace('_', ' ').title(), - 'unique_id': 'solaredge_' + k, - 'unit_of_measurement': SolarData.fields[k]['unit'] if 'unit' in SolarData.fields[k] else '', - 'state_topic': config['mqtt']['state_topic'] + '/' + k, - 'force_update': 'True', - 'device': { - 'identifiers': ['solaredge_inverter'], - 'name': 'SolarEdge Inverter' - } - } - mqtt.publish( - config['mqtt']['auto_discovery_topic'] + '/' + str(k) + '/config', - json.dumps(message), - qos=0, - retain=True - ) - - publishedAutoDiscovery = True - - for k, v in solar_data.data.items(): - mqtt.publish(config['mqtt']['state_topic'] + '/' + k, payload=str(v), qos=0, retain=False) - - time.sleep(config['poll_interval']) \ No newline at end of file + time.sleep(config['poll_interval']) diff --git a/solaredge_modbus_mqtt/__init__.py b/solaredge_modbus_mqtt/__init__.py index b4f9c2c..4576cf3 100644 --- a/solaredge_modbus_mqtt/__init__.py +++ b/solaredge_modbus_mqtt/__init__.py @@ -1,5 +1,6 @@ -import math import enum +import math +import time from pymodbus.constants import Endian from pymodbus.payload import BinaryPayloadDecoder @@ -13,23 +14,6 @@ TIMEOUT = 1 UNIT = 1 -class inverterDID(enum.Enum): - SINGLE_PHASE = 101 - SPLIT_PHASE = 102 - THREE_PHASE = 103 - - -class inverterStatus(enum.Enum): - I_STATUS_OFF = 1 - I_STATUS_SLEEPING = 2 - I_STATUS_STARTING = 3 - I_STATUS_MPPT = 4 - I_STATUS_THROTTLED = 5 - I_STATUS_SHUTTING_DOWN = 6 - I_STATUS_FAULT = 7 - I_STATUS_STANDBY = 8 - - class connectionType(enum.Enum): RTU = 1 TCP = 2 @@ -62,20 +46,14 @@ sunspec_notimplemented = { "ACC32": 0x00000000, "UINT64": 0xffffffffffffffff, "ACC64": 0x0000000000000000, - "INT16": 0x8000, - "SCALE": 0x8000, - "INT32": 0x80000000, + "INT16": -32768, + "SCALE": -32768, + "INT32": -2147483648, "INT64": 0x8000000000000000, "FLOAT": 0x7fc00000, "STRING": "\x00" } -c_sunspec_did_map = { - "101": "Single Phase", - "102": "Split Phase", - "103": "Three Phase" -} - inverter_status_map = [ "Undefined", "Off", @@ -88,6 +66,26 @@ inverter_status_map = [ "Standby" ] +# 6 observed as "idle at full battery"; the empty battery-2 slot reports 7 +battery_status_map = [ + "Off", + "Standby", + "Init", + "Charge", + "Discharge", + "Fault", + "Idle", + "Idle" +] + +storage_mode_map = [ + "Disabled", + "Maximize Self Consumption", + "Time of Use", + "Backup Only", + "Remote Control" +] + class MyInverter: @@ -96,46 +94,114 @@ class MyInverter: parity = "N" baud = 115200 - registers = ( - ("c_model", 40020, 16, registerType.HOLDING, registerDataType.STRING, str, "Model", ""), - ("c_version", 40044, 8, registerType.HOLDING, registerDataType.STRING, str, "Version", ""), - ("c_serialnumber", 40052, 16, registerType.HOLDING, registerDataType.STRING, str, "Serial", ""), - ("c_deviceaddress", 40068, 1, registerType.HOLDING, registerDataType.UINT16, int, "Modbus ID", ""), - ("c_sunspec_did", 40069, 1, registerType.HOLDING, registerDataType.UINT16, int, "SunSpec DID", c_sunspec_did_map), - ("current", 40071, 1, registerType.HOLDING, registerDataType.UINT16, int, "Current", "A"), - ("p1_current", 40072, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1 Current", "A"), - ("p2_current", 40073, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2 Current", "A"), - ("p3_current", 40074, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3 Current", "A"), - ("current_scale", 40075, 1, registerType.HOLDING, registerDataType.INT16, int, "Current Scale Factor", ""), - ("p1_voltage", 40076, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1 Voltage", "V"), - ("p2_voltage", 40077, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2 Voltage", "V"), - ("p3_voltage", 40078, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3 Voltage", "V"), - ("p1n_voltage", 40079, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1-N Voltage", "V"), - ("p2n_voltage", 40080, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2-N Voltage", "V"), - ("p3n_voltage", 40081, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3-N Voltage", "V"), - ("voltage_scale", 40082, 1, registerType.HOLDING, registerDataType.INT16, int, "Voltage Scale Factor", ""), - ("power_ac", 40083, 1, registerType.HOLDING, registerDataType.INT16, int, "Power", "W"), - ("power_ac_scale", 40084, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Scale Factor", ""), - ("frequency", 40085, 1, registerType.HOLDING, registerDataType.UINT16, int, "Frequency", "Hz"), - ("frequency_scale", 40086, 1, registerType.HOLDING, registerDataType.INT16, int, "Frequency Scale Factor", ""), - ("power_apparent", 40087, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Apparent)", "VA"), - ("power_apparent_scale", 40088, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Apparent) Scale Factor", ""), - ("power_reactive", 40089, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Reactive)", "VA"), - ("power_reactive_scale", 40090, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Reactive) Scale Factor", ""), - ("power_factor", 40091, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Factor", "%"), - ("power_factor_scale", 40092, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Factor Scale Factor", ""), - ("energy_total", 40093, 2, registerType.HOLDING, registerDataType.UINT32, int, "Total Energy", "Wh"), - ("energy_total_scale", 40095, 1, registerType.HOLDING, registerDataType.UINT16, int, "Total Energy Scale Factor", ""), - ("current_dc", 40096, 1, registerType.HOLDING, registerDataType.UINT16, int, "DC Current", "A"), - ("current_dc_scale", 40097, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Current Scale Factor", ""), - ("voltage_dc", 40098, 1, registerType.HOLDING, registerDataType.UINT16, int, "DC Voltage", "V"), - ("voltage_dc_scale", 40099, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Voltage Scale Factor", ""), - ("power_dc", 40100, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Power", "W"), - ("power_dc_scale", 40101, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Power Scale Factor", ""), - ("temperature", 40103, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature", "°C"), - ("temperature_scale", 40106, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature Scale Factor", ""), - ("status", 40107, 1, registerType.HOLDING, registerDataType.UINT16, int, "Status", inverter_status_map), - ("vendor_status", 40108, 1, registerType.HOLDING, registerDataType.UINT16, int, "Vendor Status", ""), + # Each block: (name, word order, registers) + # Register row: (name, address, length, registerType, registerDataType, valueType, label, fmt) + # + # Battery and storage blocks decode multi-register values (floats, uint32) + # with swapped word order (Endian.Little) - verified against the device by probing. + # + # ponytail: addresses are static per probed firmware (SE10K-RWB48BFN4, 0004.0025.0011), + # not discovered by walking SunSpec models - re-probe if the inverter CPU is replaced. + registerBlocks = ( + ('inverter', Endian.Big, ( + ("c_model", 40020, 16, registerType.HOLDING, registerDataType.STRING, str, "Model", ""), + ("c_version", 40044, 8, registerType.HOLDING, registerDataType.STRING, str, "Version", ""), + ("c_serialnumber", 40052, 16, registerType.HOLDING, registerDataType.STRING, str, "Serial", ""), + ("c_deviceaddress", 40068, 1, registerType.HOLDING, registerDataType.UINT16, int, "Modbus ID", ""), + ("c_sunspec_did", 40069, 1, registerType.HOLDING, registerDataType.UINT16, int, "SunSpec DID", ""), + ("current", 40071, 1, registerType.HOLDING, registerDataType.UINT16, int, "Current", "A"), + ("p1_current", 40072, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1 Current", "A"), + ("p2_current", 40073, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2 Current", "A"), + ("p3_current", 40074, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3 Current", "A"), + ("current_scale", 40075, 1, registerType.HOLDING, registerDataType.INT16, int, "Current Scale Factor", ""), + ("p1_voltage", 40076, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1 Voltage", "V"), + ("p2_voltage", 40077, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2 Voltage", "V"), + ("p3_voltage", 40078, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3 Voltage", "V"), + ("p1n_voltage", 40079, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1-N Voltage", "V"), + ("p2n_voltage", 40080, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2-N Voltage", "V"), + ("p3n_voltage", 40081, 1, registerType.HOLDING, registerDataType.UINT16, int, "P3-N Voltage", "V"), + ("voltage_scale", 40082, 1, registerType.HOLDING, registerDataType.INT16, int, "Voltage Scale Factor", ""), + ("power_ac", 40083, 1, registerType.HOLDING, registerDataType.INT16, int, "Power", "W"), + ("power_ac_scale", 40084, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Scale Factor", ""), + ("frequency", 40085, 1, registerType.HOLDING, registerDataType.UINT16, int, "Frequency", "Hz"), + ("frequency_scale", 40086, 1, registerType.HOLDING, registerDataType.INT16, int, "Frequency Scale Factor", ""), + ("power_apparent", 40087, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Apparent)", "VA"), + ("power_apparent_scale", 40088, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Apparent) Scale Factor", ""), + ("power_reactive", 40089, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Reactive)", "VA"), + ("power_reactive_scale", 40090, 1, registerType.HOLDING, registerDataType.INT16, int, "Power (Reactive) Scale Factor", ""), + ("power_factor", 40091, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Factor", "%"), + ("power_factor_scale", 40092, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Factor Scale Factor", ""), + ("energy_total", 40093, 2, registerType.HOLDING, registerDataType.UINT32, int, "Total Energy", "Wh"), + ("energy_total_scale", 40095, 1, registerType.HOLDING, registerDataType.UINT16, int, "Total Energy Scale Factor", ""), + ("current_dc", 40096, 1, registerType.HOLDING, registerDataType.UINT16, int, "DC Current", "A"), + ("current_dc_scale", 40097, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Current Scale Factor", ""), + ("voltage_dc", 40098, 1, registerType.HOLDING, registerDataType.UINT16, int, "DC Voltage", "V"), + ("voltage_dc_scale", 40099, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Voltage Scale Factor", ""), + ("power_dc", 40100, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Power", "W"), + ("power_dc_scale", 40101, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Power Scale Factor", ""), + ("temperature", 40103, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature", "°C"), + ("temperature2", 40104, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature 2", "°C"), + ("temperature3", 40105, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature 3", "°C"), + ("temperature_scale", 40106, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature Scale Factor", ""), + ("status", 40107, 1, registerType.HOLDING, registerDataType.UINT16, int, "Status", inverter_status_map), + ("vendor_status", 40108, 1, registerType.HOLDING, registerDataType.UINT16, int, "Vendor Status", ""), + ("event_1", 40109, 2, registerType.HOLDING, registerDataType.UINT32, int, "Event 1", ""), + ("event_2", 40111, 2, registerType.HOLDING, registerDataType.UINT32, int, "Event 2", ""), + ("event_1_vendor", 40113, 2, registerType.HOLDING, registerDataType.UINT32, int, "Vendor Event 1", ""), + ("event_2_vendor", 40115, 2, registerType.HOLDING, registerDataType.UINT32, int, "Vendor Event 2", ""), + ("event_3_vendor", 40117, 2, registerType.HOLDING, registerDataType.UINT32, int, "Vendor Event 3", ""), + ("event_4_vendor", 40119, 2, registerType.HOLDING, registerDataType.UINT32, int, "Vendor Event 4", ""), + )), + # ponytail: voltage group offsets wired empirically from a probe + # (+7..+10 read ~225.4V = line-neutral, +12..+14 read ~389.7V = line-line, + # shared scale factor -1 at +15); vendor docs disagree with the SunSpec layout. + # Energy counters are skipped - they decoded implausibly; integrate + # meter1_power in queries instead. + ('meter1', Endian.Big, ( + ("m1_current", 40190, 1, registerType.HOLDING, registerDataType.UINT16, int, "Current", "A"), + ("m1_current_l1", 40191, 1, registerType.HOLDING, registerDataType.UINT16, int, "L1 Current", "A"), + ("m1_current_l2", 40192, 1, registerType.HOLDING, registerDataType.UINT16, int, "L2 Current", "A"), + ("m1_current_l3", 40193, 1, registerType.HOLDING, registerDataType.UINT16, int, "L3 Current", "A"), + ("m1_current_sf", 40194, 1, registerType.HOLDING, registerDataType.INT16, int, "Current Scale Factor", ""), + ("m1_voltage", 40195, 1, registerType.HOLDING, registerDataType.UINT16, int, "Voltage", "V"), + ("m1_voltage_l1", 40196, 1, registerType.HOLDING, registerDataType.UINT16, int, "L1-N Voltage", "V"), + ("m1_voltage_l2", 40197, 1, registerType.HOLDING, registerDataType.UINT16, int, "L2-N Voltage", "V"), + ("m1_voltage_l3", 40198, 1, registerType.HOLDING, registerDataType.UINT16, int, "L3-N Voltage", "V"), + ("m1_voltage_ll_l1", 40200, 1, registerType.HOLDING, registerDataType.UINT16, int, "L1-L2 Voltage", "V"), + ("m1_voltage_ll_l2", 40201, 1, registerType.HOLDING, registerDataType.UINT16, int, "L2-L3 Voltage", "V"), + ("m1_voltage_ll_l3", 40202, 1, registerType.HOLDING, registerDataType.UINT16, int, "L3-L1 Voltage", "V"), + ("m1_voltage_sf", 40203, 1, registerType.HOLDING, registerDataType.INT16, int, "Voltage Scale Factor", ""), + ("m1_frequency", 40204, 1, registerType.HOLDING, registerDataType.UINT16, int, "Frequency", "Hz"), + ("m1_frequency_sf", 40205, 1, registerType.HOLDING, registerDataType.INT16, int, "Frequency Scale Factor", ""), + ("m1_power", 40206, 1, registerType.HOLDING, registerDataType.INT16, int, "Power", "W"), + ("m1_power_l1", 40207, 1, registerType.HOLDING, registerDataType.INT16, int, "L1 Power", "W"), + ("m1_power_l2", 40208, 1, registerType.HOLDING, registerDataType.INT16, int, "L2 Power", "W"), + ("m1_power_l3", 40209, 1, registerType.HOLDING, registerDataType.INT16, int, "L3 Power", "W"), + ("m1_power_sf", 40210, 1, registerType.HOLDING, registerDataType.INT16, int, "Power Scale Factor", ""), + )), + ('storage', Endian.Little, ( + ("s_control_mode", 57348, 1, registerType.HOLDING, registerDataType.UINT16, int, "Storage Control Mode", storage_mode_map), + ("s_backup_reserved", 57352, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Backup Reserved", "%"), + )), + ('battery1_info', Endian.Little, ( + ("b1_model", 57616, 16, registerType.HOLDING, registerDataType.STRING, str, "Model", ""), + ("b1_version", 57632, 16, registerType.HOLDING, registerDataType.STRING, str, "Version", ""), + ("b1_serialnumber", 57648, 16, registerType.HOLDING, registerDataType.STRING, str, "Serial", ""), + ("b1_rated_energy", 57666, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Rated Energy", "Wh"), + )), + # ponytail: lifetime energy counters at 0xE176/0xE17A hang the device - never read them + ('battery1', Endian.Little, ( + ("b1_avg_temperature", 57708, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Average Temperature", "°C"), + ("b1_max_temperature", 57710, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Max Temperature", "°C"), + ("b1_voltage", 57712, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Voltage", "V"), + ("b1_current", 57714, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Current", "A"), + ("b1_power", 57716, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Power", "W"), + ("b1_max_energy", 57726, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Max Energy", "Wh"), + ("b1_available_energy", 57728, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "Available Energy", "Wh"), + ("b1_soh", 57730, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "State of Health", "%"), + ("b1_soe", 57732, 2, registerType.HOLDING, registerDataType.FLOAT32, float, "State of Energy", "%"), + ("b1_status", 57734, 2, registerType.HOLDING, registerDataType.UINT32, int, "Status", battery_status_map), + )), ) def __init__( @@ -147,9 +213,6 @@ class MyInverter: self.port = port self.device = device - self.firstRegister = self.registers[0][1] - self.registerLength = (self.registers[-1][1]+self.registers[-1][2]) - self.registers[0][1] - if stopbits: self.stopbits = stopbits @@ -188,12 +251,13 @@ class MyInverter: else: return f"<{self.__class__.__module__}.{self.__class__.__name__} object at {hex(id(self))}>" - def _read_holding_registers(self, address, length): + def _read_holding_registers(self, address, length, wordorder=Endian.Big): for i in range(self.retries): result = self.client.read_holding_registers(address=address, count=length, unit=self.unit) if isinstance(result, ReadHoldingRegistersResponse): - return BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=Endian.Big, wordorder=Endian.Big) + return BinaryPayloadDecoder.fromRegisters( + result.registers, byteorder=Endian.Big, wordorder=wordorder) return None @@ -205,12 +269,15 @@ class MyInverter: decoded = data.decode_32bit_uint() elif dtype == registerDataType.INT16: decoded = data.decode_16bit_int() + elif dtype == registerDataType.FLOAT32: + decoded = data.decode_32bit_float() elif dtype == registerDataType.STRING: decoded = data.decode_string(length * 2).decode("utf-8").replace("\x00", "") else: raise NotImplementedError(dtype) - if decoded == sunspec_notimplemented[dtype.name]: + notimplemented = sunspec_notimplemented.get(dtype.name) + if decoded == notimplemented or (isinstance(decoded, float) and math.isnan(decoded)): return False else: return vtype(decoded) @@ -221,26 +288,42 @@ class MyInverter: return bool(self.client.connect()) def read(self): - rawData = self._read_holding_registers(self.firstRegister, self.registerLength) data = {} - offset = self.firstRegister - for register in self.registers: - name, address, length, rtype, dtype, vtype, label, fmt = register + # The Modbus gateway is flaky (observed blocks failing one cycle, + # succeeding the next) - a failed block is skipped, not fatal. + for name, wordorder, registers in self.registerBlocks: + first = registers[0][1] + length = (registers[-1][1] + registers[-1][2]) - first + try: + rawData = self._read_holding_registers(first, length, wordorder) + if rawData is None: + raise IOError('no response after {0} retries'.format(self.retries)) - if (address > offset): - bytesToSkip = address - offset - offset += bytesToSkip - rawData.skip_bytes(bytesToSkip*2) + offset = first + for register in registers: + rname, address, rlength, rtype, dtype, vtype, label, fmt = register - data[name] = self._decode_value(rawData, length, dtype, vtype) - offset += length + if (address > offset): + bytesToSkip = address - offset + offset += bytesToSkip + rawData.skip_bytes(bytesToSkip * 2) + + data[rname] = self._decode_value(rawData, rlength, dtype, vtype) + offset += rlength + except Exception as err: + print('Modbus block {0} failed, skipping this cycle: {1}'.format(name, err)) return data class SolarData: + # Optional keys per field: + # field: raw register name if it differs from the output name + # scaleField: raw register holding a SunSpec scale factor (10^scale) + # bit: publish (value & bit) as boolean + # map: publish map[value] instead of the raw number fields = { 'model': {'field': 'c_model'}, 'version': {'field': 'c_version'}, @@ -249,7 +332,10 @@ class SolarData: 'sunspec_did': {'field': 'c_sunspec_did'}, 'status': {}, + 'status_text': {'field': 'status', 'map': inverter_status_map}, 'vendor_status': {}, + 'offgrid': {'field': 'event_1_vendor', 'bit': 1}, + 'error_code': {'field': 'event_4_vendor'}, 'current': {'scaleField': 'current_scale', 'unit': 'A'}, 'p1_current': {'scaleField': 'current_scale', 'unit': 'A'}, @@ -273,7 +359,46 @@ class SolarData: 'voltage_dc': {'scaleField': 'voltage_dc_scale', 'unit': 'V'}, 'power_dc': {'scaleField': 'power_dc_scale', 'unit': 'W'}, 'temperature': {'scaleField': 'temperature_scale', 'unit': '°C'}, - } + 'temperature2': {'scaleField': 'temperature_scale', 'unit': '°C'}, + 'temperature3': {'scaleField': 'temperature_scale', 'unit': '°C'}, + + 'meter1_current': {'field': 'm1_current', 'scaleField': 'm1_current_sf', 'unit': 'A'}, + 'meter1_current_l1': {'field': 'm1_current_l1', 'scaleField': 'm1_current_sf', 'unit': 'A'}, + 'meter1_current_l2': {'field': 'm1_current_l2', 'scaleField': 'm1_current_sf', 'unit': 'A'}, + 'meter1_current_l3': {'field': 'm1_current_l3', 'scaleField': 'm1_current_sf', 'unit': 'A'}, + 'meter1_voltage': {'field': 'm1_voltage', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_l1': {'field': 'm1_voltage_l1', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_l2': {'field': 'm1_voltage_l2', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_l3': {'field': 'm1_voltage_l3', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_ll_l1': {'field': 'm1_voltage_ll_l1', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_ll_l2': {'field': 'm1_voltage_ll_l2', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_voltage_ll_l3': {'field': 'm1_voltage_ll_l3', 'scaleField': 'm1_voltage_sf', 'unit': 'V'}, + 'meter1_frequency': {'field': 'm1_frequency', 'scaleField': 'm1_frequency_sf', 'unit': 'Hz'}, + 'meter1_power': {'field': 'm1_power', 'scaleField': 'm1_power_sf', 'unit': 'W'}, + 'meter1_power_l1': {'field': 'm1_power_l1', 'scaleField': 'm1_power_sf', 'unit': 'W'}, + 'meter1_power_l2': {'field': 'm1_power_l2', 'scaleField': 'm1_power_sf', 'unit': 'W'}, + 'meter1_power_l3': {'field': 'm1_power_l3', 'scaleField': 'm1_power_sf', 'unit': 'W'}, + + 'storage_control_mode': {'field': 's_control_mode'}, + 'storage_control_mode_text': {'field': 's_control_mode', 'map': storage_mode_map}, + 'storage_backup_reserved': {'field': 's_backup_reserved', 'unit': '%'}, + + 'battery1_model': {'field': 'b1_model'}, + 'battery1_version': {'field': 'b1_version'}, + 'battery1_serialnumber': {'field': 'b1_serialnumber'}, + 'battery1_rated_energy': {'field': 'b1_rated_energy', 'unit': 'Wh'}, + 'battery1_avg_temperature': {'field': 'b1_avg_temperature', 'unit': '°C'}, + 'battery1_max_temperature': {'field': 'b1_max_temperature', 'unit': '°C'}, + 'battery1_voltage': {'field': 'b1_voltage', 'unit': 'V'}, + 'battery1_current': {'field': 'b1_current', 'unit': 'A'}, + 'battery1_power': {'field': 'b1_power', 'unit': 'W'}, + 'battery1_max_energy': {'field': 'b1_max_energy', 'unit': 'Wh'}, + 'battery1_available_energy': {'field': 'b1_available_energy', 'unit': 'Wh'}, + 'battery1_soh': {'field': 'b1_soh', 'unit': '%'}, + 'battery1_soe': {'field': 'b1_soe', 'unit': '%'}, + 'battery1_status': {'field': 'b1_status'}, + 'battery1_status_text': {'field': 'b1_status', 'map': battery_status_map}, + } def __init__(self, data): self.rawData = data @@ -287,14 +412,54 @@ class SolarData: if dataField not in data: continue - value = data[config['field']] if 'field' in config else data[fieldName] + value = data[dataField] + + # SunSpec NOT_IMPLEMENTED decodes to False - drop instead of + # publishing 0 (a real zero stays 0 and is kept) + if value is False or value is None: + continue if 'scaleField' in config: - value = value * math.pow(10, data[config['scaleField']]) + scale = data.get(config['scaleField']) + if scale is False or scale is None: + continue + value = value * math.pow(10, scale) + + if 'bit' in config: + value = bool(value & config['bit']) + + if 'map' in config: + value = config['map'][value] if 0 <= value < len(config['map']) else 'Unknown ({0})'.format(value) + + if isinstance(value, float): + value = round(value, 4) result[fieldName] = value return result - def getModel(self): - return self.data['model'] \ No newline at end of file + +def _escape_tag(value): + return str(value).replace('\\', '\\\\').replace(' ', '\\ ').replace(',', '\\,').replace('=', '\\=') + + +def format_field_value(value): + if isinstance(value, bool): + return 't' if value else 'f' + if isinstance(value, int): + return '%di' % value + if isinstance(value, float): + return repr(value) + return '"%s"' % str(value).replace('\\', '\\\\').replace('"', '\\"') + + +def to_line_protocol(data, measurement='solaredge', tag_fields=('model', 'serialnumber')): + '''Build one InfluxDB line-protocol point from a SolarData.data dict.''' + tags = ','.join('%s=%s' % (_escape_tag(f), _escape_tag(data[f])) for f in tag_fields if f in data) + fields = ','.join('%s=%s' % (_escape_tag(k), format_field_value(v)) for k, v in data.items() if k not in tag_fields) + + if not fields: + return None + + line = measurement + (',' + tags if tags else '') + return line + ' ' + fields + ' ' + str(time.time_ns()) diff --git a/solaredge_modbus_mqtt/test_SolarData.py b/solaredge_modbus_mqtt/test_SolarData.py index 0bc90f9..39bd175 100644 --- a/solaredge_modbus_mqtt/test_SolarData.py +++ b/solaredge_modbus_mqtt/test_SolarData.py @@ -1,81 +1,208 @@ import unittest -from . import SolarData +from . import SolarData, to_line_protocol -class TestStringMethods(unittest.TestCase): + +def probe_data(**overrides): + data = { + 'c_model': 'SE10K-RWB48BFN4', + 'c_version': '0004.0025.0011', + 'c_serialnumber': '7E1FF4D1', + 'c_deviceaddress': 1, + 'c_sunspec_did': 103, + 'current': 4582, + 'p1_current': 1616, + 'p2_current': 1440, + 'p3_current': 1524, + 'current_scale': -3, + 'p1_voltage': 3897, + 'p2_voltage': 3896, + 'p3_voltage': 3892, + 'p1n_voltage': 2254, + 'p2n_voltage': 2252, + 'p3n_voltage': 2240, + 'voltage_scale': -1, + 'power_ac': 5970, + 'power_ac_scale': -1, + 'frequency': 4997, + 'frequency_scale': -2, + 'power_apparent': 7133, + 'power_apparent_scale': -1, + 'power_reactive': -3890, + 'power_reactive_scale': -1, + 'power_factor': -8376, + 'power_factor_scale': -2, + 'energy_total': 788428, + 'energy_total_scale': 0, + 'current_dc': 8126, + 'current_dc_scale': -4, + 'voltage_dc': 7471, + 'voltage_dc_scale': -1, + 'power_dc': 6071, + 'power_dc_scale': -1, + 'temperature': 4717, + 'temperature2': 4602, + 'temperature3': 4550, + 'temperature_scale': -2, + 'status': 4, + 'vendor_status': 0, + 'event_1': 0xffffffff, + 'event_2': 0xffffffff, + 'event_1_vendor': 0, + 'event_2_vendor': 0xffffffff, + 'event_3_vendor': 0xffffffff, + 'event_4_vendor': 0, + + 'm1_current': 4582, + 'm1_current_l1': 1616, + 'm1_current_l2': 1440, + 'm1_current_l3': 1524, + 'm1_current_sf': -3, + 'm1_voltage': 2254, + 'm1_voltage_l1': 2254, + 'm1_voltage_l2': 2252, + 'm1_voltage_l3': 2240, + 'm1_voltage_ll_l1': 3897, + 'm1_voltage_ll_l2': 3896, + 'm1_voltage_ll_l3': 3892, + 'm1_voltage_sf': -1, + 'm1_frequency': 4997, + 'm1_frequency_sf': -2, + 'm1_power': 9394, + 'm1_power_l1': 3045, + 'm1_power_l2': 3153, + 'm1_power_l3': 3196, + 'm1_power_sf': -1, + + 's_control_mode': 1, + 's_backup_reserved': 34.0, + + 'b1_model': 'SolarEdge Home Battery 9.2kWh', + 'b1_version': '48V DCDC 3.5.13', + 'b1_serialnumber': '7E1FF4D1', + 'b1_rated_energy': 9200.0, + 'b1_avg_temperature': 24.4, + 'b1_max_temperature': 0.0, + 'b1_voltage': 821.68, + 'b1_current': -0.0, + 'b1_power': 0.0, + 'b1_max_energy': 9200.0, + 'b1_available_energy': 8939.52, + 'b1_soh': 97.0, + 'b1_soe': 100.0, + 'b1_status': 6, + } + data.update(overrides) + return data + + +class TestSolarData(unittest.TestCase): def testParse(self): - solarData = SolarData({ - 'c_model': 'SE7K-ABCDEFGH4', - 'c_version': '0004.0008.0028', - 'c_serialnumber': '7F51B342', - 'c_deviceaddress': 1, - 'c_sunspec_did': 103, - 'current': 301, - 'p1_current': 99, - 'p2_current': 102, - 'p3_current': 100, - 'current_scale': -2, - 'p1_voltage': 4056, - 'p2_voltage': 4066, - 'p3_voltage': 4075, - 'p1n_voltage': 2346, - 'p2n_voltage': 2342, - 'p3n_voltage': 2351, - 'voltage_scale': -1, - 'power_ac': 5970, - 'power_ac_scale': -1, - 'frequency': 4997, - 'frequency_scale': -2, - 'power_apparent': 7133, - 'power_apparent_scale': -1, - 'power_reactive': -3890, - 'power_reactive_scale': -1, - 'power_factor': -8376, - 'power_factor_scale': -2, - 'energy_total': 788428, - 'energy_total_scale': 0, - 'current_dc': 8126, - 'current_dc_scale': -4, - 'voltage_dc': 7471, - 'voltage_dc_scale': -1, - 'power_dc': 6071, - 'power_dc_scale': -1, - 'temperature': 4717, - 'temperature_scale': -2, - 'status': 4, - 'vendor_status': 0 - }) + solarData = SolarData(probe_data()) - self.assertEqual('SE7K-ABCDEFGH4', solarData.data['model']) - self.assertEqual('0004.0008.0028', solarData.data['version']) - self.assertEqual('7F51B342', solarData.data['serialnumber']) + self.assertEqual('SE10K-RWB48BFN4', solarData.data['model']) + self.assertEqual('0004.0025.0011', solarData.data['version']) + self.assertEqual('7E1FF4D1', solarData.data['serialnumber']) self.assertEqual(1, solarData.data['deviceaddress']) self.assertEqual(103, solarData.data['sunspec_did']) - self.assertAlmostEqual(3.01, solarData.data['current']) - self.assertAlmostEqual(0.99, solarData.data['p1_current']) - self.assertAlmostEqual(1.02, solarData.data['p2_current']) - self.assertAlmostEqual(1.0, solarData.data['p3_current']) - - self.assertAlmostEqual(405.6, solarData.data['p1_voltage']) - self.assertAlmostEqual(406.6, solarData.data['p2_voltage']) - self.assertAlmostEqual(407.5, solarData.data['p3_voltage']) - self.assertAlmostEqual(234.6, solarData.data['p1n_voltage']) - self.assertAlmostEqual(234.2, solarData.data['p2n_voltage']) - self.assertAlmostEqual(235.1, solarData.data['p3n_voltage']) + self.assertAlmostEqual(4.582, solarData.data['current']) + self.assertAlmostEqual(1.616, solarData.data['p1_current']) + self.assertAlmostEqual(389.7, solarData.data['p1_voltage']) + self.assertAlmostEqual(225.4, solarData.data['p1n_voltage']) self.assertAlmostEqual(597.0, solarData.data['power_ac']) self.assertAlmostEqual(49.97, solarData.data['frequency']) - self.assertAlmostEqual(713.3, solarData.data['power_apparent']) - self.assertAlmostEqual(-389.0, solarData.data['power_reactive']) - self.assertAlmostEqual(-83.76, solarData.data['power_factor']) self.assertAlmostEqual(788428, solarData.data['energy_total']) - - self.assertAlmostEqual(0.8126, solarData.data['current_dc']) - self.assertAlmostEqual(747.1, solarData.data['voltage_dc']) - self.assertAlmostEqual(607.1, solarData.data['power_dc']) self.assertAlmostEqual(47.17, solarData.data['temperature']) - self.assertAlmostEqual(4, solarData.data['status']) - self.assertAlmostEqual(0, solarData.data['vendor_status']) + self.assertAlmostEqual(46.02, solarData.data['temperature2']) + self.assertAlmostEqual(45.5, solarData.data['temperature3']) + + self.assertEqual(4, solarData.data['status']) + self.assertEqual('Producing', solarData.data['status_text']) + + def testNotImplementedRegistersAreDropped(self): + solarData = SolarData(probe_data()) + # event_1/event_2/event_2_vendor/event_3_vendor are NOT_IMPLEMENTED on this inverter + self.assertNotIn('event_1', solarData.data) + self.assertNotIn('event_2_vendor', solarData.data) + # but the readable ones survive + self.assertEqual(0, solarData.data['error_code']) + + def testInt16NotImplementedSentinel(self): + from pymodbus.constants import Endian + from pymodbus.payload import BinaryPayloadDecoder + from . import MyInverter, registerDataType + + inverter = MyInverter(host='localhost', port=1502) + decoder = BinaryPayloadDecoder.fromRegisters([0x8000], byteorder=Endian.Big, wordorder=Endian.Big) + self.assertIs(False, inverter._decode_value(decoder, 1, registerDataType.INT16, int)) + + def testOffgrid(self): + self.assertIs(False, SolarData(probe_data()).data['offgrid']) + self.assertIs(True, SolarData(probe_data(event_1_vendor=5)).data['offgrid']) + + def testMeter(self): + solarData = SolarData(probe_data()) + + self.assertAlmostEqual(4.582, solarData.data['meter1_current']) + self.assertAlmostEqual(1.616, solarData.data['meter1_current_l1']) + self.assertAlmostEqual(225.4, solarData.data['meter1_voltage']) + self.assertAlmostEqual(225.2, solarData.data['meter1_voltage_l2']) + self.assertAlmostEqual(389.6, solarData.data['meter1_voltage_ll_l2']) + self.assertAlmostEqual(49.97, solarData.data['meter1_frequency']) + self.assertAlmostEqual(939.4, solarData.data['meter1_power']) + self.assertAlmostEqual(304.5, solarData.data['meter1_power_l1']) + self.assertAlmostEqual(319.6, solarData.data['meter1_power_l3']) + + def testStorage(self): + solarData = SolarData(probe_data()) + + self.assertEqual(1, solarData.data['storage_control_mode']) + self.assertEqual('Maximize Self Consumption', solarData.data['storage_control_mode_text']) + self.assertAlmostEqual(34.0, solarData.data['storage_backup_reserved']) + + def testBattery(self): + solarData = SolarData(probe_data()) + + self.assertEqual('SolarEdge Home Battery 9.2kWh', solarData.data['battery1_model']) + self.assertAlmostEqual(9200.0, solarData.data['battery1_rated_energy']) + self.assertAlmostEqual(24.4, solarData.data['battery1_avg_temperature']) + self.assertAlmostEqual(821.68, solarData.data['battery1_voltage']) + self.assertAlmostEqual(8939.52, solarData.data['battery1_available_energy']) + self.assertAlmostEqual(97.0, solarData.data['battery1_soh']) + self.assertAlmostEqual(100.0, solarData.data['battery1_soe']) + self.assertEqual(6, solarData.data['battery1_status']) + self.assertEqual('Idle', solarData.data['battery1_status_text']) + + def testMissingBlocksAreSkipped(self): + # e.g. the battery block failed to read this cycle + data = probe_data() + for key in [k for k in data if k.startswith('b1_') or k.startswith('s_')]: + del data[key] + solarData = SolarData(data) + + self.assertNotIn('battery1_soe', solarData.data) + self.assertNotIn('storage_control_mode', solarData.data) + self.assertAlmostEqual(597.0, solarData.data['power_ac']) + + def testLineProtocol(self): + solarData = SolarData(probe_data()) + line = to_line_protocol(solarData.data) + + self.assertTrue(line.startswith('solaredge,model=SE10K-RWB48BFN4,serialnumber=7E1FF4D1 ')) + self.assertIn(',status=4i', line) + self.assertIn(',offgrid=f', line) + self.assertIn(',power_ac=597.0', line) + self.assertIn(',meter1_power=939.4', line) + self.assertIn(',battery1_soe=100.0', line) + self.assertIn(',battery1_status=6i', line) + self.assertIn(',status_text="Producing"', line) + # timestamp present at the end + self.assertRegex(line, r' \d{19}$') + + def testLineProtocolEmpty(self): + self.assertIsNone(to_line_protocol({})) + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main()