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'
password: 'password'
# Influx2 output
influxdb:
disabled: true
url: 'http://influxserver.local:8086'
org: 'myorg'
token: '<base64-token>'
bucket: 'telegraf/autogen'
measurement: 'hoymiles'
dtu:
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 crcmod
import json
import time
import re
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 .decoders import *
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
HOYMILES_TRANSACTION_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
address each other.
:param str s: inverter serial
:param str inverter_ser: inverter serial
:return: inverter address
:rtype: bytes
"""
bcd = int(str(s)[-8:], base=16)
bcd = int(str(inverter_ser)[-8:], base=16)
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
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,
followed by \x01.
:param str s: inverter serial
:param str inverter_ser: inverter serial
:return: ESB inverter address
: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]
def print_addr(a):
def print_addr(inverter_ser):
"""
Debug print addresses
:param str a: inverter serial
:param str inverter_ser: 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)])}")
# time of last transmission - to calculcate response time
t_last_tx = 0
print(f"ser# {inverter_ser} ", end='')
print(f" -> HM {' '.join([f'{byte:02x}' for byte in ser_to_hm_addr(inverter_ser)])}", end='')
print(f" -> ESB {' '.join([f'{byte:02x}' for byte in ser_to_esb_addr(inverter_ser)])}")
class ResponseDecoderFactory:
"""
@ -67,14 +70,19 @@ class ResponseDecoderFactory:
:type request: bytes
:param inverter_ser: inverter serial
:type inverter_ser: str
:param time_rx: idatetime when payload was received
:type time_rx: datetime
"""
model = None
request = None
response = None
time_rx = None
def __init__(self, response, **params):
self.response = response
self.time_rx = params.get('time_rx', datetime.now())
if 'request' in params:
self.request = params['request']
elif hasattr(response, 'request'):
@ -110,16 +118,16 @@ class ResponseDecoderFactory:
raise ValueError('Inverter serial while decoding response')
ser_db = [
('HM300', r'^1121........'),
('HM600', r'^1141........'),
('HM1200', r'^1161........'),
('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
for s_model, r_match in ser_db:
if re.match(r_match, ser_str):
model = s_model
break
if len(model):
@ -157,14 +165,17 @@ class ResponseDecoder(ResponseDecoderFactory):
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()}')
model_decoders = __import__('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')
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:
"""ESB Frame"""
@ -180,6 +191,8 @@ class InverterPacketFragment:
:type ch_rx: int
:param ch_tx: channel where request was sent
:type ch_tx: int
:raises BufferError: when data gets lost on SPI bus
"""
if not time_rx:
@ -247,11 +260,11 @@ class InverterPacketFragment:
:return: log line received frame
: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)
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}"
return f"{c_datetime} Received {size} bytes{channel}: {raw}"
class HoymilesNRF:
"""Hoymiles NRF24 Interface"""
@ -322,6 +335,7 @@ class HoymilesNRF:
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
@ -334,9 +348,11 @@ class HoymilesNRF:
ch_rx=self.rx_channel, ch_tx=self.tx_channel,
time_rx=datetime.now()
)
yield(fragment)
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
@ -399,7 +415,7 @@ def frame_payload(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
@ -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
"""
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''
p = p + b'\x15'
p = p + ser_to_hm_addr(dst)
p = p + ser_to_hm_addr(src)
p = p + seq
packet = b'\x15'
packet = packet + ser_to_hm_addr(dst)
packet = packet + ser_to_hm_addr(src)
packet = packet + seq
p = p + fragment
packet = packet + fragment
crc8 = f_crc8(p)
p = p + struct.pack('B', crc8)
crc8 = f_crc8(packet)
packet = packet + struct.pack('B', crc8)
return p
return packet
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):
fragment = compose_esb_fragment(packet[i:i+mtu], **params)
yield(fragment)
yield fragment
def compose_set_time_payload(timestamp=None):
"""
@ -472,6 +487,7 @@ class InverterTransaction:
inverter_addr = None
dtu_ser = None
req_type = None
time_rx = None
radio = None
@ -530,14 +546,14 @@ class InverterTransaction:
if not self.radio:
return False
if not len(self.tx_queue):
if len(self.tx_queue) == 0:
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)}')
c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
print(f'{c_datetime} Transmit {len(packet)} | {hexify_payload(packet)}')
self.radio.transmit(packet)
@ -646,9 +662,9 @@ class InverterTransaction:
:return: log line of payload for transmission
: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)
return f'{dt} Transmit | {hexify_payload(self.request)}'
return f'{c_datetime} Transmit | {hexify_payload(self.request)}'
def hexify_payload(byte_var):
"""

61
tools/rpi/hoymiles/__main__.py

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

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

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

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