Implementation for direct modbus export, factor out Prometheus code

This commit is contained in:
Max 2024-03-25 22:43:45 +01:00
parent aef6f5700c
commit 7716dd8418
6 changed files with 306 additions and 2 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

57
prom.py Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/python3
import bottle
import datetime
import logging
from collections import defaultdict
from sys import stderr
def format_metrics(metric_data, entries, timestamps=False):
""" metric_data = { 'metric_name' : ('gauge'|'counter', 'description')
entries = [ (datetime, 'metric_name', { 'attr': 'key' }, value) ]
"""
collected = defaultdict(list)
for entry in entries:
collected[entry[1]].append(entry)
for metric in collected:
md = metric_data.get(metric)
if md is not None:
yield "# TYPE {} {}\n".format(metric, md[0])
yield "# HELP {} {}\n".format(metric, md[1])
for (time, metric, attrs, value) in collected[metric]:
time_s = int(time.timestamp() * 1000)
attr_s = ""
if attrs:
attr_s = "{" + ','.join('{}="{}"'.format(k, attrs[k]) for k in attrs ) + "}"
if timestamps:
yield f"{metric} {attr_s} {value} {time_s}\n"
else:
yield f"{metric} {attr_s} {value}\n"
def run(collect, metric_data, description='Mini Prometheus Exporter',
host='localhost', port=9150, timestamps=False, debug=False):
@bottle.route('/')
def root():
return description
@bottle.route('/metrics')
def metrics():
return format_metrics(metric_data, collect(), timestamps)
bottle.run(host=host, port=port, debug=debug)
if __name__ == "__main__":
def collect_dummy():
now = datetime.datetime.now()
yield (now, 'pi', {'one': '1', 'two': '2' }, 3.14)
yield (now, 'e', {'three': '3'}, 2.72)
metric_data = { 'pi': ['gauge', 'Pi value'],
'e': ['counter', 'E counter'] }
run(collect_dummy, metric_data, port=9159)

View File

@ -78,13 +78,16 @@ def get_inverters():
def energy(**kw): def energy(**kw):
return get(f"site/{site_id}/energyDetails.json", return get(f"site/{site_id}/energyDetails.json",
kw | {"timeUnit": "QUARTER_OF_AN_HOUR"}) kw | {"timeUnit": "QUARTER_OF_AN_HOUR"})
11
def tech_data(**kw): def tech_data(**kw):
return get(f"equipment/{site_id}/{inverter}/data.json", kw) return get(f"equipment/{site_id}/{inverter}/data.json", kw | {"timeUnit": "QUARTER_OF_AN_HOUR"})
def meters(**kw): def meters(**kw):
return get(f"site/{site_id}/meters.json", kw | {"timeUnit": "QUARTER_OF_AN_HOUR"}) return get(f"site/{site_id}/meters.json", kw | {"timeUnit": "QUARTER_OF_AN_HOUR"})
def flow(**kw):
return get(f"site/{site_id}/currentPowerFlow.json", kw)
def latest(method, timefn, **kw): def latest(method, timefn, **kw):
name = method.__name__ name = method.__name__
if name in cache and is_fresh(timefn(cache[name])): if name in cache and is_fresh(timefn(cache[name])):
@ -131,6 +134,8 @@ def collect():
yield (date, 'ac_active_power_watts', {'phase': p[1:2] }, phase.get('activePower')) yield (date, 'ac_active_power_watts', {'phase': p[1:2] }, phase.get('activePower'))
yield (date, 'ac_reactive_power_watts', {'phase': p[1:2] }, phase.get('reactivePower')) yield (date, 'ac_reactive_power_watts', {'phase': p[1:2] }, phase.get('reactivePower'))
yield (date, 'ac_cos_phi', {'phase': p[1:2] }, phase.get('cosPhi')) yield (date, 'ac_cos_phi', {'phase': p[1:2] }, phase.get('cosPhi'))
else:
yield (datetime.datetime.now(), 'inverter_mode', {}, 1)
for m in ms: for m in ms:
point = m['values'][-1] point = m['values'][-1]

87
solaredge_modbus.py Normal file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
import argparse
import datetime
import prom
import sunspec
metric_data = {
"dc_voltage_volts": ["gauge", "Input bus voltage"],
"dc_current_amps": ["gauge", "Input bus current"],
"dc_power_watts": ["gauge", "Input bus power"],
"energy_watthours_total": ["counter", "Power meter total"],
"temperature_celsius": ["gauge", "Temperature"],
"inverter_status": ["gauge", "Inverter state (0=off, 3=producing, 5=fault)"],
"inverter_status_vendor": ["gauge", "Inverter vendor-specific fault code"],
"ac_current_amps": ["gauge", "Output bus current"],
"ac_voltage_volts": ["gauge", "Output bus voltage"],
'ac_frequency_hertz': ["gauge", "Output bus frequency"],
'ac_power_watts': ["gauge", "Active/Reactive/Apparent AC power"],
'ac_power_factor': ["gauge", "AC Phase factor"]
}
def export(d, m):
yield ('dc_voltage_volts', {}, d.dc_voltage)
yield ('dc_current_amps', {}, d.dc_current)
yield ('dc_power_watts', {}, d.dc_power)
yield ('ac_current_amps', {'phase': 'a', 'meter': 'production'}, d.ac_current_a)
yield ('ac_current_amps', {'phase': 'b', 'meter': 'production'}, d.ac_current_b)
yield ('ac_current_amps', {'phase': 'c', 'meter': 'production'}, d.ac_current_c)
yield ('ac_power_watts', {'power': 'active', 'meter': 'production'}, d.ac_power)
yield ('ac_power_watts', {'power': 'reactive', 'meter': 'production'}, d.ac_apparent_power)
yield ('ac_power_watts', {'power': 'apparent', 'meter': 'production'}, d.ac_reactive_power)
yield ('energy_watthours_total', {'meter': 'production'}, d.ac_energy)
yield ('ac_voltage_volts', {'phase': 'ab', 'meter': 'production'}, d.ac_voltage_ab)
yield ('ac_voltage_volts', {'phase': 'bc', 'meter': 'production'}, d.ac_voltage_bc)
yield ('ac_voltage_volts', {'phase': 'ca', 'meter': 'production'}, d.ac_voltage_ca)
yield ('ac_frequency_hertz', {'meter': 'production'}, d.frequency)
yield ('inverter_status', {}, d.status_code)
yield ('inverter_status_vendor', {}, d.status_vendor)
for phase in ('a','b','c','total'):
yield ('ac_current_amps', {'phase': phase, 'meter': 'mains'}, m.ac_current[phase])
yield ('ac_power_watts', {'phase': phase, 'meter': 'mains', 'power': 'active'}, m.ac_real_power[phase])
yield ('ac_power_watts', {'phase': phase, 'meter': 'mains', 'power': 'reactive'}, m.ac_reactive_power[phase])
yield ('ac_power_watts', {'phase': phase, 'meter': 'mains', 'power': 'apparent'}, m.ac_apparent_power[phase])
for phase in ('a', 'b', 'c', 'average'):
yield ('ac_power_factor', {'phase': phase, 'meter': 'mains'}, m.power_factor[phase])
yield ('ac_voltage_volts', {'phase': phase, 'meter': 'mains'}, m.ac_voltage[phase])
for meter in ('import', 'export'):
yield ('energy_watthours_total', {'meter': meter}, m.energy_real[meter])
yield ('ac_frequency_hertz', {'meter': 'mains'}, m.frequency)
def main():
parser = argparse.ArgumentParser(prog='solaredge_modbus.py', description='SolarEdge Modbus Exporter')
parser.add_argument('-d', '--debug', action='store_true')
parser.add_argument('-b', '--bind', default='localhost')
parser.add_argument('-p', '--bind-port', default='9150')
parser.add_argument('host')
parser.add_argument('port', nargs='?', default=1502)
args = parser.parse_args()
inverter = sunspec.Inverter(host=args.host, port=args.port)
def collect():
date = datetime.datetime.now()
common = inverter.common_block()
inv = inverter.data()
meters = inverter.meter(0)
yield (date, 'firmware_version', {'version': common.version}, 1)
for (m,a,v) in export(inv, meters):
yield (date,m,a,v)
prom.run(collect, metric_data, 'SolarEdge Modbus/TCP Exporter\n',
host=args.bind, port=args.bind_port, debug=args.debug)
if __name__ == '__main__':
main()

4
solaredge_modbus.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
cd $HOME
. venv/bin/activate
exec python3 solaredge_modbus.py solaredge

150
sunspec.py Normal file
View File

@ -0,0 +1,150 @@
from pymodbus.client import ModbusTcpClient
class ConnectionFailed(Exception): pass
class InvalidAddress(Exception): pass
class Registers:
def __init__(self, lower, count=1):
self.count = count
self.lower = lower
self.range = range(count)
def check_address(self, address):
if address not in self.range:
raise InvalidAddress(f"requested {address} for {self.range}")
def scale(self, value, scale):
if scale is None:
return value
return value * self.read_scale(scale)
def read_uint16(self, address, scale=None):
self.check_address(address)
value = self.lower.getRegister(address)
return self.scale(value, scale)
def read_int16(self, address, scale=None):
value = self.read_uint16(address)
if value & 0x8000:
value = value - 0x10000
return self.scale(value, scale)
def read_uint32(self, address, scale=None):
value = self.read_uint16(address) * 65536 + self.read_uint16(address + 1)
return self.scale(value, scale)
def read_bytes(self, address, length):
string = bytearray(length)
for n in range(length//2):
nibble = self.read_uint16(address + n)
string[2*n] = nibble >> 8
string[2*n+1] = nibble & 0xFF
return bytes(string).split(b'\x00', 1)[0]
def read_scale(self, address):
return 10 ** self.read_int16(address)
class CommonBlock:
def __init__(self, regs):
self.did = regs.read_uint16(0)
self.info_len = regs.read_uint16(1)
self.manufacturer = regs.read_bytes(2, 32)
self.model = regs.read_bytes(18, 32)
self.version = '.'.join(s.lstrip('0') for s in regs.read_bytes(42, 16).decode('ascii').split('.'))
self.serial = regs.read_bytes(50, 32)
self.device = regs.read_uint16(66)
class Meter:
def __init__(self, regs):
blocks16 = (('ac_current', 0, ('total', 'a', 'b', 'c'), 0.1),
('ac_voltage', 5, ('average', 'a', 'b', 'c',
'll', 'ab', 'bc', 'ca'), 1),
('ac_real_power', 16, ('total', 'a', 'b', 'c'), 0.1),
('ac_apparent_power', 21, ('total', 'a', 'b', 'c'), 0.1),
('ac_reactive_power', 26, ('total', 'a', 'b', 'c'), 0.1),
('power_factor', 31, ('average', 'a', 'b', 'c'), 1))
quadrants = ['_'.join((k,q,p)) for (k, qs) in (
('import', ('1','2')),
('export', ('3','4'))
)
for q in qs
for p in ['total', 'a', 'b', 'c']]
blocks32 = (('energy_real', 36, ('export', 'ex_a', 'ex_b', 'ex_c',
'import', 'im_a', 'im_b', 'im_c')),
('energy_apparent', 53, ('export', 'ex_a', 'ex_b', 'ex_c',
'import', 'im_a', 'im_b', 'im_c')),
('energy_reactive', 70, quadrants))
for (metric, offset, labels, fix) in blocks16:
data = {}
scale = regs.read_scale(offset + len(labels)) * fix
for (i, key) in enumerate(labels):
data[key] = regs.read_int16(offset + i) * scale
self.__dict__[metric] = data
for (metric, offset, labels) in blocks32:
data = {}
scale = regs.read_scale(offset + len(labels)*2)
for (i, key) in enumerate(labels):
data[key] = regs.read_uint32(offset + 2*i) * scale / 10
self.__dict__[metric] = data
self.frequency = regs.read_int16(14, scale=15)
status_codes = [None, 'off', 'sleep', 'start', 'on', 'throttled', 'stop', 'fault', 'setup']
class InverterBlock:
def __init__(self, regs):
current_scale = regs.read_scale(6)
self.ac_current = current_scale * regs.read_uint16(2)
self.ac_current_a = current_scale * regs.read_uint16(3)
self.ac_current_b = current_scale * regs.read_uint16(4)
self.ac_current_c = current_scale * regs.read_uint16(5)
voltage_scale = regs.read_scale(13)
self.ac_voltage_ab = voltage_scale * regs.read_uint16(7)
self.ac_voltage_bc = voltage_scale * regs.read_uint16(8)
self.ac_voltage_ca = voltage_scale * regs.read_uint16(9)
self.ac_power = regs.read_int16(14, scale=15)
self.frequency = regs.read_int16(16, scale=17)
self.ac_apparent_power = regs.read_int16(18, scale=19)
self.ac_reactive_power = regs.read_int16(20, scale=21)
self.power_factor = regs.read_int16(22, scale=23)
self.ac_energy = regs.read_uint32(24, scale=26)
self.dc_current = regs.read_uint16(27, scale=28)
self.dc_voltage = regs.read_uint16(29, scale=30)
self.dc_power = regs.read_int16(31, scale=32)
self.temperature = regs.read_int16(34, scale=37)
self.status_code = regs.read_uint16(38)
self.status = status_codes[self.status_code]
self.status_vendor = regs.read_uint16(39)
class Inverter:
def __init__(self, host, port=1502, device=1):
self._conn = ModbusTcpClient(host, port)
self._device = 1
def connect(self):
if not self._conn.connect():
raise ConnectionFailed()
def registers(self, addr, length):
self.connect()
rs = self._conn.read_holding_registers(addr, length, slave=self._device)
return Registers(rs, length)
def common_block(self):
return CommonBlock(self.registers(40002, 67))
def data(self):
return InverterBlock(self.registers(40069, 40))
def meter(self, n):
base = 40190 + 174*n
return Meter(self.registers(base, 105))