Harden Modbus/Influx paths, add Grafana dashboard
Short or garbled gateway responses are rejected on register count instead of half-decoding (previously: 'unpack requires a buffer', OverflowError on garbage scale factors). Scale factors outside +/-10 and non-finite floats are dropped before they reach line protocol. offgrid is written as integer offgrid_state: the existing DB cannot be migrated (mixed inmem/tsi1 shards block all deletes) and the old boolean field conflicts with integer writes, which poisoned the spool - HTTP 400 now drops the batch and logs the InfluxDB error body instead of re-spooling it forever. grafana_dashboard.json: fully German dashboard (InfluxQL, InfluxDB 1.x) with off-grid state, SoC (SoE*SoH/100) and consumption-source panels; status tiles use integers plus value mappings because Grafana drops string/boolean fields from InfluxQL time series.
This commit is contained in:
4 files changed
+2944
-5
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import base64
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
@@ -43,6 +44,18 @@ def publish(line, influx):
|
||||
send_to_influx(backlog + [line], influx)
|
||||
if backlog:
|
||||
os.remove(SPOOL_FILE)
|
||||
except urllib.error.HTTPError as err:
|
||||
body = err.read().decode('utf-8', 'replace')[:200]
|
||||
if err.code == 400:
|
||||
# Permanently rejected (malformed line or field type conflict).
|
||||
# Re-spooling would poison every future write, so drop the batch.
|
||||
print('InfluxDB rejected the write, dropping {0} spooled line(s): {1}'.format(len(backlog) + 1, body))
|
||||
if os.path.exists(SPOOL_FILE):
|
||||
os.remove(SPOOL_FILE)
|
||||
else:
|
||||
print('InfluxDB write failed (HTTP {0}), spooling line: {1}'.format(err.code, body or err))
|
||||
with open(SPOOL_FILE, 'a') as f:
|
||||
f.write(line + '\n')
|
||||
except Exception as err:
|
||||
print('InfluxDB write failed, spooling line: {0}'.format(err))
|
||||
with open(SPOOL_FILE, 'a') as f:
|
||||
|
||||
@@ -261,7 +261,9 @@ class MyInverter:
|
||||
for i in range(self.retries):
|
||||
result = self.client.read_holding_registers(address=address, count=length, unit=self.unit)
|
||||
|
||||
if isinstance(result, ReadHoldingRegistersResponse):
|
||||
# length check: the flaky gateway sometimes returns short responses,
|
||||
# which still pass isinstance and would decode garbage
|
||||
if isinstance(result, ReadHoldingRegistersResponse) and len(result.registers) == length:
|
||||
return BinaryPayloadDecoder.fromRegisters(
|
||||
result.registers, byteorder=Endian.Big, wordorder=wordorder)
|
||||
|
||||
@@ -340,7 +342,7 @@ class SolarData:
|
||||
'status': {},
|
||||
'status_text': {'field': 'status', 'map': inverter_status_map},
|
||||
'vendor_status': {},
|
||||
'offgrid': {'field': 'event_1_vendor', 'bit': 1},
|
||||
'offgrid_state': {'field': 'event_1_vendor', 'bit': 1},
|
||||
'error_code': {'field': 'event_4_vendor'},
|
||||
|
||||
'current': {'scaleField': 'current_scale', 'unit': 'A'},
|
||||
@@ -466,6 +468,10 @@ class SolarData:
|
||||
scale = data.get(config['scaleField'])
|
||||
if scale is False or scale is None:
|
||||
continue
|
||||
# SunSpec scale factors live in [-10, 10]; anything else is a
|
||||
# garbage decode that would overflow math.pow
|
||||
if not isinstance(scale, int) or not -10 <= scale <= 10:
|
||||
continue
|
||||
value = value * math.pow(10, scale)
|
||||
|
||||
if 'bit' in config:
|
||||
@@ -475,6 +481,9 @@ class SolarData:
|
||||
if 'map' in config:
|
||||
value = config['map'][value] if 0 <= value < len(config['map']) else 'Unknown ({0})'.format(value)
|
||||
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
continue
|
||||
|
||||
if isinstance(value, float):
|
||||
value = round(value, 4)
|
||||
|
||||
|
||||
@@ -138,8 +138,32 @@ class TestSolarData(unittest.TestCase):
|
||||
self.assertIs(False, inverter._decode_value(decoder, 1, registerDataType.INT16, int))
|
||||
|
||||
def testOffgrid(self):
|
||||
self.assertEqual(0, SolarData(probe_data()).data['offgrid'])
|
||||
self.assertEqual(1, SolarData(probe_data(event_1_vendor=5)).data['offgrid'])
|
||||
self.assertEqual(0, SolarData(probe_data()).data['offgrid_state'])
|
||||
self.assertEqual(1, SolarData(probe_data(event_1_vendor=5)).data['offgrid_state'])
|
||||
|
||||
def testGarbageScaleIsSkipped(self):
|
||||
# a garbled block once produced a scale of ~4915 -> OverflowError
|
||||
solarData = SolarData(probe_data(current_scale=4915))
|
||||
self.assertNotIn('current', solarData.data)
|
||||
self.assertNotIn('p1_current', solarData.data)
|
||||
self.assertAlmostEqual(939.4, solarData.data['meter1_power'])
|
||||
|
||||
def testNonFiniteIsSkipped(self):
|
||||
solarData = SolarData(probe_data(b1_soe=float('inf'), b1_soh=float('nan')))
|
||||
self.assertNotIn('battery1_soe', solarData.data)
|
||||
self.assertNotIn('battery1_soh', solarData.data)
|
||||
|
||||
def testShortModbusResponseRejected(self):
|
||||
from pymodbus.register_read_message import ReadHoldingRegistersResponse
|
||||
from . import MyInverter
|
||||
|
||||
inverter = MyInverter(host='localhost', port=1502)
|
||||
inverter.retries = 1
|
||||
inverter.client = type('FakeClient', (), {
|
||||
'read_holding_registers': lambda self, **kw: ReadHoldingRegistersResponse([1, 2, 3])
|
||||
})()
|
||||
# asked for 5 registers, got 3 -> must be rejected, not half-decoded
|
||||
self.assertIsNone(inverter._read_holding_registers(40020, 5))
|
||||
|
||||
def testMeter(self):
|
||||
solarData = SolarData(probe_data())
|
||||
@@ -229,7 +253,7 @@ class TestSolarData(unittest.TestCase):
|
||||
|
||||
self.assertTrue(line.startswith('solaredge,model=SE10K-RWB48BFN4,serialnumber=7E1FF4D1 '))
|
||||
self.assertIn(',status=4i', line)
|
||||
self.assertIn(',offgrid=0i', line)
|
||||
self.assertIn(',offgrid_state=0i', line)
|
||||
self.assertIn(',power_ac=597.0', line)
|
||||
self.assertIn(',meter1_power=939.4', line)
|
||||
self.assertIn(',battery1_soe=100.0', line)
|
||||
|
||||
Reference in new issue
Block a user