mirror of https://github.com/lumapu/ahoy.git
committed by
GitHub
9 changed files with 1111 additions and 4538 deletions
@ -0,0 +1,11 @@ |
|||||
|
# Python |
||||
|
# Byte-compiled / optimized / DLL files |
||||
|
__pycache__/ |
||||
|
*.py[cod] |
||||
|
*$py.class |
||||
|
|
||||
|
# Virtual Environment |
||||
|
venv/ |
||||
|
|
||||
|
# vim leftovers |
||||
|
**.swp |
@ -1,11 +0,0 @@ |
|||||
[mqtt] |
|
||||
host = 192.168.84.2 |
|
||||
port = 1883 |
|
||||
user = bla |
|
||||
password = blub |
|
||||
|
|
||||
[dtu] |
|
||||
serial = 99978563412 |
|
||||
|
|
||||
[inverter] |
|
||||
serial = 444473104619 |
|
@ -1,345 +1,212 @@ |
|||||
""" |
#!/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 struct |
import struct |
||||
import crcmod |
import re |
||||
import json |
import time |
||||
from datetime import datetime |
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 |
import paho.mqtt.client |
||||
from configparser import ConfigParser |
import yaml |
||||
|
from yaml.loader import SafeLoader |
||||
|
|
||||
cfg = ConfigParser() |
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway') |
||||
cfg.read('ahoy.conf') |
parser.add_argument("-c", "--config-file", nargs="?", |
||||
mqtt_host = cfg.get('mqtt', 'host', fallback='192.168.1.1') |
help="configuration file") |
||||
mqtt_port = cfg.getint('mqtt', 'port', fallback=1883) |
global_config = parser.parse_args() |
||||
mqtt_user = cfg.get('mqtt', 'user', fallback='') |
|
||||
mqtt_password = cfg.get('mqtt', 'password', fallback='') |
|
||||
|
|
||||
radio = RF24(22, 0, 1000000) |
if global_config.config_file: |
||||
mqtt_client = paho.mqtt.client.Client() |
with open(global_config.config_file) as yf: |
||||
mqtt_client.username_pw_set(mqtt_user, mqtt_password) |
cfg = yaml.load(yf, Loader=SafeLoader) |
||||
mqtt_client.connect(mqtt_host, mqtt_port) |
else: |
||||
mqtt_client.loop_start() |
with open(global_config.config_file) as yf: |
||||
|
cfg = yaml.load('ahoy.yml', Loader=SafeLoader) |
||||
# 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 |
|
||||
|
|
||||
# all inverters |
radio = RF24(22, 0, 1000000) |
||||
#... |
hmradio = hoymiles.HoymilesNRF(device=radio) |
||||
|
mqtt_client = None |
||||
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') |
|
||||
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) |
|
||||
|
|
||||
|
command_queue = {} |
||||
|
mqtt_command_topic_subs = [] |
||||
|
|
||||
def ser_to_hm_addr(s): |
hoymiles.HOYMILES_TRANSACTION_LOGGING=True |
||||
""" |
hoymiles.HOYMILES_DEBUG_LOGGING=True |
||||
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 main_loop(): |
||||
|
inverters = [ |
||||
|
inverter for inverter in ahoy_config.get('inverters', []) |
||||
|
if not inverter.get('disabled', False)] |
||||
|
|
||||
def ser_to_esb_addr(s): |
for inverter in inverters: |
||||
""" |
if hoymiles.HOYMILES_DEBUG_LOGGING: |
||||
Convert a Hoymiles inverter/DTU serial number into its |
print(f'Poll inverter {inverter["serial"]}') |
||||
corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes). |
poll_inverter(inverter) |
||||
|
|
||||
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 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): |
if len(command_queue[str(inverter_ser)]) > 0: |
||||
""" |
payload = command_queue[str(inverter_ser)].pop(0) |
||||
Create a valid 0x80 request with the given parameters, and containing the |
else: |
||||
current system time. |
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: |
Inverters must have mqtt.send_raw_enabled: true configured |
||||
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' |
|
||||
|
|
||||
# CRC_M |
This can be used to inject debug payloads |
||||
crc_m = f_crc_m(pp) |
The message must be in hexlified format |
||||
|
|
||||
p = p + pp |
Use of variables: |
||||
p = p + struct.pack('>H', crc_m) |
tttttttt gets expanded to a current int(time) |
||||
|
|
||||
crc8 = f_crc8(p) |
Example injects exactly the same as we normally use to poll data: |
||||
p = p + struct.pack('B', crc8) |
mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000 |
||||
return p |
|
||||
|
|
||||
|
This allows for even faster hacking during runtime |
||||
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): |
|
||||
""" |
""" |
||||
Callback: get's invoked whenever a packet has been received. |
try: |
||||
:param p: Payload of the received packet. |
inverter_ser = next( |
||||
""" |
item[0] for item in mqtt_command_topic_subs if item[1] == message.topic) |
||||
|
except StopIteration: |
||||
d = {} |
print('Unexpedtedly received mqtt message for {message.topic}') |
||||
|
|
||||
t_now_ns = time.monotonic_ns() |
if inverter_ser: |
||||
ts = datetime.utcnow() |
p_message = message.payload.decode('utf-8').lower() |
||||
ts_unixtime = ts.timestamp() |
|
||||
d['ts_unixtime'] = ts_unixtime |
# Expand tttttttt to current time for use in hexlified payload |
||||
d['isodate'] = ts.isoformat() |
expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time()))) |
||||
d['rawdata'] = " ".join([f"{b:02x}" for b in p]) |
p_message = p_message.replace('tttttttt', expand_time) |
||||
print(ts.isoformat(), end='Z ') |
|
||||
|
if (len(p_message) < 2048 \ |
||||
# check crc8 |
and len(p_message) % 2 == 0 \ |
||||
crc8 = f_crc8(p[:-1]) |
and re.match(r'^[a-f0-9]+$', p_message)): |
||||
d['crc8_valid'] = True if crc8==p[-1] else False |
payload = bytes.fromhex(p_message) |
||||
|
# commands must start with \x80 |
||||
# interpret content |
if payload[0] == 0x80: |
||||
mid = p[0] |
command_queue[str(inverter_ser)].append( |
||||
d['mid'] = mid |
hoymiles.frame_payload(payload[1:])) |
||||
d['response_time_ns'] = t_now_ns-t_last_tx |
|
||||
d['ch_rx'] = ch_rx |
if __name__ == '__main__': |
||||
d['ch_tx'] = ch_tx |
ahoy_config = dict(cfg.get('ahoy', {})) |
||||
d['src'] = 'src_unkn' |
|
||||
d['name'] = 'name_unkn' |
mqtt_config = ahoy_config.get('mqtt', []) |
||||
|
if not mqtt_config.get('disabled', False): |
||||
if mid == 0x95: |
mqtt_client = paho.mqtt.client.Client() |
||||
src, dst, cmd = struct.unpack('>LLB', p[1:10]) |
mqtt_client.username_pw_set(mqtt_config.get('user', None), mqtt_config.get('password', None)) |
||||
d['src'] = f'{src:08x}' |
mqtt_client.connect(mqtt_config.get('host', '127.0.0.1'), mqtt_config.get('port', 1883)) |
||||
d['dst'] = f'{dst:08x}' |
mqtt_client.loop_start() |
||||
d['cmd'] = cmd |
mqtt_client.on_message = mqtt_on_command |
||||
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']) |
|
||||
|
|
||||
|
|
||||
|
|
||||
def main_loop(): |
if not radio.begin(): |
||||
""" |
raise RuntimeError('Can\'t open radio') |
||||
Keep receiving on channel 3. Every once in a while, transmit a request |
|
||||
to one of our inverters on channel 40. |
|
||||
""" |
|
||||
|
|
||||
global t_last_tx |
inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])] |
||||
|
for inverter in ahoy_config.get('inverters', []): |
||||
print_addr(inv_ser) |
inverter_ser = inverter.get('serial') |
||||
print_addr(dtu_ser) |
command_queue[str(inverter_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] |
|
||||
|
|
||||
# |
# |
||||
# TX |
# Enables and subscribe inverter to mqtt /command-Topic |
||||
# |
# |
||||
radio.stopListening() # put radio in TX mode |
if inverter.get('mqtt', {}).get('send_raw_enabled', False): |
||||
radio.setChannel(tx_channel) |
topic_item = ( |
||||
radio.openWritingPipe(ser_to_esb_addr(inv_ser)) |
str(inverter_ser), |
||||
|
inverter.get('mqtt', {}).get('topic', f'hoymiles/{inverter_ser}') + '/command' |
||||
ts = int(time.time()) |
) |
||||
payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, ts=ts) |
mqtt_client.subscribe(topic_item[1]) |
||||
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") |
mqtt_command_topic_subs.append(topic_item) |
||||
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}" |
loop_interval = ahoy_config.get('interval', 1) |
||||
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 |
|
||||
|
|
||||
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() |
||||
|
@ -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}' |
File diff suppressed because it is too large
@ -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]) |
@ -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 |
@ -1,2 +1,3 @@ |
|||||
paho-mqtt |
paho-mqtt>=1.5 |
||||
crcmod |
crcmod>=1.7 |
||||
|
PyYAML>=5.0 |
||||
|
Loading…
Reference in new issue