#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Hoymiles output plugin library """ import socket import logging from datetime import datetime, timezone from hoymiles.decoders import StatusResponse, HardwareInfoResponse from hoymiles import HOYMILES_TRANSACTION_LOGGING, HOYMILES_DEBUG_LOGGING class OutputPluginFactory: def __init__(self, **params): """ Initialize output plugin :param inverter_ser: The inverter serial :type inverter_ser: str :param inverter_name: The configured name for the inverter :type inverter_name: str """ self.inverter_ser = params.get('inverter_ser', '') self.inverter_name = params.get('inverter_name', None) def store_status(self, response, **params): """ Default function :raises NotImplementedError: when the plugin does not implement store status data """ raise NotImplementedError('The current output plugin does not implement store_status') class InfluxOutputPlugin(OutputPluginFactory): """ Influx2 output plugin """ api = None def __init__(self, url, token, **params): """ Initialize InfluxOutputPlugin https://influxdb-client.readthedocs.io/en/stable/api.html#influxdbclient The following targets must be present in your InfluxDB. This does not automatically create anything for You. :param str url: The url to connect this client to. Like http://localhost:8086 :param str token: Influx2 access token which is allowed to write to bucket :param org: Influx2 org, the token belongs to :type org: str :param bucket: Influx2 bucket to store data in (also known as retention policy) :type bucket: str :param measurement: Default measurement-prefix to use :type measurement: str """ super().__init__(**params) try: from influxdb_client import InfluxDBClient except ModuleNotFoundError: ErrorText1 = f'Module "influxdb_client" for INFLUXDB necessary.' ErrorText2 = f'Install module with command: python3 -m pip install influxdb_client' print(ErrorText1, ErrorText2) logging.error(ErrorText1) logging.error(ErrorText2) exit() self._bucket = params.get('bucket', 'hoymiles/autogen') self._org = params.get('org', '') self._measurement = params.get('measurement', f'inverter,host={socket.gethostname()}') with InfluxDBClient(url, token, bucket=self._bucket) as self.client: self.api = self.client.write_api() def disco(self, **params): self.client.close() # Shutdown the client return def store_status(self, response, **params): """ Publish StatusResponse object :param hoymiles.decoders.StatusResponse response: StatusResponse object :type response: hoymiles.decoders.StatusResponse :param measurement: Custom influx measurement name :type measurement: str or None :raises ValueError: when response is not instance of StatusResponse """ if not isinstance(response, StatusResponse): raise ValueError('Data needs to be instance of StatusResponse') data = response.__dict__() measurement = self._measurement + f',location={data["inverter_ser"]}' data_stack = [] time_rx = datetime.now() if 'time' in data and isinstance(data['time'], datetime): time_rx = data['time'] # InfluxDB uses UTC utctime = datetime.fromtimestamp(time_rx.timestamp(), tz=timezone.utc) # InfluxDB requires nanoseconds ctime = int(utctime.timestamp() * 1e9) if HOYMILES_DEBUG_LOGGING: logging.info(f'InfluxDB: utctime: {utctime}') # AC Data phase_id = 0 for phase in data['phases']: data_stack.append(f'{measurement},phase={phase_id},type=voltage value={phase["voltage"]} {ctime}') data_stack.append(f'{measurement},phase={phase_id},type=current value={phase["current"]} {ctime}') data_stack.append(f'{measurement},phase={phase_id},type=power value={phase["power"]} {ctime}') data_stack.append(f'{measurement},phase={phase_id},type=Q_AC value={phase["reactive_power"]} {ctime}') data_stack.append(f'{measurement},phase={phase_id},type=frequency value={phase["frequency"]:.3f} {ctime}') phase_id = phase_id + 1 # DC Data string_id = 0 for string in data['strings']: data_stack.append(f'{measurement},string={string_id},type=voltage value={string["voltage"]:.3f} {ctime}') data_stack.append(f'{measurement},string={string_id},type=current value={string["current"]:3f} {ctime}') data_stack.append(f'{measurement},string={string_id},type=power value={string["power"]:.2f} {ctime}') data_stack.append(f'{measurement},string={string_id},type=YieldDay value={string["energy_daily"]:.2f} {ctime}') data_stack.append(f'{measurement},string={string_id},type=YieldTotal value={string["energy_total"]/1000:.4f} {ctime}') data_stack.append(f'{measurement},string={string_id},type=Irradiation value={string["irradiation"]:.2f} {ctime}') string_id = string_id + 1 # Global if data['event_count'] is not None: data_stack.append(f'{measurement},type=total_events value={data["event_count"]} {ctime}') if data['powerfactor'] is not None: data_stack.append(f'{measurement},type=PF_AC value={data["powerfactor"]:f} {ctime}') data_stack.append(f'{measurement},type=Temp value={data["temperature"]:.2f} {ctime}') if data['yield_total'] is not None: data_stack.append(f'{measurement},type=YieldTotal value={data["yield_total"]/1000:.3f} {ctime}') if data['yield_today'] is not None: data_stack.append(f'{measurement},type=YieldToday value={data["yield_today"]/1000:.3f} {ctime}') data_stack.append(f'{measurement},type=Efficiency value={data["efficiency"]:.2f} {ctime}') if HOYMILES_DEBUG_LOGGING: #logging.debug(f'INFLUX data to DB: {data_stack}') pass self.api.write(self._bucket, self._org, data_stack) class MqttOutputPlugin(OutputPluginFactory): """ Mqtt output plugin """ client = None def __init__(self, config, **params): """ Initialize MqttOutputPlugin :param host: Broker ip or hostname (defaults to: 127.0.0.1) :type host: str :param port: Broker port :type port: int (defaults to: 1883) :param user: Optional username to login to the broker :type user: str or None :param password: Optional passwort to login to the broker :type password: str or None :param topic: Topic prefix to use (defaults to: hoymiles/{inverter_ser}) :type topic: str :param paho.mqtt.client.Client broker: mqtt-client instance :param str inverter_ser: inverter serial :param hoymiles.StatusResponse data: decoded inverter StatusResponse :param topic: custom mqtt topic prefix (default: hoymiles/{inverter_ser}) :type topic: str """ super().__init__(**params) try: import paho.mqtt.client except ModuleNotFoundError: ErrorText1 = f'Module "paho.mqtt.client" for MQTT-output necessary.' ErrorText2 = f'Install module with command: python3 -m pip install paho-mqtt' print(ErrorText1, ErrorText2) logging.error(ErrorText1) logging.error(ErrorText2) exit() mqtt_client = paho.mqtt.client.Client() if config.get('useTLS',False): mqtt_client.tls_set() mqtt_client.tls_insecure_set(config.get('insecureTLS',False)) mqtt_client.username_pw_set(config.get('user', None), config.get('password', None)) last_will = config.get('last_will', None) if last_will: lw_topic = last_will.get('topic', 'last will hoymiles') lw_payload = last_will.get('payload', 'last will') mqtt_client.will_set(str(lw_topic), str(lw_payload)) mqtt_client.connect(config.get('host', '127.0.0.1'), config.get('port', 1883)) mqtt_client.loop_start() self.client = mqtt_client self.qos = config.get('QoS', 0) # Quality of Service self.ret = config.get('Retain', True) # Retain Message def disco(self, **params): self.client.loop_stop() # Stop loop self.client.disconnect() # disconnect return def info2mqtt(self, mqtt_topic, mqtt_data): for mqtt_key in mqtt_data: self.client.publish(f'{mqtt_topic["topic"]}/{mqtt_key}', mqtt_data[mqtt_key], self.qos, self.ret) return def store_status(self, response, **params): """ Publish StatusResponse object :param hoymiles.decoders.StatusResponse response: StatusResponse object :param topic: custom mqtt topic prefix (default: hoymiles/{inverter_ser}) :type topic: str :raises ValueError: when response is not instance of StatusResponse """ data = response.__dict__() if data is None: logging.warn("received data object is empty") return topic = params.get('topic', None) if not topic: topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}' if HOYMILES_DEBUG_LOGGING: logging.info(f'MQTT-topic: {topic} data-type: {type(response)}') if isinstance(response, StatusResponse): # Global Head if data['time'] is not None: self.client.publish(f'{topic}/time', data['time'].strftime("%d.%m.%YT%H:%M:%S"), self.qos, self.ret) # AC Data phase_id = 0 phase_sum_power = 0 if data['phases'] is not None: for phase in data['phases']: self.client.publish(f'{topic}/emeter/{phase_id}/voltage', phase['voltage'], self.qos, self.ret) self.client.publish(f'{topic}/emeter/{phase_id}/current', phase['current'], self.qos, self.ret) self.client.publish(f'{topic}/emeter/{phase_id}/power', phase['power'], self.qos, self.ret) self.client.publish(f'{topic}/emeter/{phase_id}/Q_AC', phase['reactive_power'], self.qos, self.ret) self.client.publish(f'{topic}/emeter/{phase_id}/frequency', phase['frequency'], self.qos, self.ret) phase_id = phase_id + 1 phase_sum_power += phase['power'] # DC Data string_id = 0 string_sum_power = 0 if data['strings'] is not None: for string in data['strings']: if 'name' in string: string_name = string['name'].replace(" ","_") else: string_name = string_id self.client.publish(f'{topic}/emeter-dc/{string_name}/voltage', string['voltage'], self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/current', string['current'], self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/power', string['power'], self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/YieldDay', string['energy_daily'], self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/YieldTotal', string['energy_total']/1000, self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/Irradiation', string['irradiation'], self.qos, self.ret) string_id = string_id + 1 string_sum_power += string['power'] # Global if data['event_count'] is not None: self.client.publish(f'{topic}/total_events', data['event_count'], self.qos, self.ret) if data['powerfactor'] is not None: self.client.publish(f'{topic}/PF_AC', data['powerfactor'], self.qos, self.ret) self.client.publish(f'{topic}/Temp', data['temperature'], self.qos, self.ret) if data['yield_total'] is not None: self.client.publish(f'{topic}/YieldTotal', data['yield_total']/1000, self.qos, self.ret) if data['yield_today'] is not None: self.client.publish(f'{topic}/YieldToday', data['yield_today']/1000, self.qos, self.ret) if data['efficiency'] is not None: self.client.publish(f'{topic}/Efficiency', data['efficiency'], self.qos, self.ret) elif isinstance(response, HardwareInfoResponse): if data["FW_ver_maj"] is not None and data["FW_ver_min"] is not None and data["FW_ver_pat"] is not None: self.client.publish(f'{topic}/Firmware/Version',\ f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}', self.qos, self.ret) if data["FW_build_dd"] is not None and data["FW_build_mm"] is not None and data["FW_build_yy"] is not None and data["FW_build_HH"] is not None and data["FW_build_MM"] is not None: self.client.publish(f'{topic}/Firmware/Build_at',\ f'{data["FW_build_dd"]}/{data["FW_build_mm"]}/{data["FW_build_yy"]}T{data["FW_build_HH"]}:{data["FW_build_MM"]}',\ self.qos, self.ret) if data["FW_HW_ID"] is not None: self.client.publish(f'{topic}/Firmware/HWPartId',\ f'{data["FW_HW_ID"]}', self.qos, self.ret) else: raise ValueError('Data needs to be instance of StatusResponse or a instance of HardwareInfoResponse') class VzInverterOutput: def __init__(self, config, session): self.session = session self.serial = config.get('serial') self.baseurl = config.get('url', 'http://localhost/middleware/') self.channels = dict() for channel in config.get('channels', []): uid = channel.get('uid', None) ctype = channel.get('type') # if uid and ctype: if ctype: self.channels[ctype] = uid def store_status(self, data, session): """ Publish StatusResponse object :param hoymiles.decoders.StatusResponse response: StatusResponse object :raises ValueError: when response is not instance of StatusResponse """ if len(self.channels) == 0: return ts = int(round(data['time'].timestamp() * 1000)) if HOYMILES_DEBUG_LOGGING: logging.info(f'Volkszaehler-Timestamp: {ts}') # AC Data phase_id = 0 for phase in data['phases']: self.try_publish(ts, f'ac_voltage{phase_id}', phase['voltage']) self.try_publish(ts, f'ac_current{phase_id}', phase['current']) self.try_publish(ts, f'ac_power{phase_id}', phase['power']) self.try_publish(ts, f'ac_reactive_power{phase_id}', phase['reactive_power']) self.try_publish(ts, f'ac_frequency{phase_id}', phase['frequency']) phase_id = phase_id + 1 # DC Data string_id = 0 for string in data['strings']: self.try_publish(ts, f'dc_voltage{string_id}', string['voltage']) self.try_publish(ts, f'dc_current{string_id}', string['current']) self.try_publish(ts, f'dc_power{string_id}', string['power']) self.try_publish(ts, f'dc_energy_daily{string_id}', string['energy_daily']) self.try_publish(ts, f'dc_energy_total{string_id}', string['energy_total']) self.try_publish(ts, f'dc_irradiation{string_id}', string['irradiation']) string_id = string_id + 1 # Global if data['event_count'] is not None: self.try_publish(ts, f'event_count', data['event_count']) if data['powerfactor'] is not None: self.try_publish(ts, f'powerfactor', data['powerfactor']) self.try_publish(ts, f'temperature', data['temperature']) if data['yield_total'] is not None: self.try_publish(ts, f'yield_total', data['yield_total']) if data['yield_today'] is not None: self.try_publish(ts, f'yield_today', data['yield_today']) self.try_publish(ts, f'efficiency', data['efficiency']) return def try_publish(self, ts, ctype, value): if not ctype in self.channels: if HOYMILES_DEBUG_LOGGING: logging.warning(f'ctype \"{ctype}\" not found in ahoy.yml') return uid = self.channels[ctype] url = f'{self.baseurl}/data/{uid}.json?operation=add&ts={ts}&value={value}' if uid == None: if HOYMILES_DEBUG_LOGGING: logging.debug(f'ctype \"{ctype}\" has no configured uid-value in ahoy.yml') return if HOYMILES_DEBUG_LOGGING: logging.debug(f'VZ-url: {url}') try: r = self.session.get(url) if r.status_code == 404: logging.critical('VZ-DB not reachable, please check "middleware"') if r.status_code == 400: logging.critical('UUID not configured in VZ-DB') elif r.status_code != 200: raise ValueError(f'Transmit result {url}') except ConnectionError as e: raise ValueError(f'Could not connect VZ-DB {type(e)} {e.keys()}') return class VolkszaehlerOutputPlugin(OutputPluginFactory): def __init__(self, config, **params): """ Initialize VolkszaehlerOutputPlugin """ super().__init__(**params) try: import requests import time except ModuleNotFoundError: ErrorText1 = f'Module "requests" and "time" for VolkszaehlerOutputPlugin necessary.' ErrorText2 = f'Install module with command: python3 -m pip install requests' print(ErrorText1, ErrorText2) logging.error(ErrorText1) logging.error(ErrorText2) exit(1) self.session = requests.Session() self.inverters = dict() for inverterconfig in config.get('inverters', []): serial = inverterconfig.get('serial') output = VzInverterOutput(inverterconfig, self.session) self.inverters[serial] = output def disco(self, **params): self.session.close() # closing the connection return def store_status(self, response, **params): """ Publish StatusResponse object :param hoymiles.decoders.StatusResponse response: StatusResponse object :raises ValueError: when response is not instance of StatusResponse """ # check decoder object for output if not isinstance(response, StatusResponse): raise ValueError('Data needs to be instance of StatusResponse') if len(self.inverters) == 0: return data = response.__dict__() serial = data["inverter_ser"] if serial in self.inverters: output = self.inverters[serial] try: output.store_status(data, self.session) except ValueError as e: logging.warning('Could not send data to volkszaehler instance: %s' % e) return