migrate paho.mqtt CallbackAPIVersion to VERSION2 (#225)

This commit is contained in:
Stefan Allius
2024-12-02 22:49:56 +01:00
committed by GitHub
parent 9bae905c08
commit 28cf875533
30 changed files with 5129 additions and 1 deletions

View File

@@ -0,0 +1,204 @@
from typing import Generator
from infos import Infos, Register, ProxyMode, Fmt
class RegisterMap:
# make the class read/only by using __slots__
__slots__ = ()
FMT_2_16BIT_VAL = '!HH'
FMT_3_16BIT_VAL = '!HHH'
FMT_4_16BIT_VAL = '!HHHH'
map = {
# 0x41020007: {'reg': Register.DEVICE_SNR, 'fmt': '<L'}, # noqa: E501
0x41020018: {'reg': Register.DATA_UP_INTERVAL, 'fmt': '<B', 'ratio': 60, 'dep': ProxyMode.SERVER}, # noqa: E501
0x41020019: {'reg': Register.COLLECT_INTERVAL, 'fmt': '<B', 'quotient': 60, 'dep': ProxyMode.SERVER}, # noqa: E501
0x4102001a: {'reg': Register.HEARTBEAT_INTERVAL, 'fmt': '<B', 'ratio': 1}, # noqa: E501
0x4102001b: {'reg': None, 'fmt': '<B', 'const': 1}, # noqa: E501 Max No Of Connected Devices
0x4102001c: {'reg': Register.SIGNAL_STRENGTH, 'fmt': '<B', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501
0x4102001d: {'reg': None, 'fmt': '<B', 'const': 1}, # noqa: E501
0x4102001e: {'reg': Register.CHIP_MODEL, 'fmt': '!40s'}, # noqa: E501
0x41020046: {'reg': Register.MAC_ADDR, 'fmt': '!6B', 'func': Fmt.mac}, # noqa: E501
0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # noqa: E501
0x4102005c: {'reg': None, 'fmt': '<B', 'const': 15}, # noqa: E501
0x4102005e: {'reg': None, 'fmt': '<B', 'const': 1}, # noqa: E501 No Of Sensors (ListLen)
0x4102005f: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'func': Fmt.hex4}, # noqa: E501
0x41020061: {'reg': None, 'fmt': '<HB', 'const': (15, 255)}, # noqa: E501
0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501
0x4102008c: {'reg': None, 'fmt': '<BB', 'const': (254, 254)}, # noqa: E501
0x4102008e: {'reg': None, 'fmt': '<B'}, # noqa: E501 Encryption Certificate File Status
0x4102008f: {'reg': None, 'fmt': '!40s'}, # noqa: E501
0x410200b7: {'reg': Register.SSID, 'fmt': '!40s'}, # noqa: E501
0x4201000c: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'func': Fmt.hex4}, # noqa: E501
0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501, or packet number
0x42010020: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501
# Start MODBUS Block: 0x3000 (R/O Measurements)
0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501
0x420100c2: {'reg': Register.DETECT_STATUS_1, 'fmt': '!H'}, # noqa: E501
0x420100c4: {'reg': Register.DETECT_STATUS_2, 'fmt': '!H'}, # noqa: E501
0x420100c6: {'reg': Register.EVENT_ALARM, 'fmt': '!H'}, # noqa: E501
0x420100c8: {'reg': Register.EVENT_FAULT, 'fmt': '!H'}, # noqa: E501
0x420100ca: {'reg': Register.EVENT_BF1, 'fmt': '!H'}, # noqa: E501
0x420100cc: {'reg': Register.EVENT_BF2, 'fmt': '!H'}, # noqa: E501
# 0x420100ce
0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'func': Fmt.version}, # noqa: E501
0x420100d2: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100d4: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100d6: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100d8: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'offset': -40}, # noqa: E501
# 0x420100da
0x420100dc: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
0x420100de: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100e0: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100e2: {'reg': Register.PV1_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100e4: {'reg': Register.PV1_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100e6: {'reg': Register.PV2_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100e8: {'reg': Register.PV2_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100ea: {'reg': Register.PV2_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100ec: {'reg': Register.PV3_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100ee: {'reg': Register.PV3_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100f0: {'reg': Register.PV3_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100f2: {'reg': Register.PV4_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100f4: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100f6: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100f8: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100fa: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x420100fe: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x42010100: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x42010104: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x42010106: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x4201010a: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x4201010c: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x42010110: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x42010112: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x42010116: {'reg': Register.INV_UNKNOWN_1, 'fmt': '!H'}, # noqa: E501
# Start MODBUS Block: 0x2000 (R/W Config Paramaneters)
0x42010118: {'reg': Register.BOOT_STATUS, 'fmt': '!H'},
0x4201011a: {'reg': Register.DSP_STATUS, 'fmt': '!H'},
0x4201011c: {'reg': None, 'fmt': '!H', 'const': 1}, # noqa: E501
0x4201011e: {'reg': Register.WORK_MODE, 'fmt': '!H'},
0x42010124: {'reg': Register.OUTPUT_SHUTDOWN, 'fmt': '!H'},
0x42010126: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H'},
0x42010128: {'reg': Register.RATED_LEVEL, 'fmt': '!H'},
0x4201012a: {'reg': Register.INPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
0x4201012c: {'reg': Register.GRID_VOLT_CAL_COEF, 'fmt': '!H'},
0x4201012e: {'reg': None, 'fmt': '!H', 'const': 1024}, # noqa: E501
0x42010130: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (1024, 1, 0xffff, 1)}, # noqa: E501
0x42010138: {'reg': Register.PROD_COMPL_TYPE, 'fmt': '!H'},
0x4201013a: {'reg': None, 'fmt': FMT_3_16BIT_VAL, 'const': (0x68, 0x68, 0x500)}, # noqa: E501
0x42010140: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x9cd, 0x7b6, 0x139c, 0x1324)}, # noqa: E501
0x42010148: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (1, 0x7ae, 0x40f, 0x41)}, # noqa: E501
0x42010150: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0xf, 0xa64, 0xa64, 0x6)}, # noqa: E501
0x42010158: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x6, 0x9f6, 0x128c, 0x128c)}, # noqa: E501
0x42010160: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x10, 0x10, 0x1452, 0x1452)}, # noqa: E501
0x42010168: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x10, 0x10, 0x151, 0x5)}, # noqa: E501
0x42010170: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
0x42010172: {'reg': None, 'fmt': FMT_3_16BIT_VAL, 'const': (0x1, 0x139c, 0xfa0)}, # noqa: E501
0x42010178: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x4e, 0x66, 0x3e8, 0x400)}, # noqa: E501
0x42010180: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x9ce, 0x7a8, 0x139c, 0x1326)}, # noqa: E501
0x42010188: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x0, 0x0, 0x0, 0)}, # noqa: E501
0x42010190: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x0, 0x0, 1024, 1024)}, # noqa: E501
0x42010198: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0, 0, 0xffff, 0)}, # noqa: E501
0x420101a0: {'reg': None, 'fmt': FMT_2_16BIT_VAL, 'const': (0x0, 0x0)}, # noqa: E501
0xffffff02: {'reg': Register.POLLING_INTERVAL},
# 0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501
}
class InfosG3P(Infos):
__slots__ = ('client_mode', )
def __init__(self, client_mode: bool):
super().__init__()
self.client_mode = client_mode
self.set_db_def_value(Register.MANUFACTURER, 'TSUN')
self.set_db_def_value(Register.EQUIPMENT_MODEL, 'TSOL-MSxx00')
self.set_db_def_value(Register.CHIP_TYPE, 'IGEN TECH')
self.set_db_def_value(Register.NO_INPUTS, 4)
def __hide_topic(self, row: dict) -> bool:
if 'dep' in row:
mode = row['dep']
if self.client_mode:
return mode != ProxyMode.CLIENT
else:
return mode != ProxyMode.SERVER
return False
def ha_confs(self, ha_prfx: str, node_id: str, snr: str,
sug_area: str = '') \
-> Generator[tuple[dict, str], None, None]:
'''Generator function yields a json register struct for home-assistant
auto configuration and a unique entity string
arguments:
prfx:str ==> MQTT prefix for the home assistant 'stat_t string
snr:str ==> serial number of the inverter, used to build unique
entity strings
sug_area:str ==> suggested area string from the config file'''
# iterate over RegisterMap.map and get the register values
for row in RegisterMap.map.values():
info_id = row['reg']
if self.__hide_topic(row):
res = self.ha_remove(info_id, node_id, snr) # noqa: E501
else:
res = self.ha_conf(info_id, ha_prfx, node_id, snr, False, sug_area) # noqa: E501
if res:
yield res
def parse(self, buf, msg_type: int, rcv_ftype: int, node_id: str = '') \
-> Generator[tuple[str, bool], None, None]:
'''parse a data sequence received from the inverter and
stores the values in Infos.db
buf: buffer of the sequence to parse'''
for idx, row in RegisterMap.map.items():
addr = idx & 0xffff
ftype = (idx >> 16) & 0xff
mtype = (idx >> 24) & 0xff
if ftype != rcv_ftype or mtype != msg_type:
continue
if not isinstance(row, dict):
continue
info_id = row['reg']
result = Fmt.get_value(buf, addr, row)
keys, level, unit, must_incr = self._key_obj(info_id)
if keys:
name, update = self.update_db(keys, must_incr, result)
yield keys[0], update
else:
name = str(f'info-id.0x{addr:x}')
update = False
if update:
self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}'
f' : {result}{unit}')
def build(self, len, msg_type: int, rcv_ftype: int):
buf = bytearray(len)
for idx, row in RegisterMap.map.items():
addr = idx & 0xffff
ftype = (idx >> 16) & 0xff
mtype = (idx >> 24) & 0xff
if ftype != rcv_ftype or mtype != msg_type:
continue
if not isinstance(row, dict):
continue
if 'const' in row:
val = row['const']
else:
info_id = row['reg']
val = self.get_db_value(info_id)
if not val:
continue
Fmt.set_value(buf, addr, row, val)
return buf

View File

@@ -0,0 +1,15 @@
from asyncio import StreamReader, StreamWriter
from inverter_base import InverterBase
from gen3plus.solarman_v5 import SolarmanV5
from gen3plus.solarman_emu import SolarmanEmu
class InverterG3P(InverterBase):
def __init__(self, reader: StreamReader, writer: StreamWriter,
client_mode: bool = False):
remote_prot = None
if client_mode:
remote_prot = SolarmanEmu
super().__init__(reader, writer, 'solarman',
SolarmanV5, client_mode, remote_prot)

View File

@@ -0,0 +1,138 @@
import logging
import struct
from async_ifc import AsyncIfc
from gen3plus.solarman_v5 import SolarmanBase
from my_timer import Timer
from infos import Register
logger = logging.getLogger('msg')
class SolarmanEmu(SolarmanBase):
def __init__(self, addr, ifc: "AsyncIfc",
server_side: bool, client_mode: bool):
super().__init__(addr, ifc, server_side=False,
_send_modbus_cb=None,
mb_timeout=8)
logging.debug('SolarmanEmu.init()')
self.db = ifc.remote.stream.db
self.snr = ifc.remote.stream.snr
self.hb_timeout = 60
'''actual heatbeat timeout from the last response message'''
self.data_up_inv = self.db.get_db_value(Register.DATA_UP_INTERVAL)
'''time interval for getting new MQTT data messages'''
self.hb_timer = Timer(self.send_heartbeat_cb, self.node_id)
self.data_timer = Timer(self.send_data_cb, self.node_id)
self.last_sync = self._emu_timestamp()
'''timestamp when we send the last sync message (4110)'''
self.pkt_cnt = 0
'''last sent packet number'''
self.switch = {
0x4210: 'msg_data_ind', # real time data
0x1210: self.msg_response, # at least every 5 minutes
0x4710: 'msg_hbeat_ind', # heatbeat
0x1710: self.msg_response, # every 2 minutes
0x4110: 'msg_dev_ind', # device data, sync start
0x1110: self.msg_response, # every 3 hours
}
self.log_lvl = {
0x4110: logging.INFO, # device data, sync start
0x1110: logging.INFO, # every 3 hours
0x4210: logging.INFO, # real time data
0x1210: logging.INFO, # at least every 5 minutes
0x4710: logging.DEBUG, # heatbeat
0x1710: logging.DEBUG, # every 2 minutes
}
'''
Our puplic methods
'''
def close(self) -> None:
logging.info('SolarmanEmu.close()')
# we have references to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak
self.switch.clear()
self.log_lvl.clear()
self.hb_timer.close()
self.data_timer.close()
self.db = None
super().close()
def _set_serial_no(self, snr: int):
logging.debug(f'SolarmanEmu._set_serial_no, snr: {snr}')
self.unique_id = str(snr)
def _init_new_client_conn(self) -> bool:
logging.debug('SolarmanEmu.init_new()')
self.data_timer.start(self.data_up_inv)
return False
def next_pkt_cnt(self):
'''get the next packet number'''
self.pkt_cnt = (self.pkt_cnt + 1) & 0xffffffff
return self.pkt_cnt
def seconds_since_last_sync(self):
'''get seconds since last 0x4110 message was sent'''
return self._emu_timestamp() - self.last_sync
def send_heartbeat_cb(self, exp_cnt):
'''send a heartbeat to the TSUN cloud'''
self._build_header(0x4710)
self.ifc.tx_add(struct.pack('<B', 0))
self._finish_send_msg()
log_lvl = self.log_lvl.get(0x4710, logging.WARNING)
self.ifc.tx_log(log_lvl, 'Send heartbeat:')
self.ifc.tx_flush()
def send_data_cb(self, exp_cnt):
'''send a inverter data message to the TSUN cloud'''
self.hb_timer.start(self.hb_timeout)
self.data_timer.start(self.data_up_inv)
_len = 420
ftype = 1
build_msg = self.db.build(_len, 0x42, ftype)
self._build_header(0x4210)
self.ifc.tx_add(
struct.pack(
'<BHLLLHL', ftype, 0x02b0,
self._emu_timestamp(),
self.seconds_since_last_sync(),
self.time_ofs,
1, # offset 0x1a
self.next_pkt_cnt()))
self.ifc.tx_add(build_msg[0x20:])
self._finish_send_msg()
log_lvl = self.log_lvl.get(0x4210, logging.WARNING)
self.ifc.tx_log(log_lvl, 'Send inv-data:')
self.ifc.tx_flush()
'''
Message handler methods
'''
def msg_response(self):
'''handle a received response from the TSUN cloud'''
logger.debug("EMU received rsp:")
_, _, ts, hb = super().msg_response()
logger.debug(f"EMU ts:{ts} hb:{hb}")
self.hb_timeout = hb
self.time_ofs = ts - self._emu_timestamp()
self.hb_timer.start(self.hb_timeout)
def msg_unknown(self):
'''counts a unknown or unexpected message from the TSUN cloud'''
logger.warning(f"EMU Unknow Msg: ID:{int(self.control):#04x}")
self.inc_counter('Unknown_Msg')

View File

@@ -0,0 +1,706 @@
import struct
import logging
import time
import asyncio
from datetime import datetime
from async_ifc import AsyncIfc
from messages import hex_dump_memory, Message, State
from config import Config
from modbus import Modbus
from gen3plus.infos_g3p import InfosG3P
from infos import Register, Fmt
logger = logging.getLogger('msg')
class Sequence():
def __init__(self, server_side: bool):
self.rcv_idx = 0
self.snd_idx = 0
self.server_side = server_side
def set_recv(self, val: int):
if self.server_side:
self.rcv_idx = val >> 8
self.snd_idx = val & 0xff
else:
self.rcv_idx = val & 0xff
self.snd_idx = val >> 8
def get_send(self):
self.snd_idx += 1
self.snd_idx &= 0xff
if self.server_side:
return (self.rcv_idx << 8) | self.snd_idx
else:
return (self.snd_idx << 8) | self.rcv_idx
def __str__(self):
return f'{self.rcv_idx:02x}:{self.snd_idx:02x}'
class SolarmanBase(Message):
def __init__(self, addr, ifc: "AsyncIfc", server_side: bool,
_send_modbus_cb, mb_timeout: int):
super().__init__('G3P', ifc, server_side, _send_modbus_cb,
mb_timeout)
ifc.rx_set_cb(self.read)
ifc.prot_set_timeout_cb(self._timeout)
ifc.prot_set_init_new_client_conn_cb(self._init_new_client_conn)
ifc.prot_set_update_header_cb(self.__update_header)
self.addr = addr
self.conn_no = ifc.get_conn_no()
self.header_len = 11 # overwrite construcor in class Message
self.control = 0
self.seq = Sequence(server_side)
self.snr = 0
self.time_ofs = 0
def read(self) -> float:
'''process all received messages in the _recv_buffer'''
self._read()
while True:
if not self.header_valid:
self.__parse_header(self.ifc.rx_peek(),
self.ifc.rx_len())
if self.header_valid and self.ifc.rx_len() >= \
(self.header_len + self.data_len+2):
self.__process_complete_received_msg()
self.__flush_recv_msg()
else:
return 0 # wait 0s before sending a response
'''
Our public methods
'''
def _flow_str(self, server_side: bool, type: str): # noqa: F821
switch = {
'rx': ' <',
'tx': ' >',
'forwrd': '<< ',
'drop': ' xx',
'rxS': '> ',
'txS': '< ',
'forwrdS': ' >>',
'dropS': 'xx ',
}
if server_side:
type += 'S'
return switch.get(type, '???')
def get_fnc_handler(self, ctrl):
fnc = self.switch.get(ctrl, self.msg_unknown)
if callable(fnc):
return fnc, repr(fnc.__name__)
else:
return self.msg_unknown, repr(fnc)
def _build_header(self, ctrl) -> None:
'''build header for new transmit message'''
self.send_msg_ofs = self.ifc.tx_len()
self.ifc.tx_add(struct.pack(
'<BHHHL', 0xA5, 0, ctrl, self.seq.get_send(), self.snr))
_fnc, _str = self.get_fnc_handler(ctrl)
logger.info(self._flow_str(self.server_side, 'tx') +
f' Ctl: {int(ctrl):#04x} Msg: {_str}')
def _finish_send_msg(self) -> None:
'''finish the transmit message, set lenght and checksum'''
_len = self.ifc.tx_len() - self.send_msg_ofs
struct.pack_into('<H', self.ifc.tx_peek(), self.send_msg_ofs+1,
_len-11)
check = sum(self.ifc.tx_peek()[
self.send_msg_ofs+1:self.send_msg_ofs + _len]) & 0xff
self.ifc.tx_add(struct.pack('<BB', check, 0x15)) # crc & stop
def _timestamp(self):
# utc as epoche
return int(time.time()) # pragma: no cover
def _emu_timestamp(self):
'''timestamp for an emulated inverter (realtime - 1 day)'''
one_day = 24*60*60
return self._timestamp()-one_day
'''
Our private methods
'''
def __update_header(self, _forward_buffer):
'''update header for message before forwarding,
set sequence and checksum'''
_len = len(_forward_buffer)
ofs = 0
while ofs < _len:
result = struct.unpack_from('<BH', _forward_buffer, ofs)
data_len = result[1] # len of variable id string
struct.pack_into('<H', _forward_buffer, ofs+5,
self.seq.get_send())
check = sum(_forward_buffer[ofs+1:ofs+data_len+11]) & 0xff
struct.pack_into('<B', _forward_buffer, ofs+data_len+11, check)
ofs += (13 + data_len)
def __process_complete_received_msg(self):
log_lvl = self.log_lvl.get(self.control, logging.WARNING)
if callable(log_lvl):
log_lvl = log_lvl()
self.ifc.rx_log(log_lvl, f'Received from {self.addr}:')
# self._recv_buffer, self.header_len +
# self.data_len+2)
if self.__trailer_is_ok(self.ifc.rx_peek(), self.header_len
+ self.data_len + 2):
if self.state == State.init:
self.state = State.received
self._set_serial_no(self.snr)
self.__dispatch_msg()
def __parse_header(self, buf: bytes, buf_len: int) -> None:
if (buf_len < self.header_len): # enough bytes for complete header?
return
result = struct.unpack_from('<BHHHL', buf, 0)
# store parsed header values in the class
start = result[0] # start byte
self.data_len = result[1] # len of variable id string
self.control = result[2]
self.seq.set_recv(result[3])
self.snr = result[4]
if start != 0xA5:
hex_dump_memory(logging.ERROR,
'Drop packet w invalid start byte from'
f' {self.addr}:', buf, buf_len)
self.inc_counter('Invalid_Msg_Format')
# erase broken recv buffer
self.ifc.rx_clear()
return
self.header_valid = True
def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool:
crc = buf[self.data_len+11]
stop = buf[self.data_len+12]
if stop != 0x15:
hex_dump_memory(logging.ERROR,
'Drop packet w invalid stop byte from '
f'{self.addr}:', buf, buf_len)
self.inc_counter('Invalid_Msg_Format')
if self.ifc.rx_len() > (self.data_len+13):
next_start = buf[self.data_len+13]
if next_start != 0xa5:
# erase broken recv buffer
self.ifc.rx_clear()
return False
check = sum(buf[1:buf_len-2]) & 0xff
if check != crc:
self.inc_counter('Invalid_Msg_Format')
logger.debug(f'CRC {int(crc):#02x} {int(check):#08x}'
f' Stop:{int(stop):#02x}')
# start & stop byte are valid, discard only this message
return False
return True
def __flush_recv_msg(self) -> None:
self.ifc.rx_get(self.header_len + self.data_len+2)
self.header_valid = False
def __dispatch_msg(self) -> None:
_fnc, _str = self.get_fnc_handler(self.control)
if self.unique_id:
logger.info(self._flow_str(self.server_side, 'rx') +
f' Ctl: {int(self.control):#04x}' +
f' Msg: {_str}')
_fnc()
else:
logger.info(self._flow_str(self.server_side, 'drop') +
f' Ctl: {int(self.control):#04x}' +
f' Msg: {_str}')
'''
Message handler methods
'''
def msg_response(self):
data = self.ifc.rx_peek()[self.header_len:]
result = struct.unpack_from('<BBLL', data, 0)
ftype = result[0] # always 2
valid = result[1] == 1 # status
ts = result[2]
set_hb = result[3] # always 60 or 120
logger.debug(f'ftype:{ftype} accepted:{valid}'
f' ts:{ts:08x} nextHeartbeat: {set_hb}s')
dt = datetime.fromtimestamp(ts)
logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
return ftype, valid, ts, set_hb
class SolarmanV5(SolarmanBase):
AT_CMD = 1
MB_RTU_CMD = 2
MB_CLIENT_DATA_UP = 30
'''Data up time in client mode'''
HDR_FMT = '<BLLL'
'''format string for packing of the header'''
def __init__(self, addr, ifc: "AsyncIfc",
server_side: bool, client_mode: bool):
super().__init__(addr, ifc, server_side, self.send_modbus_cb,
mb_timeout=8)
self.db = InfosG3P(client_mode)
self.forward_at_cmd_resp = False
self.no_forwarding = False
'''not allowed to connect to TSUN cloud by connection type'''
self.establish_inv_emu = False
'''create an Solarman EMU instance to send data to the TSUN cloud'''
self.switch = {
0x4210: self.msg_data_ind, # real time data
0x1210: self.msg_response, # at least every 5 minutes
0x4710: self.msg_hbeat_ind, # heatbeat
0x1710: self.msg_response, # every 2 minutes
# every 3 hours comes a sync seuqence:
# 00:00:00 0x4110 device data ftype: 0x02
# 00:00:02 0x4210 real time data ftype: 0x01
# 00:00:03 0x4210 real time data ftype: 0x81
# 00:00:05 0x4310 wifi data ftype: 0x81 sub-id 0x0018: 0c # noqa: E501
# 00:00:06 0x4310 wifi data ftype: 0x81 sub-id 0x0018: 1c # noqa: E501
# 00:00:07 0x4310 wifi data ftype: 0x01 sub-id 0x0018: 0c # noqa: E501
# 00:00:08 0x4810 options? ftype: 0x01
0x4110: self.msg_dev_ind, # device data, sync start
0x1110: self.msg_response, # every 3 hours
0x4310: self.msg_sync_start, # regulary after 3-6 hours
0x1310: self.msg_response,
0x4810: self.msg_sync_end, # sync end
0x1810: self.msg_response,
#
# MODbus or AT cmd
0x4510: self.msg_command_req, # from server
0x1510: self.msg_command_rsp, # from inverter
# 0x0510: self.msg_command_rsp, # from inverter
}
self.log_lvl = {
0x4210: logging.INFO, # real time data
0x1210: logging.INFO, # at least every 5 minutes
0x4710: logging.DEBUG, # heatbeat
0x1710: logging.DEBUG, # every 2 minutes
0x4110: logging.INFO, # device data, sync start
0x1110: logging.INFO, # every 3 hours
0x4310: logging.INFO, # regulary after 3-6 hours
0x1310: logging.INFO,
0x4810: logging.INFO, # sync end
0x1810: logging.INFO,
#
# MODbus or AT cmd
0x4510: logging.INFO, # from server
0x1510: self.get_cmd_rsp_log_lvl,
}
g3p_cnf = Config.get('gen3plus')
if 'at_acl' in g3p_cnf: # pragma: no cover
self.at_acl = g3p_cnf['at_acl']
self.sensor_list = 0
'''
Our puplic methods
'''
def close(self) -> None:
logging.debug('Solarman.close()')
# we have references to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak
self.switch.clear()
self.log_lvl.clear()
super().close()
async def send_start_cmd(self, snr: int, host: str,
forward: bool,
start_timeout=MB_CLIENT_DATA_UP):
self.no_forwarding = True
self.establish_inv_emu = forward
self.snr = snr
self._set_serial_no(snr)
self.mb_timeout = start_timeout
self.db.set_db_def_value(Register.IP_ADDRESS, host)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
self.db.set_db_def_value(Register.DATA_UP_INTERVAL,
300)
self.db.set_db_def_value(Register.COLLECT_INTERVAL,
1)
self.db.set_db_def_value(Register.HEARTBEAT_INTERVAL,
120)
self.db.set_db_def_value(Register.SENSOR_LIST,
Fmt.hex4((self.sensor_list, )))
self.new_data['controller'] = True
self.state = State.up
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
self.mb_timer.start(self.mb_timeout)
def new_state_up(self):
if self.state is not State.up:
self.state = State.up
if (self.modbus_polling):
self.mb_timer.start(self.mb_first_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
def establish_emu(self):
_len = 223
build_msg = self.db.build(_len, 0x41, 2)
struct.pack_into(
'<BHHHLBL', build_msg, 0, 0xA5, _len-11, 0x4110,
0, self.snr, 2, self._emu_timestamp())
self.ifc.fwd_add(build_msg)
self.ifc.fwd_add(struct.pack('<BB', 0, 0x15)) # crc & stop
def __set_config_parms(self, inv: dict):
'''init connection with params from the configuration'''
self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
self.sensor_list = inv['sensor_list']
if self.mb:
self.mb.set_node_id(self.node_id)
def _set_serial_no(self, snr: int):
'''check the serial number and configure the inverter connection'''
serial_no = str(snr)
if self.unique_id == serial_no:
logger.debug(f'SerialNo: {serial_no}')
else:
inverters = Config.get('inverters')
# logger.debug(f'Inverters: {inverters}')
for key, inv in inverters.items():
# logger.debug(f'key: {key} -> {inv}')
if (type(inv) is dict and 'monitor_sn' in inv
and inv['monitor_sn'] == snr):
self.__set_config_parms(inv)
self.db.set_pv_module_details(inv)
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
self.db.set_db_def_value(Register.COLLECTOR_SNR, snr)
self.db.set_db_def_value(Register.SERIAL_NUMBER, key)
break
else:
self.node_id = ''
self.sug_area = ''
if 'allow_all' not in inverters or not inverters['allow_all']:
self.inc_counter('Unknown_SNR')
self.unique_id = None
logger.warning(f'ignore message from unknow inverter! (SerialNo: {serial_no})') # noqa: E501
return
logger.warning(f'SerialNo {serial_no} not known but accepted!')
self.unique_id = serial_no
def forward(self, buffer, buflen) -> None:
'''add the actual receive msg to the forwarding queue'''
if self.no_forwarding:
return
tsun = Config.get('solarman')
if tsun['enabled']:
self.ifc.fwd_add(buffer[:buflen])
self.ifc.fwd_log(logging.DEBUG, 'Store for forwarding:')
_, _str = self.get_fnc_handler(self.control)
logger.info(self._flow_str(self.server_side, 'forwrd') +
f' Ctl: {int(self.control):#04x}'
f' Msg: {_str}')
def _init_new_client_conn(self) -> bool:
return False
def _heartbeat(self) -> int:
return 60 # pragma: no cover
def __send_ack_rsp(self, msgtype, ftype, ack=1):
self._build_header(msgtype)
self.ifc.tx_add(struct.pack('<BBLL', ftype, ack,
self._timestamp(),
self._heartbeat()))
self._finish_send_msg()
def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str):
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore MODBUS cmd,'
' cause the state is not UP anymore')
return
self._build_header(0x4510)
self.ifc.tx_add(struct.pack('<BHLLL', self.MB_RTU_CMD,
self.sensor_list, 0, 0, 0))
self.ifc.tx_add(pdu)
self._finish_send_msg()
self.ifc.tx_log(log_lvl, f'Send Modbus {state}:{self.addr}:')
self.ifc.tx_flush()
def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.mb_timeout)
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
if 1 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
def at_cmd_forbidden(self, cmd: str, connection: str) -> bool:
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
cmd.startswith(tuple(self.at_acl[connection]['block']))
async def send_at_cmd(self, at_cmd: str) -> None:
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore AT+ cmd,'
' as the state is not UP')
return
at_cmd = at_cmd.strip()
if self.at_cmd_forbidden(cmd=at_cmd, connection='mqtt'):
data_json = f'\'{at_cmd}\' is forbidden'
node_id = self.node_id
key = 'at_resp'
logger.info(f'{key}: {data_json}')
await self.mqtt.publish(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
return
self.forward_at_cmd_resp = False
self._build_header(0x4510)
self.ifc.tx_add(struct.pack(f'<BHLLL{len(at_cmd)}sc', self.AT_CMD,
0x0002, 0, 0, 0,
at_cmd.encode('utf-8'), b'\r'))
self._finish_send_msg()
self.ifc.tx_log(logging.INFO, 'Send AT Command:')
try:
self.ifc.tx_flush()
except Exception:
self.ifc.tx_clear()
def __forward_msg(self):
self.forward(self.ifc.rx_peek(), self.header_len+self.data_len+2)
def __build_model_name(self):
db = self.db
max_pow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
rated = db.get_db_value(Register.RATED_POWER, 0)
model = None
if max_pow == 2000:
if rated == 800 or rated == 600:
model = f'TSOL-MS{max_pow}({rated})'
else:
model = f'TSOL-MS{max_pow}'
elif max_pow == 1800 or max_pow == 1600:
model = f'TSOL-MS{max_pow}'
if model:
logger.info(f'Model: {model}')
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, model)
def __process_data(self, ftype, ts):
inv_update = False
msg_type = self.control >> 8
for key, update in self.db.parse(self.ifc.rx_peek(), msg_type, ftype,
self.node_id):
if update:
if key == 'inverter':
inv_update = True
self._set_mqtt_timestamp(key, ts)
self.new_data[key] = True
if inv_update:
self.__build_model_name()
'''
Message handler methods
'''
def msg_unknown(self):
logger.warning(f"Unknow Msg: ID:{int(self.control):#04x}")
self.inc_counter('Unknown_Msg')
self.__forward_msg()
def msg_dev_ind(self):
data = self.ifc.rx_peek()[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0)
ftype = result[0] # always 2
total = result[1]
tim = result[2]
res = result[3] # always zero
logger.info(f'frame type:{ftype:02x}'
f' timer:{tim:08x}s null:{res}')
if self.time_ofs:
# dt = datetime.fromtimestamp(total + self.time_ofs)
# logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
ts = total + self.time_ofs
else:
ts = None
self.__process_data(ftype, ts)
self.sensor_list = int(self.db.get_db_value(Register.SENSOR_LIST, 0),
16)
self.__forward_msg()
self.__send_ack_rsp(0x1110, ftype)
def msg_data_ind(self):
data = self.ifc.rx_peek()
result = struct.unpack_from('<BHLLLHL', data, self.header_len)
ftype = result[0] # 1 or 0x81
sensor = result[1]
total = result[2]
tim = result[3]
if 1 == ftype:
self.time_ofs = result[4]
unkn = result[5]
cnt = result[6]
if sensor != self.sensor_list:
logger.warning(f'Unexpected Sensor-List:{sensor:04x}'
f' (!={self.sensor_list:04x})')
logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s'
f' ??: {unkn:04x} cnt:{cnt}')
if self.time_ofs:
# dt = datetime.fromtimestamp(total + self.time_ofs)
# logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
ts = total + self.time_ofs
else:
ts = None
self.__process_data(ftype, ts)
self.__forward_msg()
self.__send_ack_rsp(0x1210, ftype)
self.new_state_up()
def msg_sync_start(self):
data = self.ifc.rx_peek()[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0)
ftype = result[0]
total = result[1]
self.time_ofs = result[3]
dt = datetime.fromtimestamp(total + self.time_ofs)
logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
self.__forward_msg()
self.__send_ack_rsp(0x1310, ftype)
def msg_command_req(self):
data = self.ifc.rx_peek()[self.header_len:
self.header_len+self.data_len]
result = struct.unpack_from('<B', data, 0)
ftype = result[0]
if ftype == self.AT_CMD:
at_cmd = data[15:].decode()
if self.at_cmd_forbidden(cmd=at_cmd, connection='tsun'):
self.inc_counter('AT_Command_Blocked')
return
self.inc_counter('AT_Command')
self.forward_at_cmd_resp = True
elif ftype == self.MB_RTU_CMD:
rstream = self.ifc.remote.stream
if rstream.mb.recv_req(data[15:],
rstream.__forward_msg):
self.inc_counter('Modbus_Command')
else:
logger.error('Invalid Modbus Msg')
self.inc_counter('Invalid_Msg_Format')
return
self.__forward_msg()
def publish_mqtt(self, key, data): # pragma: no cover
asyncio.ensure_future(
self.mqtt.publish(key, data))
def get_cmd_rsp_log_lvl(self) -> int:
ftype = self.ifc.rx_peek()[self.header_len]
if ftype == self.AT_CMD:
if self.forward_at_cmd_resp:
return logging.INFO
return logging.DEBUG
elif ftype == self.MB_RTU_CMD \
and self.server_side:
return self.mb.last_log_lvl
return logging.WARNING
def msg_command_rsp(self):
data = self.ifc.rx_peek()[self.header_len:
self.header_len+self.data_len]
ftype = data[0]
if ftype == self.AT_CMD:
if not self.forward_at_cmd_resp:
data_json = data[14:].decode("utf-8")
node_id = self.node_id
key = 'at_resp'
logger.info(f'{key}: {data_json}')
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
return
elif ftype == self.MB_RTU_CMD:
self.__modbus_command_rsp(data)
return
self.__forward_msg()
def __parse_modbus_rsp(self, data):
inv_update = False
self.modbus_elms = 0
for key, update, _ in self.mb.recv_resp(self.db, data[14:]):
self.modbus_elms += 1
if update:
if key == 'inverter':
inv_update = True
self._set_mqtt_timestamp(key, self._timestamp())
self.new_data[key] = True
return inv_update
def __modbus_command_rsp(self, data):
'''precess MODBUS RTU response'''
valid = data[1]
modbus_msg_len = self.data_len - 14
# logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
if valid == 1 and modbus_msg_len > 4:
# logger.info(f'first byte modbus:{data[14]}')
inv_update = self.__parse_modbus_rsp(data)
if inv_update:
self.__build_model_name()
if self.establish_inv_emu and not self.ifc.remote.stream:
self.establish_emu()
def msg_hbeat_ind(self):
data = self.ifc.rx_peek()[self.header_len:]
result = struct.unpack_from('<B', data, 0)
ftype = result[0]
self.__forward_msg()
self.__send_ack_rsp(0x1710, ftype)
self.new_state_up()
def msg_sync_end(self):
data = self.ifc.rx_peek()[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0)
ftype = result[0]
total = result[1]
self.time_ofs = result[3]
dt = datetime.fromtimestamp(total + self.time_ofs)
logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
self.__forward_msg()
self.__send_ack_rsp(0x1810, ftype)