mirror of https://github.com/lumapu/ahoy.git
Browse Source
Transform ahoy.py into a python library, implements decoding of fragmented large payloads. The module also allows for easier tinkering and replay testing.pull/25/head
Jan-Jonas Sämann
3 years ago
7 changed files with 571 additions and 4598 deletions
@ -0,0 +1,11 @@ |
|||
# Python |
|||
# Byte-compiled / optimized / DLL files |
|||
__pycache__/ |
|||
*.py[cod] |
|||
*$py.class |
|||
|
|||
# Virtual Environment |
|||
venv/ |
|||
|
|||
# vim leftovers |
|||
**.swp |
File diff suppressed because it is too large
@ -0,0 +1,280 @@ |
|||
import struct |
|||
import crcmod |
|||
import json |
|||
import time |
|||
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 |
|||
|
|||
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, 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='') |
|||
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 hm600_02_response_decode: |
|||
""" TBD """ |
|||
def __init__(self, response): |
|||
self.response = response |
|||
|
|||
class hm600_11_response_decode: |
|||
""" TBD """ |
|||
def __init__(self, response): |
|||
self.response = response |
|||
|
|||
class hm600_0b_response_decode: |
|||
def __init__(self, response): |
|||
self.response = response |
|||
|
|||
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] |
|||
|
|||
@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 ac_frequency(self): |
|||
return self.unpack('>H', 28)[0]/100 |
|||
@property |
|||
def temperature(self): |
|||
return self.unpack('>H', 38)[0]/10 |
|||
|
|||
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 InverterTransaction: |
|||
def __init__(self, |
|||
request_time=datetime.now(), |
|||
inverter_ser=None, |
|||
dtu_ser=None, |
|||
**params): |
|||
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.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.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.request_time = request_time |
|||
|
|||
def frame_append(self, payload_frame): |
|||
self.scratch.append(payload_frame) |
|||
|
|||
def get_payload(self, src=None): |
|||
""" |
|||
Reconstruct Hoymiles payload from scratch |
|||
""" |
|||
|
|||
if not src: |
|||
src = self.peer_src |
|||
|
|||
# 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('Missing packet: Last packet') |
|||
|
|||
# Rebuild payload from unordered frames |
|||
payload = b'' |
|||
seq_missing = [] |
|||
for i in range(1, tr_len): |
|||
try: |
|||
data_frame = next(item for item in frames if item.seq == i) |
|||
payload = payload + data_frame.data |
|||
except StopIteration: |
|||
seq_missing.append(i) |
|||
pass |
|||
|
|||
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.') |
|||
|
|||
return payload |
|||
|
|||
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}' |
@ -0,0 +1,15 @@ |
|||
#!/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 |
@ -0,0 +1,84 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
import sys |
|||
import codecs |
|||
import re |
|||
import time |
|||
from datetime import datetime |
|||
import hoymiles |
|||
|
|||
logdata = """ |
|||
2022-05-01 12:29:02.139673 Transmit 368223: channel=40 len=27 ack=False | 15 72 22 01 43 78 56 34 12 80 0b 00 62 6e 60 ee 00 00 00 05 00 00 00 00 7e 58 25 |
|||
2022-05-01 12:29:02.184796 Received 27 bytes on channel 3 after tx 6912328ns: 95 72 22 01 43 72 22 01 43 01 00 01 01 4e 00 9d 02 0a 01 50 00 9d 02 10 00 00 91 |
|||
2022-05-01 12:29:02.184796 Decoder src=72220143, dst=72220143, cmd=1, u1=33.4V, i1=1.57A, p1=52.2W, u2=33.6V, i2=1.57A, p2=52.8W, uk1=1, uk2=0 |
|||
2022-05-01 12:29:02.226251 Received 27 bytes on channel 75 after tx 48355619ns: 95 72 22 01 43 72 22 01 43 02 88 1f 00 00 7f 08 00 94 00 97 08 e2 13 89 03 eb ec |
|||
2022-05-01 12:29:02.226251 Decoder src=72220143, dst=72220143, cmd=2, ac_u1=227.4V, ac_f=50.01Hz, ac_p1=100.3W, uk1=34847, uk2=0, uk3=32520, uk4=148, uk5=151 |
|||
2022-05-01 12:29:02.273766 Received 23 bytes on channel 75 after tx 95876606ns: 95 72 22 01 43 72 22 01 43 83 00 01 00 2c 03 e8 00 d8 00 06 0c 35 37 |
|||
2022-05-01 12:29:02.273766 Decoder src=72220143, dst=72220143, cmd=131, ac_i1=0.44A, t=21.60C, uk1=1, uk3=1000, uk5=6, uk6=3125 |
|||
""" |
|||
|
|||
def payload_from_log(line): |
|||
values = re.match(r'(?P<datetime>\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\.\d+) Received.*: (?P<data>[0-9a-z ]+)$', line) |
|||
if values: |
|||
payload=values.group('data') |
|||
return hoymiles.InverterPacketFragment( |
|||
time_rx=datetime.strptime(values.group('datetime'), '%Y-%m-%d %H:%M:%S.%f'), |
|||
payload=bytes.fromhex(payload) |
|||
) |
|||
|
|||
with open('example-logs/example.log', 'r') as fh: |
|||
for line in fh: |
|||
kind = re.match(r'\d{4}-\d{2}-\d{2} \d\d:\d\d:\d\d.\d+ (?P<type>Transmit|Received)', line) |
|||
if kind: |
|||
if kind.group('type') == 'Transmit': |
|||
u, data = line.split('|') |
|||
rx_buffer = hoymiles.InverterTransaction( |
|||
request=bytes.fromhex(data)) |
|||
|
|||
elif kind.group('type') == 'Received': |
|||
try: |
|||
payload = payload_from_log(line) |
|||
print(payload) |
|||
except BufferError as err: |
|||
print(f'Debug: {err}') |
|||
payload = None |
|||
pass |
|||
if payload: |
|||
rx_buffer.frame_append(payload) |
|||
try: |
|||
#packet = rx_buffer.get_payload(72220143) |
|||
packet = rx_buffer.get_payload() |
|||
except BufferError as err: |
|||
print(f'Debug: {err}') |
|||
packet = None |
|||
pass |
|||
|
|||
if packet: |
|||
plen = len(packet) |
|||
dt = rx_buffer.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f") |
|||
iv = hoymiles.hm600_0b_response_decode(packet) |
|||
|
|||
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=', end='') |
|||
print(f' {iv.ac_frequency}Hz', end='') |
|||
print(f' {iv.temperature}°C', end='') |
|||
print() |
|||
|
|||
print('', end='', flush=True) |
Loading…
Reference in new issue