Browse Source

Adds documentation to python-hoymiles module

pull/27/head
Jan-Jonas Sämann 3 years ago
parent
commit
d408264521
  1. 240
      tools/rpi/hoymiles/__init__.py
  2. 22
      tools/rpi/hoymiles/__main__.py

240
tools/rpi/hoymiles/__init__.py

@ -18,6 +18,10 @@ def ser_to_hm_addr(s):
"""
Calculate the 4 bytes that the HM devices use in their internal messages to
address each other.
:param str s: inverter serial
:return: inverter address
:rtype: bytes
"""
bcd = int(str(s)[-8:], base=16)
return struct.pack('>L', bcd)
@ -33,11 +37,20 @@ def ser_to_esb_addr(s):
The inverters use a BCD representation of the last 8
digits of their serial number, in reverse byte order,
followed by \x01.
:param str s: inverter serial
:return: ESB inverter address
:rtype: bytes
"""
air_order = ser_to_hm_addr(s)[::-1] + b'\x01'
return air_order[::-1]
def print_addr(a):
"""
Debug print addresses
:param str a: inverter serial
"""
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)])}")
@ -46,6 +59,15 @@ def print_addr(a):
t_last_tx = 0
class ResponseDecoderFactory:
"""
Prepare payload decoder
:param bytes response: ESB response frame to decode
:param request: ESB request frame
:type request: bytes
:param inverter_ser: inverter serial
:type inverter_ser: str
"""
model = None
request = None
response = None
@ -63,11 +85,27 @@ class ResponseDecoderFactory:
self.model = self.inverter_model
def unpack(self, fmt, base):
"""
Data unpack helper
:param str fmt: struct format string
:param int base: unpack base position from self.response bytes
:return: unpacked values
:rtype: tuple
"""
size = struct.calcsize(fmt)
return struct.unpack(fmt, self.response[base:base+size])
@property
def inverter_model(self):
"""
Find decoder for inverter model
:return: suitable decoder model string
:rtype: str
:raises ValueError: on invalid inverter serial
:raises NotImplementedError: if inverter model can not be determined
"""
if not self.inverter_ser:
raise ValueError('Inverter serial while decoding response')
@ -90,14 +128,32 @@ class ResponseDecoderFactory:
@property
def request_command(self):
"""
Return requested command identifier byte
:return: hexlified command byte string
:rtype: str
"""
r_code = self.request[10]
return f'{r_code:02x}'
class ResponseDecoder(ResponseDecoderFactory):
"""
Base response
:param bytes response: ESB frame response
"""
def __init__(self, response, **params):
"""Initialize ResponseDecoder"""
ResponseDecoderFactory.__init__(self, response, **params)
def decode(self):
"""
Decode Payload
:return: payload decoder instance
:rtype: object
"""
model = self.inverter_model
command = self.request_command
@ -111,10 +167,19 @@ class ResponseDecoder(ResponseDecoderFactory):
return device(self.response)
class InverterPacketFragment:
"""ESB Frame"""
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.
:param time_rx: datetime when frame was received
:type time_rx: datetime
:param payload: payload bytes
:type payload: bytes
:param ch_rx: channel where packet was received
:type ch_rx: int
:param ch_tx: channel where request was sent
:type ch_tx: int
"""
if not time_rx:
@ -132,39 +197,56 @@ class InverterPacketFragment:
@property
def mid(self):
"""
Transaction counter
"""
"""Transaction counter"""
return self.frame[0]
@property
def src(self):
"""
Sender dddress
Sender adddress
:return: sender address
:rtype: int
"""
src = struct.unpack('>L', self.frame[1:5])
return src[0]
@property
def dst(self):
"""
Receiver address
Receiver adddress
:return: receiver address
:rtype: int
"""
dst = struct.unpack('>L', self.frame[5:8])
return dst[0]
@property
def seq(self):
"""
Packet sequence
Framne sequence number
:return: sequence number
:rtype: int
"""
result = struct.unpack('>B', self.frame[9:10])
return result[0]
@property
def data(self):
"""
Packet without protocol framing
Data without protocol framing
:return: payload chunk
:rtype: bytes
"""
return self.frame[10:-1]
def __str__(self):
"""
Represent received ESB frame
:return: log line received frame
:rtype: str
"""
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 ''
@ -172,6 +254,7 @@ class InverterPacketFragment:
return f"{dt} Received {size} bytes{channel}: {raw}"
class HoymilesNRF:
"""Hoymiles NRF24 Interface"""
tx_channel_id = 0
tx_channel_list = [40]
rx_channel_id = 0
@ -180,11 +263,20 @@ class HoymilesNRF:
rx_error = 0
def __init__(self, device):
"""
Claim radio device
:param NRF24 device: instance of NRF24
"""
self.radio = device
def transmit(self, packet):
"""
Transmit Packet
:param bytes packet: buffer to send
:return: if ACK received of ACK disabled
:rtype: bool
"""
#dst_esb_addr = b'\x01' + packet[1:5]
@ -208,6 +300,10 @@ class HoymilesNRF:
def receive(self, timeout=None):
"""
Receive Packets
:param timeout: receive timeout in nanoseconds (default: 12e8)
:type timeout: int
:yields: fragment
"""
if not timeout:
@ -257,6 +353,14 @@ class HoymilesNRF:
time.sleep(0.005)
def next_rx_channel(self):
"""
Select next channel from hop list
- if hopping enabled
- if channel has no ack
:return: if new channel selected
:rtype: bool
"""
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):
@ -266,19 +370,52 @@ class HoymilesNRF:
@property
def tx_channel(self):
"""
Get current tx channel
:return: tx_channel
:rtype: int
"""
return self.tx_channel_list[self.tx_channel_id]
@property
def rx_channel(self):
"""
Get current rx channel
:return: rx_channel
:rtype: int
"""
return self.rx_channel_list[self.rx_channel_id]
def frame_payload(payload):
"""
Prepare payload for transmission, append Modbus CRC16
:param bytes payload: payload to be prepared
:return: payload + crc
:rtype: bytes
"""
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):
"""
Build standart ESB request fragment
:param bytes fragment: up to 16 bytes payload chunk
:param seq: frame sequence byte
:type seq: bytes
:param src: dtu address
:type src: int
:param dst: inverter address
:type dst: int
:return: esb frame fragment
:rtype: bytes
:raises ValueError: if fragment size larger 16 byte
"""
if len(fragment) > 17:
raise ValueError(f'ESB fragment exeeds mtu ({mtu}): Fragment size {len(fragment)} bytes')
@ -296,11 +433,27 @@ def compose_esb_fragment(fragment, seq=b'\80', src=99999999, dst=1, **params):
return p
def compose_esb_packet(packet, mtu=17, **params):
"""
Build ESB packet, chunk packet
:param bytes packet: payload data
:param mtu: maximum transmission unit per frame (default: 17)
:type mtu: int
:yields: fragment
"""
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):
"""
Build set time request packet
:param timestamp: time to set (default: int(time.time()) )
:type timestamp: int
:return: payload
:rtype: bytes
"""
if not timestamp:
timestamp = int(time.time())
@ -310,22 +463,11 @@ def compose_set_time_payload(timestamp=None):
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:
"""
Inverter transaction buffer, implements transport-layer functions while
communicating with Hoymiles inverters
"""
tx_queue = []
scratch = []
inverter_ser = None
@ -341,6 +483,18 @@ class InverterTransaction:
dtu_ser=None,
radio=None,
**params):
"""
:param request: Transmit ESB packet
:type request: bytes
:param request_time: datetime of transmission
:type request_time: datetime
:param inverter_ser: inverter serial
:type inverter_ser: str
:param dtu_ser: DTU serial
:type dtu_ser: str
:param radio: HoymilesNRF instance to use
:type radio: HoymilesNRF or None
"""
if radio:
self.radio = radio
@ -371,6 +525,9 @@ class InverterTransaction:
"""
Transmit next packet from tx_queue if available
and wait for responses
:return: if we got contact
:rtype: bool
"""
if not self.radio:
return False
@ -399,15 +556,22 @@ class InverterTransaction:
return wait
def frame_append(self, payload_frame):
def frame_append(self, frame):
"""
Append received raw frame to local scratch buffer
:param bytes frame: Received ESB frame
:return None
"""
self.scratch.append(payload_frame)
self.scratch.append(frame)
def queue_tx(self, frame):
"""
Enqueue packet for transmission if radio is available
:param bytes frame: ESB frame for transmit
:return: if radio is available and frame scheduled
:rtype: bool
"""
if not self.radio:
return False
@ -418,7 +582,14 @@ class InverterTransaction:
def get_payload(self, src=None):
"""
Reconstruct Hoymiles payload from scratch
Reconstruct Hoymiles payload from scratch buffer
:param src: filter frames by inverter hm_address (default self.inverter_address)
:type src: bytes
:return: payload
:rtype: bytes
:raises BufferError: if one or more frames are missing
:raises ValueError: if assambled payload fails CRC check
"""
if not src:
@ -458,6 +629,10 @@ class InverterTransaction:
def __retransmit_frame(self, frame_id):
"""
Build and queue retransmit request
:param int frame_id: frame id to re-schedule
:return: if successful scheduled
:rtype: bool
"""
packet = compose_esb_fragment(b'',
seq=int(0x80 + frame_id).to_bytes(1, 'big'),
@ -467,9 +642,22 @@ class InverterTransaction:
return self.queue_tx(packet)
def __str__(self):
"""
Represent transmit payload
:return: log line of payload for transmission
:rtype: str
"""
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):
"""
Represent bytes
:param bytes byte_var: bytes to be hexlified
:return: two-byte while-space padded byte representation
:rtype: str
"""
return ' '.join([f"{b:02x}" for b in byte_var])

22
tools/rpi/hoymiles/__main__.py

@ -14,6 +14,7 @@ import yaml
from yaml.loader import SafeLoader
def main_loop():
"""Main loop"""
inverters = [
inverter for inverter in ahoy_config.get('inverters', [])
if not inverter.get('disabled', False)]
@ -24,6 +25,13 @@ def main_loop():
poll_inverter(inverter)
def poll_inverter(inverter, retries=4):
"""
Send/Receive command_queue, initiate status poll on inverter
:param str inverter: inverter serial
:param retries: tx retry count if no inverter contact
:type retries: int
"""
inverter_ser = inverter.get('serial')
dtu_ser = ahoy_config.get('dtu', {}).get('serial')
@ -85,7 +93,15 @@ def poll_inverter(inverter, retries=4):
topic=inverter.get('mqtt', {}).get('topic', None))
def mqtt_send_status(broker, inverter_ser, data, topic=None):
""" Publish StatusResponse object """
"""
Publish StatusResponse object
:param paho.mqtt.client.Client broker: mqtt-client instance
:param str inverter_ser: inverter serial
:param hoymiles.StatusResponse data: decoded inverter StatusResponse
:param topic: custom mqtt topic prefix (default: hoymiles/{inverter_ser})
:type topic: str
"""
if not topic:
topic = f'hoymiles/{inverter_ser}'
@ -128,6 +144,10 @@ def mqtt_on_command(client, userdata, message):
mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000
This allows for even faster hacking during runtime
:param paho.mqtt.client.Client client: mqtt-client instance
:param dict userdata: Userdata
:param dict message: mqtt-client message object
"""
try:
inverter_ser = next(

Loading…
Cancel
Save