Browse Source

Merge pull request #25 from Sprinterfreak/pypackage

Python Implementation full rewrite
pull/37/head
lumapu 2 years ago
committed by GitHub
parent
commit
904c42a702
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      tools/rpi/.gitignore
  2. 48
      tools/rpi/README.md
  3. 11
      tools/rpi/ahoy.conf.example
  4. 497
      tools/rpi/ahoy.py
  5. 21
      tools/rpi/ahoy.yml.example
  6. 4270
      tools/rpi/example-logs/example.log
  7. 475
      tools/rpi/hoymiles/__init__.py
  8. 311
      tools/rpi/hoymiles/decoders/__init__.py
  9. 5
      tools/rpi/requirements.txt

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

48
tools/rpi/README.md

@ -40,7 +40,35 @@ contact the inverter every second on channel 40, and listen for replies.
Whenever it sees a reply, it will decoded and logged to the given log file.
$ sudo python3 ahoy.py | tee -a log2.log
$ sudo python3 ahoy.py --config /home/dtu/ahoy.yml | tee -a log2.log
Inject payloads via MQTT
------------------------
To enable mqtt payload injection, this must be configured per inverter
```yaml
...
inverters:
...
- serial: 1147112345
mqtt:
send_raw_enabled: true
...
```
This can be used to inject debug payloads
The message must be in hexlified format
Use of variables:
* tttttttt expands to current time like we know from our `80 0b` command
Example injects exactly the same as we normally use to poll data
$ mosquitto_pub -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000
This allows for even faster hacking during runtime
@ -49,12 +77,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,9 +93,9 @@ 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.yml
An example is provided as ahoy.yml.example
Todo
@ -78,6 +107,7 @@ Todo
- configurable polling interval
- commands
- picture of setup!
- python module
- ...

11
tools/rpi/ahoy.conf.example

@ -1,11 +0,0 @@
[mqtt]
host = 192.168.84.2
port = 1883
user = bla
password = blub
[dtu]
serial = 99978563412
[inverter]
serial = 444473104619

497
tools/rpi/ahoy.py

@ -1,345 +1,212 @@
"""
First attempt at providing basic 'master' ('DTU') functionality
for Hoymiles micro inverters.
Based in particular on demostrated first contact by 'of22'.
"""
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import argparse
import time
import struct
import crcmod
import json
import re
import time
from datetime import datetime
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS
import argparse
import hoymiles
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
import yaml
from yaml.loader import SafeLoader
cfg = ConfigParser()
cfg.read('ahoy.conf')
mqtt_host = cfg.get('mqtt', 'host', fallback='192.168.1.1')
mqtt_port = cfg.getint('mqtt', 'port', fallback=1883)
mqtt_user = cfg.get('mqtt', 'user', fallback='')
mqtt_password = cfg.get('mqtt', 'password', fallback='')
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway')
parser.add_argument("-c", "--config-file", nargs="?",
help="configuration file")
global_config = parser.parse_args()
radio = RF24(22, 0, 1000000)
mqtt_client = paho.mqtt.client.Client()
mqtt_client.username_pw_set(mqtt_user, mqtt_password)
mqtt_client.connect(mqtt_host, mqtt_port)
mqtt_client.loop_start()
# Master Address ('DTU')
dtu_ser = cfg.get('dtu', 'serial', fallback='99978563412') # identical to fc22's
# inverter serial numbers
inv_ser = cfg.get('inverter', 'serial', fallback='444473104619') # my inverter
if global_config.config_file:
with open(global_config.config_file) as yf:
cfg = yaml.load(yf, Loader=SafeLoader)
else:
with open(global_config.config_file) as yf:
cfg = yaml.load('ahoy.yml', Loader=SafeLoader)
# all inverters
#...
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
radio = RF24(22, 0, 1000000)
hmradio = hoymiles.HoymilesNRF(device=radio)
mqtt_client = None
command_queue = {}
mqtt_command_topic_subs = []
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)
hoymiles.HOYMILES_TRANSACTION_LOGGING=True
hoymiles.HOYMILES_DEBUG_LOGGING=True
def main_loop():
inverters = [
inverter for inverter in ahoy_config.get('inverters', [])
if not inverter.get('disabled', False)]
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]
for inverter in inverters:
if hoymiles.HOYMILES_DEBUG_LOGGING:
print(f'Poll inverter {inverter["serial"]}')
poll_inverter(inverter)
def poll_inverter(inverter):
inverter_ser = inverter.get('serial')
dtu_ser = ahoy_config.get('dtu', {}).get('serial')
def compose_0x80_msg(dst_ser_no=72220200, src_ser_no=72220200, ts=None):
"""
Create a valid 0x80 request with the given parameters, and containing the
current system time.
if len(command_queue[str(inverter_ser)]) > 0:
payload = command_queue[str(inverter_ser)].pop(0)
else:
payload = hoymiles.compose_set_time_payload()
payload_ttl = 4
while payload_ttl > 0:
payload_ttl = payload_ttl - 1
com = hoymiles.InverterTransaction(
radio=hmradio,
dtu_ser=dtu_ser,
inverter_ser=inverter_ser,
request=next(hoymiles.compose_esb_packet(
payload,
seq=b'\x80',
src=dtu_ser,
dst=inverter_ser
)))
response = None
while com.rxtx():
try:
response = com.get_payload()
payload_ttl = 0
except Exception as e:
print(f'Error while retrieving data: {e}')
pass
if response:
dt = datetime.now()
print(f'{dt} Payload: ' + hoymiles.hexify_payload(response))
decoder = hoymiles.ResponseDecoder(response,
request=com.request,
inverter_ser=inverter_ser
)
result = decoder.decode()
if isinstance(result, hoymiles.decoders.StatusResponse):
data = result.__dict__()
if hoymiles.HOYMILES_DEBUG_LOGGING:
print(f'{dt} Decoded: {data["temperature"]}', end='')
phase_id = 0
for phase in data['phases']:
print(f' phase{phase_id}=voltage:{phase["voltage"]}, current:{phase["current"]}, power:{phase["power"]}, frequency:{data["frequency"]}', end='')
phase_id = phase_id + 1
string_id = 0
for string in data['strings']:
print(f' string{string_id}=voltage:{string["voltage"]}, current:{string["current"]}, power:{string["power"]}, total:{string["energy_total"]/1000}, daily:{string["energy_daily"]}', end='')
string_id = string_id + 1
print()
if mqtt_client:
mqtt_send_status(mqtt_client, inverter_ser, data,
topic=inverter.get('mqtt', {}).get('topic', None))
def mqtt_send_status(broker, inverter_ser, data, topic=None):
""" Publish StatusResponse object """
if not topic:
topic = f'hoymiles/{inverter_ser}'
# AC Data
phase_id = 0
for phase in data['phases']:
broker.publish(f'{topic}/emeter/{phase_id}/power', phase['power'])
broker.publish(f'{topic}/emeter/{phase_id}/voltage', phase['voltage'])
broker.publish(f'{topic}/emeter/{phase_id}/current', phase['current'])
phase_id = phase_id + 1
# DC Data
string_id = 0
for string in data['strings']:
broker.publish(f'{topic}/emeter-dc/{string_id}/total', string['energy_total']/1000)
broker.publish(f'{topic}/emeter-dc/{string_id}/power', string['power'])
broker.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage'])
broker.publish(f'{topic}/emeter-dc/{string_id}/current', string['current'])
string_id = string_id + 1
# Global
broker.publish(f'{topic}/frequency', data['frequency'])
broker.publish(f'{topic}/temperature', data['temperature'])
def mqtt_on_command(client, userdata, message):
"""
Handle commands to topic
hoymiles/{inverter_ser}/command
frame a payload and put onto command_queue
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 = b'\x0b\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'
Inverters must have mqtt.send_raw_enabled: true configured
# CRC_M
crc_m = f_crc_m(pp)
This can be used to inject debug payloads
The message must be in hexlified format
p = p + pp
p = p + struct.pack('>H', crc_m)
Use of variables:
tttttttt gets expanded to a current int(time)
crc8 = f_crc8(p)
p = p + struct.pack('B', crc8)
return p
Example injects exactly the same as we normally use to poll data:
mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000
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, ch_rx=None, ch_tx=None):
This allows for even faster hacking during runtime
"""
Callback: get's invoked whenever a 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()
d['ts_unixtime'] = ts_unixtime
d['isodate'] = ts.isoformat()
d['rawdata'] = " ".join([f"{b:02x}" for b in p])
print(ts.isoformat(), end='Z ')
# 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
d['response_time_ns'] = t_now_ns-t_last_tx
d['ch_rx'] = ch_rx
d['ch_tx'] = ch_tx
d['src'] = 'src_unkn'
d['name'] = 'name_unkn'
if mid == 0x95:
src, dst, cmd = struct.unpack('>LLB', p[1:10])
d['src'] = f'{src:08x}'
d['dst'] = f'{dst:08x}'
d['cmd'] = cmd
print(f'MSG src={d["src"]}, dst={d["dst"]}, cmd={d["cmd"]}:')
if cmd==1:
d['name'] = 'dcdata'
unknown1, u1, i1, p1, u2, i2, p2, unknown2 = struct.unpack(
'>HHHHHHHH', p[10:26])
d['u1_V'] = u1/10
d['i1_A'] = i1/100
d['p1_W'] = p1/10
d['u2_V'] = u2/10
d['i2_A'] = i2/100
d['p2_W'] = p2/10
d['p_W'] = d['p1_W']+d['p2_W']
d['unknown1'] = unknown1
d['unknown2'] = unknown2
elif cmd==2:
d['name'] = 'acdata'
uk1, uk2, uk3, uk4, uk5, u, f, p = struct.unpack(
'>HHHHHHHH', p[10:26])
d['u_V'] = u/10
d['f_Hz'] = f/100
d['p_W'] = p/10
d['wtot1_Wh'] = uk1
d['wtot2_Wh'] = uk3
d['wday1_Wh'] = uk4
d['wday2_Wh'] = uk5
d['uk2'] = uk2
elif cmd==129:
d['name'] = 'error'
elif cmd==131: # 0x83
d['name'] = 'statedata'
uk1, l, uk3, t, uk5, uk6 = struct.unpack('>HHHHHH', p[10:22])
d['l_Pct'] = l
d['t_C'] = t/10
d['uk1'] = uk1
d['uk3'] = uk3
d['uk5'] = uk5
d['uk6'] = uk6
elif cmd==132: # 0x84
d['name'] = 'unknown0x84'
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
'>HHHHHHHH', p[10:26])
d['uk1'] = uk1
d['uk2'] = uk2
d['uk3'] = uk3
d['uk4'] = uk4
d['uk5'] = uk5
d['uk6'] = uk6
d['uk7'] = uk7
d['uk8'] = uk8
else:
print(f'unknown cmd {cmd}')
else:
print(f'unknown frame id {p[0]}')
# output to stdout
if d:
print(json.dumps(d))
# output to MQTT
if d:
j = json.dumps(d)
mqtt_client.publish(f"ahoy/{d['src']}/{d['name']}", j)
if d['cmd']==2:
mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/voltage', d['u_V'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/power', d['p_W'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter/0/total', d['wtot1_Wh'])
mqtt_client.publish(f'ahoy/{d["src"]}/frequency', d['f_Hz'])
if d['cmd']==1:
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/power', d['p1_W'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/voltage', d['u1_V'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/0/current', d['i1_A'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/power', d['p2_W'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/voltage', d['u2_V'])
mqtt_client.publish(f'ahoy/{d["src"]}/emeter-dc/1/current', d['i2_A'])
if d['cmd']==131:
mqtt_client.publish(f'ahoy/{d["src"]}/temperature', d['t_C'])
try:
inverter_ser = next(
item[0] for item in mqtt_command_topic_subs if item[1] == message.topic)
except StopIteration:
print('Unexpedtedly received mqtt message for {message.topic}')
if inverter_ser:
p_message = message.payload.decode('utf-8').lower()
# Expand tttttttt to current time for use in hexlified payload
expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time())))
p_message = p_message.replace('tttttttt', expand_time)
if (len(p_message) < 2048 \
and len(p_message) % 2 == 0 \
and re.match(r'^[a-f0-9]+$', p_message)):
payload = bytes.fromhex(p_message)
# commands must start with \x80
if payload[0] == 0x80:
command_queue[str(inverter_ser)].append(
hoymiles.frame_payload(payload[1:]))
if __name__ == '__main__':
ahoy_config = dict(cfg.get('ahoy', {}))
mqtt_config = ahoy_config.get('mqtt', [])
if not mqtt_config.get('disabled', False):
mqtt_client = paho.mqtt.client.Client()
mqtt_client.username_pw_set(mqtt_config.get('user', None), mqtt_config.get('password', None))
mqtt_client.connect(mqtt_config.get('host', '127.0.0.1'), mqtt_config.get('port', 1883))
mqtt_client.loop_start()
mqtt_client.on_message = mqtt_on_command
def main_loop():
"""
Keep receiving on channel 3. Every once in a while, transmit a request
to one of our inverters on channel 40.
"""
if not radio.begin():
raise RuntimeError('Can\'t open radio')
global t_last_tx
print_addr(inv_ser)
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_channel_id = 0
rx_channel = rx_channels[rx_channel_id]
tx_channels = [40]
tx_channel_id = 0
tx_channel = tx_channels[tx_channel_id]
while True:
# Sweep receive start channel
rx_channel_id = ctr % len(rx_channels)
rx_channel = rx_channels[rx_channel_id]
radio.setChannel(rx_channel)
radio.enableDynamicPayloads()
radio.setAutoAck(False)
radio.setPALevel(RF24_PA_MAX)
radio.setDataRate(RF24_250KBPS)
radio.openWritingPipe(ser_to_esb_addr(inv_ser))
radio.flush_rx()
radio.flush_tx()
radio.openReadingPipe(1,ser_to_esb_addr(dtu_ser))
radio.startListening()
tx_channel_id = tx_channel_id + 1
if tx_channel_id >= len(tx_channels):
tx_channel_id = 0
tx_channel = tx_channels[tx_channel_id]
inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])]
for inverter in ahoy_config.get('inverters', []):
inverter_ser = inverter.get('serial')
command_queue[str(inverter_ser)] = []
#
# TX
# Enables and subscribe inverter to mqtt /command-Topic
#
radio.stopListening() # put radio in TX mode
radio.setChannel(tx_channel)
radio.openWritingPipe(ser_to_esb_addr(inv_ser))
ts = int(time.time())
payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, ts=ts)
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
last_tx_message = f"{dt} Transmit {ctr:5d}: channel={tx_channel} len={len(payload)} | " + \
" ".join([f"{b:02x}" for b in payload]) + f" rx_ch: {rx_channel}"
print(last_tx_message)
# for i in range(0,3):
result = radio.write(payload) # will always yield 'True' because auto-ack is disabled
# time.sleep(.05)
t_last_tx = time.monotonic_ns()
ctr = ctr + 1
t_end = time.monotonic_ns()+5e9
tslots = [1000] #, 40, 50, 60, 70] # switch channel at these ms times since transmission
for tslot in tslots:
t_end = t_last_tx + tslot*1e6 # ms to ns
radio.stopListening()
radio.setChannel(rx_channel)
radio.startListening()
while time.monotonic_ns() < t_end:
has_payload, pipe_number = radio.available_pipe()
if has_payload:
size = radio.getDynamicPayloadSize()
payload = radio.read(size)
# print(last_tx_message, end='')
last_tx_message = ''
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
print(f"{dt} Received {size} bytes on channel {rx_channel} pipe {pipe_number}: " +
" ".join([f"{b:02x}" for b in payload]))
on_receive(payload, ch_rx=rx_channel, ch_tx=tx_channel)
else:
pass
# time.sleep(0.001)
rx_channel_id = rx_channel_id + 1
if rx_channel_id >= len(rx_channels):
rx_channel_id = 0
rx_channel = rx_channels[rx_channel_id]
print(flush=True, end='')
# time.sleep(2)
if __name__ == "__main__":
if not radio.begin():
raise RuntimeError("radio hardware is not responding")
radio.setPALevel(RF24_PA_LOW) # RF24_PA_MAX is default
# radio.printDetails(); # (smaller) function that prints raw register values
# radio.printPrettyDetails(); # (larger) function that prints human readable data
if inverter.get('mqtt', {}).get('send_raw_enabled', False):
topic_item = (
str(inverter_ser),
inverter.get('mqtt', {}).get('topic', f'hoymiles/{inverter_ser}') + '/command'
)
mqtt_client.subscribe(topic_item[1])
mqtt_command_topic_subs.append(topic_item)
loop_interval = ahoy_config.get('interval', 1)
try:
main_loop()
while True:
main_loop()
if loop_interval:
time.sleep(time.time() % loop_interval)
except KeyboardInterrupt:
print(" Keyboard Interrupt detected. Exiting...")
radio.powerDown()
sys.exit()

21
tools/rpi/ahoy.yml.example

@ -0,0 +1,21 @@
---
ahoy:
interval: 0
sunset: true
mqtt:
disabled: false
host: example-broker.local
port: 1883
user: 'username'
password: 'password'
dtu:
serial: 99978563001
inverters:
- name: 'balkon'
serial: 114172220003
mqtt:
send_raw_enabled: false # allow inject debug data via mqtt
topic: 'hoymiles/114172221234' # defaults to 'hoymiles/{serial}'

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

File diff suppressed because it is too large

475
tools/rpi/hoymiles/__init__.py

@ -0,0 +1,475 @@
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
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 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 ResponseDecoderFactory:
model = None
request = None
response = None
def __init__(self, response, **params):
self.response = response
if 'request' in params:
self.request = params['request']
elif hasattr(response, 'request'):
self.request = response.request
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 inverter_model(self):
if not self.inverter_ser:
raise ValueError('Inverter serial while decoding response')
ser_db = [
('HM300', r'^1121........'),
('HM600', r'^1141........'),
('HM1200', r'^1161........'),
]
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 request_command(self):
r_code = self.request[10]
return f'{r_code:02x}'
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_decoders = __import__(f'hoymiles.decoders')
if hasattr(model_decoders, f'{model}_Decode{command.upper()}'):
device = getattr(model_decoders, f'{model}_Decode{command.upper()}')
else:
if HOYMILES_DEBUG_LOGGING:
device = getattr(model_decoders, f'DEBUG_DecodeAny')
return device(self.response)
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 HoymilesNRF:
tx_channel_id = 0
tx_channel_list = [40]
rx_channel_id = 0
rx_channel_list = [3,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 > 1:
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=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.inverter_addr = ser_to_hm_addr(inverter_ser)
self.dtu_ser = dtu_ser
if dtu_ser:
self.dtu_addr = ser_to_hm_addr(dtu_ser)
self.request = None
if 'request' in params:
self.request = params['request']
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.inverter_addr
# 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(f'Missing packet: Last packet {len(self.scratch)}')
# Rebuild payload from unordered frames
payload = b''
for frame_id in range(1, tr_len):
try:
data_frame = next(item for item in frames if item.seq == frame_id)
payload = payload + data_frame.data
except StopIteration:
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 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)
return f'{dt} Transmit | {hexify_payload(self.request)}'
def hexify_payload(byte_var):
return ' '.join([f"{b:02x}" for b in byte_var])

311
tools/rpi/hoymiles/decoders/__init__.py

@ -0,0 +1,311 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import struct
class StatusResponse:
e_keys = ['voltage','current','power','energy_total','energy_daily']
def unpack(self, fmt, base):
size = struct.calcsize(fmt)
return struct.unpack(fmt, self.response[base:base+size])
@property
def phases(self):
phases = []
p_exists = True
while p_exists:
p_exists = False
phase_id = len(phases)
phase = {}
for key in self.e_keys:
prop = f'ac_{key}_{phase_id}'
if hasattr(self, prop):
p_exists = True
phase[key] = getattr(self, prop)
if p_exists:
phases.append(phase)
return phases
@property
def strings(self):
strings = []
s_exists = True
while s_exists:
s_exists = False
string_id = len(strings)
string = {}
for key in self.e_keys:
prop = f'dc_{key}_{string_id}'
if hasattr(self, prop):
s_exists = True
string[key] = getattr(self, prop)
if s_exists:
strings.append(string)
return strings
def __dict__(self):
data = {}
data['phases'] = self.phases
data['strings'] = self.strings
data['temperature'] = self.temperature
data['frequency'] = self.frequency
return data
class UnknownResponse:
@property
def hex_ascii(self):
return ' '.join([f'{b:02x}' for b in self.response])
@property
def dump_longs(self):
res = self.response
n = len(res)/4
vals = None
if n % 4 == 0:
vals = struct.unpack(f'>{int(n)}L', res)
return vals
@property
def dump_longs_pad1(self):
res = self.response[1:]
n = len(res)/4
vals = None
if n % 4 == 0:
vals = struct.unpack(f'>{int(n)}L', res)
return vals
@property
def dump_shorts(self):
n = len(self.response)/2
vals = None
if n % 2 == 0:
vals = struct.unpack(f'>{int(n)}H', self.response)
return vals
@property
def dump_shorts_pad1(self):
res = self.response[1:]
n = len(res)/2
vals = None
if n % 2 == 0:
vals = struct.unpack(f'>{int(n)}H', res)
return vals
class DEBUG_DecodeAny(UnknownResponse):
def __init__(self, response):
self.response = response
longs = self.dump_longs
if not longs:
print(' type long : unable to decode (len or not mod 4)')
else:
print(' type long : ' + str(longs))
longs = self.dump_longs_pad1
if not longs:
print(' type long pad1 : unable to decode (len or not mod 4)')
else:
print(' type long pad1 : ' + str(longs))
shorts = self.dump_shorts
if not shorts:
print(' type short : unable to decode (len or not mod 2)')
else:
print(' type short : ' + str(shorts))
shorts = self.dump_shorts_pad1
if not shorts:
print(' type short pad1: unable to decode (len or not mod 2)')
else:
print(' type short pad1: ' + str(shorts))
# 1121-Series Intervers, 1 MPPT, 1 Phase
class HM300_Decode0B(StatusResponse):
def __init__(self, response):
self.response = response
@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', 8)[0]
@property
def dc_energy_daily_0(self):
return self.unpack('>H', 12)[0]
@property
def ac_voltage_0(self):
return self.unpack('>H', 14)[0]/10
@property
def ac_current_0(self):
return self.unpack('>H', 22)[0]/100
@property
def ac_power_0(self):
return self.unpack('>H', 18)[0]/10
@property
def frequency(self):
return self.unpack('>H', 16)[0]/100
@property
def temperature(self):
return self.unpack('>H', 26)[0]/10
# 1141-Series Inverters, 2 MPPT, 1 Phase
class HM600_Decode0B(StatusResponse):
def __init__(self, response):
self.response = response
@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 frequency(self):
return self.unpack('>H', 28)[0]/100
@property
def temperature(self):
return self.unpack('>H', 38)[0]/10
class HM600_Decode0C(HM600_Decode0B):
def __init__(self, response):
self.response = response
# 1161-Series Inverters, 4 MPPT, 1 Phase
class HM1200_Decode0B(StatusResponse):
def __init__(self, response):
self.response = response
@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', 8)[0]/10
@property
def dc_energy_total_0(self):
return self.unpack('>L', 12)[0]
@property
def dc_energy_daily_0(self):
return self.unpack('>H', 20)[0]
@property
def dc_voltage_1(self):
return self.unpack('>H', 2)[0]/10
@property
def dc_current_1(self):
return self.unpack('>H', 4)[0]/100
@property
def dc_power_1(self):
return self.unpack('>H', 10)[0]/10
@property
def dc_energy_total_1(self):
return self.unpack('>L', 16)[0]
@property
def dc_energy_daily_1(self):
return self.unpack('>H', 22)[0]
@property
def dc_voltage_2(self):
return self.unpack('>H', 24)[0]/10
@property
def dc_current_2(self):
return self.unpack('>H', 26)[0]/100
@property
def dc_power_2(self):
return self.unpack('>H', 30)[0]/10
@property
def dc_energy_total_2(self):
return self.unpack('>L', 34)[0]
@property
def dc_energy_daily_2(self):
return self.unpack('>H', 42)[0]
@property
def dc_voltage_3(self):
return self.unpack('>H', 24)[0]/10
@property
def dc_current_3(self):
return self.unpack('>H', 28)[0]/100
@property
def dc_power_3(self):
return self.unpack('>H', 32)[0]/10
@property
def dc_energy_total_3(self):
return self.unpack('>L', 38)[0]
@property
def dc_energy_daily_3(self):
return self.unpack('>H', 44)[0]
@property
def ac_voltage_0(self):
return self.unpack('>H', 46)[0]/10
@property
def ac_current_0(self):
return self.unpack('>H', 54)[0]/100
@property
def ac_power_0(self):
return self.unpack('>H', 50)[0]/10
@property
def frequency(self):
return self.unpack('>H', 48)[0]/100
@property
def temperature(self):
return self.unpack('>H', 58)[0]/10

5
tools/rpi/requirements.txt

@ -1,2 +1,3 @@
paho-mqtt
crcmod
paho-mqtt>=1.5
crcmod>=1.7
PyYAML>=5.0

Loading…
Cancel
Save