Browse Source

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.
pull/25/head
Jan-Jonas Sämann 3 years ago
parent
commit
9d75ca0c34
  1. 6
      tools/rpi/README.md
  2. 11
      tools/rpi/ahoy.conf.example
  3. 351
      tools/rpi/ahoy.py
  4. 20
      tools/rpi/ahoy.yml.example
  5. 405
      tools/rpi/hoymiles/__init__.py
  6. 143
      tools/rpi/hoymiles/decoders/__init__.py
  7. 15
      tools/rpi/hoymiles/factory/__init__.py

6
tools/rpi/README.md

@ -40,7 +40,7 @@ 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. 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
@ -65,8 +65,8 @@ A brief example log is supplied in the `example-logs` folder.
Configuration Configuration
------------- -------------
Local settings are read from ~/ahoy.conf Local settings are read from ahoy.yml
An example is provided as ahoy.conf.example An example is provided as ahoy.yml.example
Todo Todo
---- ----

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

351
tools/rpi/ahoy.py

@ -1,239 +1,162 @@
""" #!/usr/bin/env python3
First attempt at providing basic 'master' ('DTU') functionality # -*- coding: utf-8 -*-
for Hoymiles micro inverters.
Based in particular on demostrated first contact by 'of22'.
"""
import sys import sys
import argparse
import time import time
import struct
import crcmod
import json
from datetime import datetime from datetime import datetime
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 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 import paho.mqtt.client
from configparser import ConfigParser import yaml
#from hoymiles import ser_to_hm_addr, ser_to_esb_addr from yaml.loader import SafeLoader
import hoymiles
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='')
radio = RF24(22, 0, 1000000) parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway')
mqtt_client = paho.mqtt.client.Client() parser.add_argument("-c", "--config-file", nargs="?",
mqtt_client.username_pw_set(mqtt_user, mqtt_password) help="configuration file")
mqtt_client.connect(mqtt_host, mqtt_port) global_config = parser.parse_args()
mqtt_client.loop_start()
# Master Address ('DTU')
dtu_ser = cfg.get('dtu', 'serial', fallback='99978563412') # identical to fc22's
# inverter serial numbers if global_config.config_file:
inv_ser = cfg.get('inverter', 'serial', fallback='444473104619') # my inverter 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 radio = RF24(22, 0, 1000000)
#... hmradio = hoymiles.HoymilesNRF(device=radio)
mqtt_client = None
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') command_queue = {}
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
# time of last transmission - to calculcate response time hoymiles.HOYMILES_TRANSACTION_LOGGING=True
t_last_tx = 0 hoymiles.HOYMILES_DEBUG_LOGGING=True
def main_loop(): def main_loop():
inverters = [
inverter for inverter in ahoy_config.get('inverters', [])
if not inverter.get('disabled', False)]
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')
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)
def mqtt_send_status(broker, interter_ser, data):
topic = f'ahoy/{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():
""" """
Keep receiving on channel 3. Every once in a while, transmit a request Handle commands to topic
to one of our inverters on channel 40. ahoy/{inverter_ser}/command
frame it and put onto command_queue
""" """
raise NotImplementedError('Receiving mqtt commands is yet to be implemented')
global t_last_tx if __name__ == '__main__':
ahoy_config = dict(cfg.get('ahoy', {}))
hoymiles.print_addr(inv_ser)
hoymiles.print_addr(dtu_ser) mqtt_config = ahoy_config.get('mqtt', [])
if mqtt_config.get('disabled', True):
ctr = 1 mqtt_client = paho.mqtt.client.Client()
last_tx_message = '' 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))
rx_channels = [3,6,9,11,23,40,61,75] mqtt_client.loop_start()
rx_channel_id = 0
rx_channel = rx_channels[rx_channel_id]
rx_channel_ack = None
rx_error = 0
tx_channels = [40]
tx_channel_id = 0
tx_channel = tx_channels[tx_channel_id]
radio.setChannel(rx_channel)
radio.setRetries(10, 2)
radio.setPALevel(RF24_PA_LOW)
#radio.setPALevel(RF24_PA_MAX)
radio.setDataRate(RF24_250KBPS)
radio.openReadingPipe(1,hoymiles.ser_to_esb_addr(dtu_ser))
radio.openWritingPipe(hoymiles.ser_to_esb_addr(inv_ser))
while True:
m_buf = []
# 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]
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]
# 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(com.request)
t_last_tx = t_tx_end = time.monotonic_ns()
ctr = ctr + 1
# 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()+2e8
size = radio.getDynamicPayloadSize()
payload = radio.read(size)
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)
else:
# No data in nRF rx buffer, search and wait
# Channel lock in (not currently used)
rx_error = rx_error + 1
if rx_error > 0:
rx_channel_ack = None
# Channel hopping
if not rx_channel_ack:
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]
radio.stopListening()
radio.setChannel(rx_channel)
radio.startListening()
time.sleep(0.005)
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__":
if not radio.begin(): if not radio.begin():
raise RuntimeError("radio hardware is not responding") raise RuntimeError('Can\'t open radio')
radio.setPALevel(RF24_PA_LOW) # RF24_PA_MAX is default #command_queue.append(hoymiles.compose_02_payload())
#command_queue.append(hoymiles.compose_11_payload())
# radio.printDetails(); # (smaller) function that prints raw register values inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])]
# radio.printPrettyDetails(); # (larger) function that prints human readable data for inverter_ser in inverters:
command_queue[str(inverter_ser)] = []
loop_interval = ahoy_config.get('interval', 1)
try: try:
main_loop() while True:
main_loop()
if loop_interval:
time.sleep(time.time() % loop_interval)
except KeyboardInterrupt: except KeyboardInterrupt:
print(" Keyboard Interrupt detected. Exiting...")
radio.powerDown() radio.powerDown()
sys.exit() sys.exit()

20
tools/rpi/ahoy.yml.example

@ -0,0 +1,20 @@
---
ahoy:
interval: 0
sunset: true
mqtt:
disabled: false
host: example-broker.local
port: 1883
user: 'username'
password: 'password'
dtu:
serial: 99978563000
inverters:
- name: 'balkon'
serial: 114172220003
mqtt:
topic: 'ahoy/114172220143' # defaults to 'ahoy/{serial}'

405
tools/rpi/hoymiles/__init__.py

@ -2,13 +2,18 @@ import struct
import crcmod import crcmod
import json import json
import time import time
import re
from datetime import datetime 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 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_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
HOYMILES_TRANSACTION_LOGGING=True
HOYMILES_DEBUG_LOGGING=True
def ser_to_hm_addr(s): def ser_to_hm_addr(s):
""" """
Calculate the 4 bytes that the HM devices use in their internal messages to 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) bcd = int(str(s)[-8:], base=16)
return struct.pack('>L', bcd) return struct.pack('>L', bcd)
def ser_to_esb_addr(s): def ser_to_esb_addr(s):
""" """
Convert a Hoymiles inverter/DTU serial number into its 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' air_order = ser_to_hm_addr(s)[::-1] + b'\x01'
return air_order[::-1] 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): def print_addr(a):
print(f"ser# {a} ", end='') print(f"ser# {a} ", end='')
print(f" -> HM {' '.join([f'{x:02x}' for x in ser_to_hm_addr(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 # time of last transmission - to calculcate response time
t_last_tx = 0 t_last_tx = 0
class hm600_02_response_decode: class ResponseDecoderFactory:
""" TBD """ model = None
def __init__(self, response): request = None
self.response = response response = None
class hm600_11_response_decode: def __init__(self, response, **params):
""" TBD """
def __init__(self, response):
self.response = response self.response = response
class hm600_0b_response_decode: if 'request' in params:
def __init__(self, response): self.request = params['request']
self.response = response 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): def unpack(self, fmt, base):
size = struct.calcsize(fmt) size = struct.calcsize(fmt)
return struct.unpack(fmt, self.response[base:base+size]) return struct.unpack(fmt, self.response[base:base+size])
@property @property
def dc_voltage_0(self): def inverter_model(self):
return self.unpack('>H', 2)[0]/10 if not self.inverter_ser:
@property raise ValueError('Inverter serial while decoding response')
def dc_current_0(self):
return self.unpack('>H', 4)[0]/100 ser_db = [
@property ('HM300', r'^112171......'),
def dc_power_0(self): ('HM350', r'^112172......'),
return self.unpack('>H', 6)[0]/10 ('HM600', r'^114172......'),
@property ('HM700', r'^114174......'),
def dc_energy_total_0(self): ('HM1200', r'^116170......')
return self.unpack('>L', 14)[0] ]
@property ser_str = str(self.inverter_ser)
def dc_energy_daily_0(self):
return self.unpack('>H', 22)[0] 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 @property
def dc_voltage_1(self): def request_command(self):
return self.unpack('>H', 8)[0]/10 r_code = self.request[10]
@property return f'{r_code:02x}'
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 class ResponseDecoder(ResponseDecoderFactory):
def ac_voltage_0(self): def __init__(self, response, **params):
return self.unpack('>H', 26)[0]/10 ResponseDecoderFactory.__init__(self, response, **params)
@property
def ac_current_0(self): def decode(self):
return self.unpack('>H', 34)[0]/10 model = self.inverter_model
@property command = self.request_command
def ac_power_0(self):
return self.unpack('>H', 30)[0]/10 model_decoder = __import__(f'hoymiles.decoders')
@property device = getattr(model_decoder, f'{model}_Decode{command.upper()}')
def ac_frequency(self):
return self.unpack('>H', 28)[0]/100 return device(self.response)
@property
def temperature(self):
return self.unpack('>H', 38)[0]/10
class InverterPacketFragment: class InverterPacketFragment:
def __init__(self, time_rx=None, payload=None, ch_rx=None, ch_tx=None, **params): 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]) raw = " ".join([f"{b:02x}" for b in self.frame])
return f"{dt} Received {size} bytes{channel}: {raw}" 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: class InverterTransaction:
tx_queue = []
scratch = []
inverter_ser = None
inverter_addr = None
dtu_ser = None
req_type = None
radio = None
def __init__(self, def __init__(self,
request_time=datetime.now(), request_time=None,
inverter_ser=None, inverter_ser=None,
dtu_ser=None, dtu_ser=None,
radio=None,
**params): **params):
if radio:
self.radio = radio
if not request_time:
request_time=datetime.now()
self.scratch = [] self.scratch = []
if 'scratch' in params: if 'scratch' in params:
self.scratch = params['scratch'] self.scratch = params['scratch']
self.inverter_ser = inverter_ser self.inverter_ser = inverter_ser
if 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 self.dtu_ser = dtu_ser
if dtu_ser: if dtu_ser:
self.dtu_dst = ser_to_hm_addr(dtu_ser) self.dtu_addr = ser_to_hm_addr(dtu_ser)
self.peer_src, self.peer_dst, self.req_type = (None,None,None)
self.request = None self.request = None
if 'request' in params: if 'request' in params:
self.request = params['request'] 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 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): def frame_append(self, payload_frame):
"""
Append received raw frame to local scratch buffer
"""
self.scratch.append(payload_frame) 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): def get_payload(self, src=None):
""" """
Reconstruct Hoymiles payload from scratch Reconstruct Hoymiles payload from scratch
""" """
if not src: if not src:
src = self.peer_src src = self.inverter_addr
# Collect all frames from source_address src # Collect all frames from source_address src
frames = [frame for frame in self.scratch if frame.src == 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 self.time_rx = end_frame.time_rx
tr_len = end_frame.seq - 0x80 tr_len = end_frame.seq - 0x80
except StopIteration: except StopIteration:
raise BufferError('Missing packet: Last packet') raise BufferError(f'Missing packet: Last packet {len(self.scratch)}')
# Rebuild payload from unordered frames # Rebuild payload from unordered frames
payload = b'' payload = b''
seq_missing = [] for frame_id in range(1, tr_len):
for i in range(1, tr_len):
try: 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 payload = payload + data_frame.data
except StopIteration: except StopIteration:
seq_missing.append(i) self.__retransmit_frame(frame_id)
pass raise BufferError(f'Frame {frame_id} missing: Request Retransmit')
payload = payload + end_frame.data payload = payload + end_frame.data
# check crc # check crc
pcrc = struct.unpack('>H', payload[-2:])[0] pcrc = struct.unpack('>H', payload[-2:])[0]
if f_crc_m(payload[:-2]) != pcrc: if f_crc_m(payload[:-2]) != pcrc:
raise BufferError('Payload failed CRC check.') raise ValueError('Payload failed CRC check.')
return payload 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): def __str__(self):
dt = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f") dt = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f")
size = len(self.request) size = len(self.request)
raw = " ".join([f"{b:02x}" for b in self.request]) return f'{dt} Transmit | {hexify_payload(self.request)}'
return f'{dt} Transmit | {raw}'
def hexify_payload(byte_var):
return ' '.join([f"{b:02x}" for b in byte_var])

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

@ -0,0 +1,143 @@
#!/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):
n = len(self.response)/4
vals = struct.unpack(f'>{int(n)}L', self.response)
return vals
@property
def dump_shorts(self):
n = len(self.response)/2
vals = struct.unpack(f'>{int(n)}H', self.response)
return vals
class HM600_Decode02(UnknownResponse):
def __init__(self, response):
self.response = response
class HM600_Decode11(UnknownResponse):
def __init__(self, response):
self.response = response
class HM600_Decode12(UnknownResponse):
def __init__(self, response):
self.response = response
class HM600_Decode0A(UnknownResponse):
def __init__(self, response):
self.response = response
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

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

@ -1,15 +0,0 @@
#!/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
Loading…
Cancel
Save