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.
This commit is contained in:
dj7nt committed 2026-10-10 15:42:48 +02:00
1 parent 7f91aabd3a
commit b168e2da43
7 files changed
+472 -144

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
__pycache__ __pycache__
*.pyc *.pyc
config.yaml config.yaml
spool.txt
+37
View File
@@ -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 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 ### Uninstall
+5 -6
View File
@@ -1,15 +1,14 @@
modbus: modbus:
host: 127.0.0.1 host: 192.168.178.28
port: 1502 port: 1502
timeout: 1 timeout: 1
unit: 1 unit: 1
mqtt: influx:
host: 127.0.0.1 url: http://influx.example.com:8086
port: 1883 db: solaredge
user: ~ user: ~
pass: ~ pass: ~
auto_discovery_topic: homeassistant/sensor/solaredge_inverter measurement: solaredge
state_topic: my_home/garage/sensor/solaredge_inverter
poll_interval: 3 poll_interval: 3
+1 -2
View File
@@ -1,3 +1,2 @@
paho-mqtt>=1.5.0 pymodbus>=2.0,<3.0
solaredge_modbus>=0.2.0
pyyaml>=5.3 pyyaml>=5.3
+48 -48
View File
@@ -1,17 +1,54 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import json import base64
import os import os
import solaredge_modbus
import sys import sys
import time import time
import paho.mqtt.client as mqtt import urllib.parse
import urllib.request
import yaml import yaml
from solaredge_modbus_mqtt import SolarData
from solaredge_modbus_mqtt import MyInverter
from pymodbus.exceptions import ConnectionException 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__': if __name__ == '__main__':
argparser = argparse.ArgumentParser() argparser = argparse.ArgumentParser()
argparser.add_argument('--config', type=str, default=os.environ.get('CONFIG_FILE', 'config.yaml'), help='Path to the config.yaml') argparser.add_argument('--config', type=str, default=os.environ.get('CONFIG_FILE', 'config.yaml'), help='Path to the config.yaml')
@@ -22,13 +59,6 @@ if __name__ == '__main__':
config = yaml.safe_load(stream) config = yaml.safe_load(stream)
except yaml.YAMLError as exc: except yaml.YAMLError as exc:
print(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) sys.exit(1)
inverter = MyInverter( inverter = MyInverter(
@@ -38,45 +68,15 @@ if __name__ == '__main__':
unit=config['modbus']['unit'] unit=config['modbus']['unit']
) )
run = True while True:
publishedAutoDiscovery = False
while run:
try: try:
raw = inverter.read() raw = inverter.read()
solar_data = SolarData(raw) 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: except ConnectionException as err:
print('Cannot connect to SolarEdge Modbus server on {0}:{1}, error: {2}'.format(config['modbus']['host'], config['modbus']['port'], err)) print('Cannot connect to SolarEdge Modbus server on {0}:{1}, error: {2}'.format(
sys.exit(2) 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']) time.sleep(config['poll_interval'])
+210 -45
View File
@@ -1,5 +1,6 @@
import math
import enum import enum
import math
import time
from pymodbus.constants import Endian from pymodbus.constants import Endian
from pymodbus.payload import BinaryPayloadDecoder from pymodbus.payload import BinaryPayloadDecoder
@@ -13,23 +14,6 @@ TIMEOUT = 1
UNIT = 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): class connectionType(enum.Enum):
RTU = 1 RTU = 1
TCP = 2 TCP = 2
@@ -62,20 +46,14 @@ sunspec_notimplemented = {
"ACC32": 0x00000000, "ACC32": 0x00000000,
"UINT64": 0xffffffffffffffff, "UINT64": 0xffffffffffffffff,
"ACC64": 0x0000000000000000, "ACC64": 0x0000000000000000,
"INT16": 0x8000, "INT16": -32768,
"SCALE": 0x8000, "SCALE": -32768,
"INT32": 0x80000000, "INT32": -2147483648,
"INT64": 0x8000000000000000, "INT64": 0x8000000000000000,
"FLOAT": 0x7fc00000, "FLOAT": 0x7fc00000,
"STRING": "\x00" "STRING": "\x00"
} }
c_sunspec_did_map = {
"101": "Single Phase",
"102": "Split Phase",
"103": "Three Phase"
}
inverter_status_map = [ inverter_status_map = [
"Undefined", "Undefined",
"Off", "Off",
@@ -88,6 +66,26 @@ inverter_status_map = [
"Standby" "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: class MyInverter:
@@ -96,12 +94,21 @@ class MyInverter:
parity = "N" parity = "N"
baud = 115200 baud = 115200
registers = ( # 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_model", 40020, 16, registerType.HOLDING, registerDataType.STRING, str, "Model", ""),
("c_version", 40044, 8, registerType.HOLDING, registerDataType.STRING, str, "Version", ""), ("c_version", 40044, 8, registerType.HOLDING, registerDataType.STRING, str, "Version", ""),
("c_serialnumber", 40052, 16, registerType.HOLDING, registerDataType.STRING, str, "Serial", ""), ("c_serialnumber", 40052, 16, registerType.HOLDING, registerDataType.STRING, str, "Serial", ""),
("c_deviceaddress", 40068, 1, registerType.HOLDING, registerDataType.UINT16, int, "Modbus ID", ""), ("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), ("c_sunspec_did", 40069, 1, registerType.HOLDING, registerDataType.UINT16, int, "SunSpec DID", ""),
("current", 40071, 1, registerType.HOLDING, registerDataType.UINT16, int, "Current", "A"), ("current", 40071, 1, registerType.HOLDING, registerDataType.UINT16, int, "Current", "A"),
("p1_current", 40072, 1, registerType.HOLDING, registerDataType.UINT16, int, "P1 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"), ("p2_current", 40073, 1, registerType.HOLDING, registerDataType.UINT16, int, "P2 Current", "A"),
@@ -133,9 +140,68 @@ class MyInverter:
("power_dc", 40100, 1, registerType.HOLDING, registerDataType.INT16, int, "DC Power", "W"), ("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", ""), ("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", 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", ""), ("temperature_scale", 40106, 1, registerType.HOLDING, registerDataType.INT16, int, "Temperature Scale Factor", ""),
("status", 40107, 1, registerType.HOLDING, registerDataType.UINT16, int, "Status", inverter_status_map), ("status", 40107, 1, registerType.HOLDING, registerDataType.UINT16, int, "Status", inverter_status_map),
("vendor_status", 40108, 1, registerType.HOLDING, registerDataType.UINT16, int, "Vendor Status", ""), ("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__( def __init__(
@@ -147,9 +213,6 @@ class MyInverter:
self.port = port self.port = port
self.device = device 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: if stopbits:
self.stopbits = stopbits self.stopbits = stopbits
@@ -188,12 +251,13 @@ class MyInverter:
else: else:
return f"<{self.__class__.__module__}.{self.__class__.__name__} object at {hex(id(self))}>" 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): for i in range(self.retries):
result = self.client.read_holding_registers(address=address, count=length, unit=self.unit) result = self.client.read_holding_registers(address=address, count=length, unit=self.unit)
if isinstance(result, ReadHoldingRegistersResponse): 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 return None
@@ -205,12 +269,15 @@ class MyInverter:
decoded = data.decode_32bit_uint() decoded = data.decode_32bit_uint()
elif dtype == registerDataType.INT16: elif dtype == registerDataType.INT16:
decoded = data.decode_16bit_int() decoded = data.decode_16bit_int()
elif dtype == registerDataType.FLOAT32:
decoded = data.decode_32bit_float()
elif dtype == registerDataType.STRING: elif dtype == registerDataType.STRING:
decoded = data.decode_string(length * 2).decode("utf-8").replace("\x00", "") decoded = data.decode_string(length * 2).decode("utf-8").replace("\x00", "")
else: else:
raise NotImplementedError(dtype) 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 return False
else: else:
return vtype(decoded) return vtype(decoded)
@@ -221,26 +288,42 @@ class MyInverter:
return bool(self.client.connect()) return bool(self.client.connect())
def read(self): def read(self):
rawData = self._read_holding_registers(self.firstRegister, self.registerLength)
data = {} data = {}
offset = self.firstRegister # The Modbus gateway is flaky (observed blocks failing one cycle,
for register in self.registers: # succeeding the next) - a failed block is skipped, not fatal.
name, address, length, rtype, dtype, vtype, label, fmt = register 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))
offset = first
for register in registers:
rname, address, rlength, rtype, dtype, vtype, label, fmt = register
if (address > offset): if (address > offset):
bytesToSkip = address - offset bytesToSkip = address - offset
offset += bytesToSkip offset += bytesToSkip
rawData.skip_bytes(bytesToSkip * 2) rawData.skip_bytes(bytesToSkip * 2)
data[name] = self._decode_value(rawData, length, dtype, vtype) data[rname] = self._decode_value(rawData, rlength, dtype, vtype)
offset += length offset += rlength
except Exception as err:
print('Modbus block {0} failed, skipping this cycle: {1}'.format(name, err))
return data return data
class SolarData: 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 = { fields = {
'model': {'field': 'c_model'}, 'model': {'field': 'c_model'},
'version': {'field': 'c_version'}, 'version': {'field': 'c_version'},
@@ -249,7 +332,10 @@ class SolarData:
'sunspec_did': {'field': 'c_sunspec_did'}, 'sunspec_did': {'field': 'c_sunspec_did'},
'status': {}, 'status': {},
'status_text': {'field': 'status', 'map': inverter_status_map},
'vendor_status': {}, 'vendor_status': {},
'offgrid': {'field': 'event_1_vendor', 'bit': 1},
'error_code': {'field': 'event_4_vendor'},
'current': {'scaleField': 'current_scale', 'unit': 'A'}, 'current': {'scaleField': 'current_scale', 'unit': 'A'},
'p1_current': {'scaleField': 'current_scale', 'unit': 'A'}, 'p1_current': {'scaleField': 'current_scale', 'unit': 'A'},
@@ -273,6 +359,45 @@ class SolarData:
'voltage_dc': {'scaleField': 'voltage_dc_scale', 'unit': 'V'}, 'voltage_dc': {'scaleField': 'voltage_dc_scale', 'unit': 'V'},
'power_dc': {'scaleField': 'power_dc_scale', 'unit': 'W'}, 'power_dc': {'scaleField': 'power_dc_scale', 'unit': 'W'},
'temperature': {'scaleField': 'temperature_scale', 'unit': '°C'}, '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): def __init__(self, data):
@@ -287,14 +412,54 @@ class SolarData:
if dataField not in data: if dataField not in data:
continue 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: 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 result[fieldName] = value
return result return result
def getModel(self):
return self.data['model'] 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())
+170 -43
View File
@@ -1,25 +1,25 @@
import unittest import unittest
from . import SolarData from . import SolarData, to_line_protocol
class TestStringMethods(unittest.TestCase):
def testParse(self): def probe_data(**overrides):
solarData = SolarData({ data = {
'c_model': 'SE7K-ABCDEFGH4', 'c_model': 'SE10K-RWB48BFN4',
'c_version': '0004.0008.0028', 'c_version': '0004.0025.0011',
'c_serialnumber': '7F51B342', 'c_serialnumber': '7E1FF4D1',
'c_deviceaddress': 1, 'c_deviceaddress': 1,
'c_sunspec_did': 103, 'c_sunspec_did': 103,
'current': 301, 'current': 4582,
'p1_current': 99, 'p1_current': 1616,
'p2_current': 102, 'p2_current': 1440,
'p3_current': 100, 'p3_current': 1524,
'current_scale': -2, 'current_scale': -3,
'p1_voltage': 4056, 'p1_voltage': 3897,
'p2_voltage': 4066, 'p2_voltage': 3896,
'p3_voltage': 4075, 'p3_voltage': 3892,
'p1n_voltage': 2346, 'p1n_voltage': 2254,
'p2n_voltage': 2342, 'p2n_voltage': 2252,
'p3n_voltage': 2351, 'p3n_voltage': 2240,
'voltage_scale': -1, 'voltage_scale': -1,
'power_ac': 5970, 'power_ac': 5970,
'power_ac_scale': -1, 'power_ac_scale': -1,
@@ -40,42 +40,169 @@ class TestStringMethods(unittest.TestCase):
'power_dc': 6071, 'power_dc': 6071,
'power_dc_scale': -1, 'power_dc_scale': -1,
'temperature': 4717, 'temperature': 4717,
'temperature2': 4602,
'temperature3': 4550,
'temperature_scale': -2, 'temperature_scale': -2,
'status': 4, 'status': 4,
'vendor_status': 0 '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,
self.assertEqual('SE7K-ABCDEFGH4', solarData.data['model']) 'm1_current': 4582,
self.assertEqual('0004.0008.0028', solarData.data['version']) 'm1_current_l1': 1616,
self.assertEqual('7F51B342', solarData.data['serialnumber']) '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(probe_data())
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(1, solarData.data['deviceaddress'])
self.assertEqual(103, solarData.data['sunspec_did']) self.assertEqual(103, solarData.data['sunspec_did'])
self.assertAlmostEqual(3.01, solarData.data['current']) self.assertAlmostEqual(4.582, solarData.data['current'])
self.assertAlmostEqual(0.99, solarData.data['p1_current']) self.assertAlmostEqual(1.616, solarData.data['p1_current'])
self.assertAlmostEqual(1.02, solarData.data['p2_current']) self.assertAlmostEqual(389.7, solarData.data['p1_voltage'])
self.assertAlmostEqual(1.0, solarData.data['p3_current']) self.assertAlmostEqual(225.4, solarData.data['p1n_voltage'])
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(597.0, solarData.data['power_ac']) self.assertAlmostEqual(597.0, solarData.data['power_ac'])
self.assertAlmostEqual(49.97, solarData.data['frequency']) 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(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(47.17, solarData.data['temperature'])
self.assertAlmostEqual(4, solarData.data['status']) self.assertAlmostEqual(46.02, solarData.data['temperature2'])
self.assertAlmostEqual(0, solarData.data['vendor_status']) 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__': if __name__ == '__main__':
unittest.main() unittest.main()