#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Hoymiles micro-inverters main application """ import sys import struct from enum import IntEnum import re import time from datetime import datetime import argparse import yaml from yaml.loader import SafeLoader import paho.mqtt.client import hoymiles def main_loop(do_init): """Main loop""" inverters = [ inverter for inverter in ahoy_config.get('inverters', []) if not inverter.get('disabled', False)] for inverter in inverters: if hoymiles.HOYMILES_DEBUG_LOGGING: print(f'Poll inverter {inverter["serial"]}') poll_inverter(inverter, do_init) class InfoCommands(IntEnum): InverterDevInform_Simple = 0 # 0x00 InverterDevInform_All = 1 # 0x01 GridOnProFilePara = 2 # 0x02 HardWareConfig = 3 # 0x03 SimpleCalibrationPara = 4 # 0x04 SystemConfigPara = 5 # 0x05 RealTimeRunData_Debug = 11 # 0x0b RealTimeRunData_Reality = 12 # 0x0c RealTimeRunData_A_Phase = 13 # 0x0d RealTimeRunData_B_Phase = 14 # 0x0e RealTimeRunData_C_Phase = 15 # 0x0f AlarmData = 17 # 0x11, Alarm data - all unsent alarms AlarmUpdate = 18 # 0x12, Alarm data - all pending alarms RecordData = 19 # 0x13 InternalData = 20 # 0x14 GetLossRate = 21 # 0x15 GetSelfCheckState = 30 # 0x1e InitDataState = 0xff def poll_inverter(inverter, do_init, retries=4): """ Send/Receive command_queue, initiate status poll on inverter :param str inverter: inverter serial :param retries: tx retry count if no inverter contact :type retries: int """ inverter_ser = inverter.get('serial') dtu_ser = ahoy_config.get('dtu', {}).get('serial') # Queue at least status data request inv_str = str(inverter_ser) if do_init: command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.InverterDevInform_All)) # command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.SystemConfigPara)) command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.RealTimeRunData_Debug)) # Put all queued commands for current inverter on air while len(command_queue[inv_str]) > 0: payload = command_queue[inv_str].pop(0) # Send payload {ttl}-times until we get at least one reponse payload_ttl = retries while payload_ttl > 0: payload_ttl = payload_ttl - 1 com = hoymiles.InverterTransaction( radio=hmradio, txpower=inverter.get('txpower', None), dtu_ser=dtu_ser, inverter_ser=inverter_ser, request=next(hoymiles.compose_esb_packet( payload, seq=b'\x80', src=dtu_ser, dst=inverter_ser ))) response = None while com.rxtx(): try: response = com.get_payload() payload_ttl = 0 except Exception as e_all: print(f'Error while retrieving data: {e_all}') pass # Handle the response data if any if response: c_datetime = datetime.now() if hoymiles.HOYMILES_DEBUG_LOGGING: print(f'{c_datetime} Payload: ' + hoymiles.hexify_payload(response)) decoder = hoymiles.ResponseDecoder(response, request=com.request, inverter_ser=inverter_ser ) result = decoder.decode() if isinstance(result, hoymiles.decoders.StatusResponse): data = result.__dict__() if hoymiles.HOYMILES_DEBUG_LOGGING: print(f'{c_datetime} Decoded: temp={data["temperature"]}', end='') if data['powerfactor'] is not None: print(f', pf={data["powerfactor"]}', end='') phase_id = 0 for phase in data['phases']: print(f' phase{phase_id}=voltage:{phase["voltage"]}, current:{phase["current"]}, power:{phase["power"]}, frequency:{data["frequency"]}', end='') phase_id = phase_id + 1 string_id = 0 for string in data['strings']: print(f' string{string_id}=voltage:{string["voltage"]}, current:{string["current"]}, power:{string["power"]}, total:{string["energy_total"]/1000}, daily:{string["energy_daily"]}', end='') string_id = string_id + 1 print() 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)) if influx_client: 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 :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 """ if not topic: topic = f'hoymiles/{inverter_ser}' # 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']) phase_id = phase_id + 1 # DC Data string_id = 0 for string in data['strings']: broker.publish(f'{topic}/emeter-dc/{string_id}/total', string['energy_total']/1000) broker.publish(f'{topic}/emeter-dc/{string_id}/power', string['power']) broker.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage']) broker.publish(f'{topic}/emeter-dc/{string_id}/current', string['current']) 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']) def mqtt_on_command(client, userdata, message): """ Handle commands to topic hoymiles/{inverter_ser}/command frame a payload and put onto command_queue Inverters must have mqtt.send_raw_enabled: true configured This can be used to inject debug payloads The message must be in hexlified format Use of variables: tttttttt gets expanded to a current int(time) Example injects exactly the same as we normally use to poll data: mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000 This allows for even faster hacking during runtime :param paho.mqtt.client.Client client: mqtt-client instance :param dict userdata: Userdata :param dict message: mqtt-client message object """ try: inverter_ser = next( item[0] for item in mqtt_command_topic_subs if item[1] == message.topic) except StopIteration: print('Unexpedtedly received mqtt message for {message.topic}') if inverter_ser: p_message = message.payload.decode('utf-8').lower() # Expand tttttttt to current time for use in hexlified payload expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time()))) p_message = p_message.replace('tttttttt', expand_time) if (len(p_message) < 2048 \ and len(p_message) % 2 == 0 \ and re.match(r'^[a-f0-9]+$', p_message)): payload = bytes.fromhex(p_message) # commands must start with \x80 if payload[0] == 0x80: command_queue[str(inverter_ser)].append( hoymiles.frame_payload(payload[1:])) if __name__ == '__main__': parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles") parser.add_argument("-c", "--config-file", nargs="?", required=True, help="configuration file") parser.add_argument("--log-transactions", action="store_true", default=False, help="Enable transaction logging output") parser.add_argument("--verbose", action="store_true", default=False, help="Enable debug output") global_config = parser.parse_args() # Load ahoy.yml config file try: if isinstance(global_config.config_file, str): with open(global_config.config_file, 'r') as fh_yaml: cfg = yaml.load(fh_yaml, Loader=SafeLoader) else: with open('ahoy.yml', 'r') as fh_yaml: cfg = yaml.load(fh_yaml, Loader=SafeLoader) except FileNotFoundError: print("Could not load config file. Try --help") sys.exit(2) except yaml.YAMLError as e_yaml: print('Failed to load config frile {global_config.config_file}: {e_yaml}') sys.exit(1) ahoy_config = dict(cfg.get('ahoy', {})) # Prepare for multiple transceivers, makes them configurable (currently # only one supported) for radio_config in ahoy_config.get('nrf', [{}]): hmradio = hoymiles.HoymilesNRF(**radio_config) mqtt_client = None 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 mqtt_config = ahoy_config.get('mqtt', []) if not mqtt_config.get('disabled', False): mqtt_client = paho.mqtt.client.Client() if mqtt_config.get('useTLS',False): mqtt_client.tls_set() mqtt_client.tls_insecure_set(mqtt_config.get('insecureTLS',False)) mqtt_client.username_pw_set(mqtt_config.get('user', None), mqtt_config.get('password', None)) mqtt_client.connect(mqtt_config.get('host', '127.0.0.1'), mqtt_config.get('port', 1883)) mqtt_client.loop_start() mqtt_client.on_message = mqtt_on_command influx_client = None influx_config = ahoy_config.get('influxdb', {}) if influx_config and not influx_config.get('disabled', False): from .outputs import InfluxOutputPlugin influx_client = InfluxOutputPlugin( influx_config.get('url'), influx_config.get('token'), org=influx_config.get('org', ''), bucket=influx_config.get('bucket', None), measurement=influx_config.get('measurement', 'hoymiles')) 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) 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), g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{g_inverter_ser}') + '/command' ) mqtt_client.subscribe(topic_item[1]) mqtt_command_topic_subs.append(topic_item) loop_interval = ahoy_config.get('interval', 1) try: do_init = True while True: t_loop_start = time.time() main_loop(do_init) do_init = False print('', end='', flush=True) if loop_interval > 0 and (time.time() - t_loop_start) < loop_interval: time.sleep(loop_interval - (time.time() - t_loop_start)) except KeyboardInterrupt: sys.exit() except Exception as e: print ('Exception catched: %s' % e) raise