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:
dj7nt committed 2026-10-10 16:58:44 +02:00
1 parent 34e77991e6
commit d266654696
4 files changed
+2944 -5

No files matched your search

File diff suppressed because it is too large. Load diff
+13
View File
@@ -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:
+11 -2
View File
@@ -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)
+27 -3
View File
@@ -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)