From 0ee867993ca4466c85d8cd090c7acc0630eff148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Jonas=20S=C3=A4mann?= Date: Fri, 6 May 2022 19:54:04 +0200 Subject: [PATCH] MQTT payload injection and default unknown decoder Adds the ability to directly inject payloads to be sent to the inverter. Fixes application crash at missing decoder by adding default decoding. All unknown payloads are now printed as long- and short-lists for faster protocol analysis --- tools/rpi/README.md | 30 +++++++++++ tools/rpi/ahoy.py | 61 +++++++++++++++++++--- tools/rpi/ahoy.yml.example | 1 + tools/rpi/hoymiles/__init__.py | 8 ++- tools/rpi/hoymiles/decoders/__init__.py | 68 ++++++++++++++++++++----- 5 files changed, 146 insertions(+), 22 deletions(-) diff --git a/tools/rpi/README.md b/tools/rpi/README.md index f18f5d0f..e4c3bd40 100644 --- a/tools/rpi/README.md +++ b/tools/rpi/README.md @@ -44,6 +44,34 @@ Whenever it sees a reply, it will decoded and logged to the given log file. +Inject payloads via MQTT +------------------------ + +To enable mqtt payload injection, this must be configured per inverter +```yaml +... + inverters: +... + - serial: 1147112345 + mqtt: + send_raw_enabled: true +... +``` + +This can be used to inject debug payloads +The message must be in hexlified format + +Use of variables: + * tttttttt expands to current time like we know from our `80 0b` command + +Example injects exactly the same as we normally use to poll data + + $ mosquitto_pub -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000 + +This allows for even faster hacking during runtime + + + Analysing the Logs ------------------ @@ -68,6 +96,8 @@ Configuration Local settings are read from ahoy.yml An example is provided as ahoy.yml.example + + Todo ---- diff --git a/tools/rpi/ahoy.py b/tools/rpi/ahoy.py index 19aabdb2..19812808 100644 --- a/tools/rpi/ahoy.py +++ b/tools/rpi/ahoy.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- import sys +import struct +import re import time from datetime import datetime import argparse @@ -28,6 +30,7 @@ hmradio = hoymiles.HoymilesNRF(device=radio) mqtt_client = None command_queue = {} +mqtt_command_topic_subs = [] hoymiles.HOYMILES_TRANSACTION_LOGGING=True hoymiles.HOYMILES_DEBUG_LOGGING=True @@ -125,13 +128,46 @@ def mqtt_send_status(broker, inverter_ser, data, topic=None): broker.publish(f'{topic}/frequency', data['frequency']) broker.publish(f'{topic}/temperature', data['temperature']) -def mqtt_on_command(): +def mqtt_on_command(client, userdata, message): """ Handle commands to topic hoymiles/{inverter_ser}/command - frame it and put onto command_queue + 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 """ - raise NotImplementedError('Receiving mqtt commands is yet to be implemented') + 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__': ahoy_config = dict(cfg.get('ahoy', {})) @@ -142,21 +178,32 @@ if __name__ == '__main__': 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 if not radio.begin(): raise RuntimeError('Can\'t open radio') - #command_queue.append(hoymiles.compose_02_payload()) - #command_queue.append(hoymiles.compose_11_payload()) - inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])] - for inverter_ser in inverters: + for inverter in ahoy_config.get('inverters', []): + inverter_ser = inverter.get('serial') command_queue[str(inverter_ser)] = [] + # + # Enables and subscribe inverter to mqtt /command-Topic + # + if inverter.get('mqtt', {}).get('send_raw_enabled', False): + topic_item = ( + str(inverter_ser), + inverter.get('mqtt', {}).get('topic', f'hoymiles/{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: main_loop() + if loop_interval: time.sleep(time.time() % loop_interval) diff --git a/tools/rpi/ahoy.yml.example b/tools/rpi/ahoy.yml.example index bcab9693..4e2cb586 100644 --- a/tools/rpi/ahoy.yml.example +++ b/tools/rpi/ahoy.yml.example @@ -17,4 +17,5 @@ ahoy: - name: 'balkon' serial: 114172220003 mqtt: + send_raw_enabled: false # allow inject debug data via mqtt topic: 'hoymiles/114172221234' # defaults to 'hoymiles/{serial}' diff --git a/tools/rpi/hoymiles/__init__.py b/tools/rpi/hoymiles/__init__.py index d1b0ae13..4c0a7c4a 100644 --- a/tools/rpi/hoymiles/__init__.py +++ b/tools/rpi/hoymiles/__init__.py @@ -104,8 +104,12 @@ class ResponseDecoder(ResponseDecoderFactory): model = self.inverter_model command = self.request_command - model_decoder = __import__(f'hoymiles.decoders') - device = getattr(model_decoder, f'{model}_Decode{command.upper()}') + model_decoders = __import__(f'hoymiles.decoders') + if hasattr(model_decoders, f'{model}_Decode{command.upper()}'): + device = getattr(model_decoders, f'{model}_Decode{command.upper()}') + else: + if HOYMILES_DEBUG_LOGGING: + device = getattr(model_decoders, f'DEBUG_DecodeAny') return device(self.response) diff --git a/tools/rpi/hoymiles/decoders/__init__.py b/tools/rpi/hoymiles/decoders/__init__.py index 3cef0946..0353d07d 100644 --- a/tools/rpi/hoymiles/decoders/__init__.py +++ b/tools/rpi/hoymiles/decoders/__init__.py @@ -57,33 +57,75 @@ class UnknownResponse: @property def hex_ascii(self): return ' '.join([f'{b:02x}' for b in self.response]) + @property def dump_longs(self): - n = len(self.response)/4 - vals = struct.unpack(f'>{int(n)}L', self.response) + res = self.response + n = len(res)/4 + + vals = None + if n % 4 == 0: + vals = struct.unpack(f'>{int(n)}L', res) + + return vals + + @property + def dump_longs_pad1(self): + res = self.response[1:] + n = len(res)/4 + + vals = None + if n % 4 == 0: + vals = struct.unpack(f'>{int(n)}L', res) + return vals @property def dump_shorts(self): n = len(self.response)/2 - vals = struct.unpack(f'>{int(n)}H', self.response) + + vals = None + if n % 2 == 0: + vals = struct.unpack(f'>{int(n)}H', self.response) return vals -class HM600_Decode02(UnknownResponse): - def __init__(self, response): - self.response = response + @property + def dump_shorts_pad1(self): + res = self.response[1:] + n = len(res)/2 -class HM600_Decode11(UnknownResponse): - def __init__(self, response): - self.response = response + vals = None + if n % 2 == 0: + vals = struct.unpack(f'>{int(n)}H', res) + return vals -class HM600_Decode12(UnknownResponse): +class DEBUG_DecodeAny(UnknownResponse): def __init__(self, response): self.response = response -class HM600_Decode0A(UnknownResponse): - def __init__(self, response): - self.response = response + longs = self.dump_longs + if not longs: + print(' type long : unable to decode (len or not mod 4)') + else: + print(' type long : ' + str(longs)) + + longs = self.dump_longs_pad1 + if not longs: + print(' type long pad1 : unable to decode (len or not mod 4)') + else: + print(' type long pad1 : ' + str(longs)) + + shorts = self.dump_shorts + if not shorts: + print(' type short : unable to decode (len or not mod 2)') + else: + print(' type short : ' + str(shorts)) + + shorts = self.dump_shorts_pad1 + if not shorts: + print(' type short pad1: unable to decode (len or not mod 2)') + else: + print(' type short pad1: ' + str(shorts)) class HM600_Decode0B(StatusResponse): def __init__(self, response):