Browse Source

PoC Hoymiles package full payload decode WIP

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 2 years ago
parent
commit
d7f9f6d3be
  1. 11
      tools/rpi/.gitignore
  2. 18
      tools/rpi/README.md
  3. 489
      tools/rpi/ahoy.py
  4. 4270
      tools/rpi/example-logs/example.log
  5. 280
      tools/rpi/hoymiles/__init__.py
  6. 15
      tools/rpi/hoymiles/factory/__init__.py
  7. 84
      tools/rpi/test.py

11
tools/rpi/.gitignore

@ -0,0 +1,11 @@
# Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual Environment
venv/
# vim leftovers
**.swp

18
tools/rpi/README.md

@ -49,12 +49,13 @@ Analysing the Logs
Use basic command line tools to get an idea what you recorded. For example:
$ cat log2.log | grep 'cmd=2'
$ cat log2.log
[...]
2022-03-28T17:36:53.018058Z MSG src=74608145, dst=74608145, cmd=2, u=235.0V, f=49.98Hz, p=2.5W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
2022-03-28T17:38:07.309501Z MSG src=74608145, dst=74608145, cmd=2, u=234.7V, f=49.99Hz, p=2.3W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
2022-03-28T17:38:24.378337Z MSG src=74608145, dst=74608145, cmd=2, u=234.7V, f=49.98Hz, p=2.2W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
2022-03-28T17:38:34.417683Z MSG src=74608145, dst=74608145, cmd=2, u=234.8V, f=49.98Hz, p=2.2W, uk1=12851, uk2=0, uk3=14267, uk4=1663, uk5=1667
2022-05-02 16:41:16.044179 Transmit | 15 72 22 01 43 78 56 34 12 80 0b 00 62 3c 8e cf 00 00 00 05 00 00 00 00 35 a3 08
2022-05-02 17:01:41.844361 Received 27 bytes on channel 3: 95 72 22 01 43 72 22 01 43 01 00 01 01 44 00 4e 00 fe 01 46 00 4f 01 02 00 00 6b
2022-05-02 17:01:41.886796 Received 27 bytes on channel 75: 95 72 22 01 43 72 22 01 43 02 8f 82 00 00 86 7a 05 fe 06 0b 08 fc 13 8a 01 e9 15
2022-05-02 17:01:41.934667 Received 23 bytes on channel 75: 95 72 22 01 43 72 22 01 43 83 00 00 00 15 03 e8 00 df 03 83 d5 f3 91
2022-05-02 17:01:41.934667 Decoded: 44 string1= 32.4VDC 0.78A 25.4W 36738Wh 1534Wh/day string2= 32.6VDC 0.79A 25.8W 34426Wh 1547Wh/day phase1= 230.0VAC 2.1A 48.9W inverter=114171230143 50.02Hz 22.3°C
[...]
A brief example log is supplied in the `example-logs` folder.
@ -64,10 +65,8 @@ A brief example log is supplied in the `example-logs` folder.
Configuration
-------------
Nothing so far, I'm afraid. You can change the serial number of the inverter
that you are trying to talk to by changing the line that defines the
`inv_ser` variable towards the top of `ahoy.py`.
Local settings are read from ~/ahoy.conf
An example is provided as ahoy.conf.example
Todo
----
@ -78,6 +77,7 @@ Todo
- configurable polling interval
- commands
- picture of setup!
- python module
- ...

489
tools/rpi/ahoy.py

@ -10,9 +10,11 @@ import struct
import crcmod
import json
from datetime import datetime
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS
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
#from hoymiles import ser_to_hm_addr, ser_to_esb_addr
import hoymiles
cfg = ConfigParser()
cfg.read('ahoy.conf')
@ -39,358 +41,9 @@ inv_ser = cfg.get('inverter', 'serial', fallback='444473104619') # my inverter
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 = 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 = 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
def on_receive(p=None, ctr=None, ch_rx=None, ch_tx=None, time_rx=datetime.now(), latency=None):
"""
Callback: get's invoked whenever a Nordic ESB packet has been received.
:param p: Payload of the received packet.
"""
d = {}
t_now_ns = time.monotonic_ns()
ts = datetime.utcnow()
ts_unixtime = ts.timestamp()
size = len(p)
d['ts_unixtime'] = ts_unixtime
d['isodate'] = ts.isoformat()
d['rawdata'] = " ".join([f"{b:02x}" for b in p])
d['trans_id'] = ctr
dt = time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
print(f"{dt} Received {size} bytes on channel {ch_rx} after tx {latency}ns: " +
" ".join([f"{b:02x}" for b in p]))
# 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
name = 'unknowndata'
d['response_time_ns'] = t_now_ns-t_last_tx
d['ch_rx'] = ch_rx
d['ch_tx'] = ch_tx
if mid == 0x95:
decode_hoymiles_hm600(d, p, time_rx=time_rx)
else:
print(f'unknown frame id {p[0]}')
def decode_hoymiles_hm600(d, p, time_rx=datetime.now()):
"""
Decode payload from Hoymiles HM-600
:param d: Pre parsed data from on_receive
:param p: raw payload byte array
:param time_rx: datetime object when packet was received
"""
src, dst, cmd = struct.unpack('>LLB', p[1:10])
src_s = f'{src:08x}'
dst_s = f'{dst:08x}'
d['src'] = src_s
d['dst'] = dst_s
d['cmd'] = cmd
dt = time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
print(f'{dt} Decoder src={src_s}, dst={dst_s}, cmd={cmd}, ', end=' ')
if cmd==1: # 0x01
"""
On HM600 Response to
0x80 0x0b
0x80 0x0c
0x80 0x0d
0x80 0x0f
0x80 0x03 (garbled data)
"""
name = 'dcdata'
uk1, u1, i1, p1, u2, i2, p2, uk2 = struct.unpack(
'>HHHHHHHH', p[10:26])
print(f'u1={u1/10}V, i1={i1/100}A, p1={p1/10}W, ', end='')
print(f'u2={u2/10}V, i2={i2/100}A, p2={p2/10}W, ', end='')
print(f'uk1={uk1}, uk2={uk2}')
d['dc'] = {0: {}, 1: {}}
d['dc'][0]['voltage'] = u1/10
d['dc'][0]['current'] = i1/100
d['dc'][0]['power'] = p1/10
d['dc'][1]['voltage'] = u2/10
d['dc'][1]['current'] = i2/100
d['dc'][1]['power'] = p2/10
d['uk1'] = uk1
d['uk2'] = uk2
elif cmd==2: # 0x02
"""
On HM600 Response to
0x80 0x0b
0x80 0x0c
0x80 0x0d
0x80 0x0f
0x80 0x03 (garbled data)
"""
name = 'acdata'
uk1, uk2, uk3, uk4, uk5, ac_u1, f, ac_p1 = struct.unpack(
'>HHHHHHHH', p[10:26])
print(f'ac_u1={ac_u1/10:.1f}V, ac_f={f/100:.2f}Hz, ac_p1={ac_p1/10:.1f}W, ', end='')
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}')
d['ac'] = {0: {}}
d['ac'][0]['voltage'] = ac_u1/10
d['frequency'] = f/100
d['ac'][0]['power'] = ac_p1/10
d['wtot1_Wh'] = uk1
d['wtot2_Wh'] = uk3
d['wday1_Wh'] = uk4
d['wday2_Wh'] = uk5
d['uk2'] = uk2
elif cmd==3: # 0x03
"""
On HM600 Response to
0x80 0x03 (garbled data)
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==4: # 0x04
"""
On HM600 Response to
0x80 0x03 (garbled data)
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==5: # 0x05
"""
On HM600 Response to
0x80 0x03 (garbled data)
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==6: # 0x06
"""
On HM600 Response to
0x80 0x03 (garbled data)
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==7: # 0x07
"""
On HM600 Response to
0x80 0x03 (garbled data)
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==129 and len(p) == 17: # 0x81
"""
On HM600 Response to
0x80 0x0a
"""
uk1, uk2, uk3 = struct.unpack(
'>HHH', p[10:16])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ')
elif cmd==129: # 0x81
"""
On HM600 Response to
0x80 0x02
0x80 0x11
"""
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
name = 'error'
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
elif cmd==131: # 0x83
"""
On HM600 Response to
0x80 0x0b
0x80 0x0c
0x80 0x0d
0x80 0x0f
"""
name = 'statedata'
uk1, ac_i1, uk3, t, uk5, uk6 = struct.unpack('>HHHHHH', p[10:22])
print(f'ac_i1={ac_i1/100}A, t={t/10:.2f}C, ', end='')
print(f'uk1={uk1}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}')
d['ac'] = {0: {}}
d['ac'][0]['current'] = ac_i1/100
d['temperature'] = t/10
d['uk1'] = uk1
d['uk3'] = uk3
d['uk5'] = uk5
d['uk6'] = uk6
elif cmd==132: # 0x84
name = 'unknown0x84'
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
print(f'uk1={uk1}, ', end='')
print(f'uk2={uk2}, ', end='')
print(f'uk3={uk3}, ', end='')
print(f'uk4={uk4}, ', end='')
print(f'uk5={uk5}, ', end='')
print(f'uk6={uk6}, ', end='')
print(f'uk7={uk7}, ', end='')
print(f'uk8={uk8}')
else:
print(f'unknown cmd {cmd}')
# output to MQTT
if d:
j = json.dumps(d)
mqtt_client.publish(f'ahoy/{src}/debug', j)
if d['cmd']==2:
mqtt_client.publish(f'ahoy/{src}/emeter/0/voltage', d['ac'][0]['voltage'])
mqtt_client.publish(f'ahoy/{src}/emeter/0/power', d['ac'][0]['power'])
mqtt_client.publish(f'ahoy/{src}/emeter/0/total', d['wtot1_Wh'])
mqtt_client.publish(f'ahoy/{src}/frequency', d['frequency'])
if d['cmd']==1:
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/power', d['dc'][0]['power'])
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/voltage', d['dc'][0]['voltage'])
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/current', d['dc'][0]['current'])
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/power', d['dc'][1]['power'])
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/voltage', d['dc'][1]['voltage'])
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/current', d['dc'][1]['current'])
if d['cmd']==131:
mqtt_client.publish(f'ahoy/{src}/temperature', d['temperature'])
mqtt_client.publish(f'ahoy/{src}/emeter/0/current', d['ac'][0]['current'])
def main_loop():
"""
Keep receiving on channel 3. Every once in a while, transmit a request
@ -399,15 +52,13 @@ def main_loop():
global t_last_tx
print_addr(inv_ser)
print_addr(dtu_ser)
hoymiles.print_addr(inv_ser)
hoymiles.print_addr(dtu_ser)
ctr = 1
last_tx_message = ''
ts = int(time.time()) # see what happens if we always send one and the same (constant) time!
rx_channels = [3,23,61,75]
rx_channels = [3,6,9,11,23,40,61,75]
rx_channel_id = 0
rx_channel = rx_channels[rx_channel_id]
rx_channel_ack = None
@ -418,20 +69,16 @@ def main_loop():
tx_channel = tx_channels[tx_channel_id]
radio.setChannel(rx_channel)
radio.enableDynamicPayloads()
radio.setAutoAck(True)
radio.setRetries(15, 2)
radio.setRetries(10, 2)
radio.setPALevel(RF24_PA_LOW)
#radio.setPALevel(RF24_PA_MAX)
radio.setDataRate(RF24_250KBPS)
radio.openReadingPipe(1,ser_to_esb_addr(dtu_ser))
radio.openWritingPipe(ser_to_esb_addr(inv_ser))
radio.openReadingPipe(1,hoymiles.ser_to_esb_addr(dtu_ser))
radio.openWritingPipe(hoymiles.ser_to_esb_addr(inv_ser))
while True:
radio.flush_rx()
radio.flush_tx()
m_buf = []
# Sweep receive start channel
# Channel selection: Sweep receive start channel
if not rx_channel_ack:
rx_channel_id = ctr % len(rx_channels)
rx_channel = rx_channels[rx_channel_id]
@ -441,43 +88,59 @@ def main_loop():
tx_channel_id = 0
tx_channel = tx_channels[tx_channel_id]
# Transmit
ts = int(time.time())
payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, ts=ts, subtype=b'\x0b')
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
# Transmit: Compose data
com = hoymiles.InverterTransaction(
request_time = datetime.now(),
inverter_ser=inv_ser,
request = hoymiles.compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, subtype=b'\x0b')
)
print(com)
# Transmit: Setup radio
radio.stopListening() # put radio in TX mode
radio.setChannel(tx_channel)
radio.setAutoAck(True)
radio.setRetries(3, 15)
radio.setCRCLength(RF24_CRC_16)
radio.enableDynamicPayloads()
# Transmit: Send payload
t_tx_start = time.monotonic_ns()
tx_status = radio.write(payload) # will always yield 'True' because auto-ack is disabled
tx_status = radio.write(com.request)
t_last_tx = t_tx_end = time.monotonic_ns()
radio.setChannel(rx_channel)
radio.startListening()
last_tx_message = f"{dt} Transmit {ctr:5d}: channel={tx_channel} len={len(payload)} ack={tx_status} | " + \
" ".join([f"{b:02x}" for b in payload]) + "\n"
ctr = ctr + 1
# Receive loop
# Receive: Setup radio
radio.setChannel(rx_channel)
radio.setAutoAck(False)
radio.setRetries(0, 0)
radio.enableDynamicPayloads()
radio.setCRCLength(RF24_CRC_16)
radio.startListening()
# Receive: Loop
t_end = time.monotonic_ns()+1e9
while time.monotonic_ns() < t_end:
has_payload, pipe_number = radio.available_pipe()
if has_payload:
# Data in nRF24 buffer, read it
rx_error = 0
rx_channel_ack = rx_channel
t_end = time.monotonic_ns()+6e7
t_end = time.monotonic_ns()+2e8
size = radio.getDynamicPayloadSize()
payload = radio.read(size)
m_buf.append( {
'p': payload,
'ch_rx': rx_channel, 'ch_tx': tx_channel,
'time_rx': datetime.now(), 'latency': time.monotonic_ns()-t_last_tx} )
fragment = hoymiles.InverterPacketFragment(
payload=payload,
ch_rx=rx_channel, ch_tx=tx_channel,
time_rx=datetime.now(),
latency=time.monotonic_ns()-t_last_tx
)
print(fragment)
com.frame_append(fragment)
# Only print last transmittet message if we got any response
print(last_tx_message, end='')
last_tx_message = ''
else:
# No data in nRF rx buffer, search and wait
# Channel lock in (not currently used)
@ -495,15 +158,67 @@ def main_loop():
radio.startListening()
time.sleep(0.005)
# Process receive buffer outside time critical receive loop
for param in m_buf:
on_receive(**param)
# Flush console
print(flush=True, end='')
inv_ser_hm = hoymiles.ser_to_hm_addr(inv_ser)
try:
payload = com.get_payload()
except BufferError:
payload = None
#print("Garbage")
iv = None
if payload:
plen = len(payload)
dt = com.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
iv = hoymiles.hm600_0b_response_decode(payload)
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={com.inverter_ser}', end='')
print(f' {iv.ac_frequency}Hz', end='')
print(f' {iv.temperature}°C', end='')
print()
# output to MQTT
if iv:
src = com.inverter_ser
# AC Data
mqtt_client.publish(f'ahoy/{src}/frequency', iv.ac_frequency)
mqtt_client.publish(f'ahoy/{src}/emeter/0/power', iv.ac_power_0)
mqtt_client.publish(f'ahoy/{src}/emeter/0/voltage', iv.ac_voltage_0)
mqtt_client.publish(f'ahoy/{src}/emeter/0/current', iv.ac_current_0)
mqtt_client.publish(f'ahoy/{src}/emeter/0/total', iv.dc_energy_total_0)
# DC Data
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/total', iv.dc_energy_total_0/1000)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/power', iv.dc_power_0)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/voltage', iv.dc_voltage_0)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/current', iv.dc_current_0)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/total', iv.dc_energy_total_1/1000)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/power', iv.dc_power_1)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/voltage', iv.dc_voltage_1)
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/current', iv.dc_current_1)
# Global
mqtt_client.publish(f'ahoy/{src}/temperature', iv.temperature)
time.sleep(5)
# Flush console
print(flush=True, end='')
if __name__ == "__main__":

4270
tools/rpi/example-logs/example.log

File diff suppressed because it is too large

280
tools/rpi/hoymiles/__init__.py

@ -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}'

15
tools/rpi/hoymiles/factory/__init__.py

@ -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

84
tools/rpi/test.py

@ -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…
Cancel
Save