mirror of
https://github.com/lumapu/ahoy.git
synced 2025-07-30 22:48:27 +02:00
Ahoy Python Rewrite
Is was clear, the cmd approach does not decode payloads reliably. The modular form allows for easy tinkering. This implements * hoymiles protocol * transport-layer enables for retransmit of missed fragments * full payload decode * device specific decoders * transaction tracking enables decoding of different datasets * multi-inverter support * configuration format change to YAML (required for multi-inverter) First PoC, lots of things have to be relocated, rewritten and exteded. Currently only supports Hoymiles HM-600, more device decodes have to be added by users who have the hardware.
This commit is contained in:
parent
d7f9f6d3be
commit
9d75ca0c34
7 changed files with 594 additions and 341 deletions
|
@ -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):
|
||||
class ResponseDecoderFactory:
|
||||
model = None
|
||||
request = None
|
||||
response = None
|
||||
|
||||
def __init__(self, response, **params):
|
||||
self.response = response
|
||||
|
||||
class hm600_11_response_decode:
|
||||
""" TBD """
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
if 'request' in params:
|
||||
self.request = params['request']
|
||||
elif hasattr(response, 'request'):
|
||||
self.request = response.request
|
||||
|
||||
class hm600_0b_response_decode:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
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])
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue