diff --git a/app/Dockerfile b/app/Dockerfile index 90e8b0e..300c1e0 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -62,18 +62,10 @@ RUN python -m pip install --no-cache --no-index /root/wheels/* && \ COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh COPY config . COPY src . -RUN echo ${VERSION} > /proxy-version.txt -RUN date > /build-date.txt +RUN echo ${VERSION} > /proxy-version.txt \ + && date > /build-date.txt EXPOSE 5005 8127 10000 # command to run on container start ENTRYPOINT ["/root/entrypoint.sh"] CMD [ "python3", "./server.py" ] - - -LABEL org.opencontainers.image.title="TSUN Gen3 Proxy" -LABEL org.opencontainers.image.authors="Stefan Allius" -LABEL org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy -LABEL org.opencontainers.image.description='This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.' -LABEL org.opencontainers.image.licenses="BSD-3-Clause" -LABEL org.opencontainers.image.vendor="Stefan Allius" diff --git a/app/build.sh b/app/build.sh index 7d66c91..ac1c23c 100755 --- a/app/build.sh +++ b/app/build.sh @@ -17,6 +17,7 @@ VERSION="${VERSION:1}" arr=(${VERSION//./ }) MAJOR=${arr[0]} IMAGE=tsun-gen3-proxy + GREEN='\033[0;32m' BLUE='\033[0;34m' NC='\033[0m' @@ -26,44 +27,22 @@ IMAGE=docker.io/sallius/${IMAGE} VERSION=${VERSION}+$1 elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then IMAGE=ghcr.io/s-allius/${IMAGE} +echo 'login to ghcr.io' +echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin else echo argument missing! echo try: $0 '[debug|dev|preview|rc|rel]' exit 1 fi -if [[ $1 == debug ]] ;then -BUILD_ENV="dev" -else -BUILD_ENV="production" -fi - -BUILD_CMD="buildx build --push --build-arg VERSION=${VERSION} --build-arg environment=${BUILD_ENV} --attest type=provenance,mode=max --attest type=sbom,generator=docker/scout-sbom-indexer:latest" -ARCH="--platform linux/amd64,linux/arm64,linux/arm/v7" -LABELS="--label org.opencontainers.image.created=${BUILD_DATE} --label org.opencontainers.image.version=${VERSION} --label org.opencontainers.image.revision=${BRANCH}" +export IMAGE +export VERSION +export BUILD_DATE +export BRANCH +export MAJOR echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE -if [[ $1 == debug ]];then -docker ${BUILD_CMD} ${ARCH} ${LABELS} --build-arg "LOG_LVL=DEBUG" -t ${IMAGE}:debug app - -elif [[ $1 == dev ]];then -docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:dev app - -elif [[ $1 == preview ]];then -echo 'login to ghcr.io' -echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin -docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:preview -t ${IMAGE}:${VERSION} app - -elif [[ $1 == rc ]];then -echo 'login to ghcr.io' -echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin -docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app - -elif [[ $1 == rel ]];then -echo 'login to ghcr.io' -echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin -docker ${BUILD_CMD} ${ARCH} ${LABELS} --no-cache -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app -fi +docker buildx bake -f app/docker-bake.hcl $1 echo -e "${BLUE} => checking docker-compose.yaml file${NC}" docker-compose config -q diff --git a/app/docker-bake.hcl b/app/docker-bake.hcl new file mode 100644 index 0000000..b6d58d9 --- /dev/null +++ b/app/docker-bake.hcl @@ -0,0 +1,93 @@ +variable "IMAGE" { + default = "tsun-gen3-proxy" +} +variable "VERSION" { + default = "0.0.0" +} +variable "MAJOR" { + default = "0" +} +variable "BUILD_DATE" { + default = "dev" +} +variable "BRANCH" { + default = "" +} +variable "DESCRIPTION" { + default = "This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations." +} + +target "_common" { + context = "app" + dockerfile = "Dockerfile" + args = { + VERSION = "${VERSION}" + environment = "production" + } + attest = [ + "type =provenance,mode=max", + "type =sbom,generator=docker/scout-sbom-indexer:latest" + ] + annotations = [ + "index:org.opencontainers.image.title=TSUN Gen3 Proxy", + "index:org.opencontainers.image.authors=Stefan Allius", + "index:org.opencontainers.image.created=${BUILD_DATE}", + "index:org.opencontainers.image.version=${VERSION}", + "index:org.opencontainers.image.revision=${BRANCH}", + "index:org.opencontainers.image.description=${DESCRIPTION}", + "index:org.opencontainers.image.licenses=BSD-3-Clause", + "index:org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy" + ] + labels = { + "org.opencontainers.image.title" = "TSUN Gen3 Proxy" + "org.opencontainers.image.authors" = "Stefan Allius" + "org.opencontainers.image.created" = "${BUILD_DATE}" + "org.opencontainers.image.version" = "${VERSION}" + "org.opencontainers.image.revision" = "${BRANCH}" + "org.opencontainers.image.description" = "${DESCRIPTION}" + "org.opencontainers.image.licenses" = "BSD-3-Clause" + "org.opencontainers.image.source" = "https://github.com/s-allius/tsun-gen3-proxy" + } + output = [ + "type=image,push=true" + ] + + no-cache = false + platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7"] +} + +target "_debug" { + args = { + LOG_LVL = "DEBUG" + environment = "dev" + } +} +target "_prod" { + args = { + } +} +target "debug" { + inherits = ["_common", "_debug"] + tags = ["${IMAGE}:debug"] +} + +target "dev" { + inherits = ["_common"] + tags = ["${IMAGE}:dev"] +} + +target "preview" { + inherits = ["_common", "_prod"] + tags = ["${IMAGE}:dev", "${IMAGE}:${VERSION}"] +} + +target "rc" { + inherits = ["_common", "_prod"] + tags = ["${IMAGE}:rc", "${IMAGE}:${VERSION}"] +} + +target "rel" { + inherits = ["_common", "_prod"] + tags = ["${IMAGE}:latest", "${IMAGE}:${MAJOR}", "${IMAGE}:${VERSION}"] + no-cache = true +} diff --git a/app/src/async_stream.py b/app/src/async_stream.py index f6b58b5..5892ba2 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -3,10 +3,15 @@ import logging import traceback import time from asyncio import StreamReader, StreamWriter -from messages import hex_dump_memory, State from typing import Self from itertools import count +if __name__ == "app.src.async_stream": + from app.src.messages import hex_dump_memory, State +else: # pragma: no cover + from messages import hex_dump_memory, State + + import gc logger = logging.getLogger('conn') diff --git a/app/src/gen3plus/connection_g3p.py b/app/src/gen3plus/connection_g3p.py index 6591110..dc4eca1 100644 --- a/app/src/gen3plus/connection_g3p.py +++ b/app/src/gen3plus/connection_g3p.py @@ -1,7 +1,12 @@ import logging from asyncio import StreamReader, StreamWriter -from async_stream import AsyncStream -from gen3plus.solarman_v5 import SolarmanV5 + +if __name__ == "app.src.gen3plus.connection_g3p": + from app.src.async_stream import AsyncStream + from app.src.gen3plus.solarman_v5 import SolarmanV5 +else: # pragma: no cover + from async_stream import AsyncStream + from gen3plus.solarman_v5 import SolarmanV5 logger = logging.getLogger('conn') diff --git a/app/src/gen3plus/inverter_g3p.py b/app/src/gen3plus/inverter_g3p.py index 6c1d6b5..d9bf0f2 100644 --- a/app/src/gen3plus/inverter_g3p.py +++ b/app/src/gen3plus/inverter_g3p.py @@ -3,11 +3,18 @@ import traceback import json import asyncio from asyncio import StreamReader, StreamWriter -from config import Config -from inverter import Inverter -from gen3plus.connection_g3p import ConnectionG3P from aiomqtt import MqttCodeError -from infos import Infos + +if __name__ == "app.src.gen3plus.inverter_g3p": + from app.src.config import Config + from app.src.inverter import Inverter + from app.src.gen3plus.connection_g3p import ConnectionG3P + from app.src.infos import Infos +else: # pragma: no cover + from config import Config + from inverter import Inverter + from gen3plus.connection_g3p import ConnectionG3P + from infos import Infos logger_mqtt = logging.getLogger('mqtt') diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 0cbdfab..94089f3 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -172,7 +172,7 @@ class SolarmanV5(Message): self.db.set_db_def_value(Register.POLLING_INTERVAL, self.mb_timeout) self.db.set_db_def_value(Register.HEARTBEAT_INTERVAL, - 120) # fixme + 120) self.new_data['controller'] = True self.state = State.up diff --git a/app/src/inverter.py b/app/src/inverter.py index cf20c96..996fa0f 100644 --- a/app/src/inverter.py +++ b/app/src/inverter.py @@ -1,11 +1,15 @@ import asyncio import logging import json -from config import Config -from mqtt import Mqtt -from infos import Infos +if __name__ == "app.src.inverter": + from app.src.config import Config + from app.src.mqtt import Mqtt + from app.src.infos import Infos +else: # pragma: no cover + from config import Config + from mqtt import Mqtt + from infos import Infos -# logger = logging.getLogger('conn') logger_mqtt = logging.getLogger('mqtt') @@ -72,7 +76,7 @@ class Inverter(): Infos.new_stat_data[key] = False @classmethod - def class_close(cls, loop) -> None: + def class_close(cls, loop) -> None: # pragma: no cover logging.debug('Inverter.class_close') logging.info('Close MQTT Task') loop.run_until_complete(cls.mqtt.close()) diff --git a/app/src/modbus_tcp.py b/app/src/modbus_tcp.py index 5116bc8..a06e38f 100644 --- a/app/src/modbus_tcp.py +++ b/app/src/modbus_tcp.py @@ -1,9 +1,13 @@ import logging import traceback import asyncio -from config import Config -from gen3plus.inverter_g3p import InverterG3P +if __name__ == "app.src.modbus_tcp": + from app.src.config import Config + from app.src.gen3plus.inverter_g3p import InverterG3P +else: # pragma: no cover + from config import Config + from gen3plus.inverter_g3p import InverterG3P logger = logging.getLogger('conn') diff --git a/app/tests/test_infos_g3.py b/app/tests/test_infos_g3.py index e811d90..37a8076 100644 --- a/app/tests/test_infos_g3.py +++ b/app/tests/test_infos_g3.py @@ -325,7 +325,11 @@ def test_build_ha_conf1(contr_data_seq): assert tests==4 +def test_build_ha_conf2(contr_data_seq): + i = InfosG3() + i.static_init() # initialize counter + tests = 0 for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): if id == 'out_power_123': @@ -344,9 +348,9 @@ def test_build_ha_conf1(contr_data_seq): assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) tests +=1 - assert tests==5 + assert tests==1 -def test_build_ha_conf2(contr_data_seq, inv_data_seq, inv_data_seq2): +def test_build_ha_conf3(contr_data_seq, inv_data_seq, inv_data_seq2): i = InfosG3() for key, result in i.parse (contr_data_seq): pass # side effect in calling i.parse() @@ -397,12 +401,9 @@ def test_must_incr_total(inv_data_seq2, inv_data_seq2_zero): assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 23}) tests = 0 for key, update in i.parse (inv_data_seq2): - if key == 'total': + if key == 'total' or key == 'env': assert update == False tests +=1 - elif key == 'env': - assert update == False - tests +=1 assert tests==3 assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36}) @@ -442,12 +443,9 @@ def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero): tests = 0 for key, update in i.parse (inv_data_seq2_zero): - if key == 'total': + if key == 'total' or key == 'env': assert update == False tests +=1 - elif key == 'env': - assert update == False - tests +=1 assert tests==3 assert json.dumps(i.db['total']) == json.dumps({}) @@ -456,12 +454,9 @@ def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero): tests = 0 for key, update in i.parse (inv_data_seq2): - if key == 'total': + if key == 'total' or key == 'env': assert update == True tests +=1 - elif key == 'env': - assert update == True - tests +=1 assert tests==3 assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36}) diff --git a/app/tests/test_infos_g3p.py b/app/tests/test_infos_g3p.py index 21ef570..f7aef4d 100644 --- a/app/tests/test_infos_g3p.py +++ b/app/tests/test_infos_g3p.py @@ -1,18 +1,33 @@ # test_with_pytest.py -import pytest, json, math +import pytest, json, math, random from app.src.infos import Register from app.src.gen3plus.infos_g3p import InfosG3P from app.src.gen3plus.infos_g3p import RegisterMap +@pytest.fixture(scope="session") +def str_test_ip(): + ip = ".".join(str(random.randint(1, 254)) for _ in range(4)) + print(f'random_ip: {ip}') + return ip + +@pytest.fixture(scope="session") +def bytes_test_ip(str_test_ip): + ip = bytes(str.encode(str_test_ip)) + l = len(ip) + if l < 16: + ip = ip + bytearray(16-l) + print(f'random_ip: {ip}') + return ip + @pytest.fixture -def device_data(): # 0x4110 ftype: 0x02 +def device_data(bytes_test_ip): # 0x4110 ftype: 0x02 msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xba\xd2\x00\x00' msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53' msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e' msg += b'\x30\x35\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' - msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54\x31\x39\x32\x2e' - msg += b'\x31\x36\x38\x2e\x38\x30\x2e\x34\x39\x00\x00\x00\x0f\x00\x01\xb0' + msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54' + bytes_test_ip + msg += b'\x0f\x00\x01\xb0' msg += b'\x02\x0f\x00\xff\x56\x31\x2e\x31\x2e\x30\x30\x2e\x30\x42\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\xfe\xfe\x00\x00' @@ -63,14 +78,14 @@ def test_default_db(): "collector": {"Chip_Type": "IGEN TECH"}, }) -def test_parse_4110(device_data: bytes): +def test_parse_4110(str_test_ip, device_data: bytes): i = InfosG3P(client_mode=False) i.db.clear() for key, update in i.parse (device_data, 0x41, 2): pass # side effect is calling generator i.parse() assert json.dumps(i.db) == json.dumps({ - 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "192.168.80.49", "Sensor_List": "02b0"}, + 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": str_test_ip, "Sensor_List": "02b0"}, 'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"}, }) @@ -139,7 +154,11 @@ def test_build_ha_conf1(): assert tests==7 +def test_build_ha_conf2(): + i = InfosG3P(client_mode=False) + i.static_init() # initialize counter + tests = 0 for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): if id == 'out_power_123': @@ -161,9 +180,9 @@ def test_build_ha_conf1(): assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) tests +=1 - assert tests==8 + assert tests==1 -def test_build_ha_conf2(): +def test_build_ha_conf3(): i = InfosG3P(client_mode=True) i.static_init() # initialize counter @@ -209,7 +228,11 @@ def test_build_ha_conf2(): assert tests==7 +def test_build_ha_conf4(): + i = InfosG3P(client_mode=True) + i.static_init() # initialize counter + tests = 0 for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): if id == 'out_power_123': @@ -231,7 +254,7 @@ def test_build_ha_conf2(): assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) tests +=1 - assert tests==8 + assert tests==1 def test_exception_and_eval(inverter_data: bytes): diff --git a/app/tests/test_inverter.py b/app/tests/test_inverter.py new file mode 100644 index 0000000..40b23bf --- /dev/null +++ b/app/tests/test_inverter.py @@ -0,0 +1,90 @@ +# test_with_pytest.py +import pytest +import asyncio +import aiomqtt +import logging + +from mock import patch, Mock +from app.src.singleton import Singleton +from app.src.inverter import Inverter +from app.src.mqtt import Mqtt +from app.src.gen3plus.solarman_v5 import SolarmanV5 +from app.src.config import Config + + +pytest_plugins = ('pytest_asyncio',) + + +@pytest.fixture(scope="module", autouse=True) +def module_init(): + def new_init(cls, cb_mqtt_is_up): + cb_mqtt_is_up() + + Singleton._instances.clear() + with patch.object(Mqtt, '__init__', new_init): + yield + +@pytest.fixture(scope="module") +def test_port(): + return 1883 + +@pytest.fixture(scope="module") +def test_hostname(): + # if getenv("GITHUB_ACTIONS") == "true": + # return 'mqtt' + # else: + return 'test.mosquitto.org' + +@pytest.fixture +def config_conn(test_hostname, test_port): + Config.act_config = { + 'mqtt':{ + 'host': test_hostname, + 'port': test_port, + 'user': '', + 'passwd': '' + }, + 'ha':{ + 'auto_conf_prefix': 'homeassistant', + 'discovery_prefix': 'homeassistant', + 'entity_prefix': 'tsun', + 'proxy_node_id': 'test_1', + 'proxy_unique_id': '' + }, + 'inverters': { + 'allow_all': True, + "R170000000000001":{ + 'node_id': 'inv_1' + } + } + } + +@pytest.mark.asyncio +async def test_inverter_cb(config_conn): + _ = config_conn + + with patch.object(Inverter, '_cb_mqtt_is_up', wraps=Inverter._cb_mqtt_is_up) as spy: + print('call Inverter.class_init') + Inverter.class_init() + assert 'homeassistant/' == Inverter.discovery_prfx + assert 'tsun/' == Inverter.entity_prfx + assert 'test_1/' == Inverter.proxy_node_id + spy.assert_called_once() + +@pytest.mark.asyncio +async def test_mqtt_is_up(config_conn): + _ = config_conn + + with patch.object(Mqtt, 'publish') as spy: + Inverter.class_init() + await Inverter._cb_mqtt_is_up() + spy.assert_called() + +@pytest.mark.asyncio +async def test_mqtt_proxy_statt_invalid(config_conn): + _ = config_conn + + with patch.object(Mqtt, 'publish') as spy: + Inverter.class_init() + await Inverter._async_publ_mqtt_proxy_stat('InValId_kEy') + spy.assert_not_called() diff --git a/app/tests/test_modbus_tcp.py b/app/tests/test_modbus_tcp.py new file mode 100644 index 0000000..7792894 --- /dev/null +++ b/app/tests/test_modbus_tcp.py @@ -0,0 +1,78 @@ +# test_with_pytest.py +import pytest +import asyncio + +from mock import patch +from app.src.singleton import Singleton +from app.src.config import Config +from app.src.infos import Infos +from app.src.modbus_tcp import ModbusConn + + +pytest_plugins = ('pytest_asyncio',) + +# initialize the proxy statistics +Infos.static_init() + +@pytest.fixture(scope="module", autouse=True) +def module_init(): + Singleton._instances.clear() + yield + +@pytest.fixture(scope="module") +def test_port(): + return 1883 + +@pytest.fixture(scope="module") +def test_hostname(): + # if getenv("GITHUB_ACTIONS") == "true": + # return 'mqtt' + # else: + return 'test.mosquitto.org' + +@pytest.fixture +def config_mqtt_conn(test_hostname, test_port): + Config.act_config = {'mqtt':{'host': test_hostname, 'port': test_port, 'user': '', 'passwd': ''}, + 'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'} + } + +@pytest.fixture +def config_no_conn(test_port): + Config.act_config = {'mqtt':{'host': "", 'port': test_port, 'user': '', 'passwd': ''}, + 'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'} + } + +class FakeReader(): + pass + + +class FakeWriter(): + pass + + +@pytest.fixture +def patch_open(): + async def new_conn(conn): + await asyncio.sleep(0.01) + return FakeReader(), FakeWriter() + + def new_open(host: str, port: int): + return new_conn(None) + + with patch.object(asyncio, 'open_connection', new_open) as conn: + yield conn + + +@pytest.mark.asyncio +async def test_modbus_conn(patch_open): + _ = patch_open + assert Infos.stat['proxy']['Inverter_Cnt'] == 0 + + async with ModbusConn('test.local', 1234) as stream: + assert stream.node_id == 'G3P' + assert stream.addr == ('test.local', 1234) + assert type(stream.reader) is FakeReader + assert type(stream.writer) is FakeWriter + assert Infos.stat['proxy']['Inverter_Cnt'] == 1 + + assert Infos.stat['proxy']['Inverter_Cnt'] == 0 diff --git a/app/tests/test_mqtt.py b/app/tests/test_mqtt.py index 7dea973..1d7c5dd 100644 --- a/app/tests/test_mqtt.py +++ b/app/tests/test_mqtt.py @@ -5,6 +5,7 @@ import aiomqtt import logging from mock import patch, Mock +from app.src.singleton import Singleton from app.src.mqtt import Mqtt from app.src.modbus import Modbus from app.src.gen3plus.solarman_v5 import SolarmanV5 @@ -13,7 +14,10 @@ from app.src.config import Config pytest_plugins = ('pytest_asyncio',) - +@pytest.fixture(scope="module", autouse=True) +def module_init(): + Singleton._instances.clear() + yield @pytest.fixture(scope="module") def test_port(): diff --git a/app/tests/test_singleton.py b/app/tests/test_singleton.py index 2ea82eb..d645e08 100644 --- a/app/tests/test_singleton.py +++ b/app/tests/test_singleton.py @@ -7,6 +7,7 @@ class Test(metaclass=Singleton): pass # is a dummy test class def test_singleton_metaclass(): + Singleton._instances.clear() a = Test() assert 1 == len(Singleton._instances) b = Test() diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index c9227bd..521ef9b 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -3,6 +3,7 @@ import struct import time import asyncio import logging +import random from math import isclose from app.src.gen3plus.solarman_v5 import SolarmanV5 from app.src.config import Config @@ -148,6 +149,12 @@ def incorrect_checksum(buf): checksum = (sum(buf[1:])+1) & 0xff return checksum.to_bytes(length=1) +@pytest.fixture(scope="session") +def str_test_ip(): + ip = ".".join(str(random.randint(1, 254)) for _ in range(4)) + print(f'random_ip: {ip}') + return ip + @pytest.fixture def device_ind_msg(): # 0x4110 msg = b'\xa5\xd4\x00\x10\x41\x00\x01' +get_sn() +b'\x02\xba\xd2\x00\x00' @@ -1692,7 +1699,7 @@ async def test_modbus_polling(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp m.close() @pytest.mark.asyncio -async def test_start_client_mode(config_tsun_inv1): +async def test_start_client_mode(config_tsun_inv1, str_test_ip): _ = config_tsun_inv1 assert asyncio.get_running_loop() m = MemoryStream(b'') @@ -1700,9 +1707,9 @@ async def test_start_client_mode(config_tsun_inv1): assert m.no_forwarding == False assert m.mb_timer.tim == None assert asyncio.get_running_loop() == m.mb_timer.loop - await m.send_start_cmd(get_sn_int(), '192.168.1.1', m.mb_first_timeout) + await m.send_start_cmd(get_sn_int(), str_test_ip, m.mb_first_timeout) assert m.writer.sent_pdu==bytearray(b'\xa5\x17\x00\x10E\x01\x00!Ce{\x02\xb0\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x030\x00\x000J\xde\xf1\x15') - assert m.db.get_db_value(Register.IP_ADDRESS) == '192.168.1.1' + assert m.db.get_db_value(Register.IP_ADDRESS) == str_test_ip assert isclose(m.db.get_db_value(Register.POLLING_INTERVAL), 0.5) assert m.db.get_db_value(Register.HEARTBEAT_INTERVAL) == 120 diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 62df532..13f462e 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -1150,7 +1150,7 @@ def test_msg_ota_invalid(config_tsun_inv1, msg_ota_invalid): m.close() def test_msg_unknown(config_tsun_inv1, msg_unknown): - config_tsun_inv1 + _ = config_tsun_inv1 m = MemoryStream(msg_unknown, (0,), False) m.db.stat['proxy']['Unknown_Msg'] = 0 m.read() # read complete msg, and dispatch msg diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py index 00ec70f..e663577 100644 --- a/system_tests/test_tcp_socket.py +++ b/system_tests/test_tcp_socket.py @@ -13,31 +13,31 @@ def get_invalid_sn(): @pytest.fixture -def MsgContactInfo(): # Contact Info message +def msg_contact_info(): # Contact Info message return b'\x00\x00\x00\x2c\x10'+get_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456' @pytest.fixture -def MsgContactResp(): # Contact Response message +def msg_contact_resp(): # Contact Response message return b'\x00\x00\x00\x14\x10'+get_sn()+b'\x91\x00\x01' @pytest.fixture -def MsgContactInfo2(): # Contact Info message +def msg_contact_info2(): # Contact Info message return b'\x00\x00\x00\x2c\x10'+get_invalid_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456' @pytest.fixture -def MsgContactResp2(): # Contact Response message +def msg_contact_resp2(): # Contact Response message return b'\x00\x00\x00\x14\x10'+get_invalid_sn()+b'\x91\x00\x01' @pytest.fixture -def MsgTimeStampReq(): # Get Time Request message +def msg_timestamp_req(): # Get Time Request message return b'\x00\x00\x00\x13\x10'+get_sn()+b'\x91\x22' @pytest.fixture -def MsgTimeStampResp(): # Get Time Resonse message +def msg_timestamp_resp(): # Get Time Resonse message return b'\x00\x00\x00\x1b\x10'+get_sn()+b'\x99\x22\x00\x00\x01\x89\xc6\x63\x4d\x80' @pytest.fixture -def MsgContollerInd(): # Data indication from the controller +def msg_controller_ind(): # Data indication from the controller msg = b'\x00\x00\x01\x2f\x10'+ get_sn() + b'\x91\x71\x0e\x10\x00\x00\x10'+get_sn() msg += b'\x01\x00\x00\x01\x89\xc6\x63\x55\x50' msg += b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f' @@ -49,7 +49,7 @@ def MsgContollerInd(): # Data indication from the controller return msg @pytest.fixture -def MsgInvData(): # Data indication from the controller +def msg_inv_data(): # Data indication from the controller msg = b'\x00\x00\x00\x8b\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no() msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08' msg += b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28' @@ -57,7 +57,7 @@ def MsgInvData(): # Data indication from the controller return msg @pytest.fixture -def MsgInverterInd(): # Data indication from the inverter +def msg_inverter_ind(): # Data indication from the inverter msg = b'\x00\x00\x05\x02\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no() msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08' msg += b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00' @@ -94,7 +94,7 @@ def MsgInverterInd(): # Data indication from the inverter return msg @pytest.fixture -def MsgOtaUpdateReq(): # Over the air update request from talent cloud +def msg_ota_update_req(): # Over the air update request from talent cloud msg = b'\x00\x00\x01\x16\x10'+ get_sn() + b'\x70\x13\x01\x02\x76\x35' msg += b'\x70\x68\x74\x74\x70' msg += b'\x3a\x2f\x2f\x77\x77\x77\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f' @@ -117,7 +117,7 @@ def MsgOtaUpdateReq(): # Over the air update request from talent cloud @pytest.fixture(scope="session") -def ClientConnection(): +def client_connection(): host = 'logger.talent-monitoring.com' port = 5005 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -127,7 +127,7 @@ def ClientConnection(): time.sleep(2.5) s.close() -def tempClientConnection(): +def tempclient_connection(): host = 'logger.talent-monitoring.com' port = 5005 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -138,25 +138,25 @@ def tempClientConnection(): def test_open_close(): try: - for s in tempClientConnection(): - pass - except: + for _ in tempclient_connection(): + pass # test side effect of generator + except Exception: assert False -def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp): - s = ClientConnection +def test_send_contact_info1(client_connection, msg_contact_info, msg_contact_resp): + s = client_connection try: - s.sendall(MsgContactInfo) + s.sendall(msg_contact_info) data = s.recv(1024) except TimeoutError: pass - assert data == MsgContactResp + assert data == msg_contact_resp -def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, MsgContactResp): - s = ClientConnection +def test_send_contact_info2(client_connection, msg_contact_info2, msg_contact_info, msg_contact_resp): + s = client_connection try: - s.sendall(MsgContactInfo2) + s.sendall(msg_contact_info2) data = s.recv(1024) except TimeoutError: pass @@ -164,73 +164,73 @@ def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, M assert False try: - s.sendall(MsgContactInfo) + s.sendall(msg_contact_info) data = s.recv(1024) except TimeoutError: pass - assert data == MsgContactResp + assert data == msg_contact_resp -def test_send_contact_info3(ClientConnection, MsgContactInfo, MsgContactResp, MsgTimeStampReq): - s = ClientConnection +def test_send_contact_info3(client_connection, msg_contact_info, msg_contact_resp, msg_timestamp_req): + s = client_connection try: - s.sendall(MsgContactInfo) + s.sendall(msg_contact_info) data = s.recv(1024) except TimeoutError: pass - assert data == MsgContactResp + assert data == msg_contact_resp try: - s.sendall(MsgTimeStampReq) + s.sendall(msg_timestamp_req) data = s.recv(1024) except TimeoutError: pass -def test_send_contact_resp(ClientConnection, MsgContactResp): - s = ClientConnection +def test_send_contact_resp(client_connection, msg_contact_resp): + s = client_connection try: - s.sendall(MsgContactResp) + s.sendall(msg_contact_resp) data = s.recv(1024) except TimeoutError: pass else: assert data == b'' -def test_send_ctrl_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgContollerInd): - s = ClientConnection +def test_send_ctrl_data(client_connection, msg_timestamp_req, msg_timestamp_resp, msg_controller_ind): + s = client_connection try: - s.sendall(MsgTimeStampReq) - data = s.recv(1024) + s.sendall(msg_timestamp_req) + _ = s.recv(1024) except TimeoutError: pass # time.sleep(2.5) - # assert data == MsgTimeStampResp + # assert data == msg_timestamp_resp try: - s.sendall(MsgContollerInd) - data = s.recv(1024) + s.sendall(msg_controller_ind) + _ = s.recv(1024) except TimeoutError: pass -def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgInvData, MsgInverterInd): - s = ClientConnection +def test_send_inv_data(client_connection, msg_timestamp_req, msg_timestamp_resp, msg_inv_data, msg_inverter_ind): + s = client_connection try: - s.sendall(MsgTimeStampReq) - data = s.recv(1024) + s.sendall(msg_timestamp_req) + _ = s.recv(1024) except TimeoutError: pass # time.sleep(32.5) - # assert data == MsgTimeStampResp + # assert data == msg_timestamp_resp try: - s.sendall(MsgInvData) - data = s.recv(1024) - s.sendall(MsgInverterInd) - data = s.recv(1024) + s.sendall(msg_inv_data) + _ = s.recv(1024) + s.sendall(msg_inverter_ind) + _ = s.recv(1024) except TimeoutError: pass -def test_ota_req(ClientConnection, MsgOtaUpdateReq): - s = ClientConnection +def test_ota_req(client_connection, msg_ota_update_req): + s = client_connection try: - s.sendall(MsgOtaUpdateReq) - data = s.recv(1024) + s.sendall(msg_ota_update_req) + _ = s.recv(1024) except TimeoutError: pass