#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Hoymiles micro-inverters main application """ import sys import struct 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(): """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) def poll_inverter(inverter, 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 command_queue[str(inverter_ser)].append(hoymiles.compose_set_time_payload()) # Putt all queued commands for current inverter on air while len(command_queue[str(inverter_ser)]) > 0: payload = command_queue[str(inverter_ser)].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() 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 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 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') command_queue[str(g_inverter_ser)] = [] # # 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: while True: t_loop_start = time.time() main_loop() 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()