mirror of https://github.com/lumapu/ahoy.git
				
				
			
							committed by
							
								 GitHub
								GitHub
							
						
					
				
				 9 changed files with 1111 additions and 4538 deletions
			
			
		| @ -0,0 +1,11 @@ | |||
| # Python | |||
| # Byte-compiled / optimized / DLL files | |||
| __pycache__/ | |||
| *.py[cod] | |||
| *$py.class | |||
| 
 | |||
| # Virtual Environment | |||
| venv/ | |||
| 
 | |||
| # vim leftovers | |||
| **.swp | |||
| @ -1,11 +0,0 @@ | |||
| [mqtt] | |||
| host = 192.168.84.2 | |||
| port = 1883 | |||
| user = bla | |||
| password = blub | |||
| 
 | |||
| [dtu] | |||
| serial = 99978563412 | |||
| 
 | |||
| [inverter] | |||
| serial = 444473104619 | |||
| @ -1,345 +1,212 @@ | |||
| """ | |||
| 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 | |||
| import re | |||
| import time | |||
| from datetime import datetime | |||
| from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS | |||
| 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 | |||
| 
 | |||
| 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='') | |||
| 
 | |||
| 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() | |||
| 
 | |||
| # Master Address ('DTU') | |||
| dtu_ser = cfg.get('dtu', 'serial', fallback='99978563412')  # identical to fc22's | |||
| 
 | |||
| # inverter serial numbers | |||
| inv_ser = cfg.get('inverter', 'serial', fallback='444473104619')  # my inverter | |||
| 
 | |||
| # all inverters | |||
| #... | |||
| 
 | |||
| f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') | |||
| f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) | |||
| 
 | |||
| 
 | |||
| def ser_to_hm_addr(s): | |||
|     """ | |||
|     Calculate the 4 bytes that the HM devices use in their internal messages to  | |||
|     address each other. | |||
|     """ | |||
|     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 | |||
|     corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes). | |||
| 
 | |||
|     The NRF library expects these in LSB to MSB order, even though the transceiver | |||
|     itself will then output them in MSB-to-LSB order over the air. | |||
|      | |||
|     The inverters use a BCD representation of the last 8 | |||
|     digits of their serial number, in reverse byte order,  | |||
|     followed by \x01. | |||
|     """ | |||
|     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): | |||
|     """ | |||
|     Create a valid 0x80 request with the given parameters, and containing the  | |||
|     current system time. | |||
|     """ | |||
| 
 | |||
|     if not ts: | |||
|         ts = 0x623C8ECF  # identical to fc22's for testing  # doc: 1644758171 | |||
| 
 | |||
|     # "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 = b'\x0b\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' | |||
| import yaml | |||
| from yaml.loader import SafeLoader | |||
| 
 | |||
|     # CRC_M | |||
|     crc_m = f_crc_m(pp) | |||
| parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway') | |||
| parser.add_argument("-c", "--config-file", nargs="?", | |||
|     help="configuration file") | |||
| global_config = parser.parse_args() | |||
| 
 | |||
|     p = p + pp | |||
|     p = p + struct.pack('>H', crc_m) | |||
| 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) | |||
| 
 | |||
|     crc8 = f_crc8(p) | |||
|     p = p + struct.pack('B', crc8)    | |||
|     return p | |||
| radio = RF24(22, 0, 1000000) | |||
| hmradio = hoymiles.HoymilesNRF(device=radio) | |||
| mqtt_client = None | |||
| 
 | |||
| command_queue = {} | |||
| mqtt_command_topic_subs = [] | |||
| 
 | |||
| 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='') | |||
|     print(f" -> ESB {' '.join([f'{x:02x}' for x in ser_to_esb_addr(a)])}") | |||
| hoymiles.HOYMILES_TRANSACTION_LOGGING=True | |||
| hoymiles.HOYMILES_DEBUG_LOGGING=True | |||
| 
 | |||
| # time of last transmission - to calculcate response time | |||
| t_last_tx = 0 | |||
| def main_loop(): | |||
|     inverters = [ | |||
|             inverter for inverter in ahoy_config.get('inverters', []) | |||
|             if not inverter.get('disabled', False)] | |||
| 
 | |||
| def on_receive(p, ch_rx=None, ch_tx=None): | |||
|     """ | |||
|     Callback: get's invoked whenever a packet has been received. | |||
|     :param p: Payload of the received packet. | |||
|     """ | |||
|     for inverter in inverters: | |||
|         if hoymiles.HOYMILES_DEBUG_LOGGING: | |||
|             print(f'Poll inverter {inverter["serial"]}') | |||
|         poll_inverter(inverter) | |||
| 
 | |||
|     d = {} | |||
| 
 | |||
|     t_now_ns = time.monotonic_ns() | |||
|     ts = datetime.utcnow() | |||
|     ts_unixtime = ts.timestamp() | |||
|     d['ts_unixtime'] = ts_unixtime | |||
|     d['isodate'] = ts.isoformat() | |||
|     d['rawdata'] = " ".join([f"{b:02x}" for b in p]) | |||
|     print(ts.isoformat(), end='Z ') | |||
| 
 | |||
|     # check crc8 | |||
|     crc8 = f_crc8(p[:-1]) | |||
|     d['crc8_valid'] = True if crc8==p[-1] else False | |||
| 
 | |||
|     # interpret content | |||
|     mid = p[0] | |||
|     d['mid'] = mid | |||
|     d['response_time_ns'] = t_now_ns-t_last_tx | |||
|     d['ch_rx'] = ch_rx | |||
|     d['ch_tx'] = ch_tx | |||
|     d['src'] = 'src_unkn' | |||
|     d['name'] = 'name_unkn' | |||
|   | |||
|     if mid == 0x95: | |||
|         src, dst, cmd = struct.unpack('>LLB', p[1:10]) | |||
|         d['src'] = f'{src:08x}' | |||
|         d['dst'] = f'{dst:08x}' | |||
|         d['cmd'] = cmd | |||
|         print(f'MSG src={d["src"]}, dst={d["dst"]}, cmd={d["cmd"]}:') | |||
| 
 | |||
|         if cmd==1: | |||
|             d['name'] = 'dcdata' | |||
|             unknown1, u1, i1, p1, u2, i2, p2, unknown2 = struct.unpack( | |||
|                 '>HHHHHHHH', p[10:26]) | |||
|             d['u1_V'] = u1/10 | |||
|             d['i1_A'] = i1/100 | |||
|             d['p1_W'] = p1/10 | |||
|             d['u2_V'] = u2/10 | |||
|             d['i2_A'] = i2/100 | |||
|             d['p2_W'] = p2/10 | |||
|             d['p_W'] = d['p1_W']+d['p2_W'] | |||
|             d['unknown1'] = unknown1 | |||
|             d['unknown2'] = unknown2 | |||
| 
 | |||
|         elif cmd==2: | |||
|             d['name'] = 'acdata' | |||
|             uk1, uk2, uk3, uk4, uk5, u, f, p = struct.unpack( | |||
|                 '>HHHHHHHH', p[10:26]) | |||
|             d['u_V'] = u/10 | |||
|             d['f_Hz'] = f/100 | |||
|             d['p_W'] = p/10 | |||
|             d['wtot1_Wh'] = uk1 | |||
|             d['wtot2_Wh'] = uk3 | |||
|             d['wday1_Wh'] = uk4 | |||
|             d['wday2_Wh'] = uk5 | |||
|             d['uk2'] = uk2 | |||
| 
 | |||
|         elif cmd==129: | |||
|             d['name'] = 'error' | |||
| 
 | |||
|         elif cmd==131:  # 0x83 | |||
|             d['name'] = 'statedata' | |||
|             uk1, l, uk3, t, uk5, uk6 = struct.unpack('>HHHHHH', p[10:22]) | |||
|             d['l_Pct'] = l | |||
|             d['t_C'] = t/10 | |||
|             d['uk1'] = uk1 | |||
|             d['uk3'] = uk3 | |||
|             d['uk5'] = uk5 | |||
|             d['uk6'] = uk6 | |||
| 
 | |||
|         elif cmd==132:  # 0x84 | |||
|             d['name'] = 'unknown0x84' | |||
|             uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack( | |||
|                 '>HHHHHHHH', p[10:26]) | |||
|             d['uk1'] = uk1 | |||
|             d['uk2'] = uk2 | |||
|             d['uk3'] = uk3 | |||
|             d['uk4'] = uk4 | |||
|             d['uk5'] = uk5 | |||
|             d['uk6'] = uk6 | |||
|             d['uk7'] = uk7 | |||
|             d['uk8'] = uk8 | |||
| 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: | |||
|             print(f'unknown cmd {cmd}') | |||
|     else: | |||
|         print(f'unknown frame id {p[0]}') | |||
| 
 | |||
|     # output to stdout | |||
|     if d: | |||
|         print(json.dumps(d)) | |||
| 
 | |||
|     # output to MQTT | |||
|     if d: | |||
|         j = json.dumps(d) | |||
|         mqtt_client.publish(f"ahoy/{d['src']}/{d['name']}", j) | |||
|         if d['cmd']==2: | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/voltage', d['u_V']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/power', d['p_W']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/total', d['wtot1_Wh']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/frequency', d['f_Hz']) | |||
|         if d['cmd']==1: | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/power', d['p1_W']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/voltage', d['u1_V']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/current', d['i1_A']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/power', d['p2_W']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/voltage', d['u2_V']) | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/current', d['i2_A']) | |||
|         if d['cmd']==131: | |||
|             mqtt_client.publish(f'ahoy/{d["src"]}/temperature', d['t_C']) | |||
| 
 | |||
| 
 | |||
|         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 | |||
| 
 | |||
| def main_loop(): | |||
|     """ | |||
|     Keep receiving on channel 3. Every once in a while, transmit a request | |||
|     to one of our inverters on channel 40. | |||
|     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)) | |||
| 
 | |||
| def mqtt_send_status(broker, inverter_ser, data, topic=None): | |||
|     """ Publish StatusResponse object """ | |||
| 
 | |||
|     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 | |||
|     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 | |||
| 
 | |||
|     global t_last_tx | |||
|     Inverters must have mqtt.send_raw_enabled: true configured | |||
| 
 | |||
|     print_addr(inv_ser) | |||
|     print_addr(dtu_ser) | |||
|     This can be used to inject debug payloads | |||
|     The message must be in hexlified format | |||
| 
 | |||
|     ctr = 1 | |||
|     last_tx_message = '' | |||
|     Use of variables: | |||
|     tttttttt gets expanded to a current int(time) | |||
| 
 | |||
|     ts = int(time.time())  # see what happens if we always send one and the same (constant) time! | |||
|     Example injects exactly the same as we normally use to poll data: | |||
|       mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000 | |||
| 
 | |||
|     rx_channels = [3,23,61,75] | |||
|     rx_channel_id = 0 | |||
|     rx_channel = rx_channels[rx_channel_id] | |||
|     This allows for even faster hacking during runtime | |||
|     """ | |||
|     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', {})) | |||
| 
 | |||
|     mqtt_config = ahoy_config.get('mqtt', []) | |||
|     if not mqtt_config.get('disabled', False): | |||
|         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() | |||
|         mqtt_client.on_message = mqtt_on_command | |||
| 
 | |||
|     tx_channels = [40] | |||
|     tx_channel_id = 0 | |||
|     tx_channel = tx_channels[tx_channel_id] | |||
|     if not radio.begin(): | |||
|         raise RuntimeError('Can\'t open radio') | |||
| 
 | |||
|     while True: | |||
|         # Sweep receive start channel | |||
|         rx_channel_id = ctr % len(rx_channels) | |||
|         rx_channel = rx_channels[rx_channel_id] | |||
| 
 | |||
|         radio.setChannel(rx_channel) | |||
|         radio.enableDynamicPayloads() | |||
|         radio.setAutoAck(False) | |||
|         radio.setPALevel(RF24_PA_MAX) | |||
|         radio.setDataRate(RF24_250KBPS) | |||
|         radio.openWritingPipe(ser_to_esb_addr(inv_ser)) | |||
|         radio.flush_rx() | |||
|         radio.flush_tx() | |||
|         radio.openReadingPipe(1,ser_to_esb_addr(dtu_ser)) | |||
|         radio.startListening() | |||
| 
 | |||
|         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] | |||
|     inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])] | |||
|     for inverter in ahoy_config.get('inverters', []): | |||
|         inverter_ser = inverter.get('serial') | |||
|         command_queue[str(inverter_ser)] = [] | |||
| 
 | |||
|         # | |||
|         # TX | |||
|         # Enables and subscribe inverter to mqtt /command-Topic | |||
|         # | |||
|         radio.stopListening()  # put radio in TX mode | |||
|         radio.setChannel(tx_channel) | |||
|         radio.openWritingPipe(ser_to_esb_addr(inv_ser)) | |||
| 
 | |||
|         ts = int(time.time()) | |||
|         payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, ts=ts) | |||
|         dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") | |||
|         last_tx_message = f"{dt} Transmit {ctr:5d}: channel={tx_channel} len={len(payload)} | " + \ | |||
|             " ".join([f"{b:02x}" for b in payload]) + f" rx_ch: {rx_channel}" | |||
|         print(last_tx_message) | |||
| 
 | |||
|         # for i in range(0,3): | |||
|         result = radio.write(payload)  # will always yield 'True' because auto-ack is disabled | |||
|         #    time.sleep(.05) | |||
| 
 | |||
|         t_last_tx = time.monotonic_ns() | |||
|         ctr = ctr + 1 | |||
| 
 | |||
|         t_end = time.monotonic_ns()+5e9 | |||
|         tslots = [1000]  #, 40, 50, 60, 70]  # switch channel at these ms times since transmission | |||
| 
 | |||
|         for tslot in tslots: | |||
|             t_end = t_last_tx + tslot*1e6  # ms to ns | |||
| 
 | |||
|             radio.stopListening() | |||
|             radio.setChannel(rx_channel) | |||
|             radio.startListening() | |||
|             while time.monotonic_ns() < t_end: | |||
|                 has_payload, pipe_number = radio.available_pipe() | |||
|                 if has_payload: | |||
|                     size = radio.getDynamicPayloadSize() | |||
|                     payload = radio.read(size) | |||
|                     # print(last_tx_message, end='') | |||
|                     last_tx_message = '' | |||
|                     dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") | |||
|                     print(f"{dt} Received {size} bytes on channel {rx_channel} pipe {pipe_number}: " + | |||
|                           " ".join([f"{b:02x}" for b in payload])) | |||
|                     on_receive(payload, ch_rx=rx_channel, ch_tx=tx_channel) | |||
|                 else: | |||
|                     pass | |||
|                     # time.sleep(0.001) | |||
| 
 | |||
|             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] | |||
| 
 | |||
|         print(flush=True, end='') | |||
|         # time.sleep(2) | |||
| 
 | |||
| 
 | |||
| 
 | |||
| if __name__ == "__main__": | |||
| 
 | |||
|     if not radio.begin(): | |||
|         raise RuntimeError("radio hardware is not responding") | |||
| 
 | |||
|     radio.setPALevel(RF24_PA_LOW)  # RF24_PA_MAX is default | |||
| 
 | |||
|     # radio.printDetails();  # (smaller) function that prints raw register values | |||
|     # radio.printPrettyDetails();  # (larger) function that prints human readable data | |||
| 
 | |||
|         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) | |||
| 
 | |||
|     except KeyboardInterrupt: | |||
|         print(" Keyboard Interrupt detected. Exiting...") | |||
|         radio.powerDown() | |||
|         sys.exit() | |||
|  | |||
| @ -0,0 +1,21 @@ | |||
| --- | |||
| 
 | |||
| ahoy: | |||
|   interval: 0 | |||
|   sunset: true | |||
|   mqtt: | |||
|     disabled: false | |||
|     host: example-broker.local | |||
|     port: 1883 | |||
|     user: 'username' | |||
|     password: 'password' | |||
| 
 | |||
|   dtu: | |||
|     serial: 99978563001 | |||
| 
 | |||
|   inverters: | |||
|     - name: 'balkon' | |||
|       serial: 114172220003 | |||
|       mqtt: | |||
|         send_raw_enabled: false        # allow inject debug data via mqtt | |||
|         topic: 'hoymiles/114172221234' # defaults to 'hoymiles/{serial}' | |||
								
									
										File diff suppressed because it is too large
									
								
							
						
					| @ -0,0 +1,475 @@ | |||
| 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  | |||
|     address each other. | |||
|     """ | |||
|     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 | |||
|     corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes). | |||
| 
 | |||
|     The NRF library expects these in LSB to MSB order, even though the transceiver | |||
|     itself will then output them in MSB-to-LSB order over the air. | |||
|      | |||
|     The inverters use a BCD representation of the last 8 | |||
|     digits of their serial number, in reverse byte order,  | |||
|     followed by \x01. | |||
|     """ | |||
|     air_order = ser_to_hm_addr(s)[::-1] + b'\x01' | |||
|     return air_order[::-1] | |||
| 
 | |||
| 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='') | |||
|     print(f" -> ESB {' '.join([f'{x:02x}' for x in ser_to_esb_addr(a)])}") | |||
| 
 | |||
| # time of last transmission - to calculcate response time | |||
| t_last_tx = 0 | |||
| 
 | |||
| class ResponseDecoderFactory: | |||
|     model = None | |||
|     request = None | |||
|     response = None | |||
| 
 | |||
|     def __init__(self, response, **params): | |||
|         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 inverter_model(self): | |||
|         if not self.inverter_ser: | |||
|             raise ValueError('Inverter serial while decoding response') | |||
| 
 | |||
|         ser_db = [ | |||
|                 ('HM300', r'^1121........'), | |||
|                 ('HM600', r'^1141........'), | |||
|                 ('HM1200', r'^1161........'), | |||
|                 ] | |||
|         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 request_command(self): | |||
|         r_code = self.request[10] | |||
|         return f'{r_code:02x}' | |||
| 
 | |||
| 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_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) | |||
| 
 | |||
| class InverterPacketFragment: | |||
|     def __init__(self, time_rx=None, payload=None, ch_rx=None, ch_tx=None, **params): | |||
|         """ | |||
|         Callback: get's invoked whenever a Nordic ESB packet has been received. | |||
|         :param p: Payload of the received packet. | |||
|         """ | |||
| 
 | |||
|         if not time_rx: | |||
|             time_rx = datetime.now() | |||
|         self.time_rx = time_rx | |||
| 
 | |||
|         self.frame = payload | |||
| 
 | |||
|         # check crc8 | |||
|         if f_crc8(payload[:-1]) != payload[-1]: | |||
|             raise BufferError('Frame kaputt') | |||
| 
 | |||
|         self.ch_rx = ch_rx | |||
|         self.ch_tx = ch_tx | |||
| 
 | |||
|     @property | |||
|     def mid(self): | |||
|         """ | |||
|         Transaction counter | |||
|         """ | |||
|         return self.frame[0] | |||
|     @property | |||
|     def src(self): | |||
|         """ | |||
|         Sender dddress | |||
|         """ | |||
|         src = struct.unpack('>L', self.frame[1:5]) | |||
|         return src[0] | |||
|     @property | |||
|     def dst(self): | |||
|         """ | |||
|         Receiver address | |||
|         """ | |||
|         dst = struct.unpack('>L', self.frame[5:8]) | |||
|         return dst[0] | |||
|     @property | |||
|     def seq(self): | |||
|         """ | |||
|         Packet sequence | |||
|         """ | |||
|         result = struct.unpack('>B', self.frame[9:10]) | |||
|         return result[0] | |||
|     @property | |||
|     def data(self): | |||
|         """ | |||
|         Packet without protocol framing | |||
|         """ | |||
|         return self.frame[10:-1] | |||
| 
 | |||
|     def __str__(self): | |||
|         dt = self.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f") | |||
|         size = len(self.frame) | |||
|         channel = f' channel {self.ch_rx}' if self.ch_rx else '' | |||
|         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,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 > 1: | |||
|                     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=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.inverter_addr = ser_to_hm_addr(inverter_ser) | |||
| 
 | |||
|         self.dtu_ser = dtu_ser | |||
|         if dtu_ser: | |||
|             self.dtu_addr = ser_to_hm_addr(dtu_ser) | |||
| 
 | |||
|         self.request = None | |||
|         if 'request' in params: | |||
|             self.request = params['request'] | |||
|             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.inverter_addr | |||
| 
 | |||
|         # Collect all frames from source_address src | |||
|         frames = [frame for frame in self.scratch if frame.src == src] | |||
| 
 | |||
|         tr_len = 0 | |||
|         # Find end frame and extract message frame count | |||
|         try: | |||
|             end_frame = next(frame for frame in frames if frame.seq > 0x80) | |||
|             self.time_rx = end_frame.time_rx | |||
|             tr_len = end_frame.seq - 0x80 | |||
|         except StopIteration: | |||
|             raise BufferError(f'Missing packet: Last packet {len(self.scratch)}') | |||
| 
 | |||
|         # Rebuild payload from unordered frames | |||
|         payload = b'' | |||
|         for frame_id in range(1, tr_len): | |||
|             try: | |||
|                 data_frame = next(item for item in frames if item.seq == frame_id) | |||
|                 payload = payload + data_frame.data | |||
|             except StopIteration: | |||
|                 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 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) | |||
|         return f'{dt} Transmit | {hexify_payload(self.request)}' | |||
| 
 | |||
| def hexify_payload(byte_var): | |||
|     return ' '.join([f"{b:02x}" for b in byte_var]) | |||
| @ -0,0 +1,311 @@ | |||
| #!/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): | |||
|         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 = None | |||
|         if n % 2 == 0: | |||
|             vals = struct.unpack(f'>{int(n)}H', self.response) | |||
|         return vals | |||
| 
 | |||
|     @property | |||
|     def dump_shorts_pad1(self): | |||
|         res = self.response[1:] | |||
|         n = len(res)/2 | |||
| 
 | |||
|         vals = None | |||
|         if n % 2 == 0: | |||
|             vals = struct.unpack(f'>{int(n)}H', res) | |||
|         return vals | |||
| 
 | |||
| class DEBUG_DecodeAny(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)) | |||
| 
 | |||
| 
 | |||
| # 1121-Series Intervers, 1 MPPT, 1 Phase | |||
| class HM300_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', 8)[0] | |||
|     @property | |||
|     def dc_energy_daily_0(self): | |||
|         return self.unpack('>H', 12)[0] | |||
| 
 | |||
| 
 | |||
|     @property | |||
|     def ac_voltage_0(self): | |||
|         return self.unpack('>H', 14)[0]/10 | |||
|     @property | |||
|     def ac_current_0(self): | |||
|         return self.unpack('>H', 22)[0]/100 | |||
|     @property | |||
|     def ac_power_0(self): | |||
|         return self.unpack('>H', 18)[0]/10 | |||
|     @property | |||
|     def frequency(self): | |||
|         return self.unpack('>H', 16)[0]/100 | |||
|     @property | |||
|     def temperature(self): | |||
|         return self.unpack('>H', 26)[0]/10 | |||
| 
 | |||
| 
 | |||
| # 1141-Series Inverters, 2 MPPT, 1 Phase | |||
| 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 | |||
| 
 | |||
| 
 | |||
| # 1161-Series Inverters, 4 MPPT, 1 Phase | |||
| class HM1200_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', 8)[0]/10 | |||
|     @property | |||
|     def dc_energy_total_0(self): | |||
|         return self.unpack('>L', 12)[0] | |||
|     @property | |||
|     def dc_energy_daily_0(self): | |||
|         return self.unpack('>H', 20)[0] | |||
| 
 | |||
|     @property | |||
|     def dc_voltage_1(self): | |||
|         return self.unpack('>H', 2)[0]/10 | |||
|     @property | |||
|     def dc_current_1(self): | |||
|         return self.unpack('>H', 4)[0]/100 | |||
|     @property | |||
|     def dc_power_1(self): | |||
|         return self.unpack('>H', 10)[0]/10 | |||
|     @property | |||
|     def dc_energy_total_1(self): | |||
|         return self.unpack('>L', 16)[0] | |||
|     @property | |||
|     def dc_energy_daily_1(self): | |||
|         return self.unpack('>H', 22)[0] | |||
| 
 | |||
|     @property | |||
|     def dc_voltage_2(self): | |||
|         return self.unpack('>H', 24)[0]/10 | |||
|     @property | |||
|     def dc_current_2(self): | |||
|         return self.unpack('>H', 26)[0]/100 | |||
|     @property | |||
|     def dc_power_2(self): | |||
|         return self.unpack('>H', 30)[0]/10 | |||
|     @property | |||
|     def dc_energy_total_2(self): | |||
|         return self.unpack('>L', 34)[0] | |||
|     @property | |||
|     def dc_energy_daily_2(self): | |||
|         return self.unpack('>H', 42)[0] | |||
| 
 | |||
|     @property | |||
|     def dc_voltage_3(self): | |||
|         return self.unpack('>H', 24)[0]/10 | |||
|     @property | |||
|     def dc_current_3(self): | |||
|         return self.unpack('>H', 28)[0]/100 | |||
|     @property | |||
|     def dc_power_3(self): | |||
|         return self.unpack('>H', 32)[0]/10 | |||
|     @property | |||
|     def dc_energy_total_3(self): | |||
|         return self.unpack('>L', 38)[0] | |||
|     @property | |||
|     def dc_energy_daily_3(self): | |||
|         return self.unpack('>H', 44)[0] | |||
| 
 | |||
|     @property | |||
|     def ac_voltage_0(self): | |||
|         return self.unpack('>H', 46)[0]/10 | |||
|     @property | |||
|     def ac_current_0(self): | |||
|         return self.unpack('>H', 54)[0]/100 | |||
|     @property | |||
|     def ac_power_0(self): | |||
|         return self.unpack('>H', 50)[0]/10 | |||
|     @property | |||
|     def frequency(self): | |||
|         return self.unpack('>H', 48)[0]/100 | |||
|     @property | |||
|     def temperature(self): | |||
|         return self.unpack('>H', 58)[0]/10 | |||
| @ -1,2 +1,3 @@ | |||
| paho-mqtt | |||
| crcmod | |||
| paho-mqtt>=1.5 | |||
| crcmod>=1.7 | |||
| PyYAML>=5.0 | |||
|  | |||
					Loading…
					
					
				
		Reference in new issue