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()