diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8f4f09a..2c7031b 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,15 +29,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install flake8 pytest pytest-asyncio if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8c073..3d4eeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- improve logging: add protocol or node_id to connection logs +- improve logging: log ignored AT+ or MODBUS commands +- improve tracelog: log level depends on message type and source +- fix typo in docker-compose.yaml and remove the external network definition +- trace heartbeat and regular modbus pakets witl log level DEBUG +- GEN3PLUS: don't forward ack paket from tsun to the inverter +- GEN3PLUS: add allow and block filter for AT+ commands +- catch all OSError errors in the read loop +- log Modbus traces with different log levels +- add Modbus fifo and timeout handler +- build version string in the same format as TSUN for GEN3 invterts +- add graceful shutdown +- parse Modbus values and store them in the database +- add cron task to request the output power every minute +- GEN3PLUS: add MQTT topics to send AT commands to the inverter +- add MQTT topics to send Modbus commands to the inverter +- convert data collect interval to minutes +- add postfix for rc and dev versions to the version number +- change logging level to DEBUG for some logs +- remove experimental value Register.VALUE_1 +- format Register.POWER_ON_TIME as integer +- ignore catch-up values from the inverters for now + ## [0.7.0] - 2024-04-20 - GEN3PLUS: fix temperature values diff --git a/README.md b/README.md index b3ef585..67a6ead 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,17 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. ## Features -- supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600 -- supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300 +- Supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600 +- Supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300 - `MQTT` support - `Home-Assistant` auto-discovery support +- `MODBUS` support via MQTT topics +- `AT-Command` support via MQTT topics (GEN3PLUS only) +- Faster DataUp interval sends measurement data to the MQTT broker every minute - Self-sufficient island operation without internet -- runs in a non-root Docker Container +- Security-Features: + - control access via `AT-commands` + - Runs in a non-root Docker Container ## Home Assistant Screenshots @@ -156,7 +161,7 @@ suggested_area = 'balcony' # Optional, suggested installation area for home-a pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr -[inverters."Y17xxxxxxxxxxxx1"] +[inverters."Y17xxxxxxxxxxxx1"] # This block is also for inverters with a Y47 serial no 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 @@ -165,6 +170,12 @@ pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module de pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +[gen3plus.at_acl] +tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] # allow this for TSUN access +tsun.block = [] +mqtt.allow = ['AT+'] # allow all via mqtt +mqtt.block = [] + ``` ## Inverter Configuration @@ -230,7 +241,7 @@ Legend 🚧: Proxy support in preparation ``` -❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` instead of `R17E` +❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` or `Y47E` instead of `R17E` If you have one of these combinations with a red question mark, it would be very nice if you could send me a proxy trace so that I can carry out the detailed checks and adjust the device and system tests. [Ask here how to send a trace](https://github.com/s-allius/tsun-gen3-proxy/discussions/categories/traces-for-compatibility-check) diff --git a/app/build.sh b/app/build.sh index ac8879d..c654c1c 100755 --- a/app/build.sh +++ b/app/build.sh @@ -4,7 +4,7 @@ # rc: release candidate build # rel: release build and push to ghcr.io # Note: for release build, you need to set GHCR_TOKEN -# export GHCR_TOKEN= +# export GHCR_TOKEN= in your .profile # see also: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry @@ -18,22 +18,30 @@ arr=(${VERSION//./ }) MAJOR=${arr[0]} IMAGE=tsun-gen3-proxy -if [[ $1 == dev ]] || [[ $1 == rc ]] ;then +if [[ $1 == debug ]] || [[ $1 == dev ]] ;then IMAGE=docker.io/sallius/${IMAGE} -VERSION=${VERSION} -elif [[ $1 == rel ]];then +VERSION=${VERSION}-$1 +elif [[ $1 == rc ]] || [[ $1 == rel ]];then IMAGE=ghcr.io/s-allius/${IMAGE} else echo argument missing! -echo try: $0 '[dev|rc|rel]' +echo try: $0 '[debug|dev|rc|rel]' exit 1 fi echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE -if [[ $1 == dev ]];then -docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app +if [[ $1 == debug ]];then +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app +elif [[ $1 == dev ]];then +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app + elif [[ $1 == rc ]];then -docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app +echo 'login to ghcr.io' +echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin +docker push ghcr.io/s-allius/tsun-gen3-proxy:rc +docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION} + elif [[ $1 == rel ]];then docker build --no-cache --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app echo 'login to ghcr.io' @@ -41,4 +49,7 @@ echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin docker push ghcr.io/s-allius/tsun-gen3-proxy:latest docker push ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR} docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION} -fi \ No newline at end of file +fi + +echo 'check docker-compose.yaml file' +docker-compose config -q \ No newline at end of file diff --git a/app/config/default_config.toml b/app/config/default_config.toml index cd95d75..fbe2651 100644 --- a/app/config/default_config.toml +++ b/app/config/default_config.toml @@ -49,3 +49,8 @@ monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker e #pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +[gen3plus.at_acl] +tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] +tsun.block = [] +mqtt.allow = ['AT+'] +mqtt.block = [] diff --git a/app/proxy.svg b/app/proxy.svg index 588835e..b111fd4 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -4,340 +4,381 @@ - - + + 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 - - - - -A2->A10 - - - - -A3 - -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 - - - -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() - - - -A4->A5 - - - - - -A6 - -SolarmanV5 - -control -serial -snr -switch - -msg_unknown() -close() - - - -A4->A6 - - - - - -A7 - -ConnectionG3 - -remoteStream:ConnectionG3 - -close() - - - -A5->A7 - - - - - -A8 - -ConnectionG3P - -remoteStream:ConnectionG3P - -close() - - - -A6->A8 - - - - - -A7->A7 - - -0..1 -has + + A11 - -InverterG3 - -__ha_restarts - -async_create_remote() -close() + +Inverter + +cls.db_stat +cls.entity_prfx +cls.discovery_prfx +cls.proxy_node_id +cls.proxy_unique_id +cls.mqtt:Mqtt + - - -A7->A11 - - + + +A2->A11 + - - -A8->A8 - - -0..1 -has + + +A3 + +Modbus + +que +snd_handler +rsp_handler +timeout:max_retires +last_xxx +err +retry_cnt +req_pend +tim + +build_msg() +recv_req() +recv_resp() - - -A12 - -InverterG3P - -__ha_restarts - -async_create_remote() -close() + + +A4 + +IterRegistry + + +__iter__ - - -A8->A12 - - + + +A5 + +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 +state + +_read():void<abstract> +close():void +inc_counter():void +dec_counter():void + + + +A4->A5 + + + + + +A6 + +Talent + +await_conn_resp_cnt +id_str +contact_name +contact_mail +db:InfosG3 +mb:Modbus +switch + +msg_contact_info() +msg_ota_update() +msg_get_time() +msg_collector_data() +msg_inverter_data() +msg_unknown() +close() + + + +A5->A6 + + + + + +A7 + +SolarmanV5 + +control +serial +snr +db:InfosG3P +mb:Modbus +switch + +msg_unknown() +close() + + + +A5->A7 + + + + + +A6->A3 + + +1 +has + + + +A8 + +ConnectionG3 + +remoteStream:ConnectionG3 + +close() + + + +A6->A8 + + + + + +A7->A3 + + +1 +has 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() + +ConnectionG3P + +remoteStream:ConnectionG3P + +close() - + -A9->A7 - - +A7->A9 + + - - -A9->A8 - - + + +A8->A8 + + +0..1 +has - - -A10->A11 - - + + +A12 + +InverterG3 + +__ha_restarts + +async_create_remote() +close() - - -A10->A12 - - + + +A8->A12 + + + + + +A9->A9 + + +0..1 +has 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 + +InverterG3P + +__ha_restarts + +async_create_remote() +close() + + + +A9->A13 + + + + + +A10 + +AsyncStream + +reader +writer +addr +r_addr +l_addr + +<async>server_loop() +<async>client_loop() +<async>loop +disc() +close() +__async_read() +async_write() +__async_forward() + + + +A10->A8 + + + + + +A10->A9 + + + + + +A11->A12 + + + + + +A11->A13 + + A14 - -InfosG3 - - -ha_confs() -parse() - - - -A13->A14 - - + +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 A15 - -InfosG3P - - -ha_confs() -parse() + +InfosG3 + + +ha_confs() +parse() - - -A13->A15 - - + + +A14->A15 + + - + + +A16 + +InfosG3P + + +ha_confs() +parse() + + -A14->A5 - - +A14->A16 + + - + A15->A6 - - + + + + + +A16->A7 + + diff --git a/app/proxy.yuml b/app/proxy.yuml index 7f5be21..60b506e 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -4,13 +4,15 @@ [note: You can stick notes on diagrams too!{bg:cornsilk}] [Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()] - -[IterRegistry||__iter__]^[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;close():void;inc_counter():void;dec_counter():void] -[Message]^[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()] -[Message]^[SolarmanV5|control;serial;snr;switch|msg_unknown();;close()] +[Modbus|que;;snd_handler;rsp_handler;timeout:max_retires;last_xxx;err;retry_cnt;req_pend;tim|build_msg();recv_req();recv_resp()] +[IterRegistry||__iter__]^[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;state|_read():void;close():void;inc_counter():void;dec_counter():void] +[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] +[Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()] [Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()] +[Talent]has-1>[Modbus] [SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()] -[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();__async_write();__async_forward()]^[ConnectionG3] +[SolarmanV5]has-1>[Modbus] +[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3] [AsyncStream]^[ConnectionG3P] [Inverter|cls.db_stat;cls.entity_prfx;cls.discovery_prfx;cls.proxy_node_id;cls.proxy_unique_id;cls.mqtt:Mqtt|]^[InverterG3|__ha_restarts|async_create_remote();;close()] [Inverter]^[InverterG3P|__ha_restarts|async_create_remote();;close()] diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 6c1136c..17f5f59 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -17,17 +17,18 @@ class AsyncStream(): async def server_loop(self, addr): '''Loop for receiving messages from the inverter (server-side)''' - logging.info(f'Accept connection from {addr}') + logging.info(f'[{self.node_id}] Accept connection from {addr}') self.inc_counter('Inverter_Cnt') await self.loop() self.dec_counter('Inverter_Cnt') - logging.info(f'Server loop stopped for r{self.r_addr}') + logging.info(f'[{self.node_id}] 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.remoteStream: logging.debug("disconnect client connection") - self.remoteStream.disc() + await self.remoteStream.disc() try: await self._async_publ_mqtt_proxy_stat('proxy') except Exception: @@ -36,7 +37,8 @@ class AsyncStream(): async def client_loop(self, addr): '''Loop for receiving messages from the TSUN cloud (client-side)''' clientStream = await self.remoteStream.loop() - logging.info(f'Client loop stopped for l{clientStream.l_addr}') + logging.info(f'[{self.node_id}] Client loop stopped for' + f' l{clientStream.l_addr}') # if the client connection closes, we don't touch the server # connection. Instead we erase the client connection stream, @@ -61,31 +63,39 @@ class AsyncStream(): await self.__async_read() if self.unique_id: - await self.__async_write() + await self.async_write() await self.__async_forward() await self.async_publ_mqtt() - except (ConnectionResetError, - ConnectionAbortedError, - BrokenPipeError, - RuntimeError) as error: - logger.warning(f'In loop for l{self.l_addr} | ' - f'r{self.r_addr}: {error}') + except OSError as error: + logger.error(f'[{self.node_id}] {error} for l{self.l_addr} | ' + f'r{self.r_addr}') + await self.disc() self.close() return self + + except RuntimeError as error: + logger.info(f"[{self.node_id}] {error} for {self.l_addr}") + await self.disc() + self.close() + return self + except Exception: self.inc_counter('SW_Exception') logger.error( f"Exception for {self.addr}:\n" f"{traceback.format_exc()}") - self.close() - return self - def disc(self) -> None: + async def disc(self) -> None: + 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): + if self.writer.is_closing(): + return logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}') self.writer.close() @@ -100,9 +110,9 @@ class AsyncStream(): else: raise RuntimeError("Peer closed.") - async def __async_write(self) -> None: + async def async_write(self, headline='Transmit to ') -> None: if self._send_buffer: - hex_dump_memory(logging.INFO, f'Transmit to {self.addr}:', + hex_dump_memory(logging.INFO, f'{headline}{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) await self.writer.drain() @@ -114,7 +124,7 @@ class AsyncStream(): await self.async_create_remote() if self.remoteStream: if self.remoteStream._init_new_client_conn(): - await self.remoteStream.__async_write() + await self.remoteStream.async_write() if self.remoteStream: self.remoteStream._update_header(self._forward_buffer) diff --git a/app/src/config.py b/app/src/config.py index 6218e65..e1ef749 100644 --- a/app/src/config.py +++ b/app/src/config.py @@ -3,7 +3,7 @@ import shutil import tomllib import logging -from schema import Schema, And, Use, Optional +from schema import Schema, And, Or, Use, Optional class Config(): @@ -38,6 +38,14 @@ class Config(): '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), @@ -76,51 +84,75 @@ class Config(): ) @classmethod - def read(cls) -> None: + def class_init(cls): # 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 + cls.read() + + @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: - # make the default config transparaent by copying it - # in the config.example file - shutil.copy2("default_config.toml", "config/config.example.toml") - # read example config file as default configuration - with open("default_config.toml", "rb") as f: + 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: - with open("config/config.toml", "rb") as f: - usr_config = tomllib.load(f) + cls.config = cls.conf_schema.validate(config) 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 + err = f'Config.read: {error}' + logging.error(err) - config['tsun'] = def_config['tsun'] | usr_config['tsun'] - config['solarman'] = def_config['solarman'] | \ - usr_config['solarman'] - config['mqtt'] = def_config['mqtt'] | usr_config['mqtt'] - config['ha'] = def_config['ha'] | usr_config['ha'] - config['inverters'] = def_config['inverters'] | \ - 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: - logger.error(f'Config.read: {error}') + err = f'Config.read: {error}' + logger.error(err) cls.config = {} + return err + @classmethod def get(cls, member: str = None): '''Get a named attribute from the proxy config. If member == diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py index 7e45634..0dc6a35 100644 --- a/app/src/gen3/infos_g3.py +++ b/app/src/gen3/infos_g3.py @@ -30,6 +30,7 @@ class RegisterMap: 0xffffff05: Register.UNKNOWN_CTRL, 0xffffff06: Register.OTA_START_MSG, 0xffffff07: Register.SW_EXCEPTION, + 0xffffff08: Register.MAX_DESIGNED_POWER, 0xfffffffe: Register.TEST_REG1, 0xffffffff: Register.TEST_REG2, 0x00000640: Register.OUTPUT_POWER, @@ -104,7 +105,8 @@ class InfosG3(Infos): if res: yield res - def parse(self, buf, ind=0) -> Generator[tuple[str, bool], None, None]: + 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 @@ -161,7 +163,8 @@ class InfosG3(Infos): update = False name = str(f'info-id.0x{addr:x}') - self.tracer.log(level, f'GEN3: {name} : {result}{unit}' - f' update: {update}') + if update: + self.tracer.log(level, f'[{node_id}] GEN3: {name} :' + f' {result}{unit}') i += 1 diff --git a/app/src/gen3/inverter_g3.py b/app/src/gen3/inverter_g3.py index 5fccbc2..1930f0e 100644 --- a/app/src/gen3/inverter_g3.py +++ b/app/src/gen3/inverter_g3.py @@ -56,7 +56,7 @@ class InverterG3(Inverter, ConnectionG3): addr = (host, port) try: - logging.info(f'Connected to {addr}') + logging.info(f'[{self.node_id}] Connected to {addr}') connect = asyncio.open_connection(host, port) reader, writer = await connect self.remoteStream = ConnectionG3(reader, writer, addr, self, diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 46302ac..ad37337 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -5,10 +5,12 @@ from datetime import datetime if __name__ == "app.src.gen3.talent": from app.src.messages import hex_dump_memory, Message + from app.src.modbus import Modbus from app.src.config import Config from app.src.gen3.infos_g3 import InfosG3 else: # pragma: no cover from messages import hex_dump_memory, Message + from modbus import Modbus from config import Config from gen3.infos_g3 import InfosG3 @@ -33,9 +35,8 @@ class Control: class Talent(Message): - def __init__(self, server_side: bool, id_str=b''): - super().__init__(server_side) + super().__init__(server_side, self.send_modbus_cb, mb_timeout=11) self.await_conn_resp_cnt = 0 self.id_str = id_str self.contact_name = b'' @@ -46,8 +47,24 @@ class Talent(Message): 0x13: self.msg_ota_update, 0x22: self.msg_get_time, 0x71: self.msg_collector_data, + # 0x76: + 0x77: self.msg_modbus, + # 0x78: 0x04: self.msg_inverter_data, } + self.log_lvl = { + 0x00: logging.INFO, + 0x13: logging.INFO, + 0x22: logging.INFO, + 0x71: logging.INFO, + # 0x76: + 0x77: self.get_modbus_log_lvl, + # 0x78: + 0x04: logging.INFO, + } + self.modbus_elms = 0 # for unit tests + self.node_id = 'G3' # will be overwritten in __set_serial_no + # self.forwarding = Config.get('tsun')['enabled'] ''' Our puplic methods @@ -58,6 +75,9 @@ class Talent(Message): # 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.state = self.STATE_CLOSED + super().close() def __set_serial_no(self, serial_no: str): @@ -93,7 +113,11 @@ class Talent(Message): if self.header_valid and len(self._recv_buffer) >= (self.header_len + self.data_len): - hex_dump_memory(logging.INFO, f'Received from {self.addr}:', + log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING) + if callable(log_lvl): + log_lvl = log_lvl() + + hex_dump_memory(log_lvl, f'Received from {self.addr}:', self._recv_buffer, self.header_len+self.data_len) self.__set_serial_no(self.id_str.decode("utf-8")) @@ -115,6 +139,30 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return + def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): + if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' cause the state is not UP anymore') + return + + self.__build_header(0x70, 0x77) + self._send_buffer += b'\x00\x01\xa3\x28' # fixme + self._send_buffer += struct.pack('!B', len(modbus_pdu)) + self._send_buffer += modbus_pdu + self.__finish_send_msg() + + hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:', + self._send_buffer, len(self._send_buffer)) + self.writer.write(self._send_buffer) + self._send_buffer = bytearray(0) # self._send_buffer[sent:] + + async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: + if self.state != self.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) + def _init_new_client_conn(self) -> bool: contact_name = self.contact_name contact_mail = self.contact_mail @@ -190,11 +238,13 @@ class Talent(Message): self.header_valid = True return - def __build_header(self, ctrl) -> None: + def __build_header(self, ctrl, msg_id=None) -> None: + if not msg_id: + msg_id = self.msg_id self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB', - 0, self.id_str, ctrl, self.msg_id) - fnc = self.switch.get(self.msg_id, self.msg_unknown) + 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}') @@ -306,6 +356,7 @@ class Talent(Message): self._send_buffer += b'\x01' self.__finish_send_msg() self.__process_data() + self.state = self.STATE_UP elif self.ctrl.is_resp(): return # ignore received response @@ -321,6 +372,7 @@ class Talent(Message): self._send_buffer += b'\x01' self.__finish_send_msg() self.__process_data() + self.state = self.STATE_UP elif self.ctrl.is_resp(): return # ignore received response @@ -334,7 +386,7 @@ class Talent(Message): msg_hdr_len = self.parse_msg_header() for key, update in self.db.parse(self._recv_buffer, self.header_len - + msg_hdr_len): + + msg_hdr_len, self.node_id): if update: self.new_data[key] = True @@ -348,6 +400,53 @@ class Talent(Message): self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) + def parse_modbus_header(self): + + msg_hdr_len = 5 + + result = struct.unpack_from('!lBB', self._recv_buffer, + self.header_len) + modbus_len = result[1] + # logger.debug(f'Ref: {result[0]}') + # logger.debug(f'Modbus MsgLen: {modbus_len} Func:{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(): + if self.server_side: + return self.mb.last_log_lvl + return logging.WARNING + + def msg_modbus(self): + hdr_len, modbus_len = self.parse_modbus_header() + data = self._recv_buffer[self.header_len: + self.header_len+self.data_len] + + if self.ctrl.is_req(): + if self.remoteStream.mb.recv_req(data[hdr_len:], + self.msg_forward): + self.inc_counter('Modbus_Command') + else: + self.inc_counter('Invalid_Msg_Format') + elif self.ctrl.is_ind(): + # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') + self.modbus_elms = 0 + for key, update, _ in self.mb.recv_resp(self.db, data[ + hdr_len:], + self.node_id): + if update: + self.new_data[key] = True + self.modbus_elms += 1 # count for unit tests + else: + logger.warning('Unknown Ctrl') + self.inc_counter('Unknown_Ctrl') + self.forward(self._recv_buffer, self.header_len+self.data_len) + + def msg_forward(self): + self.forward(self._recv_buffer, self.header_len+self.data_len) + def msg_unknown(self): logger.warning(f"Unknow Msg: ID:{self.msg_id}") self.inc_counter('Unknown_Msg') diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index a9843cf..bf0aed8 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -15,17 +15,17 @@ class RegisterMap: map = { # 0x41020007: {'reg': Register.DEVICE_SNR, 'fmt': '>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 + 0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x420100d2: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x420100d4: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x420100d6: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 @@ -58,7 +58,7 @@ class RegisterMap: 0x42010126: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 0x42010170: {'reg': Register.NO_INPUTS, 'fmt': '!B'}, # noqa: E501 - 0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': ' Generator[tuple[str, bool], None, None]: '''parse a data sequence received from the inverter and stores the values in Infos.db @@ -122,5 +122,6 @@ class InfosG3P(Infos): name = str(f'info-id.0x{addr:x}') update = False - self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}' - f' update: {update}') + if update: + self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}' + f' : {result}{unit}') diff --git a/app/src/gen3plus/inverter_g3p.py b/app/src/gen3plus/inverter_g3p.py index b7b9800..487fe1e 100644 --- a/app/src/gen3plus/inverter_g3p.py +++ b/app/src/gen3plus/inverter_g3p.py @@ -56,7 +56,7 @@ class InverterG3P(Inverter, ConnectionG3P): addr = (host, port) try: - logging.info(f'Connected to {addr}') + logging.info(f'[{self.node_id}] Connected to {addr}') connect = asyncio.open_connection(host, port) reader, writer = await connect self.remoteStream = ConnectionG3P(reader, writer, addr, self, diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index d23efa3..64e4536 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -2,16 +2,19 @@ import struct # import json import logging import time +import asyncio from datetime import datetime if __name__ == "app.src.gen3plus.solarman_v5": from app.src.messages import hex_dump_memory, Message + from app.src.modbus import Modbus from app.src.config import Config from app.src.gen3plus.infos_g3p import InfosG3P from app.src.infos import Register else: # pragma: no cover from messages import hex_dump_memory, Message from config import Config + from modbus import Modbus from gen3plus.infos_g3p import InfosG3P from infos import Register # import traceback @@ -46,9 +49,11 @@ class Sequence(): class SolarmanV5(Message): + AT_CMD = 1 + MB_RTU_CMD = 2 def __init__(self, server_side: bool): - super().__init__(server_side) + super().__init__(server_side, self.send_modbus_cb, mb_timeout=5) self.header_len = 11 # overwrite construcor in class Message self.control = 0 @@ -56,6 +61,7 @@ class SolarmanV5(Message): self.snr = 0 self.db = InfosG3P() self.time_ofs = 0 + self.forward_at_cmd_resp = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -84,9 +90,41 @@ class SolarmanV5(Message): # # MODbus or AT cmd 0x4510: self.msg_command_req, # from server - 0x1510: self.msg_response, # from inverter + 0x1510: self.msg_command_rsp, # from inverter + # 0x0510: self.msg_command_rsp, # from inverter } + self.log_lvl = { + + 0x4210: logging.INFO, # real time data + 0x1210: logging.INFO, # at least every 5 minutes + + 0x4710: logging.DEBUG, # heatbeat + 0x1710: logging.DEBUG, # every 2 minutes + + 0x4110: logging.INFO, # device data, sync start + 0x1110: logging.INFO, # every 3 hours + + 0x4310: logging.INFO, # regulary after 3-6 hours + 0x1310: logging.INFO, + + 0x4810: logging.INFO, # sync end + 0x1810: logging.INFO, + + # + # MODbus or AT cmd + 0x4510: logging.INFO, # from server + 0x1510: self.get_cmd_rsp_log_lvl, + } + self.modbus_elms = 0 # for unit tests + g3p_cnf = Config.get('gen3plus') + + if 'at_acl' in g3p_cnf: # pragma: no cover + self.at_acl = g3p_cnf['at_acl'] + + self.node_id = 'G3P' # will be overwritten in __set_serial_no + # self.forwarding = Config.get('solarman')['enabled'] + ''' Our puplic methods ''' @@ -96,6 +134,9 @@ class SolarmanV5(Message): # 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.state = self.STATE_CLOSED + super().close() def __set_serial_no(self, snr: int): serial_no = str(snr) @@ -136,7 +177,10 @@ class SolarmanV5(Message): if self.header_valid and len(self._recv_buffer) >= (self.header_len + self.data_len+2): - hex_dump_memory(logging.INFO, f'Received from {self.addr}:', + log_lvl = self.log_lvl.get(self.control, logging.WARNING) + if callable(log_lvl): + log_lvl = log_lvl() + hex_dump_memory(log_lvl, f'Received from {self.addr}:', 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): @@ -293,41 +337,90 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() - def send_at_cmd(self, AT_cmd: str) -> None: + def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): + if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' cause the state is not UP anymore') + return self.__build_header(0x4510) - self._send_buffer += struct.pack(f' None: + if self.state != self.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) + + def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: + return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ + cmd.startswith(tuple(self.at_acl[connection]['block'])) + + async def send_at_cmd(self, AT_cmd: str) -> None: + if self.state != self.STATE_UP: + logger.warn(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}') + asyncio.ensure_future( + self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501 + return + + self.forward_at_cmd_resp = False + self.__build_header(0x4510) + self._send_buffer += struct.pack(f'> 8 - for key, update in self.db.parse(self._recv_buffer, msg_type, ftype): + for key, update in self.db.parse(self._recv_buffer, msg_type, ftype, + self.node_id): if update: if key == 'inverter': inv_update = True self.new_data[key] = True if inv_update: - db = self.db - MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0) - Rated = db.get_db_value(Register.RATED_POWER, 0) - Model = None - if MaxPow == 2000: - if Rated == 800 or Rated == 600: - Model = f'TSOL-MS{MaxPow}({Rated})' - else: - Model = f'TSOL-MS{MaxPow}' - elif MaxPow == 1800 or MaxPow == 1600: - Model = f'TSOL-MS{MaxPow}' - if Model: - logger.info(f'Model: {Model}') - self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model) - + self.__build_model_name() ''' Message handler methods ''' @@ -340,14 +433,14 @@ class SolarmanV5(Message): data = self._recv_buffer[self.header_len:] result = struct.unpack_from(' int: + ftype = self._recv_buffer[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: + if self.server_side: + return self.mb.last_log_lvl + + return logging.WARNING + + def msg_command_rsp(self): + data = self._recv_buffer[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}') + asyncio.ensure_future( + self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501 + return + elif ftype == self.MB_RTU_CMD: + 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 = False + self.modbus_elms = 0 + + for key, update, _ in self.mb.recv_resp(self.db, data[14:], + self.node_id): + self.modbus_elms += 1 + if update: + if key == 'inverter': + inv_update = True + self.new_data[key] = True + + if inv_update: + self.__build_model_name() + return + self.__forward_msg() def msg_hbeat_ind(self): data = self._recv_buffer[self.header_len:] @@ -402,6 +560,7 @@ class SolarmanV5(Message): self.__forward_msg() self.__send_ack_rsp(0x1710, ftype) + self.state = self.STATE_UP def msg_sync_end(self): data = self._recv_buffer[self.header_len:] @@ -423,8 +582,8 @@ class SolarmanV5(Message): valid = result[1] == 1 # status ts = result[2] set_hb = result[3] # always 60 or 120 - logger.info(f'ftype:{ftype} accepted:{valid}' - f' ts:{ts:08x} nextHeartbeat: {set_hb}s') + logger.debug(f'ftype:{ftype} accepted:{valid}' + f' ts:{ts:08x} nextHeartbeat: {set_hb}s') dt = datetime.fromtimestamp(ts) - logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') + logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') diff --git a/app/src/infos.py b/app/src/infos.py index b0ea82b..ee5a38a 100644 --- a/app/src/infos.py +++ b/app/src/infos.py @@ -28,6 +28,8 @@ class Register(Enum): SW_EXCEPTION = 57 INVALID_MSG_FMT = 58 AT_COMMAND = 59 + MODBUS_COMMAND = 60 + AT_COMMAND_BLOCKED = 61 OUTPUT_POWER = 83 RATED_POWER = 84 INVERTER_TEMP = 85 @@ -86,7 +88,7 @@ class Register(Enum): DATA_UP_INTERVAL = 404 CONNECT_COUNT = 405 HEARTBEAT_INTERVAL = 406 - IP_ADRESS = 407 + IP_ADDRESS = 407 EVENT_401 = 500 EVENT_402 = 501 EVENT_403 = 502 @@ -145,7 +147,7 @@ class Infos: @classmethod def static_init(cls): - logging.info('Initialize proxy statistics') + logging.debug('Initialize proxy statistics') # init proxy counter in the class.stat dictionary cls.stat['proxy'] = {} for key in cls.__info_defs: @@ -192,7 +194,7 @@ class Infos: 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.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.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_', '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 @@ -209,16 +211,18 @@ class Infos: 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 - Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi: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': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi: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': '| int', 'name': 'Internal Error', 'icon': 'mdi: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': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi: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': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi: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': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi: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': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi: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': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501 + 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 + Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi: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': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi: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': '| int', 'name': 'Internal Error', 'icon': 'mdi: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': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi: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': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi: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': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi: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': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi: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': '| int', 'name': 'AT Command', 'icon': 'mdi: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': '| int', 'name': 'AT Command Blocked', 'icon': 'mdi: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': '| int', 'name': 'Modbus Command', 'icon': 'mdi: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':'| float','name': 'Grid Voltage'}}, # noqa: E501 # events @@ -230,7 +234,7 @@ class Infos: Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_409: {'name': ['events', '409_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 @@ -245,28 +249,27 @@ class Infos: Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', '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': '| 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': '| int', 'name': 'Temperature'}}, # noqa: E501 - Register.VALUE_1: {'name': ['env', 'Value_1'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'value_1_', 'fmt': '| int', 'name': 'Value 1', 'ent_cat': 'diagnostic'}}, # 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:counter'}}, # noqa: E501 # 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.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': '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.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': '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.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.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': '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.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.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': '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.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': '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.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 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 @@ -285,13 +288,13 @@ class Infos: # controller: Register.SIGNAL_STRENGTH: {'name': ['controller', 'Signal_Strength'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'signal_', 'fmt': '| int', 'name': 'Signal Strength', 'icon': 'mdi: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': '| float', 'name': 'Power on Time', 'nat_prc': '3', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " s"', 'name': 'Data Collect Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # 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': '| 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': 'mdi: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': '| int', 'name': 'Connect Count', 'icon': 'mdi: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': 'mdi: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': '| string + " s"', 'name': 'Data Up Interval', 'icon': 'mdi: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': '| string + " s"', 'name': 'Heartbeat Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.IP_ADRESS: {'name': ['controller', 'IP_Adress'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_adress_', 'fmt': '| string', 'name': 'IP Adress', 'icon': 'mdi:wifi', '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': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501 } @property @@ -403,7 +406,7 @@ class Infos: 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: + 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 diff --git a/app/src/messages.py b/app/src/messages.py index 5bcf711..6736b0b 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -1,10 +1,14 @@ import logging import weakref +from typing import Callable + if __name__ == "app.src.messages": from app.src.infos import Infos + from app.src.modbus import Modbus else: # pragma: no cover from infos import Infos + from modbus import Modbus logger = logging.getLogger('msg') @@ -50,21 +54,31 @@ class IterRegistry(type): class Message(metaclass=IterRegistry): _registry = [] + STATE_INIT = 0 + STATE_UP = 2 + STATE_CLOSED = 3 - def __init__(self, server_side: bool): + def __init__(self, server_side: bool, send_modbus_cb: + Callable[[bytes, int, str], None], mb_timeout): self._registry.append(weakref.ref(self)) self.server_side = server_side + if server_side: + self.mb = Modbus(send_modbus_cb, mb_timeout) + else: + self.mb = None + self.header_valid = False self.header_len = 0 self.data_len = 0 self.unique_id = 0 - self.node_id = '' + self.node_id = '' # will be overwritten in the child class's __init__ self.sug_area = '' self._recv_buffer = bytearray(0) self._send_buffer = bytearray(0) self._forward_buffer = bytearray(0) self.new_data = {} + self.state = self.STATE_INIT ''' Empty methods, that have to be implemented in any child class which @@ -82,6 +96,9 @@ class Message(metaclass=IterRegistry): Our puplic methods ''' def close(self) -> None: + if self.mb: + del self.mb + self.mb = None pass # pragma: no cover def inc_counter(self, counter: str) -> None: diff --git a/app/src/modbus.py b/app/src/modbus.py new file mode 100644 index 0000000..8f3778b --- /dev/null +++ b/app/src/modbus.py @@ -0,0 +1,309 @@ +'''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 + +if __name__ == "app.src.modbus": + from app.src.infos import Register +else: # pragma: no cover + from infos import Register + +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 = [] + map = { + 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 + # 0x????: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # 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', 'eval': 'result-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 + } + + 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 + + def __del__(self): + logging.debug(f'Modbus __del__:\n {self.counter}') + + 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: bytearray, node_id: str) -> \ + 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 + node_id: string for logging which identifies the slave + + 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)}') + if not self.req_pend: + self.err = 5 + return + if not self.__check_crc(buf): + logger.error(f'[{node_id}] Modbus resp: CRC error') + self.err = 1 + return + if buf[0] != self.last_addr: + logger.info(f'[{node_id}] Modbus resp: Wrong addr {buf[0]}') + self.err = 2 + return + fcode = buf[1] + if fcode != self.last_fcode: + logger.info(f'[{node_id}] Modbus: Wrong fcode {fcode}' + f' != {self.last_fcode}') + self.err = 3 + return + if self.last_addr == self.INV_ADDR and \ + (fcode == 3 or fcode == 4): + elmlen = buf[2] >> 1 + if elmlen != self.last_len: + logger.info(f'[{node_id}] Modbus: len error {elmlen}' + f' != {self.last_len}') + self.err = 4 + return + first_reg = self.last_reg # save last_reg before sending next pdu + self.__stop_timer() # stop timer and send next pdu + + for i in range(0, elmlen): + addr = first_reg+i + if addr in self.map: + row = self.map[addr] + info_id = row['reg'] + fmt = row['fmt'] + val = struct.unpack_from(fmt, buf, 3+2*i) + result = val[0] + + if 'eval' in row: + result = eval(row['eval']) + if 'ratio' in row: + result = round(result * row['ratio'], 2) + + keys, level, unit, must_incr = info_db._key_obj(info_id) + + if keys: + name, update = info_db.update_db(keys, must_incr, + result) + yield keys[0], update, result + if update: + info_db.tracer.log(level, + f'[{node_id}] MODBUS: {name}' + f' : {result}{unit}') + else: + self.__stop_timer() + + self.counter['retries'][f'{self.retry_cnt}'] += 1 + if self.rsp_handler: + self.rsp_handler() + self.__send_next_from_que() + + ''' + 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() + + 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'Modbus timeout {self}') + 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: bytearray) -> bool: + '''Check CRC-16 and returns True if valid''' + return 0 == self.__calc_crc(msg) + + def __calc_crc(self, buffer: bytearray) -> 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/app/src/mqtt.py b/app/src/mqtt.py index 5b2de02..2f55660 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -1,22 +1,15 @@ 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 Singleton(type): - _instances = {} - - def __call__(cls, *args, **kwargs): - logger_mqtt.debug('singleton: __call__') - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, - cls).__call__(*args, **kwargs) - return cls._instances[cls] - - class Mqtt(metaclass=Singleton): __client = None __cb_MqttIsUp = None @@ -65,6 +58,12 @@ class Mqtt(metaclass=Singleton): password=mqtt['passwd']) interval = 5 # Seconds + ha_status_topic = f"{ha['auto_conf_prefix']}/status" + mb_rated_topic = "tsun/+/rated_load" # fixme + mb_reads_topic = "tsun/+/modbus_read_regs" # fixme + mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme + mb_at_cmd_topic = "tsun/+/at_cmd" # fixme + while True: try: async with self.__client: @@ -74,16 +73,36 @@ class Mqtt(metaclass=Singleton): await self.__cb_MqttIsUp() # async with self.__client.messages() as messages: - await self.__client.subscribe( - f"{ha['auto_conf_prefix']}" - "/status") + await self.__client.subscribe(ha_status_topic) + await self.__client.subscribe(mb_rated_topic) + await self.__client.subscribe(mb_reads_topic) + await self.__client.subscribe(mb_inputs_topic) + await self.__client.subscribe(mb_at_cmd_topic) + 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() + if message.topic.matches(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_MqttIsUp() + + if message.topic.matches(mb_rated_topic): + await self.modbus_cmd(message, + Modbus.WRITE_SINGLE_REG, + 1, 0x2008) + + if message.topic.matches(mb_reads_topic): + await self.modbus_cmd(message, + Modbus.READ_REGS, 2) + + if message.topic.matches(mb_inputs_topic): + await self.modbus_cmd(message, + Modbus.READ_INPUTS, 2) + + if message.topic.matches(mb_at_cmd_topic): + await self.at_cmd(message) except aiomqtt.MqttError: if Config.is_default('mqtt'): @@ -101,3 +120,54 @@ class Mqtt(metaclass=Singleton): 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()}") + + def each_inverter(self, message, func_name: str): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + found = False + for m in Message: + if m.server_side and (m.node_id == node_id): + found = True + 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}') + + if not found: + logger_mqtt.warning(f'Node_id: {node_id} not found') + + async def modbus_cmd(self, message, func, params=0, addr=0, val=0): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + # refactor into a loop over a table + payload = message.payload.decode("UTF-8") + logger_mqtt.info(f'MODBUS via MQTT: {topic} = {payload}') + for m in Message: + if m.server_side and (m.node_id == node_id): + logger_mqtt.debug(f'Found: {node_id}') + fnc = getattr(m, "send_modbus_cmd", None) + res = payload.split(',') + if params != len(res): + logger_mqtt.error(f'Parameter expected: {params}, ' + f'got: {len(res)}') + return + + if callable(fnc): + 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/app/src/scheduler.py b/app/src/scheduler.py index 5c4eb2d..785bbcd 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -3,21 +3,26 @@ import json from mqtt import Mqtt from aiocron import crontab from infos import ClrAtMidnight +from modbus import Modbus +from messages import Message 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.info("Scheduler init") + logging.debug("Scheduler init") cls.mqtt = Mqtt(None) crontab('0 0 * * *', func=cls.atmidnight, start=True) - # crontab('*/5 * * * *', func=cls.atmidnight, start=True) + + # every minute + crontab('* * * * *', func=cls.regular_modbus_cmds, start=True) @classmethod async def atmidnight(cls) -> None: @@ -28,3 +33,15 @@ class Schedule: logger_mqtt.debug(f'{key}: {data}') data_json = json.dumps(data) await cls.mqtt.publish(f"{key}", data_json) + + @classmethod + async def regular_modbus_cmds(cls): + for m in Message: + if m.server_side: + fnc = getattr(m, "send_modbus_cmd", None) + if callable(fnc): + await fnc(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG) + if 0 == (cls.count % 30): + # logging.info("Regular Modbus Status request") + await fnc(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG) + cls.count += 1 diff --git a/app/src/server.py b/app/src/server.py index 48fd346..18dc401 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -1,7 +1,6 @@ import logging import asyncio import signal -import functools import os from logging import config # noqa F401 from messages import Message @@ -26,13 +25,23 @@ async def handle_client_v2(reader, writer): await InverterG3P(reader, writer, addr).server_loop(addr) -def handle_SIGTERM(loop): +async def handle_shutdown(loop): '''Close all TCP connections and stop the event loop''' logging.info('Shutdown due to SIGTERM') # - # first, close all open TCP connections + # first, disc all open TCP connections gracefully + # + for stream in Message: + try: + await asyncio.wait_for(stream.disc(), 2) + except Exception: + pass + logging.info('Disconnecting done') + + # + # second, close all open TCP connections # for stream in Message: stream.close() @@ -74,10 +83,11 @@ if __name__ == "__main__": 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('mqtt').setLevel(log_level) # read config file - Config.read() + Config.class_init() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -91,7 +101,8 @@ if __name__ == "__main__": # for signame in ('SIGINT', 'SIGTERM'): loop.add_signal_handler(getattr(signal, signame), - functools.partial(handle_SIGTERM, loop)) + lambda loop=loop: asyncio.create_task( + handle_shutdown(loop))) # # Create taska for our listening servera. These must be tasks! If we call diff --git a/app/src/singleton.py b/app/src/singleton.py new file mode 100644 index 0000000..48778b9 --- /dev/null +++ b/app/src/singleton.py @@ -0,0 +1,9 @@ +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + # logger_mqtt.debug('singleton: __call__') + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, + cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/app/tests/test_config.py b/app/tests/test_config.py new file mode 100644 index 0000000..746d1d8 --- /dev/null +++ b/app/tests/test_config.py @@ -0,0 +1,142 @@ +# test_with_pytest.py +import tomllib +from schema import SchemaMissingKeyError +from app.src.config import Config + +class TstConfig(Config): + + @classmethod + def set(cls, cnf): + cls.config = cnf + + @classmethod + def _read_config_file(cls) -> dict: + return cls.config + + +def test_empty_config(): + cnf = {} + try: + Config.conf_schema.validate(cnf) + assert False + except SchemaMissingKeyError: + assert True + +def test_default_config(): + with open("app/config/default_config.toml", "rb") as f: + cnf = tomllib.load(f) + + try: + validated = Config.conf_schema.validate(cnf) + assert True + except: + assert False + assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}} + +def test_full_config(): + cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, + 'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, + 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, + 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, + 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''}, + 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, + 'inverters': {'allow_all': True, + 'R170000000000001': {'node_id': '', 'suggested_area': '', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}, 'pv3': {'type': 'type3', 'manufacturer': 'man3'}}, + 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}} + try: + validated = Config.conf_schema.validate(cnf) + assert True + except: + assert False + assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}} + +def test_mininum_config(): + cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, + 'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+']}, + 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE']}}}, + 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, + 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''}, + 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, + 'inverters': {'allow_all': True, + 'R170000000000001': {}} + } + + try: + validated = Config.conf_schema.validate(cnf) + assert True + except: + assert False + assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}}} + +def test_read_empty(): + cnf = {} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err == None + cnf = TstConfig.get() + assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}} + + defcnf = TstConfig.def_config.get('solarman') + assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000} + assert True == TstConfig.is_default('solarman') + +def test_no_file(): + cnf = {} + TstConfig.set(cnf) + err = TstConfig.read('') + assert err == "Config.read: [Errno 2] No such file or directory: 'default_config.toml'" + cnf = TstConfig.get() + assert cnf == {} + defcnf = TstConfig.def_config.get('solarman') + assert defcnf == None + +def test_read_cnf1(): + cnf = {'solarman' : {'enabled': False}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err == None + cnf = TstConfig.get() + assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}} + cnf = TstConfig.get('solarman') + assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000} + defcnf = TstConfig.def_config.get('solarman') + assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000} + assert False == TstConfig.is_default('solarman') + +def test_read_cnf2(): + cnf = {'solarman' : {'enabled': 'FALSE'}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err == None + cnf = TstConfig.get() + assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}} + assert True == TstConfig.is_default('solarman') + +def test_read_cnf3(): + cnf = {'solarman' : {'port': 'FALSE'}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err == 'Config.read: Key \'solarman\' error:\nKey \'port\' error:\nint(\'FALSE\') raised ValueError("invalid literal for int() with base 10: \'FALSE\'")' + cnf = TstConfig.get() + assert cnf == {'solarman': {'port': 'FALSE'}} + +def test_read_cnf4(): + cnf = {'solarman' : {'port': 5000}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err == None + cnf = TstConfig.get() + assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 5000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}} + assert False == TstConfig.is_default('solarman') + +def test_read_cnf5(): + cnf = {'solarman' : {'port': 1023}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err != None + +def test_read_cnf6(): + cnf = {'solarman' : {'port': 65536}} + TstConfig.set(cnf) + err = TstConfig.read('app/config/') + assert err != None diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py index d3b542e..4b23bfb 100644 --- a/app/tests/test_infos.py +++ b/app/tests/test_infos.py @@ -17,13 +17,13 @@ def test_statistic_counter(): assert val == None or val == 0 i.static_init() # initialize counter - assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr assert val == 0 i.inc_counter('Inverter_Cnt') - assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) val = i.dev_value(Register.INVERTER_CNT) assert val == 1 diff --git a/app/tests/test_infos_g3p.py b/app/tests/test_infos_g3p.py index c5609be..a127d13 100644 --- a/app/tests/test_infos_g3p.py +++ b/app/tests/test_infos_g3p.py @@ -70,7 +70,7 @@ def test_parse_4110(DeviceData: bytes): pass assert json.dumps(i.db) == json.dumps({ - 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 60, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Adress": "192.168.80.49"}, + 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "192.168.80.49"}, 'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"}, }) @@ -83,7 +83,7 @@ def test_parse_4210(InverterData: bytes): assert json.dumps(i.db) == json.dumps({ "controller": {"Power_On_Time": 2051}, - "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "v4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4}, + "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4}, "env": {"Inverter_Status": 1, "Inverter_Temp": 14}, "grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8}, "input": {"pv1": {"Voltage": 35.3, "Current": 1.68, "Power": 59.6, "Daily_Generation": 0.04, "Total_Generation": 30.76}, diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py new file mode 100644 index 0000000..b44cb12 --- /dev/null +++ b/app/tests/test_modbus.py @@ -0,0 +1,380 @@ +# test_with_pytest.py +import pytest +import asyncio +from app.src.modbus import Modbus +from app.src.infos import Infos, Register + +pytest_plugins = ('pytest_asyncio',) +pytestmark = pytest.mark.asyncio(scope="module") + +class TestHelper(Modbus): + def __init__(self): + super().__init__(self.send_cb) + self.db = Infos() + self.pdu = None + self.send_calls = 0 + self.recv_responses = 0 + def send_cb(self, pdu: bytearray, log_lvl: int, state: str): + self.pdu = pdu + self.send_calls += 1 + def resp_handler(self): + self.recv_responses += 1 + +def test_modbus_crc(): + '''Check CRC-16 calculation''' + mb = Modbus(None) + assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') + assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') + assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') + + assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00') + assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + + assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') + +def test_build_modbus_pdu(): + '''Check building and sending a MODBUS RTU''' + mb = TestHelper() + mb.build_msg(1,6,0x2000,0x12) + assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' + assert mb._Modbus__check_crc(mb.pdu) + assert mb.last_addr == 1 + assert mb.last_fcode == 6 + assert mb.last_reg == 0x2000 + assert mb.last_len == 18 + assert mb.err == 0 + +def test_recv_req(): + '''Receive a valid request, which must transmitted''' + mb = TestHelper() + assert mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') + assert mb.last_fcode == 6 + assert mb.last_reg == 0x2000 + assert mb.last_len == 0x12 + assert mb.err == 0 + +def test_recv_req_crc_err(): + '''Receive a request with invalid CRC, which must be dropped''' + mb = TestHelper() + assert not mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') + assert mb.pdu == None + assert mb.last_fcode == 0 + assert mb.last_reg == 0 + assert mb.last_len == 0 + assert mb.err == 1 + +def test_recv_resp_crc_err(): + '''Receive a response with invalid CRC, which must be dropped''' + mb = TestHelper() + # simulate a transmitted request + mb.req_pend = True + mb.last_addr = 1 + mb.last_fcode = 3 + mb.last_reg == 0x300e + mb.last_len == 2 + # check matching response, but with CRC error + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'): + call += 1 + assert mb.err == 1 + assert 0 == call + assert mb.req_pend == True + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend + +def test_recv_resp_invalid_addr(): + '''Receive a response with wrong server addr, which must be dropped''' + mb = TestHelper() + mb.req_pend = True + # simulate a transmitted request + mb.last_addr = 1 + mb.last_fcode = 3 + mb.last_reg == 0x300e + mb.last_len == 2 + + # check not matching response, with wrong server addr + call = 0 + for key, update in mb.recv_resp(mb.db, b'\x02\x03\x04\x01\x2c\x00\x46\x88\xf4', 'test'): + call += 1 + assert mb.err == 2 + assert 0 == call + assert mb.req_pend == True + assert mb.que.qsize() == 0 + + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend + +def test_recv_recv_fcode(): + '''Receive a response with wrong function code, which must be dropped''' + mb = TestHelper() + mb.build_msg(1,4,0x300e,2) + assert mb.que.qsize() == 0 + assert mb.req_pend + + # check not matching response, with wrong function code + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + + assert mb.err == 3 + assert 0 == call + assert mb.req_pend == True + assert mb.que.qsize() == 0 + + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend + +def test_recv_resp_len(): + '''Receive a response with wrong data length, which must be dropped''' + mb = TestHelper() + mb.build_msg(1,3,0x300e,3) + assert mb.que.qsize() == 0 + assert mb.req_pend + assert mb.last_len == 3 + + # check not matching response, with wrong data length + call = 0 + for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + + assert mb.err == 4 + assert 0 == call + assert mb.req_pend == True + assert mb.que.qsize() == 0 + + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend + +def test_recv_unexpect_resp(): + '''Receive a response when we havb't sent a request''' + mb = TestHelper() + assert not mb.req_pend + + # check unexpected response, which must be dropped + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + + assert mb.err == 5 + assert 0 == call + assert mb.req_pend == False + assert mb.que.qsize() == 0 + +def test_parse_resp(): + '''Receive matching response and parse the values''' + mb = TestHelper() + mb.build_msg(1,3,0x3007,6) + assert mb.que.qsize() == 0 + assert mb.req_pend + + call = 0 + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + assert mb.que.qsize() == 0 + assert not mb.req_pend + +def test_queue(): + mb = TestHelper() + mb.build_msg(1,3,0x3022,4) + assert mb.que.qsize() == 0 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03' + mb.pdu = None + assert mb.send_calls == 1 + assert mb.pdu == None + + assert mb.que.qsize() == 0 + + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend + +def test_queue2(): + '''Check queue handling for build_msg() calls''' + mb = TestHelper() + mb.build_msg(1,3,0x3007,6) + mb.build_msg(1,6,0x2008,4) + assert mb.que.qsize() == 1 + assert mb.req_pend + mb.build_msg(1,3,0x3007,6) + assert mb.que.qsize() == 2 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + call = 0 + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + + assert mb.que.qsize() == 1 + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): + pass + + assert mb.que.qsize() == 0 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + call += 1 + assert 0 == mb.err + assert 5 == call + + assert mb.que.qsize() == 0 + assert not mb.req_pend + +def test_queue3(): + '''Check queue handling for recv_req() calls''' + mb = TestHelper() + assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t', mb.resp_handler) + assert mb.recv_req(b'\x01\x06\x20\x08\x00\x04\x02\x0b', mb.resp_handler) + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t') + assert mb.que.qsize() == 2 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + assert mb.recv_responses == 0 + + call = 0 + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + assert mb.recv_responses == 1 + + assert mb.que.qsize() == 1 + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): + pass + assert 0 == mb.err + assert mb.recv_responses == 2 + + assert mb.que.qsize() == 0 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + call += 1 + assert 0 == mb.err + assert mb.recv_responses == 2 + assert 5 == call + + + assert mb.que.qsize() == 0 + assert not mb.req_pend + +@pytest.mark.asyncio +async def test_timeout(): + '''Test MODBUS response timeout and RTU retransmitting''' + assert asyncio.get_running_loop() + mb = TestHelper() + mb.max_retries = 2 + mb.timeout = 0.1 # 100ms timeout for fast testing, expect a time resolution of at least 10ms + assert asyncio.get_running_loop() == mb.loop + mb.build_msg(1,3,0x3007,6) + mb.build_msg(1,6,0x2008,4) + + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.retry_cnt == 0 + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + + mb.pdu = None + await asyncio.sleep(0.11) # wait for first timeout and retransmittion + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.retry_cnt == 1 + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + + mb.pdu = None + await asyncio.sleep(0.11) # wait for second timeout and retransmittion + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.retry_cnt == 2 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + + mb.pdu = None + await asyncio.sleep(0.11) # wait for third timeout and next pdu + assert mb.que.qsize() == 0 + assert mb.req_pend + assert mb.retry_cnt == 0 + assert mb.send_calls == 4 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + mb.max_retries = 0 # next pdu without retranmsission + await asyncio.sleep(0.11) # wait for fourth timout + assert mb.que.qsize() == 0 + assert not mb.req_pend + assert mb.retry_cnt == 0 + assert mb.send_calls == 4 + + # assert mb.counter == {} + +def test_recv_unknown_data(): + '''Receive a response with an unknwon register''' + mb = TestHelper() + assert 0x9000 not in mb.map + mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1} + + mb.build_msg(1,3,0x9000,2) + + # check matching response, but with CRC error + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + assert mb.err == 0 + assert 0 == call + assert not mb.req_pend + + del mb.map[0x9000] diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 48f5509..9deae56 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -1,10 +1,15 @@ import pytest import struct import time +import logging 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 +from app.src.modbus import Modbus + + +pytest_plugins = ('pytest_asyncio',) # initialize the proxy statistics Infos.static_init() @@ -12,9 +17,19 @@ Infos.static_init() timestamp = int(time.time()) # 1712861197 heartbeat = 60 +class Writer(): + def __init__(self): + self.sent_pdu = b'' + + def write(self, pdu: bytearray): + self.sent_pdu = pdu + class MemoryStream(SolarmanV5): def __init__(self, msg, chunks = (0,), server_side: bool = True): super().__init__(server_side) + if server_side: + self.mb.timeout = 1 # overwrite for faster testing + self.writer = Writer() self.__msg = msg self.__msg_len = len(msg) self.__chunks = chunks @@ -24,13 +39,16 @@ class MemoryStream(SolarmanV5): self.addr = 'Test: SrvSide' self.db.stat['proxy']['Invalid_Msg_Format'] = 0 self.db.stat['proxy']['AT_Command'] = 0 + self.db.stat['proxy']['AT_Command_Blocked'] = 0 + self.test_exception_async_write = False + self.entity_prfx = '' + self.at_acl = {'mqtt': {'allow': ['AT+'], 'block': ['AT+WEBU']}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE', 'AT+TIME'], 'block': ['AT+WEBU']}} def _timestamp(self): return timestamp def _heartbeat(self) -> int: return heartbeat - def append_msg(self, msg): self.__msg += msg @@ -54,6 +72,16 @@ class MemoryStream(SolarmanV5): pass return copied_bytes + async def async_write(self, headline=''): + if self.test_exception_async_write: + raise RuntimeError("Peer closed.") + + def createClientStream(self, msg, chunks = (0,)): + c = MemoryStream(msg, chunks, False) + self.remoteStream = c + c. remoteStream = self + return c + def _SolarmanV5__flush_recv_msg(self) -> None: super()._SolarmanV5__flush_recv_msg() self.msg_count += 1 @@ -308,6 +336,72 @@ def InverterIndMsg2000(): # 0x4210 rated Power 2000W msg += b'\x15' return msg +@pytest.fixture +def InverterIndMsg800(): # 0x4210 rated Power 800W + msg = b'\xa5\x99\x01\x10\x42\xe6\x9e' +get_sn() +b'\x01\xb0\x02\xbc\xc8' + msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00' + msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x03\x20\x06\x7a' + msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd' + msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04' + msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75' + msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + msg += b'\x00\x00\x00\x00\xff\xff\x03\x20\x00\x03\x04\x00\x04\x00\x04\x00' + msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' + msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' + msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c' + msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05' + msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' + msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def InverterIndMsg_81(): # 0x4210 fcode 0x81 + msg = b'\xa5\x99\x01\x10\x42\x02\x03' +get_sn() +b'\x81\xb0\x02\xbc\xc8' + msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x07\x04\x03\x01\x00\x03\x08\x00\x00' + msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x02\x58\x06\x7a' + msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd' + msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04' + msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75' + msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00' + msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' + msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' + msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c' + msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05' + msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' + msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def InverterRspMsg(): # 0x1210 msg = b'\xa5\x0a\x00\x10\x12\x02\02' +get_sn() +b'\x01\x01' @@ -317,6 +411,15 @@ def InverterRspMsg(): # 0x1210 msg += b'\x15' return msg +@pytest.fixture +def InverterRspMsg_81(): # 0x1210 fcode 0x81 + msg = b'\xa5\x0a\x00\x10\x12\x03\03' +get_sn() +b'\x81\x01' + msg += total() + msg += hb() + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def UnknownMsg(): # 0x5110 msg = b'\xa5\x0a\x00\x10\x51\x10\x84' +get_sn() +b'\x01\x01\x69\x6f\x09' @@ -346,7 +449,7 @@ def SyncStartRspMsg(): # 0x1310 @pytest.fixture def SyncStartFwdMsg(): # 0x4310 - msg = b'\xa5\x2f\x00\x10\x43\x0e\x0d' +get_sn() +b'\x81\x7a\x0b\x2e\x32' + msg = b'\xa5\x2f\x00\x10\x43\x0d\x0e' +get_sn() +b'\x81\x7a\x0b\x2e\x32' msg += b'\x39\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x41\x6c\x6c\x69\x75\x73' msg += b'\x2d\x48\x6f\x6d\x65\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x61\x01' @@ -357,16 +460,25 @@ def SyncStartFwdMsg(): # 0x4310 @pytest.fixture def AtCommandIndMsg(): # 0x4510 - msg = b'\xa5\x27\x00\x10\x45\x02\x01' +get_sn() +b'\x01\x02\x00' + msg = b'\xa5\x27\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'AT+TIME=214028,1,60,120\r' msg += correct_checksum(msg) msg += b'\x15' return msg +@pytest.fixture +def AtCommandIndMsgBlock(): # 0x4510 + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'AT+WEBU\r' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def AtCommandRspMsg(): # 0x1510 - msg = b'\xa5\x0a\x00\x10\x15\x02\x02' +get_sn() +b'\x01\x01' + msg = b'\xa5\x0a\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01' msg += total() msg += hb() msg += correct_checksum(msg) @@ -410,6 +522,72 @@ def SyncEndRspMsg(): # 0x1810 msg += b'\x15' return msg +@pytest.fixture +def MsgModbusCmd(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgModbusCmdCrcErr(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x04\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgModbusRsp(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgModbusInvalid(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x00' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgUnknownCmd(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x03\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgUnknownCmdRsp(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x03\x01' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def ConfigTsunAllowAll(): Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}} @@ -693,6 +871,52 @@ def test_read_two_messages(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, Inver assert m._send_buffer==b'' m.close() +def test_read_two_messages2(ConfigTsunAllowAll, InverterIndMsg, InverterIndMsg_81, InverterRspMsg, InverterRspMsg_81): + ConfigTsunAllowAll + m = MemoryStream(InverterIndMsg, (0,)) + m.append_msg(InverterIndMsg_81) + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + assert m.unique_id == '2070233889' + assert m.control == 0x4210 + assert m.time_ofs == 0x33e447a0 + assert str(m.seq) == '02:02' + assert m.data_len == 0x199 + assert m.msg_count == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m._forward_buffer==InverterIndMsg + assert m._send_buffer==InverterRspMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._init_new_client_conn() + assert m._send_buffer==b'' + assert m._recv_buffer==InverterIndMsg_81 + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear forward buffer for next test + m.read() # read complete msg, and dispatch msg + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 2 + assert m.header_len==11 + assert m.snr == 2070233889 + assert m.unique_id == '2070233889' + assert m.control == 0x4210 + assert m.time_ofs == 0x33e447a0 + assert str(m.seq) == '03:03' + assert m.data_len == 0x199 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m._forward_buffer==InverterIndMsg_81 + assert m._send_buffer==InverterRspMsg_81 + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._init_new_client_conn() + assert m._send_buffer==b'' + m.close() + def test_unkown_message(ConfigTsunInv1, UnknownMsg): ConfigTsunInv1 m = MemoryStream(UnknownMsg, (0,)) @@ -725,7 +949,7 @@ def test_device_rsp(ConfigTsunInv1, DeviceRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # DeviceRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -743,7 +967,7 @@ def test_inverter_rsp(ConfigTsunInv1, InverterRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # InverterRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -779,7 +1003,7 @@ def test_heartbeat_rsp(ConfigTsunInv1, HeartbeatRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -800,6 +1024,7 @@ def test_sync_start_ind(ConfigTsunInv1, SyncStartIndMsg, SyncStartRspMsg, SyncSt assert m._forward_buffer==SyncStartIndMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.seq.server_side = False # simulate forawding to TSUN cloud m._update_header(m._forward_buffer) assert str(m.seq) == '0d:0e' # value after forwarding indication assert m._forward_buffer==SyncStartFwdMsg @@ -820,7 +1045,7 @@ def test_sync_start_rsp(ConfigTsunInv1, SyncStartRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -856,29 +1081,10 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() -def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg, AtCommandRspMsg): - ConfigTsunInv1 - m = MemoryStream(AtCommandIndMsg, (0,), False) - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.header_len==11 - assert m.snr == 2070233889 - # assert m.unique_id == '2070233889' - assert m.control == 0x4510 - assert str(m.seq) == '02:02' - assert m.data_len == 39 - assert m._recv_buffer==b'' - assert m._send_buffer==AtCommandRspMsg - assert m._forward_buffer==AtCommandIndMsg - assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 - assert m.db.stat['proxy']['AT_Command'] == 1 - m.close() - def test_build_modell_600(ConfigTsunAllowAll, InverterIndMsg): ConfigTsunAllowAll m = MemoryStream(InverterIndMsg, (0,)) @@ -931,6 +1137,18 @@ def test_build_modell_2000(ConfigTsunAllowAll, InverterIndMsg2000): assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) m.close() +def test_build_modell_800(ConfigTsunAllowAll, InverterIndMsg800): + ConfigTsunAllowAll + m = MemoryStream(InverterIndMsg800, (0,)) + assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) + assert None == m.db.get_db_value(Register.RATED_POWER, None) + assert None == m.db.get_db_value(Register.INVERTER_TEMP, None) + m.read() # read complete msg, and dispatch msg + assert 800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) + assert 800 == m.db.get_db_value(Register.RATED_POWER, 0) + assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) + m.close() + def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): ConfigTsunAllowAll m = MemoryStream(DeviceIndMsg, (0,)) @@ -942,21 +1160,505 @@ def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): assert 'V1.1.00.0B' == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0).rstrip('\00') m.close() -def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg): - ConfigTsunAllowAll +def test_msg_iterator(): + m1 = SolarmanV5(server_side=True) + m2 = SolarmanV5(server_side=True) + m3 = SolarmanV5(server_side=True) + m3.close() + del m3 + test1 = 0 + test2 = 0 + for key in SolarmanV5: + if key == m1: + test1+=1 + elif key == m2: + test2+=1 + elif type(key) != SolarmanV5: + continue + else: + assert False + assert test1 == 1 + assert test2 == 1 + +def test_proxy_counter(): + m = SolarmanV5(server_side=True) + assert m.new_data == {} + m.db.stat['proxy']['Unknown_Msg'] = 0 + Infos.new_stat_data['proxy'] = False + + m.inc_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert 1 == m.db.stat['proxy']['Unknown_Msg'] + + Infos.new_stat_data['proxy'] = False + m.dec_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert 0 == m.db.stat['proxy']['Unknown_Msg'] + m.close() + +@pytest.mark.asyncio +async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, MsgModbusCmd): + ConfigTsunInv1 m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) m.read() assert m.control == 0x4110 assert str(m.seq) == '01:01' - assert m._recv_buffer==b'' + assert m._recv_buffer==InverterIndMsg # unhandled next message assert m._send_buffer==DeviceRspMsg assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m.writer.sent_pdu == b'' # modbus command must be ignore, cause connection is still not up + assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up + + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m.writer.sent_pdu == MsgModbusCmd + assert m._send_buffer == b'' + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + m.close() + +@pytest.mark.asyncio +async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): + ConfigTsunAllowAll + m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) + m.read() + assert m.control == 0x4110 + assert str(m.seq) == '01:01' + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==DeviceRspMsg + assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+TIME=214028,1,60,120') + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '01:01' + + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg m._send_buffer = bytearray(0) # clear send buffer for next test m._forward_buffer = bytearray(0) # clear send buffer for next test - m.send_at_cmd('AT+TIME=214028,1,60,120') + await m.send_at_cmd('AT+TIME=214028,1,60,120') assert m._recv_buffer==b'' assert m._send_buffer==AtCommandIndMsg assert m._forward_buffer==b'' - assert str(m.seq) == '01:02' + assert str(m.seq) == '02:03' + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_at_cmd('AT+TIME=214028,1,60,120') + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '02:04' + assert m.forward_at_cmd_resp == False m.close() + +@pytest.mark.asyncio +async def test_AT_cmd_blocked(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): + ConfigTsunAllowAll + m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) + m.read() + assert m.control == 0x4110 + assert str(m.seq) == '01:01' + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==DeviceRspMsg + assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+WEBU') + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '01:01' + + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+WEBU') + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '02:02' + assert m.forward_at_cmd_resp == False + m.close() + +def test_AT_cmd_ind(ConfigTsunInv1, AtCommandIndMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandIndMsg, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['AT_Command_Blocked'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + # assert m.unique_id == '2070233889' + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.data_len == 39 + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==AtCommandIndMsg + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 1 + assert m.db.stat['proxy']['AT_Command_Blocked'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_AT_cmd_ind_block(ConfigTsunInv1, AtCommandIndMsgBlock): + ConfigTsunInv1 + m = MemoryStream(AtCommandIndMsgBlock, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['AT_Command_Blocked'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + # assert m.unique_id == '2070233889' + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.data_len == 23 + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['AT_Command_Blocked'] == 1 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_at_command_rsp1(ConfigTsunInv1, AtCommandRspMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandRspMsg) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_at_cmd_resp = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==10 + assert m._forward_buffer==AtCommandRspMsg + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_at_command_rsp2(ConfigTsunInv1, AtCommandRspMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandRspMsg) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_at_cmd_resp = False + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==10 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmd) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.control == 0x4510 + assert str(c.seq) == '03:02' + assert c.header_len==11 + assert c.data_len==23 + assert c._forward_buffer==MsgModbusCmd + assert c._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): + ConfigTsunInv1 + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmdCrcErr) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.control == 0x4510 + assert str(c.seq) == '03:02' + assert c.header_len==11 + assert c.data_len==23 + assert c._forward_buffer==MsgModbusCmdCrcErr + assert c._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 + m.close() + +def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): + ConfigTsunInv1 + m = MemoryStream(MsgUnknownCmd, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.header_len==11 + assert m.data_len==23 + assert m._forward_buffer==MsgUnknownCmd + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response without a valid Modbus request must be dropped''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==59 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp) + m.append_msg(MsgModbusRsp) + + m.mb.rsp_handler = m._SolarmanV5__forward_msg + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + + m.mb.req_pend = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp) + m.append_msg(MsgModbusRsp) + + m.mb.rsp_handler = m._SolarmanV5__forward_msg + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 5 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_unknown_rsp(ConfigTsunInv1, MsgUnknownCmdRsp): + ConfigTsunInv1 + m = MemoryStream(MsgUnknownCmdRsp) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==59 + assert m._forward_buffer==MsgUnknownCmdRsp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInvalid): + ConfigTsunInv1 + m = MemoryStream(MsgModbusInvalid, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + # receive more bytes than expected (7 bytes from the next msg) + m = MemoryStream(MsgModbusRsp+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.mb.rsp_handler = m._SolarmanV5__forward_msg + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer == b'' + assert m.mb.err == 0 + assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() +''' +def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): + ConfigTsunInv1 + tracer.setLevel(logging.DEBUG) + m1 = MemoryStream(MsgInverterInd, (0,)) + m2 = MemoryStream(MsgInverterInd, (0,)) + m3 = MemoryStream(MsgInverterInd, (0,)) + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m1.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m2.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m3.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_CLOSED + assert m3.state == m3.STATE_INIT + m1.close() + m2.close() + m3.close() +''' \ No newline at end of file diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 89fd420..102979c 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -2,16 +2,31 @@ import pytest, logging from app.src.gen3.talent import Talent, Control from app.src.config import Config -from app.src.infos import Infos +from app.src.infos import Infos, Register +from app.src.modbus import Modbus + + +pytest_plugins = ('pytest_asyncio',) # initialize the proxy statistics Infos.static_init() tracer = logging.getLogger('tracer') - + + +class Writer(): + def __init__(self): + self.sent_pdu = b'' + + def write(self, pdu: bytearray): + self.sent_pdu = pdu + class MemoryStream(Talent): def __init__(self, msg, chunks = (0,), server_side: bool = True): super().__init__(server_side) + if server_side: + self.mb.timeout = 1 # overwrite for faster testing + self.writer = Writer() self.__msg = msg self.__msg_len = len(msg) self.__chunks = chunks @@ -19,6 +34,8 @@ class MemoryStream(Talent): self.__chunk_idx = 0 self.msg_count = 0 self.addr = 'Test: SrvSide' + self.send_msg_ofs = 0 + self.test_exception_async_write = False def append_msg(self, msg): self.__msg += msg @@ -45,11 +62,22 @@ class MemoryStream(Talent): def _timestamp(self): return 1700260990000 + def createClientStream(self, msg, chunks = (0,)): + c = MemoryStream(msg, chunks, False) + self.remoteStream = c + c. remoteStream = self + return c + def _Talent__flush_recv_msg(self) -> None: super()._Talent__flush_recv_msg() self.msg_count += 1 return + async def async_write(self, headline=''): + if self.test_exception_async_write: + raise RuntimeError("Peer closed.") + + @pytest.fixture def MsgContactInfo(): # Contact Info message @@ -170,6 +198,42 @@ def MsgOtaAck(): # Over the air update rewuest from tsun cloud def MsgOtaInvalid(): # Get Time Request message return b'\x00\x00\x00\x14\x10R170000000000001\x99\x13\x01' +@pytest.fixture +def MsgModbusCmd(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg + +@pytest.fixture +def MsgModbusCmdCrcErr(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x04\xc8' + return msg + +@pytest.fixture +def MsgModbusRsp(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x91\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg + +@pytest.fixture +def MsgModbusInv(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x99\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg + +@pytest.fixture +def MsgModbusResp20(): + msg = b'\x00\x00\x00\x45\x10R170000000000001' + msg += b'\x91\x77\x17\x18\x19\x1a\x2d\x01\x03\x28\x51' + msg += b'\x09\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00' + msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\xdb\x6b' + return msg def test_read_message(MsgContactInfo): m = MemoryStream(MsgContactInfo, (0,)) @@ -695,10 +759,16 @@ def test_msg_unknown(ConfigTsunInv1, MsgUnknown): m.close() def test_ctrl_byte(): + c = Control(0x70) + assert not c.is_ind() + assert not c.is_resp() + assert c.is_req() c = Control(0x91) + assert not c.is_req() assert c.is_ind() assert not c.is_resp() c = Control(0x99) + assert not c.is_req() assert not c.is_ind() assert c.is_resp() @@ -724,19 +794,355 @@ def test_msg_iterator(): assert test2 == 1 def test_proxy_counter(): - m = Talent(server_side=True) + # m = MemoryStream(b'') + # m.close() + Infos.stat['proxy']['Modbus_Command'] = 1 + + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + c = m.createClientStream(b'') + assert m.new_data == {} m.db.stat['proxy']['Unknown_Msg'] = 0 + c.db.stat['proxy']['Unknown_Msg'] = 0 Infos.new_stat_data['proxy'] = False m.inc_counter('Unknown_Msg') + m.close() + m = MemoryStream(b'') + assert m.new_data == {} assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} assert 1 == m.db.stat['proxy']['Unknown_Msg'] + assert 1 == c.db.stat['proxy']['Unknown_Msg'] + Infos.new_stat_data['proxy'] = False + + c.inc_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} + assert 2 == m.db.stat['proxy']['Unknown_Msg'] + assert 2 == c.db.stat['proxy']['Unknown_Msg'] + Infos.new_stat_data['proxy'] = False + + c.inc_counter('Modbus_Command') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} + assert 2 == m.db.stat['proxy']['Modbus_Command'] + assert 2 == c.db.stat['proxy']['Modbus_Command'] Infos.new_stat_data['proxy'] = False m.dec_counter('Unknown_Msg') assert m.new_data == {} assert Infos.new_stat_data == {'proxy': True} - assert 0 == m.db.stat['proxy']['Unknown_Msg'] + assert 1 == m.db.stat['proxy']['Unknown_Msg'] m.close() + +def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + m.state = m.STATE_UP + + c = m.createClientStream(MsgModbusCmd) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==b'' + assert c._send_buffer==b'' + assert m.id_str == b"R170000000000001" + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu == MsgModbusCmd + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + + c = m.createClientStream(MsgModbusCmd) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==b'' + assert c._send_buffer==b'' + assert m.id_str == b"R170000000000001" + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu == b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_req3(ConfigTsunInv1, MsgModbusCmdCrcErr): + ConfigTsunInv1 + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + c = m.createClientStream(MsgModbusCmdCrcErr) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==b'' + assert c._send_buffer==b'' + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu ==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 + m.close() + +def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response without a valid Modbus request must be dropped''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==145 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusResp20): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusResp20) + m.append_msg(MsgModbusResp20) + + m.mb.rsp_handler = m.msg_forward + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + + assert m.db.db == {} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == True + + m.new_data['inverter'] = False + m.mb.req_pend = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusResp20) + m.append_msg(MsgModbusResp20) + + m.mb.rsp_handler = m.msg_forward + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + + assert m.db.db == {} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + assert m.mb.req_pend == False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 5 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): + ConfigTsunInv1 + m = MemoryStream(MsgModbusInv, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==153 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==MsgModbusInv + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 1 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): + ConfigTsunInv1 + # receive more bytes than expected (7 bytes from the next msg) + m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.mb.rsp_handler = m.msg_forward + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl) == 0x91 + assert m.msg_id == 119 + assert m.header_len == 23 + assert m.data_len == 50 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer == b'' + assert m.mb.err == 0 + assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +@pytest.mark.asyncio +async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'', (0,), True) + m.id_str = b"R170000000000001" + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + assert m.writer.sent_pdu == b'' + + m.state = m.STATE_UP + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + assert m.writer.sent_pdu == MsgModbusCmd + + m.writer.sent_pdu = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + assert m.writer.sent_pdu == b'' + m.close() +''' +def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): + ConfigTsunInv1 + tracer.setLevel(logging.DEBUG) + start_val = MemoryStream._RefNo + + m1 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 1 + start_val + assert m1.RefNo == 1 + start_val + m2 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 2 + start_val + assert m2.RefNo == 2 + start_val + m3 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 3 + start_val + assert m3.RefNo == 3 + start_val + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m1.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_UP + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m2.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_UP + assert m3.state == m3.STATE_INIT + m3.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_CLOSED + assert m3.state == m3.STATE_UP + m1.close() + m2.close() + m3.close() +''' \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index de3e5e5..4566a80 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,3 @@ - -version: '3.0' - services: ####### H O M E - A S S I S T A N T ##### home-assistant: @@ -34,7 +31,7 @@ services: ports: - 8123:8123 volumes: - - ${PROJECT_DIR}./homeassistant/config:/config + - ${PROJECT_DIR:-./}homeassistant/config:/config - /etc/localtime:/etc/localtime:ro healthcheck: test: curl --fail http://0.0.0.0:8123/auth/providers || exit 1 @@ -56,18 +53,18 @@ services: expose: - 1883 volumes: - - ${PROJECT_DIR}./mosquitto/config:/mosquitto/config - - ${PROJECT_DIR}./mosquitto/data:/mosquitto/data + - ${PROJECT_DIR:-./}mosquitto/config:/mosquitto/config + - ${PROJECT_DIR:-./}mosquitto/data:/mosquitto/data networks: - outside: - ipv4_address: 172.28.1.5 # static IP required to receive mDNS traffic - + - outside + ####### T S U N - P R O X Y ###### tsun-proxy: container_name: tsun-proxy image: ghcr.io/s-allius/tsun-gen3-proxy:latest + # image: ghcr.io/s-allius/tsun-gen3-proxy:rc restart: unless-stopped depends_on: - mqtt @@ -77,13 +74,13 @@ services: - GID=${GID:-1000} dns: - ${DNS1:-8.8.8.8} - - $(DNS2:-4.4.4.4} + - ${DNS2:-4.4.4.4} ports: - 5005:5005 - 10000:10000 volumes: - - ${PROJECT_DIR}./tsun-proxy/log:/home/tsun-proxy/log - - ${PROJECT_DIR}./tsun-proxy/config:/home/tsun-proxy/config + - ${PROJECT_DIR:-./}tsun-proxy/log:/home/tsun-proxy/log + - ${PROJECT_DIR:-./}tsun-proxy/config:/home/tsun-proxy/config networks: - outside @@ -93,11 +90,4 @@ services: networks: outside: name: home-assistant - external: true - ipam: - driver: default - config: - - subnet: 172.28.1.0/26 - ip_range: 172.28.1.32/27 - gateway: 172.28.1.62 \ No newline at end of file diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py index 606ea68..e2b64f8 100644 --- a/system_tests/test_tcp_socket.py +++ b/system_tests/test_tcp_socket.py @@ -224,7 +224,7 @@ def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgI data = s.recv(1024) except TimeoutError: pass - # time.sleep(32.5) + # time.sleep(32.5) # assert data == MsgTimeStampResp try: s.sendall(MsgInvData) diff --git a/system_tests/test_tcp_socket_v2.py b/system_tests/test_tcp_socket_v2.py index a4326db..c41b41c 100644 --- a/system_tests/test_tcp_socket_v2.py +++ b/system_tests/test_tcp_socket_v2.py @@ -19,6 +19,9 @@ def get_inv_no() -> bytes: def get_invalid_sn(): return b'R170000000000002' +def correct_checksum(buf): + checksum = sum(buf[1:]) & 0xff + return checksum.to_bytes(length=1) @pytest.fixture def MsgContactInfo(): # Contact Info message @@ -61,10 +64,11 @@ def MsgDataInd(): msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x01\x12\x02\x12\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x40\x10\x08\xd8\x00\x09\x13\x84\x00\x35\x00\x00\x02\x58\x00\xd8' - msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18' + msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18' msg += b'\x00\x52\x00\x12\x00\x01\x00\x00\x00\x7c\x00\x00\x24\xed\x00\x2c' msg += b'\x00\x00\x0b\x10\x00\x26\x00\x00\x0a\x0f\x00\x30\x00\x00\x0b\x76' - msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + + msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x01\x55\xaa\x00\x01\x00\x00' msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00' msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' @@ -73,7 +77,9 @@ def MsgDataInd(): msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' - msg += b'\x00\x00\x00\x00\x24\x15' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' return msg @pytest.fixture @@ -147,4 +153,6 @@ def test_data_ind(ClientConnection,MsgDataInd, MsgDataResp): except TimeoutError: pass # time.sleep(2.5) - checkResponse(data, MsgDataResp) \ No newline at end of file + checkResponse(data, MsgDataResp) + + \ No newline at end of file