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):
|
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
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