diff --git a/tools/rpi/README.md b/tools/rpi/README.md index 867f5d23..f18f5d0f 100644 --- a/tools/rpi/README.md +++ b/tools/rpi/README.md @@ -40,7 +40,7 @@ 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 | tee -a log2.log + $ sudo python3 ahoy.py --config /home/dtu/ahoy.yml | tee -a log2.log @@ -65,8 +65,8 @@ A brief example log is supplied in the `example-logs` folder. Configuration ------------- -Local settings are read from ~/ahoy.conf -An example is provided as ahoy.conf.example +Local settings are read from ahoy.yml +An example is provided as ahoy.yml.example Todo ---- diff --git a/tools/rpi/ahoy.conf.example b/tools/rpi/ahoy.conf.example deleted file mode 100644 index 8aba8d0e..00000000 --- a/tools/rpi/ahoy.conf.example +++ /dev/null @@ -1,11 +0,0 @@ -[mqtt] -host = 192.168.84.2 -port = 1883 -user = bla -password = blub - -[dtu] -serial = 99978563412 - -[inverter] -serial = 444473104619 diff --git a/tools/rpi/ahoy.py b/tools/rpi/ahoy.py index 490a70c9..4bfe7932 100644 --- a/tools/rpi/ahoy.py +++ b/tools/rpi/ahoy.py @@ -1,239 +1,162 @@ -""" -First attempt at providing basic 'master' ('DTU') functionality -for Hoymiles micro inverters. -Based in particular on demostrated first contact by 'of22'. -""" +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + import sys -import argparse import time -import struct -import crcmod -import json from datetime import datetime +import argparse +import hoymiles from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16 import paho.mqtt.client -from configparser import ConfigParser -#from hoymiles import ser_to_hm_addr, ser_to_esb_addr -import hoymiles - -cfg = ConfigParser() -cfg.read('ahoy.conf') -mqtt_host = cfg.get('mqtt', 'host', fallback='192.168.1.1') -mqtt_port = cfg.getint('mqtt', 'port', fallback=1883) -mqtt_user = cfg.get('mqtt', 'user', fallback='') -mqtt_password = cfg.get('mqtt', 'password', fallback='') +import yaml +from yaml.loader import SafeLoader -radio = RF24(22, 0, 1000000) -mqtt_client = paho.mqtt.client.Client() -mqtt_client.username_pw_set(mqtt_user, mqtt_password) -mqtt_client.connect(mqtt_host, mqtt_port) -mqtt_client.loop_start() +parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway') +parser.add_argument("-c", "--config-file", nargs="?", + help="configuration file") +global_config = parser.parse_args() -# Master Address ('DTU') -dtu_ser = cfg.get('dtu', 'serial', fallback='99978563412') # identical to fc22's +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) -# inverter serial numbers -inv_ser = cfg.get('inverter', 'serial', fallback='444473104619') # my inverter - -# all inverters -#... +radio = RF24(22, 0, 1000000) +hmradio = hoymiles.HoymilesNRF(device=radio) +mqtt_client = None -f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') -f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) +command_queue = {} -# time of last transmission - to calculcate response time -t_last_tx = 0 +hoymiles.HOYMILES_TRANSACTION_LOGGING=True +hoymiles.HOYMILES_DEBUG_LOGGING=True def 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): + inverter_ser = inverter.get('serial') + dtu_ser = ahoy_config.get('dtu', {}).get('serial') + + if 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 + + 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) + + +def mqtt_send_status(broker, interter_ser, data): + topic = f'ahoy/{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 + broker.publish(f'{topic}/frequency', data['frequency']) + broker.publish(f'{topic}/temperature', data['temperature']) + +def mqtt_on_command(): """ - Keep receiving on channel 3. Every once in a while, transmit a request - to one of our inverters on channel 40. + Handle commands to topic + ahoy/{inverter_ser}/command + frame it and put onto command_queue """ + raise NotImplementedError('Receiving mqtt commands is yet to be implemented') - global t_last_tx - - hoymiles.print_addr(inv_ser) - hoymiles.print_addr(dtu_ser) - - ctr = 1 - last_tx_message = '' - - rx_channels = [3,6,9,11,23,40,61,75] - rx_channel_id = 0 - rx_channel = rx_channels[rx_channel_id] - rx_channel_ack = None - rx_error = 0 - - tx_channels = [40] - tx_channel_id = 0 - tx_channel = tx_channels[tx_channel_id] - - radio.setChannel(rx_channel) - radio.setRetries(10, 2) - radio.setPALevel(RF24_PA_LOW) - #radio.setPALevel(RF24_PA_MAX) - radio.setDataRate(RF24_250KBPS) - radio.openReadingPipe(1,hoymiles.ser_to_esb_addr(dtu_ser)) - radio.openWritingPipe(hoymiles.ser_to_esb_addr(inv_ser)) - - while True: - m_buf = [] - # Channel selection: Sweep receive start channel - if not rx_channel_ack: - rx_channel_id = ctr % len(rx_channels) - rx_channel = rx_channels[rx_channel_id] - - tx_channel_id = tx_channel_id + 1 - if tx_channel_id >= len(tx_channels): - tx_channel_id = 0 - tx_channel = tx_channels[tx_channel_id] - - # Transmit: Compose data - com = hoymiles.InverterTransaction( - request_time = datetime.now(), - inverter_ser=inv_ser, - request = hoymiles.compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, subtype=b'\x0b') - ) - print(com) - - # Transmit: Setup radio - radio.stopListening() # put radio in TX mode - radio.setChannel(tx_channel) - radio.setAutoAck(True) - radio.setRetries(3, 15) - radio.setCRCLength(RF24_CRC_16) - radio.enableDynamicPayloads() - - # Transmit: Send payload - t_tx_start = time.monotonic_ns() - tx_status = radio.write(com.request) - t_last_tx = t_tx_end = time.monotonic_ns() - - ctr = ctr + 1 - - # Receive: Setup radio - radio.setChannel(rx_channel) - radio.setAutoAck(False) - radio.setRetries(0, 0) - radio.enableDynamicPayloads() - radio.setCRCLength(RF24_CRC_16) - radio.startListening() - - # Receive: Loop - t_end = time.monotonic_ns()+1e9 - while time.monotonic_ns() < t_end: - - has_payload, pipe_number = radio.available_pipe() - if has_payload: - # Data in nRF24 buffer, read it - rx_error = 0 - rx_channel_ack = rx_channel - t_end = time.monotonic_ns()+2e8 - - size = radio.getDynamicPayloadSize() - payload = radio.read(size) - fragment = hoymiles.InverterPacketFragment( - payload=payload, - ch_rx=rx_channel, ch_tx=tx_channel, - time_rx=datetime.now(), - latency=time.monotonic_ns()-t_last_tx - ) - print(fragment) - com.frame_append(fragment) - - else: - # No data in nRF rx buffer, search and wait - # Channel lock in (not currently used) - rx_error = rx_error + 1 - if rx_error > 0: - rx_channel_ack = None - # Channel hopping - if not rx_channel_ack: - rx_channel_id = rx_channel_id + 1 - if rx_channel_id >= len(rx_channels): - rx_channel_id = 0 - rx_channel = rx_channels[rx_channel_id] - radio.stopListening() - radio.setChannel(rx_channel) - radio.startListening() - time.sleep(0.005) - - inv_ser_hm = hoymiles.ser_to_hm_addr(inv_ser) - try: - payload = com.get_payload() - except BufferError: - payload = None - #print("Garbage") - - iv = None - if payload: - plen = len(payload) - dt = com.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f") - iv = hoymiles.hm600_0b_response_decode(payload) - - print(f'{dt} Decoded: {plen}', end='') - print(f' string1=', end='') - print(f' {iv.dc_voltage_0}VDC', end='') - print(f' {iv.dc_current_0}A', end='') - print(f' {iv.dc_power_0}W', end='') - print(f' {iv.dc_energy_total_0}Wh', end='') - print(f' {iv.dc_energy_daily_0}Wh/day', end='') - print(f' string2=', end='') - print(f' {iv.dc_voltage_1}VDC', end='') - print(f' {iv.dc_current_1}A', end='') - print(f' {iv.dc_power_1}W', end='') - print(f' {iv.dc_energy_total_1}Wh', end='') - print(f' {iv.dc_energy_daily_1}Wh/day', end='') - print(f' phase1=', end='') - print(f' {iv.ac_voltage_0}VAC', end='') - print(f' {iv.ac_current_0}A', end='') - print(f' {iv.ac_power_0}W', end='') - print(f' inverter={com.inverter_ser}', end='') - print(f' {iv.ac_frequency}Hz', end='') - print(f' {iv.temperature}°C', end='') - print() - - - # output to MQTT - if iv: - src = com.inverter_ser - # AC Data - mqtt_client.publish(f'ahoy/{src}/frequency', iv.ac_frequency) - mqtt_client.publish(f'ahoy/{src}/emeter/0/power', iv.ac_power_0) - mqtt_client.publish(f'ahoy/{src}/emeter/0/voltage', iv.ac_voltage_0) - mqtt_client.publish(f'ahoy/{src}/emeter/0/current', iv.ac_current_0) - mqtt_client.publish(f'ahoy/{src}/emeter/0/total', iv.dc_energy_total_0) - # DC Data - mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/total', iv.dc_energy_total_0/1000) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/power', iv.dc_power_0) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/voltage', iv.dc_voltage_0) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/current', iv.dc_current_0) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/total', iv.dc_energy_total_1/1000) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/power', iv.dc_power_1) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/voltage', iv.dc_voltage_1) - mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/current', iv.dc_current_1) - # Global - mqtt_client.publish(f'ahoy/{src}/temperature', iv.temperature) - - time.sleep(5) - - # Flush console - print(flush=True, end='') - -if __name__ == "__main__": +if __name__ == '__main__': + ahoy_config = dict(cfg.get('ahoy', {})) - if not radio.begin(): - raise RuntimeError("radio hardware is not responding") + mqtt_config = ahoy_config.get('mqtt', []) + if mqtt_config.get('disabled', True): + mqtt_client = paho.mqtt.client.Client() + 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() - radio.setPALevel(RF24_PA_LOW) # RF24_PA_MAX is default + if not radio.begin(): + raise RuntimeError('Can\'t open radio') - # radio.printDetails(); # (smaller) function that prints raw register values - # radio.printPrettyDetails(); # (larger) function that prints human readable data + #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: + command_queue[str(inverter_ser)] = [] + loop_interval = ahoy_config.get('interval', 1) try: - main_loop() + while True: + main_loop() + if loop_interval: + time.sleep(time.time() % loop_interval) except KeyboardInterrupt: - print(" Keyboard Interrupt detected. Exiting...") radio.powerDown() sys.exit() diff --git a/tools/rpi/ahoy.yml.example b/tools/rpi/ahoy.yml.example new file mode 100644 index 00000000..149f40cc --- /dev/null +++ b/tools/rpi/ahoy.yml.example @@ -0,0 +1,20 @@ +--- + +ahoy: + interval: 0 + sunset: true + mqtt: + disabled: false + host: example-broker.local + port: 1883 + user: 'username' + password: 'password' + + dtu: + serial: 99978563000 + + inverters: + - name: 'balkon' + serial: 114172220003 + mqtt: + topic: 'ahoy/114172220143' # defaults to 'ahoy/{serial}' diff --git a/tools/rpi/hoymiles/__init__.py b/tools/rpi/hoymiles/__init__.py index 3eaf9f75..37d0cb3b 100644 --- a/tools/rpi/hoymiles/__init__.py +++ b/tools/rpi/hoymiles/__init__.py @@ -2,13 +2,18 @@ import struct import crcmod import json import time +import re from datetime import datetime from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16 +from .decoders import * f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) +HOYMILES_TRANSACTION_LOGGING=True +HOYMILES_DEBUG_LOGGING=True + def ser_to_hm_addr(s): """ Calculate the 4 bytes that the HM devices use in their internal messages to @@ -17,7 +22,6 @@ def ser_to_hm_addr(s): bcd = int(str(s)[-8:], base=16) return struct.pack('>L', bcd) - def ser_to_esb_addr(s): """ Convert a Hoymiles inverter/DTU serial number into its @@ -33,42 +37,6 @@ def ser_to_esb_addr(s): air_order = ser_to_hm_addr(s)[::-1] + b'\x01' return air_order[::-1] - -def compose_0x80_msg(dst_ser_no=72220200, src_ser_no=72220200, ts=None, subtype=b'\x0b'): - """ - Create a valid 0x80 request with the given parameters, and containing the - current system time. - """ - - if not ts: - ts = int(time.time()) - - # "framing" - p = b'' - p = p + b'\x15' - p = p + ser_to_hm_addr(dst_ser_no) - p = p + ser_to_hm_addr(src_ser_no) - p = p + b'\x80' - - # encapsulated payload - pp = subtype + b'\x00' - pp = pp + struct.pack('>L', ts) # big-endian: msb at low address - #pp = pp + b'\x00' * 8 # of22 adds a \x05 at position 19 - - pp = pp + b'\x00\x00\x00\x05\x00\x00\x00\x00' - - # CRC_M - crc_m = f_crc_m(pp) - - p = p + pp - p = p + struct.pack('>H', crc_m) - - crc8 = f_crc8(p) - p = p + struct.pack('B', crc8) - - return p - - def print_addr(a): print(f"ser# {a} ", end='') print(f" -> HM {' '.join([f'{x:02x}' for x in ser_to_hm_addr(a)])}", end='') @@ -77,71 +45,68 @@ def print_addr(a): # time of last transmission - to calculcate response time t_last_tx = 0 -class hm600_02_response_decode: - """ TBD """ - def __init__(self, response): - self.response = response +class ResponseDecoderFactory: + model = None + request = None + response = None -class hm600_11_response_decode: - """ TBD """ - def __init__(self, response): + def __init__(self, response, **params): self.response = response -class hm600_0b_response_decode: - def __init__(self, response): - self.response = response + if 'request' in params: + self.request = params['request'] + elif hasattr(response, 'request'): + self.request = response.request + + if 'inverter_ser' in params: + self.inverter_ser = params['inverter_ser'] + self.model = self.inverter_model def unpack(self, fmt, base): size = struct.calcsize(fmt) return struct.unpack(fmt, self.response[base:base+size]) @property - def dc_voltage_0(self): - return self.unpack('>H', 2)[0]/10 - @property - def dc_current_0(self): - return self.unpack('>H', 4)[0]/100 - @property - def dc_power_0(self): - return self.unpack('>H', 6)[0]/10 - @property - def dc_energy_total_0(self): - return self.unpack('>L', 14)[0] - @property - def dc_energy_daily_0(self): - return self.unpack('>H', 22)[0] + def inverter_model(self): + if not self.inverter_ser: + raise ValueError('Inverter serial while decoding response') + + ser_db = [ + ('HM300', r'^112171......'), + ('HM350', r'^112172......'), + ('HM600', r'^114172......'), + ('HM700', r'^114174......'), + ('HM1200', r'^116170......') + ] + ser_str = str(self.inverter_ser) + + model = None + for m, r in ser_db: + if re.match(r, ser_str): + model = m + break + + if len(model): + return model + raise NotImplementedError('Model lookup failed for serial {ser_str}') @property - def dc_voltage_1(self): - return self.unpack('>H', 8)[0]/10 - @property - def dc_current_1(self): - return self.unpack('>H', 10)[0]/100 - @property - def dc_power_1(self): - return self.unpack('>H', 12)[0]/10 - @property - def dc_energy_total_1(self): - return self.unpack('>L', 18)[0] - @property - def dc_energy_daily_1(self): - return self.unpack('>H', 24)[0] + def request_command(self): + r_code = self.request[10] + return f'{r_code:02x}' - @property - def ac_voltage_0(self): - return self.unpack('>H', 26)[0]/10 - @property - def ac_current_0(self): - return self.unpack('>H', 34)[0]/10 - @property - def ac_power_0(self): - return self.unpack('>H', 30)[0]/10 - @property - def ac_frequency(self): - return self.unpack('>H', 28)[0]/100 - @property - def temperature(self): - return self.unpack('>H', 38)[0]/10 +class ResponseDecoder(ResponseDecoderFactory): + def __init__(self, response, **params): + ResponseDecoderFactory.__init__(self, response, **params) + + def decode(self): + model = self.inverter_model + command = self.request_command + + model_decoder = __import__(f'hoymiles.decoders') + device = getattr(model_decoder, f'{model}_Decode{command.upper()}') + + return device(self.response) class InverterPacketFragment: def __init__(self, time_rx=None, payload=None, ch_rx=None, ch_tx=None, **params): @@ -204,42 +169,258 @@ class InverterPacketFragment: raw = " ".join([f"{b:02x}" for b in self.frame]) return f"{dt} Received {size} bytes{channel}: {raw}" +class HoymilesNRF: + tx_channel_id = 0 + tx_channel_list = [40] + rx_channel_id = 0 + rx_channel_list = [3,6,9,11,23,40,61,75] + rx_channel_ack = False + rx_error = 0 + + def __init__(self, device): + self.radio = device + + def transmit(self, packet): + """ + Transmit Packet + """ + + #dst_esb_addr = b'\x01' + packet[1:5] + #src_esb_addr = b'\x01' + packet[6:9] + + #hexify_payload(dst_esb_addr) + #hexify_payload(src_esb_addr) + + self.radio.stopListening() # put radio in TX mode + self.radio.setDataRate(RF24_250KBPS) + #self.radio.openReadingPipe(1, src_esb_addr ) + #self.radio.openWritingPipe( dst_esb_addr ) + self.radio.setChannel(self.tx_channel) + self.radio.setAutoAck(True) + self.radio.setRetries(3, 15) + self.radio.setCRCLength(RF24_CRC_16) + self.radio.enableDynamicPayloads() + + return self.radio.write(packet) + + def receive(self, timeout=None): + """ + Receive Packets + """ + + if not timeout: + timeout=12e8 + + self.radio.setChannel(self.rx_channel) + self.radio.setAutoAck(False) + self.radio.setRetries(0, 0) + self.radio.enableDynamicPayloads() + self.radio.setCRCLength(RF24_CRC_16) + self.radio.startListening() + + fragments = [] + + # Receive: Loop + t_end = time.monotonic_ns()+timeout + while time.monotonic_ns() < t_end: + + has_payload, pipe_number = self.radio.available_pipe() + if has_payload: + # Data in nRF24 buffer, read it + self.rx_error = 0 + self.rx_channel_ack = True + t_end = time.monotonic_ns()+5e8 + + size = self.radio.getDynamicPayloadSize() + payload = self.radio.read(size) + fragment = InverterPacketFragment( + payload=payload, + ch_rx=self.rx_channel, ch_tx=self.tx_channel, + time_rx=datetime.now() + ) + yield(fragment) + + else: + # No data in nRF rx buffer, search and wait + # Channel lock in (not currently used) + self.rx_error = self.rx_error + 1 + if self.rx_error > 0: + self.rx_channel_ack = False + # Channel hopping + if self.next_rx_channel(): + self.radio.stopListening() + self.radio.setChannel(self.rx_channel) + self.radio.startListening() + + time.sleep(0.005) + + def next_rx_channel(self): + if not self.rx_channel_ack: + self.rx_channel_id = self.rx_channel_id + 1 + if self.rx_channel_id >= len(self.rx_channel_list): + self.rx_channel_id = 0 + return True + return False + + @property + def tx_channel(self): + return self.tx_channel_list[self.tx_channel_id] + + @property + def rx_channel(self): + return self.rx_channel_list[self.rx_channel_id] + +def frame_payload(payload): + payload_crc = f_crc_m(payload) + payload = payload + struct.pack('>H', payload_crc) + + return payload + +def compose_esb_fragment(fragment, seq=b'\80', src=99999999, dst=1, **params): + if len(fragment) > 17: + raise ValueError(f'ESB fragment exeeds mtu ({mtu}): Fragment size {len(fragment)} bytes') + + p = b'' + p = p + b'\x15' + p = p + ser_to_hm_addr(dst) + p = p + ser_to_hm_addr(src) + p = p + seq + + p = p + fragment + + crc8 = f_crc8(p) + p = p + struct.pack('B', crc8) + + return p + +def compose_esb_packet(packet, mtu=17, **params): + for i in range(0, len(packet), mtu): + fragment = compose_esb_fragment(packet[i:i+mtu], **params) + yield(fragment) + +def compose_set_time_payload(timestamp=None): + if not timestamp: + timestamp = int(time.time()) + + payload = b'\x0b\x00' + payload = payload + struct.pack('>L', timestamp) # big-endian: msb at low address + payload = payload + b'\x00\x00\x00\x05\x00\x00\x00\x00' + + return frame_payload(payload) + +def compose_02_payload(timestamp=None): + payload = b'\x02' + if timestamp: + payload = payload + b'\x00' + payload = payload + struct.pack('>L', timestamp) # big-endian: msb at low address + payload = payload + b'\x00\x00\x00\x05\x00\x00\x00\x00' + + return frame_payload(payload) + +def compose_11_payload(): + payload = b'\x11' + + return frame_payload(payload) + + class InverterTransaction: + tx_queue = [] + scratch = [] + inverter_ser = None + inverter_addr = None + dtu_ser = None + req_type = None + + radio = None + def __init__(self, - request_time=datetime.now(), + request_time=None, inverter_ser=None, dtu_ser=None, + radio=None, **params): + + if radio: + self.radio = radio + + if not request_time: + request_time=datetime.now() + self.scratch = [] if 'scratch' in params: self.scratch = params['scratch'] self.inverter_ser = inverter_ser if inverter_ser: - self.peer_src = ser_to_hm_addr(inverter_ser) + self.inverter_addr = ser_to_hm_addr(inverter_ser) self.dtu_ser = dtu_ser if dtu_ser: - self.dtu_dst = ser_to_hm_addr(dtu_ser) - - self.peer_src, self.peer_dst, self.req_type = (None,None,None) + self.dtu_addr = ser_to_hm_addr(dtu_ser) self.request = None if 'request' in params: self.request = params['request'] - self.peer_src, self.peer_dst, skip, self.req_type = struct.unpack('>LLBB', params['request'][1:11]) + self.queue_tx(self.request) + self.inverter_addr, self.dtu_addr, seq, self.req_type = struct.unpack('>LLBB', params['request'][1:11]) self.request_time = request_time + def rxtx(self): + """ + Transmit next packet from tx_queue if available + and wait for responses + """ + if not self.radio: + return False + + if not len(self.tx_queue): + return False + + packet = self.tx_queue.pop(0) + + if HOYMILES_TRANSACTION_LOGGING: + dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + print(f'{dt} Transmit {len(packet)} | {hexify_payload(packet)}') + + self.radio.transmit(packet) + + wait = False + try: + for response in self.radio.receive(): + if HOYMILES_TRANSACTION_LOGGING: + print(response) + + self.frame_append(response) + wait = True + except TimeoutError: + pass + + return wait + def frame_append(self, payload_frame): + """ + Append received raw frame to local scratch buffer + """ self.scratch.append(payload_frame) + def queue_tx(self, frame): + """ + Enqueue packet for transmission if radio is available + """ + if not self.radio: + return False + + self.tx_queue.append(frame) + + return True + def get_payload(self, src=None): """ Reconstruct Hoymiles payload from scratch """ if not src: - src = self.peer_src + src = self.inverter_addr # Collect all frames from source_address src frames = [frame for frame in self.scratch if frame.src == src] @@ -251,30 +432,42 @@ class InverterTransaction: self.time_rx = end_frame.time_rx tr_len = end_frame.seq - 0x80 except StopIteration: - raise BufferError('Missing packet: Last packet') + raise BufferError(f'Missing packet: Last packet {len(self.scratch)}') # Rebuild payload from unordered frames payload = b'' - seq_missing = [] - for i in range(1, tr_len): + for frame_id in range(1, tr_len): try: - data_frame = next(item for item in frames if item.seq == i) + data_frame = next(item for item in frames if item.seq == frame_id) payload = payload + data_frame.data except StopIteration: - seq_missing.append(i) - pass + self.__retransmit_frame(frame_id) + raise BufferError(f'Frame {frame_id} missing: Request Retransmit') payload = payload + end_frame.data # check crc pcrc = struct.unpack('>H', payload[-2:])[0] if f_crc_m(payload[:-2]) != pcrc: - raise BufferError('Payload failed CRC check.') + raise ValueError('Payload failed CRC check.') return payload + def __retransmit_frame(self, frame_id): + """ + Build and queue retransmit request + """ + packet = compose_esb_fragment(b'', + seq=int(0x80 + frame_id).to_bytes(1, 'big'), + src=self.dtu_ser, + dst=self.inverter_ser) + + return self.queue_tx(packet) + def __str__(self): dt = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f") size = len(self.request) - raw = " ".join([f"{b:02x}" for b in self.request]) - return f'{dt} Transmit | {raw}' + return f'{dt} Transmit | {hexify_payload(self.request)}' + +def hexify_payload(byte_var): + return ' '.join([f"{b:02x}" for b in byte_var]) diff --git a/tools/rpi/hoymiles/decoders/__init__.py b/tools/rpi/hoymiles/decoders/__init__.py new file mode 100644 index 00000000..bf6d90db --- /dev/null +++ b/tools/rpi/hoymiles/decoders/__init__.py @@ -0,0 +1,143 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import struct + +class StatusResponse: + e_keys = ['voltage','current','power','energy_total','energy_daily'] + + def unpack(self, fmt, base): + size = struct.calcsize(fmt) + return struct.unpack(fmt, self.response[base:base+size]) + + @property + def phases(self): + phases = [] + p_exists = True + while p_exists: + p_exists = False + phase_id = len(phases) + phase = {} + for key in self.e_keys: + prop = f'ac_{key}_{phase_id}' + if hasattr(self, prop): + p_exists = True + phase[key] = getattr(self, prop) + if p_exists: + phases.append(phase) + + return phases + + @property + def strings(self): + strings = [] + s_exists = True + while s_exists: + s_exists = False + string_id = len(strings) + string = {} + for key in self.e_keys: + prop = f'dc_{key}_{string_id}' + if hasattr(self, prop): + s_exists = True + string[key] = getattr(self, prop) + if s_exists: + strings.append(string) + + return strings + + def __dict__(self): + data = {} + data['phases'] = self.phases + data['strings'] = self.strings + data['temperature'] = self.temperature + data['frequency'] = self.frequency + return data + +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) + return vals + + @property + def dump_shorts(self): + n = len(self.response)/2 + vals = struct.unpack(f'>{int(n)}H', self.response) + return vals + +class HM600_Decode02(UnknownResponse): + def __init__(self, response): + self.response = response + +class HM600_Decode11(UnknownResponse): + def __init__(self, response): + self.response = response + +class HM600_Decode12(UnknownResponse): + def __init__(self, response): + self.response = response + +class HM600_Decode0A(UnknownResponse): + def __init__(self, response): + self.response = response + +class HM600_Decode0B(StatusResponse): + def __init__(self, response): + self.response = response + + @property + def dc_voltage_0(self): + return self.unpack('>H', 2)[0]/10 + @property + def dc_current_0(self): + return self.unpack('>H', 4)[0]/100 + @property + def dc_power_0(self): + return self.unpack('>H', 6)[0]/10 + @property + def dc_energy_total_0(self): + return self.unpack('>L', 14)[0] + @property + def dc_energy_daily_0(self): + return self.unpack('>H', 22)[0] + + @property + def dc_voltage_1(self): + return self.unpack('>H', 8)[0]/10 + @property + def dc_current_1(self): + return self.unpack('>H', 10)[0]/100 + @property + def dc_power_1(self): + return self.unpack('>H', 12)[0]/10 + @property + def dc_energy_total_1(self): + return self.unpack('>L', 18)[0] + @property + def dc_energy_daily_1(self): + return self.unpack('>H', 24)[0] + + @property + def ac_voltage_0(self): + return self.unpack('>H', 26)[0]/10 + @property + def ac_current_0(self): + return self.unpack('>H', 34)[0]/10 + @property + def ac_power_0(self): + return self.unpack('>H', 30)[0]/10 + @property + def frequency(self): + return self.unpack('>H', 28)[0]/100 + @property + def temperature(self): + return self.unpack('>H', 38)[0]/10 + +class HM600_Decode0C(HM600_Decode0B): + def __init__(self, response): + self.response = response + diff --git a/tools/rpi/hoymiles/factory/__init__.py b/tools/rpi/hoymiles/factory/__init__.py deleted file mode 100644 index 3de31746..00000000 --- a/tools/rpi/hoymiles/factory/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# TBD - -class ESBFrameFactory: - def __init__(self, payload): - self.payload = payload - -class ESBTransactionFactory: - """ - Put a payload into ESB packets for transmission - """ - def __init__(self, src, dst, **params): - self.src = src - self.dst = dst