Files
solaredge_modbus_mqtt/solaredge_modbus_mqtt.py
dj7nt 154f953109 feat: mark Modbus connection loss in InfluxDB
SolarEdge firmware is known to silently drop the Modbus TCP listener
(port refuses connections while still shown as enabled). Write a
connection_state=1 point on first failed poll and 0 on recovery so the
outage is visible in Grafana instead of just a data gap.

ConnectionException is re-raised from read() - it is fatal for the
whole cycle, not a flaky single block. Marker points carry no tags
(registers unreadable during outage), so the tile query skips the
serial filter.

Also includes threshold tweaks on the power stat tiles.
2026-10-11 07:33:44 +02:00

105 lines
3.8 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import base64
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import yaml
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 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:
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')
args = argparser.parse_args()
with open(args.config, 'r') as stream:
try:
config = yaml.safe_load(stream)
except yaml.YAMLError as exc:
print(exc)
sys.exit(1)
inverter = MyInverter(
host=config['modbus']['host'],
port=config['modbus']['port'],
timeout=config['modbus']['timeout'],
unit=config['modbus']['unit']
)
modbus_ok = True
measurement = config['influx'].get('measurement', 'solaredge')
while True:
try:
raw = inverter.read()
if not modbus_ok:
publish(to_line_protocol({'connection_state': 0}, measurement), config['influx'])
modbus_ok = True
solar_data = SolarData(raw)
line = to_line_protocol(solar_data.data, measurement)
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))
if modbus_ok:
publish(to_line_protocol({'connection_state': 1}, measurement), config['influx'])
modbus_ok = False
time.sleep(config['poll_interval'])