diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/prom.py b/prom.py new file mode 100755 index 0000000..03d2f49 --- /dev/null +++ b/prom.py @@ -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) diff --git a/solaredge.py b/solaredge.py index 6b8b1c3..41fda59 100755 --- a/solaredge.py +++ b/solaredge.py @@ -7,7 +7,7 @@ import requests from collections import defaultdict from sys import stderr -api_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +api_key = "ZYR991B0ZO9S530WW21B0RRNYBJGO482" site_id = 4143190 inverter = '7B0E5700-E0' @@ -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] diff --git a/solaredge_api.py b/solaredge_api.py new file mode 100755 index 0000000..014656c --- /dev/null +++ b/solaredge_api.py @@ -0,0 +1,179 @@ +#!/usr/bin/python3 + +import bottle +import datetime +import logging +import requests +from collections import defaultdict +from sys import stderr + +api_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +site_id = 4143190 + +inverter = '7B0E5700-E0' + +endpoint = "https://monitoringapi.solaredge.com/" + +query_delta = datetime.timedelta(minutes=20) +freshness_delta = datetime.timedelta(minutes=20) +cache = {} + +numeric_modes = { + 'OFF': 0, + 'SLEEPING': 1, + 'STARTING': 2, + 'MPPT': 3, + 'SHUTTING_DOWN': 4, + 'FAULT': 5, + 'STANDBY': 6, + 'LOCKED': 7, +} + +metric_data = { + "dc_voltage_volts": ["gauge", "Input bus voltage"], + "meter_energy_watthours_total": ["counter", "Power meter"], + "temperature_celsius": ["gauge", "Temperature"], + "inverter_mode": ["gauge", "Inverter state (0=off, 3=producing, 5=fault)"], + "ac_phase_volts": ["gauge", "Output bus phase voltage"], + "ac_current_amps": ["gauge", "Output bus current"], + "ac_voltage_volts": ["gauge", "Output bus voltage"], + 'ac_frequency_hertz': ["gauge", "Output bus frequency"], + 'ac_apparent_power_watts': ["gauge", "Apparent AC power"], + 'ac_active_power_watts': ["gauge", "Active AC power"], + 'ac_reactive_power_watts': ["gauge", "Reactive AC power"], + 'ac_cos_phi': ["gauge", "AC Phase factor"] +} + +last_ts = {} + +def is_fresh(date): + return (datetime.datetime.now()) - date < freshness_delta + +def numeric_mode(s): + if s in numeric_modes: + return numeric_modes[s] + if s.startswith('LOCKED'): + return 7 + +def time(t): + return t.strftime("%F %T") + +def ptime(s): + return datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S") + +def get(path, params={}, **kw): + r = requests.get(endpoint + path, params | { "api_key": api_key }, **kw) + r.raise_for_status() + return r.json() | { 'fetch_time': datetime.datetime.now().timestamp() } + +def details(): + return get(f"site/{site_id}/details.json") + +def inventory(): + return get(f"site/{site_id}/inventory.json") + +def get_inverters(): + return [ i['SN'] for i in inventory()['Inventory']['inverters'] ] + +def energy(**kw): + return get(f"site/{site_id}/energyDetails.json", + kw | {"timeUnit": "QUARTER_OF_AN_HOUR"}) + +def tech_data(**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])): + logging.info("Cache for {} is fresh", method.__name__) + return cache[name] + + logging.info("Cache for {} is stale, requesting", method.__name__) + end = datetime.datetime.now() + start = end - query_delta + data = method(startTime=time(start), endTime=time(end), **kw) + cache[name] = data + return data + +def tech_check(c): + if c['data']['telemetries']: + return ptime(c['data']['telemetries'][-1]['date']) + else: + return datetime.datetime.fromtimestamp(c['fetch_time']) + + +def meters_check(c): + return ptime(c['meterEnergyDetails']['meters'][0]['values'][-1]['date']) + +def collect(): + tech = latest(tech_data, tech_check)['data']['telemetries'] + ms = latest(meters, meters_check)['meterEnergyDetails']['meters'] + if len(tech): + point = tech[-1] + date = ptime(point['date']) + yield (date, 'dc_voltage_volts', {}, point.get('dcVoltage')) + yield (date, 'meter_energy_watthours_total', {'meter': 'production'}, point.get('totalEnergy')) + yield (date, 'temperature_celsius', {}, point.get('temperature')) + yield (date, 'inverter_mode', {}, numeric_mode(point.get('inverterMode'))) + yield (date, 'ac_phase_volts', {'phase':'1-2'}, point.get('vL1To2')) + yield (date, 'ac_phase_volts', {'phase':'2-3'}, point.get('vL2To3')) + yield (date, 'ac_phase_volts', {'phase':'3-1'}, point.get('vL3To1')) + + for p in ('L1Data','L2Data','L3Data'): + phase = point[p] + yield (date, 'ac_current_amps', {'phase': p[1:2] }, phase.get('acCurrent')) + yield (date, 'ac_voltage_volts', {'phase': p[1:2] }, phase.get('acVoltage')) + yield (date, 'ac_frequency_hertz', {'phase': p[1:2] }, phase.get('acFrequency')) + yield (date, 'ac_apparent_power_watts', {'phase': p[1:2] }, phase.get('apparentPower')) + 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] + date = ptime(point['date']) + yield (date, 'meter_energy_watthours_total', {'meter': m['meterType'].lower()}, point['value']) + +def format_metrics(entries, timestamps=False): + 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" + +@bottle.route('/') +def root(): + return "SolarEdge prometheus exporter" + +@bottle.route('/metrics') +def metrics(): + return format_metrics(collect()) + +def main(): + bottle.run(host='localhost', port=9150) + + +if __name__ == '__main__': + main() diff --git a/solaredge_modbus.py b/solaredge_modbus.py new file mode 100644 index 0000000..ed4bfd6 --- /dev/null +++ b/solaredge_modbus.py @@ -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() diff --git a/solaredge_modbus.sh b/solaredge_modbus.sh new file mode 100755 index 0000000..a43f053 --- /dev/null +++ b/solaredge_modbus.sh @@ -0,0 +1,4 @@ +#!/bin/sh +cd $HOME +. venv/bin/activate +exec python3 solaredge_modbus.py solaredge diff --git a/sunspec.py b/sunspec.py new file mode 100644 index 0000000..fa33e70 --- /dev/null +++ b/sunspec.py @@ -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))