You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

666 lines
19 KiB

#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
Hoymiles Micro-Inverters decoder library
"""
import struct
from datetime import datetime, timedelta
import crcmod
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
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','powerfactor']
temperature = None
frequency = None
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 phases(self):
"""
AC power data
:retrun: list of dict's
:rtype: list
"""
phases = []
p_exists = True
while p_exists:
p_exists = False
phase_id = len(phases)
phase = {}
for key in self.e_keys:
prop = f'ac_{key}_{phase_id}'
if hasattr(self, prop):
p_exists = True
phase[key] = getattr(self, prop)
if p_exists:
phases.append(phase)
return phases
@property
def strings(self):
"""
DC PV-string data
:retrun: list of dict's
:rtype: list
"""
strings = []
s_exists = True
while s_exists:
s_exists = False
string_id = len(strings)
string = {}
for key in self.e_keys:
prop = f'dc_{key}_{string_id}'
if hasattr(self, prop):
s_exists = True
string[key] = getattr(self, prop)
if s_exists:
strings.append(string)
return strings
def __dict__(self):
"""
Get all known data
:return: dict of properties
:rtype: dict
"""
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(Response):
"""
Debugging helper for unknown payload format
"""
@property
def hex_ascii(self):
"""
Generate white-space separated byte representation
:return: hexlifierd byte string
:rtype: str
"""
return ' '.join([f'{byte:02x}' for byte in self.response])
@property
def valid_crc(self):
"""
Checks if self.response has valid Modbus CRC
:return: if crc is available and correct
:rtype: bool
"""
# check crc
pcrc = struct.unpack('>H', self.response[-2:])[0]
return f_crc_m(self.response[:-2]) == pcrc
@property
def dump_longs(self):
"""Get all data, interpreted as long"""
if len(self.response) < 3:
return None
res = self.response
rem = len(res) % 16
res = res[:rem*-1]
vals = None
if len(res) % 16 == 0:
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) < 5:
return None
res = self.response[2:]
rem = len(res) % 16
res = res[:rem*-1]
vals = None
if len(res) % 16 == 0:
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) < 7:
return None
res = self.response[4:]
rem = len(res) % 16
res = res[:rem*-1]
vals = None
if len(res) % 16 == 0:
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) < 9:
return None
res = self.response[6:]
rem = len(res) % 16
res = res[:rem*-1]
vals = None
if len(res) % 16 == 0:
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) < 3:
return None
res = self.response
rem = len(res) % 4
res = res[:rem*-1]
vals = None
if len(res) % 4 == 0:
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) < 4:
return None
res = self.response[1:]
rem = len(res) % 4
res = res[:rem*-1]
vals = None
if len(res) % 4 == 0:
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',
121: 'Over temperature protection',
125: 'Grid configuration parameter error',
126: 'Software error code 126',
127: 'Firmware error',
128: 'Software error code 128',
129: 'Software error code 129',
130: 'Offline',
141: 'Grid overvoltage',
142: 'Average grid overvoltage',
143: 'Grid undervoltage',
144: 'Grid overfrequency',
145: 'Grid underfrequency',
146: 'Rapid grid frequency change',
147: 'Power grid outage',
148: 'Grid disconnection',
149: 'Island detected',
205: 'Input port 1 & 2 overvoltage',
206: 'Input port 3 & 4 overvoltage',
207: 'Input port 1 & 2 undervoltage',
208: 'Input port 3 & 4 undervoltage',
209: 'Port 1 no input',
210: 'Port 2 no input',
211: 'Port 3 no input',
212: 'Port 4 no input',
213: 'PV-1 & PV-2 abnormal wiring',
214: 'PV-3 & PV-4 abnormal wiring',
215: 'PV-1 Input overvoltage',
216: 'PV-1 Input undervoltage',
217: 'PV-2 Input overvoltage',
218: 'PV-2 Input undervoltage',
219: 'PV-3 Input overvoltage',
220: 'PV-3 Input undervoltage',
221: 'PV-4 Input overvoltage',
222: 'PV-4 Input undervoltage',
301: 'Hardware error code 301',
302: 'Hardware error code 302',
303: 'Hardware error code 303',
304: 'Hardware error code 304',
305: 'Hardware error code 305',
306: 'Hardware error code 306',
307: 'Hardware error code 307',
308: 'Hardware error code 308',
309: 'Hardware error code 309',
310: 'Hardware error code 310',
311: 'Hardware error code 311',
312: 'Hardware error code 312',
313: 'Hardware error code 313',
314: 'Hardware error code 314',
5041: 'Error code-04 Port 1',
5042: 'Error code-04 Port 2',
5043: 'Error code-04 Port 3',
5044: 'Error code-04 Port 4',
5051: 'PV Input 1 Overvoltage/Undervoltage',
5052: 'PV Input 2 Overvoltage/Undervoltage',
5053: 'PV Input 3 Overvoltage/Undervoltage',
5054: 'PV Input 4 Overvoltage/Undervoltage',
5060: 'Abnormal bias',
5070: 'Over temperature protection',
5080: 'Grid Overvoltage/Undervoltage',
5090: 'Grid Overfrequency/Underfrequency',
5100: 'Island detected',
5120: 'EEPROM reading and writing error',
5150: '10 min value grid overvoltage',
5200: 'Firmware error',
8310: 'Shut down',
9000: 'Microinverter is suspected of being stolen'
}
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 = self.response[:-2]
status = self.response[:2]
chunk_size = 12
for i_chunk in range(2, len(self.response), chunk_size):
chunk = self.response[i_chunk:i_chunk+chunk_size]
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')
print(f' uptime={timedelta(seconds=uptime_sec)} a_count={a_count} opcode={opcode} a_code={a_code} a_text={a_text}')
for fmt in ['BBHHHHH']:
print(f' {fmt:7}: ' + str(struct.unpack('>' + fmt, chunk)))
print(end='', flush=True)
class DebugDecodeAny(UnknownResponse):
"""Default decoder"""
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 = self.response[:-2]
l_payload = len(self.response)
print(f' payload has {l_payload} bytes')
longs = self.dump_longs
if not longs:
print(' type long : unable to decode (len or not mod 4)')
else:
print(' type long : ' + str(longs))
longs = self.dump_longs_pad1
if not longs:
print(' type long pad1 : unable to decode (len or not mod 4)')
else:
print(' type long pad1 : ' + str(longs))
longs = self.dump_longs_pad2
if not longs:
print(' type long pad2 : unable to decode (len or not mod 4)')
else:
print(' type long pad2 : ' + str(longs))
longs = self.dump_longs_pad3
if not longs:
print(' type long pad3 : unable to decode (len or not mod 4)')
else:
print(' type long pad3 : ' + str(longs))
shorts = self.dump_shorts
if not shorts:
print(' type short : unable to decode (len or not mod 2)')
else:
print(' type short : ' + str(shorts))
shorts = self.dump_shorts_pad1
if not shorts:
print(' type short pad1: unable to decode (len or not mod 2)')
else:
print(' type short pad1: ' + str(shorts))
try:
if len(self.response) > 2:
print(' type utf-8 : ' + self.response.decode('utf-8'))
except UnicodeDecodeError:
print(' type utf-8 : utf-8 decode error')
try:
if len(self.response) > 2:
print(' type ascii : ' + self.response.decode('ascii'))
except UnicodeDecodeError:
print(' type ascii : ascii decode error')
# 1121-Series Intervers, 1 MPPT, 1 Phase
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 Hm300Decode11(EventsResponse):
""" Inverter generic events log """
class Hm300Decode12(EventsResponse):
""" Inverter major events log """
# 1141-Series Inverters, 2 MPPT, 1 Phase
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 Hm600Decode11(EventsResponse):
""" Inverter generic events log """
class Hm600Decode12(EventsResponse):
""" Inverter major events log """
# 1161-Series Inverters, 4 MPPT, 1 Phase
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 Hm1200Decode11(EventsResponse):
""" Inverter generic events log """
class Hm1200Decode12(EventsResponse):
""" Inverter major events log """