mirror of https://github.com/lumapu/ahoy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
281 lines
7.7 KiB
281 lines
7.7 KiB
2 years ago
|
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}'
|