diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d9ad0b4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E203, E266, E501, W503, F403, F401 +max-line-length = 79 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 diff --git a/.gitignore b/.gitignore index 5d381cc..489bdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,3 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1030aa7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: +- repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + name: Trim Trailing Whitespace + description: This hook trims trailing whitespace. + entry: trailing-whitespace-fixer + language: python + types: [ text ] +- repo: https://github.com/PyCQA/isort + rev: "5.12.0" + hooks: + - id: isort + + +- repo: https://github.com/PyCQA/flake8 + rev: "6.0.0" + hooks: + - id: flake8 + args: + - "--max-line-length=80" +- repo: https://gitlab.com/emilv2/pre-commit-hooks + rev: 0.0.7 + hooks: + - id: check-config diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..53df130 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.10-slim-buster + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt + +COPY . . + +CMD [ "python3", "src/main.py"] diff --git a/README.md b/README.md index 2e0f828..e92e6c0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # Inverter_huawei - diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..fae64ee --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,19 @@ +version: "3" +services: + + huawei-solar: + container_name: huawei-solar + restart: unless-stopped + image: huawei-solar-rtu:latest + user: root + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + environment: + - INVERTER_PORT=/dev/ttyUSB0 + - MQTT_HOST=192.168.43.102 + - BROKER_PORT=1883 + - USE_CREDENTIALS=NO + - USER_NAME=none + - PASSWORD=none + - MQTT_TOPIC=raspberryTopic + - DATA_MODE=INVERTER # INVERTER or OFFLINE diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2044233 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[tool.poetry] +name = "inverter_huawei" +version = "0.1.0" +description = "" +authors = ["Simon Milvert "] + +[tool.poetry.dependencies] +python = "^3.9" +huawei-solar = "^2.2.4" +paho-mqtt = "^1.6.1" + +[tool.poetry.dev-dependencies] +pytest = "^7.2.1" +black = "^23.1.0" +flake8 = "^6.0.0" +isort = "^5.12.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 79 +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/data.py b/src/data.py new file mode 100644 index 0000000..033ad4e --- /dev/null +++ b/src/data.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass + +from huawei_solar import register_names as rn + + +@dataclass +class InverterData: + """Class for keeping track of an item in inventory.""" + + name: str + unit: str + value: int = 0 + + +class ReadRegister: + inverter_reg_status = rn.DEVICE_STATUS + + reg_to_read_measured = [ + rn.PV_01_VOLTAGE, + rn.PV_02_VOLTAGE, + rn.PV_01_CURRENT, + rn.PV_02_CURRENT, + rn.INPUT_POWER, + rn.ACTIVE_POWER, + rn.GRID_CURRENT, + ] + + reg_to_read_calculated = [ + rn.DAY_ACTIVE_POWER_PEAK, + rn.EFFICIENCY, + rn.DAILY_YIELD_ENERGY, + rn.ACCUMULATED_YIELD_ENERGY, + rn.INTERNAL_TEMPERATURE, + ] + + reg_to_read_status = [ + rn.FAULT_CODE, + rn.STATE_1, + rn.STATE_2, + rn.STATE_3, + rn.ALARM_1, + rn.ALARM_2, + rn.ALARM_3, + ] diff --git a/src/inverter.py b/src/inverter.py new file mode 100644 index 0000000..f569a6b --- /dev/null +++ b/src/inverter.py @@ -0,0 +1,8 @@ +from src.data import InverterData +from src.log import log_info + + +async def get_data(client, register, slave_id): + result = await client.get(register, slave_id) + log_info(f"{register}: {result.value}") + return InverterData(value=result.value, unit=result.unit, name=register) diff --git a/src/inverter_dummy.py b/src/inverter_dummy.py new file mode 100644 index 0000000..156722b --- /dev/null +++ b/src/inverter_dummy.py @@ -0,0 +1,22 @@ +import random + +from src.data import InverterData, ReadRegister +from src.log import log_info + +POSSIBLE_SAMPLE_UNITS = [ + "EXAMPLE_UNIT_A", + "EXAMPLE_UNIT_B", + "EXAMPLE_UNIT_C", + "EXAMPLE_UNIT_D", + "EXAMPLE_UNIT_E", + "EXAMPLE_UNIT_F", +] + + +async def get_dummy_data() -> InverterData: + log_info("Generate dummy data") + value = random.randint(0, 100) + name = random.choice(ReadRegister.reg_to_read_measured) + unit = random.choice(POSSIBLE_SAMPLE_UNITS) + sample_reg = InverterData(value=value, unit=unit, name=name) + return sample_reg diff --git a/src/log.py b/src/log.py new file mode 100644 index 0000000..14702ed --- /dev/null +++ b/src/log.py @@ -0,0 +1,13 @@ +import logging + +FORMAT = ( + "%(asctime)-15s %(threadName)-15s " + "%(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" +) +logging.basicConfig(format=FORMAT) +log = logging.getLogger() +log.setLevel(logging.INFO) + + +def log_info(message): + log.info(message) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..b96df76 --- /dev/null +++ b/src/main.py @@ -0,0 +1,124 @@ +import asyncio +import json +import os + +from huawei_solar import AsyncHuaweiSolar +from huawei_solar import register_values as rv + +from src.data import InverterData, ReadRegister +from src.inverter import get_data +from src.inverter_dummy import get_dummy_data +from src.log import log_info +from src.mqtt import connect + +DATA_MODE = os.getenv("DATA_MODE", "dummy") +MQTT_TOPIC = os.getenv("MQTT_TOPIC", "inverter") + +REQUEST_INTERVAL = 60 +slave_id = os.getenv("INVERTER_ID", 1) +port = os.getenv("INVERTER_PORT", 502) +host = os.getenv("INVERTER_HOST", "10.0.2.20") + + +async def get_inverter_client() -> (InverterData, str): + inverter_client = await AsyncHuaweiSolar.create(host, port, slave_id) + log_info("-- INVERTER -- Connected to Inverter") + status = await inverter_client.get( + ReadRegister.inverter_reg_status, slave_id + ) + + log_info(f"-- INVERTER -- Status: {status}") + return inverter_client, status + + +async def main(): + inverter_client, status = await get_inverter_client() + count = 0 + while True: + if status.value == rv.DEVICE_STATUS_DEFINITIONS.get( + 0x0200 + ): # Inverter is active, Get data + # Get measured values + for register in ReadRegister.reg_to_read_measured: + result = await get_inverter_data( + inverter_client, register, slave_id + ) + send_data( + mqtt_client, + format_data_to_serialized_json(result), + f"measure/{result.name}", + ) + + # Get calculated values + for register in ReadRegister.reg_to_read_calculated: + result = await get_inverter_data( + inverter_client, register, slave_id + ) + send_data( + mqtt_client, + format_data_to_serialized_json(result), + f"calculated/{result.name}", + ) + + if count == 5: + for register in ReadRegister.reg_to_read_status: + result = await get_inverter_data( + inverter_client, register, slave_id + ) + send_data( + mqtt_client, + format_data_to_serialized_json(result), + f"status/{result.name}", + ) + count = 0 + count += 1 + await asyncio.sleep(REQUEST_INTERVAL) + + +def send_data(client, storable_data, topic=""): + try: + log_info( + f"Sending data\n topic: {MQTT_TOPIC + '/' + topic} \n" + f"msg: {storable_data}" + ) + client.publish( + MQTT_TOPIC + "/" + topic, + payload=storable_data, + qos=0, + retain=False, + ) + except Exception: + log_info(f"ERROR PUBLISHING DATA TO MQTT BROKER. \n{Exception}") + + +def format_data_to_serialized_json(info: InverterData) -> str: + data = {"value": info.value, "name": info.name, "unit": info.unit} + json_obj = json.dumps(data) + log_info(f"json_obj: {json_obj}, type: {type(json_obj)}") + return json_obj + + +async def get_inverter_data( + inverter_client: AsyncHuaweiSolar, register: str, slave_id: int +) -> InverterData: + if DATA_MODE == "INVERTER": + data = await get_data(inverter_client, register, slave_id) + return data + else: + data = await get_dummy_data() + return data + + +log_info("| === START === |") +mqtt_client = connect() +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + +try: + asyncio.ensure_future(main()) + loop.run_forever() +except KeyboardInterrupt: + pass +finally: + log_info("| === END === |") + loop.close() diff --git a/src/mqtt.py b/src/mqtt.py new file mode 100644 index 0000000..aa80ca8 --- /dev/null +++ b/src/mqtt.py @@ -0,0 +1,42 @@ +import os +import time + +import paho.mqtt.client as mqtt + +from src.log import log_info + +mqtt_host = os.getenv("MQTT_HOST", "10.0.0.3") +broker_port = os.getenv("BROKER_PORT", "1883") +have_credentials = os.getenv("USE_CREDENTIALS", "NO") +user_name = os.getenv("USER_NAME", "") +password = os.getenv("PASSWORD", "") + + +def on_connect(client, userdata, flags, rc): + if rc == 0: + client.connected_flag = True + log_info("MQTT OK!") + else: + log_info(f"MQTT FAILURE. ERROR CODE: {rc}") + + +def setup_mqtt(): + mqtt.Client.connected_flag = False + mqtt_client = mqtt.Client() + mqtt_client.on_connect = on_connect + mqtt_client.loop_start() + log_info("Connecting to MQTT broker: " + mqtt_host) + log_info("Port: " + broker_port) + if have_credentials == "YES": + mqtt_client.username_pw_set(username=user_name, password=password) + mqtt_client.connect(mqtt_host, int(broker_port), 60) + while not mqtt_client.connected_flag: + log_info("...") + time.sleep(1) + log_info("START MODBUS...") + return mqtt_client + + +def connect(): + mqtt_client = setup_mqtt() + return mqtt_client