Browse Source

Merge pull request #34 from Sprinterfreak/pypackage

Pypackage: improve code quality
pull/44/head^2
lumapu 3 years ago
committed by GitHub
parent
commit
2dcf948d60
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      tools/rpi/ahoy.yml.example
  2. 112
      tools/rpi/hoymiles/__init__.py
  3. 61
      tools/rpi/hoymiles/__main__.py
  4. 238
      tools/rpi/hoymiles/decoders/__init__.py
  5. 197
      tools/rpi/hoymiles/outputs.py
  6. 1
      tools/rpi/optional-requirements.txt

9
tools/rpi/ahoy.yml.example

@ -16,6 +16,15 @@ ahoy:
user: 'username' user: 'username'
password: 'password' password: 'password'
# Influx2 output
influxdb:
disabled: true
url: 'http://influxserver.local:8086'
org: 'myorg'
token: '<base64-token>'
bucket: 'telegraf/autogen'
measurement: 'hoymiles'
dtu: dtu:
serial: 99978563001 serial: 99978563001

112
tools/rpi/hoymiles/__init__.py

@ -1,32 +1,38 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Hoymiles micro-inverters python shared code
"""
import struct import struct
import crcmod
import json
import time import time
import re import re
from datetime import datetime from datetime import datetime
import json
import crcmod
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 * 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=False HOYMILES_TRANSACTION_LOGGING=False
HOYMILES_DEBUG_LOGGING=False HOYMILES_DEBUG_LOGGING=False
def ser_to_hm_addr(s): def ser_to_hm_addr(inverter_ser):
""" """
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
address each other. address each other.
:param str s: inverter serial :param str inverter_ser: inverter serial
:return: inverter address :return: inverter address
:rtype: bytes :rtype: bytes
""" """
bcd = int(str(s)[-8:], base=16) bcd = int(str(inverter_ser)[-8:], base=16)
return struct.pack('>L', bcd) return struct.pack('>L', bcd)
def ser_to_esb_addr(s): def ser_to_esb_addr(inverter_ser):
""" """
Convert a Hoymiles inverter/DTU serial number into its Convert a Hoymiles inverter/DTU serial number into its
corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes). corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes).
@ -38,25 +44,22 @@ def ser_to_esb_addr(s):
digits of their serial number, in reverse byte order, digits of their serial number, in reverse byte order,
followed by \x01. followed by \x01.
:param str s: inverter serial :param str inverter_ser: inverter serial
:return: ESB inverter address :return: ESB inverter address
:rtype: bytes :rtype: bytes
""" """
air_order = ser_to_hm_addr(s)[::-1] + b'\x01' air_order = ser_to_hm_addr(inverter_ser)[::-1] + b'\x01'
return air_order[::-1] return air_order[::-1]
def print_addr(a): def print_addr(inverter_ser):
""" """
Debug print addresses Debug print addresses
:param str a: inverter serial :param str inverter_ser: inverter serial
""" """
print(f"ser# {a} ", end='') print(f"ser# {inverter_ser} ", end='')
print(f" -> HM {' '.join([f'{x:02x}' for x in ser_to_hm_addr(a)])}", end='') print(f" -> HM {' '.join([f'{byte:02x}' for byte in ser_to_hm_addr(inverter_ser)])}", end='')
print(f" -> ESB {' '.join([f'{x:02x}' for x in ser_to_esb_addr(a)])}") print(f" -> ESB {' '.join([f'{byte:02x}' for byte in ser_to_esb_addr(inverter_ser)])}")
# time of last transmission - to calculcate response time
t_last_tx = 0
class ResponseDecoderFactory: class ResponseDecoderFactory:
""" """
@ -67,14 +70,19 @@ class ResponseDecoderFactory:
:type request: bytes :type request: bytes
:param inverter_ser: inverter serial :param inverter_ser: inverter serial
:type inverter_ser: str :type inverter_ser: str
:param time_rx: idatetime when payload was received
:type time_rx: datetime
""" """
model = None model = None
request = None request = None
response = None response = None
time_rx = None
def __init__(self, response, **params): def __init__(self, response, **params):
self.response = response self.response = response
self.time_rx = params.get('time_rx', datetime.now())
if 'request' in params: if 'request' in params:
self.request = params['request'] self.request = params['request']
elif hasattr(response, 'request'): elif hasattr(response, 'request'):
@ -110,16 +118,16 @@ class ResponseDecoderFactory:
raise ValueError('Inverter serial while decoding response') raise ValueError('Inverter serial while decoding response')
ser_db = [ ser_db = [
('HM300', r'^1121........'), ('Hm300', r'^1121........'),
('HM600', r'^1141........'), ('Hm600', r'^1141........'),
('HM1200', r'^1161........'), ('Hm1200', r'^1161........'),
] ]
ser_str = str(self.inverter_ser) ser_str = str(self.inverter_ser)
model = None model = None
for m, r in ser_db: for s_model, r_match in ser_db:
if re.match(r, ser_str): if re.match(r_match, ser_str):
model = m model = s_model
break break
if len(model): if len(model):
@ -157,14 +165,17 @@ class ResponseDecoder(ResponseDecoderFactory):
model = self.inverter_model model = self.inverter_model
command = self.request_command command = self.request_command
model_decoders = __import__(f'hoymiles.decoders') model_decoders = __import__('hoymiles.decoders')
if hasattr(model_decoders, f'{model}_Decode{command.upper()}'): if hasattr(model_decoders, f'{model}Decode{command.upper()}'):
device = getattr(model_decoders, f'{model}_Decode{command.upper()}') device = getattr(model_decoders, f'{model}Decode{command.upper()}')
else: else:
if HOYMILES_DEBUG_LOGGING: if HOYMILES_DEBUG_LOGGING:
device = getattr(model_decoders, f'DEBUG_DecodeAny') device = getattr(model_decoders, 'DebugDecodeAny')
return device(self.response) return device(self.response,
time_rx=self.time_rx,
inverter_ser=self.inverter_ser
)
class InverterPacketFragment: class InverterPacketFragment:
"""ESB Frame""" """ESB Frame"""
@ -180,6 +191,8 @@ class InverterPacketFragment:
:type ch_rx: int :type ch_rx: int
:param ch_tx: channel where request was sent :param ch_tx: channel where request was sent
:type ch_tx: int :type ch_tx: int
:raises BufferError: when data gets lost on SPI bus
""" """
if not time_rx: if not time_rx:
@ -247,11 +260,11 @@ class InverterPacketFragment:
:return: log line received frame :return: log line received frame
:rtype: str :rtype: str
""" """
dt = self.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f") c_datetime = self.time_rx.strftime("%Y-%m-%d %H:%M:%S.%f")
size = len(self.frame) size = len(self.frame)
channel = f' channel {self.ch_rx}' if self.ch_rx else '' channel = f' channel {self.ch_rx}' if self.ch_rx else ''
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"{c_datetime} Received {size} bytes{channel}: {raw}"
class HoymilesNRF: class HoymilesNRF:
"""Hoymiles NRF24 Interface""" """Hoymiles NRF24 Interface"""
@ -322,6 +335,7 @@ class HoymilesNRF:
has_payload, pipe_number = self.radio.available_pipe() has_payload, pipe_number = self.radio.available_pipe()
if has_payload: if has_payload:
# Data in nRF24 buffer, read it # Data in nRF24 buffer, read it
self.rx_error = 0 self.rx_error = 0
self.rx_channel_ack = True self.rx_channel_ack = True
@ -334,9 +348,11 @@ class HoymilesNRF:
ch_rx=self.rx_channel, ch_tx=self.tx_channel, ch_rx=self.rx_channel, ch_tx=self.tx_channel,
time_rx=datetime.now() time_rx=datetime.now()
) )
yield(fragment)
yield fragment
else: else:
# No data in nRF rx buffer, search and wait # No data in nRF rx buffer, search and wait
# Channel lock in (not currently used) # Channel lock in (not currently used)
self.rx_error = self.rx_error + 1 self.rx_error = self.rx_error + 1
@ -399,7 +415,7 @@ def frame_payload(payload):
return payload return payload
def compose_esb_fragment(fragment, seq=b'\80', src=99999999, dst=1, **params): def compose_esb_fragment(fragment, seq=b'\x80', src=99999999, dst=1, **params):
""" """
Build standart ESB request fragment Build standart ESB request fragment
@ -415,20 +431,19 @@ def compose_esb_fragment(fragment, seq=b'\80', src=99999999, dst=1, **params):
:raises ValueError: if fragment size larger 16 byte :raises ValueError: if fragment size larger 16 byte
""" """
if len(fragment) > 17: if len(fragment) > 17:
raise ValueError(f'ESB fragment exeeds mtu ({mtu}): Fragment size {len(fragment)} bytes') raise ValueError(f'ESB fragment exeeds mtu: Fragment size {len(fragment)} bytes')
p = b'' packet = b'\x15'
p = p + b'\x15' packet = packet + ser_to_hm_addr(dst)
p = p + ser_to_hm_addr(dst) packet = packet + ser_to_hm_addr(src)
p = p + ser_to_hm_addr(src) packet = packet + seq
p = p + seq
p = p + fragment packet = packet + fragment
crc8 = f_crc8(p) crc8 = f_crc8(packet)
p = p + struct.pack('B', crc8) packet = packet + struct.pack('B', crc8)
return p return packet
def compose_esb_packet(packet, mtu=17, **params): def compose_esb_packet(packet, mtu=17, **params):
""" """
@ -441,7 +456,7 @@ def compose_esb_packet(packet, mtu=17, **params):
""" """
for i in range(0, len(packet), mtu): for i in range(0, len(packet), mtu):
fragment = compose_esb_fragment(packet[i:i+mtu], **params) fragment = compose_esb_fragment(packet[i:i+mtu], **params)
yield(fragment) yield fragment
def compose_set_time_payload(timestamp=None): def compose_set_time_payload(timestamp=None):
""" """
@ -472,6 +487,7 @@ class InverterTransaction:
inverter_addr = None inverter_addr = None
dtu_ser = None dtu_ser = None
req_type = None req_type = None
time_rx = None
radio = None radio = None
@ -530,14 +546,14 @@ class InverterTransaction:
if not self.radio: if not self.radio:
return False return False
if not len(self.tx_queue): if len(self.tx_queue) == 0:
return False return False
packet = self.tx_queue.pop(0) packet = self.tx_queue.pop(0)
if HOYMILES_TRANSACTION_LOGGING: if HOYMILES_TRANSACTION_LOGGING:
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
print(f'{dt} Transmit {len(packet)} | {hexify_payload(packet)}') print(f'{c_datetime} Transmit {len(packet)} | {hexify_payload(packet)}')
self.radio.transmit(packet) self.radio.transmit(packet)
@ -646,9 +662,9 @@ class InverterTransaction:
:return: log line of payload for transmission :return: log line of payload for transmission
:rtype: str :rtype: str
""" """
dt = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f") c_datetime = self.request_time.strftime("%Y-%m-%d %H:%M:%S.%f")
size = len(self.request) size = len(self.request)
return f'{dt} Transmit | {hexify_payload(self.request)}' return f'{c_datetime} Transmit | {hexify_payload(self.request)}'
def hexify_payload(byte_var): def hexify_payload(byte_var):
""" """

61
tools/rpi/hoymiles/__main__.py

@ -1,17 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
Hoymiles micro-inverters main application
"""
import sys import sys
import struct import struct
import re import re
import time import time
from datetime import datetime from datetime import datetime
import argparse 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 yaml import yaml
from yaml.loader import SafeLoader from yaml.loader import SafeLoader
import paho.mqtt.client
from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16
import hoymiles
def main_loop(): def main_loop():
"""Main loop""" """Main loop"""
@ -61,14 +65,14 @@ def poll_inverter(inverter, retries=4):
try: try:
response = com.get_payload() response = com.get_payload()
payload_ttl = 0 payload_ttl = 0
except Exception as e: except Exception as e_all:
print(f'Error while retrieving data: {e}') print(f'Error while retrieving data: {e_all}')
pass pass
# Handle the response data if any # Handle the response data if any
if response: if response:
dt = datetime.now() c_datetime = datetime.now()
print(f'{dt} Payload: ' + hoymiles.hexify_payload(response)) print(f'{c_datetime} Payload: ' + hoymiles.hexify_payload(response))
decoder = hoymiles.ResponseDecoder(response, decoder = hoymiles.ResponseDecoder(response,
request=com.request, request=com.request,
inverter_ser=inverter_ser inverter_ser=inverter_ser
@ -77,7 +81,7 @@ def poll_inverter(inverter, retries=4):
if isinstance(result, hoymiles.decoders.StatusResponse): if isinstance(result, hoymiles.decoders.StatusResponse):
data = result.__dict__() data = result.__dict__()
if hoymiles.HOYMILES_DEBUG_LOGGING: if hoymiles.HOYMILES_DEBUG_LOGGING:
print(f'{dt} Decoded: {data["temperature"]}', end='') print(f'{c_datetime} Decoded: {data["temperature"]}', end='')
phase_id = 0 phase_id = 0
for phase in data['phases']: for phase in data['phases']:
print(f' phase{phase_id}=voltage:{phase["voltage"]}, current:{phase["current"]}, power:{phase["power"]}, frequency:{data["frequency"]}', end='') print(f' phase{phase_id}=voltage:{phase["voltage"]}, current:{phase["current"]}, power:{phase["power"]}, frequency:{data["frequency"]}', end='')
@ -91,6 +95,8 @@ def poll_inverter(inverter, retries=4):
if mqtt_client: if mqtt_client:
mqtt_send_status(mqtt_client, inverter_ser, data, mqtt_send_status(mqtt_client, inverter_ser, data,
topic=inverter.get('mqtt', {}).get('topic', None)) topic=inverter.get('mqtt', {}).get('topic', None))
if influx_client:
influx_client.store_status(result)
def mqtt_send_status(broker, inverter_ser, data, topic=None): def mqtt_send_status(broker, inverter_ser, data, topic=None):
""" """
@ -183,17 +189,17 @@ if __name__ == '__main__':
# Load ahoy.yml config file # Load ahoy.yml config file
try: try:
if isinstance(global_config.config_file, str) == True: if isinstance(global_config.config_file, str):
with open(global_config.config_file, 'r') as yf: with open(global_config.config_file, 'r') as fh_yaml:
cfg = yaml.load(yf, Loader=SafeLoader) cfg = yaml.load(fh_yaml, Loader=SafeLoader)
else: else:
with open('ahoy.yml', 'r') as yf: with open('ahoy.yml', 'r') as fh_yaml:
cfg = yaml.load(yf, Loader=SafeLoader) cfg = yaml.load(fh_yaml, Loader=SafeLoader)
except FileNotFoundError: except FileNotFoundError:
print("Could not load config file. Try --help") print("Could not load config file. Try --help")
sys.exit(2) sys.exit(2)
except yaml.YAMLError as ye: except yaml.YAMLError as e_yaml:
print('Failed to load config frile {global_config.config_file}: {ye}') print('Failed to load config frile {global_config.config_file}: {e_yaml}')
sys.exit(1) sys.exit(1)
ahoy_config = dict(cfg.get('ahoy', {})) ahoy_config = dict(cfg.get('ahoy', {}))
@ -225,21 +231,32 @@ if __name__ == '__main__':
mqtt_client.loop_start() mqtt_client.loop_start()
mqtt_client.on_message = mqtt_on_command mqtt_client.on_message = mqtt_on_command
influx_client = None
influx_config = ahoy_config.get('influxdb', {})
if influx_config and not influx_config.get('disabled', False):
from .outputs import InfluxOutputPlugin
influx_client = InfluxOutputPlugin(
influx_config.get('url'),
influx_config.get('token'),
org=influx_config.get('org', ''),
bucket=influx_config.get('bucket', None),
measurement=influx_config.get('measurement', 'hoymiles'))
if not radio.begin(): if not radio.begin():
raise RuntimeError('Can\'t open radio') raise RuntimeError('Can\'t open radio')
inverters = [inverter.get('serial') for inverter in ahoy_config.get('inverters', [])] g_inverters = [g_inverter.get('serial') for g_inverter in ahoy_config.get('inverters', [])]
for inverter in ahoy_config.get('inverters', []): for g_inverter in ahoy_config.get('inverters', []):
inverter_ser = inverter.get('serial') g_inverter_ser = g_inverter.get('serial')
command_queue[str(inverter_ser)] = [] command_queue[str(g_inverter_ser)] = []
# #
# Enables and subscribe inverter to mqtt /command-Topic # Enables and subscribe inverter to mqtt /command-Topic
# #
if mqtt_client and inverter.get('mqtt', {}).get('send_raw_enabled', False): if mqtt_client and g_inverter.get('mqtt', {}).get('send_raw_enabled', False):
topic_item = ( topic_item = (
str(inverter_ser), str(g_inverter_ser),
inverter.get('mqtt', {}).get('topic', f'hoymiles/{inverter_ser}') + '/command' g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{g_inverter_ser}') + '/command'
) )
mqtt_client.subscribe(topic_item[1]) mqtt_client.subscribe(topic_item[1])
mqtt_command_topic_subs.append(topic_item) mqtt_command_topic_subs.append(topic_item)

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

@ -1,14 +1,50 @@
#!/usr/bin/python3 #!/usr/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
Hoymiles Micro-Inverters decoder library
"""
import struct import struct
from datetime import datetime, timedelta
import crcmod import crcmod
from datetime import timedelta
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
class StatusResponse: class Response:
""" All Response Shared methods """
inverter_ser = None
inverter_name = None
dtu_ser = None
response = None
def __init__(self, *args, **params):
"""
:param bytes response: response payload bytes
"""
self.inverter_ser = params.get('inverter_ser', None)
self.inverter_name = params.get('inverter_name', None)
self.dtu_ser = params.get('dtu_ser', None)
self.response = args[0]
if isinstance(params.get('time_rx', None), datetime):
self.time_rx = params['time_rx']
else:
self.time_rx = datetime.now()
def __dict__(self):
""" Base values, availabe in each __dict__ call """
return {
'inverter_ser': self.inverter_ser,
'inverter_name': self.inverter_name,
'dtu_ser': self.dtu_ser}
class StatusResponse(Response):
"""Inverter StatusResponse object""" """Inverter StatusResponse object"""
e_keys = ['voltage','current','power','energy_total','energy_daily'] e_keys = ['voltage','current','power','energy_total','energy_daily','powerfactor']
temperature = None
frequency = None
def unpack(self, fmt, base): def unpack(self, fmt, base):
""" """
@ -77,17 +113,19 @@ class StatusResponse:
:return: dict of properties :return: dict of properties
:rtype: dict :rtype: dict
""" """
data = {} data = super().__dict__()
data['phases'] = self.phases data['phases'] = self.phases
data['strings'] = self.strings data['strings'] = self.strings
data['temperature'] = self.temperature data['temperature'] = self.temperature
data['frequency'] = self.frequency data['frequency'] = self.frequency
data['time'] = self.time_rx
return data return data
class UnknownResponse: class UnknownResponse(Response):
""" """
Debugging helper for unknown payload format Debugging helper for unknown payload format
""" """
@property @property
def hex_ascii(self): def hex_ascii(self):
""" """
@ -96,7 +134,7 @@ class UnknownResponse:
:return: hexlifierd byte string :return: hexlifierd byte string
:rtype: str :rtype: str
""" """
return ' '.join([f'{b:02x}' for b in self.response]) return ' '.join([f'{byte:02x}' for byte in self.response])
@property @property
def valid_crc(self): def valid_crc(self):
@ -113,116 +151,117 @@ class UnknownResponse:
@property @property
def dump_longs(self): def dump_longs(self):
"""Get all data, interpreted as long""" """Get all data, interpreted as long"""
if len(self.response) < 5: if len(self.response) < 3:
return None return None
res = self.response res = self.response
r = len(res) % 16 rem = len(res) % 16
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 16 == 0: if len(res) % 16 == 0:
n = len(res)/4 rlen = len(res)/4
vals = struct.unpack(f'>{int(n)}L', res) vals = struct.unpack(f'>{int(rlen)}L', res)
return vals return vals
@property @property
def dump_longs_pad1(self): def dump_longs_pad1(self):
"""Get all data, interpreted as long""" """Get all data, interpreted as long"""
if len(self.response) < 7: if len(self.response) < 5:
return None return None
res = self.response[2:] res = self.response[2:]
r = len(res) % 16 rem = len(res) % 16
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 16 == 0: if len(res) % 16 == 0:
n = len(res)/4 rlen = len(res)/4
vals = struct.unpack(f'>{int(n)}L', res) vals = struct.unpack(f'>{int(rlen)}L', res)
return vals return vals
@property @property
def dump_longs_pad2(self): def dump_longs_pad2(self):
"""Get all data, interpreted as long""" """Get all data, interpreted as long"""
if len(self.response) < 9: if len(self.response) < 7:
return None return None
res = self.response[4:] res = self.response[4:]
r = len(res) % 16 rem = len(res) % 16
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 16 == 0: if len(res) % 16 == 0:
n = len(res)/4 rlen = len(res)/4
vals = struct.unpack(f'>{int(n)}L', res) vals = struct.unpack(f'>{int(rlen)}L', res)
return vals return vals
@property @property
def dump_longs_pad3(self): def dump_longs_pad3(self):
"""Get all data, interpreted as long""" """Get all data, interpreted as long"""
if len(self.response) < 11: if len(self.response) < 9:
return None return None
res = self.response[6:] res = self.response[6:]
r = len(res) % 16 rem = len(res) % 16
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 16 == 0: if len(res) % 16 == 0:
n = len(res)/4 rlen = len(res)/4
vals = struct.unpack(f'>{int(n)}L', res) vals = struct.unpack(f'>{int(rlen)}L', res)
return vals return vals
@property @property
def dump_shorts(self): def dump_shorts(self):
"""Get all data, interpreted as short""" """Get all data, interpreted as short"""
if len(self.response) < 5: if len(self.response) < 3:
return None return None
res = self.response res = self.response
r = len(res) % 4 rem = len(res) % 4
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 4 == 0: if len(res) % 4 == 0:
n = len(res)/2 rlen = len(res)/2
vals = struct.unpack(f'>{int(n)}H', res) vals = struct.unpack(f'>{int(rlen)}H', res)
return vals return vals
@property @property
def dump_shorts_pad1(self): def dump_shorts_pad1(self):
"""Get all data, interpreted as short""" """Get all data, interpreted as short"""
if len(self.response) < 6: if len(self.response) < 4:
return None return None
res = self.response[1:] res = self.response[1:]
r = len(res) % 4 rem = len(res) % 4
res = res[:r*-1] res = res[:rem*-1]
vals = None vals = None
if len(res) % 4 == 0: if len(res) % 4 == 0:
n = len(res)/2 rlen = len(res)/2
vals = struct.unpack(f'>{int(n)}H', res) vals = struct.unpack(f'>{int(rlen)}H', res)
return vals return vals
class EventsResponse(UnknownResponse): class EventsResponse(UnknownResponse):
""" Hoymiles micro-inverter event log decode helper """
alarm_codes = { alarm_codes = {
1: 'Inverter start', 1: 'Inverter start',
2: 'Producing power', 2: 'DTU command failed',
121: 'Over temperature protection', 121: 'Over temperature protection',
125: 'Grid configuration parameter error', 125: 'Grid configuration parameter error',
126: 'Software error code 126', 126: 'Software error code 126',
@ -291,21 +330,21 @@ class EventsResponse(UnknownResponse):
9000: 'Microinverter is suspected of being stolen' 9000: 'Microinverter is suspected of being stolen'
} }
def __init__(self, response): def __init__(self, *args, **params):
self.response = response super().__init__(*args, **params)
crc_valid = self.valid_crc crc_valid = self.valid_crc
if crc_valid: if crc_valid:
print(' payload has valid modbus crc') print(' payload has valid modbus crc')
self.response = response[:-2] self.response = self.response[:-2]
status = self.response[:2] status = self.response[:2]
chunk_size = 12 chunk_size = 12
for c in range(2, len(self.response), chunk_size): for i_chunk in range(2, len(self.response), chunk_size):
chunk = self.response[c:c+chunk_size] chunk = self.response[i_chunk:i_chunk+chunk_size]
print(' '.join([f'{b:02x}' for b in chunk]) + ': ') print(' '.join([f'{byte:02x}' for byte in chunk]) + ': ')
opcode, a_code, a_count, uptime_sec = struct.unpack('>BBHH', chunk[0:6]) opcode, a_code, a_count, uptime_sec = struct.unpack('>BBHH', chunk[0:6])
a_text = self.alarm_codes.get(a_code, 'N/A') a_text = self.alarm_codes.get(a_code, 'N/A')
@ -316,20 +355,16 @@ class EventsResponse(UnknownResponse):
print(f' {fmt:7}: ' + str(struct.unpack('>' + fmt, chunk))) print(f' {fmt:7}: ' + str(struct.unpack('>' + fmt, chunk)))
print(end='', flush=True) print(end='', flush=True)
class DEBUG_DecodeAny(UnknownResponse): class DebugDecodeAny(UnknownResponse):
"""Default decoder""" """Default decoder"""
def __init__(self, response):
"""
Try interpret and print unknown response data
:param bytes response: response payload bytes def __init__(self, *args, **params):
""" super().__init__(*args, **params)
self.response = response
crc_valid = self.valid_crc crc_valid = self.valid_crc
if crc_valid: if crc_valid:
print(' payload has valid modbus crc') print(' payload has valid modbus crc')
self.response = response[:-2] self.response = self.response[:-2]
l_payload = len(self.response) l_payload = len(self.response)
print(f' payload has {l_payload} bytes') print(f' payload has {l_payload} bytes')
@ -384,204 +419,247 @@ class DEBUG_DecodeAny(UnknownResponse):
# 1121-Series Intervers, 1 MPPT, 1 Phase # 1121-Series Intervers, 1 MPPT, 1 Phase
class HM300_Decode0B(StatusResponse): class Hm300Decode0B(StatusResponse):
def __init__(self, response): """ 1121-series mirco-inverters status data """
self.response = response
@property @property
def dc_voltage_0(self): def dc_voltage_0(self):
""" String 1 VDC """
return self.unpack('>H', 2)[0]/10 return self.unpack('>H', 2)[0]/10
@property @property
def dc_current_0(self): def dc_current_0(self):
""" String 1 ampere """
return self.unpack('>H', 4)[0]/100 return self.unpack('>H', 4)[0]/100
@property @property
def dc_power_0(self): def dc_power_0(self):
""" String 1 watts """
return self.unpack('>H', 6)[0]/10 return self.unpack('>H', 6)[0]/10
@property @property
def dc_energy_total_0(self): def dc_energy_total_0(self):
""" String 1 total energy in Wh """
return self.unpack('>L', 8)[0] return self.unpack('>L', 8)[0]
@property @property
def dc_energy_daily_0(self): def dc_energy_daily_0(self):
""" String 1 daily energy in Wh """
return self.unpack('>H', 12)[0] return self.unpack('>H', 12)[0]
@property @property
def ac_voltage_0(self): def ac_voltage_0(self):
""" Phase 1 VAC """
return self.unpack('>H', 14)[0]/10 return self.unpack('>H', 14)[0]/10
@property @property
def ac_current_0(self): def ac_current_0(self):
""" Phase 1 ampere """
return self.unpack('>H', 22)[0]/100 return self.unpack('>H', 22)[0]/100
@property @property
def ac_power_0(self): def ac_power_0(self):
""" Phase 1 watts """
return self.unpack('>H', 18)[0]/10 return self.unpack('>H', 18)[0]/10
@property @property
def frequency(self): def frequency(self):
""" Grid frequency in Hertz """
return self.unpack('>H', 16)[0]/100 return self.unpack('>H', 16)[0]/100
@property @property
def temperature(self): def temperature(self):
""" Inverter temperature in °C """
return self.unpack('>H', 26)[0]/10 return self.unpack('>H', 26)[0]/10
class HM300_Decode11(EventsResponse): class Hm300Decode11(EventsResponse):
def __init__(self, response): """ Inverter generic events log """
super().__init__(response)
class HM300_Decode12(EventsResponse):
def __init__(self, response):
super().__init__(response)
class Hm300Decode12(EventsResponse):
""" Inverter major events log """
# 1141-Series Inverters, 2 MPPT, 1 Phase # 1141-Series Inverters, 2 MPPT, 1 Phase
class HM600_Decode0B(StatusResponse): class Hm600Decode0B(StatusResponse):
def __init__(self, response): """ 1141-series mirco-inverters status data """
self.response = response
@property @property
def dc_voltage_0(self): def dc_voltage_0(self):
""" String 1 VDC """
return self.unpack('>H', 2)[0]/10 return self.unpack('>H', 2)[0]/10
@property @property
def dc_current_0(self): def dc_current_0(self):
""" String 1 ampere """
return self.unpack('>H', 4)[0]/100 return self.unpack('>H', 4)[0]/100
@property @property
def dc_power_0(self): def dc_power_0(self):
""" String 1 watts """
return self.unpack('>H', 6)[0]/10 return self.unpack('>H', 6)[0]/10
@property @property
def dc_energy_total_0(self): def dc_energy_total_0(self):
""" String 1 total energy in Wh """
return self.unpack('>L', 14)[0] return self.unpack('>L', 14)[0]
@property @property
def dc_energy_daily_0(self): def dc_energy_daily_0(self):
""" String 1 daily energy in Wh """
return self.unpack('>H', 22)[0] return self.unpack('>H', 22)[0]
@property @property
def dc_voltage_1(self): def dc_voltage_1(self):
""" String 2 VDC """
return self.unpack('>H', 8)[0]/10 return self.unpack('>H', 8)[0]/10
@property @property
def dc_current_1(self): def dc_current_1(self):
""" String 2 ampere """
return self.unpack('>H', 10)[0]/100 return self.unpack('>H', 10)[0]/100
@property @property
def dc_power_1(self): def dc_power_1(self):
""" String 2 watts """
return self.unpack('>H', 12)[0]/10 return self.unpack('>H', 12)[0]/10
@property @property
def dc_energy_total_1(self): def dc_energy_total_1(self):
""" String 2 total energy in Wh """
return self.unpack('>L', 18)[0] return self.unpack('>L', 18)[0]
@property @property
def dc_energy_daily_1(self): def dc_energy_daily_1(self):
""" String 2 daily energy in Wh """
return self.unpack('>H', 24)[0] return self.unpack('>H', 24)[0]
@property @property
def ac_voltage_0(self): def ac_voltage_0(self):
""" Phase 1 VAC """
return self.unpack('>H', 26)[0]/10 return self.unpack('>H', 26)[0]/10
@property @property
def ac_current_0(self): def ac_current_0(self):
""" Phase 1 ampere """
return self.unpack('>H', 34)[0]/10 return self.unpack('>H', 34)[0]/10
@property @property
def ac_power_0(self): def ac_power_0(self):
""" Phase 1 watts """
return self.unpack('>H', 30)[0]/10 return self.unpack('>H', 30)[0]/10
@property @property
def frequency(self): def frequency(self):
""" Grid frequency in Hertz """
return self.unpack('>H', 28)[0]/100 return self.unpack('>H', 28)[0]/100
@property @property
def temperature(self): def temperature(self):
""" Inverter temperature in °C """
return self.unpack('>H', 38)[0]/10 return self.unpack('>H', 38)[0]/10
@property
def alarm_count(self):
""" Event counter """
return self.unpack('>H', 40)[0]
class HM600_Decode11(EventsResponse): class Hm600Decode11(EventsResponse):
def __init__(self, response): """ Inverter generic events log """
super().__init__(response)
class HM600_Decode12(EventsResponse): class Hm600Decode12(EventsResponse):
def __init__(self, response): """ Inverter major events log """
super().__init__(response)
# 1161-Series Inverters, 4 MPPT, 1 Phase # 1161-Series Inverters, 4 MPPT, 1 Phase
class HM1200_Decode0B(StatusResponse): class Hm1200Decode0B(StatusResponse):
def __init__(self, response): """ 1161-series mirco-inverters status data """
self.response = response
@property @property
def dc_voltage_0(self): def dc_voltage_0(self):
""" String 1 VDC """
return self.unpack('>H', 2)[0]/10 return self.unpack('>H', 2)[0]/10
@property @property
def dc_current_0(self): def dc_current_0(self):
""" String 1 ampere """
return self.unpack('>H', 4)[0]/100 return self.unpack('>H', 4)[0]/100
@property @property
def dc_power_0(self): def dc_power_0(self):
""" String 1 watts """
return self.unpack('>H', 8)[0]/10 return self.unpack('>H', 8)[0]/10
@property @property
def dc_energy_total_0(self): def dc_energy_total_0(self):
""" String 1 total energy in Wh """
return self.unpack('>L', 12)[0] return self.unpack('>L', 12)[0]
@property @property
def dc_energy_daily_0(self): def dc_energy_daily_0(self):
""" String 1 daily energy in Wh """
return self.unpack('>H', 20)[0] return self.unpack('>H', 20)[0]
@property @property
def dc_voltage_1(self): def dc_voltage_1(self):
""" String 2 VDC """
return self.unpack('>H', 2)[0]/10 return self.unpack('>H', 2)[0]/10
@property @property
def dc_current_1(self): def dc_current_1(self):
""" String 2 ampere """
return self.unpack('>H', 4)[0]/100 return self.unpack('>H', 4)[0]/100
@property @property
def dc_power_1(self): def dc_power_1(self):
""" String 2 watts """
return self.unpack('>H', 10)[0]/10 return self.unpack('>H', 10)[0]/10
@property @property
def dc_energy_total_1(self): def dc_energy_total_1(self):
""" String 2 total energy in Wh """
return self.unpack('>L', 16)[0] return self.unpack('>L', 16)[0]
@property @property
def dc_energy_daily_1(self): def dc_energy_daily_1(self):
""" String 2 daily energy in Wh """
return self.unpack('>H', 22)[0] return self.unpack('>H', 22)[0]
@property @property
def dc_voltage_2(self): def dc_voltage_2(self):
""" String 3 VDC """
return self.unpack('>H', 24)[0]/10 return self.unpack('>H', 24)[0]/10
@property @property
def dc_current_2(self): def dc_current_2(self):
""" String 3 ampere """
return self.unpack('>H', 26)[0]/100 return self.unpack('>H', 26)[0]/100
@property @property
def dc_power_2(self): def dc_power_2(self):
""" String 3 watts """
return self.unpack('>H', 30)[0]/10 return self.unpack('>H', 30)[0]/10
@property @property
def dc_energy_total_2(self): def dc_energy_total_2(self):
""" String 3 total energy in Wh """
return self.unpack('>L', 34)[0] return self.unpack('>L', 34)[0]
@property @property
def dc_energy_daily_2(self): def dc_energy_daily_2(self):
""" String 3 daily energy in Wh """
return self.unpack('>H', 42)[0] return self.unpack('>H', 42)[0]
@property @property
def dc_voltage_3(self): def dc_voltage_3(self):
""" String 4 VDC """
return self.unpack('>H', 24)[0]/10 return self.unpack('>H', 24)[0]/10
@property @property
def dc_current_3(self): def dc_current_3(self):
""" String 4 ampere """
return self.unpack('>H', 28)[0]/100 return self.unpack('>H', 28)[0]/100
@property @property
def dc_power_3(self): def dc_power_3(self):
""" String 4 watts """
return self.unpack('>H', 32)[0]/10 return self.unpack('>H', 32)[0]/10
@property @property
def dc_energy_total_3(self): def dc_energy_total_3(self):
""" String 4 total energy in Wh """
return self.unpack('>L', 38)[0] return self.unpack('>L', 38)[0]
@property @property
def dc_energy_daily_3(self): def dc_energy_daily_3(self):
""" String 4 daily energy in Wh """
return self.unpack('>H', 44)[0] return self.unpack('>H', 44)[0]
@property @property
def ac_voltage_0(self): def ac_voltage_0(self):
""" Phase 1 VAC """
return self.unpack('>H', 46)[0]/10 return self.unpack('>H', 46)[0]/10
@property @property
def ac_current_0(self): def ac_current_0(self):
""" Phase 1 ampere """
return self.unpack('>H', 54)[0]/100 return self.unpack('>H', 54)[0]/100
@property @property
def ac_power_0(self): def ac_power_0(self):
""" Phase 1 watts """
return self.unpack('>H', 50)[0]/10 return self.unpack('>H', 50)[0]/10
@property @property
def frequency(self): def frequency(self):
""" Grid frequency in Hertz """
return self.unpack('>H', 48)[0]/100 return self.unpack('>H', 48)[0]/100
@property @property
def temperature(self): def temperature(self):
""" Inverter temperature in °C """
return self.unpack('>H', 58)[0]/10 return self.unpack('>H', 58)[0]/10
class HM1200_Decode11(EventsResponse): class Hm1200Decode11(EventsResponse):
def __init__(self, response): """ Inverter generic events log """
super().__init__(response)
class HM1200_Decode12(EventsResponse): class Hm1200Decode12(EventsResponse):
def __init__(self, response): """ Inverter major events log """
super().__init__(response)

197
tools/rpi/hoymiles/outputs.py

@ -0,0 +1,197 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Hoymiles output plugin library
"""
import socket
from datetime import datetime, timezone
from hoymiles.decoders import StatusResponse
try:
from influxdb_client import InfluxDBClient
except ModuleNotFoundError:
pass
class OutputPluginFactory:
def __init__(self, **params):
"""
Initialize output plugin
:param inverter_ser: The inverter serial
:type inverter_ser: str
:param inverter_name: The configured name for the inverter
:type inverter_name: str
"""
self.inverter_ser = params.get('inverter_ser', '')
self.inverter_name = params.get('inverter_name', None)
def store_status(self, response, **params):
"""
Default function
:raises NotImplementedError: when the plugin does not implement store status data
"""
raise NotImplementedError('The current output plugin does not implement store_status')
class InfluxOutputPlugin(OutputPluginFactory):
""" Influx2 output plugin """
api = None
def __init__(self, url, token, **params):
"""
Initialize InfluxOutputPlugin
The following targets must be present in your InfluxDB. This does not
automatically create anything for You.
:param str url: The url to connect this client to. Like http://localhost:8086
:param str token: Influx2 access token which is allowed to write to bucket
:param org: Influx2 org, the token belongs to
:type org: str
:param bucket: Influx2 bucket to store data in (also known as retention policy)
:type bucket: str
:param measurement: Default measurement-prefix to use
:type measurement: str
"""
super().__init__(**params)
self._bucket = params.get('bucket', 'hoymiles/autogen')
self._org = params.get('org', '')
self._measurement = params.get('measurement',
f'inverter,host={socket.gethostname()}')
client = InfluxDBClient(url, token, bucket=self._bucket)
self.api = client.write_api()
def store_status(self, response, **params):
"""
Publish StatusResponse object
:param hoymiles.decoders.StatusResponse response: StatusResponse object
:type response: hoymiles.decoders.StatusResponse
:param measurement: Custom influx measurement name
:type measurement: str or None
:raises ValueError: when response is not instance of StatusResponse
"""
if not isinstance(response, StatusResponse):
raise ValueError('Data needs to be instance of StatusResponse')
data = response.__dict__()
measurement = self._measurement + f',location={data["inverter_ser"]}'
data_stack = []
time_rx = datetime.now()
if 'time' in data and isinstance(data['time'], datetime):
time_rx = data['time']
# InfluxDB uses UTC
utctime = datetime.fromtimestamp(time_rx.timestamp(), tz=timezone.utc)
# InfluxDB requires nanoseconds
ctime = int(utctime.timestamp() * 1e9)
# AC Data
phase_id = 0
for phase in data['phases']:
data_stack.append(f'{measurement},phase={phase_id},type=power value={phase["power"]} {ctime}')
data_stack.append(f'{measurement},phase={phase_id},type=voltage value={phase["voltage"]} {ctime}')
data_stack.append(f'{measurement},phase={phase_id},type=current value={phase["current"]} {ctime}')
phase_id = phase_id + 1
# DC Data
string_id = 0
for string in data['strings']:
data_stack.append(f'{measurement},string={string_id},type=total value={string["energy_total"]/1000:.4f} {ctime}')
data_stack.append(f'{measurement},string={string_id},type=power value={string["power"]:.2f} {ctime}')
data_stack.append(f'{measurement},string={string_id},type=voltage value={string["voltage"]:.3f} {ctime}')
data_stack.append(f'{measurement},string={string_id},type=current value={string["current"]:3f} {ctime}')
string_id = string_id + 1
# Global
data_stack.append(f'{measurement},type=frequency value={data["frequency"]:.3f} {ctime}')
data_stack.append(f'{measurement},type=temperature value={data["temperature"]:.2f} {ctime}')
self.api.write(self._bucket, self._org, data_stack)
try:
import paho.mqtt.client
except ModuleNotFoundError:
pass
class MqttOutputPlugin(OutputPluginFactory):
""" Mqtt output plugin """
client = None
def __init__(self, *args, **params):
"""
Initialize MqttOutputPlugin
:param host: Broker ip or hostname (defaults to: 127.0.0.1)
:type host: str
:param port: Broker port
:type port: int (defaults to: 1883)
:param user: Optional username to login to the broker
:type user: str or None
:param password: Optional passwort to login to the broker
:type password: str or None
:param topic: Topic prefix to use (defaults to: hoymiles/{inverter_ser})
:type topic: str
: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
"""
super().__init__(*args, **params)
mqtt_client = paho.mqtt.client.Client()
mqtt_client.username_pw_set(params.get('user', None), params.get('password', None))
mqtt_client.connect(params.get('host', '127.0.0.1'), params.get('port', 1883))
mqtt_client.loop_start()
self.client = mqtt_client
def store_status(self, response, **params):
"""
Publish StatusResponse object
:param hoymiles.decoders.StatusResponse response: StatusResponse object
:param topic: custom mqtt topic prefix (default: hoymiles/{inverter_ser})
:type topic: str
:raises ValueError: when response is not instance of StatusResponse
"""
if not isinstance(response, StatusResponse):
raise ValueError('Data needs to be instance of StatusResponse')
data = response.__dict__()
topic = params.get('topic', f'hoymiles/{data["inverter_ser"]}')
# AC Data
phase_id = 0
for phase in data['phases']:
self.client.publish(f'{topic}/emeter/{phase_id}/power', phase['power'])
self.client.publish(f'{topic}/emeter/{phase_id}/voltage', phase['voltage'])
self.client.publish(f'{topic}/emeter/{phase_id}/current', phase['current'])
phase_id = phase_id + 1
# DC Data
string_id = 0
for string in data['strings']:
self.client.publish(f'{topic}/emeter-dc/{string_id}/total', string['energy_total']/1000)
self.client.publish(f'{topic}/emeter-dc/{string_id}/power', string['power'])
self.client.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage'])
self.client.publish(f'{topic}/emeter-dc/{string_id}/current', string['current'])
string_id = string_id + 1
# Global
self.client.publish(f'{topic}/frequency', data['frequency'])
self.client.publish(f'{topic}/temperature', data['temperature'])

1
tools/rpi/optional-requirements.txt

@ -0,0 +1 @@
influxdb-client>=1.28.0
Loading…
Cancel
Save