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