From d27f0c1148816f7631d0cd3d789a9cfd835a51c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Jonas=20S=C3=A4mann?= Date: Wed, 11 May 2022 00:05:09 +0200 Subject: [PATCH] Integrate ahoy.py into hoymiles module Finally get rid of ahoy.py and integrate functionallity into the module itself. Prepares for pipelines, adding pip installer or debian packaging. Improve configuration adds commandline switches for: * `--verbose, enabling verbose logging * `--log-transactions`, outbut all rf raw data Improve loop, now runs all queued commands per inverter within interval. Skip sleep when interval is allready due. --- tools/rpi/README.md | 21 ++- tools/rpi/ahoy.yml.example | 6 + tools/rpi/hoymiles/__init__.py | 4 +- tools/rpi/{ahoy.py => hoymiles/__main__.py} | 173 ++++++++++++-------- 4 files changed, 129 insertions(+), 75 deletions(-) rename tools/rpi/{ahoy.py => hoymiles/__main__.py} (52%) diff --git a/tools/rpi/README.md b/tools/rpi/README.md index e4c3bd40..3e5001ea 100644 --- a/tools/rpi/README.md +++ b/tools/rpi/README.md @@ -40,8 +40,27 @@ contact the inverter every second on channel 40, and listen for replies. Whenever it sees a reply, it will decoded and logged to the given log file. - $ sudo python3 ahoy.py --config /home/dtu/ahoy.yml | tee -a log2.log + $ sudo python3 -um hoymiles --log-transactions --verbose --config /home/dtu/ahoy.yml | tee -a log2.log +Python parameters +- `-u` enables python's unbuffered mode +- `-m hoymiles` tells python to load module 'hoymiles' as main app + + +The application describes itself +``` +python -m hoymiles --help +usage: hoymiles [-h] -c [CONFIG_FILE] [--log-transactions] [--verbose] + +Ahoy - Hoymiles solar inverter gateway + +optional arguments: + -h, --help show this help message and exit + -c [CONFIG_FILE], --config-file [CONFIG_FILE] + configuration file + --log-transactions Enable transaction logging output + --verbose Enable debug output +``` Inject payloads via MQTT diff --git a/tools/rpi/ahoy.yml.example b/tools/rpi/ahoy.yml.example index 4e2cb586..5c27c003 100644 --- a/tools/rpi/ahoy.yml.example +++ b/tools/rpi/ahoy.yml.example @@ -3,6 +3,12 @@ ahoy: interval: 0 sunset: true + + # List of available NRF24 transceivers + nrf: + - ce_pin: 22 + cs_pin: 0 + mqtt: disabled: false host: example-broker.local diff --git a/tools/rpi/hoymiles/__init__.py b/tools/rpi/hoymiles/__init__.py index ba3bd143..cc65107d 100644 --- a/tools/rpi/hoymiles/__init__.py +++ b/tools/rpi/hoymiles/__init__.py @@ -11,8 +11,8 @@ f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) -HOYMILES_TRANSACTION_LOGGING=True -HOYMILES_DEBUG_LOGGING=True +HOYMILES_TRANSACTION_LOGGING=False +HOYMILES_DEBUG_LOGGING=False def ser_to_hm_addr(s): """ diff --git a/tools/rpi/ahoy.py b/tools/rpi/hoymiles/__main__.py similarity index 52% rename from tools/rpi/ahoy.py rename to tools/rpi/hoymiles/__main__.py index 19812808..945226b8 100644 --- a/tools/rpi/ahoy.py +++ b/tools/rpi/hoymiles/__main__.py @@ -13,28 +13,6 @@ import paho.mqtt.client import yaml from yaml.loader import SafeLoader -parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway') -parser.add_argument("-c", "--config-file", nargs="?", - help="configuration file") -global_config = parser.parse_args() - -if global_config.config_file: - with open(global_config.config_file) as yf: - cfg = yaml.load(yf, Loader=SafeLoader) -else: - with open(global_config.config_file) as yf: - cfg = yaml.load('ahoy.yml', Loader=SafeLoader) - -radio = RF24(22, 0, 1000000) -hmradio = hoymiles.HoymilesNRF(device=radio) -mqtt_client = None - -command_queue = {} -mqtt_command_topic_subs = [] - -hoymiles.HOYMILES_TRANSACTION_LOGGING=True -hoymiles.HOYMILES_DEBUG_LOGGING=True - def main_loop(): inverters = [ inverter for inverter in ahoy_config.get('inverters', []) @@ -45,62 +23,66 @@ def main_loop(): print(f'Poll inverter {inverter["serial"]}') poll_inverter(inverter) -def poll_inverter(inverter): +def poll_inverter(inverter, retries=4): inverter_ser = inverter.get('serial') dtu_ser = ahoy_config.get('dtu', {}).get('serial') - if len(command_queue[str(inverter_ser)]) > 0: + # 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) - else: - payload = hoymiles.compose_set_time_payload() - payload_ttl = 4 - while payload_ttl > 0: - payload_ttl = payload_ttl - 1 - com = hoymiles.InverterTransaction( - radio=hmradio, - 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: - print(f'Error while retrieving data: {e}') - pass + # 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, + 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: + print(f'Error while retrieving data: {e}') + pass - if response: - dt = datetime.now() - print(f'{dt} 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'{dt} Decoded: {data["temperature"]}', 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() + # Handle the response data if any + if response: + dt = datetime.now() + print(f'{dt} 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'{dt} Decoded: {data["temperature"]}', 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 mqtt_client: + mqtt_send_status(mqtt_client, inverter_ser, data, + topic=inverter.get('mqtt', {}).get('topic', None)) def mqtt_send_status(broker, inverter_ser, data, topic=None): """ Publish StatusResponse object """ @@ -170,8 +152,51 @@ def mqtt_on_command(client, userdata, message): 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) == True: + with open(global_config.config_file, 'r') as yf: + cfg = yaml.load(yf, Loader=SafeLoader) + else: + with open('ahoy.yml', 'r') as yf: + cfg = yaml.load(yf, Loader=SafeLoader) + except FileNotFoundError: + print("Could not load config file. Try --help") + sys.exit(2) + except yaml.YAMLError as ye: + print('Failed to load config frile {global_config.config_file}: {ye}') + 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', [{}]): + radio = RF24( + radio_config.get('ce_pin', 22), + radio_config.get('cs_pin', 0), + radio_config.get('spispeed', 1000000)) + hmradio = hoymiles.HoymilesNRF(device=radio) + + 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() @@ -202,9 +227,13 @@ if __name__ == '__main__': loop_interval = ahoy_config.get('interval', 1) try: while True: + t_loop_start = time.time() + main_loop() - if loop_interval: + print('', end='', flush=True) + + if loop_interval > 0 and (time.time() - t_loop_start) < loop_interval: time.sleep(time.time() % loop_interval) except KeyboardInterrupt: