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:
7 files changed
+538
-210
No files matched your search
@@ -1,3 +1,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
config.yaml
|
||||
spool.txt
|
||||
@@ -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
|
||||
|
||||
|
||||
+5
-6
@@ -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
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
paho-mqtt>=1.5.0
|
||||
solaredge_modbus>=0.2.0
|
||||
pymodbus>=2.0,<3.0
|
||||
pyyaml>=5.3
|
||||
+49
-49
@@ -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')
|
||||
@@ -22,14 +59,7 @@ if __name__ == '__main__':
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
print('Cannot connect to SolarEdge Modbus server on {0}:{1}, error: {2}'.format(
|
||||
config['modbus']['host'], config['modbus']['port'], err))
|
||||
|
||||
time.sleep(config['poll_interval'])
|
||||
@@ -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,6 +359,45 @@ 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):
|
||||
@@ -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']
|
||||
|
||||
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())
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user