diff --git a/tools/rpi/ahoy.yml.example b/tools/rpi/ahoy.yml.example index 7cb0c3b7..fb033f80 100644 --- a/tools/rpi/ahoy.yml.example +++ b/tools/rpi/ahoy.yml.example @@ -28,6 +28,11 @@ ahoy: password: 'password' useTLS: False insecureTLS: False #set True for e.g. self signed certificates. + QoS: 0 + Retain: True + last_will: + topic: Appelweg_PV/114181807700 # defaults to 'hoymiles/{serial}' + payload: "LAST-WILL-MESSAGE: Please check my HOST and Process!" # Influx2 output influxdb: @@ -44,9 +49,7 @@ ahoy: - serial: 114172220003 url: 'http://localhost/middleware/' channels: - - type: 'temperature' - uid: 'ad578a40-1d97-11ed-8e8b-fda01a416575' - - type: 'frequency' + - type: 'ac_frequency0' uid: '' - type: 'ac_power0' uid: '7ca5ac50-1e41-11ed-927f-610c4cb2c69e' @@ -54,18 +57,42 @@ ahoy: uid: '9a38e2e0-1d94-11ed-b539-25f8607ac030' - type: 'ac_current0' uid: 'a9a4daf0-1e41-11ed-b68c-eb73eef3d21d' + - type: 'ac_reactive_power0' + uid: '' - type: 'dc_power0' uid: '38eb3ca0-1e53-11ed-b830-792e70a592fa' - type: 'dc_voltage0' uid: '' - type: 'dc_current0' uid: '' + - type: 'dc_energy_total0' + uid: '' + - type: 'dc_energy_daily0' + uid: 'c2a93ea0-9a4e-11ed-8000-7d82e3ac8959' + - type: 'dc_irradiation0' + uid: 'c2d887a0-9a4e-11ed-a7ac-0dab944fd82d' - type: 'dc_power1' uid: '51c0e9d0-1e53-11ed-b574-8bc81547eb8f' - type: 'dc_voltage1' uid: '' - type: 'dc_current1' uid: '' + - type: 'dc_energy_total1' + uid: '' + - type: 'dc_energy_daily1' + uid: 'c3c04df0-9a4e-11ed-82c6-a15a9aba54a3' + - type: 'dc_irradiation1' + uid: 'c3f3efd0-9a4e-11ed-9a77-3fd3187e6237' + - type: 'temperature' + uid: 'ad578a40-1d97-11ed-8e8b-fda01a416575' + - type: 'powerfactor' + uid: '' + - type: 'yield_total' + uid: '' + - type: 'yield_today' + uid: 'c4a76dd0-9a4e-11ed-b79f-2de013d39150' + - type: 'efficiency' + uid: 'c4d8e9c0-9a4e-11ed-9d9e-9737749e4b45' dtu: serial: 99978563001 diff --git a/tools/rpi/hoymiles/__init__.py b/tools/rpi/hoymiles/__init__.py index 169ad732..74ec9fd8 100644 --- a/tools/rpi/hoymiles/__init__.py +++ b/tools/rpi/hoymiles/__init__.py @@ -158,6 +158,9 @@ class ResponseDecoder(ResponseDecoderFactory): model = self.inverter_model command = self.request_command + c_datetime = self.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f") + logging.info(f'{c_datetime} model_decoder: {model}Decode{command.upper()}') + model_decoders = __import__('hoymiles.decoders') if hasattr(model_decoders, f'{model}Decode{command.upper()}'): device = getattr(model_decoders, f'{model}Decode{command.upper()}') diff --git a/tools/rpi/hoymiles/__main__.py b/tools/rpi/hoymiles/__main__.py index d9e35df1..97773ed1 100644 --- a/tools/rpi/hoymiles/__main__.py +++ b/tools/rpi/hoymiles/__main__.py @@ -17,10 +17,31 @@ from suntimes import SunTimes import argparse import yaml from yaml.loader import SafeLoader -import paho.mqtt.client import hoymiles import logging +################################################################################ +""" Signal Handler """ +################################################################################ +# from signal import signal, Signals, SIGINT, SIGTERM, SIGKILL, SIGHUP +from signal import * +def signal_handler(sig_num, frame): + signame = Signals(sig_num).name + logging.info(f'Stop by Signal {signame} ({sig_num})') + print (f'Stop by Signal <{signame}> ({sig_num}) at: {time.strftime("%d.%m.%Y %H:%M:%S")}') + + if mqtt_client: + mqtt_client.disco() + + sys.exit(0) + +signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C) +signal(SIGTERM, signal_handler) # Signal Handler from terminating processes +signal(SIGHUP, signal_handler) # Hangup detected on controlling terminal or death of controlling process +# signal(SIGKILL, signal_handler) # Signal Handler SIGKILL and SIGSTOP cannot be caught, blocked, or ignored!! +################################################################################ +################################################################################ + class InfoCommands(IntEnum): InverterDevInform_Simple = 0 # 0x00 InverterDevInform_All = 1 # 0x01 @@ -81,8 +102,8 @@ def main_loop(ahoy_config): sunset = SunsetHandler(ahoy_config.get('sunset')) dtu_ser = ahoy_config.get('dtu', {}).get('serial') - loop_interval = ahoy_config.get('interval', 1) + try: do_init = True while True: @@ -92,7 +113,7 @@ def main_loop(ahoy_config): for inverter in inverters: if hoymiles.HOYMILES_DEBUG_LOGGING: - logging.debug(f'Poll inverter {inverter["serial"]}') + logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}') poll_inverter(inverter, dtu_ser, do_init, 3) do_init = False @@ -161,6 +182,8 @@ def poll_inverter(inverter, dtu_ser, do_init, retries): c_datetime = datetime.now() if hoymiles.HOYMILES_DEBUG_LOGGING: logging.debug(f'{c_datetime} Payload: ' + hoymiles.hexify_payload(response)) + + # prepare decoder object decoder = hoymiles.ResponseDecoder(response, request=com.request, inverter_ser=inverter_ser, @@ -168,71 +191,35 @@ def poll_inverter(inverter, dtu_ser, do_init, retries): dtu_ser=dtu_ser, strings=inverter_strings ) + + # get decoder object result = decoder.decode() - if isinstance(result, hoymiles.decoders.StatusResponse): - data = result.__dict__() + if hoymiles.HOYMILES_DEBUG_LOGGING: + logging.info(f'{c_datetime} Decoded: {result.__dict__()}') - if hoymiles.HOYMILES_DEBUG_LOGGING: - logging.debug(f'{c_datetime} Decoded: {result.__dict__()}') + # check decoder object for output + if isinstance(result, hoymiles.decoders.StatusResponse): + data = result.__dict__() if 'event_count' in data: if event_message_index[inv_str] < data['event_count']: event_message_index[inv_str] = data['event_count'] command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.AlarmData, alarm_id=event_message_index[inv_str])) if mqtt_client: - # mqtt_send_status(mqtt_client, inverter_ser, data, topic=inverter.get('mqtt', {}).get('topic', None)) - mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None)) + mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None)) if influx_client: - influx_client.store_status(result) + influx_client.store_status(result) if volkszaehler_client: - volkszaehler_client.store_status(result) - -def mqtt_send_status(broker, inverter_ser, data, topic=None): - """ - Publish StatusResponse object + volkszaehler_client.store_status(result) - :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 - """ + # check decoder object for output + if isinstance(result, hoymiles.decoders.HardwareInfoResponse): + if mqtt_client: + mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None)) - if not topic: - topic = f'hoymiles/{inverter_ser}' - - # Global Head - if data['time'] is not None: - broker.publish(f'{topic}/time', data['time'].strftime("%d.%m.%y - %H:%M:%S")) - - # AC Data - phase_id = 0 - for phase in data['phases']: - broker.publish(f'{topic}/emeter/{phase_id}/power', phase['power']) - broker.publish(f'{topic}/emeter/{phase_id}/voltage', phase['voltage']) - broker.publish(f'{topic}/emeter/{phase_id}/current', phase['current']) - broker.publish(f'{topic}/emeter/{phase_id}/Q_AC', phase['reactive_power']) - phase_id = phase_id + 1 - - # DC Data - string_id = 0 - for string in data['strings']: - broker.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage']) - broker.publish(f'{topic}/emeter-dc/{string_id}/current', string['current']) - broker.publish(f'{topic}/emeter-dc/{string_id}/power', string['power']) - broker.publish(f'{topic}/emeter-dc/{string_id}/YieldDay', string['energy_daily']) - broker.publish(f'{topic}/emeter-dc/{string_id}/YieldTotal', string['energy_total']/1000) - string_id = string_id + 1 - # Global - if data['powerfactor'] is not None: - broker.publish(f'{topic}/pf', data['powerfactor']) - broker.publish(f'{topic}/frequency', data['frequency']) - broker.publish(f'{topic}/temperature', data['temperature']) - if data['energy_total'] is not None: - broker.publish(f'{topic}/total', data['energy_total']/1000) def mqtt_on_command(client, userdata, message): """ @@ -318,32 +305,30 @@ if __name__ == '__main__': logging.error("Could not load config file. Try --help") sys.exit(2) except yaml.YAMLError as e_yaml: - logging.error('Failed to load config file {global_config.config_file}: {e_yaml}') + logging.error(f'Failed to load config file {global_config.config_file}: {e_yaml}') sys.exit(1) + # read AHOY configuration file and prepare logging ahoy_config = dict(cfg.get('ahoy', {})) init_logging(ahoy_config) - # Prepare for multiple transceivers, makes them configurable (currently - # only one supported) - for radio_config in ahoy_config.get('nrf', [{}]): - hmradio = hoymiles.HoymilesNRF(**radio_config) - - event_message_index = {} - command_queue = {} - mqtt_command_topic_subs = [] - if global_config.log_transactions: hoymiles.HOYMILES_TRANSACTION_LOGGING=True if global_config.verbose: hoymiles.HOYMILES_DEBUG_LOGGING=True + # Prepare for multiple transceivers, makes them configurable + for radio_config in ahoy_config.get('nrf', [{}]): + hmradio = hoymiles.HoymilesNRF(**radio_config) + + # create MQTT - client object mqtt_client = None mqtt_config = ahoy_config.get('mqtt', {}) if mqtt_config and not mqtt_config.get('disabled', False): from .outputs import MqttOutputPlugin mqtt_client = MqttOutputPlugin(mqtt_config) + # create INFLUX - client object influx_client = None influx_config = ahoy_config.get('influxdb', {}) if influx_config and not influx_config.get('disabled', False): @@ -355,23 +340,24 @@ if __name__ == '__main__': bucket=influx_config.get('bucket', None), measurement=influx_config.get('measurement', 'hoymiles')) + # create VOLKSZAEHLER - client object volkszaehler_client = None volkszaehler_config = ahoy_config.get('volkszaehler', {}) if volkszaehler_config and not volkszaehler_config.get('disabled', False): from .outputs import VolkszaehlerOutputPlugin - volkszaehler_client = VolkszaehlerOutputPlugin( - volkszaehler_config) + volkszaehler_client = VolkszaehlerOutputPlugin(volkszaehler_config) + + event_message_index = {} + command_queue = {} + mqtt_command_topic_subs = [] - g_inverters = [g_inverter.get('serial') for g_inverter in ahoy_config.get('inverters', [])] for g_inverter in ahoy_config.get('inverters', []): g_inverter_ser = g_inverter.get('serial') inv_str = str(g_inverter_ser) command_queue[inv_str] = [] event_message_index[inv_str] = 0 - # # Enables and subscribe inverter to mqtt /command-Topic - # if mqtt_client and g_inverter.get('mqtt', {}).get('send_raw_enabled', False): topic_item = ( str(g_inverter_ser), @@ -380,5 +366,6 @@ if __name__ == '__main__': mqtt_client.subscribe(topic_item[1]) mqtt_command_topic_subs.append(topic_item) - logging.info(f'Starting main_loop with inverter(s) {g_inverters}') + # start main-loop main_loop(ahoy_config) + diff --git a/tools/rpi/hoymiles/decoders/__init__.py b/tools/rpi/hoymiles/decoders/__init__.py index c379f2e4..b46dbe48 100644 --- a/tools/rpi/hoymiles/decoders/__init__.py +++ b/tools/rpi/hoymiles/decoders/__init__.py @@ -93,7 +93,8 @@ class Response: class StatusResponse(Response): """Inverter StatusResponse object""" - e_keys = ['voltage','current','power','energy_total','energy_daily','powerfactor', 'reactive_power', 'irradiation'] + phase_keys = ['voltage','current','power','reactive_power','frequency'] + string_keys = ['voltage','current','power','energy_total','energy_daily', 'irradiation'] temperature = None frequency = None powerfactor = None @@ -125,7 +126,7 @@ class StatusResponse(Response): p_exists = False phase_id = len(phases) phase = {} - for key in self.e_keys: + for key in self.phase_keys: prop = f'ac_{key}_{phase_id}' if hasattr(self, prop): p_exists = True @@ -149,7 +150,7 @@ class StatusResponse(Response): s_exists = False string_id = len(strings) string = {} - for key in self.e_keys: + for key in self.string_keys: prop = f'dc_{key}_{string_id}' if hasattr(self, prop): s_exists = True @@ -170,14 +171,27 @@ class StatusResponse(Response): data['phases'] = self.phases data['strings'] = self.strings data['temperature'] = self.temperature - data['frequency'] = self.frequency data['powerfactor'] = self.powerfactor - data['event_count'] = self.event_count - data['time'] = self.time_rx - data['energy_total'] = 0.0 + data['yield_total'] = 0.0 + data['yield_today'] = 0.0 for string in data['strings']: - data['energy_total'] += string['energy_total'] + data['yield_total'] += string['energy_total'] + data['yield_today'] += string['energy_daily'] + + ac_sum_power = 0.0 + for phase in data['phases']: + ac_sum_power += phase['power'] + dc_sum_power = 0.0 + for string in data['strings']: + dc_sum_power += string['power'] + if dc_sum_power != 0: + data['efficiency'] = round(ac_sum_power * 100 / dc_sum_power, 2) + else: + data['efficiency'] = 0.0 + + data['event_count'] = self.event_count + data['time'] = self.time_rx return data @@ -343,10 +357,14 @@ class HardwareInfoResponse(UnknownResponse): }; self.response = bytes('\x27\x1a\x07\xe5\x04\x4d\x03\x4a\x00\x68\x00\x00\x00\x00\xe6\xfb', 'latin1') """ - fw_version, fw_build_yyyy, fw_build_mmdd, fw_build_hhmm, hw_id = struct.unpack('>HHHHH', self.response[0:10]) + + def __dict__(self): + """ Base values, availabe in each __dict__ call """ responce_info = self.response - logging.debug(f'HardwareInfoResponse: {struct.unpack(">HHHHHHHH", responce_info)}') + logging.info(f'HardwareInfoResponse: {struct.unpack(">HHHHHHHH", responce_info)}') + + fw_version, fw_build_yyyy, fw_build_mmdd, fw_build_hhmm, hw_id = struct.unpack('>HHHHH', self.response[0:10]) fw_version_maj = int((fw_version / 10000)) fw_version_min = int((fw_version % 10000) / 100) @@ -355,10 +373,22 @@ class HardwareInfoResponse(UnknownResponse): fw_build_dd = int(fw_build_mmdd % 100) fw_build_HH = int(fw_build_hhmm / 100) fw_build_MM = int(fw_build_hhmm % 100) - logging.debug(f'Firmware: {fw_version_maj}.{fw_version_min}.{fw_version_pat} '\ + logging.info(f'Firmware: {fw_version_maj}.{fw_version_min}.{fw_version_pat} '\ f'build at {fw_build_dd:>02}/{fw_build_mm:>02}/{fw_build_yyyy}T{fw_build_HH:>02}:{fw_build_MM:>02}, '\ f'HW revision {hw_id}') + data = super().__dict__() + data['FW_ver_maj'] = fw_version_maj + data['FW_ver_min'] = fw_version_min + data['FW_ver_pat'] = fw_version_pat + data['FW_build_yy'] = fw_build_yyyy + data['FW_build_mm'] = fw_build_mm + data['FW_build_dd'] = fw_build_dd + data['FW_build_HH'] = fw_build_HH + data['FW_build_MM'] = fw_build_MM + data['FW_HW_ID'] = hw_id + return data + class DebugDecodeAny(UnknownResponse): """Default decoder""" @@ -405,10 +435,10 @@ class DebugDecodeAny(UnknownResponse): # 1121-Series Intervers, 1 MPPT, 1 Phase class Hm300Decode01(HardwareInfoResponse): - """ Firmware version / date """ + """ 1121-series Firmware version / date """ class Hm300Decode02(EventsResponse): - """ Inverter generic events log """ + """ 1121-series Inverter generic events log """ class Hm300Decode0B(StatusResponse): """ 1121-series mirco-inverters status data """ @@ -453,7 +483,7 @@ class Hm300Decode0B(StatusResponse): """ Phase 1 watts """ return self.unpack('>H', 18)[0]/10 @property - def frequency(self): + def ac_frequency_0(self): """ Grid frequency in Hertz """ return self.unpack('>H', 16)[0]/100 @property @@ -469,18 +499,18 @@ class Hm300Decode0C(Hm300Decode0B): """ 1121-series mirco-inverters status data """ class Hm300Decode11(EventsResponse): - """ Inverter generic events log """ + """ 1121-series Inverter generic events log """ class Hm300Decode12(EventsResponse): - """ Inverter major events log """ + """ 1121-series Inverter major events log """ # 1141-Series Inverters, 2 MPPT, 1 Phase class Hm600Decode01(HardwareInfoResponse): - """ Firmware version / date """ + """ 1141-Series Firmware version / date """ class Hm600Decode02(EventsResponse): - """ Inverter generic events log """ + """ 1141-Series Inverter generic events log """ class Hm600Decode0B(StatusResponse): """ 1141-series mirco-inverters status data """ @@ -552,7 +582,7 @@ class Hm600Decode0B(StatusResponse): """ Phase 1 watts """ return self.unpack('>H', 30)[0]/10 @property - def frequency(self): + def ac_frequency_0(self): """ Grid frequency in Hertz """ return self.unpack('>H', 28)[0]/100 @property @@ -576,18 +606,18 @@ class Hm600Decode0C(Hm600Decode0B): """ 1141-series mirco-inverters status data """ class Hm600Decode11(EventsResponse): - """ Inverter generic events log """ + """ 1141-Series Inverter generic events log """ class Hm600Decode12(EventsResponse): - """ Inverter major events log """ + """ 1141-Series Inverter major events log """ # 1161-Series Inverters, 2 MPPT, 1 Phase class Hm1200Decode01(HardwareInfoResponse): - """ Firmware version / date """ + """ 1161-Series Firmware version / date """ class Hm1200Decode02(EventsResponse): - """ Inverter generic events log """ + """ 1161-Series Inverter generic events log """ class Hm1200Decode0B(StatusResponse): """ 1161-series mirco-inverters status data """ @@ -713,7 +743,7 @@ class Hm1200Decode0B(StatusResponse): """ Phase 1 watts """ return self.unpack('>H', 50)[0]/10 @property - def frequency(self): + def ac_frequency_0(self): """ Grid frequency in Hertz """ return self.unpack('>H', 48)[0]/100 @property @@ -737,7 +767,7 @@ class Hm1200Decode0C(Hm1200Decode0B): """ 1161-series mirco-inverters status data """ class Hm1200Decode11(EventsResponse): - """ Inverter generic events log """ + """ 1161-Series Inverter generic events log """ class Hm1200Decode12(EventsResponse): - """ Inverter major events log """ + """ 1161-Series Inverter major events log """ diff --git a/tools/rpi/hoymiles/outputs.py b/tools/rpi/hoymiles/outputs.py index e70c3b8d..8fb55f3e 100644 --- a/tools/rpi/hoymiles/outputs.py +++ b/tools/rpi/hoymiles/outputs.py @@ -8,12 +8,7 @@ Hoymiles output plugin library import socket import logging from datetime import datetime, timezone -from hoymiles.decoders import StatusResponse - -try: - from influxdb_client import InfluxDBClient -except ModuleNotFoundError: - pass +from hoymiles.decoders import StatusResponse, HardwareInfoResponse class OutputPluginFactory: def __init__(self, **params): @@ -59,10 +54,19 @@ class InfluxOutputPlugin(OutputPluginFactory): """ 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()}') + self._measurement = params.get('measurement', f'inverter,host={socket.gethostname()}') client = InfluxDBClient(url, token, bucket=self._bucket) self.api = client.write_api() @@ -105,6 +109,7 @@ class InfluxOutputPlugin(OutputPluginFactory): 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 @@ -115,28 +120,23 @@ class InfluxOutputPlugin(OutputPluginFactory): 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}') - if 'irradiation' in string: - data_stack.append(f'{measurement},string={string_id},type=Irradiation value={string["irradiation"]:.2f} {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 value={data["powerfactor"]:f} {ctime}') - data_stack.append(f'{measurement},type=frequency value={data["frequency"]:.3f} {ctime}') - + 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['energy_total'] is not None: - data_stack.append(f'{measurement},type=total value={data["energy_total"]/1000:.3f} {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}') self.api.write(self._bucket, self._org, data_stack) -try: - import paho.mqtt.client -except ModuleNotFoundError: - pass - class MqttOutputPlugin(OutputPluginFactory): """ Mqtt output plugin """ client = None @@ -164,15 +164,38 @@ class MqttOutputPlugin(OutputPluginFactory): """ 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 def store_status(self, response, **params): """ @@ -185,51 +208,66 @@ class MqttOutputPlugin(OutputPluginFactory): :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__() topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}' - # Global Head - if data['time'] is not None: - self.client.publish(f'{topic}/time', data['time'].strftime("%d.%m.%y - %H:%M:%S")) - - # AC Data - phase_id = 0 - for phase in data['phases']: - self.client.publish(f'{topic}/emeter/{phase_id}/power', phase['power']) - self.client.publish(f'{topic}/emeter/{phase_id}/voltage', phase['voltage']) - self.client.publish(f'{topic}/emeter/{phase_id}/current', phase['current']) - self.client.publish(f'{topic}/emeter/{phase_id}/Q_AC', phase['reactive_power']) - phase_id = phase_id + 1 - - # DC Data - string_id = 0 - for string in data['strings']: - self.client.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage']) - self.client.publish(f'{topic}/emeter-dc/{string_id}/current', string['current']) - self.client.publish(f'{topic}/emeter-dc/{string_id}/power', string['power']) - self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldDay', string['energy_daily']) - self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldTotal', string['energy_total']/1000) - if 'irradiation' in string: - self.client.publish(f'{topic}/emeter-dc/{string_id}/Irradiation', string['irradiation']) - string_id = string_id + 1 - - # Global - if data['powerfactor'] is not None: - self.client.publish(f'{topic}/pf', data['powerfactor']) - self.client.publish(f'{topic}/frequency', data['frequency']) - - self.client.publish(f'{topic}/Temp', data['temperature']) - if data['energy_total'] is not None: - self.client.publish(f'{topic}/total', data['energy_total']/1000) - -try: - import requests - import time -except ModuleNotFoundError: - pass + if isinstance(response, StatusResponse): + + # Global Head + if data['time'] is not None: + self.client.publish(f'{topic}/time', data['time'].strftime("%d.%m.%y - %H:%M:%S"), self.qos, self.ret) + + # AC Data + phase_id = 0 + phase_sum_power = 0 + 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 + for string in data['strings']: + self.client.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage'], self.qos, self.ret) + self.client.publish(f'{topic}/emeter-dc/{string_id}/current', string['current'], self.qos, self.ret) + self.client.publish(f'{topic}/emeter-dc/{string_id}/power', string['power'], self.qos, self.ret) + self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldDay', string['energy_daily'], self.qos, self.ret) + self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldTotal', string['energy_total']/1000, self.qos, self.ret) + self.client.publish(f'{topic}/emeter-dc/{string_id}/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) + self.client.publish(f'{topic}/Efficiency', data['efficiency'], self.qos, self.ret) + + + elif isinstance(response, HardwareInfoResponse): + self.client.publish(f'{topic}/Firmware/Version',\ + f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}', self.qos, self.ret) + + 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) + + 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): @@ -237,6 +275,7 @@ class VzInverterOutput: 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') ctype = channel.get('type') @@ -262,7 +301,8 @@ class VzInverterOutput: 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_Q{phase_id}', phase['reactive_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 @@ -271,32 +311,39 @@ class VzInverterOutput: 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_YieldDay{string_id}', string['energy_daily']) - self.try_publish(ts, f'dc_YieldTotal{string_id}', string['energy_total']) - if 'irradiation' in string: - self.try_publish(ts, f'dc_Irradiation{string_id}', string['irradiation']) + 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'frequency', data['frequency']) - - self.try_publish(ts, f'Temp', data['temperature']) - if data['energy_total'] is not None: - self.try_publish(ts, f'total', data['energy_total']) + 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']) def try_publish(self, ts, ctype, value): if not ctype in self.channels: + 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}' try: r = self.session.get(url) - if r.status_code != 200: - raise ValueError('Could not send request (%s)' % url) - except requests.exceptions.ConnectionError as e: - raise ValueError('Could not send request (%s)' % e) + 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()}') class VolkszaehlerOutputPlugin(OutputPluginFactory): def __init__(self, config, **params): @@ -305,8 +352,20 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory): """ 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) @@ -320,6 +379,8 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory): :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')