diff --git a/CHANGELOG.md b/CHANGELOG.md index 09063c7..5bb20c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +- migrate paho.mqtt CallbackAPIVersion to VERSION2 [#224](https://github.com/s-allius/tsun-gen3-proxy/issues/224) - add PROD_COMPL_TYPE to trace - add SolarmanV5 messages builder - report inverter alarms and faults per MQTT [#7](https://github.com/s-allius/tsun-gen3-proxy/issues/7) diff --git a/app/tests/test_mqtt.py b/app/tests/test_mqtt.py index 80bf436..b85d746 100644 --- a/app/tests/test_mqtt.py +++ b/app/tests/test_mqtt.py @@ -75,7 +75,7 @@ def test_native_client(test_hostname, test_port): import paho.mqtt.client as mqtt import threading - c = mqtt.Client() + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) c.loop_start() try: # Just make sure the client connects successfully diff --git a/ha_addons/ha_addon/rootfs/home/proxy/async_ifc.py b/ha_addons/ha_addon/rootfs/home/proxy/async_ifc.py new file mode 100644 index 0000000..80af383 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/async_ifc.py @@ -0,0 +1,104 @@ +from abc import ABC, abstractmethod + + +class AsyncIfc(ABC): + @abstractmethod + def get_conn_no(self): + pass # pragma: no cover + + @abstractmethod + def set_node_id(self, value: str): + pass # pragma: no cover + + # + # TX - QUEUE + # + @abstractmethod + def tx_add(self, data: bytearray): + ''' add data to transmit queue''' + pass # pragma: no cover + + @abstractmethod + def tx_flush(self): + ''' send transmit queue and clears it''' + pass # pragma: no cover + + @abstractmethod + def tx_peek(self, size: int = None) -> bytearray: + '''returns size numbers of byte without removing them''' + pass # pragma: no cover + + @abstractmethod + def tx_log(self, level, info): + ''' log the transmit queue''' + pass # pragma: no cover + + @abstractmethod + def tx_clear(self): + ''' clear transmit queue''' + pass # pragma: no cover + + @abstractmethod + def tx_len(self): + ''' get numner of bytes in the transmit queue''' + pass # pragma: no cover + + # + # FORWARD - QUEUE + # + @abstractmethod + def fwd_add(self, data: bytearray): + ''' add data to forward queue''' + pass # pragma: no cover + + @abstractmethod + def fwd_log(self, level, info): + ''' log the forward queue''' + pass # pragma: no cover + + # + # RX - QUEUE + # + @abstractmethod + def rx_get(self, size: int = None) -> bytearray: + '''removes size numbers of bytes and return them''' + pass # pragma: no cover + + @abstractmethod + def rx_peek(self, size: int = None) -> bytearray: + '''returns size numbers of byte without removing them''' + pass # pragma: no cover + + @abstractmethod + def rx_log(self, level, info): + ''' logs the receive queue''' + pass # pragma: no cover + + @abstractmethod + def rx_clear(self): + ''' clear receive queue''' + pass # pragma: no cover + + @abstractmethod + def rx_len(self): + ''' get numner of bytes in the receive queue''' + pass # pragma: no cover + + @abstractmethod + def rx_set_cb(self, callback): + pass # pragma: no cover + + # + # Protocol Callbacks + # + @abstractmethod + def prot_set_timeout_cb(self, callback): + pass # pragma: no cover + + @abstractmethod + def prot_set_init_new_client_conn_cb(self, callback): + pass # pragma: no cover + + @abstractmethod + def prot_set_update_header_cb(self, callback): + pass # pragma: no cover diff --git a/ha_addons/ha_addon/rootfs/home/proxy/async_stream.py b/ha_addons/ha_addon/rootfs/home/proxy/async_stream.py new file mode 100644 index 0000000..ec060b2 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/async_stream.py @@ -0,0 +1,397 @@ +import asyncio +import logging +import traceback +import time +from asyncio import StreamReader, StreamWriter +from typing import Self +from itertools import count + +from proxy import Proxy +from byte_fifo import ByteFifo +from async_ifc import AsyncIfc +from infos import Infos + + +import gc +logger = logging.getLogger('conn') + + +class AsyncIfcImpl(AsyncIfc): + _ids = count(0) + + def __init__(self) -> None: + logger.debug('AsyncIfcImpl.__init__') + self.fwd_fifo = ByteFifo() + self.tx_fifo = ByteFifo() + self.rx_fifo = ByteFifo() + self.conn_no = next(self._ids) + self.node_id = '' + self.timeout_cb = None + self.init_new_client_conn_cb = None + self.update_header_cb = None + + def close(self): + self.timeout_cb = None + self.fwd_fifo.reg_trigger(None) + self.tx_fifo.reg_trigger(None) + self.rx_fifo.reg_trigger(None) + + def set_node_id(self, value: str): + self.node_id = value + + def get_conn_no(self): + return self.conn_no + + def tx_add(self, data: bytearray): + ''' add data to transmit queue''' + self.tx_fifo += data + + def tx_flush(self): + ''' send transmit queue and clears it''' + self.tx_fifo() + + def tx_peek(self, size: int = None) -> bytearray: + '''returns size numbers of byte without removing them''' + return self.tx_fifo.peek(size) + + def tx_log(self, level, info): + ''' log the transmit queue''' + self.tx_fifo.logging(level, info) + + def tx_clear(self): + ''' clear transmit queue''' + self.tx_fifo.clear() + + def tx_len(self): + ''' get numner of bytes in the transmit queue''' + return len(self.tx_fifo) + + def fwd_add(self, data: bytearray): + ''' add data to forward queue''' + self.fwd_fifo += data + + def fwd_log(self, level, info): + ''' log the forward queue''' + self.fwd_fifo.logging(level, info) + + def rx_get(self, size: int = None) -> bytearray: + '''removes size numbers of bytes and return them''' + return self.rx_fifo.get(size) + + def rx_peek(self, size: int = None) -> bytearray: + '''returns size numbers of byte without removing them''' + return self.rx_fifo.peek(size) + + def rx_log(self, level, info): + ''' logs the receive queue''' + self.rx_fifo.logging(level, info) + + def rx_clear(self): + ''' clear receive queue''' + self.rx_fifo.clear() + + def rx_len(self): + ''' get numner of bytes in the receive queue''' + return len(self.rx_fifo) + + def rx_set_cb(self, callback): + self.rx_fifo.reg_trigger(callback) + + def prot_set_timeout_cb(self, callback): + self.timeout_cb = callback + + def prot_set_init_new_client_conn_cb(self, callback): + self.init_new_client_conn_cb = callback + + def prot_set_update_header_cb(self, callback): + self.update_header_cb = callback + + +class StreamPtr(): + '''Descr StreamPtr''' + def __init__(self, _stream, _ifc=None): + self.stream = _stream + self.ifc = _ifc + + @property + def ifc(self): + return self._ifc + + @ifc.setter + def ifc(self, value): + self._ifc = value + + @property + def stream(self): + return self._stream + + @stream.setter + def stream(self, value): + self._stream = value + + +class AsyncStream(AsyncIfcImpl): + MAX_PROC_TIME = 2 + '''maximum processing time for a received msg in sec''' + MAX_START_TIME = 400 + '''maximum time without a received msg in sec''' + MAX_INV_IDLE_TIME = 120 + '''maximum time without a received msg from the inverter in sec''' + MAX_DEF_IDLE_TIME = 360 + '''maximum default time without a received msg in sec''' + + def __init__(self, reader: StreamReader, writer: StreamWriter, + rstream: "StreamPtr") -> None: + AsyncIfcImpl.__init__(self) + + logger.debug('AsyncStream.__init__') + + self.remote = rstream + self.tx_fifo.reg_trigger(self.__write_cb) + self._reader = reader + self._writer = writer + self.r_addr = writer.get_extra_info('peername') + self.l_addr = writer.get_extra_info('sockname') + self.proc_start = None # start processing start timestamp + self.proc_max = 0 + self.async_publ_mqtt = None # will be set AsyncStreamServer only + + def __write_cb(self): + self._writer.write(self.tx_fifo.get()) + + def __timeout(self) -> int: + if self.timeout_cb: + return self.timeout_cb() + return 360 + + async def loop(self) -> Self: + """Async loop handler for precessing all received messages""" + self.proc_start = time.time() + while True: + try: + self.__calc_proc_time() + dead_conn_to = self.__timeout() + await asyncio.wait_for(self.__async_read(), + dead_conn_to) + + await self.__async_write() + await self.__async_forward() + if self.async_publ_mqtt: + await self.async_publ_mqtt() + + except asyncio.TimeoutError: + logger.warning(f'[{self.node_id}:{self.conn_no}] Dead ' + f'connection timeout ({dead_conn_to}s) ' + f'for {self.l_addr}') + await self.disc() + return self + + except OSError as error: + logger.error(f'[{self.node_id}:{self.conn_no}] ' + f'{error} for l{self.l_addr} | ' + f'r{self.r_addr}') + await self.disc() + return self + + except RuntimeError as error: + logger.info(f'[{self.node_id}:{self.conn_no}] ' + f'{error} for {self.l_addr}') + await self.disc() + return self + + except Exception: + Infos.inc_counter('SW_Exception') + logger.error( + f"Exception for {self.r_addr}:\n" + f"{traceback.format_exc()}") + await asyncio.sleep(0) # be cooperative to other task + + def __calc_proc_time(self): + if self.proc_start: + proc = time.time() - self.proc_start + if proc > self.proc_max: + self.proc_max = proc + self.proc_start = None + + async def disc(self) -> None: + """Async disc handler for graceful disconnect""" + if self._writer.is_closing(): + return + logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}') + self._writer.close() + await self._writer.wait_closed() + + def close(self) -> None: + logging.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}') + """close handler for a no waiting disconnect + + hint: must be called before releasing the connection instance + """ + super().close() + self._reader.feed_eof() # abort awaited read + if self._writer.is_closing(): + return + self._writer.close() + + def healthy(self) -> bool: + elapsed = 0 + if self.proc_start is not None: + elapsed = time.time() - self.proc_start + if elapsed > self.MAX_PROC_TIME: + logging.debug(f'[{self.node_id}:{self.conn_no}:' + f'{type(self).__name__}]' + f' act:{round(1000*elapsed)}ms' + f' max:{round(1000*self.proc_max)}ms') + logging.debug(f'Healthy()) refs: {gc.get_referrers(self)}') + return elapsed < 5 + + ''' + Our private methods + ''' + async def __async_read(self) -> None: + """Async read handler to read received data from TCP stream""" + data = await self._reader.read(4096) + if data: + self.proc_start = time.time() + self.rx_fifo += data + wait = self.rx_fifo() # call read in parent class + if wait and wait > 0: + await asyncio.sleep(wait) + else: + raise RuntimeError("Peer closed.") + + async def __async_write(self, headline: str = 'Transmit to ') -> None: + """Async write handler to transmit the send_buffer""" + if len(self.tx_fifo) > 0: + self.tx_fifo.logging(logging.INFO, f'{headline}{self.r_addr}:') + self._writer.write(self.tx_fifo.get()) + await self._writer.drain() + + async def __async_forward(self) -> None: + """forward handler transmits data over the remote connection""" + if len(self.fwd_fifo) == 0: + return + try: + await self._async_forward() + + except OSError as error: + if self.remote.stream: + rmt = self.remote + logger.error(f'[{rmt.stream.node_id}:{rmt.stream.conn_no}] ' + f'Fwd: {error} for ' + f'l{rmt.ifc.l_addr} | r{rmt.ifc.r_addr}') + await rmt.ifc.disc() + if rmt.ifc.close_cb: + rmt.ifc.close_cb() + + except RuntimeError as error: + if self.remote.stream: + rmt = self.remote + logger.info(f'[{rmt.stream.node_id}:{rmt.stream.conn_no}] ' + f'Fwd: {error} for {rmt.ifc.l_addr}') + await rmt.ifc.disc() + if rmt.ifc.close_cb: + rmt.ifc.close_cb() + + except Exception: + Infos.inc_counter('SW_Exception') + logger.error( + f"Fwd Exception for {self.r_addr}:\n" + f"{traceback.format_exc()}") + + async def publish_outstanding_mqtt(self): + '''Publish all outstanding MQTT topics''' + try: + await self.async_publ_mqtt() + await Proxy._async_publ_mqtt_proxy_stat('proxy') + except Exception: + pass + + +class AsyncStreamServer(AsyncStream): + def __init__(self, reader: StreamReader, writer: StreamWriter, + async_publ_mqtt, create_remote, + rstream: "StreamPtr") -> None: + AsyncStream.__init__(self, reader, writer, rstream) + self.create_remote = create_remote + self.async_publ_mqtt = async_publ_mqtt + + def close(self) -> None: + logging.debug('AsyncStreamServer.close()') + self.create_remote = None + self.async_publ_mqtt = None + super().close() + + async def server_loop(self) -> None: + '''Loop for receiving messages from the inverter (server-side)''' + logger.info(f'[{self.node_id}:{self.conn_no}] ' + f'Accept connection from {self.r_addr}') + Infos.inc_counter('Inverter_Cnt') + await self.publish_outstanding_mqtt() + await self.loop() + Infos.dec_counter('Inverter_Cnt') + await self.publish_outstanding_mqtt() + logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for' + f' r{self.r_addr}') + + # if the server connection closes, we also have to disconnect + # the connection to te TSUN cloud + if self.remote and self.remote.stream: + logger.info(f'[{self.node_id}:{self.conn_no}] disc client ' + f'connection: [{self.remote.ifc.node_id}:' + f'{self.remote.ifc.conn_no}]') + await self.remote.ifc.disc() + + async def _async_forward(self) -> None: + """forward handler transmits data over the remote connection""" + if not self.remote.stream: + await self.create_remote() + if self.remote.stream and \ + self.remote.ifc.init_new_client_conn_cb(): + await self.remote.ifc._AsyncStream__async_write() + if self.remote.stream: + self.remote.ifc.update_header_cb(self.fwd_fifo.peek()) + self.fwd_fifo.logging(logging.INFO, 'Forward to ' + f'{self.remote.ifc.r_addr}:') + self.remote.ifc._writer.write(self.fwd_fifo.get()) + await self.remote.ifc._writer.drain() + + +class AsyncStreamClient(AsyncStream): + def __init__(self, reader: StreamReader, writer: StreamWriter, + rstream: "StreamPtr", close_cb) -> None: + AsyncStream.__init__(self, reader, writer, rstream) + self.close_cb = close_cb + + async def disc(self) -> None: + logging.debug('AsyncStreamClient.disc()') + self.remote = None + await super().disc() + + def close(self) -> None: + logging.debug('AsyncStreamClient.close()') + self.close_cb = None + super().close() + + async def client_loop(self, _: str) -> None: + '''Loop for receiving messages from the TSUN cloud (client-side)''' + Infos.inc_counter('Cloud_Conn_Cnt') + await self.publish_outstanding_mqtt() + await self.loop() + Infos.dec_counter('Cloud_Conn_Cnt') + await self.publish_outstanding_mqtt() + logger.info(f'[{self.node_id}:{self.conn_no}] ' + 'Client loop stopped for' + f' l{self.l_addr}') + + if self.close_cb: + self.close_cb() + + async def _async_forward(self) -> None: + """forward handler transmits data over the remote connection""" + if self.remote.stream: + self.remote.ifc.update_header_cb(self.fwd_fifo.peek()) + self.fwd_fifo.logging(logging.INFO, 'Forward to ' + f'{self.remote.ifc.r_addr}:') + self.remote.ifc._writer.write(self.fwd_fifo.get()) + await self.remote.ifc._writer.drain() diff --git a/ha_addons/ha_addon/rootfs/home/proxy/byte_fifo.py b/ha_addons/ha_addon/rootfs/home/proxy/byte_fifo.py new file mode 100644 index 0000000..959eab2 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/byte_fifo.py @@ -0,0 +1,52 @@ +from messages import hex_dump_str, hex_dump_memory + + +class ByteFifo: + """ a byte FIFO buffer with trigger callback """ + __slots__ = ('__buf', '__trigger_cb') + + def __init__(self): + self.__buf = bytearray() + self.__trigger_cb = None + + def reg_trigger(self, cb) -> None: + self.__trigger_cb = cb + + def __iadd__(self, data): + self.__buf.extend(data) + return self + + def __call__(self): + '''triggers the observer''' + if callable(self.__trigger_cb): + return self.__trigger_cb() + return None + + def get(self, size: int = None) -> bytearray: + '''removes size numbers of byte and return them''' + if not size: + data = self.__buf + self.clear() + else: + data = self.__buf[:size] + # The fast delete syntax + self.__buf[:size] = b'' + return data + + def peek(self, size: int = None) -> bytearray: + '''returns size numbers of byte without removing them''' + if not size: + return self.__buf + return self.__buf[:size] + + def clear(self): + self.__buf = bytearray() + + def __len__(self) -> int: + return len(self.__buf) + + def __str__(self) -> str: + return hex_dump_str(self.__buf, self.__len__()) + + def logging(self, level, info): + hex_dump_memory(level, info, self.__buf, self.__len__()) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/config.py b/ha_addons/ha_addon/rootfs/home/proxy/config.py new file mode 100644 index 0000000..3424bd9 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/config.py @@ -0,0 +1,181 @@ +'''Config module handles the proxy configuration in the config.toml file''' + +import shutil +import tomllib +import logging +from schema import Schema, And, Or, Use, Optional + + +class Config(): + '''Static class Config is reads and sanitize the config. + + Read config.toml file and sanitize it with read(). + Get named parts of the config with get()''' + + act_config = {} + def_config = {} + conf_schema = Schema({ + 'tsun': { + 'enabled': Use(bool), + 'host': Use(str), + 'port': And(Use(int), lambda n: 1024 <= n <= 65535) + }, + 'solarman': { + 'enabled': Use(bool), + 'host': Use(str), + 'port': And(Use(int), lambda n: 1024 <= n <= 65535) + }, + 'mqtt': { + 'host': Use(str), + 'port': And(Use(int), lambda n: 1024 <= n <= 65535), + 'user': And(Use(str), Use(lambda s: s if len(s) > 0 else None)), + 'passwd': And(Use(str), Use(lambda s: s if len(s) > 0 else None)) + }, + 'ha': { + 'auto_conf_prefix': Use(str), + 'discovery_prefix': Use(str), + 'entity_prefix': Use(str), + 'proxy_node_id': Use(str), + 'proxy_unique_id': Use(str) + }, + 'gen3plus': { + 'at_acl': { + Or('mqtt', 'tsun'): { + 'allow': [str], + Optional('block', default=[]): [str] + } + } + }, + 'inverters': { + 'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): { + Optional('monitor_sn', default=0): Use(int), + Optional('node_id', default=""): And(Use(str), + Use(lambda s: s + '/' + if len(s) > 0 + and s[-1] != '/' + else s)), + Optional('client_mode'): { + 'host': Use(str), + Optional('port', default=8899): + And(Use(int), lambda n: 1024 <= n <= 65535), + Optional('forward', default=False): Use(bool), + }, + Optional('modbus_polling', default=True): Use(bool), + Optional('suggested_area', default=""): Use(str), + Optional('sensor_list', default=0x2b0): Use(int), + Optional('pv1'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + }, + Optional('pv2'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + }, + Optional('pv3'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + }, + Optional('pv4'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + }, + Optional('pv5'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + }, + Optional('pv6'): { + Optional('type'): Use(str), + Optional('manufacturer'): Use(str), + } + } + } + }, ignore_extra_keys=True + ) + + @classmethod + def class_init(cls) -> None | str: # pragma: no cover + try: + # make the default config transparaent by copying it + # in the config.example file + logging.debug('Copy Default Config to config.example.toml') + + shutil.copy2("default_config.toml", + "config/config.example.toml") + except Exception: + pass + err_str = cls.read() + del cls.conf_schema + return err_str + + @classmethod + def _read_config_file(cls) -> dict: # pragma: no cover + usr_config = {} + + try: + with open("config/config.toml", "rb") as f: + usr_config = tomllib.load(f) + except Exception as error: + err = f'Config.read: {error}' + logging.error(err) + logging.info( + '\n To create the missing config.toml file, ' + 'you can rename the template config.example.toml\n' + ' and customize it for your scenario.\n') + return usr_config + + @classmethod + def read(cls, path='') -> None | str: + '''Read config file, merge it with the default config + and sanitize the result''' + err = None + config = {} + logger = logging.getLogger('data') + + try: + # read example config file as default configuration + cls.def_config = {} + with open(f"{path}default_config.toml", "rb") as f: + def_config = tomllib.load(f) + cls.def_config = cls.conf_schema.validate(def_config) + + # overwrite the default values, with values from + # the config.toml file + usr_config = cls._read_config_file() + + # merge the default and the user config + config = def_config.copy() + for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters', + 'gen3plus']: + if key in usr_config: + config[key] |= usr_config[key] + + try: + cls.act_config = cls.conf_schema.validate(config) + except Exception as error: + err = f'Config.read: {error}' + logging.error(err) + + # logging.debug(f'Readed config: "{cls.act_config}" ') + + except Exception as error: + err = f'Config.read: {error}' + logger.error(err) + cls.act_config = {} + + return err + + @classmethod + def get(cls, member: str = None): + '''Get a named attribute from the proxy config. If member == + None it returns the complete config dict''' + + if member: + return cls.act_config.get(member, {}) + else: + return cls.act_config + + @classmethod + def is_default(cls, member: str) -> bool: + '''Check if the member is the default value''' + + return cls.act_config.get(member) == cls.def_config.get(member) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/default_config.toml b/ha_addons/ha_addon/rootfs/home/proxy/default_config.toml new file mode 100644 index 0000000..57b2baf --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/default_config.toml @@ -0,0 +1,177 @@ +########################################################################################## +### +### T S U N - G E N 3 - P R O X Y +### +### from Stefan Allius +### +########################################################################################## +### +### The readme will give you an overview of the project: +### https://s-allius.github.io/tsun-gen3-proxy/ +### +### The proxy supports different operation modes. Select the proper mode +### which depends on your inverter type and you inverter firmware. +### Please read: +### https://github.com/s-allius/tsun-gen3-proxy/wiki/Operation-Modes-Overview +### +### Here you will find a description of all configuration options: +### https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details +### +### The configration uses the TOML format, which aims to be easy to read due to +### obvious semantics. You find more details here: https://toml.io/en/v1.0.0 +### +########################################################################################## + + +########################################################################################## +## +## MQTT broker configuration +## +## In this block, you must configure the connection to your MQTT broker and specify the +## required credentials. As the proxy does not currently support an encrypted connection +## to the MQTT broker, it is strongly recommended that you do not use a public broker. +## +## https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details#mqtt-broker-account +## + +mqtt.host = 'mqtt' # URL or IP address of the mqtt broker +mqtt.port = 1883 +mqtt.user = '' +mqtt.passwd = '' + + +########################################################################################## +## +## HOME ASSISTANT +## +## The proxy supports the MQTT autoconfiguration of Home Assistant (HA). The default +## values match the HA default configuration. If you need to change these or want to use +## a different MQTT client, you can adjust the prefixes of the MQTT topics below. +## +## https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details#home-assistant +## + +ha.auto_conf_prefix = 'homeassistant' # MQTT prefix for subscribing for homeassistant status updates +ha.discovery_prefix = 'homeassistant' # MQTT prefix for discovery topic +ha.entity_prefix = 'tsun' # MQTT topic prefix for publishing inverter values +ha.proxy_node_id = 'proxy' # MQTT node id, for the proxy_node_id +ha.proxy_unique_id = 'P170000000000001' # MQTT unique id, to identify a proxy instance + + +########################################################################################## +## +## GEN3 Proxy Mode Configuration +## +## In this block, you can configure an optional connection to the TSUN cloud for GEN3 +## inverters. This connection is only required if you want send data to the TSUN cloud +## to use the TSUN APPs or receive firmware updates. +## +## https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details#tsun-cloud-for-gen3-inverter-only +## + +tsun.enabled = true # false: disables connecting to the tsun cloud, and avoids updates +tsun.host = 'logger.talent-monitoring.com' +tsun.port = 5005 + + +########################################################################################## +## +## GEN3PLUS Proxy Mode Configuration +## +## In this block, you can configure an optional connection to the TSUN cloud for GEN3PLUS +## inverters. This connection is only required if you want send data to the TSUN cloud +## to use the TSUN APPs or receive firmware updates. +## +## https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details#solarman-cloud-for-gen3plus-inverter-only +## + +solarman.enabled = true # false: disables connecting to the tsun cloud, and avoids updates +solarman.host = 'iot.talent-monitoring.com' +solarman.port = 10000 + + +########################################################################################## +### +### Inverter Definitions +### +### The proxy supports the simultaneous operation of several inverters, even of different +### types. A configuration block must be defined for each inverter, in which all necessary +### parameters must be specified. These depend on the operation mode used and also differ +### slightly depending on the inverter type. +### +### In addition, the PV modules can be defined at the individual inputs for documentation +### purposes, whereby these are displayed in Home Assistant. +### +### The proxy only accepts connections from known inverters. This can be switched off for +### test purposes and unknown serial numbers are also accepted. +### + +inverters.allow_all = false # only allow known inverters + + +########################################################################################## +## +## For each GEN3 inverter, the serial number of the inverter must be mapped to an MQTT +## definition. To do this, the corresponding configuration block is started with +## `[Inverter.“<16-digit serial number>”]` so that all subsequent parameters are assigned +## to this inverter. Further inverter-specific parameters (e.g. polling mode) can be set +## in the configuration block +## +## The serial numbers of all GEN3 inverters start with `R17`! +## + +[inverters."R170000000000001"] +node_id = '' # MQTT replacement for inverters serial number +suggested_area = '' # suggested installation area for home-assistant +modbus_polling = false # Disable optional MODBUS polling +pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr +pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr + + +########################################################################################## +## +## For each GEN3PLUS inverter, the serial number of the inverter must be mapped to an MQTT +## definition. To do this, the corresponding configuration block is started with +## `[Inverter.“<16-digit serial number>”]` so that all subsequent parameters are assigned +## to this inverter. Further inverter-specific parameters (e.g. polling mode, client mode) +## can be set in the configuration block +## +## The serial numbers of all GEN3PLUS inverters start with `Y17` or Y47! Each GEN3PLUS +## inverter is supplied with a “Monitoring SN:”. This can be found on a sticker enclosed +## with the inverter. +## + +[inverters."Y170000000000001"] +monitor_sn = 2000000000 # The GEN3PLUS "Monitoring SN:" +node_id = '' # MQTT replacement for inverters serial number +suggested_area = '' # suggested installation place for home-assistant +modbus_polling = true # Enable optional MODBUS polling + +# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment +# the next line and configure the fixed IP of your inverter +#client_mode = {host = '192.168.0.1', port = 8899} + +pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr + + +########################################################################################## +### +### If the proxy mode is configured, commands from TSUN can be sent to the inverter via +### this connection or parameters (e.g. network credentials) can be queried. Filters can +### then be configured for the AT+ commands from the TSUN Cloud so that only certain +### accesses are permitted. +### +### An overview of all known AT+ commands can be found here: +### https://github.com/s-allius/tsun-gen3-proxy/wiki/AT--commands +### + +[gen3plus.at_acl] +# filter for received commands from the internet +tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] +tsun.block = [] +# filter for received commands from the MQTT broker +mqtt.allow = ['AT+'] +mqtt.block = [] diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3/infos_g3.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3/infos_g3.py new file mode 100644 index 0000000..efa220c --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3/infos_g3.py @@ -0,0 +1,194 @@ + +import struct +import logging +from typing import Generator + +from infos import Infos, Register + + +class RegisterMap: + __slots__ = () + + map = { + 0x00092ba8: {'reg': Register.COLLECTOR_FW_VERSION}, + 0x000927c0: {'reg': Register.CHIP_TYPE}, + 0x00092f90: {'reg': Register.CHIP_MODEL}, + 0x00094ae8: {'reg': Register.MAC_ADDR}, + 0x00095a88: {'reg': Register.TRACE_URL}, + 0x00095aec: {'reg': Register.LOGGER_URL}, + 0x0000000a: {'reg': Register.PRODUCT_NAME}, + 0x00000014: {'reg': Register.MANUFACTURER}, + 0x0000001e: {'reg': Register.VERSION}, + 0x00000028: {'reg': Register.SERIAL_NUMBER}, + 0x00000032: {'reg': Register.EQUIPMENT_MODEL}, + 0x00013880: {'reg': Register.NO_INPUTS}, + 0xffffff00: {'reg': Register.INVERTER_CNT}, + 0xffffff01: {'reg': Register.UNKNOWN_SNR}, + 0xffffff02: {'reg': Register.UNKNOWN_MSG}, + 0xffffff03: {'reg': Register.INVALID_DATA_TYPE}, + 0xffffff04: {'reg': Register.INTERNAL_ERROR}, + 0xffffff05: {'reg': Register.UNKNOWN_CTRL}, + 0xffffff06: {'reg': Register.OTA_START_MSG}, + 0xffffff07: {'reg': Register.SW_EXCEPTION}, + 0xffffff08: {'reg': Register.POLLING_INTERVAL}, + 0xfffffffe: {'reg': Register.TEST_REG1}, + 0xffffffff: {'reg': Register.TEST_REG2}, + 0x00000640: {'reg': Register.OUTPUT_POWER}, + 0x000005dc: {'reg': Register.RATED_POWER}, + 0x00000514: {'reg': Register.INVERTER_TEMP}, + 0x000006a4: {'reg': Register.PV1_VOLTAGE}, + 0x00000708: {'reg': Register.PV1_CURRENT}, + 0x0000076c: {'reg': Register.PV1_POWER}, + 0x000007d0: {'reg': Register.PV2_VOLTAGE}, + 0x00000834: {'reg': Register.PV2_CURRENT}, + 0x00000898: {'reg': Register.PV2_POWER}, + 0x000008fc: {'reg': Register.PV3_VOLTAGE}, + 0x00000960: {'reg': Register.PV3_CURRENT}, + 0x000009c4: {'reg': Register.PV3_POWER}, + 0x00000a28: {'reg': Register.PV4_VOLTAGE}, + 0x00000a8c: {'reg': Register.PV4_CURRENT}, + 0x00000af0: {'reg': Register.PV4_POWER}, + 0x00000c1c: {'reg': Register.PV1_DAILY_GENERATION}, + 0x00000c80: {'reg': Register.PV1_TOTAL_GENERATION}, + 0x00000ce4: {'reg': Register.PV2_DAILY_GENERATION}, + 0x00000d48: {'reg': Register.PV2_TOTAL_GENERATION}, + 0x00000dac: {'reg': Register.PV3_DAILY_GENERATION}, + 0x00000e10: {'reg': Register.PV3_TOTAL_GENERATION}, + 0x00000e74: {'reg': Register.PV4_DAILY_GENERATION}, + 0x00000ed8: {'reg': Register.PV4_TOTAL_GENERATION}, + 0x00000b54: {'reg': Register.DAILY_GENERATION}, + 0x00000bb8: {'reg': Register.TOTAL_GENERATION}, + 0x000003e8: {'reg': Register.GRID_VOLTAGE}, + 0x0000044c: {'reg': Register.GRID_CURRENT}, + 0x000004b0: {'reg': Register.GRID_FREQUENCY}, + 0x000cfc38: {'reg': Register.CONNECT_COUNT}, + 0x000c3500: {'reg': Register.SIGNAL_STRENGTH}, + 0x000c96a8: {'reg': Register.POWER_ON_TIME}, + 0x000d0020: {'reg': Register.COLLECT_INTERVAL}, + 0x000cf850: {'reg': Register.DATA_UP_INTERVAL}, + 0x000c7f38: {'reg': Register.COMMUNICATION_TYPE}, + 0x00000190: {'reg': Register.EVENT_ALARM}, + 0x000001f4: {'reg': Register.EVENT_FAULT}, + 0x00000258: {'reg': Register.EVENT_BF1}, + 0x000002bc: {'reg': Register.EVENT_BF2}, + 0x00000064: {'reg': Register.INVERTER_STATUS}, + + 0x00000fa0: {'reg': Register.BOOT_STATUS}, + 0x00001004: {'reg': Register.DSP_STATUS}, + 0x000010cc: {'reg': Register.WORK_MODE}, + 0x000011f8: {'reg': Register.OUTPUT_SHUTDOWN}, + 0x0000125c: {'reg': Register.MAX_DESIGNED_POWER}, + 0x000012c0: {'reg': Register.RATED_LEVEL}, + 0x00001324: {'reg': Register.INPUT_COEFFICIENT, 'ratio': 100/1024}, + 0x00001388: {'reg': Register.GRID_VOLT_CAL_COEF}, + 0x00002710: {'reg': Register.PROD_COMPL_TYPE}, + 0x00003200: {'reg': Register.OUTPUT_COEFFICIENT, 'ratio': 100/1024}, + } + + +class InfosG3(Infos): + __slots__ = () + + 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(): + reg = row['reg'] + res = self.ha_conf(reg, ha_prfx, node_id, snr, False, sug_area) # noqa: E501 + if res: + yield res + + def parse(self, buf, ind=0, 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''' + result = struct.unpack_from('!l', buf, ind) + elms = result[0] + i = 0 + ind += 4 + while i < elms: + result = struct.unpack_from('!lB', buf, ind) + addr = result[0] + if addr not in RegisterMap.map: + row = None + info_id = -1 + else: + row = RegisterMap.map[addr] + info_id = row['reg'] + data_type = result[1] + ind += 5 + + if data_type == 0x54: # 'T' -> Pascal-String + str_len = buf[ind] + result = struct.unpack_from(f'!{str_len+1}p', buf, + ind)[0].decode(encoding='ascii', + errors='replace') + ind += str_len+1 + + elif data_type == 0x00: # 'Nul' -> end + i = elms # abort the loop + + elif data_type == 0x41: # 'A' -> Nop ?? + ind += 0 + i += 1 + continue + + elif data_type == 0x42: # 'B' -> byte, int8 + result = struct.unpack_from('!B', buf, ind)[0] + ind += 1 + + elif data_type == 0x49: # 'I' -> int32 + result = struct.unpack_from('!l', buf, ind)[0] + ind += 4 + + elif data_type == 0x53: # 'S' -> short, int16 + result = struct.unpack_from('!h', buf, ind)[0] + ind += 2 + + elif data_type == 0x46: # 'F' -> float32 + result = round(struct.unpack_from('!f', buf, ind)[0], 2) + ind += 4 + + elif data_type == 0x4c: # 'L' -> long, int64 + result = struct.unpack_from('!q', buf, ind)[0] + ind += 8 + + else: + self.inc_counter('Invalid_Data_Type') + logging.error(f"Infos.parse: data_type: {data_type}" + f" @0x{addr:04x} No:{i}" + " not supported") + return + + result = self.__modify_val(row, result) + + yield from self.__store_result(addr, result, info_id, node_id) + i += 1 + + def __modify_val(self, row, result): + if row and 'ratio' in row: + result = round(result * row['ratio'], 2) + return result + + def __store_result(self, addr, result, info_id, node_id): + 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: + update = False + name = str(f'info-id.0x{addr:x}') + if update: + self.tracer.log(level, f'[{node_id}] GEN3: {name} :' + f' {result}{unit}') diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3/inverter_g3.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3/inverter_g3.py new file mode 100644 index 0000000..efaeca0 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3/inverter_g3.py @@ -0,0 +1,9 @@ +from asyncio import StreamReader, StreamWriter + +from inverter_base import InverterBase +from gen3.talent import Talent + + +class InverterG3(InverterBase): + def __init__(self, reader: StreamReader, writer: StreamWriter): + super().__init__(reader, writer, 'tsun', Talent) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3/talent.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3/talent.py new file mode 100644 index 0000000..da3ebc8 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3/talent.py @@ -0,0 +1,569 @@ +import struct +import logging +from zoneinfo import ZoneInfo +from datetime import datetime +from tzlocal import get_localzone + +from async_ifc import AsyncIfc +from messages import Message, State +from modbus import Modbus +from config import Config +from gen3.infos_g3 import InfosG3 +from infos import Register + +logger = logging.getLogger('msg') + + +class Control: + def __init__(self, ctrl: int): + self.ctrl = ctrl + + def __int__(self) -> int: + return self.ctrl + + def is_ind(self) -> bool: + return (self.ctrl == 0x91) + + def is_req(self) -> bool: + return (self.ctrl == 0x70) + + def is_resp(self) -> bool: + return (self.ctrl == 0x99) + + +class Talent(Message): + TXT_UNKNOWN_CTRL = 'Unknown Ctrl' + + def __init__(self, addr, ifc: "AsyncIfc", server_side: bool, + client_mode: bool = False, id_str=b''): + super().__init__('G3', ifc, server_side, self.send_modbus_cb, + mb_timeout=15) + 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.await_conn_resp_cnt = 0 + self.id_str = id_str + self.contact_name = b'' + self.contact_mail = b'' + self.ts_offset = 0 # time offset between tsun cloud and local + self.db = InfosG3() + self.switch = { + 0x00: self.msg_contact_info, + 0x13: self.msg_ota_update, + 0x22: self.msg_get_time, + 0x99: self.msg_heartbeat, + 0x71: self.msg_collector_data, + # 0x76: + 0x77: self.msg_modbus, + # 0x78: + 0x87: self.msg_modbus2, + 0x04: self.msg_inverter_data, + } + self.log_lvl = { + 0x00: logging.INFO, + 0x13: logging.INFO, + 0x22: logging.INFO, + 0x99: logging.INFO, + 0x71: logging.INFO, + # 0x76: + 0x77: self.get_modbus_log_lvl, + # 0x78: + 0x87: self.get_modbus_log_lvl, + 0x04: logging.INFO, + } + + ''' + Our puplic methods + ''' + def close(self) -> None: + logging.debug('Talent.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() + + def __set_serial_no(self, serial_no: str): + + if self.unique_id == serial_no: + logger.debug(f'SerialNo: {serial_no}') + else: + inverters = Config.get('inverters') + # logger.debug(f'Inverters: {inverters}') + + if serial_no in inverters: + inv = inverters[serial_no] + self.node_id = inv['node_id'] + self.sug_area = inv['suggested_area'] + self.modbus_polling = inv['modbus_polling'] + logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 + self.db.set_pv_module_details(inv) + if self.mb: + self.mb.set_node_id(self.node_id) + 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.debug(f'SerialNo {serial_no} not known but accepted!') + + self.unique_id = serial_no + self.db.set_db_def_value(Register.COLLECTOR_SNR, serial_no) + + 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): + if self.state == State.init: + self.state = State.received # received 1st package + + log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING) + if callable(log_lvl): + log_lvl = log_lvl() + + self.ifc.rx_log(log_lvl, f'Received from {self.addr}:' + f' BufLen: {self.ifc.rx_len()}' + f' HdrLen: {self.header_len}' + f' DtaLen: {self.data_len}') + + self.__set_serial_no(self.id_str.decode("utf-8")) + self.__dispatch_msg() + self.__flush_recv_msg() + else: + return 0 # don not wait before sending a response + + def forward(self) -> None: + '''add the actual receive msg to the forwarding queue''' + tsun = Config.get('tsun') + if tsun['enabled']: + buflen = self.header_len+self.data_len + buffer = self.ifc.rx_peek(buflen) + self.ifc.fwd_add(buffer) + self.ifc.fwd_log(logging.DEBUG, 'Store for forwarding:') + + fnc = self.switch.get(self.msg_id, self.msg_unknown) + logger.info(self.__flow_str(self.server_side, 'forwrd') + + f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') + + def send_modbus_cb(self, modbus_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(0x70, 0x77) + self.ifc.tx_add(b'\x00\x01\xa3\x28') # magic ? + self.ifc.tx_add(struct.pack('!B', len(modbus_pdu))) + self.ifc.tx_add(modbus_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) + + if 2 == (exp_cnt % 30): + # logging.info("Regular Modbus Status request") + self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG) + else: + self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG) + + def _init_new_client_conn(self) -> bool: + contact_name = self.contact_name + contact_mail = self.contact_mail + logger.info(f'name: {contact_name} mail: {contact_mail}') + self.msg_id = 0 + self.await_conn_resp_cnt += 1 + self.__build_header(0x91) + self.ifc.tx_add(struct.pack(f'!{len(contact_name)+1}p' + f'{len(contact_mail)+1}p', + contact_name, contact_mail)) + + self.__finish_send_msg() + return True + + ''' + Our private 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 _timestamp(self): # pragma: no cover + '''returns timestamp fo the inverter as localtime + since 1.1.1970 in msec''' + # convert localtime in epoche + ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds() + return round(ts*1000) + + def _utcfromts(self, ts: float): + '''converts inverter timestamp into unix time (epoche)''' + dt = datetime.fromtimestamp(ts/1000, tz=ZoneInfo("UTC")). \ + replace(tzinfo=get_localzone()) + return dt.timestamp() + + def _utc(self): # pragma: no cover + '''returns unix time (epoche)''' + return datetime.now().timestamp() + + def _update_header(self, _forward_buffer): + '''update header for message before forwarding, + add time offset to timestamp''' + _len = len(_forward_buffer) + ofs = 0 + while ofs < _len: + result = struct.unpack_from('!lB', _forward_buffer, 0) + msg_len = 4 + result[0] + id_len = result[1] # len of variable id string + if _len < 2*id_len + 21: + return + + result = struct.unpack_from('!B', _forward_buffer, id_len+6) + msg_code = result[0] + if msg_code == 0x71 or msg_code == 0x04: + result = struct.unpack_from('!q', _forward_buffer, 13+2*id_len) + ts = result[0] + self.ts_offset + logger.debug(f'offset: {self.ts_offset:08x}' + f' proxy-time: {ts:08x}') + struct.pack_into('!q', _forward_buffer, 13+2*id_len, ts) + ofs += msg_len + + # check if there is a complete header in the buffer, parse it + # and set + # self.header_len + # self.data_len + # self.id_str + # self.ctrl + # self.msg_id + # + # if the header is incomplete, than self.header_len is still 0 + # + def __parse_header(self, buf: bytes, buf_len: int) -> None: + + if (buf_len < 5): # enough bytes to read len and id_len? + return + result = struct.unpack_from('!lB', buf, 0) + msg_len = result[0] # len of complete message + id_len = result[1] # len of variable id string + if id_len > 17: + logger.warning(f'len of ID string must == 16 but is {id_len}') + self.inc_counter('Invalid_Msg_Format') + + # erase broken recv buffer + self.ifc.rx_clear() + return + + hdr_len = 5+id_len+2 + + if (buf_len < hdr_len): # enough bytes for complete header? + return + + result = struct.unpack_from(f'!{id_len+1}pBB', buf, 4) + + # store parsed header values in the class + self.id_str = result[0] + self.ctrl = Control(result[1]) + self.msg_id = result[2] + self.data_len = msg_len-id_len-3 + self.header_len = hdr_len + self.header_valid = True + + def __build_header(self, ctrl, msg_id=None) -> None: + if not msg_id: + msg_id = self.msg_id + self.send_msg_ofs = self.ifc.tx_len() + self.ifc.tx_add(struct.pack(f'!l{len(self.id_str)+1}pBB', + 0, self.id_str, ctrl, msg_id)) + fnc = self.switch.get(msg_id, self.msg_unknown) + logger.info(self.__flow_str(self.server_side, 'tx') + + f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') + + def __finish_send_msg(self) -> None: + _len = self.ifc.tx_len() - self.send_msg_ofs + struct.pack_into('!l', self.ifc.tx_peek(), self.send_msg_ofs, + _len-4) + + def __dispatch_msg(self) -> None: + fnc = self.switch.get(self.msg_id, self.msg_unknown) + if self.unique_id: + logger.info(self.__flow_str(self.server_side, 'rx') + + f' Ctl: {int(self.ctrl):#02x} ({self.state}) ' + f'Msg: {fnc.__name__!r}') + fnc() + else: + logger.info(self.__flow_str(self.server_side, 'drop') + + f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') + + def __flush_recv_msg(self) -> None: + self.ifc.rx_get(self.header_len+self.data_len) + self.header_valid = False + + ''' + Message handler methods + ''' + def msg_contact_info(self): + if self.ctrl.is_ind(): + if self.server_side and self.__process_contact_info(): + self.__build_header(0x91) + self.ifc.tx_add(b'\x01') + self.__finish_send_msg() + # don't forward this contact info here, we will build one + # when the remote connection is established + elif self.await_conn_resp_cnt > 0: + self.await_conn_resp_cnt -= 1 + else: + self.forward() + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + self.forward() + + def __process_contact_info(self) -> bool: + buf = self.ifc.rx_peek() + result = struct.unpack_from('!B', buf, self.header_len) + name_len = result[0] + if self.data_len == 1: # this is a response withone status byte + return False + if self.data_len >= name_len+2: + result = struct.unpack_from(f'!{name_len+1}pB', buf, + self.header_len) + self.contact_name = result[0] + mail_len = result[1] + logger.info(f'name: {self.contact_name}') + + result = struct.unpack_from(f'!{mail_len+1}p', buf, + self.header_len+name_len+1) + self.contact_mail = result[0] + logger.info(f'mail: {self.contact_mail}') + return True + + def msg_get_time(self): + if self.ctrl.is_ind(): + if self.data_len == 0: + if self.state == State.up: + self.state = State.pend # block MODBUS cmds + + ts = self._timestamp() + logger.debug(f'time: {ts:08x}') + self.__build_header(0x91) + self.ifc.tx_add(struct.pack('!q', ts)) + self.__finish_send_msg() + + elif self.data_len >= 8: + ts = self._timestamp() + result = struct.unpack_from('!q', self.ifc.rx_peek(), + self.header_len) + self.ts_offset = result[0]-ts + if self.ifc.remote.stream: + self.ifc.remote.stream.ts_offset = self.ts_offset + logger.debug(f'tsun-time: {int(result[0]):08x}' + f' proxy-time: {ts:08x}' + f' offset: {self.ts_offset}') + return # ignore received response + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + + self.forward() + + def msg_heartbeat(self): + if self.ctrl.is_ind(): + if self.data_len == 9: + self.state = State.up # allow MODBUS cmds + if (self.modbus_polling): + self.mb_timer.start(self.mb_first_timeout) + self.db.set_db_def_value(Register.POLLING_INTERVAL, + self.mb_timeout) + self.__build_header(0x99) + self.ifc.tx_add(b'\x02') + self.__finish_send_msg() + + result = struct.unpack_from('!Bq', self.ifc.rx_peek(), + self.header_len) + resp_code = result[0] + ts = result[1]+self.ts_offset + logger.debug(f'inv-time: {int(result[1]):08x}' + f' tsun-time: {ts:08x}' + f' offset: {self.ts_offset}') + struct.pack_into('!Bq', self.ifc.rx_peek(), + self.header_len, resp_code, ts) + elif self.ctrl.is_resp(): + result = struct.unpack_from('!B', self.ifc.rx_peek(), + self.header_len) + resp_code = result[0] + logging.debug(f'Heartbeat-RespCode: {resp_code}') + return + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + + self.forward() + + def parse_msg_header(self): + result = struct.unpack_from('!lB', self.ifc.rx_peek(), + self.header_len) + + data_id = result[0] # len of complete message + id_len = result[1] # len of variable id string + logger.debug(f'Data_ID: 0x{data_id:08x} id_len: {id_len}') + + msg_hdr_len = 5+id_len+9 + + result = struct.unpack_from(f'!{id_len+1}pBq', self.ifc.rx_peek(), + self.header_len + 4) + + timestamp = result[2] + logger.debug(f'ID: {result[0]} B: {result[1]}') + logger.debug(f'time: {timestamp:08x}') + # logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime( + # "%Y-%m-%d %H:%M:%S")}') + return msg_hdr_len, timestamp + + def msg_collector_data(self): + if self.ctrl.is_ind(): + self.__build_header(0x99) + self.ifc.tx_add(b'\x01') + self.__finish_send_msg() + self.__process_data() + + elif self.ctrl.is_resp(): + return # ignore received response + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + + self.forward() + + def msg_inverter_data(self): + if self.ctrl.is_ind(): + self.__build_header(0x99) + self.ifc.tx_add(b'\x01') + self.__finish_send_msg() + self.__process_data() + self.state = State.up # allow MODBUS cmds + if (self.modbus_polling): + self.mb_timer.start(self.mb_first_timeout) + self.db.set_db_def_value(Register.POLLING_INTERVAL, + self.mb_timeout) + + elif self.ctrl.is_resp(): + return # ignore received response + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + + self.forward() + + def __process_data(self): + msg_hdr_len, ts = self.parse_msg_header() + + for key, update in self.db.parse(self.ifc.rx_peek(), self.header_len + + msg_hdr_len, self.node_id): + if update: + self._set_mqtt_timestamp(key, self._utcfromts(ts)) + self.new_data[key] = True + + def msg_ota_update(self): + if self.ctrl.is_req(): + self.inc_counter('OTA_Start_Msg') + elif self.ctrl.is_ind(): + pass # Ok, nothing to do + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + self.forward() + + def parse_modbus_header(self): + + msg_hdr_len = 5 + + result = struct.unpack_from('!lBB', self.ifc.rx_peek(), + self.header_len) + modbus_len = result[1] + return msg_hdr_len, modbus_len + + def parse_modbus_header2(self): + + msg_hdr_len = 6 + + result = struct.unpack_from('!lBBB', self.ifc.rx_peek(), + self.header_len) + modbus_len = result[2] + return msg_hdr_len, modbus_len + + def get_modbus_log_lvl(self) -> int: + if self.ctrl.is_req(): + return logging.INFO + elif self.ctrl.is_ind() and self.server_side: + return self.mb.last_log_lvl + return logging.WARNING + + def msg_modbus(self): + hdr_len, _ = self.parse_modbus_header() + self.__msg_modbus(hdr_len) + + def msg_modbus2(self): + hdr_len, _ = self.parse_modbus_header2() + self.__msg_modbus(hdr_len) + + def __msg_modbus(self, hdr_len): + data = self.ifc.rx_peek()[self.header_len: + self.header_len+self.data_len] + + if self.ctrl.is_req(): + rstream = self.ifc.remote.stream + if rstream.mb.recv_req(data[hdr_len:], rstream.msg_forward): + self.inc_counter('Modbus_Command') + else: + self.inc_counter('Invalid_Msg_Format') + elif self.ctrl.is_ind(): + self.modbus_elms = 0 + # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') + if not self.server_side: + logger.warning('Unknown Message') + self.inc_counter('Unknown_Msg') + return + + for key, update, _ in self.mb.recv_resp(self.db, data[ + hdr_len:]): + if update: + self._set_mqtt_timestamp(key, self._utc()) + self.new_data[key] = True + self.modbus_elms += 1 # count for unit tests + else: + logger.warning(self.TXT_UNKNOWN_CTRL) + self.inc_counter('Unknown_Ctrl') + self.forward() + + def msg_forward(self): + self.forward() + + def msg_unknown(self): + logger.warning(f"Unknow Msg: ID:{self.msg_id}") + self.inc_counter('Unknown_Msg') + self.forward() diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/infos_g3p.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/infos_g3p.py new file mode 100644 index 0000000..417487a --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/infos_g3p.py @@ -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': ' 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 diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/inverter_g3p.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/inverter_g3p.py new file mode 100644 index 0000000..f3680c9 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/inverter_g3p.py @@ -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) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/solarman_emu.py b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/solarman_emu.py new file mode 100644 index 0000000..66035bb --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/gen3plus/solarman_emu.py @@ -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('> 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( + ' None: + '''finish the transmit message, set lenght and checksum''' + _len = self.ifc.tx_len() - self.send_msg_ofs + struct.pack_into(' None: + + if (buf_len < self.header_len): # enough bytes for complete header? + return + + result = struct.unpack_from(' 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(' 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( + ' {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(' 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'> 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(' 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(' str | int: + if not reverse: + return f'{val[0]:04x}' + else: + return int(val, 16) + + @staticmethod + def mac(val: tuple | str, reverse=False) -> str | tuple: + if not reverse: + return "%02x:%02x:%02x:%02x:%02x:%02x" % val + else: + return ( + int(val[0:2], 16), int(val[3:5], 16), + int(val[6:8], 16), int(val[9:11], 16), + int(val[12:14], 16), int(val[15:], 16)) + + @staticmethod + def version(val: tuple | str, reverse=False) -> str | int: + if not reverse: + x = val[0] + return f'V{(x >> 12)}.{(x >> 8) & 0xf}' \ + f'.{(x >> 4) & 0xf}{x & 0xf:1X}' + else: + arr = val[1:].split('.') + return int(arr[0], 10) << 12 | \ + int(arr[1], 10) << 8 | \ + int(arr[2][:-1], 10) << 4 | \ + int(arr[2][-1:], 16) + + @staticmethod + def set_value(buf: bytearray, idx: int, row: dict, val): + '''Get a value from buf and interpret as in row defined''' + fmt = row['fmt'] + if 'offset' in row: + val = val - row['offset'] + if 'quotient' in row: + val = round(val * row['quotient']) + if 'ratio' in row: + val = round(val / row['ratio']) + if 'func' in row: + val = row['func'](val, reverse=True) + if isinstance(val, str): + val = bytes(val, 'UTF8') + + if isinstance(val, tuple): + struct.pack_into(fmt, buf, idx, *val) + else: + struct.pack_into(fmt, buf, idx, val) + + +class ClrAtMidnight: + __clr_at_midnight = [Register.PV1_DAILY_GENERATION, Register.PV2_DAILY_GENERATION, Register.PV3_DAILY_GENERATION, Register.PV4_DAILY_GENERATION, Register.PV5_DAILY_GENERATION, Register.PV6_DAILY_GENERATION, Register.DAILY_GENERATION] # noqa: E501 + db = {} + + @classmethod + def add(cls, keys: list, prfx: str, reg: Register) -> None: + if reg not in cls.__clr_at_midnight: + return + + prfx += f'{keys[0]}' + db_dict = cls.db + if prfx not in db_dict: + db_dict[prfx] = {} + db_dict = db_dict[prfx] + + for key in keys[1:-1]: + if key not in db_dict: + db_dict[key] = {} + db_dict = db_dict[key] + db_dict[keys[-1]] = 0 + + @classmethod + def elm(cls) -> Generator[tuple[str, dict], None, None]: + for reg, name in cls.db.items(): + yield reg, name + cls.db = {} + + +class Infos: + __slots__ = ('db', 'tracer', ) + + LIGHTNING = 'mdi:lightning-bolt' + COUNTER = 'mdi:counter' + GAUGE = 'mdi:gauge' + SOLAR_POWER_VAR = 'mdi:solar-power-variant' + SOLAR_POWER = 'mdi:solar-power' + WIFI = 'mdi:wifi' + UPDATE = 'mdi:update' + DAILY_GEN = 'Daily Generation' + TOTAL_GEN = 'Total Generation' + FMT_INT = '| int' + FMT_FLOAT = '| float' + FMT_STRING_SEC = '| string + " s"' + stat = {} + app_name = os.getenv('SERVICE_NAME', 'proxy') + version = os.getenv('VERSION', 'unknown') + new_stat_data = {} + + @classmethod + def static_init(cls): + logging.debug('Initialize proxy statistics') + # init proxy counter in the class.stat dictionary + cls.stat['proxy'] = {} + for key in cls.__info_defs: + name = cls.__info_defs[key]['name'] + if name[0] == 'proxy': + cls.stat['proxy'][name[1]] = 0 + + # add values from the environment to the device definition table + prxy = cls.__info_devs['proxy'] + prxy['sw'] = cls.version + prxy['mdl'] = cls.app_name + + def __init__(self): + self.db = {} + self.tracer = logging.getLogger('data') + + __info_devs = { + 'proxy': {'singleton': True, 'name': 'Proxy', 'mf': 'Stefan Allius'}, # noqa: E501 + 'controller': {'via': 'proxy', 'name': 'Controller', 'mdl': Register.CHIP_MODEL, 'mf': Register.CHIP_TYPE, 'sw': Register.COLLECTOR_FW_VERSION, 'mac': Register.MAC_ADDR, 'sn': Register.COLLECTOR_SNR}, # noqa: E501 + 'inverter': {'via': 'controller', 'name': 'Micro Inverter', 'mdl': Register.EQUIPMENT_MODEL, 'mf': Register.MANUFACTURER, 'sw': Register.VERSION, 'sn': Register.SERIAL_NUMBER}, # noqa: E501 + 'input_pv1': {'via': 'inverter', 'name': 'Module PV1', 'mdl': Register.PV1_MODEL, 'mf': Register.PV1_MANUFACTURER}, # noqa: E501 + 'input_pv2': {'via': 'inverter', 'name': 'Module PV2', 'mdl': Register.PV2_MODEL, 'mf': Register.PV2_MANUFACTURER, 'dep': {'reg': Register.NO_INPUTS, 'gte': 2}}, # noqa: E501 + 'input_pv3': {'via': 'inverter', 'name': 'Module PV3', 'mdl': Register.PV3_MODEL, 'mf': Register.PV3_MANUFACTURER, 'dep': {'reg': Register.NO_INPUTS, 'gte': 3}}, # noqa: E501 + 'input_pv4': {'via': 'inverter', 'name': 'Module PV4', 'mdl': Register.PV4_MODEL, 'mf': Register.PV4_MANUFACTURER, 'dep': {'reg': Register.NO_INPUTS, 'gte': 4}}, # noqa: E501 + 'input_pv5': {'via': 'inverter', 'name': 'Module PV5', 'mdl': Register.PV5_MODEL, 'mf': Register.PV5_MANUFACTURER, 'dep': {'reg': Register.NO_INPUTS, 'gte': 5}}, # noqa: E501 + 'input_pv6': {'via': 'inverter', 'name': 'Module PV6', 'mdl': Register.PV6_MODEL, 'mf': Register.PV6_MANUFACTURER, 'dep': {'reg': Register.NO_INPUTS, 'gte': 6}}, # noqa: E501 + } + + __comm_type_val_tpl = "{%set com_types = ['n/a','Wi-Fi', 'G4', 'G5', 'GPRS'] %}{{com_types[value_json['Communication_Type']|int(0)]|default(value_json['Communication_Type'])}}" # noqa: E501 + __work_mode_val_tpl = "{%set mode = ['Normal-Mode', 'Aging-Mode', 'ATE-Mode', 'Shielding GFDI', 'DTU-Mode'] %}{{mode[value_json['Work_Mode']|int(0)]|default(value_json['Work_Mode'])}}" # noqa: E501 + __status_type_val_tpl = "{%set inv_status = ['Off-line', 'On-grid', 'Off-grid'] %}{{inv_status[value_json['Inverter_Status']|int(0)]|default(value_json['Inverter_Status'])}}" # noqa: E501 + __rated_power_val_tpl = "{% if 'Rated_Power' in value_json and value_json['Rated_Power'] != None %}{{value_json['Rated_Power']|string() +' W'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501 + __designed_power_val_tpl = ''' +{% if 'Max_Designed_Power' in value_json and + value_json['Max_Designed_Power'] != None %} + {% if value_json['Max_Designed_Power'] | int(0xffff) < 0x8000 %} + {{value_json['Max_Designed_Power']|string() +' W'}} + {% else %} + n/a + {% endif %} +{% else %} + {{ this.state }} +{% endif %} +''' + __inv_alarm_val_tpl = ''' +{% if 'Inverter_Alarm' in value_json and + value_json['Inverter_Alarm'] != None %} + {% set val_int = value_json['Inverter_Alarm'] | int %} + {% if val_int == 0 %} + {% set result = 'noAlarm'%} + {%else%} + {% set result = '' %} + {% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%} + {% endif %} + {% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%} + {% endif %} + {% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%} + {% endif %} + {% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%} + {% endif %} + {% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%} + {% endif %} + {% if val_int | bitwise_and(6)%}{% set result = result + 'Bit6, '%} + {% endif %} + {% if val_int | bitwise_and(7)%}{% set result = result + 'Bit7, '%} + {% endif %} + {% if val_int | bitwise_and(8)%}{% set result = result + 'Bit8, '%} + {% endif %} + {% if val_int | bitwise_and(9)%}{% set result = result + 'noUtility, '%} + {% endif %} + {% if val_int | bitwise_and(10)%}{% set result = result + 'Bit10, '%} + {% endif %} + {% if val_int | bitwise_and(11)%}{% set result = result + 'Bit11, '%} + {% endif %} + {% if val_int | bitwise_and(12)%}{% set result = result + 'Bit12, '%} + {% endif %} + {% if val_int | bitwise_and(13)%}{% set result = result + 'Bit13, '%} + {% endif %} + {% if val_int | bitwise_and(14)%}{% set result = result + 'Bit14, '%} + {% endif %} + {% if val_int | bitwise_and(15)%}{% set result = result + 'Bit15, '%} + {% endif %} + {% if val_int | bitwise_and(16)%}{% set result = result + 'Bit16, '%} + {% endif %} + {% endif %} + {{ result }} +{% else %} + {{ this.state }} +{% endif %} +''' + __inv_fault_val_tpl = ''' +{% if 'Inverter_Fault' in value_json and + value_json['Inverter_Fault'] != None %} + {% set val_int = value_json['Inverter_Fault'] | int %} + {% if val_int == 0 %} + {% set result = 'noFault'%} + {%else%} + {% set result = '' %} + {% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%} + {% endif %} + {% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%} + {% endif %} + {% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%} + {% endif %} + {% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%} + {% endif %} + {% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%} + {% endif %} + {% if val_int | bitwise_and(6)%}{% set result = result + 'Bit6, '%} + {% endif %} + {% if val_int | bitwise_and(7)%}{% set result = result + 'Bit7, '%} + {% endif %} + {% if val_int | bitwise_and(8)%}{% set result = result + 'Bit8, '%} + {% endif %} + {% if val_int | bitwise_and(9)%}{% set result = result + 'Bit9, '%} + {% endif %} + {% if val_int | bitwise_and(10)%}{% set result = result + 'Bit10, '%} + {% endif %} + {% if val_int | bitwise_and(11)%}{% set result = result + 'Bit11, '%} + {% endif %} + {% if val_int | bitwise_and(12)%}{% set result = result + 'Bit12, '%} + {% endif %} + {% if val_int | bitwise_and(13)%}{% set result = result + 'Bit13, '%} + {% endif %} + {% if val_int | bitwise_and(14)%}{% set result = result + 'Bit14, '%} + {% endif %} + {% if val_int | bitwise_and(15)%}{% set result = result + 'Bit15, '%} + {% endif %} + {% if val_int | bitwise_and(16)%}{% set result = result + 'Bit16, '%} + {% endif %} + {% endif %} + {{ result }} +{% else %} + {{ this.state }} +{% endif %} +''' + + __input_coef_val_tpl = "{% if 'Output_Coefficient' in value_json and value_json['Input_Coefficient'] != None %}{{value_json['Input_Coefficient']|string() +' %'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501 + __output_coef_val_tpl = "{% if 'Output_Coefficient' in value_json and value_json['Output_Coefficient'] != None %}{{value_json['Output_Coefficient']|string() +' %'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501 + + __info_defs = { + # collector values used for device registration: + Register.COLLECTOR_FW_VERSION: {'name': ['collector', 'Collector_Fw_Version'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.CHIP_TYPE: {'name': ['collector', 'Chip_Type'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.CHIP_MODEL: {'name': ['collector', 'Chip_Model'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.TRACE_URL: {'name': ['collector', 'Trace_URL'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.LOGGER_URL: {'name': ['collector', 'Logger_URL'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.MAC_ADDR: {'name': ['collector', 'MAC-Addr'], 'singleton': False, 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.COLLECTOR_SNR: {'name': ['collector', 'Serial_Number'], 'singleton': False, 'level': logging.INFO, 'unit': ''}, # noqa: E501 + + + # inverter values used for device registration: + Register.PRODUCT_NAME: {'name': ['inverter', 'Product_Name'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.MANUFACTURER: {'name': ['inverter', 'Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.VERSION: {'name': ['inverter', 'Version'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'val_tpl': __designed_power_val_tpl, 'name': 'Max Designed Power', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'val_tpl': __rated_power_val_tpl, 'name': 'Rated Power', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.WORK_MODE: {'name': ['inverter', 'Work_Mode'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'work_mode_', 'name': 'Work Mode', 'val_tpl': __work_mode_val_tpl, 'icon': 'mdi:power', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.INPUT_COEFFICIENT: {'name': ['inverter', 'Input_Coefficient'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'input_coef_', 'val_tpl': __input_coef_val_tpl, 'name': 'Input Coefficient', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.OUTPUT_COEFFICIENT: {'name': ['inverter', 'Output_Coefficient'], 'level': logging.INFO, 'unit': '%', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'output_coef_', 'val_tpl': __output_coef_val_tpl, 'name': 'Output Coefficient', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV1_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV2_MODEL: {'name': ['inverter', 'PV2_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV3_MANUFACTURER: {'name': ['inverter', 'PV3_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV3_MODEL: {'name': ['inverter', 'PV3_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV4_MANUFACTURER: {'name': ['inverter', 'PV4_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV4_MODEL: {'name': ['inverter', 'PV4_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV5_MANUFACTURER: {'name': ['inverter', 'PV5_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV5_MODEL: {'name': ['inverter', 'PV5_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV6_MANUFACTURER: {'name': ['inverter', 'PV6_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PV6_MODEL: {'name': ['inverter', 'PV6_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.BOOT_STATUS: {'name': ['inverter', 'BOOT_STATUS'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.DSP_STATUS: {'name': ['inverter', 'DSP_STATUS'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + # proxy: + Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': FMT_INT, 'name': 'Active Inverter Connections', 'icon': COUNTER}}, # noqa: E501 + Register.CLOUD_CONN_CNT: {'name': ['proxy', 'Cloud_Conn_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'cloud_conn_count_', 'fmt': FMT_INT, 'name': 'Active Cloud Connections', 'icon': COUNTER}}, # noqa: E501 + Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': FMT_INT, 'name': 'Unknown Serial No', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': FMT_INT, 'name': 'Unknown Msg Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': FMT_INT, 'name': 'Invalid Data Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': FMT_INT, 'name': 'Internal Error', 'icon': COUNTER, 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501 + Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': FMT_INT, 'name': 'Unknown Control Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': FMT_INT, 'name': 'OTA Start Cmd', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': FMT_INT, 'name': 'Internal SW Exception', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': FMT_INT, 'name': 'Invalid Message Format', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': FMT_INT, 'name': 'AT Command', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.AT_COMMAND_BLOCKED: {'name': ['proxy', 'AT_Command_Blocked'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_blocked_', 'fmt': FMT_INT, 'name': 'AT Command Blocked', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.MODBUS_COMMAND: {'name': ['proxy', 'Modbus_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'modbus_cmd_', 'fmt': FMT_INT, 'name': 'Modbus Command', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 + # 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':FMT_FLOAT,'name': 'Grid Voltage'}}, # noqa: E501 + + # events + Register.EVENT_ALARM: {'name': ['events', 'Inverter_Alarm'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_alarm_', 'name': 'Inverter Alarm', 'val_tpl': __inv_alarm_val_tpl, 'icon': 'mdi:alarm-light'}}, # noqa: E501 + Register.EVENT_FAULT: {'name': ['events', 'Inverter_Fault'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_fault_', 'name': 'Inverter Fault', 'val_tpl': __inv_fault_val_tpl, 'icon': 'mdi:alarm-light'}}, # noqa: E501 + Register.EVENT_BF1: {'name': ['events', 'Inverter_Bitfield_1'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.EVENT_BF2: {'name': ['events', 'Inverter_bitfield_2'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + # Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + # Register.EVENT_415: {'name': ['events', '415_GridFreqOverRating'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + + # grid measures: + Register.TS_GRID: {'name': ['grid', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': FMT_FLOAT, 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.GRID_CURRENT: {'name': ['grid', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'inverter', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'out_cur_', 'fmt': FMT_FLOAT, 'name': 'Grid Current', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', 'fmt': FMT_FLOAT, 'name': 'Grid Frequency', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.OUTPUT_POWER: {'name': ['grid', 'Output_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'out_power_', 'fmt': FMT_FLOAT, 'name': 'Power'}}, # noqa: E501 + Register.INVERTER_TEMP: {'name': ['env', 'Inverter_Temp'], 'level': logging.DEBUG, 'unit': '°C', 'ha': {'dev': 'inverter', 'dev_cla': 'temperature', 'stat_cla': 'measurement', 'id': 'temp_', 'fmt': FMT_INT, 'name': 'Temperature'}}, # noqa: E501 + Register.INVERTER_STATUS: {'name': ['env', 'Inverter_Status'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_status_', 'name': 'Inverter Status', 'val_tpl': __status_type_val_tpl, 'icon': 'mdi:power'}}, # noqa: E501 + Register.DETECT_STATUS_1: {'name': ['env', 'Detect_Status_1'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.DETECT_STATUS_2: {'name': ['env', 'Detect_Status_2'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + + # input measures: + Register.TS_INPUT: {'name': ['input', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.PV1_VOLTAGE: {'name': ['input', 'pv1', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv1', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv1_', 'val_tpl': "{{ (value_json['pv1']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV1_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501 + Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501 + Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501 + Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501 + Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501 + Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501 + Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV2_TOTAL_GENERATION: {'name': ['input', 'pv2', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv2_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv2']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + Register.PV3_DAILY_GENERATION: {'name': ['input', 'pv3', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv3_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv3']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV3_TOTAL_GENERATION: {'name': ['input', 'pv3', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv3_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv3']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + Register.PV4_DAILY_GENERATION: {'name': ['input', 'pv4', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv4_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv4']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV4_TOTAL_GENERATION: {'name': ['input', 'pv4', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv4_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv4']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + Register.PV5_DAILY_GENERATION: {'name': ['input', 'pv5', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv5_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv5']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV5_TOTAL_GENERATION: {'name': ['input', 'pv5', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv5_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv5']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + Register.PV6_DAILY_GENERATION: {'name': ['input', 'pv6', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv6_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.PV6_TOTAL_GENERATION: {'name': ['input', 'pv6', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv6_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + # total: + Register.TS_TOTAL: {'name': ['total', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.DAILY_GENERATION: {'name': ['total', 'Daily_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_', 'fmt': FMT_FLOAT, 'name': DAILY_GEN, 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 + Register.TOTAL_GENERATION: {'name': ['total', 'Total_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_', 'fmt': FMT_FLOAT, 'name': TOTAL_GEN, 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 + + # controller: + Register.SIGNAL_STRENGTH: {'name': ['controller', 'Signal_Strength'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'signal_', 'fmt': FMT_INT, 'name': 'Signal Strength', 'icon': WIFI}}, # noqa: E501 + Register.POWER_ON_TIME: {'name': ['controller', 'Power_On_Time'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': 'duration', 'stat_cla': 'measurement', 'id': 'power_on_time_', 'fmt': FMT_INT, 'name': 'Power on Time', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 'min', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " min"', 'name': 'Data Collect Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.CONNECT_COUNT: {'name': ['controller', 'Connect_Count'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'connect_count_', 'fmt': FMT_INT, 'name': 'Connect Count', 'icon': COUNTER, 'comp': 'sensor', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.COMMUNICATION_TYPE: {'name': ['controller', 'Communication_Type'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'comm_type_', 'name': 'Communication Type', 'val_tpl': __comm_type_val_tpl, 'comp': 'sensor', 'icon': WIFI}}, # noqa: E501 + Register.DATA_UP_INTERVAL: {'name': ['controller', 'Data_Up_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_up_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Data Up Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.HEARTBEAT_INTERVAL: {'name': ['controller', 'Heartbeat_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'heartbeat_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Heartbeat Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.IP_ADDRESS: {'name': ['controller', 'IP_Address'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_address_', 'fmt': '| string', 'name': 'IP Address', 'icon': WIFI, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.POLLING_INTERVAL: {'name': ['controller', 'Polling_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'polling_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Polling Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.SENSOR_LIST: {'name': ['controller', 'Sensor_List'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.SSID: {'name': ['controller', 'WiFi_SSID'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + + Register.OUTPUT_SHUTDOWN: {'name': ['other', 'Output_Shutdown'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.RATED_LEVEL: {'name': ['other', 'Rated_Level'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.GRID_VOLT_CAL_COEF: {'name': ['other', 'Grid_Volt_Cal_Coef'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.PROD_COMPL_TYPE: {'name': ['other', 'Prod_Compliance_Type'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 + Register.INV_UNKNOWN_1: {'name': ['inv_unknown', 'Unknown_1'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + + } + + @property + def info_devs(self) -> dict: + return self.__info_devs + + @property + def info_defs(self) -> dict: + return self.__info_defs + + def dev_value(self, idx: str | int) -> str | int | float | dict | None: + '''returns the stored device value from our database + + idx:int ==> lookup the value in the database and return it as str, + int or float. If the value is not available return 'None' + idx:str ==> returns the string as a fixed value without a + database lookup + ''' + if type(idx) is str: + return idx # return idx as a fixed value + elif idx in self.info_defs: + row = self.info_defs[idx] + if 'singleton' in row and row['singleton']: + db_dict = self.stat + else: + db_dict = self.db + + keys = row['name'] + + for key in keys: + if key not in db_dict: + return None # value not found in the database + db_dict = db_dict[key] + return db_dict # value of the reqeusted entry + + return None # unknwon idx, not in info_defs + + @classmethod + def inc_counter(cls, counter: str) -> None: + '''inc proxy statistic counter''' + db_dict = cls.stat['proxy'] + db_dict[counter] += 1 + cls.new_stat_data['proxy'] = True + + @classmethod + def dec_counter(cls, counter: str) -> None: + '''dec proxy statistic counter''' + db_dict = cls.stat['proxy'] + db_dict[counter] -= 1 + cls.new_stat_data['proxy'] = True + + def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \ + -> Generator[tuple[str, str, str, str], None, None]: + '''Generator function yields json register struct for home-assistant + auto configuration and the unique entity string, for all proxy + registers + + arguments: + ha_prfx:str ==> MQTT prefix for the home assistant 'stat_t string + node_id:str ==> node id of the inverter, used to build unique entity + snr:str ==> serial number of the inverter, used to build unique + entity strings + ''' + # iterate over RegisterMap.map and get the register values for entries + # with Singleton=True, which means that this is a proxy register + for reg in self.info_defs.keys(): + res = self.ha_conf(reg, ha_prfx, node_id, snr, True) # noqa: E501 + if res: + yield res + + def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool, + sug_area: str = '') -> tuple[str, str, str, str] | None: + '''Method to build json register struct for home-assistant + auto configuration and the unique entity string, for all proxy + registers + + arguments: + key ==> index of info_defs dict which reference the topic + ha_prfx:str ==> MQTT prefix for the home assistant 'stat_t string + node_id:str ==> node id of the inverter, used to build unique entity + snr:str ==> serial number of the inverter, used to build unique + entity strings + singleton ==> bool to allow/disaalow proxy topics which are common + for all invters + sug_area ==> area name for home assistant + ''' + if key not in self.info_defs: + return None + row = self.info_defs[key] + + if 'singleton' in row: + if singleton != row['singleton']: + return None + elif singleton: + return None + + # check if we have details for home assistant + if 'ha' in row: + return self.__ha_conf(row, key, ha_prfx, node_id, snr, sug_area) + return None + + def __ha_conf(self, row, key, ha_prfx, node_id, snr, + sug_area: str) -> tuple[str, str, str, str] | None: + ha = row['ha'] + if 'comp' in ha: + component = ha['comp'] + else: + component = 'sensor' + attr = self.__build_attr(row, key, ha_prfx, node_id, snr) + if 'dev' in ha: + device = self.info_devs[ha['dev']] + if 'dep' in device and self.ignore_this_device(device['dep']): # noqa: E501 + return None + attr['dev'] = self.__build_dev(device, key, ha, snr, + sug_area) + attr['o'] = self.__build_origin() + + else: + self.inc_counter('Internal_Error') + logging.error(f"Infos.info_defs: the row for {key} " + "missing 'dev' value for ha register") + return json.dumps(attr), component, node_id, attr['uniq_id'] + + def __build_attr(self, row, key, ha_prfx, node_id, snr): + attr = {} + ha = row['ha'] + if 'name' in ha: + attr['name'] = ha['name'] + else: + attr['name'] = row['name'][-1] + prfx = ha_prfx + node_id + attr['stat_t'] = prfx + row['name'][0] + attr['dev_cla'] = ha['dev_cla'] + attr['stat_cla'] = ha['stat_cla'] + attr['uniq_id'] = ha['id']+snr + if 'val_tpl' in ha: + attr['val_tpl'] = ha['val_tpl'] + elif 'fmt' in ha: + attr['val_tpl'] = '{{value_json' + f"['{row['name'][-1]}'] {ha['fmt']}" + '}}' # eg. 'val_tpl': "{{ value_json['Output_Power']|float }} # noqa: E501 + else: + self.inc_counter('Internal_Error') + logging.error(f"Infos.info_defs: the row for {key} do" + " not have a 'val_tpl' nor a 'fmt' value") + # add unit_of_meas only, if status_class isn't none. If + # status_cla is None we want a number format and not line + # graph in home assistant. A unit will change the number + # format to a line graph + if 'unit' in row and attr['stat_cla'] is not None: + attr['unit_of_meas'] = row['unit'] # 'unit_of_meas' + if 'icon' in ha: + attr['ic'] = ha['icon'] # icon for the entity + if 'nat_prc' in ha: # pragma: no cover + attr['sug_dsp_prc'] = ha['nat_prc'] # precison of floats + if 'ent_cat' in ha: + attr['ent_cat'] = ha['ent_cat'] # diagnostic, config + # enabled_by_default is deactivated, since it avoid the via + # setup of the devices. It seems, that there is a bug in home + # assistant. tested with 'Home Assistant 2023.10.4' + # if 'en' in ha: # enabled_by_default + # attr['en'] = ha['en'] + return attr + + def __build_dev(self, device, key, ha, snr, sug_area): + dev = {} + singleton = 'singleton' in device and device['singleton'] + # the same name for 'name' and 'suggested area', so we get + # dedicated devices in home assistant with short value + # name and headline + if (sug_area == '' or singleton): + dev['name'] = device['name'] + dev['sa'] = device['name'] + else: + dev['name'] = device['name']+' - '+sug_area + dev['sa'] = device['name']+' - '+sug_area + self.__add_via_dev(dev, device, key, snr) + for key in ('mdl', 'mf', 'sw', 'hw', 'sn'): # add optional + # values fpr 'modell', 'manufacturer', 'sw version' and + # 'hw version' + if key in device: + data = self.dev_value(device[key]) + if data is not None: + dev[key] = data + if singleton: + dev['ids'] = [f"{ha['dev']}"] + else: + dev['ids'] = [f"{ha['dev']}_{snr}"] + self.__add_connection(dev, device) + return dev + + def __add_connection(self, dev, device): + if 'mac' in device: + mac_str = self.dev_value(device['mac']) + if mac_str is not None: + if 12 == len(mac_str): + mac_str = ':'.join(mac_str[i:i+2] for i in range(0, 12, 2)) + dev['cns'] = [["mac", f"{mac_str}"]] + + def __add_via_dev(self, dev, device, key, snr): + if 'via' in device: # add the link to the parent device + via = device['via'] + if via in self.info_devs: + via_dev = self.info_devs[via] + if 'singleton' in via_dev and via_dev['singleton']: + dev['via_device'] = via + else: + dev['via_device'] = f"{via}_{snr}" + else: + self.inc_counter('Internal_Error') + logging.error(f"Infos.info_defs: the row for " + f"{key} has an invalid via value: " + f"{via}") + + def __build_origin(self): + origin = {} + origin['name'] = self.app_name + origin['sw'] = self.version + return origin + + def ha_remove(self, key, node_id, snr) -> tuple[str, str, str, str] | None: + '''Method to build json unregister struct for home-assistant + to remove topics per auto configuration. Only for inverer topics. + + arguments: + key ==> index of info_defs dict which reference the topic + node_id:str ==> node id of the inverter, used to build unique entity + snr:str ==> serial number of the inverter, used to build unique + entity strings + + hint: + the returned tuple must have the same format as self.ha_conf() + ''' + if key not in self.info_defs: + return None + row = self.info_defs[key] + + if 'singleton' in row and row['singleton']: + return None + + # check if we have details for home assistant + if 'ha' in row: + ha = row['ha'] + if 'comp' in ha: + component = ha['comp'] + else: + component = 'sensor' + attr = {} + uniq_id = ha['id']+snr + + return json.dumps(attr), component, node_id, uniq_id + return None + + def _key_obj(self, id: Register) -> tuple: + d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG, + 'unit': ''}) + if 'ha' in d and 'must_incr' in d['ha']: + must_incr = d['ha']['must_incr'] + else: + must_incr = False + + return d['name'], d['level'], d['unit'], must_incr + + def update_db(self, keys: list, must_incr: bool, result): + name = '' + db_dict = self.db + for key in keys[:-1]: + if key not in db_dict: + db_dict[key] = {} + db_dict = db_dict[key] + name += key + '.' + if keys[-1] not in db_dict: + update = (not must_incr or result > 0) + else: + if must_incr: + update = db_dict[keys[-1]] < result + else: + update = db_dict[keys[-1]] != result + if update: + db_dict[keys[-1]] = result + name += keys[-1] + return name, update + + def set_db_def_value(self, id: Register, value) -> None: + '''set default value''' + row = self.info_defs[id] + if isinstance(row, dict): + keys = row['name'] + self.update_db(keys, False, value) + + def reg_clr_at_midnight(self, prfx: str, + check_dependencies: bool = True) -> None: + '''register all registers for the 'ClrAtMidnight' class and + check if device of every register is available otherwise ignore + the register. + + prfx:str ==> prefix for the home assistant 'stat_t string'' + ''' + for id, row in self.info_defs.items(): + if check_dependencies and 'ha' in row: + ha = row['ha'] + if 'dev' in ha: + device = self.info_devs[ha['dev']] + if 'dep' in device and self.ignore_this_device(device['dep']): # noqa: E501 + continue + + keys = row['name'] + ClrAtMidnight.add(keys, prfx, id) + + def get_db_value(self, id: Register, not_found_result: any = None): + '''get database value''' + if id not in self.info_defs: + return not_found_result + row = self.info_defs[id] + if isinstance(row, dict): + keys = row['name'] + elm = self.db + for key in keys: + if key not in elm: + return not_found_result + elm = elm[key] + return elm + return not_found_result + + def ignore_this_device(self, dep: dict) -> bool: + '''Checks the equation in the dep(endency) dict + + returns 'False' only if the equation is valid; + 'True' in any other case''' + if 'reg' in dep: + value = self.dev_value(dep['reg']) + if not value: + return True + + if 'gte' in dep: + return value < dep['gte'] + elif 'less_eq' in dep: + return value > dep['less_eq'] + return True + + def set_pv_module_details(self, inv: dict) -> None: + pvs = {'pv1': {'manufacturer': Register.PV1_MANUFACTURER, 'model': Register.PV1_MODEL}, # noqa: E501 + 'pv2': {'manufacturer': Register.PV2_MANUFACTURER, 'model': Register.PV2_MODEL}, # noqa: E501 + 'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501 + 'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501 + 'pv5': {'manufacturer': Register.PV5_MANUFACTURER, 'model': Register.PV5_MODEL}, # noqa: E501 + 'pv6': {'manufacturer': Register.PV6_MANUFACTURER, 'model': Register.PV6_MODEL} # noqa: E501 + } + + for key, reg in pvs.items(): + if key in inv: + if 'manufacturer' in inv[key]: + self.set_db_def_value(reg['manufacturer'], + inv[key]['manufacturer']) + if 'type' in inv[key]: + self.set_db_def_value(reg['model'], inv[key]['type']) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/inverter_base.py b/ha_addons/ha_addon/rootfs/home/proxy/inverter_base.py new file mode 100644 index 0000000..757b883 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/inverter_base.py @@ -0,0 +1,178 @@ +import weakref +import asyncio +import logging +import traceback +import json +import gc +from aiomqtt import MqttCodeError +from asyncio import StreamReader, StreamWriter + +from inverter_ifc import InverterIfc +from proxy import Proxy +from async_stream import StreamPtr +from async_stream import AsyncStreamClient +from async_stream import AsyncStreamServer +from config import Config +from infos import Infos + +logger_mqtt = logging.getLogger('mqtt') + + +class InverterBase(InverterIfc, Proxy): + + def __init__(self, reader: StreamReader, writer: StreamWriter, + config_id: str, prot_class, + client_mode: bool = False, + remote_prot_class=None): + Proxy.__init__(self) + self._registry.append(weakref.ref(self)) + self.addr = writer.get_extra_info('peername') + self.config_id = config_id + if remote_prot_class: + self.prot_class = remote_prot_class + else: + self.prot_class = prot_class + self.__ha_restarts = -1 + self.remote = StreamPtr(None) + ifc = AsyncStreamServer(reader, writer, + self.async_publ_mqtt, + self.create_remote, + self.remote) + + self.local = StreamPtr( + prot_class(self.addr, ifc, True, client_mode), ifc + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + logging.debug(f'InverterBase.__exit__() {self.addr}') + self.__del_remote() + + self.local.stream.close() + self.local.stream = None + self.local.ifc.close() + self.local.ifc = None + + # now explicitly call garbage collector to release unreachable objects + unreachable_obj = gc.collect() + logging.debug( + f'InverterBase.__exit: freed unreachable obj: {unreachable_obj}') + + def __del_remote(self): + if self.remote.stream: + self.remote.stream.close() + self.remote.stream = None + + if self.remote.ifc: + self.remote.ifc.close() + self.remote.ifc = None + + async def disc(self, shutdown_started=False) -> None: + if self.remote.stream: + self.remote.stream.shutdown_started = shutdown_started + if self.remote.ifc: + await self.remote.ifc.disc() + if self.local.stream: + self.local.stream.shutdown_started = shutdown_started + if self.local.ifc: + await self.local.ifc.disc() + + def healthy(self) -> bool: + logging.debug('InverterBase healthy()') + + if self.local.ifc and not self.local.ifc.healthy(): + return False + if self.remote.ifc and not self.remote.ifc.healthy(): + return False + return True + + async def create_remote(self) -> None: + '''Establish a client connection to the TSUN cloud''' + + tsun = Config.get(self.config_id) + host = tsun['host'] + port = tsun['port'] + addr = (host, port) + stream = self.local.stream + + try: + logging.info(f'[{stream.node_id}] Connect to {addr}') + connect = asyncio.open_connection(host, port) + reader, writer = await connect + ifc = AsyncStreamClient( + reader, writer, self.local, self.__del_remote) + + self.remote.ifc = ifc + if hasattr(stream, 'id_str'): + self.remote.stream = self.prot_class( + addr, ifc, server_side=False, + client_mode=False, id_str=stream.id_str) + else: + self.remote.stream = self.prot_class( + addr, ifc, server_side=False, + client_mode=False) + + logging.info(f'[{self.remote.stream.node_id}:' + f'{self.remote.stream.conn_no}] ' + f'Connected to {addr}') + asyncio.create_task(self.remote.ifc.client_loop(addr)) + + except (ConnectionRefusedError, TimeoutError) as error: + logging.info(f'{error}') + except Exception: + Infos.inc_counter('SW_Exception') + logging.error( + f"Inverter: Exception for {addr}:\n" + f"{traceback.format_exc()}") + + async def async_publ_mqtt(self) -> None: + '''publish data to MQTT broker''' + stream = self.local.stream + if not stream or not stream.unique_id: + return + # check if new inverter or collector infos are available or when the + # home assistant has changed the status back to online + try: + if (('inverter' in stream.new_data and stream.new_data['inverter']) + or ('collector' in stream.new_data and + stream.new_data['collector']) + or self.mqtt.ha_restarts != self.__ha_restarts): + await self._register_proxy_stat_home_assistant() + await self.__register_home_assistant(stream) + self.__ha_restarts = self.mqtt.ha_restarts + + for key in stream.new_data: + await self.__async_publ_mqtt_packet(stream, key) + for key in Infos.new_stat_data: + await Proxy._async_publ_mqtt_proxy_stat(key) + + except MqttCodeError as error: + logging.error(f'Mqtt except: {error}') + except Exception: + Infos.inc_counter('SW_Exception') + logging.error( + f"Inverter: Exception:\n" + f"{traceback.format_exc()}") + + async def __async_publ_mqtt_packet(self, stream, key): + db = stream.db.db + if key in db and stream.new_data[key]: + data_json = json.dumps(db[key]) + node_id = stream.node_id + logger_mqtt.debug(f'{key}: {data_json}') + await self.mqtt.publish(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501 + stream.new_data[key] = False + + async def __register_home_assistant(self, stream) -> None: + '''register all our topics at home assistant''' + for data_json, component, node_id, id in stream.db.ha_confs( + self.entity_prfx, stream.node_id, stream.unique_id, + stream.sug_area): + logger_mqtt.debug(f"MQTT Register: cmp:'{component}'" + f" node_id:'{node_id}' {data_json}") + await self.mqtt.publish(f"{self.discovery_prfx}{component}" + f"/{node_id}{id}/config", data_json) + + stream.db.reg_clr_at_midnight(f'{self.entity_prfx}{stream.node_id}') diff --git a/ha_addons/ha_addon/rootfs/home/proxy/inverter_ifc.py b/ha_addons/ha_addon/rootfs/home/proxy/inverter_ifc.py new file mode 100644 index 0000000..11bd5e8 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/inverter_ifc.py @@ -0,0 +1,37 @@ +from abc import abstractmethod +import logging +from asyncio import StreamReader, StreamWriter + +from iter_registry import AbstractIterMeta + +logger_mqtt = logging.getLogger('mqtt') + + +class InverterIfc(metaclass=AbstractIterMeta): + _registry = [] + + @abstractmethod + def __init__(self, reader: StreamReader, writer: StreamWriter, + config_id: str, prot_class, + client_mode: bool): + pass # pragma: no cover + + @abstractmethod + def __enter__(self): + pass # pragma: no cover + + @abstractmethod + def __exit__(self, exc_type, exc, tb): + pass # pragma: no cover + + @abstractmethod + def healthy(self) -> bool: + pass # pragma: no cover + + @abstractmethod + async def disc(self, shutdown_started=False) -> None: + pass # pragma: no cover + + @abstractmethod + async def create_remote(self) -> None: + pass # pragma: no cover diff --git a/ha_addons/ha_addon/rootfs/home/proxy/iter_registry.py b/ha_addons/ha_addon/rootfs/home/proxy/iter_registry.py new file mode 100644 index 0000000..ea0cd73 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/iter_registry.py @@ -0,0 +1,9 @@ +from abc import ABCMeta + + +class AbstractIterMeta(ABCMeta): + def __iter__(cls): + for ref in cls._registry: + obj = ref() + if obj is not None: + yield obj diff --git a/ha_addons/ha_addon/rootfs/home/proxy/logging.ini b/ha_addons/ha_addon/rootfs/home/proxy/logging.ini new file mode 100644 index 0000000..34db695 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/logging.ini @@ -0,0 +1,76 @@ +[loggers] +keys=root,tracer,mesg,conn,data,mqtt,asyncio + +[handlers] +keys=console_handler,file_handler_name1,file_handler_name2 + +[formatters] +keys=console_formatter,file_formatter + +[logger_root] +level=DEBUG +handlers=console_handler,file_handler_name1 + + +[logger_conn] +level=DEBUG +handlers=console_handler,file_handler_name1 +propagate=0 +qualname=conn + +[logger_mqtt] +level=INFO +handlers=console_handler,file_handler_name1 +propagate=0 +qualname=mqtt + +[logger_asyncio] +level=INFO +handlers=console_handler,file_handler_name1 +propagate=0 +qualname=asyncio + +[logger_data] +level=DEBUG +handlers=file_handler_name1 +propagate=0 +qualname=data + + +[logger_mesg] +level=DEBUG +handlers=file_handler_name2 +propagate=0 +qualname=msg + +[logger_tracer] +level=INFO +handlers=file_handler_name2 +propagate=0 +qualname=tracer + +[handler_console_handler] +class=StreamHandler +level=DEBUG +formatter=console_formatter + +[handler_file_handler_name1] +class=handlers.TimedRotatingFileHandler +level=INFO +formatter=file_formatter +args=('log/proxy.log', when:='midnight') + +[handler_file_handler_name2] +class=handlers.TimedRotatingFileHandler +level=NOTSET +formatter=file_formatter +args=('log/trace.log', when:='midnight') + +[formatter_console_formatter] +format=%(asctime)s %(levelname)5s | %(name)4s | %(message)s' +datefmt='%Y-%m-%d %H:%M:%S + +[formatter_file_formatter] +format=%(asctime)s %(levelname)5s | %(name)4s | %(message)s' +datefmt='%Y-%m-%d %H:%M:%S + diff --git a/ha_addons/ha_addon/rootfs/home/proxy/messages.py b/ha_addons/ha_addon/rootfs/home/proxy/messages.py new file mode 100644 index 0000000..eecfc80 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/messages.py @@ -0,0 +1,203 @@ +import logging +import weakref +from typing import Callable +from enum import Enum + +from async_ifc import AsyncIfc +from protocol_ifc import ProtocolIfc +from infos import Infos, Register +from modbus import Modbus +from my_timer import Timer + +logger = logging.getLogger('msg') + + +def __hex_val(n, data, data_len): + line = '' + for j in range(n-16, n): + if j >= data_len: + break + line += '%02x ' % abs(data[j]) + return line + + +def __asc_val(n, data, data_len): + line = '' + for j in range(n-16, n): + if j >= data_len: + break + c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.' + line += '%c' % c + return line + + +def hex_dump(data, data_len) -> list: + n = 0 + lines = [] + + for i in range(0, data_len, 16): + line = ' ' + line += '%04x | ' % (i) + n += 16 + line += __hex_val(n, data, data_len) + line += ' ' * (3 * 16 + 9 - len(line)) + ' | ' + line += __asc_val(n, data, data_len) + lines.append(line) + + return lines + + +def hex_dump_str(data, data_len): + lines = hex_dump(data, data_len) + return '\n'.join(lines) + + +def hex_dump_memory(level, info, data, data_len): + lines = [] + lines.append(info) + tracer = logging.getLogger('tracer') + if not tracer.isEnabledFor(level): + return + + lines += hex_dump(data, data_len) + + tracer.log(level, '\n'.join(lines)) + + +class State(Enum): + '''state of the logical connection''' + init = 0 + '''just created''' + received = 1 + '''at least one packet received''' + up = 2 + '''at least one cmd-rsp transaction''' + pend = 3 + '''inverter transaction pending, don't send MODBUS cmds''' + closed = 4 + '''connection closed''' + + +class Message(ProtocolIfc): + MAX_START_TIME = 400 + '''maximum time without a received msg in sec''' + MAX_INV_IDLE_TIME = 120 + '''maximum time without a received msg from the inverter in sec''' + MAX_DEF_IDLE_TIME = 360 + '''maximum default time without a received msg in sec''' + MB_START_TIMEOUT = 40 + '''start delay for Modbus polling in server mode''' + MB_REGULAR_TIMEOUT = 60 + '''regular Modbus polling time in server mode''' + + def __init__(self, node_id, ifc: "AsyncIfc", server_side: bool, + send_modbus_cb: Callable[[bytes, int, str], None], + mb_timeout: int): + self._registry.append(weakref.ref(self)) + + self.server_side = server_side + self.ifc = ifc + self.node_id = node_id + if server_side: + self.mb = Modbus(send_modbus_cb, mb_timeout) + self.mb_timer = Timer(self.mb_timout_cb, self.node_id) + else: + self.mb = None + self.mb_timer = None + self.header_valid = False + self.header_len = 0 + self.data_len = 0 + self.unique_id = 0 + self.sug_area = '' + self.new_data = {} + self.state = State.init + self.shutdown_started = False + self.modbus_elms = 0 # for unit tests + self.mb_timeout = self.MB_REGULAR_TIMEOUT + self.mb_first_timeout = self.MB_START_TIMEOUT + '''timer value for next Modbus polling request''' + self.modbus_polling = False + + @property + def node_id(self): + return self._node_id + + @node_id.setter + def node_id(self, value): + self._node_id = value + self.ifc.set_node_id(value) + + ''' + Empty methods, that have to be implemented in any child class which + don't use asyncio + ''' + def _read(self) -> None: # read data bytes from socket and copy them + # to our _recv_buffer + return # pragma: no cover + + def _set_mqtt_timestamp(self, key, ts: float | None): + if key not in self.new_data or \ + not self.new_data[key]: + if key == 'grid': + info_id = Register.TS_GRID + elif key == 'input': + info_id = Register.TS_INPUT + elif key == 'total': + info_id = Register.TS_TOTAL + else: + return + # tstr = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts)) + # logger.info(f'update: key: {key} ts:{tstr}' + self.db.set_db_def_value(info_id, round(ts)) + + def _timeout(self) -> int: + if self.state == State.init or self.state == State.received: + to = self.MAX_START_TIME + elif self.state == State.up and \ + self.server_side and self.modbus_polling: + to = self.MAX_INV_IDLE_TIME + else: + to = self.MAX_DEF_IDLE_TIME + return to + + def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None: + if self.state != State.up: + logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') + return + self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) + + async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: + self._send_modbus_cmd(func, addr, val, log_lvl) + + ''' + Our puplic methods + ''' + def close(self) -> None: + if self.server_side: + # set inverter state to offline, if output power is very low + logging.debug('close power: ' + f'{self.db.get_db_value(Register.OUTPUT_POWER, -1)}') + if self.db.get_db_value(Register.OUTPUT_POWER, 999) < 2: + self.db.set_db_def_value(Register.INVERTER_STATUS, 0) + self.new_data['env'] = True + self.mb_timer.close() + self.state = State.closed + self.ifc.rx_set_cb(None) + self.ifc.prot_set_timeout_cb(None) + self.ifc.prot_set_init_new_client_conn_cb(None) + self.ifc.prot_set_update_header_cb(None) + self.ifc = None + + if self.mb: + self.mb.close() + self.mb = None + # pragma: no cover + + def inc_counter(self, counter: str) -> None: + self.db.inc_counter(counter) + Infos.new_stat_data['proxy'] = True + + def dec_counter(self, counter: str) -> None: + self.db.dec_counter(counter) + Infos.new_stat_data['proxy'] = True diff --git a/ha_addons/ha_addon/rootfs/home/proxy/modbus.py b/ha_addons/ha_addon/rootfs/home/proxy/modbus.py new file mode 100644 index 0000000..5c64086 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/modbus.py @@ -0,0 +1,345 @@ +'''MODBUS module for TSUN inverter support + +TSUN uses the MODBUS in the RTU transmission mode over serial line. +see: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf +see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf + +A Modbus PDU consists of: 'Function-Code' + 'Data' +A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16' +The inverter is a MODBUS server and the proxy the MODBUS client. + +The 16-bit CRC is known as CRC-16-ANSI(reverse) +see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks +''' +import struct +import logging +import asyncio +from typing import Generator, Callable + +from infos import Register, Fmt + +logger = logging.getLogger('data') + +CRC_POLY = 0xA001 # (LSBF/reverse) +CRC_INIT = 0xFFFF + + +class Modbus(): + '''Simple MODBUS implementation with TX queue and retransmit timer''' + INV_ADDR = 1 + '''MODBUS server address of the TSUN inverter''' + READ_REGS = 3 + '''MODBUS function code: Read Holding Register''' + READ_INPUTS = 4 + '''MODBUS function code: Read Input Register''' + WRITE_SINGLE_REG = 6 + '''Modbus function code: Write Single Register''' + + __crc_tab = [] + mb_reg_mapping = { + 0x2000: {'reg': Register.BOOT_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x2001: {'reg': Register.DSP_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x2003: {'reg': Register.WORK_MODE, 'fmt': '!H'}, + 0x2006: {'reg': Register.OUTPUT_SHUTDOWN, 'fmt': '!H'}, + 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 + 0x2008: {'reg': Register.RATED_LEVEL, 'fmt': '!H'}, + 0x2009: {'reg': Register.INPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501 + 0x200a: {'reg': Register.GRID_VOLT_CAL_COEF, 'fmt': '!H'}, + 0x2010: {'reg': Register.PROD_COMPL_TYPE, 'fmt': '!H'}, + 0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501 + + 0x3000: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x3001: {'reg': Register.DETECT_STATUS_1, 'fmt': '!H'}, # noqa: E501 + 0x3002: {'reg': Register.DETECT_STATUS_2, 'fmt': '!H'}, # noqa: E501 + 0x3003: {'reg': Register.EVENT_ALARM, 'fmt': '!H'}, # noqa: E501 + 0x3004: {'reg': Register.EVENT_FAULT, 'fmt': '!H'}, # noqa: E501 + 0x3005: {'reg': Register.EVENT_BF1, 'fmt': '!H'}, # noqa: E501 + 0x3006: {'reg': Register.EVENT_BF2, 'fmt': '!H'}, # noqa: E501 + + 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'func': Fmt.version}, # noqa: E501 + 0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x300c: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'offset': -40}, # noqa: E501 + # 0x300d + 0x300e: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 + 0x300f: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3010: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3011: {'reg': Register.PV1_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3012: {'reg': Register.PV1_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3013: {'reg': Register.PV2_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3014: {'reg': Register.PV2_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3015: {'reg': Register.PV2_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3016: {'reg': Register.PV3_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3017: {'reg': Register.PV3_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3018: {'reg': Register.PV3_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3019: {'reg': Register.PV4_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x302a + } + + def __init__(self, snd_handler: Callable[[bytes, int, str], None], + timeout: int = 1): + if not len(self.__crc_tab): + self.__build_crc_tab(CRC_POLY) + self.que = asyncio.Queue(100) + self.snd_handler = snd_handler + '''Send handler to transmit a MODBUS RTU request''' + self.rsp_handler = None + '''Response handler to forward the response''' + self.timeout = timeout + '''MODBUS response timeout in seconds''' + self.max_retries = 1 + '''Max retransmit for MODBUS requests''' + self.retry_cnt = 0 + self.last_req = b'' + self.counter = {} + '''Dictenary with statistic counter''' + self.counter['timeouts'] = 0 + self.counter['retries'] = {} + for i in range(0, self.max_retries+1): + self.counter['retries'][f'{i}'] = 0 + self.last_log_lvl = logging.DEBUG + self.last_addr = 0 + self.last_fcode = 0 + self.last_len = 0 + self.last_reg = 0 + self.err = 0 + self.loop = asyncio.get_event_loop() + self.req_pend = False + self.tim = None + self.node_id = '' + + def close(self): + """free the queue and erase the callback handlers""" + logging.debug('Modbus close:') + self.__stop_timer() + self.rsp_handler = None + self.snd_handler = None + while not self.que.empty(): + self.que.get_nowait() + + def set_node_id(self, node_id: str): + self.node_id = node_id + + def build_msg(self, addr: int, func: int, reg: int, val: int, + log_lvl=logging.DEBUG) -> None: + """Build MODBUS RTU request frame and add it to the tx queue + + Keyword arguments: + addr: RTU server address (inverter) + func: MODBUS function code + reg: 16-bit register number + val: 16 bit value + """ + msg = struct.pack('>BBHH', addr, func, reg, val) + msg += struct.pack(' bool: + """Add the received Modbus RTU request to the tx queue + + Keyword arguments: + buf: Modbus RTU pdu incl ADDR byte and trailing CRC + rsp_handler: Callback, if the received pdu is valid + + Returns: + True: PDU was added to the queue + False: PDU was ignored, due to an error + """ + # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') + if not self.__check_crc(buf): + self.err = 1 + logger.error('Modbus recv: CRC error') + return False + self.que.put_nowait({'req': buf, + 'rsp_hdl': rsp_handler, + 'log_lvl': logging.INFO}) + if self.que.qsize() == 1: + self.__send_next_from_que() + + return True + + def recv_resp(self, info_db, buf: bytes) -> \ + Generator[tuple[str, bool, int | float | str], None, None]: + """Generator which check and parse a received MODBUS response. + + Keyword arguments: + info_db: database for info lockups + buf: received Modbus RTU response frame + + Returns on error and set Self.err to: + 1: CRC error + 2: Wrong server address + 3: Unexpected function code + 4: Unexpected data length + 5: No MODBUS request pending + """ + # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') + + fcode = buf[1] + data_available = self.last_addr == self.INV_ADDR and \ + (fcode == 3 or fcode == 4) + + if self.__resp_error_check(buf, data_available): + return + + if data_available: + elmlen = buf[2] >> 1 + first_reg = self.last_reg # save last_reg before sending next pdu + self.__stop_timer() # stop timer and send next pdu + yield from self.__process_data(info_db, buf, first_reg, elmlen) + else: + self.__stop_timer() + + self.counter['retries'][f'{self.retry_cnt}'] += 1 + if self.rsp_handler: + self.rsp_handler() + self.__send_next_from_que() + + def __resp_error_check(self, buf: bytes, data_available: bool) -> bool: + '''Check the MODBUS response for errors, returns True if one accure''' + if not self.req_pend: + self.err = 5 + return True + if not self.__check_crc(buf): + logger.error(f'[{self.node_id}] Modbus resp: CRC error') + self.err = 1 + return True + if buf[0] != self.last_addr: + logger.info(f'[{self.node_id}] Modbus resp: Wrong addr {buf[0]}') + self.err = 2 + return True + fcode = buf[1] + if fcode != self.last_fcode: + logger.info(f'[{self.node_id}] Modbus: Wrong fcode {fcode}' + f' != {self.last_fcode}') + self.err = 3 + return True + if data_available: + elmlen = buf[2] >> 1 + if elmlen != self.last_len: + logger.info(f'[{self.node_id}] Modbus: len error {elmlen}' + f' != {self.last_len}') + self.err = 4 + return True + + return False + + def __process_data(self, info_db, buf: bytes, first_reg, elmlen): + '''Generator over received registers, updates the db''' + for i in range(0, elmlen): + addr = first_reg+i + if addr in self.mb_reg_mapping: + row = self.mb_reg_mapping[addr] + info_id = row['reg'] + keys, level, unit, must_incr = info_db._key_obj(info_id) + if keys: + result = Fmt.get_value(buf, 3+2*i, row) + name, update = info_db.update_db(keys, must_incr, + result) + yield keys[0], update, result + if update: + info_db.tracer.log(level, + f'[{self.node_id}] MODBUS: {name}' + f' : {result}{unit}') + + ''' + MODBUS response timer + ''' + def __start_timer(self) -> None: + '''Start response timer and set `req_pend` to True''' + self.req_pend = True + self.tim = self.loop.call_later(self.timeout, self.__timeout_cb) + # logging.debug(f'Modbus start timer {self}') + + def __stop_timer(self) -> None: + '''Stop response timer and set `req_pend` to False''' + self.req_pend = False + # logging.debug(f'Modbus stop timer {self}') + if self.tim: + self.tim.cancel() + self.tim = None + + def __timeout_cb(self) -> None: + '''Rsponse timeout handler retransmit pdu or send next pdu''' + self.req_pend = False + + if self.retry_cnt < self.max_retries: + logger.debug(f'Modbus retrans {self}') + self.retry_cnt += 1 + self.__start_timer() + self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans') + else: + logger.info(f'[{self.node_id}] Modbus timeout ' + f'(FCode: {self.last_fcode} ' + f'Reg: 0x{self.last_reg:04x}, ' + f'{self.last_len})') + self.counter['timeouts'] += 1 + self.__send_next_from_que() + + def __send_next_from_que(self) -> None: + '''Get next MODBUS pdu from queue and transmit it''' + if self.req_pend: + return + try: + item = self.que.get_nowait() + req = item['req'] + self.last_req = req + self.rsp_handler = item['rsp_hdl'] + self.last_log_lvl = item['log_lvl'] + self.last_addr = req[0] + self.last_fcode = req[1] + + res = struct.unpack_from('>HH', req, 2) + self.last_reg = res[0] + self.last_len = res[1] + self.retry_cnt = 0 + self.__start_timer() + self.snd_handler(self.last_req, self.last_log_lvl, state='Command') + except asyncio.QueueEmpty: + pass + + ''' + Helper function for CRC-16 handling + ''' + def __check_crc(self, msg: bytes) -> bool: + '''Check CRC-16 and returns True if valid''' + return 0 == self.__calc_crc(msg) + + def __calc_crc(self, buffer: bytes) -> int: + '''Build CRC-16 for buffer and returns it''' + crc = CRC_INIT + + for cur in buffer: + crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF] + return crc + + def __build_crc_tab(self, poly: int) -> None: + '''Build CRC-16 helper table, must be called exactly one time''' + for index in range(256): + data = index << 1 + crc = 0 + for _ in range(8, 0, -1): + data >>= 1 + if (data ^ crc) & 1: + crc = (crc >> 1) ^ poly + else: + crc >>= 1 + self.__crc_tab.append(crc) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/modbus_tcp.py b/ha_addons/ha_addon/rootfs/home/proxy/modbus_tcp.py new file mode 100644 index 0000000..f3788d4 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/modbus_tcp.py @@ -0,0 +1,88 @@ +import logging +import traceback +import asyncio + +from config import Config +from gen3plus.inverter_g3p import InverterG3P +from infos import Infos + +logger = logging.getLogger('conn') + + +class ModbusConn(): + def __init__(self, host, port): + self.host = host + self.port = port + self.addr = (host, port) + self.inverter = None + + async def __aenter__(self) -> 'InverterG3P': + '''Establish a client connection to the TSUN cloud''' + connection = asyncio.open_connection(self.host, self.port) + reader, writer = await connection + self.inverter = InverterG3P(reader, writer, + client_mode=True) + self.inverter.__enter__() + stream = self.inverter.local.stream + logging.info(f'[{stream.node_id}:{stream.conn_no}] ' + f'Connected to {self.addr}') + Infos.inc_counter('Inverter_Cnt') + await self.inverter.local.ifc.publish_outstanding_mqtt() + return self.inverter + + async def __aexit__(self, exc_type, exc, tb): + Infos.dec_counter('Inverter_Cnt') + await self.inverter.local.ifc.publish_outstanding_mqtt() + self.inverter.__exit__(exc_type, exc, tb) + + +class ModbusTcp(): + + def __init__(self, loop, tim_restart=10) -> None: + self.tim_restart = tim_restart + + inverters = Config.get('inverters') + # logging.info(f'Inverters: {inverters}') + + for inv in inverters.values(): + if (type(inv) is dict + and 'monitor_sn' in inv + and 'client_mode' in inv): + client = inv['client_mode'] + # logging.info(f"SerialNo:{inv['monitor_sn']} host:{client['host']} port:{client['port']}") # noqa: E501 + loop.create_task(self.modbus_loop(client['host'], + client['port'], + inv['monitor_sn'], + client['forward'])) + + async def modbus_loop(self, host, port, + snr: int, forward: bool) -> None: + '''Loop for receiving messages from the TSUN cloud (client-side)''' + while True: + try: + async with ModbusConn(host, port) as inverter: + stream = inverter.local.stream + await stream.send_start_cmd(snr, host, forward) + await stream.ifc.loop() + logger.info(f'[{stream.node_id}:{stream.conn_no}] ' + f'Connection closed - Shutdown: ' + f'{stream.shutdown_started}') + if stream.shutdown_started: + return + del inverter # decrease ref counter after the with block + + except (ConnectionRefusedError, TimeoutError) as error: + logging.debug(f'Inv-conn:{error}') + + except OSError as error: + if error.errno == 113: # pragma: no cover + logging.debug(f'os-error:{error}') + else: + logging.info(f'os-error: {error}') + + except Exception: + logging.error( + f"ModbusTcpCreate: Exception for {(host, port)}:\n" + f"{traceback.format_exc()}") + + await asyncio.sleep(self.tim_restart) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/mqtt.py b/ha_addons/ha_addon/rootfs/home/proxy/mqtt.py new file mode 100644 index 0000000..f52b797 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/mqtt.py @@ -0,0 +1,182 @@ +import asyncio +import logging +import aiomqtt +import traceback + +from modbus import Modbus +from messages import Message +from config import Config +from singleton import Singleton + +logger_mqtt = logging.getLogger('mqtt') + + +class Mqtt(metaclass=Singleton): + __client = None + __cb_mqtt_is_up = None + + def __init__(self, cb_mqtt_is_up): + logger_mqtt.debug('MQTT: __init__') + if cb_mqtt_is_up: + self.__cb_mqtt_is_up = cb_mqtt_is_up + loop = asyncio.get_event_loop() + self.task = loop.create_task(self.__loop()) + self.ha_restarts = 0 + + ha = Config.get('ha') + self.ha_status_topic = f"{ha['auto_conf_prefix']}/status" + self.mb_rated_topic = f"{ha['entity_prefix']}/+/rated_load" + self.mb_out_coeff_topic = f"{ha['entity_prefix']}/+/out_coeff" + self.mb_reads_topic = f"{ha['entity_prefix']}/+/modbus_read_regs" + self.mb_inputs_topic = f"{ha['entity_prefix']}/+/modbus_read_inputs" + self.mb_at_cmd_topic = f"{ha['entity_prefix']}/+/at_cmd" + + @property + def ha_restarts(self): + return self._ha_restarts + + @ha_restarts.setter + def ha_restarts(self, value): + self._ha_restarts = value + + async def close(self) -> None: + logger_mqtt.debug('MQTT: close') + self.task.cancel() + try: + await self.task + + except (asyncio.CancelledError, Exception) as e: + logging.debug(f"Mqtt.close: exception: {e} ...") + + async def publish(self, topic: str, payload: str | bytes | bytearray + | int | float | None = None) -> None: + if self.__client: + await self.__client.publish(topic, payload) + + async def __loop(self) -> None: + mqtt = Config.get('mqtt') + logger_mqtt.info(f'start MQTT: host:{mqtt["host"]} port:' + f'{mqtt["port"]} ' + f'user:{mqtt["user"]}') + self.__client = aiomqtt.Client(hostname=mqtt['host'], + port=mqtt['port'], + username=mqtt['user'], + password=mqtt['passwd']) + + interval = 5 # Seconds + + while True: + try: + async with self.__client: + logger_mqtt.info('MQTT broker connection established') + + if self.__cb_mqtt_is_up: + await self.__cb_mqtt_is_up() + + await self.__client.subscribe(self.ha_status_topic) + await self.__client.subscribe(self.mb_rated_topic) + await self.__client.subscribe(self.mb_out_coeff_topic) + await self.__client.subscribe(self.mb_reads_topic) + await self.__client.subscribe(self.mb_inputs_topic) + await self.__client.subscribe(self.mb_at_cmd_topic) + + async for message in self.__client.messages: + await self.dispatch_msg(message) + + except aiomqtt.MqttError: + if Config.is_default('mqtt'): + logger_mqtt.info( + "MQTT is unconfigured; Check your config.toml!") + interval = 30 + else: + interval = 5 # Seconds + logger_mqtt.info( + f"Connection lost; Reconnecting in {interval}" + " seconds ...") + + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger_mqtt.debug("MQTT task cancelled") + self.__client = None + return + except Exception: + # self.inc_counter('SW_Exception') # fixme + logger_mqtt.error( + f"Exception:\n" + f"{traceback.format_exc()}") + + async def dispatch_msg(self, message): + if message.topic.matches(self.ha_status_topic): + status = message.payload.decode("UTF-8") + logger_mqtt.info('Home-Assistant Status:' + f' {status}') + if status == 'online': + self.ha_restarts += 1 + await self.__cb_mqtt_is_up() + + if message.topic.matches(self.mb_rated_topic): + await self.modbus_cmd(message, + Modbus.WRITE_SINGLE_REG, + 1, 0x2008) + + if message.topic.matches(self.mb_out_coeff_topic): + payload = message.payload.decode("UTF-8") + try: + val = round(float(payload) * 1024/100) + if val < 0 or val > 1024: + logger_mqtt.error('out_coeff: value must be in' + 'the range 0..100,' + f' got: {payload}') + else: + await self.modbus_cmd(message, + Modbus.WRITE_SINGLE_REG, + 0, 0x202c, val) + except Exception: + pass + + if message.topic.matches(self.mb_reads_topic): + await self.modbus_cmd(message, + Modbus.READ_REGS, 2) + + if message.topic.matches(self.mb_inputs_topic): + await self.modbus_cmd(message, + Modbus.READ_INPUTS, 2) + + if message.topic.matches(self.mb_at_cmd_topic): + await self.at_cmd(message) + + def each_inverter(self, message, func_name: str): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + for m in Message: + if m.server_side and (m.node_id == node_id): + logger_mqtt.debug(f'Found: {node_id}') + fnc = getattr(m, func_name, None) + if callable(fnc): + yield fnc + else: + logger_mqtt.warning(f'Cmd not supported by: {node_id}') + break + + else: + logger_mqtt.warning(f'Node_id: {node_id} not found') + + async def modbus_cmd(self, message, func, params=0, addr=0, val=0): + payload = message.payload.decode("UTF-8") + for fnc in self.each_inverter(message, "send_modbus_cmd"): + res = payload.split(',') + if params > 0 and params != len(res): + logger_mqtt.error(f'Parameter expected: {params}, ' + f'got: {len(res)}') + return + if params == 1: + val = int(payload) + elif params == 2: + addr = int(res[0], base=16) + val = int(res[1]) # lenght + await fnc(func, addr, val, logging.INFO) + + async def at_cmd(self, message): + payload = message.payload.decode("UTF-8") + for fnc in self.each_inverter(message, "send_at_cmd"): + await fnc(payload) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/my_timer.py b/ha_addons/ha_addon/rootfs/home/proxy/my_timer.py new file mode 100644 index 0000000..46435bd --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/my_timer.py @@ -0,0 +1,35 @@ +import asyncio +import logging +from itertools import count + + +class Timer: + def __init__(self, cb, id_str: str = ''): + self.__timeout_cb = cb + self.loop = asyncio.get_event_loop() + self.tim = None + self.id_str = id_str + self.exp_count = count(0) + + def start(self, timeout: float) -> None: + '''Start timer with timeout seconds''' + if self.tim: + self.tim.cancel() + self.tim = self.loop.call_later(timeout, self.__timeout) + logging.debug(f'[{self.id_str}]Start timer') + + def stop(self) -> None: + '''Stop timer''' + logging.debug(f'[{self.id_str}]Stop timer') + if self.tim: + self.tim.cancel() + self.tim = None + + def __timeout(self) -> None: + '''timer expired handler''' + logging.debug(f'[{self.id_str}]Timer expired') + self.__timeout_cb(next(self.exp_count)) + + def close(self) -> None: + self.stop() + self.__timeout_cb = None diff --git a/ha_addons/ha_addon/rootfs/home/proxy/protocol_ifc.py b/ha_addons/ha_addon/rootfs/home/proxy/protocol_ifc.py new file mode 100644 index 0000000..3b6c886 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/protocol_ifc.py @@ -0,0 +1,17 @@ +from abc import abstractmethod + +from async_ifc import AsyncIfc +from iter_registry import AbstractIterMeta + + +class ProtocolIfc(metaclass=AbstractIterMeta): + _registry = [] + + @abstractmethod + def __init__(self, addr, ifc: "AsyncIfc", server_side: bool, + client_mode: bool = False, id_str=b''): + pass # pragma: no cover + + @abstractmethod + def close(self): + pass # pragma: no cover diff --git a/ha_addons/ha_addon/rootfs/home/proxy/proxy.py b/ha_addons/ha_addon/rootfs/home/proxy/proxy.py new file mode 100644 index 0000000..eadc3ac --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/proxy.py @@ -0,0 +1,101 @@ +import asyncio +import logging +import json + +from config import Config +from mqtt import Mqtt +from infos import Infos + +logger_mqtt = logging.getLogger('mqtt') + + +class Proxy(): + '''class Proxy is a baseclass + + The class has some class method for managing common resources like a + connection to the MQTT broker or proxy error counter which are common + for all inverter connection + + Instances of the class are connections to an inverter and can have an + optional link to an remote connection to the TSUN cloud. A remote + connection dies with the inverter connection. + + class methods: + class_init(): initialize the common resources of the proxy (MQTT + broker, Proxy DB, etc). Must be called before the + first inverter instance can be created + class_close(): release the common resources of the proxy. Should not + be called before any instances of the class are + destroyed + + methods: + create_remote(): Establish a client connection to the TSUN cloud + async_publ_mqtt(): Publish data to MQTT broker + ''' + @classmethod + def class_init(cls) -> None: + logging.debug('Proxy.class_init') + # initialize the proxy statistics + Infos.static_init() + cls.db_stat = Infos() + + ha = Config.get('ha') + cls.entity_prfx = ha['entity_prefix'] + '/' + cls.discovery_prfx = ha['discovery_prefix'] + '/' + cls.proxy_node_id = ha['proxy_node_id'] + '/' + cls.proxy_unique_id = ha['proxy_unique_id'] + + # call Mqtt singleton to establisch the connection to the mqtt broker + cls.mqtt = Mqtt(cls._cb_mqtt_is_up) + + # register all counters which should be reset at midnight. + # This is needed if the proxy is restated before midnight + # and the inverters are offline, cause the normal refgistering + # needs an update on the counters. + # Without this registration here the counters would not be + # reset at midnight when you restart the proxy just before + # midnight! + inverters = Config.get('inverters') + # logger.debug(f'Proxys: {inverters}') + for inv in inverters.values(): + if (type(inv) is dict): + node_id = inv['node_id'] + cls.db_stat.reg_clr_at_midnight(f'{cls.entity_prfx}{node_id}', + check_dependencies=False) + + @classmethod + async def _cb_mqtt_is_up(cls) -> None: + logging.info('Initialize proxy device on home assistant') + # register proxy status counters at home assistant + await cls._register_proxy_stat_home_assistant() + + # send values of the proxy status counters + await asyncio.sleep(0.5) # wait a bit, before sending data + Infos.new_stat_data['proxy'] = True # force sending data to sync ha + await cls._async_publ_mqtt_proxy_stat('proxy') + + @classmethod + async def _register_proxy_stat_home_assistant(cls) -> None: + '''register all our topics at home assistant''' + for data_json, component, node_id, id in cls.db_stat.ha_proxy_confs( + cls.entity_prfx, cls.proxy_node_id, cls.proxy_unique_id): + logger_mqtt.debug(f"MQTT Register: cmp:'{component}' node_id:'{node_id}' {data_json}") # noqa: E501 + await cls.mqtt.publish(f'{cls.discovery_prfx}{component}/{node_id}{id}/config', data_json) # noqa: E501 + + @classmethod + async def _async_publ_mqtt_proxy_stat(cls, key) -> None: + stat = Infos.stat + if key in stat and Infos.new_stat_data[key]: + data_json = json.dumps(stat[key]) + node_id = cls.proxy_node_id + logger_mqtt.debug(f'{key}: {data_json}') + await cls.mqtt.publish(f"{cls.entity_prfx}{node_id}{key}", + data_json) + Infos.new_stat_data[key] = False + + @classmethod + def class_close(cls, loop) -> None: # pragma: no cover + logging.debug('Proxy.class_close') + logging.info('Close MQTT Task') + loop.run_until_complete(cls.mqtt.close()) + cls.mqtt = None diff --git a/ha_addons/ha_addon/rootfs/home/proxy/scheduler.py b/ha_addons/ha_addon/rootfs/home/proxy/scheduler.py new file mode 100644 index 0000000..3c1d25a --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/scheduler.py @@ -0,0 +1,30 @@ +import logging +import json +from mqtt import Mqtt +from aiocron import crontab +from infos import ClrAtMidnight + +logger_mqtt = logging.getLogger('mqtt') + + +class Schedule: + mqtt = None + count = 0 + + @classmethod + def start(cls) -> None: + '''Start the scheduler and schedule the tasks (cron jobs)''' + logging.debug("Scheduler init") + cls.mqtt = Mqtt(None) + + crontab('0 0 * * *', func=cls.atmidnight, start=True) + + @classmethod + async def atmidnight(cls) -> None: + '''Clear daily counters at midnight''' + logging.info("Clear daily counters at midnight") + + for key, data in ClrAtMidnight.elm(): + logger_mqtt.debug(f'{key}: {data}') + data_json = json.dumps(data) + await cls.mqtt.publish(f"{key}", data_json) diff --git a/ha_addons/ha_addon/rootfs/home/proxy/server.py b/ha_addons/ha_addon/rootfs/home/proxy/server.py new file mode 100644 index 0000000..cda8501 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/server.py @@ -0,0 +1,191 @@ +import logging +import asyncio +import signal +import os +from asyncio import StreamReader, StreamWriter +from aiohttp import web +from logging import config # noqa F401 +from proxy import Proxy +from inverter_ifc import InverterIfc +from gen3.inverter_g3 import InverterG3 +from gen3plus.inverter_g3p import InverterG3P +from scheduler import Schedule +from config import Config +from modbus_tcp import ModbusTcp + +routes = web.RouteTableDef() +proxy_is_up = False + + +@routes.get('/') +async def hello(request): + return web.Response(text="Hello, world") + + +@routes.get('/-/ready') +async def ready(request): + if proxy_is_up: + status = 200 + text = 'Is ready' + else: + status = 503 + text = 'Not ready' + return web.Response(status=status, text=text) + + +@routes.get('/-/healthy') +async def healthy(request): + + if proxy_is_up: + # logging.info('web reqeust healthy()') + for inverter in InverterIfc: + try: + res = inverter.healthy() + if not res: + return web.Response(status=503, text="I have a problem") + except Exception as err: + logging.info(f'Exception:{err}') + + return web.Response(status=200, text="I'm fine") + + +async def webserver(addr, port): + '''coro running our webserver''' + app = web.Application() + app.add_routes(routes) + runner = web.AppRunner(app) + + await runner.setup() + site = web.TCPSite(runner, addr, port) + await site.start() + logging.info(f'HTTP server listen on port: {port}') + + try: + # Normal interaction with aiohttp + while True: + await asyncio.sleep(3600) # sleep forever + except asyncio.CancelledError: + logging.info('HTTP server cancelled') + await runner.cleanup() + logging.debug('HTTP cleanup done') + + +async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class): + '''Handles a new incoming connection and starts an async loop''' + + with inv_class(reader, writer) as inv: + await inv.local.ifc.server_loop() + + +async def handle_shutdown(web_task): + '''Close all TCP connections and stop the event loop''' + + logging.info('Shutdown due to SIGTERM') + global proxy_is_up + proxy_is_up = False + + # + # first, disc all open TCP connections gracefully + # + for inverter in InverterIfc: + await inverter.disc(True) + + logging.info('Proxy disconnecting done') + + # + # second, cancel the web server + # + web_task.cancel() + await web_task + + # + # now cancel all remaining (pending) tasks + # + pending = asyncio.all_tasks() + for task in pending: + task.cancel() + + # + # at last, start a coro for stopping the loop + # + logging.debug("Stop event loop") + loop.stop() + + +def get_log_level() -> int: + '''checks if LOG_LVL is set in the environment and returns the + corresponding logging.LOG_LEVEL''' + log_level = os.getenv('LOG_LVL', 'INFO') + if log_level == 'DEBUG': + log_level = logging.DEBUG + elif log_level == 'WARN': + log_level = logging.WARNING + else: + log_level = logging.INFO + return log_level + + +if __name__ == "__main__": + # + # Setup our daily, rotating logger + # + serv_name = os.getenv('SERVICE_NAME', 'proxy') + version = os.getenv('VERSION', 'unknown') + + logging.config.fileConfig('logging.ini') + logging.info(f'Server "{serv_name} - {version}" will be started') + + # set lowest-severity for 'root', 'msg', 'conn' and 'data' logger + log_level = get_log_level() + logging.getLogger().setLevel(log_level) + logging.getLogger('msg').setLevel(log_level) + logging.getLogger('conn').setLevel(log_level) + logging.getLogger('data').setLevel(log_level) + logging.getLogger('tracer').setLevel(log_level) + logging.getLogger('asyncio').setLevel(log_level) + # logging.getLogger('mqtt').setLevel(log_level) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # read config file + ConfigErr = Config.class_init() + if ConfigErr is not None: + logging.info(f'ConfigErr: {ConfigErr}') + Proxy.class_init() + Schedule.start() + ModbusTcp(loop) + + # + # Create tasks for our listening servers. These must be tasks! If we call + # start_server directly out of our main task, the eventloop will be blocked + # and we can't receive and handle the UNIX signals! + # + for inv_class, port in [(InverterG3, 5005), (InverterG3P, 10000)]: + loop.create_task(asyncio.start_server(lambda r, w, i=inv_class: + handle_client(r, w, i), + '0.0.0.0', port)) + web_task = loop.create_task(webserver('0.0.0.0', 8127)) + + # + # Register some UNIX Signal handler for a gracefully server shutdown + # on Docker restart and stop + # + for signame in ('SIGINT', 'SIGTERM'): + loop.add_signal_handler(getattr(signal, signame), + lambda loop=loop: asyncio.create_task( + handle_shutdown(web_task))) + + loop.set_debug(log_level == logging.DEBUG) + try: + if ConfigErr is None: + proxy_is_up = True + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + logging.info("Event loop is stopped") + Proxy.class_close(loop) + logging.debug('Close event loop') + loop.close() + logging.info(f'Finally, exit Server "{serv_name}"') diff --git a/ha_addons/ha_addon/rootfs/home/proxy/singleton.py b/ha_addons/ha_addon/rootfs/home/proxy/singleton.py new file mode 100644 index 0000000..8222146 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/home/proxy/singleton.py @@ -0,0 +1,14 @@ +from weakref import WeakValueDictionary + + +class Singleton(type): + _instances = WeakValueDictionary() + + def __call__(cls, *args, **kwargs): + # logger_mqtt.debug('singleton: __call__') + if cls not in cls._instances: + instance = super(Singleton, + cls).__call__(*args, **kwargs) + cls._instances[cls] = instance + + return cls._instances[cls] diff --git a/ha_addons/ha_addon/rootfs/requirements.txt b/ha_addons/ha_addon/rootfs/requirements.txt new file mode 100644 index 0000000..1fb1c53 --- /dev/null +++ b/ha_addons/ha_addon/rootfs/requirements.txt @@ -0,0 +1,4 @@ + aiomqtt==2.3.0 + schema==0.7.7 + aiocron==1.8 + aiohttp==3.10.11 \ No newline at end of file