Implementation for direct modbus export, factor out Prometheus code
This commit is contained in:
parent
aef6f5700c
commit
7716dd8418
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__
|
57
prom.py
Executable file
57
prom.py
Executable 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)
|
@ -78,13 +78,16 @@ def get_inverters():
|
||||
def energy(**kw):
|
||||
return get(f"site/{site_id}/energyDetails.json",
|
||||
kw | {"timeUnit": "QUARTER_OF_AN_HOUR"})
|
||||
11
|
||||
|
||||
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):
|
||||
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):
|
||||
name = method.__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_reactive_power_watts', {'phase': p[1:2] }, phase.get('reactivePower'))
|
||||
yield (date, 'ac_cos_phi', {'phase': p[1:2] }, phase.get('cosPhi'))
|
||||
else:
|
||||
yield (datetime.datetime.now(), 'inverter_mode', {}, 1)
|
||||
|
||||
for m in ms:
|
||||
point = m['values'][-1]
|
87
solaredge_modbus.py
Normal file
87
solaredge_modbus.py
Normal 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
4
solaredge_modbus.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
cd $HOME
|
||||
. venv/bin/activate
|
||||
exec python3 solaredge_modbus.py solaredge
|
150
sunspec.py
Normal file
150
sunspec.py
Normal 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))
|
Loading…
Reference in New Issue
Block a user