diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index af96376..8f4f09a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,7 +5,7 @@ name: Python application on: push: - branches: [ "main" ] + branches: [ "main", "dev-*" ] paths-ignore: - '**.md' # Do no build on *.md changes - '**.yml' # Do no build on *.yml changes diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ebeb6..100edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Prepare support of inverters with 6 MTPPs +- Clear `Daily Generation` values at midnigth +- Read pv module details from config file and use it for the Home Assistant registration + see: [#43](https://github.com/s-allius/tsun-gen3-proxy/issues/43) +- migrate to aiomqtt version 2.0.0 + ## [0.6.0] - 2024-04-02 - Refactoring to support Solarman V5 protocol diff --git a/README.md b/README.md index fe0a7a3..3552aec 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@

License: BSD-3-Clause Supported Python versions - Supported aiomqtt versions + Supported aiomqtt versions + Supported aiocron versions Supported toml versions

@@ -43,7 +44,7 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. - supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300 - `MQTT` support - `Home-Assistant` auto-discovery support -- Self-sufficient island operation without internet (for TSUN GEN3 PLUS inverters in preparation) +- Self-sufficient island operation without internet - runs in a non-root Docker Container ## Home Assistant Screenshots @@ -127,15 +128,23 @@ inverters.allow_all = false # True: allow inverters, even if we have no invert [inverters."R17xxxxxxxxxxxx1"] node_id = 'inv1' # Optional, MQTT replacement for inverters serial number suggested_area = 'roof' # Optional, suggested installation area for home-assistant +pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr +pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr [inverters."R17xxxxxxxxxxxx2"] node_id = 'inv2' # Optional, MQTT replacement for inverters serial number suggested_area = 'balcony' # Optional, suggested installation area for home-assistant +pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr +pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr [inverters."Y17xxxxxxxxxxxx1"] monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter node_id = 'inv_3' # MQTT replacement for inverters serial number suggested_area = 'garage' # suggested installation place for home-assistant +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 ``` diff --git a/app/build.sh b/app/build.sh index 25c5547..bb89cfa 100755 --- a/app/build.sh +++ b/app/build.sh @@ -11,6 +11,7 @@ set -e BUILD_DATE=$(date -Iminutes) +BRANCH=$(git rev-parse --abbrev-ref HEAD) VERSION=$(git describe --tags --abbrev=0) VERSION="${VERSION:1}" arr=(${VERSION//./ }) @@ -19,7 +20,7 @@ IMAGE=tsun-gen3-proxy if [[ $1 == dev ]] || [[ $1 == rc ]] ;then IMAGE=docker.io/sallius/${IMAGE} -VERSION=${VERSION}-$1 +VERSION=${VERSION}-$1-${BRANCH} elif [[ $1 == rel ]];then IMAGE=ghcr.io/s-allius/${IMAGE} else diff --git a/app/config/default_config.toml b/app/config/default_config.toml index fccc54b..cd95d75 100644 --- a/app/config/default_config.toml +++ b/app/config/default_config.toml @@ -31,13 +31,21 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp [inverters."R170000000000001"] #node_id = '' # Optional, MQTT replacement for inverters serial number #suggested_area = '' # Optional, suggested installation area for home-assistant +#pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr +#pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr #[inverters."R17xxxxxxxxxxxx2"] #node_id = '' # Optional, MQTT replacement for inverters serial number #suggested_area = '' # Optional, suggested installation area for home-assistant +#pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr +#pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr [inverters."Y170000000000001"] -#monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter +monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter #node_id = '' # Optional, MQTT replacement for inverters serial number #suggested_area = '' # Optional, suggested installation place for home-assistant +#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 diff --git a/app/proxy.svg b/app/proxy.svg index 5d2d1d7..588835e 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -4,275 +4,340 @@ - - + + G - + A0 - - - -You can stick notes -on diagrams too! + + + +You can stick notes +on diagrams too! A1 - -Singleton + +Singleton A2 - -Mqtt - -<static>ha_restarts -<static>__client -<static>__cb_MqttIsUp - -<async>publish() -<async>close() + +Mqtt + +<static>ha_restarts +<static>__client +<static>__cb_MqttIsUp + +<async>publish() +<async>close() A1->A2 - - + + A10 - -Inverter - -cls.db_stat -cls.entity_prfx -cls.discovery_prfx -cls.proxy_node_id -cls.proxy_unique_id -cls.mqtt:Mqtt - + +Inverter + +cls.db_stat +cls.entity_prfx +cls.discovery_prfx +cls.proxy_node_id +cls.proxy_unique_id +cls.mqtt:Mqtt + A2->A10 - + A3 - -IterRegistry - - -__iter__ + +IterRegistry + + +__iter__ A4 - -Message - -server_side:bool -header_valid:bool -header_len:unsigned -data_len:unsigned -unique_id -node_id -sug_area -_recv_buffer:bytearray -_send_buffer:bytearray -_forward_buffer:bytearray -db:Infos -new_data:list - -_read():void<abstract> -close():void -inc_counter():void -dec_counter():void + +Message + +server_side:bool +header_valid:bool +header_len:unsigned +data_len:unsigned +unique_id +node_id +sug_area +_recv_buffer:bytearray +_send_buffer:bytearray +_forward_buffer:bytearray +db:Infos +new_data:list + +_read():void<abstract> +close():void +inc_counter():void +dec_counter():void A3->A4 - - + + A5 - -Talent - -await_conn_resp_cnt -id_str -contact_name -contact_mail -switch - -msg_contact_info() -msg_ota_update() -msg_get_time() -msg_collector_data() -msg_inverter_data() -msg_unknown() -close() + +Talent + +await_conn_resp_cnt +id_str +contact_name +contact_mail +switch + +msg_contact_info() +msg_ota_update() +msg_get_time() +msg_collector_data() +msg_inverter_data() +msg_unknown() +close() A4->A5 - - + + A6 - -SolarmanV5 - -control -serial -snr -switch - -msg_unknown() -close() + +SolarmanV5 + +control +serial +snr +switch + +msg_unknown() +close() A4->A6 - - + + A7 - -ConnectionG3 - -remoteStream:ConnectionG3 - -close() + +ConnectionG3 + +remoteStream:ConnectionG3 + +close() A5->A7 - - + + A8 - -ConnectionG3P - -remoteStream:ConnectionG3P - -close() + +ConnectionG3P + +remoteStream:ConnectionG3P + +close() A6->A8 - - + + A7->A7 - - -0..1 -has + + +0..1 +has A11 - -InverterG3 - -__ha_restarts - -async_create_remote() -close() + +InverterG3 + +__ha_restarts + +async_create_remote() +close() A7->A11 - - + + A8->A8 - - -0..1 -has + + +0..1 +has A12 - -InverterG3P - -__ha_restarts - -async_create_remote() -close() + +InverterG3P + +__ha_restarts + +async_create_remote() +close() A8->A12 - - + + A9 - -AsyncStream - -reader -writer -addr -r_addr -l_addr - -<async>server_loop() -<async>client_loop() -<async>loop -disc() -close() -__async_read() -__async_write() -__async_forward() + +AsyncStream + +reader +writer +addr +r_addr +l_addr + +<async>server_loop() +<async>client_loop() +<async>loop +disc() +close() +__async_read() +__async_write() +__async_forward() A9->A7 - - + + A9->A8 - - + + A10->A11 - - + + A10->A12 - - + + + + + +A13 + +Infos + +stat +new_stat_data +info_dev + +static_init() +dev_value() +inc_counter() +dec_counter() +ha_proxy_conf +ha_conf +update_db +set_db_def_value +get_db_value +ignore_this_device + + + +A14 + +InfosG3 + + +ha_confs() +parse() + + + +A13->A14 + + + + + +A15 + +InfosG3P + + +ha_confs() +parse() + + + +A13->A15 + + + + + +A14->A5 + + + + + +A15->A6 + + diff --git a/app/proxy.yuml b/app/proxy.yuml index a7c47c9..7f5be21 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -19,3 +19,8 @@ [ConnectionG3]has-0..1>[ConnectionG3] [ConnectionG3P]^[InverterG3P] [ConnectionG3P]has-0..1>[ConnectionG3P] +[Infos|stat;new_stat_data;info_dev|static_init();dev_value();inc_counter();dec_counter();ha_proxy_conf;ha_conf;update_db;set_db_def_value;get_db_value;ignore_this_device]^[InfosG3||ha_confs();parse()] +[Infos]^[InfosG3P||ha_confs();parse()] +[InfosG3P]->[SolarmanV5] +[InfosG3]->[Talent] + diff --git a/app/requirements.txt b/app/requirements.txt index 5d62110..7558187 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,2 +1,3 @@ - aiomqtt==1.2.1 - schema==0.7.5 \ No newline at end of file + aiomqtt==2.0.0 + schema==0.7.5 + aiocron==1.8 \ No newline at end of file diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 98b72a6..6c1136c 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -117,6 +117,7 @@ class AsyncStream(): await self.remoteStream.__async_write() if self.remoteStream: + self.remoteStream._update_header(self._forward_buffer) hex_dump_memory(logging.INFO, f'Forward to {self.remoteStream.addr}:', self._forward_buffer, diff --git a/app/src/config.py b/app/src/config.py index 3778e09..6218e65 100644 --- a/app/src/config.py +++ b/app/src/config.py @@ -13,6 +13,7 @@ class Config(): Get named parts of the config with get()''' config = {} + def_config = {} conf_schema = Schema({ 'tsun': { 'enabled': Use(bool), @@ -45,7 +46,31 @@ class Config(): if len(s) > 0 and s[-1] != '/' else s)), - Optional('suggested_area', default=""): Use(str) + Optional('suggested_area', default=""): Use(str), + 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 ) @@ -69,8 +94,16 @@ class Config(): # overwrite the default values, with values from # the config.toml file - with open("config/config.toml", "rb") as f: - usr_config = tomllib.load(f) + try: + with open("config/config.toml", "rb") as f: + usr_config = tomllib.load(f) + except Exception as error: + logging.error(f'Config.read: {error}') + 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') + usr_config = def_config config['tsun'] = def_config['tsun'] | usr_config['tsun'] config['solarman'] = def_config['solarman'] | \ @@ -81,6 +114,7 @@ class Config(): usr_config['inverters'] cls.config = cls.conf_schema.validate(config) + cls.def_config = cls.conf_schema.validate(def_config) # logging.debug(f'Readed config: "{cls.config}" ') except Exception as error: @@ -96,3 +130,9 @@ class Config(): return cls.config.get(member, {}) else: return cls.config + + @classmethod + def is_default(cls, member: str) -> bool: + '''Check if the member is the default value''' + + return cls.config.get(member) == cls.def_config.get(member) diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py index 726f289..7e45634 100644 --- a/app/src/gen3/infos_g3.py +++ b/app/src/gen3/infos_g3.py @@ -161,7 +161,7 @@ class InfosG3(Infos): update = False name = str(f'info-id.0x{addr:x}') - self.tracer.log(level, f'{name} : {result}{unit}' + self.tracer.log(level, f'GEN3: {name} : {result}{unit}' f' update: {update}') i += 1 diff --git a/app/src/gen3/inverter_g3.py b/app/src/gen3/inverter_g3.py index e9db0f4..f9a737e 100644 --- a/app/src/gen3/inverter_g3.py +++ b/app/src/gen3/inverter_g3.py @@ -116,6 +116,8 @@ class InverterG3(Inverter, ConnectionG3): await self.mqtt.publish(f"{self.discovery_prfx}{component}" f"/{node_id}{id}/config", data_json) + self.db.reg_clr_at_midnight(f'{self.entity_prfx}{node_id}') + def close(self) -> None: logging.debug(f'InverterG3.close() l{self.l_addr} | r{self.r_addr}') super().close() # call close handler in the parent class diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 0bc08a8..46302ac 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -59,7 +59,7 @@ class Talent(Message): # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() - def set_serial_no(self, serial_no: str): + def __set_serial_no(self, serial_no: str): if self.unique_id == serial_no: logger.debug(f'SerialNo: {serial_no}') @@ -72,6 +72,7 @@ class Talent(Message): self.node_id = inv['node_id'] self.sug_area = inv['suggested_area'] logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 + self.db.set_pv_module_details(inv) else: self.node_id = '' self.sug_area = '' @@ -95,7 +96,7 @@ class Talent(Message): hex_dump_memory(logging.INFO, f'Received from {self.addr}:', self._recv_buffer, self.header_len+self.data_len) - self.set_serial_no(self.id_str.decode("utf-8")) + self.__set_serial_no(self.id_str.decode("utf-8")) self.__dispatch_msg() self.__flush_recv_msg() return diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index 355600c..c92bdb2 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -117,5 +117,5 @@ class InfosG3P(Infos): name = str(f'info-id.0x{addr:x}') update = False - self.tracer.log(level, f'{name} : {result}{unit}' + self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}' f' update: {update}') diff --git a/app/src/gen3plus/inverter_g3p.py b/app/src/gen3plus/inverter_g3p.py index 70e8d5c..75f69af 100644 --- a/app/src/gen3plus/inverter_g3p.py +++ b/app/src/gen3plus/inverter_g3p.py @@ -116,6 +116,8 @@ class InverterG3P(Inverter, ConnectionG3P): await self.mqtt.publish(f"{self.discovery_prfx}{component}" f"/{node_id}{id}/config", data_json) + self.db.reg_clr_at_midnight(f'{self.entity_prfx}{node_id}') + def close(self) -> None: logging.debug(f'InverterG3P.close() l{self.l_addr} | r{self.r_addr}') super().close() # call close handler in the parent class diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index c09512b..5c8e588 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -1,7 +1,7 @@ import struct # import json import logging -# import time +import time from datetime import datetime if __name__ == "app.src.gen3plus.solarman_v5": @@ -19,6 +19,32 @@ else: # pragma: no cover logger = logging.getLogger('msg') +class Sequence(): + def __init__(self, server_side: bool): + self.rcv_idx = 0 + self.snd_idx = 0 + self.server_side = server_side + + def set_recv(self, val: int): + if self.server_side: + self.rcv_idx = val >> 8 + self.snd_idx = val & 0xff + else: + self.rcv_idx = val & 0xff + self.snd_idx = val >> 8 + + def get_send(self): + self.snd_idx += 1 + self.snd_idx &= 0xff + if self.server_side: + return (self.rcv_idx << 8) | self.snd_idx + else: + return (self.snd_idx << 8) | self.rcv_idx + + def __str__(self): + return f'{self.rcv_idx:02x}:{self.snd_idx:02x}' + + class SolarmanV5(Message): def __init__(self, server_side: bool): @@ -26,16 +52,16 @@ class SolarmanV5(Message): self.header_len = 11 # overwrite construcor in class Message self.control = 0 - self.serial = 0 + self.seq = Sequence(server_side) self.snr = 0 self.db = InfosG3P() self.switch = { 0x4210: self.msg_data_ind, # real time data - 0x1210: self.msg_data_rsp, # at least every 5 minutes + 0x1210: self.msg_response, # at least every 5 minutes 0x4710: self.msg_hbeat_ind, # heatbeat - 0x1710: self.msg_hbeat_rsp, # every 2 minutes + 0x1710: self.msg_response, # every 2 minutes # every 3 hours comes a sync seuqence: # 00:00:00 0x4110 device data ftype: 0x02 @@ -46,18 +72,18 @@ class SolarmanV5(Message): # 00:00:07 0x4310 wifi data ftype: 0x01 sub-id 0x0018: 0c # noqa: E501 # 00:00:08 0x4810 options? ftype: 0x01 - 0x4110: self.msg_dev_ind, # device data, sync start - 0x1110: self.msg_dev_rsp, # every 3 hours + 0x4110: self.msg_dev_ind, # device data, sync start + 0x1110: self.msg_response, # every 3 hours - 0x4310: self.msg_forward, # regulary after 3-6 hours - 0x1310: self.msg_forward, - 0x4810: self.msg_forward, # sync end - 0x1810: self.msg_forward, + 0x4310: self.msg_sync_start, # regulary after 3-6 hours + 0x1310: self.msg_response, + 0x4810: self.msg_sync_end, # sync end + 0x1810: self.msg_response, # - # AT cmd - 0x4510: self.at_command_ind, # from server - 0x1510: self.msg_forward, # from inverter + # MODbus or AT cmd + 0x4510: self.msg_command_req, # from server + 0x1510: self.msg_response, # from inverter } ''' @@ -70,7 +96,7 @@ class SolarmanV5(Message): # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() - def set_serial_no(self, snr: int): + def __set_serial_no(self, snr: int): serial_no = str(snr) if self.unique_id == serial_no: logger.debug(f'SerialNo: {serial_no}') @@ -87,6 +113,7 @@ class SolarmanV5(Message): self.node_id = inv['node_id'] self.sug_area = inv['suggested_area'] logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 + self.db.set_pv_module_details(inv) if not found: self.node_id = '' @@ -112,7 +139,7 @@ class SolarmanV5(Message): self._recv_buffer, self.header_len+self.data_len+2) if self.__trailer_is_ok(self._recv_buffer, self.header_len + self.data_len + 2): - self.set_serial_no(self.snr) + self.__set_serial_no(self.snr) self.__dispatch_msg() self.__flush_recv_msg() return @@ -159,6 +186,13 @@ class SolarmanV5(Message): type += 'S' return switch.get(type, '???') + def _timestamp(self): # pragma: no cover + # utc as epoche + return int(time.time()) + + def _heartbeat(self) -> int: + return 60 + def __parse_header(self, buf: bytes, buf_len: int) -> None: if (buf_len < self.header_len): # enough bytes for complete header? @@ -167,10 +201,10 @@ class SolarmanV5(Message): result = struct.unpack_from(' None: + '''build header for new transmit message''' + self.send_msg_ofs = len(self._send_buffer) + + self._send_buffer += struct.pack( + ' None: + '''finish the transmit message, set lenght and checksum''' + _len = len(self._send_buffer) - self.send_msg_ofs + struct.pack_into(' None: fnc = self.switch.get(self.control, self.msg_unknown) if self.unique_id: @@ -219,87 +284,22 @@ class SolarmanV5(Message): self._recv_buffer = self._recv_buffer[(self.header_len + self.data_len+2):] self.header_valid = False - ''' - def modbus(self, data): - POLY = 0xA001 - crc = 0xFFFF - for byte in data: - crc ^= byte - for _ in range(8): - crc = ((crc >> 1) ^ POLY - if (crc & 0x0001) - else crc >> 1) - return crc + def __send_ack_rsp(self, msgtype, ftype, ack=1): + self.__build_header(msgtype) + self._send_buffer += struct.pack('>= 1 - calc_crc ^= 0xA001 # bitwise 'or' with modbus magic - # number (0xa001 == bitwise - # reverse of 0x8005) - else: - calc_crc >>= 1 + def send_at_cmd(self, AT_cmd: str) -> None: + self.__build_header(0x4510) + self._send_buffer += struct.pack(f' None: + if reg not in cls.__clr_at_midnight: + return + + prfx += f'{keys[0]}' + dict = cls.db + if prfx not in dict: + dict[prfx] = {} + dict = dict[prfx] + + for key in keys[1:-1]: + if key not in dict: + dict[key] = {} + dict = dict[key] + 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: stat = {} app_name = os.getenv('SERVICE_NAME', 'proxy') @@ -125,10 +165,12 @@ class Infos: '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}, # noqa: E501 'inverter': {'via': 'controller', 'name': 'Micro Inverter', 'mdl': Register.EQUIPMENT_MODEL, 'mf': Register.MANUFACTURER, 'sw': Register.VERSION}, # noqa: E501 - 'input_pv1': {'via': 'inverter', 'name': 'Module PV1'}, - 'input_pv2': {'via': 'inverter', 'name': 'Module PV2', 'dep': {'reg': Register.NO_INPUTS, 'gte': 2}}, # noqa: E501 - 'input_pv3': {'via': 'inverter', 'name': 'Module PV3', 'dep': {'reg': Register.NO_INPUTS, 'gte': 3}}, # noqa: E501 - 'input_pv4': {'via': 'inverter', 'name': 'Module PV4', 'dep': {'reg': Register.NO_INPUTS, 'gte': 4}}, # 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 @@ -151,6 +193,19 @@ class Infos: Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', '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_', 'fmt': '| string + " W"', 'name': 'Rated Power', 'icon': 'mdi:lightning-bolt', '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 + # 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': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi: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': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501 @@ -193,16 +248,22 @@ class Infos: # input measures: 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': 'mdi: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': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.INFO, '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.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.INFO, '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': 'mdi: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': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.INFO, '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.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.INFO, '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': 'mdi: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': 'mdi: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.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.INFO, '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': 'mdi: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': 'mdi: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.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.INFO, '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': 'mdi: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': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.INFO, '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': 'mdi: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': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.INFO, '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 Generation', 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', '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 Generation', 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': 'mdi: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 Generation', 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501 @@ -211,6 +272,10 @@ class Infos: 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 Generation', 'val_tpl': "{{ (value_json['pv3']['Total_Generation'] | float)}}", 'icon': 'mdi: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 Generation', 'val_tpl': "{{ (value_json['pv4']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', '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 Generation', 'val_tpl': "{{ (value_json['pv4']['Total_Generation'] | float)}}", 'icon': 'mdi: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 Generation', 'val_tpl': "{{ (value_json['pv5']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', '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 Generation', 'val_tpl': "{{ (value_json['pv5']['Total_Generation'] | float)}}", 'icon': 'mdi: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 Generation', 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', '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 Generation', 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501 # total: 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': '| float', 'name': 'Daily Generation', 'icon': 'mdi:solar-power-variant', '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': '| float', 'name': 'Total Generation', 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501 @@ -233,24 +298,14 @@ class Infos: @property def info_defs(self) -> dict: return self.__info_defs - ''' - if __name__ == "app.src.messages": - @info_defs.setter - def info_defs(self, value: dict) -> None: - self.__info_defs = value - - @info_devs.setter - def info_devs(self, value: dict) -> None: - self.__info_devs = value - ''' def dev_value(self, idx: str | int) -> str | int | float | None: '''returns the stored device value from our database idx:int ==> lookup the value in the database and return it as str, - int or flout. If the value is not available return 'None' + int or float. If the value is not available return 'None' idx:str ==> returns the string as a fixed value without a - database loopup + database lookup ''' if type(idx) is str: return idx # return idx as a fixed value @@ -300,7 +355,8 @@ class Infos: if res: yield res - def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool, sug_area: str = '') -> tuple[str, str, str, str]: # noqa: E501 + def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool, + sug_area: str = '') -> tuple[str, str, str, str] | None: if key not in self.info_defs: return None row = self.info_defs[key] @@ -404,7 +460,7 @@ class Infos: return json.dumps(attr), component, node_id, attr['uniq_id'] return None - def _key_obj(self, id) -> list: + def _key_obj(self, id: Register) -> list: d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG, 'unit': ''}) if 'ha' in d and 'must_incr' in d['ha']: @@ -414,7 +470,7 @@ class Infos: return d['name'], d['level'], d['unit'], must_incr - def update_db(self, keys, must_incr, result): + def update_db(self, keys: list, must_incr: bool, result): name = '' dict = self.db for key in keys[:-1]: @@ -434,30 +490,46 @@ class Infos: name += keys[-1] return name, update - def set_db_def_value(self, id, value): + def set_db_def_value(self, id: Register, value) -> None: '''set default value''' row = self.info_defs[id] - if isinstance(row, dict): # pragma: no cover + if isinstance(row, dict): keys = row['name'] self.update_db(keys, False, value) - def get_db_value(self, id, not_found_result=None): + def reg_clr_at_midnight(self, prfx: str) -> 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 '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''' row = self.info_defs[id] - if isinstance(row, dict): # pragma: no cover + if isinstance(row, dict): keys = row['name'] elm = self.db - for key in keys[:-1]: + for key in keys: if key not in elm: return not_found_result elm = elm[key] - - if keys[-1] in elm: - return elm[keys[-1]] + return elm return not_found_result def ignore_this_device(self, dep: dict) -> bool: - '''Checks the equation in the dep dict + '''Checks the equation in the dep(endency) dict returns 'False' only if the equation is valid; 'True' in any other case''' @@ -471,3 +543,20 @@ class Infos: elif 'less_eq' in dep: return not value <= dep['less_eq'] return True + + def set_pv_module_details(self, inv: dict) -> None: + map = {'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 map.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/app/src/messages.py b/app/src/messages.py index ab0b6c5..9f6efdf 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -74,6 +74,10 @@ class Message(metaclass=IterRegistry): # to our _recv_buffer return # pragma: no cover + def _update_header(self, _forward_buffer): + '''callback for updating the header of the forward buffer''' + return + ''' Our puplic methods ''' diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 413daf4..5b2de02 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -24,7 +24,7 @@ class Mqtt(metaclass=Singleton): def __init__(self, cb_MqttIsUp): logger_mqtt.debug('MQTT: __init__') if cb_MqttIsUp: - self.cb_MqttIsUp = cb_MqttIsUp + self.__cb_MqttIsUp = cb_MqttIsUp loop = asyncio.get_event_loop() self.task = loop.create_task(self.__loop()) self.ha_restarts = 0 @@ -70,24 +70,32 @@ class Mqtt(metaclass=Singleton): async with self.__client: logger_mqtt.info('MQTT broker connection established') - if self.cb_MqttIsUp: - await self.cb_MqttIsUp() + if self.__cb_MqttIsUp: + await self.__cb_MqttIsUp() - async with self.__client.messages() as messages: - await self.__client.subscribe( - f"{ha['auto_conf_prefix']}" - "/status") - async for message in messages: - status = message.payload.decode("UTF-8") - logger_mqtt.info('Home-Assistant Status:' - f' {status}') - if status == 'online': - self.ha_restarts += 1 - await self.cb_MqttIsUp() + # async with self.__client.messages() as messages: + await self.__client.subscribe( + f"{ha['auto_conf_prefix']}" + "/status") + async for message in self.__client.messages: + status = message.payload.decode("UTF-8") + logger_mqtt.info('Home-Assistant Status:' + f' {status}') + if status == 'online': + self.ha_restarts += 1 + await self.__cb_MqttIsUp() except aiomqtt.MqttError: - logger_mqtt.info(f"Connection lost; Reconnecting in {interval}" - " seconds ...") + 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") diff --git a/app/src/scheduler.py b/app/src/scheduler.py new file mode 100644 index 0000000..5c4eb2d --- /dev/null +++ b/app/src/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 + + @classmethod + def start(cls) -> None: + '''Start the scheduler and schedule the tasks (cron jobs)''' + logging.info("Scheduler init") + cls.mqtt = Mqtt(None) + + crontab('0 0 * * *', func=cls.atmidnight, start=True) + # crontab('*/5 * * * *', 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/app/src/server.py b/app/src/server.py index cefbde5..48fd346 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -8,6 +8,7 @@ from messages import Message from inverter import Inverter from gen3.inverter_g3 import InverterG3 from gen3plus.inverter_g3p import InverterG3P +from scheduler import Schedule from config import Config @@ -73,6 +74,7 @@ if __name__ == "__main__": logging.getLogger('msg').setLevel(log_level) logging.getLogger('conn').setLevel(log_level) logging.getLogger('data').setLevel(log_level) + # logging.getLogger('mqtt').setLevel(log_level) # read config file Config.read() @@ -81,6 +83,8 @@ if __name__ == "__main__": asyncio.set_event_loop(loop) Inverter.class_init() + Schedule.start() + # # Register some UNIX Signal handler for a gracefully server shutdown # on Docker restart and stop diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py index a018af0..8bf419d 100644 --- a/app/tests/test_infos.py +++ b/app/tests/test_infos.py @@ -1,6 +1,6 @@ # test_with_pytest.py import pytest, json -from app.src.infos import Register +from app.src.infos import Register, ClrAtMidnight from app.src.gen3.infos_g3 import InfosG3 @pytest.fixture @@ -524,3 +524,69 @@ def test_invalid_data_type(InvalidDataSeq): val = i.dev_value(Register.INVALID_DATA_TYPE) # check invalid data type counter assert val == 1 +def test_clr_at_midnight(): + i = InfosG3() + i.static_init() # initialize counter + i.set_db_def_value(Register.NO_INPUTS, 2) + val = i.dev_value(Register.NO_INPUTS) # valid addr but not initiliazed + assert val == 2 + i.info_defs[Register.TEST_REG1] = { # add a entry with incomplete ha definition + 'name': ['test', 'grp', 'REG_1'], 'ha': {'dev_cla': None } + } + i.reg_clr_at_midnight('tsun/inv_1/') + # tsun/inv_2/input + assert json.dumps(ClrAtMidnight.db['tsun/inv_1/total']) == json.dumps({'Daily_Generation': 0}) + assert json.dumps(ClrAtMidnight.db['tsun/inv_1/input']) == json.dumps({"pv1": {"Daily_Generation": 0}, "pv2": {"Daily_Generation": 0}}) + + i.reg_clr_at_midnight('tsun/inv_1/') + assert json.dumps(ClrAtMidnight.db['tsun/inv_1/total']) == json.dumps({'Daily_Generation': 0}) + assert json.dumps(ClrAtMidnight.db['tsun/inv_1/input']) == json.dumps({"pv1": {"Daily_Generation": 0}, "pv2": {"Daily_Generation": 0}}) + + test = 0 + for key, data in ClrAtMidnight.elm(): + if key == 'tsun/inv_1/total': + assert json.dumps(data) == json.dumps({'Daily_Generation': 0}) + test += 1 + elif key == 'tsun/inv_1/input': + assert json.dumps(data) == json.dumps({"pv1": {"Daily_Generation": 0}, "pv2": {"Daily_Generation": 0}}) + test += 1 + assert test == 2 + assert json.dumps(ClrAtMidnight.db) == json.dumps({}) + + i.reg_clr_at_midnight('tsun/inv_1/') + + + + +def test_pv_module_config(): + i = InfosG3() + # i.set_db_def_value(Register.NO_INPUTS, 2) + + dt = { + 'pv1':{'manufacturer':'TSUN1','type': 'Module 100W'}, + 'pv2':{'manufacturer':'TSUN2'}, + 'pv3':{'manufacturer':'TSUN3','type': 'Module 300W'}, + 'pv4':{'type': 'Module 400W'}, + 'pv5':{}, + } + i.set_pv_module_details(dt) + assert 'TSUN1' == i.dev_value(Register.PV1_MANUFACTURER) + assert 'TSUN2' == i.dev_value(Register.PV2_MANUFACTURER) + assert 'TSUN3' == i.dev_value(Register.PV3_MANUFACTURER) + assert None == i.dev_value(Register.PV4_MANUFACTURER) + assert None == i.dev_value(Register.PV5_MANUFACTURER) + assert 'Module 100W' == i.dev_value(Register.PV1_MODEL) + assert None == i.dev_value(Register.PV2_MODEL) + assert 'Module 300W' == i.dev_value(Register.PV3_MODEL) + assert 'Module 400W' == i.dev_value(Register.PV4_MODEL) + assert None == i.dev_value(Register.PV5_MODEL) + +def test_broken_info_defs(): + i = InfosG3() + val = i.get_db_value(Register.NO_INPUTS, 666) + assert val == 666 + i.info_defs[Register.TEST_REG1] = 'test' # add a string instead of a dict + val = i.get_db_value(Register.TEST_REG1, 666) + assert val == 666 + i.set_db_def_value(Register.TEST_REG1, 2) + diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 6743540..28af3ea 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -1,4 +1,7 @@ -import pytest, json +import pytest +import struct +import time +from datetime import datetime from app.src.gen3plus.solarman_v5 import SolarmanV5 from app.src.config import Config from app.src.infos import Infos, Register @@ -6,6 +9,9 @@ from app.src.infos import Infos, Register # initialize the proxy statistics Infos.static_init() +timestamp = int(time.time()) # 1712861197 +heartbeat = 60 + class MemoryStream(SolarmanV5): def __init__(self, msg, chunks = (0,), server_side: bool = True): super().__init__(server_side) @@ -19,6 +25,12 @@ class MemoryStream(SolarmanV5): self.db.stat['proxy']['Invalid_Msg_Format'] = 0 self.db.stat['proxy']['AT_Command'] = 0 + def _timestamp(self): + return timestamp + + def _heartbeat(self) -> int: + return heartbeat + def append_msg(self, msg): self.__msg += msg @@ -42,9 +54,6 @@ class MemoryStream(SolarmanV5): pass return copied_bytes - def _timestamp(self): - return 1700260990000 - def _SolarmanV5__flush_recv_msg(self) -> None: super()._SolarmanV5__flush_recv_msg() self.msg_count += 1 @@ -60,6 +69,16 @@ def get_inv_no() -> bytes: def get_invalid_sn(): return b'R170000000000002' +def total(): + ts = timestamp + # convert int to little-endian bytes + return struct.pack('