mirror of
https://github.com/lumapu/ahoy.git
synced 2025-05-10 23:46:37 +02:00
PoC Hoymiles package full payload decode WIP
Transform ahoy.py into a python library, implements decoding of fragmented large payloads. The module also allows for easier tinkering and replay testing.
This commit is contained in:
parent
0309dcb41a
commit
d7f9f6d3be
7 changed files with 573 additions and 4600 deletions
11
tools/rpi/.gitignore
vendored
Normal file
11
tools/rpi/.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
|
||||
# vim leftovers
|
||||
**.swp
|
|
@ -49,12 +49,13 @@ Analysing the Logs
|
|||
|
||||
Use basic command line tools to get an idea what you recorded. For example:
|
||||
|
||||
$ cat log2.log | grep 'cmd=2'
|
||||
$ cat log2.log
|
||||
[...]
|
||||
2022-03-28T17:36:53.018058Z MSG src=74608145, dst=74608145, cmd=2, u=235.0V, f=49.98Hz, p=2.5W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
|
||||
2022-03-28T17:38:07.309501Z MSG src=74608145, dst=74608145, cmd=2, u=234.7V, f=49.99Hz, p=2.3W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
|
||||
2022-03-28T17:38:24.378337Z MSG src=74608145, dst=74608145, cmd=2, u=234.7V, f=49.98Hz, p=2.2W, uk1=12851, uk2=0, uk3=14266, uk4=1663, uk5=1666
|
||||
2022-03-28T17:38:34.417683Z MSG src=74608145, dst=74608145, cmd=2, u=234.8V, f=49.98Hz, p=2.2W, uk1=12851, uk2=0, uk3=14267, uk4=1663, uk5=1667
|
||||
2022-05-02 16:41:16.044179 Transmit | 15 72 22 01 43 78 56 34 12 80 0b 00 62 3c 8e cf 00 00 00 05 00 00 00 00 35 a3 08
|
||||
2022-05-02 17:01:41.844361 Received 27 bytes on channel 3: 95 72 22 01 43 72 22 01 43 01 00 01 01 44 00 4e 00 fe 01 46 00 4f 01 02 00 00 6b
|
||||
2022-05-02 17:01:41.886796 Received 27 bytes on channel 75: 95 72 22 01 43 72 22 01 43 02 8f 82 00 00 86 7a 05 fe 06 0b 08 fc 13 8a 01 e9 15
|
||||
2022-05-02 17:01:41.934667 Received 23 bytes on channel 75: 95 72 22 01 43 72 22 01 43 83 00 00 00 15 03 e8 00 df 03 83 d5 f3 91
|
||||
2022-05-02 17:01:41.934667 Decoded: 44 string1= 32.4VDC 0.78A 25.4W 36738Wh 1534Wh/day string2= 32.6VDC 0.79A 25.8W 34426Wh 1547Wh/day phase1= 230.0VAC 2.1A 48.9W inverter=114171230143 50.02Hz 22.3°C
|
||||
[...]
|
||||
|
||||
A brief example log is supplied in the `example-logs` folder.
|
||||
|
@ -64,10 +65,8 @@ A brief example log is supplied in the `example-logs` folder.
|
|||
Configuration
|
||||
-------------
|
||||
|
||||
Nothing so far, I'm afraid. You can change the serial number of the inverter
|
||||
that you are trying to talk to by changing the line that defines the
|
||||
`inv_ser` variable towards the top of `ahoy.py`.
|
||||
|
||||
Local settings are read from ~/ahoy.conf
|
||||
An example is provided as ahoy.conf.example
|
||||
|
||||
Todo
|
||||
----
|
||||
|
@ -78,6 +77,7 @@ Todo
|
|||
- configurable polling interval
|
||||
- commands
|
||||
- picture of setup!
|
||||
- python module
|
||||
- ...
|
||||
|
||||
|
||||
|
|
|
@ -10,9 +10,11 @@ import struct
|
|||
import crcmod
|
||||
import json
|
||||
from datetime import datetime
|
||||
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS
|
||||
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16
|
||||
import paho.mqtt.client
|
||||
from configparser import ConfigParser
|
||||
#from hoymiles import ser_to_hm_addr, ser_to_esb_addr
|
||||
import hoymiles
|
||||
|
||||
cfg = ConfigParser()
|
||||
cfg.read('ahoy.conf')
|
||||
|
@ -39,358 +41,9 @@ inv_ser = cfg.get('inverter', 'serial', fallback='444473104619') # my inverter
|
|||
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
|
||||
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
|
||||
|
||||
|
||||
def ser_to_hm_addr(s):
|
||||
"""
|
||||
Calculate the 4 bytes that the HM devices use in their internal messages to
|
||||
address each other.
|
||||
"""
|
||||
bcd = int(str(s)[-8:], base=16)
|
||||
return struct.pack('>L', bcd)
|
||||
|
||||
|
||||
def ser_to_esb_addr(s):
|
||||
"""
|
||||
Convert a Hoymiles inverter/DTU serial number into its
|
||||
corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes).
|
||||
|
||||
The NRF library expects these in LSB to MSB order, even though the transceiver
|
||||
itself will then output them in MSB-to-LSB order over the air.
|
||||
|
||||
The inverters use a BCD representation of the last 8
|
||||
digits of their serial number, in reverse byte order,
|
||||
followed by \x01.
|
||||
"""
|
||||
air_order = ser_to_hm_addr(s)[::-1] + b'\x01'
|
||||
return air_order[::-1]
|
||||
|
||||
|
||||
def compose_0x80_msg(dst_ser_no=72220200, src_ser_no=72220200, ts=None, subtype=b'\x0b'):
|
||||
"""
|
||||
Create a valid 0x80 request with the given parameters, and containing the
|
||||
current system time.
|
||||
"""
|
||||
|
||||
if not ts:
|
||||
ts = 0x623C8ECF # identical to fc22's for testing # doc: 1644758171
|
||||
|
||||
# "framing"
|
||||
p = b''
|
||||
p = p + b'\x15'
|
||||
p = p + ser_to_hm_addr(dst_ser_no)
|
||||
p = p + ser_to_hm_addr(src_ser_no)
|
||||
p = p + b'\x80'
|
||||
|
||||
# encapsulated payload
|
||||
pp = subtype + b'\x00'
|
||||
pp = pp + struct.pack('>L', ts) # big-endian: msb at low address
|
||||
#pp = pp + b'\x00' * 8 # of22 adds a \x05 at position 19
|
||||
|
||||
pp = pp + b'\x00\x00\x00\x05\x00\x00\x00\x00'
|
||||
|
||||
# CRC_M
|
||||
crc_m = f_crc_m(pp)
|
||||
|
||||
p = p + pp
|
||||
p = p + struct.pack('>H', crc_m)
|
||||
|
||||
crc8 = f_crc8(p)
|
||||
p = p + struct.pack('B', crc8)
|
||||
return p
|
||||
|
||||
|
||||
def print_addr(a):
|
||||
print(f"ser# {a} ", end='')
|
||||
print(f" -> HM {' '.join([f'{x:02x}' for x in ser_to_hm_addr(a)])}", end='')
|
||||
print(f" -> ESB {' '.join([f'{x:02x}' for x in ser_to_esb_addr(a)])}")
|
||||
|
||||
# time of last transmission - to calculcate response time
|
||||
t_last_tx = 0
|
||||
|
||||
|
||||
def on_receive(p=None, ctr=None, ch_rx=None, ch_tx=None, time_rx=datetime.now(), latency=None):
|
||||
"""
|
||||
Callback: get's invoked whenever a Nordic ESB packet has been received.
|
||||
:param p: Payload of the received packet.
|
||||
"""
|
||||
|
||||
d = {}
|
||||
|
||||
t_now_ns = time.monotonic_ns()
|
||||
ts = datetime.utcnow()
|
||||
ts_unixtime = ts.timestamp()
|
||||
size = len(p)
|
||||
d['ts_unixtime'] = ts_unixtime
|
||||
d['isodate'] = ts.isoformat()
|
||||
d['rawdata'] = " ".join([f"{b:02x}" for b in p])
|
||||
d['trans_id'] = ctr
|
||||
|
||||
dt = time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
print(f"{dt} Received {size} bytes on channel {ch_rx} after tx {latency}ns: " +
|
||||
" ".join([f"{b:02x}" for b in p]))
|
||||
|
||||
# check crc8
|
||||
crc8 = f_crc8(p[:-1])
|
||||
d['crc8_valid'] = True if crc8==p[-1] else False
|
||||
|
||||
# interpret content
|
||||
mid = p[0]
|
||||
d['mid'] = mid
|
||||
name = 'unknowndata'
|
||||
d['response_time_ns'] = t_now_ns-t_last_tx
|
||||
d['ch_rx'] = ch_rx
|
||||
d['ch_tx'] = ch_tx
|
||||
|
||||
if mid == 0x95:
|
||||
decode_hoymiles_hm600(d, p, time_rx=time_rx)
|
||||
else:
|
||||
print(f'unknown frame id {p[0]}')
|
||||
|
||||
|
||||
def decode_hoymiles_hm600(d, p, time_rx=datetime.now()):
|
||||
"""
|
||||
Decode payload from Hoymiles HM-600
|
||||
:param d: Pre parsed data from on_receive
|
||||
:param p: raw payload byte array
|
||||
:param time_rx: datetime object when packet was received
|
||||
"""
|
||||
src, dst, cmd = struct.unpack('>LLB', p[1:10])
|
||||
src_s = f'{src:08x}'
|
||||
dst_s = f'{dst:08x}'
|
||||
d['src'] = src_s
|
||||
d['dst'] = dst_s
|
||||
d['cmd'] = cmd
|
||||
dt = time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
print(f'{dt} Decoder src={src_s}, dst={dst_s}, cmd={cmd}, ', end=' ')
|
||||
|
||||
if cmd==1: # 0x01
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x0b
|
||||
0x80 0x0c
|
||||
0x80 0x0d
|
||||
0x80 0x0f
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
name = 'dcdata'
|
||||
uk1, u1, i1, p1, u2, i2, p2, uk2 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
print(f'u1={u1/10}V, i1={i1/100}A, p1={p1/10}W, ', end='')
|
||||
print(f'u2={u2/10}V, i2={i2/100}A, p2={p2/10}W, ', end='')
|
||||
print(f'uk1={uk1}, uk2={uk2}')
|
||||
d['dc'] = {0: {}, 1: {}}
|
||||
d['dc'][0]['voltage'] = u1/10
|
||||
d['dc'][0]['current'] = i1/100
|
||||
d['dc'][0]['power'] = p1/10
|
||||
d['dc'][1]['voltage'] = u2/10
|
||||
d['dc'][1]['current'] = i2/100
|
||||
d['dc'][1]['power'] = p2/10
|
||||
d['uk1'] = uk1
|
||||
d['uk2'] = uk2
|
||||
|
||||
elif cmd==2: # 0x02
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x0b
|
||||
0x80 0x0c
|
||||
0x80 0x0d
|
||||
0x80 0x0f
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
name = 'acdata'
|
||||
uk1, uk2, uk3, uk4, uk5, ac_u1, f, ac_p1 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
print(f'ac_u1={ac_u1/10:.1f}V, ac_f={f/100:.2f}Hz, ac_p1={ac_p1/10:.1f}W, ', end='')
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}')
|
||||
d['ac'] = {0: {}}
|
||||
d['ac'][0]['voltage'] = ac_u1/10
|
||||
d['frequency'] = f/100
|
||||
d['ac'][0]['power'] = ac_p1/10
|
||||
d['wtot1_Wh'] = uk1
|
||||
d['wtot2_Wh'] = uk3
|
||||
d['wday1_Wh'] = uk4
|
||||
d['wday2_Wh'] = uk5
|
||||
d['uk2'] = uk2
|
||||
|
||||
elif cmd==3: # 0x03
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==4: # 0x04
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==5: # 0x05
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==6: # 0x06
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==7: # 0x07
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x03 (garbled data)
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==129 and len(p) == 17: # 0x81
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x0a
|
||||
"""
|
||||
uk1, uk2, uk3 = struct.unpack(
|
||||
'>HHH', p[10:16])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ')
|
||||
|
||||
elif cmd==129: # 0x81
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x02
|
||||
0x80 0x11
|
||||
"""
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
name = 'error'
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
elif cmd==131: # 0x83
|
||||
"""
|
||||
On HM600 Response to
|
||||
0x80 0x0b
|
||||
0x80 0x0c
|
||||
0x80 0x0d
|
||||
0x80 0x0f
|
||||
"""
|
||||
name = 'statedata'
|
||||
uk1, ac_i1, uk3, t, uk5, uk6 = struct.unpack('>HHHHHH', p[10:22])
|
||||
print(f'ac_i1={ac_i1/100}A, t={t/10:.2f}C, ', end='')
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}')
|
||||
d['ac'] = {0: {}}
|
||||
d['ac'][0]['current'] = ac_i1/100
|
||||
d['temperature'] = t/10
|
||||
d['uk1'] = uk1
|
||||
d['uk3'] = uk3
|
||||
d['uk5'] = uk5
|
||||
d['uk6'] = uk6
|
||||
|
||||
elif cmd==132: # 0x84
|
||||
name = 'unknown0x84'
|
||||
uk1, uk2, uk3, uk4, uk5, uk6, uk7, uk8 = struct.unpack(
|
||||
'>HHHHHHHH', p[10:26])
|
||||
print(f'uk1={uk1}, ', end='')
|
||||
print(f'uk2={uk2}, ', end='')
|
||||
print(f'uk3={uk3}, ', end='')
|
||||
print(f'uk4={uk4}, ', end='')
|
||||
print(f'uk5={uk5}, ', end='')
|
||||
print(f'uk6={uk6}, ', end='')
|
||||
print(f'uk7={uk7}, ', end='')
|
||||
print(f'uk8={uk8}')
|
||||
|
||||
else:
|
||||
print(f'unknown cmd {cmd}')
|
||||
|
||||
# output to MQTT
|
||||
if d:
|
||||
j = json.dumps(d)
|
||||
mqtt_client.publish(f'ahoy/{src}/debug', j)
|
||||
if d['cmd']==2:
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter/0/voltage', d['ac'][0]['voltage'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter/0/power', d['ac'][0]['power'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter/0/total', d['wtot1_Wh'])
|
||||
mqtt_client.publish(f'ahoy/{src}/frequency', d['frequency'])
|
||||
if d['cmd']==1:
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/power', d['dc'][0]['power'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/voltage', d['dc'][0]['voltage'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/0/current', d['dc'][0]['current'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/power', d['dc'][1]['power'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/voltage', d['dc'][1]['voltage'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter-dc/1/current', d['dc'][1]['current'])
|
||||
if d['cmd']==131:
|
||||
mqtt_client.publish(f'ahoy/{src}/temperature', d['temperature'])
|
||||
mqtt_client.publish(f'ahoy/{src}/emeter/0/current', d['ac'][0]['current'])
|
||||
|
||||
|
||||
def main_loop():
|
||||
"""
|
||||
Keep receiving on channel 3. Every once in a while, transmit a request
|
||||
|
@ -399,15 +52,13 @@ def main_loop():
|
|||
|
||||
global t_last_tx
|
||||
|
||||
print_addr(inv_ser)
|
||||
print_addr(dtu_ser)
|
||||
hoymiles.print_addr(inv_ser)
|
||||
hoymiles.print_addr(dtu_ser)
|
||||
|
||||
ctr = 1
|
||||
last_tx_message = ''
|
||||
|
||||
ts = int(time.time()) # see what happens if we always send one and the same (constant) time!
|
||||
|
||||
rx_channels = [3,23,61,75]
|
||||
rx_channels = [3,6,9,11,23,40,61,75]
|
||||
rx_channel_id = 0
|
||||
rx_channel = rx_channels[rx_channel_id]
|
||||
rx_channel_ack = None
|
||||
|
@ -418,20 +69,16 @@ def main_loop():
|
|||
tx_channel = tx_channels[tx_channel_id]
|
||||
|
||||
radio.setChannel(rx_channel)
|
||||
radio.enableDynamicPayloads()
|
||||
radio.setAutoAck(True)
|
||||
radio.setRetries(15, 2)
|
||||
radio.setRetries(10, 2)
|
||||
radio.setPALevel(RF24_PA_LOW)
|
||||
#radio.setPALevel(RF24_PA_MAX)
|
||||
radio.setDataRate(RF24_250KBPS)
|
||||
radio.openReadingPipe(1,ser_to_esb_addr(dtu_ser))
|
||||
radio.openWritingPipe(ser_to_esb_addr(inv_ser))
|
||||
radio.openReadingPipe(1,hoymiles.ser_to_esb_addr(dtu_ser))
|
||||
radio.openWritingPipe(hoymiles.ser_to_esb_addr(inv_ser))
|
||||
|
||||
while True:
|
||||
radio.flush_rx()
|
||||
radio.flush_tx()
|
||||
m_buf = []
|
||||
# Sweep receive start channel
|
||||
# Channel selection: Sweep receive start channel
|
||||
if not rx_channel_ack:
|
||||
rx_channel_id = ctr % len(rx_channels)
|
||||
rx_channel = rx_channels[rx_channel_id]
|
||||
|
@ -441,43 +88,59 @@ def main_loop():
|
|||
tx_channel_id = 0
|
||||
tx_channel = tx_channels[tx_channel_id]
|
||||
|
||||
# Transmit
|
||||
ts = int(time.time())
|
||||
payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, ts=ts, subtype=b'\x0b')
|
||||
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
# Transmit: Compose data
|
||||
com = hoymiles.InverterTransaction(
|
||||
request_time = datetime.now(),
|
||||
inverter_ser=inv_ser,
|
||||
request = hoymiles.compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser, subtype=b'\x0b')
|
||||
)
|
||||
print(com)
|
||||
|
||||
# Transmit: Setup radio
|
||||
radio.stopListening() # put radio in TX mode
|
||||
radio.setChannel(tx_channel)
|
||||
t_tx_start = time.monotonic_ns()
|
||||
tx_status = radio.write(payload) # will always yield 'True' because auto-ack is disabled
|
||||
t_last_tx = t_tx_end = time.monotonic_ns()
|
||||
radio.setChannel(rx_channel)
|
||||
radio.startListening()
|
||||
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()
|
||||
|
||||
last_tx_message = f"{dt} Transmit {ctr:5d}: channel={tx_channel} len={len(payload)} ack={tx_status} | " + \
|
||||
" ".join([f"{b:02x}" for b in payload]) + "\n"
|
||||
ctr = ctr + 1
|
||||
|
||||
# Receive loop
|
||||
# Receive: Setup radio
|
||||
radio.setChannel(rx_channel)
|
||||
radio.setAutoAck(False)
|
||||
radio.setRetries(0, 0)
|
||||
radio.enableDynamicPayloads()
|
||||
radio.setCRCLength(RF24_CRC_16)
|
||||
radio.startListening()
|
||||
|
||||
# Receive: Loop
|
||||
t_end = time.monotonic_ns()+1e9
|
||||
while time.monotonic_ns() < t_end:
|
||||
|
||||
has_payload, pipe_number = radio.available_pipe()
|
||||
if has_payload:
|
||||
# Data in nRF24 buffer, read it
|
||||
rx_error = 0
|
||||
rx_channel_ack = rx_channel
|
||||
t_end = time.monotonic_ns()+6e7
|
||||
t_end = time.monotonic_ns()+2e8
|
||||
|
||||
size = radio.getDynamicPayloadSize()
|
||||
payload = radio.read(size)
|
||||
m_buf.append( {
|
||||
'p': payload,
|
||||
'ch_rx': rx_channel, 'ch_tx': tx_channel,
|
||||
'time_rx': datetime.now(), 'latency': time.monotonic_ns()-t_last_tx} )
|
||||
|
||||
# Only print last transmittet message if we got any response
|
||||
print(last_tx_message, end='')
|
||||
last_tx_message = ''
|
||||
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)
|
||||
|
@ -495,16 +158,68 @@ def main_loop():
|
|||
radio.startListening()
|
||||
time.sleep(0.005)
|
||||
|
||||
# Process receive buffer outside time critical receive loop
|
||||
for param in m_buf:
|
||||
on_receive(**param)
|
||||
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():
|
||||
|
|
File diff suppressed because it is too large
Load diff
280
tools/rpi/hoymiles/__init__.py
Normal file
280
tools/rpi/hoymiles/__init__.py
Normal file
|
@ -0,0 +1,280 @@
|
|||
import struct
|
||||
import crcmod
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16
|
||||
|
||||
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
|
||||
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
|
||||
|
||||
|
||||
def ser_to_hm_addr(s):
|
||||
"""
|
||||
Calculate the 4 bytes that the HM devices use in their internal messages to
|
||||
address each other.
|
||||
"""
|
||||
bcd = int(str(s)[-8:], base=16)
|
||||
return struct.pack('>L', bcd)
|
||||
|
||||
|
||||
def ser_to_esb_addr(s):
|
||||
"""
|
||||
Convert a Hoymiles inverter/DTU serial number into its
|
||||
corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes).
|
||||
|
||||
The NRF library expects these in LSB to MSB order, even though the transceiver
|
||||
itself will then output them in MSB-to-LSB order over the air.
|
||||
|
||||
The inverters use a BCD representation of the last 8
|
||||
digits of their serial number, in reverse byte order,
|
||||
followed by \x01.
|
||||
"""
|
||||
air_order = ser_to_hm_addr(s)[::-1] + b'\x01'
|
||||
return air_order[::-1]
|
||||
|
||||
|
||||
def compose_0x80_msg(dst_ser_no=72220200, src_ser_no=72220200, ts=None, subtype=b'\x0b'):
|
||||
"""
|
||||
Create a valid 0x80 request with the given parameters, and containing the
|
||||
current system time.
|
||||
"""
|
||||
|
||||
if not ts:
|
||||
ts = int(time.time())
|
||||
|
||||
# "framing"
|
||||
p = b''
|
||||
p = p + b'\x15'
|
||||
p = p + ser_to_hm_addr(dst_ser_no)
|
||||
p = p + ser_to_hm_addr(src_ser_no)
|
||||
p = p + b'\x80'
|
||||
|
||||
# encapsulated payload
|
||||
pp = subtype + b'\x00'
|
||||
pp = pp + struct.pack('>L', ts) # big-endian: msb at low address
|
||||
#pp = pp + b'\x00' * 8 # of22 adds a \x05 at position 19
|
||||
|
||||
pp = pp + b'\x00\x00\x00\x05\x00\x00\x00\x00'
|
||||
|
||||
# CRC_M
|
||||
crc_m = f_crc_m(pp)
|
||||
|
||||
p = p + pp
|
||||
p = p + struct.pack('>H', crc_m)
|
||||
|
||||
crc8 = f_crc8(p)
|
||||
p = p + struct.pack('B', crc8)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def print_addr(a):
|
||||
print(f"ser# {a} ", end='')
|
||||
print(f" -> HM {' '.join([f'{x:02x}' for x in ser_to_hm_addr(a)])}", end='')
|
||||
print(f" -> ESB {' '.join([f'{x:02x}' for x in ser_to_esb_addr(a)])}")
|
||||
|
||||
# time of last transmission - to calculcate response time
|
||||
t_last_tx = 0
|
||||
|
||||
class hm600_02_response_decode:
|
||||
""" TBD """
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
|
||||
class hm600_11_response_decode:
|
||||
""" TBD """
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
|
||||
class hm600_0b_response_decode:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
|
||||
def unpack(self, fmt, base):
|
||||
size = struct.calcsize(fmt)
|
||||
return struct.unpack(fmt, self.response[base:base+size])
|
||||
|
||||
@property
|
||||
def dc_voltage_0(self):
|
||||
return self.unpack('>H', 2)[0]/10
|
||||
@property
|
||||
def dc_current_0(self):
|
||||
return self.unpack('>H', 4)[0]/100
|
||||
@property
|
||||
def dc_power_0(self):
|
||||
return self.unpack('>H', 6)[0]/10
|
||||
@property
|
||||
def dc_energy_total_0(self):
|
||||
return self.unpack('>L', 14)[0]
|
||||
@property
|
||||
def dc_energy_daily_0(self):
|
||||
return self.unpack('>H', 22)[0]
|
||||
|
||||
@property
|
||||
def dc_voltage_1(self):
|
||||
return self.unpack('>H', 8)[0]/10
|
||||
@property
|
||||
def dc_current_1(self):
|
||||
return self.unpack('>H', 10)[0]/100
|
||||
@property
|
||||
def dc_power_1(self):
|
||||
return self.unpack('>H', 12)[0]/10
|
||||
@property
|
||||
def dc_energy_total_1(self):
|
||||
return self.unpack('>L', 18)[0]
|
||||
@property
|
||||
def dc_energy_daily_1(self):
|
||||
return self.unpack('>H', 24)[0]
|
||||
|
||||
@property
|
||||
def ac_voltage_0(self):
|
||||
return self.unpack('>H', 26)[0]/10
|
||||
@property
|
||||
def ac_current_0(self):
|
||||
return self.unpack('>H', 34)[0]/10
|
||||
@property
|
||||
def ac_power_0(self):
|
||||
return self.unpack('>H', 30)[0]/10
|
||||
@property
|
||||
def ac_frequency(self):
|
||||
return self.unpack('>H', 28)[0]/100
|
||||
@property
|
||||
def temperature(self):
|
||||
return self.unpack('>H', 38)[0]/10
|
||||
|
||||
class InverterPacketFragment:
|
||||
def __init__(self, time_rx=None, payload=None, ch_rx=None, ch_tx=None, **params):
|
||||
"""
|
||||
Callback: get's invoked whenever a Nordic ESB packet has been received.
|
||||
:param p: Payload of the received packet.
|
||||
"""
|
||||
|
||||
if not time_rx:
|
||||
time_rx = datetime.now()
|
||||
self.time_rx = time_rx
|
||||
|
||||
self.frame = payload
|
||||
|
||||
# check crc8
|
||||
if f_crc8(payload[:-1]) != payload[-1]:
|
||||
raise BufferError('Frame kaputt')
|
||||
|
||||
self.ch_rx = ch_rx
|
||||
self.ch_tx = ch_tx
|
||||
|
||||
@property
|
||||
def mid(self):
|
||||
"""
|
||||
Transaction counter
|
||||
"""
|
||||
return self.frame[0]
|
||||
@property
|
||||
def src(self):
|
||||
"""
|
||||
Sender dddress
|
||||
"""
|
||||
src = struct.unpack('>L', self.frame[1:5])
|
||||
return src[0]
|
||||
@property
|
||||
def dst(self):
|
||||
"""
|
||||
Receiver address
|
||||
"""
|
||||
dst = struct.unpack('>L', self.frame[5:8])
|
||||
return dst[0]
|
||||
@property
|
||||
def seq(self):
|
||||
"""
|
||||
Packet sequence
|
||||
"""
|
||||
result = struct.unpack('>B', self.frame[9:10])
|
||||
return result[0]
|
||||
@property
|
||||
def data(self):
|
||||
"""
|
||||
Packet without protocol framing
|
||||
"""
|
||||
return self.frame[10:-1]
|
||||
|
||||
def __str__(self):
|
||||
dt = self.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
size = len(self.frame)
|
||||
channel = f' channel {self.ch_rx}' if self.ch_rx else ''
|
||||
raw = " ".join([f"{b:02x}" for b in self.frame])
|
||||
return f"{dt} Received {size} bytes{channel}: {raw}"
|
||||
|
||||
class InverterTransaction:
|
||||
def __init__(self,
|
||||
request_time=datetime.now(),
|
||||
inverter_ser=None,
|
||||
dtu_ser=None,
|
||||
**params):
|
||||
self.scratch = []
|
||||
if 'scratch' in params:
|
||||
self.scratch = params['scratch']
|
||||
|
||||
self.inverter_ser = inverter_ser
|
||||
if inverter_ser:
|
||||
self.peer_src = ser_to_hm_addr(inverter_ser)
|
||||
|
||||
self.dtu_ser = dtu_ser
|
||||
if dtu_ser:
|
||||
self.dtu_dst = ser_to_hm_addr(dtu_ser)
|
||||
|
||||
self.peer_src, self.peer_dst, self.req_type = (None,None,None)
|
||||
|
||||
self.request = None
|
||||
if 'request' in params:
|
||||
self.request = params['request']
|
||||
self.peer_src, self.peer_dst, skip, self.req_type = struct.unpack('>LLBB', params['request'][1:11])
|
||||
self.request_time = request_time
|
||||
|
||||
def frame_append(self, payload_frame):
|
||||
self.scratch.append(payload_frame)
|
||||
|
||||
def get_payload(self, src=None):
|
||||
"""
|
||||
Reconstruct Hoymiles payload from scratch
|
||||
"""
|
||||
|
||||
if not src:
|
||||
src = self.peer_src
|
||||
|
||||
# Collect all frames from source_address src
|
||||
frames = [frame for frame in self.scratch if frame.src == src]
|
||||
|
||||
tr_len = 0
|
||||
# Find end frame and extract message frame count
|
||||
try:
|
||||
end_frame = next(frame for frame in frames if frame.seq > 0x80)
|
||||
self.time_rx = end_frame.time_rx
|
||||
tr_len = end_frame.seq - 0x80
|
||||
except StopIteration:
|
||||
raise BufferError('Missing packet: Last packet')
|
||||
|
||||
# Rebuild payload from unordered frames
|
||||
payload = b''
|
||||
seq_missing = []
|
||||
for i in range(1, tr_len):
|
||||
try:
|
||||
data_frame = next(item for item in frames if item.seq == i)
|
||||
payload = payload + data_frame.data
|
||||
except StopIteration:
|
||||
seq_missing.append(i)
|
||||
pass
|
||||
|
||||
payload = payload + end_frame.data
|
||||
|
||||
# check crc
|
||||
pcrc = struct.unpack('>H', payload[-2:])[0]
|
||||
if f_crc_m(payload[:-2]) != pcrc:
|
||||
raise BufferError('Payload failed CRC check.')
|
||||
|
||||
return payload
|
||||
|
||||
def __str__(self):
|
||||
dt = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
size = len(self.request)
|
||||
raw = " ".join([f"{b:02x}" for b in self.request])
|
||||
return f'{dt} Transmit | {raw}'
|
15
tools/rpi/hoymiles/factory/__init__.py
Normal file
15
tools/rpi/hoymiles/factory/__init__.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# TBD
|
||||
|
||||
class ESBFrameFactory:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
class ESBTransactionFactory:
|
||||
"""
|
||||
Put a payload into ESB packets for transmission
|
||||
"""
|
||||
def __init__(self, src, dst, **params):
|
||||
self.src = src
|
||||
self.dst = dst
|
84
tools/rpi/test.py
Normal file
84
tools/rpi/test.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
import codecs
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
import hoymiles
|
||||
|
||||
logdata = """
|
||||
2022-05-01 12:29:02.139673 Transmit 368223: channel=40 len=27 ack=False | 15 72 22 01 43 78 56 34 12 80 0b 00 62 6e 60 ee 00 00 00 05 00 00 00 00 7e 58 25
|
||||
2022-05-01 12:29:02.184796 Received 27 bytes on channel 3 after tx 6912328ns: 95 72 22 01 43 72 22 01 43 01 00 01 01 4e 00 9d 02 0a 01 50 00 9d 02 10 00 00 91
|
||||
2022-05-01 12:29:02.184796 Decoder src=72220143, dst=72220143, cmd=1, u1=33.4V, i1=1.57A, p1=52.2W, u2=33.6V, i2=1.57A, p2=52.8W, uk1=1, uk2=0
|
||||
2022-05-01 12:29:02.226251 Received 27 bytes on channel 75 after tx 48355619ns: 95 72 22 01 43 72 22 01 43 02 88 1f 00 00 7f 08 00 94 00 97 08 e2 13 89 03 eb ec
|
||||
2022-05-01 12:29:02.226251 Decoder src=72220143, dst=72220143, cmd=2, ac_u1=227.4V, ac_f=50.01Hz, ac_p1=100.3W, uk1=34847, uk2=0, uk3=32520, uk4=148, uk5=151
|
||||
2022-05-01 12:29:02.273766 Received 23 bytes on channel 75 after tx 95876606ns: 95 72 22 01 43 72 22 01 43 83 00 01 00 2c 03 e8 00 d8 00 06 0c 35 37
|
||||
2022-05-01 12:29:02.273766 Decoder src=72220143, dst=72220143, cmd=131, ac_i1=0.44A, t=21.60C, uk1=1, uk3=1000, uk5=6, uk6=3125
|
||||
"""
|
||||
|
||||
def payload_from_log(line):
|
||||
values = re.match(r'(?P<datetime>\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\.\d+) Received.*: (?P<data>[0-9a-z ]+)$', line)
|
||||
if values:
|
||||
payload=values.group('data')
|
||||
return hoymiles.InverterPacketFragment(
|
||||
time_rx=datetime.strptime(values.group('datetime'), '%Y-%m-%d %H:%M:%S.%f'),
|
||||
payload=bytes.fromhex(payload)
|
||||
)
|
||||
|
||||
with open('example-logs/example.log', 'r') as fh:
|
||||
for line in fh:
|
||||
kind = re.match(r'\d{4}-\d{2}-\d{2} \d\d:\d\d:\d\d.\d+ (?P<type>Transmit|Received)', line)
|
||||
if kind:
|
||||
if kind.group('type') == 'Transmit':
|
||||
u, data = line.split('|')
|
||||
rx_buffer = hoymiles.InverterTransaction(
|
||||
request=bytes.fromhex(data))
|
||||
|
||||
elif kind.group('type') == 'Received':
|
||||
try:
|
||||
payload = payload_from_log(line)
|
||||
print(payload)
|
||||
except BufferError as err:
|
||||
print(f'Debug: {err}')
|
||||
payload = None
|
||||
pass
|
||||
if payload:
|
||||
rx_buffer.frame_append(payload)
|
||||
try:
|
||||
#packet = rx_buffer.get_payload(72220143)
|
||||
packet = rx_buffer.get_payload()
|
||||
except BufferError as err:
|
||||
print(f'Debug: {err}')
|
||||
packet = None
|
||||
pass
|
||||
|
||||
if packet:
|
||||
plen = len(packet)
|
||||
dt = rx_buffer.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
iv = hoymiles.hm600_0b_response_decode(packet)
|
||||
|
||||
print(f'{dt} Decoded: {plen}', end='')
|
||||
print(f' string1=', end='')
|
||||
print(f' {iv.dc_voltage_0}VDC', end='')
|
||||
print(f' {iv.dc_current_0}A', end='')
|
||||
print(f' {iv.dc_power_0}W', end='')
|
||||
print(f' {iv.dc_energy_total_0}Wh', end='')
|
||||
print(f' {iv.dc_energy_daily_0}Wh/day', end='')
|
||||
print(f' string2=', end='')
|
||||
print(f' {iv.dc_voltage_1}VDC', end='')
|
||||
print(f' {iv.dc_current_1}A', end='')
|
||||
print(f' {iv.dc_power_1}W', end='')
|
||||
print(f' {iv.dc_energy_total_1}Wh', end='')
|
||||
print(f' {iv.dc_energy_daily_1}Wh/day', end='')
|
||||
print(f' phase1=', end='')
|
||||
print(f' {iv.ac_voltage_0}VAC', end='')
|
||||
print(f' {iv.ac_current_0}A', end='')
|
||||
print(f' {iv.ac_power_0}W', end='')
|
||||
print(f' inverter=', end='')
|
||||
print(f' {iv.ac_frequency}Hz', end='')
|
||||
print(f' {iv.temperature}°C', end='')
|
||||
print()
|
||||
|
||||
print('', end='', flush=True)
|
Loading…
Add table
Add a link
Reference in a new issue