From b5dc9dcb33b39aad4a48ae49408ecd9ab4291dea Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 1 Jun 2025 18:02:56 +0100 Subject: [PATCH 1/8] Telnet transport, mongodb data collector, home assistant updates, uv and nix flake --- .envrc | 1 + .../workflows/poetry-build-test-release.yml | 48 ++ .github/workflows/pypi-release.yml | 30 - .github/workflows/python-app.yml | 39 -- .gitignore | 8 + README.md | 26 +- demos/test-serial.py | 44 ++ demos/test-tcp.py | 48 ++ flake.lock | 133 +++++ flake.nix | 91 +++ pylontech/__init__.py | 1 - pylontech/pylontech.py | 308 ----------- pyproject.toml | 79 +++ setup.py | 22 - src/pylontech/__init__.py | 7 + src/pylontech/pylontech.py | 111 ++++ src/pylontech/schema.py | 104 ++++ src/pylontech/tools.py | 58 ++ src/pylontech/transport.py | 155 ++++++ src/pylontechpoller/__init__.py | 1 + src/pylontechpoller/poller.py | 167 ++++++ src/pylontechpoller/reporter.py | 71 +++ tests/test_basic.py | 17 +- uv.lock | 516 ++++++++++++++++++ 24 files changed, 1681 insertions(+), 404 deletions(-) create mode 100644 .envrc create mode 100644 .github/workflows/poetry-build-test-release.yml delete mode 100644 .github/workflows/pypi-release.yml delete mode 100644 .github/workflows/python-app.yml create mode 100644 .gitignore create mode 100644 demos/test-serial.py create mode 100644 demos/test-tcp.py create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 pylontech/__init__.py delete mode 100644 pylontech/pylontech.py create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 src/pylontech/__init__.py create mode 100644 src/pylontech/pylontech.py create mode 100644 src/pylontech/schema.py create mode 100644 src/pylontech/tools.py create mode 100644 src/pylontech/transport.py create mode 100644 src/pylontechpoller/__init__.py create mode 100644 src/pylontechpoller/poller.py create mode 100644 src/pylontechpoller/reporter.py create mode 100644 uv.lock diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..c4b17d7 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use_flake diff --git a/.github/workflows/poetry-build-test-release.yml b/.github/workflows/poetry-build-test-release.yml new file mode 100644 index 0000000..4989e00 --- /dev/null +++ b/.github/workflows/poetry-build-test-release.yml @@ -0,0 +1,48 @@ +name: Build, Test Release + +on: + push: + branches: [ "main", "master" ] + tags: + - "v*" + pull_request: + branches: [ "main", "master" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install dependencies + run: | + uv lock + - name: Build + run: | + uv build + - name: Test + run: | + uv run pytest + - name: Test Nix build + run: | + nix build . + - name: Lint with flake8 + continue-on-error: true + run: | + # stop the build if there are Python syntax errors or undefined names + uv run flake8 ./src --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + uv run flake8 ./src --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Publish distribution 📦 to PyPI + if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' + run: | + uv publish --token ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml deleted file mode 100644 index 73a722c..0000000 --- a/.github/workflows/pypi-release.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: PyPi Release - -on: - push: - tags: - - 'v*' -# based on https://github.com/pypa/gh-action-pypi-publish - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - - name: Install dependencies - run: >- - python -m pip install --user --upgrade setuptools wheel - - name: Build - run: >- - python setup.py sdist bdist_wheel - - name: Publish distribution 📦 to PyPI - if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index f4bcc8a..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,39 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python application - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest serial construct - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..909c14f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.direnv +dist + +__pycache__ +*.egg-info +.idea + +result \ No newline at end of file diff --git a/README.md b/README.md index 135417f..bb9d612 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,29 @@ Container: This lib depends on `pyserial` and the awesome `construct` lib. + +## How to run demos + +TCP demo: + +```bash +uv run python ./demos/test-tcp.py 192.168.1.7 10 +``` + +Serial: + +```bash +socat -v pty,link=/tmp/serial,waitslave tcp:192.168.1.7:23,forever +# in another terminal +uv run python ./demos/test-serial.py /tmp/serial 1 +``` + +## How to run mongodb collector + +```bash +uv run poller 192.168.1.7 --mongo-url mongodb://mongodb.local:27017 --interval 1000 --interval 5 +``` + # Hardware wiring The pylontech modules talk using the RS485 line protocol. ## Pylontech side @@ -102,4 +125,5 @@ If you are using US2000 and US3000 batteries, then the main battery must be a US ## Using Pylontech LV Hub with multible battery banks -If the LV hub is used the address of the RS485 devices is depending on the battery bank. To read values the specific device address is needed. To scan for devices on a bank you can use the `scan_for_batteries` function. The max range is 0 to 255. \ No newline at end of file +If the LV hub is used the address of the RS485 devices is depending on the battery bank. To read values the specific device address is needed. To scan for devices on a bank you can use the `scan_for_batteries` function. The max range is 0 to 255. + diff --git a/demos/test-serial.py b/demos/test-serial.py new file mode 100644 index 0000000..d06f867 --- /dev/null +++ b/demos/test-serial.py @@ -0,0 +1,44 @@ +from time import sleep + +from pylontech import * + +if __name__ == '__main__': + iters = 0 + + import sys + from rich import print_json + import json + + # socat -v pty,link=/tmp/serial,waitslave tcp:192.168.10.237:23,forever + if len(sys.argv) < 2: + print("Usage: python test-tcp.py ") + exit(1) + + host = sys.argv[1] + iterations = sys.argv[2] + + cont = lambda iter: iter < 1 + if iterations == "inf": + cont = lambda iter: True + if iterations != "inf": + cont = lambda iter: iter < int(iterations) + + p = Pylontech(SerialDeviceTransport(serial_port=host, baudrate=115200)) + bats = p.scan_for_batteries(2, 10) + print("Battery stack:") + print_json(json.dumps(to_json_serializable(bats))) + + cc = 0 + + try: + for b in p.poll_parameters(bats.range()): + cc += 1 + if not cont(cc): + break + print("System state:") + print_json(json.dumps(b)) + sleep(0.5) + except (KeyboardInterrupt, SystemExit): + exit(0) + except BaseException as e: + raise e diff --git a/demos/test-tcp.py b/demos/test-tcp.py new file mode 100644 index 0000000..c0675c5 --- /dev/null +++ b/demos/test-tcp.py @@ -0,0 +1,48 @@ +from time import sleep + +from pylontech import * + + +if __name__ == '__main__': + """ + Direct TCP connections to devices like Waveshare RS485 to ETH, are 20-50 times faster than + serial port emulation through socat. Turn "RFC2217" option on. + """ + iters = 0 + + import sys + from rich import print_json + import json + + if len(sys.argv) < 2: + print("Usage: python test-tcp.py ") + exit(1) + + host = sys.argv[1] + iterations = sys.argv[2] + + cont = lambda iter: iter < 1 + if iterations == "inf": + cont = lambda iter: True + if iterations != "inf": + cont = lambda iter: iter < int(iterations) + + p = Pylontech(ExscriptTelnetTransport(host=host, port=23)) + bats = p.scan_for_batteries(2, 10) + print("Battery stack:") + print_json(json.dumps(to_json_serializable(bats))) + + cc = 0 + + try: + for b in p.poll_parameters(bats.range()): + cc += 1 + if not cont(cc): + break + print("System state:") + print_json(json.dumps(b)) + sleep(0.5) + except (KeyboardInterrupt, SystemExit): + exit(0) + except BaseException as e: + raise e diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fca86ea --- /dev/null +++ b/flake.lock @@ -0,0 +1,133 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1748624050, + "narHash": "sha256-wvVeiiM2jyxq5lylycigpsFUCWQ/jqgabyAogv04How=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5f161237cbfdfea721e5d69c13075327c7c8054c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1748562898, + "narHash": "sha256-STk4QklrGpM3gliPKNJdBLSQvIrqRuwHI/rnYb/5rh8=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "33bd58351957bb52dd1700ea7eeefe34de06a892", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1746540146, + "narHash": "sha256-QxdHGNpbicIrw5t6U3x+ZxeY/7IEJ6lYbvsjXmcxFIM=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e09c10c24ebb955125fda449939bfba664c467fd", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1748398512, + "narHash": "sha256-99mf47Kjl/rj716cSjeA6ubZLlhNudmC4HRg/6UMfvs=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "f006d191d4ff5894d2ead6299e2eaf3659bc46b0", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..11f3662 --- /dev/null +++ b/flake.nix @@ -0,0 +1,91 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/release-25.05"; + + inputs.flake-utils.url = "github:numtide/flake-utils"; + + inputs.pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + inputs.uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + inputs.pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + # ( printf "~20024642E00202FD33\r"; sleep 1 ) | nc 192.168.10.237 23 + outputs = + { self + , nixpkgs + , flake-utils + , uv2nix + , pyproject-nix + , pyproject-build-systems + , ... + }: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + lib = pkgs.lib; + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; + + overlay = workspace.mkPyprojectOverlay { + sourcePreference = "wheel"; # or sourcePreference = "sdist"; + }; + + pyprojectOverrides = _final: _prev: { + }; + + python = pkgs.python313; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { + inherit python; + }).overrideScope + ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ] + ); + in + { + devShells.default = pkgs.mkShell { + nativeBuildInputs = with pkgs.buildPackages; [ + git + socat + uv + + (python313.withPackages (python-pkgs: [ + python-pkgs.pyserial + python-pkgs.construct + python-pkgs.standard-telnetlib + python-pkgs.rich + ])) + ]; + }; + + packages.default = pythonSet.mkVirtualEnv "pylontechpoller-env" workspace.deps.default; + + apps.default = { + type = "app"; + program = "${self.packages."${system}".default}/bin/poller"; + }; + } + ); + + +} diff --git a/pylontech/__init__.py b/pylontech/__init__.py deleted file mode 100644 index d802427..0000000 --- a/pylontech/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .pylontech import Pylontech diff --git a/pylontech/pylontech.py b/pylontech/pylontech.py deleted file mode 100644 index 356c6cc..0000000 --- a/pylontech/pylontech.py +++ /dev/null @@ -1,308 +0,0 @@ -from typing import Dict -import logging -import serial -import construct - -logger = logging.getLogger(__name__) - -class HexToByte(construct.Adapter): - def _decode(self, obj, context, path) -> bytes: - hexstr = ''.join([chr(x) for x in obj]) - return bytes.fromhex(hexstr) - - -class JoinBytes(construct.Adapter): - def _decode(self, obj, context, path) -> bytes: - return ''.join([chr(x) for x in obj]).encode() - - -class DivideBy1000(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return obj / 1000 - - -class DivideBy100(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return obj / 100 - -class DivideBy10(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return obj / 10 - -class ToVolt(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return obj / 1000 - -class ToAmp(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return obj / 10 - -class ToCelsius(construct.Adapter): - def _decode(self, obj, context, path) -> float: - return (obj - 2731) / 10.0 # in Kelvin*10 - - - -class Pylontech: - manufacturer_info_fmt = construct.Struct( - "DeviceName" / JoinBytes(construct.Array(10, construct.Byte)), - "SoftwareVersion" / construct.Array(2, construct.Byte), - "ManufacturerName" / JoinBytes(construct.GreedyRange(construct.Byte)), - ) - - system_parameters_fmt = construct.Struct( - "CellHighVoltageLimit" / ToVolt(construct.Int16ub), - "CellLowVoltageLimit" / ToVolt(construct.Int16ub), - "CellUnderVoltageLimit" / ToVolt(construct.Int16sb), - "ChargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), - "ChargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), - "ChargeCurrentLimit" / DivideBy10(construct.Int16sb), - "ModuleHighVoltageLimit" / ToVolt(construct.Int16ub), - "ModuleLowVoltageLimit" / ToVolt(construct.Int16ub), - "ModuleUnderVoltageLimit" / ToVolt(construct.Int16ub), - "DischargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), - "DischargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), - "DischargeCurrentLimit" / DivideBy10(construct.Int16sb), - ) - - management_info_fmt = construct.Struct( - "ChargeVoltageLimit" / DivideBy1000(construct.Int16ub), - "DischargeVoltageLimit" / DivideBy1000(construct.Int16ub), - "ChargeCurrentLimit" / ToAmp(construct.Int16sb), - "DischargeCurrentLimit" / ToAmp(construct.Int16sb), - "status" - / construct.BitStruct( - "ChargeEnable" / construct.Flag, - "DischargeEnable" / construct.Flag, - "ChargeImmediately2" / construct.Flag, - "ChargeImmediately1" / construct.Flag, - "FullChargeRequest" / construct.Flag, - "ShouldCharge" - / construct.Computed( - lambda this: this.ChargeImmediately2 - | this.ChargeImmediately1 - | this.FullChargeRequest - ), - "_padding" / construct.BitsInteger(3), - ), - ) - - module_serial_number_fmt = construct.Struct( - "CommandValue" / construct.Byte, - "ModuleSerialNumber" / JoinBytes(construct.Array(16, construct.Byte)), - ) - - get_values_fmt = construct.Struct( - "NumberOfModules" / construct.Byte, - "Module" / construct.Array(construct.this.NumberOfModules, construct.Struct( - "NumberOfCells" / construct.Int8ub, - "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), - "NumberOfTemperatures" / construct.Int8ub, - "AverageBMSTemperature" / ToCelsius(construct.Int16sb), - "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), - "Current" / ToAmp(construct.Int16sb), - "Voltage" / ToVolt(construct.Int16ub), - "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), - "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), - "_UserDefinedItems" / construct.Int8ub, - "_TotalCapacity1" / DivideBy1000(construct.Int16ub), - "CycleNumber" / construct.Int16ub, - "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, - construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), - "TotalCapacity2" / DivideBy1000(construct.Int24ub))), - "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), - "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), - )), - "TotalPower" / construct.Computed(lambda this: sum([x.Power for x in this.Module])), - "StateOfCharge" / construct.Computed(lambda this: sum([x.RemainingCapacity for x in this.Module]) / sum([x.TotalCapacity for x in this.Module])), - - ) - get_values_single_fmt = construct.Struct( - "NumberOfModule" / construct.Byte, - "NumberOfCells" / construct.Int8ub, - "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), - "NumberOfTemperatures" / construct.Int8ub, - "AverageBMSTemperature" / ToCelsius(construct.Int16sb), - "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), - "Current" / ToAmp(construct.Int16sb), - "Voltage" / ToVolt(construct.Int16ub), - "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), - "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), - "_UserDefinedItems" / construct.Int8ub, - "_TotalCapacity1" / DivideBy1000(construct.Int16ub), - "CycleNumber" / construct.Int16ub, - "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, - construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), - "TotalCapacity2" / DivideBy1000(construct.Int24ub))), - "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), - "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), - "TotalPower" / construct.Computed(construct.this.Power), - "StateOfCharge" / construct.Computed(construct.this.RemainingCapacity / construct.this.TotalCapacity), - ) - - def __init__(self, serial_port='/dev/ttyUSB0', baudrate=115200): - self.s = serial.Serial(serial_port, baudrate, bytesize=8, parity=serial.PARITY_NONE, stopbits=1, timeout=2, exclusive=True) - - - @staticmethod - def get_frame_checksum(frame: bytes): - assert isinstance(frame, bytes) - - sum = 0 - for byte in frame: - sum += byte - sum = ~sum - sum %= 0x10000 - sum += 1 - return sum - - @staticmethod - def get_info_length(info: bytes) -> int: - lenid = len(info) - if lenid == 0: - return 0 - - lenid_sum = (lenid & 0xf) + ((lenid >> 4) & 0xf) + ((lenid >> 8) & 0xf) - lenid_modulo = lenid_sum % 16 - lenid_invert_plus_one = 0b1111 - lenid_modulo + 1 - - return (lenid_invert_plus_one << 12) + lenid - - - def send_cmd(self, address: int, cmd, info: bytes = b''): - raw_frame = self._encode_cmd(address, cmd, info) - self.s.write(raw_frame) - - - def _encode_cmd(self, address: int, cid2: int, info: bytes = b''): - cid1 = 0x46 - - info_length = Pylontech.get_info_length(info) - - frame = "{:02X}{:02X}{:02X}{:02X}{:04X}".format(0x20, address, cid1, cid2, info_length).encode() - frame += info - - frame_chksum = Pylontech.get_frame_checksum(frame) - whole_frame = (b"~" + frame + "{:04X}".format(frame_chksum).encode() + b"\r") - return whole_frame - - - def _decode_hw_frame(self, raw_frame: bytes) -> bytes: - # XXX construct - frame_data = raw_frame[1:len(raw_frame) - 5] - frame_chksum = raw_frame[len(raw_frame) - 5:-1] - - got_frame_checksum = Pylontech.get_frame_checksum(frame_data) - assert got_frame_checksum == int(frame_chksum, 16) - - return frame_data - - def _decode_frame(self, frame): - format = construct.Struct( - "ver" / HexToByte(construct.Array(2, construct.Byte)), - "adr" / HexToByte(construct.Array(2, construct.Byte)), - "cid1" / HexToByte(construct.Array(2, construct.Byte)), - "cid2" / HexToByte(construct.Array(2, construct.Byte)), - "infolength" / HexToByte(construct.Array(4, construct.Byte)), - "info" / HexToByte(construct.GreedyRange(construct.Byte)), - ) - - return format.parse(frame) - - - def read_frame(self): - raw_frame = self.s.readline() - f = self._decode_hw_frame(raw_frame=raw_frame) - parsed = self._decode_frame(f) - return parsed - - - def scan_for_batteries(self, start=0, end=255) -> Dict[int, str]: - """ Returns a map of the batteries id to their serial number """ - batteries = {} - for adr in range(start, end, 1): - bdevid = "{:02X}".format(adr).encode() - self.send_cmd(adr, 0x93, bdevid) # Probe for serial number - raw_frame = self.s.readline() - - if raw_frame: - sn = self.get_module_serial_number(adr) - sn_str = sn["ModuleSerialNumber"].decode() - - batteries[adr] = sn_str - logger.debug("Found battery at address " + str(adr) + " with serial " + sn_str) - else: - logger.debug("No battery found at address " + str(adr)) - - return batteries - - - def get_protocol_version(self): - self.send_cmd(0, 0x4f) - return self.read_frame() - - - def get_manufacturer_info(self): - self.send_cmd(0, 0x51) - f = self.read_frame() - return self.manufacturer_info_fmt.parse(f.info) - - - def get_system_parameters(self, dev_id=None): - if dev_id: - bdevid = "{:02X}".format(dev_id).encode() - self.send_cmd(dev_id, 0x47, bdevid) - else: - self.send_cmd(2, 0x47) - - f = self.read_frame() - return self.system_parameters_fmt.parse(f.info[1:]) - - def get_management_info(self, dev_id): - bdevid = "{:02X}".format(dev_id).encode() - self.send_cmd(dev_id, 0x92, bdevid) - f = self.read_frame() - - print(f.info) - print(len(f.info)) - ff = self.management_info_fmt.parse(f.info[1:]) - print(ff) - return ff - - def get_module_serial_number(self, dev_id=None): - if dev_id: - bdevid = "{:02X}".format(dev_id).encode() - self.send_cmd(dev_id, 0x93, bdevid) - else: - self.send_cmd(2, 0x93) - - f = self.read_frame() - # infoflag = f.info[0] - return self.module_serial_number_fmt.parse(f.info[0:]) - - def get_values(self): - self.send_cmd(2, 0x42, b'FF') - f = self.read_frame() - - # infoflag = f.info[0] - d = self.get_values_fmt.parse(f.info[1:]) - return d - - def get_values_single(self, dev_id): - bdevid = "{:02X}".format(dev_id).encode() - self.send_cmd(dev_id, 0x42, bdevid) - f = self.read_frame() - # infoflag = f.info[0] - d = self.get_values_single_fmt.parse(f.info[1:]) - return d - - -if __name__ == '__main__': - p = Pylontech() - # print(p.get_protocol_version()) - # print(p.get_manufacturer_info()) - # print(p.get_system_parameters()) - # print(p.get_management_info()) - # print(p.get_module_serial_number()) - # print(p.get_values()) - print(p.get_values_single(2)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc639e0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[project] +name = "python-pylontech-ext" +version = "0.4.5" +description = "Interfaces with Pylontech Batteries using RS485 protocol" +authors = [ + { name = "Frank Villaro-Dixon", email = "frank@villaro-dixon.eu" }, + { name = "Pavel Shirshov", email = "pshirshov@eml.cc" }, +] +requires-python = ">=3.13" +readme = "README.md" +license = "MIT" +keywords = [ + "pylontech", + "pylon", + "rs485", + "lithium battery", + "US2000", + "US2000C", + "US3000", + "US5000", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Topic :: Utilities", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "pyserial", + "construct", + + "standard-telnetlib", + "Exscript", + + "rich", + "pymongo", + "requests", + +] +url = "http://github.com/Frankkkkk/python-pylontech" + +[project.scripts] +poller = "pylontechpoller:poller.main" + +[dependency-groups] +test = ["pytest"] +dev = ["flake8"] + +[tool.uv] +default-groups = [ + "test", + "dev", +] + +[tool.hatch.build.targets.sdist] +include = [ + "src/pylontech", + "src/pylontechpoller", +] +exclude = ["demos"] + +[tool.hatch.build.targets.wheel] +include = [ + "src/pylontech", + "src/pylontechpoller", +] +exclude = ["demos"] + +[tool.hatch.build.targets.wheel.sources] +"src/pylontech" = "pylontech" +"src/pylontechpoller" = "pylontechpoller" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_cli_level = "INFO" +xfail_strict = true diff --git a/setup.py b/setup.py deleted file mode 100644 index 090419f..0000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -from setuptools import setup - - -setup( - name="python-pylontech", - version="0.3.3", - author="Frank Villaro-Dixon", - author_email="frank@villaro-dixon.eu", - description=("Interfaces with Pylontech Batteries using RS485 protocol"), - license="MIT", - keywords="pylontech pylon rs485 lithium battery US2000 US2000C US3000", - url="http://github.com/Frankkkkk/python-pylontech", - packages=['pylontech'], - long_description=open("README.md", "r").read(), - long_description_content_type="text/markdown", - install_requires=['pyserial', 'construct'], - classifiers=[ - "Development Status :: 3 - Alpha", - "Topic :: Utilities", - "License :: OSI Approved :: MIT License", - ], -) diff --git a/src/pylontech/__init__.py b/src/pylontech/__init__.py new file mode 100644 index 0000000..e19ecc0 --- /dev/null +++ b/src/pylontech/__init__.py @@ -0,0 +1,7 @@ +from .pylontech import Pylontech + +from .transport import SerialTransport +from .transport import TelnetlibLegacyTransport +from .transport import ExscriptTelnetTransport +from .transport import SerialDeviceTransport +from .tools import to_json_serializable diff --git a/src/pylontech/pylontech.py b/src/pylontech/pylontech.py new file mode 100644 index 0000000..c2d74f6 --- /dev/null +++ b/src/pylontech/pylontech.py @@ -0,0 +1,111 @@ +import datetime + +from .transport import * +from .schema import * +from typing import * +import logging + +logger = logging.getLogger(__name__) + +class PylontechModule: + def __init__(self, idx, serial, manufacturer_info, device_name, system_parameters, management_info, fw_version): + self.idx = idx + self.serial = serial + self.manufacturer_info = manufacturer_info + self.device_name = device_name + self.system_parameters = system_parameters + self.management_info = management_info + self.fw_version = fw_version + +class PylontechStackData: + def __init__(self, modules: Dict[int, PylontechModule]): + self.ids = list(modules.keys()) + self.modules = modules + + def range(self): + return range(min(self.ids), max(self.ids)+1) + +class Pylontech(PylontechSchema): + def __init__(self, transport): + self.transport = transport + + def poll_parameters(self, ids: range): + while True: + result = {"timestamp": datetime.datetime.now(datetime.UTC), "modules": []} + for idx in ids: + vals = to_json_serializable(self.get_values_single(idx)) + result["modules"].append(vals) + yield result + + def scan_for_batteries(self, start=0, end=255) -> PylontechStackData: + """ Returns a map of the batteries id to their serial number """ + batteries = {} + for adr in range(start, end, 1): + self.transport.send_cmd(adr, 0x93, "{:02X}".format(adr).encode()) # Probe for serial number + raw_frame = self.transport.readln() + + if raw_frame: + sn = self.get_module_serial_number(adr) + sn_str = sn["ModuleSerialNumber"].decode() + + sp = self.get_system_parameters(adr) + mi = self.get_management_info(adr) + + m = self.get_manufacturer_info(adr) + nme = m["DeviceName"].decode() + mfr = m["ManufacturerName"].decode() + sw = m["SoftwareVersion"] + + batteries[adr] = PylontechModule(adr, sn_str, mfr, nme, sp, mi, sw) + + logger.debug("Found battery at address " + str(adr) + " with serial " + sn_str) + else: + logger.debug("No battery found at address " + str(adr)) + + return PylontechStackData(batteries) + + + def get_protocol_version(self, adr): + self.transport.send_cmd(adr, 0x4f, "{:02X}".format(adr).encode()) + return self.transport.read_frame() + + def get_manufacturer_info(self, adr): + self.transport.send_cmd(adr, 0x51, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + return self.manufacturer_info_fmt.parse(f.info) + + def get_system_parameters(self, adr): + self.transport.send_cmd(adr, 0x47, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + return self.system_parameters_fmt.parse(f.info[1:]) + + def get_management_info(self, adr): + self.transport.send_cmd(adr, 0x92, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + ff = self.management_info_fmt.parse(f.info[1:]) + return ff + + def get_module_serial_number(self, adr): + self.transport.send_cmd(adr, 0x93, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + return self.module_serial_number_fmt.parse(f.info[0:]) + + def get_module_software_version(self, adr): + self.transport.send_cmd(adr, 0x96, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + return self.module_software_version_fmt.parse(f.info) + + def get_values(self): + self.transport.send_cmd(2, 0x42, b'FF') + f = self.transport.read_frame() + return self.get_values_fmt.parse(f.info[1:]) + + def get_values_single(self, adr): + self.transport.send_cmd(adr, 0x42, "{:02X}".format(adr).encode()) + f = self.transport.read_frame() + return self.get_values_single_fmt.parse(f.info[1:]) + + def get_alarm_info(self, adr=0): + self.transport.send_cmd(adr, 0x4f,b'FF') + return self.transport.read_frame() + diff --git a/src/pylontech/schema.py b/src/pylontech/schema.py new file mode 100644 index 0000000..4dc5acf --- /dev/null +++ b/src/pylontech/schema.py @@ -0,0 +1,104 @@ +import construct +from .tools import * + +class PylontechSchema: + manufacturer_info_fmt = construct.Struct( + "DeviceName" / JoinBytes(construct.Array(10, construct.Byte)), + "SoftwareVersion" / construct.Array(2, construct.Byte), + "ManufacturerName" / JoinBytes(construct.GreedyRange(construct.Byte)), + ) + + system_parameters_fmt = construct.Struct( + "CellHighVoltageLimit" / ToVolt(construct.Int16ub), + "CellLowVoltageLimit" / ToVolt(construct.Int16ub), + "CellUnderVoltageLimit" / ToVolt(construct.Int16sb), + "ChargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), + "ChargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), + "ChargeCurrentLimit" / DivideBy10(construct.Int16sb), + "ModuleHighVoltageLimit" / ToVolt(construct.Int16ub), + "ModuleLowVoltageLimit" / ToVolt(construct.Int16ub), + "ModuleUnderVoltageLimit" / ToVolt(construct.Int16ub), + "DischargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), + "DischargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), + "DischargeCurrentLimit" / DivideBy10(construct.Int16sb), + ) + + management_info_fmt = construct.Struct( + "ChargeVoltageLimit" / DivideBy1000(construct.Int16ub), + "DischargeVoltageLimit" / DivideBy1000(construct.Int16ub), + "ChargeCurrentLimit" / ToAmp(construct.Int16sb), + "DischargeCurrentLimit" / ToAmp(construct.Int16sb), + "status" + / construct.BitStruct( + "ChargeEnable" / construct.Flag, + "DischargeEnable" / construct.Flag, + "ChargeImmediately2" / construct.Flag, + "ChargeImmediately1" / construct.Flag, + "FullChargeRequest" / construct.Flag, + "ShouldCharge" + / construct.Computed( + lambda this: this.ChargeImmediately2 + | this.ChargeImmediately1 + | this.FullChargeRequest + ), + "_padding" / construct.BitsInteger(3), + ), + ) + + module_serial_number_fmt = construct.Struct( + "CommandValue" / construct.Byte, + "ModuleSerialNumber" / JoinBytes(construct.Array(16, construct.Byte)), + ) + + module_software_version_fmt = construct.Struct( + "CommandValue" / construct.Byte, + "ModuleSoftwareVersion" / JoinBytes(construct.Array(5, construct.Byte)), + ) + + get_values_fmt = construct.Struct( + "NumberOfModules" / construct.Byte, + "Module" / construct.Array(construct.this.NumberOfModules, construct.Struct( + "NumberOfCells" / construct.Int8ub, + "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), + "NumberOfTemperatures" / construct.Int8ub, + "AverageBMSTemperature" / ToCelsius(construct.Int16sb), + "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), + "Current" / ToAmp(construct.Int16sb), + "Voltage" / ToVolt(construct.Int16ub), + "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), + "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), + "_UserDefinedItems" / construct.Int8ub, + "_TotalCapacity1" / DivideBy1000(construct.Int16ub), + "CycleNumber" / construct.Int16ub, + "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, + construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), + "TotalCapacity2" / DivideBy1000(construct.Int24ub))), + "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), + "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), + )), + "TotalPower" / construct.Computed(lambda this: sum([x.Power for x in this.Module])), + "StateOfCharge" / construct.Computed(lambda this: sum([x.RemainingCapacity for x in this.Module]) / sum([x.TotalCapacity for x in this.Module])), + + ) + get_values_single_fmt = construct.Struct( + "NumberOfModule" / construct.Byte, + "NumberOfCells" / construct.Int8ub, + "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), + "NumberOfTemperatures" / construct.Int8ub, + "AverageBMSTemperature" / ToCelsius(construct.Int16sb), + "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), + "Current" / ToAmp(construct.Int16sb), + "Voltage" / ToVolt(construct.Int16ub), + "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), + "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), + "_UserDefinedItems" / construct.Int8ub, + "_TotalCapacity1" / DivideBy1000(construct.Int16ub), + "CycleNumber" / construct.Int16ub, + "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, + construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), + "TotalCapacity2" / DivideBy1000(construct.Int24ub))), + "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), + "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), + "TotalPower" / construct.Computed(construct.this.Power), + "StateOfCharge" / construct.Computed(construct.this.RemainingCapacity / construct.this.TotalCapacity), + ) diff --git a/src/pylontech/tools.py b/src/pylontech/tools.py new file mode 100644 index 0000000..80aa084 --- /dev/null +++ b/src/pylontech/tools.py @@ -0,0 +1,58 @@ +import construct + + +class HexToByte(construct.Adapter): + def _decode(self, obj, context, path) -> bytes: + hexstr = ''.join([chr(x) for x in obj]) + return bytes.fromhex(hexstr) + + +class JoinBytes(construct.Adapter): + def _decode(self, obj, context, path) -> bytes: + return ''.join([chr(x) for x in obj]).encode() + + +class DivideBy1000(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return obj / 1000 + + +class DivideBy100(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return obj / 100 + +class DivideBy10(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return obj / 10 + +class ToVolt(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return obj / 1000 + +class ToAmp(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return obj / 10 + +class ToCelsius(construct.Adapter): + def _decode(self, obj, context, path) -> float: + return (obj - 2731) / 10.0 # in Kelvin*10 + +def to_json_serializable(obj): + from io import BytesIO + from construct import Container + import base64 + + if isinstance(obj, Container): + return {str(k): to_json_serializable(v) for k, v in obj.items() if k != "_io"} + elif isinstance(obj, dict): + return {str(k): to_json_serializable(v) for k, v in obj.items() if k != "_io"} + elif isinstance(obj, list): + return [to_json_serializable(v) for v in obj] + elif isinstance(obj, BytesIO): + return base64.b64encode(obj.getvalue()).decode('utf-8') # or use .hex() + elif isinstance(obj, bytes): + return base64.b64encode(obj).decode('utf-8') # or use obj.hex() + elif hasattr(obj, '__dict__'): + return {str(k): to_json_serializable(v) for k, v in vars(obj).items()} + else: + return obj diff --git a/src/pylontech/transport.py b/src/pylontech/transport.py new file mode 100644 index 0000000..8ec1afa --- /dev/null +++ b/src/pylontech/transport.py @@ -0,0 +1,155 @@ +import logging + +import serial +import telnetlib + +from .tools import * + +logger = logging.getLogger(__name__) + +class ChecksumMismatch(Exception): + def __init__(self, expected, actual): + self.expected = expected + self.actual = actual + super().__init__(self.__repr__()) + + def __repr__(self): + return f"expected {self.expected}, got {self.actual}" + +class FrameFormatException(Exception): + def __init__(self, raw_frame, message, cause = None): + self.raw_frame = raw_frame + self.cause = cause + self.message = message + super().__init__(self.__repr__()) + + def __repr__(self): + return self.message + + +class SerialTransport(): + def readln(self) -> bytes: + pass + + def write(self, data: bytes): + pass + + def send_cmd(self, address: int, cmd, info: bytes = b''): + raw_frame = self._encode_cmd(address, cmd, info) + self.write(raw_frame) + + def read_frame(self): + raw_frame = self.readln() + f = self._decode_hw_frame(raw_frame=raw_frame) + parsed = self._decode_frame(f) + return parsed + + def _encode_cmd(self, address: int, cid2: int, info: bytes = b''): + cid1 = 0x46 + + info_length = SerialTransport.get_info_length(info) + + frame = "{:02X}{:02X}{:02X}{:02X}{:04X}".format(0x20, address, cid1, cid2, info_length).encode() + frame += info + + frame_chksum = SerialTransport.get_frame_checksum(frame) + whole_frame = (b"~" + frame + "{:04X}".format(frame_chksum).encode() + b"\r") + return whole_frame + + + def _decode_hw_frame(self, raw_frame: bytes) -> bytes: + try: + frame_data = raw_frame[1:len(raw_frame) - 5] + frame_chksum = raw_frame[len(raw_frame) - 5:-1] + expected_frame_checksum = int(frame_chksum, 16) + real_frame_checksum = SerialTransport.get_frame_checksum(frame_data) + except BaseException as e: + m=f"cannot decode frame bytes, frame {raw_frame}" + raise FrameFormatException(raw_frame, message=m, cause=e) + + if real_frame_checksum != expected_frame_checksum: + m = f"expected checksum {expected_frame_checksum}, got {real_frame_checksum}, frame {raw_frame}" + raise FrameFormatException(raw_frame, message=m, cause=ChecksumMismatch(expected_frame_checksum, real_frame_checksum)) + + return frame_data + + @staticmethod + def get_frame_checksum(frame: bytes): + assert isinstance(frame, bytes) + + sum = 0 + for byte in frame: + sum += byte + sum = ~sum + sum %= 0x10000 + sum += 1 + return sum + + @staticmethod + def get_info_length(info: bytes) -> int: + lenid = len(info) + if lenid == 0: + return 0 + + lenid_sum = (lenid & 0xf) + ((lenid >> 4) & 0xf) + ((lenid >> 8) & 0xf) + lenid_modulo = lenid_sum % 16 + lenid_invert_plus_one = 0b1111 - lenid_modulo + 1 + + return (lenid_invert_plus_one << 12) + lenid + + def _decode_frame(self, frame): + format = construct.Struct( + "ver" / HexToByte(construct.Array(2, construct.Byte)), + "adr" / HexToByte(construct.Array(2, construct.Byte)), + "cid1" / HexToByte(construct.Array(2, construct.Byte)), + "cid2" / HexToByte(construct.Array(2, construct.Byte)), + "infolength" / HexToByte(construct.Array(4, construct.Byte)), + "info" / HexToByte(construct.GreedyRange(construct.Byte)), + ) + + return format.parse(frame) + +class SerialDeviceTransport(SerialTransport): + def __init__(self, serial_port='/dev/ttyUSB0', baudrate=115200): + self.s = serial.Serial(serial_port, baudrate, bytesize=8, parity=serial.PARITY_NONE, stopbits=1, timeout=2, exclusive=True) + + def readln(self) -> bytes: + return self.s.readline() + + def write(self, data: bytes): + self.s.write(data) + + +class TelnetlibLegacyTransport(SerialTransport): + def __init__(self, host, port=23, timeout=2): + self.timeout = timeout + self.s = telnetlib.Telnet(host, port, timeout=self.timeout) + + def readln(self) -> bytes: + return self.s.read_until(b'\r', timeout=self.timeout) + + def write(self, data: bytes): + self.s.write(data) + +from Exscript.protocols import Telnet + +class ExscriptTelnetTransport(SerialTransport): + def __init__(self, host, port=23, timeout=2): + self.timeout = timeout + self.conn = Telnet() + self.conn.connect(host, port) + self.conn.set_timeout(timeout) + + def readln(self): + data = b'' + while True: + chunk = self.conn.tn.rawq_getchar() + if not chunk: + break + data += chunk + if chunk == b'\r': + break + return data + + def write(self, data: bytes): + self.conn.send(data) diff --git a/src/pylontechpoller/__init__.py b/src/pylontechpoller/__init__.py new file mode 100644 index 0000000..2bcbec7 --- /dev/null +++ b/src/pylontechpoller/__init__.py @@ -0,0 +1 @@ +from .poller import main \ No newline at end of file diff --git a/src/pylontechpoller/poller.py b/src/pylontechpoller/poller.py new file mode 100644 index 0000000..b52e873 --- /dev/null +++ b/src/pylontechpoller/poller.py @@ -0,0 +1,167 @@ +import argparse +import json +import logging +import sys +import time + +from pylontech import * +from pylontechpoller.reporter import MongoReporter, HassReporter + +logger = logging.getLogger(__name__) + + +def find_min_max_modules(modules): + all_voltages = [] + all_disbalances = [] + + for module in modules: + mid = module["NumberOfModule"] + cvs = module["CellVoltages"] + for voltage in cvs: + all_voltages.append((mid, voltage)) + vmax = max(cvs) + vmin = min(cvs) + d = vmax - vmin + all_disbalances.append((mid, d)) + + if not all_voltages: + return None, None + + min_pair = min(all_voltages, key=lambda x: x[1]) + max_pair = max(all_voltages, key=lambda x: x[1]) + max_disbalance = max(all_disbalances, key=lambda x: abs(x[1])) + + return min_pair, max_pair, max_disbalance + + + +def minimize(b: json) -> json: + def minimize_module(m: json) -> json: + return { + "n": m["NumberOfModule"], + "v": m["Voltage"], + "cv": m["CellVoltages"], + "current": m["Current"], + "pw": m["Power"], + "cycle": m["CycleNumber"], + "soc": m["StateOfCharge"], + "tempavg": m["AverageBMSTemperature"], + "temps": m["GroupedCellsTemperatures"], + "remaining": m["RemainingCapacity"], + "disbalance": max(m["CellVoltages"]) - min(m["CellVoltages"]) + } + + modules = b["modules"] + find_min_max_modules(modules) + + (min_pair, max_pair, max_disbalance) = find_min_max_modules(modules) + + return { + "ts": b["timestamp"], + "cvmin": min_pair, + "cvmax": max_pair, + "stack_disbalance": max_pair[1] - min_pair[1], + "max_module_disbalance": max_disbalance, + "modules": list(map(minimize_module, modules)), + } + + + +def run(argv: list[str]): + parser = argparse.ArgumentParser(description="Pylontech RS485 poller") + + parser.add_argument("source_host", help="Telnet host") + + parser.add_argument("--source-port", help="Telnet host", default=23) + parser.add_argument("--timeout", type=int, help="timeout", default=2) + parser.add_argument("--interval", type=int, help="polling interval in msec", default=1000) + parser.add_argument("--retention-days", type=int, help="how long to retain history data", default=90) + parser.add_argument("--debug", type=bool, help="verbose output", default=False) + + parser.add_argument("--mongo-url", type=str, help="mongodb url", default=None) + parser.add_argument("--mongo-db", type=str, help="target mongo database", default="pylontech") + parser.add_argument("--mongo-collection-history", type=str, help="target mongo collection_hist for stack history", default="history") + parser.add_argument("--mongo-collection-meta", type=str, help="target mongo collection_hist for stack data", default="meta") + + parser.add_argument("--hass-url", type=str, help="hass url", default=None) + parser.add_argument("--hass-stack-disbalance", type=str, help="state id", default="input_number.stack_disbalance") + parser.add_argument("--hass-max-battery-disbalance", type=str, help="state id", default="input_number.max_bat_disbalance") + parser.add_argument("--hass-max-battery-disbalance-id", type=str, help="state id", default="input_text.max_disbalance_id") + parser.add_argument("--hass-token-file", type=str, help="hass token file", default="/var/run/agenix/hass-token") + + + args = parser.parse_args(argv[1:]) + + level = logging.DEBUG if args.debug else logging.INFO + logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p', level=level) + + cc = 0 + spinner = ['|', '/', '-', '\\'] + + reporters = [] + + while True: + try: + logging.debug("Preparing client...") + p = Pylontech(ExscriptTelnetTransport(host=args.source_host, port=args.source_port, timeout=args.timeout)) + + mongo_url = args.mongo_url + + if mongo_url: + reporters.append(MongoReporter( + mongo_url, + args.mongo_db, + args.mongo_collection_meta, + args.mongo_collection_history, + args.retention_days + )) + + hass_url = args.hass_url + print(hass_url) + if hass_url: + reporters.append(HassReporter( + hass_url, + args.hass_stack_disbalance, + args.hass_max_battery_disbalance, + args.hass_max_battery_disbalance_id, + args.hass_token_file + )) + + logging.info("About to start polling...") + bats = p.scan_for_batteries(2, 10) + + logging.info("Have battery stack data") + + for reporter in reporters: + reporter.report_meta(bats) + + for b in p.poll_parameters(bats.range()): + cc += 1 + + if sys.stdout.isatty(): + sys.stdout.write('\r' + spinner[cc % len(spinner)]) + sys.stdout.flush() + + mb = minimize(b) + # print(print_json(json.dumps(minimize(b)))) + for reporter in reporters: + reporter.report_state(mb) + + if cc % 86400 == 0: + for reporter in reporters: + reporter.cleanup() + + time.sleep(args.interval / 1000.0) + except (KeyboardInterrupt, SystemExit): + exit(0) + except BaseException as e: + logging.error("Exception occured: %s", e) + + + +def main(): + import sys + run(sys.argv) + +if __name__ == "__main__": + main() diff --git a/src/pylontechpoller/reporter.py b/src/pylontechpoller/reporter.py new file mode 100644 index 0000000..d3f8e4d --- /dev/null +++ b/src/pylontechpoller/reporter.py @@ -0,0 +1,71 @@ +import datetime +import json +import logging + +import requests +from pymongo import MongoClient + +from pylontech import to_json_serializable + +logger = logging.getLogger(__name__) + +class Reporter: + def report_meta(self, meta): + pass + + def report_state(self, state): + pass + + def cleanup(self): + pass + +class MongoReporter(Reporter): + def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days): + mongo = MongoClient(mongo_url) + db = mongo[mongo_db] + self.retention_days = retention_days + self.collection_meta = db[mongo_collection_meta] + self.collection_hist = db[mongo_collection_history] + self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90) + + + + def report_meta(self, meta): + self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)}) + + def report_state(self, state): + self.collection_hist.insert_one(state) + + def cleanup(self): + threshold = datetime.datetime.now() - datetime.timedelta(days= self.retention_days) + self.collection_hist.delete_many({"ts": {"$lt": threshold}}) + +class HassReporter(Reporter): + def __init__(self, hass_url, hass_stack_disbalance, hass_max_battery_disbalance, hass_max_battery_disbalance_id, hass_token_file): + self.hass_url = hass_url + self.hass_stack_disbalance = hass_stack_disbalance + self.hass_max_battery_disbalance = hass_max_battery_disbalance + self.hass_max_battery_disbalance_id = hass_max_battery_disbalance_id + with open(hass_token_file, 'r') as file: + self.hass_token = file.read().strip() + + + def report_state(self, state): + md = state["max_module_disbalance"] + self.update_hass_state(self.hass_stack_disbalance, int(state["stack_disbalance"] * 10000) / 10000.0) + self.update_hass_state(self.hass_max_battery_disbalance, int(md[1] * 10000) / 10000.0) + self.update_hass_state(self.hass_max_battery_disbalance_id, md[0]) + + def update_hass_state(self, id, value): + tpe = id.split('.')[0] + update = { + "entity_id": id, + "value": value + } + + url = f'{self.hass_url}/api/services/{tpe}/set_value' + + response = requests.post(url, data=json.dumps(update), headers={"Authorization": f"Bearer {self.hass_token}"}) + + if response.status_code != 200: + logger.error(f"hass state update failed for {id}: {response.status_code} {response.text}") diff --git a/tests/test_basic.py b/tests/test_basic.py index 0a26da6..619d897 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -2,11 +2,11 @@ from typing import List from pytest import approx +from pylontech import SerialTransport + sys.path.extend("..") import pylontech -from pylontech.pylontech import ToVolt, ToAmp, ToCelsius, DivideBy1000 -import construct class MockSerial(object): @@ -23,9 +23,20 @@ def write(self, data: bytes): print(f"write: {data}") +class MockTransport(SerialTransport): + def __init__(self, responses: List[bytes]): + self.s = MockSerial(responses) + + def readln(self) -> bytes: + return self.s.readline() + + def write(self, data: bytes): + self.s.write(data) + + class Pylontech(pylontech.Pylontech): def __init__(self, responses): - self.s = MockSerial(responses) + super().__init__(MockTransport(responses)) def test_us2000_3modules_info_parsing_1(): diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..54e0ce6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,516 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "configparser" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/ac/ea19242153b5e8be412a726a70e82c7b5c1537c83f61b20995b2eda3dcd7/configparser-7.2.0.tar.gz", hash = "sha256:b629cc8ae916e3afbd36d1b3d093f34193d851e11998920fdcfc4552218b7b70", size = 51273, upload-time = "2025-03-08T16:04:09.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/fe/f61e7129e9e689d9e40bbf8a36fb90f04eceb477f4617c02c6a18463e81f/configparser-7.2.0-py3-none-any.whl", hash = "sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62", size = 17232, upload-time = "2025-03-08T16:04:07.743Z" }, +] + +[[package]] +name = "construct" +version = "2.10.70" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "exscript" +version = "2.6.28" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configparser" }, + { name = "future" }, + { name = "paramiko" }, + { name = "pycryptodomex" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/bc/7a782226a5113e617149e850e5940eb01e42584c94d92153d41ded387361/Exscript-2.6.28-py2.py3-none-any.whl", hash = "sha256:85c061e6e6ab6ec30ec5dd5cf2375def405721f7c8b76935b6234faf196bd622", size = 255128, upload-time = "2023-03-08T23:06:15.187Z" }, +] + +[[package]] +name = "flake8" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177, upload-time = "2025-03-29T20:08:39.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786, upload-time = "2025-03-29T20:08:37.902Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paramiko" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312, upload-time = "2025-03-29T17:33:30.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424, upload-time = "2025-03-29T17:33:29.405Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175, upload-time = "2025-03-31T13:21:20.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164, upload-time = "2025-03-31T13:21:18.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pymongo" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/0c/1fb60383ab4b20566407b87f1a95b7f5cda83e8d5594da6fc84e2a543405/pymongo-4.13.0.tar.gz", hash = "sha256:92a06e3709e3c7e50820d352d3d4e60015406bcba69808937dac2a6d22226fde", size = 2166443, upload-time = "2025-05-14T19:11:08.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/9afa6091bce4adad7cad736dcdc35c139a9b551fc61032ef20c7ba17eae5/pymongo-4.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92f5e75ae265e798be1a8a40a29e2ab934e156f3827ca0e1c47e69d43f4dcb31", size = 965996, upload-time = "2025-05-14T19:10:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/e4242abffc0ee1501bb426d8a540e712e4f917491735f18622838b17f5a1/pymongo-4.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d631d879e934b46222f5092d8951cbb9fe83542649697c8d342ea7b5479f118", size = 965702, upload-time = "2025-05-14T19:10:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/0732876b48b1285bada803f4b0d7da5b720cf8f778d2117bbed9e04473a3/pymongo-4.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be048fb78e165243272a8cdbeb40d53eace82424b95417ab3ab6ec8e9b00c59b", size = 1953825, upload-time = "2025-05-14T19:10:16.214Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3b/6713fed92cab64508a1fb8359397c0720202e5f36d7faf4ed71b05875180/pymongo-4.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d81d159bd23d8ac53a6e819cccee991cb9350ab2541dfaa25aeb2f712d23b0a5", size = 2031179, upload-time = "2025-05-14T19:10:18.307Z" }, + { url = "https://files.pythonhosted.org/packages/89/2b/1aad904563c312a0dc2ff752acf0f11194f836304d6e15d05dff3a33df08/pymongo-4.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8af08ba2886f08d334bc7e5d5c662c60ea2f16e813a2c35106f399463fa11087", size = 1995093, upload-time = "2025-05-14T19:10:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/4c/cc/33786f4ce9a46c776f0d32601b353f8c42b552ea9ff8060c290c912b661e/pymongo-4.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b91f59137e46cd3ff17d5684a18e8006d65d0ee62eb1068b512262d1c2c5ae8", size = 1955820, upload-time = "2025-05-14T19:10:21.788Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dd/9a2a87bd4aab12a2281ac20d179912eed824cc6f67df49edd87fa4879b3e/pymongo-4.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61733c8f1ded90ab671a08033ee99b837073c73e505b3b3b633a55a0326e77f4", size = 1905394, upload-time = "2025-05-14T19:10:23.684Z" }, + { url = "https://files.pythonhosted.org/packages/04/be/0a70db5e4c4e1c162207e31eaa3debf98476e0265b154f6d2252f85969b0/pymongo-4.13.0-cp313-cp313-win32.whl", hash = "sha256:d10d3967e87c21869f084af5716d02626a17f6f9ccc9379fcbece5821c2a9fb4", size = 926840, upload-time = "2025-05-14T19:10:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/fb104175a7f15dd69691c8c32bd4b99c4338ec89fe094b6895c940cf2afb/pymongo-4.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9fe172e93551ddfdb94b9ad34dccebc4b7b680dc1d131bc6bd661c4a5b2945c", size = 949383, upload-time = "2025-05-14T19:10:27.234Z" }, + { url = "https://files.pythonhosted.org/packages/62/3f/c89a6121b0143fde431f04c267a0d49159b499f518630a43aa6288709749/pymongo-4.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5adc1349fd5c94d5dfbcbd1ad9858d1df61945a07f5905dcf17bb62eb4c81f93", size = 1022500, upload-time = "2025-05-14T19:10:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/89/8fc36b83768b44805dd3a1caf755f019b110d2111671950b39c8c7781cd9/pymongo-4.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8e11ea726ff8ddc8c8393895cd7e93a57e2558c27273d3712797895c53d25692", size = 1022503, upload-time = "2025-05-14T19:10:30.757Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/f216cf6218f8ceb4025fd10e3de486553bd5373c3b71a45fef3483b745bb/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02160ab3a67eca393a2a2bb83dccddf4db2196d0d7c6a980a55157e4bdadc06", size = 2282184, upload-time = "2025-05-14T19:10:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/56/32/08a9045dbcd76a25d36a0bd42c635b56d9aed47126bcca0e630a63e08444/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fca24e4df05501420b2ce2207c03f21fcbdfac1e3f41e312e61b8f416c5b4963", size = 2369224, upload-time = "2025-05-14T19:10:34.942Z" }, + { url = "https://files.pythonhosted.org/packages/16/63/7991853fa6cf5e52222f8f480081840fb452d78c1dcd6803cabe2d3557a6/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50c503b7e809e54740704ec4c87a0f2ccdb910c3b1d36c07dbd2029b6eaa6a50", size = 2328611, upload-time = "2025-05-14T19:10:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/e9/0f/11beecc8d48c7549db3f13f2101fd1c06ccb668697d33a6a5a05bb955574/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66800de4f4487e7c437991b44bc1e717aadaf06e67451a760efe5cd81ce86575", size = 2279806, upload-time = "2025-05-14T19:10:38.652Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/0358efc8dba796545e9bd4642d1337a9b67b60928c583799fb0726594855/pymongo-4.13.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82c36928c1c26580ce4f2497a6875968636e87c77108ff253d76b1355181a405", size = 2219131, upload-time = "2025-05-14T19:10:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/373cd1cd21eff769e22e4e0924dcbfd770dfa1298566d51a7097857267fc/pymongo-4.13.0-cp313-cp313t-win32.whl", hash = "sha256:1397eac713b84946210ab556666cfdd787eee824e910fbbe661d147e110ec516", size = 975711, upload-time = "2025-05-14T19:10:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/1e204091bdf264a0d9eccc21f7da099903a7a30045f055a91178686c0259/pymongo-4.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:99a52cfbf31579cc63c926048cd0ada6f96c98c1c4c211356193e07418e6207c", size = 1004287, upload-time = "2025-05-14T19:10:45.468Z" }, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "python-pylontech-ext" +version = "0.4.3" +source = { editable = "." } +dependencies = [ + { name = "construct" }, + { name = "exscript" }, + { name = "pymongo" }, + { name = "pyserial" }, + { name = "requests" }, + { name = "rich" }, + { name = "standard-telnetlib" }, +] + +[package.dev-dependencies] +dev = [ + { name = "flake8" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "construct" }, + { name = "exscript" }, + { name = "pymongo" }, + { name = "pyserial" }, + { name = "requests" }, + { name = "rich" }, + { name = "standard-telnetlib" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "flake8" }] +test = [{ name = "pytest" }] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "standard-telnetlib" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/06/7bf7c0ec16574aeb1f6602d6a7bdb020084362fb4a9b177c5465b0aae0b6/standard_telnetlib-3.13.0.tar.gz", hash = "sha256:243333696bf1659a558eb999c23add82c41ffc2f2d04a56fae13b61b536fb173", size = 12636, upload-time = "2024-10-30T16:01:42.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/85/a1808451ac0b36c61dffe8aea21e45c64ba7da28f6cb0d269171298c6281/standard_telnetlib-3.13.0-py3-none-any.whl", hash = "sha256:b268060a3220c80c7887f2ad9df91cd81e865f0c5052332b81d80ffda8677691", size = 9995, upload-time = "2024-10-30T16:01:29.289Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] From 442fccae155f59efca3eda3ec0b9e8ecb40b7b76 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 3 Jun 2025 20:36:32 +0100 Subject: [PATCH 2/8] minor improvements --- README.md | 2 +- pyproject.toml | 2 +- src/pylontechpoller/poller.py | 15 +++++++++++---- uv.lock | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bb9d612..45f4663 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ uv run python ./demos/test-serial.py /tmp/serial 1 ## How to run mongodb collector ```bash -uv run poller 192.168.1.7 --mongo-url mongodb://mongodb.local:27017 --interval 1000 --interval 5 +uv run poller 192.168.1.7 --mongo-url mongodb://mongodb.local:27017 --interval 1000 ``` # Hardware wiring diff --git a/pyproject.toml b/pyproject.toml index fc639e0..3f51766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-pylontech-ext" -version = "0.4.5" +version = "0.4.6" description = "Interfaces with Pylontech Batteries using RS485 protocol" authors = [ { name = "Frank Villaro-Dixon", email = "frank@villaro-dixon.eu" }, diff --git a/src/pylontechpoller/poller.py b/src/pylontechpoller/poller.py index b52e873..c121cef 100644 --- a/src/pylontechpoller/poller.py +++ b/src/pylontechpoller/poller.py @@ -96,6 +96,7 @@ def run(argv: list[str]): logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p', level=level) cc = 0 + errs = 0 spinner = ['|', '/', '-', '\\'] reporters = [] @@ -117,7 +118,7 @@ def run(argv: list[str]): )) hass_url = args.hass_url - print(hass_url) + if hass_url: reporters.append(HassReporter( hass_url, @@ -147,18 +148,24 @@ def run(argv: list[str]): for reporter in reporters: reporter.report_state(mb) + if cc % 1000 == 0: + logging.info("Updates submitted since startup: %d", cc) if cc % 86400 == 0: for reporter in reporters: reporter.cleanup() time.sleep(args.interval / 1000.0) + errs = 0 except (KeyboardInterrupt, SystemExit): exit(0) except BaseException as e: + errs += 1 logging.error("Exception occured: %s", e) - - - + if errs > 10: + logging.error("Too many exceptions in a row, exiting just in casej") + exit(1) + else: + time.sleep(args.interval / 1000.0) def main(): import sys run(sys.argv) diff --git a/uv.lock b/uv.lock index 54e0ce6..42619cc 100644 --- a/uv.lock +++ b/uv.lock @@ -434,7 +434,7 @@ wheels = [ [[package]] name = "python-pylontech-ext" -version = "0.4.3" +version = "0.4.5" source = { editable = "." } dependencies = [ { name = "construct" }, From be341df76385ef79752cc6ecf31d53766cefbc0f Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 3 Jun 2025 20:41:28 +0100 Subject: [PATCH 3/8] minor improvements --- src/pylontechpoller/poller.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pylontechpoller/poller.py b/src/pylontechpoller/poller.py index c121cef..0e711ab 100644 --- a/src/pylontechpoller/poller.py +++ b/src/pylontechpoller/poller.py @@ -150,7 +150,6 @@ def run(argv: list[str]): if cc % 1000 == 0: logging.info("Updates submitted since startup: %d", cc) - if cc % 86400 == 0: for reporter in reporters: reporter.cleanup() @@ -162,7 +161,7 @@ def run(argv: list[str]): errs += 1 logging.error("Exception occured: %s", e) if errs > 10: - logging.error("Too many exceptions in a row, exiting just in casej") + logging.error("Too many exceptions in a row, exiting just in case") exit(1) else: time.sleep(args.interval / 1000.0) From 7b147d1b24a6e7903950e6142629974d67b02694 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 12 Jun 2025 13:06:24 +0100 Subject: [PATCH 4/8] mqtt reporter --- pyproject.toml | 3 + src/pylontechpoller/hass_basic_reporter.py | 39 +++++ src/pylontechpoller/mongo_reporter.py | 27 ++++ src/pylontechpoller/mqtt_reporter.py | 169 +++++++++++++++++++++ src/pylontechpoller/poller.py | 82 +++------- src/pylontechpoller/reporter.py | 59 +------ src/pylontechpoller/tools.py | 55 +++++++ uv.lock | 141 ++++++++++++++++- 8 files changed, 459 insertions(+), 116 deletions(-) create mode 100644 src/pylontechpoller/hass_basic_reporter.py create mode 100644 src/pylontechpoller/mongo_reporter.py create mode 100644 src/pylontechpoller/mqtt_reporter.py create mode 100644 src/pylontechpoller/tools.py diff --git a/pyproject.toml b/pyproject.toml index 3f51766..cd629b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ "pymongo", "requests", + "paho-mqtt", + "ha-mqtt-discoverable", + ] url = "http://github.com/Frankkkkk/python-pylontech" diff --git a/src/pylontechpoller/hass_basic_reporter.py b/src/pylontechpoller/hass_basic_reporter.py new file mode 100644 index 0000000..68eca5d --- /dev/null +++ b/src/pylontechpoller/hass_basic_reporter.py @@ -0,0 +1,39 @@ +import json +import os + +import requests + +from pylontechpoller.reporter import Reporter, logger + + +class HassReporter(Reporter): + def __init__(self, hass_url, hass_stack_disbalance, hass_max_battery_disbalance, hass_max_battery_disbalance_id, hass_token): + self.hass_url = hass_url + self.hass_stack_disbalance = hass_stack_disbalance + self.hass_max_battery_disbalance = hass_max_battery_disbalance + self.hass_max_battery_disbalance_id = hass_max_battery_disbalance_id + if os.path.exists(hass_token): + with open(hass_token, 'r') as file: + hass_token = file.read().strip() + self.hass_token = hass_token + + + def report_state(self, state): + md = state["max_module_disbalance"] + self.update_hass_state(self.hass_stack_disbalance, int(state["stack_disbalance"] * 10000) / 10000.0) + self.update_hass_state(self.hass_max_battery_disbalance, int(md[1] * 10000) / 10000.0) + self.update_hass_state(self.hass_max_battery_disbalance_id, md[0]) + + def update_hass_state(self, id, value): + tpe = id.split('.')[0] + update = { + "entity_id": id, + "value": value + } + + url = f'{self.hass_url}/api/services/{tpe}/set_value' + + response = requests.post(url, data=json.dumps(update), headers={"Authorization": f"Bearer {self.hass_token}"}) + + if response.status_code != 200: + logger.error(f"hass state update failed for {id}: {response.status_code} {response.text}") diff --git a/src/pylontechpoller/mongo_reporter.py b/src/pylontechpoller/mongo_reporter.py new file mode 100644 index 0000000..3da32ae --- /dev/null +++ b/src/pylontechpoller/mongo_reporter.py @@ -0,0 +1,27 @@ +import datetime + +from pymongo import MongoClient + +from pylontech import to_json_serializable, Pylontech +from pylontech.pylontech import PylontechStackData +from pylontechpoller.reporter import Reporter + + +class MongoReporter(Reporter): + def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days): + mongo = MongoClient(mongo_url) + db = mongo[mongo_db] + self.retention_days = retention_days + self.collection_meta = db[mongo_collection_meta] + self.collection_hist = db[mongo_collection_history] + self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90) + + def report_meta(self, meta: PylontechStackData, p: Pylontech): + self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)}) + + def report_state(self, state): + self.collection_hist.insert_one(state) + + def cleanup(self): + threshold = datetime.datetime.now() - datetime.timedelta(days= self.retention_days) + self.collection_hist.delete_many({"ts": {"$lt": threshold}}) diff --git a/src/pylontechpoller/mqtt_reporter.py b/src/pylontechpoller/mqtt_reporter.py new file mode 100644 index 0000000..e00564d --- /dev/null +++ b/src/pylontechpoller/mqtt_reporter.py @@ -0,0 +1,169 @@ +import os.path + +from ha_mqtt_discoverable import Settings, DeviceInfo +from ha_mqtt_discoverable.sensors import SensorInfo, Sensor + +from pylontech.pylontech import PylontechModule, Pylontech, PylontechStackData +from pylontechpoller.tools import minimize +from pylontechpoller.reporter import Reporter + +import paho.mqtt.client as mqtt + + +class MqttReporter(Reporter): + def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): + if os.path.exists(mqtt_password): + with open(mqtt_password, 'r') as file: + mqtt_password = file.read().strip() + + client = mqtt.Client(client_id="pylontech-poller") + client.username_pw_set(mqtt_login, mqtt_password) + client.connect(mqtt_host, mqtt_port) + client.loop_start() + self.mqtt_settings = Settings.MQTT(client=client) + # client.enable_logger(logger) + + # self.mqtt_settings = Settings.MQTT(host=mqtt_host, port=mqtt_port, username=mqtt_login, password=mqtt_password, + # client_name="pylontech-poller") + + self.device_info = DeviceInfo(name="Pylontech Battery Stack", identifiers="pylontech_battery_stack") + + self.hass_stack_disbalance_info = SensorInfo( + name="Stack Disbalance", + device_class="voltage", + unique_id="stack_disbalance", + unit_of_measurement="V", + suggested_display_precision=3, + device=self.device_info, + icon="mdi:scale-unbalanced", + + ) + self.hass_stack_disbalance_settings = Settings(mqtt=self.mqtt_settings, entity=self.hass_stack_disbalance_info) + self.hass_stack_disbalance = Sensor(self.hass_stack_disbalance_settings) + + self.hass_max_battery_disbalance_info = SensorInfo( + name="Max Battery Disbalance", + device_class="voltage", + unique_id="max_battery_disbalance", + unit_of_measurement="V", + suggested_display_precision=3, + device=self.device_info, + icon="mdi:scale-unbalanced", + ) + self.hass_max_battery_disbalance_settings = Settings(mqtt=self.mqtt_settings, + entity=self.hass_max_battery_disbalance_info) + self.hass_max_battery_disbalance = Sensor(self.hass_max_battery_disbalance_settings) + + self.hass_max_disbalance_id = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Max disbalance ID", + unique_id=f"max_battery_disbalance_id", + device=self.device_info, + icon="mdi:battery-alert", + + ))) + self.bats = {} + + def report_meta(self, meta: PylontechStackData, p: Pylontech): + moduledata = { m["n"] : m for m in minimize( next(p.poll_parameters(meta.range())) )["modules"]} + cells = {} + + for id in meta.ids: + m = meta.modules[id] + device_info = DeviceInfo( + name=f"Pylontech Battery {id}", + identifiers=[f"pylontech_battery_{m.serial}", f"pylontech_battery_{id}", ], + manufacturer=m.manufacturer_info, + sw_version=".".join([str(x) for x in m.fw_version]), + model=m.device_name + ) + mdata = moduledata[id] + for cn, c in enumerate(mdata["cv"]): + cells[f"cell_{cn}_voltage"] = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name=f"Cell {cn} Voltage", + device_class="voltage", + unique_id=f"cell_voltage_{id}_{cn}", + unit_of_measurement="V", + suggested_display_precision=3, + device=device_info, + entity_category="diagnostic", + icon="mdi:gauge", + ))) + + self.bats[id] = { + "bat_soc": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="SoC", + device_class="battery", + unique_id=f"battery_soc_{id}", + unit_of_measurement="%", + suggested_display_precision=1, + device=device_info + ))), + "bat_disbalance": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Cell Disbalance", + device_class="voltage", + unique_id=f"battery_disbalance_{id}", + unit_of_measurement="V", + suggested_display_precision=3, + device=device_info, + icon="mdi:scale-unbalanced", + ))), + "bat_voltage": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Voltage", + device_class="voltage", + unique_id=f"battery_voltage_{id}", + unit_of_measurement="V", + suggested_display_precision=3, + device=device_info, + icon="mdi:gauge", + ))), + "bat_current": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Current", + device_class="current", + unique_id=f"battery_current_{id}", + unit_of_measurement="A", + suggested_display_precision=3, + device=device_info, + icon="mdi:current-dc", + ))), + "bat_power": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Power", + device_class="power", + unique_id=f"battery_power_{id}", + unit_of_measurement="W", + suggested_display_precision=2, + device=device_info, + icon="mdi:battery-charging", + ))), + "bat_cycle": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Cycle", + unique_id=f"battery_cycle_{id}", + device=device_info, + icon="mdi:battery-sync", + ))), + "bat_temp": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( + name="Temperature", + device_class="temperature", + unique_id=f"battery_temperature_{id}", + unit_of_measurement="C", + suggested_display_precision=1, + device=device_info, + ))), + } | cells + + def report_state(self, state): + md = state["max_module_disbalance"] + self.hass_stack_disbalance.set_state(state["stack_disbalance"]) + self.hass_max_battery_disbalance.set_state(md[1]) + self.hass_max_disbalance_id.set_state(md[0]) + + for b in state["modules"]: + s = self.bats[b["n"]] + s["bat_disbalance"].set_state(b["disbalance"]) + s["bat_voltage"].set_state(b["v"]) + s["bat_current"].set_state(b["current"]) + s["bat_soc"].set_state(int(b["soc"] * 1000) / 10.0) + s["bat_power"].set_state(b["pw"]) + s["bat_cycle"].set_state(b["cycle"]) + s["bat_temp"].set_state(b["tempavg"]) + for cn, c in enumerate(b["cv"]): + s[f"cell_{cn}_voltage"].set_state(c) \ No newline at end of file diff --git a/src/pylontechpoller/poller.py b/src/pylontechpoller/poller.py index 0e711ab..d69eb7b 100644 --- a/src/pylontechpoller/poller.py +++ b/src/pylontechpoller/poller.py @@ -1,70 +1,17 @@ import argparse -import json import logging import sys import time from pylontech import * -from pylontechpoller.reporter import MongoReporter, HassReporter +from pylontechpoller.mqtt_reporter import MqttReporter +from pylontechpoller.hass_basic_reporter import HassReporter +from pylontechpoller.mongo_reporter import MongoReporter +from pylontechpoller.tools import minimize logger = logging.getLogger(__name__) -def find_min_max_modules(modules): - all_voltages = [] - all_disbalances = [] - - for module in modules: - mid = module["NumberOfModule"] - cvs = module["CellVoltages"] - for voltage in cvs: - all_voltages.append((mid, voltage)) - vmax = max(cvs) - vmin = min(cvs) - d = vmax - vmin - all_disbalances.append((mid, d)) - - if not all_voltages: - return None, None - - min_pair = min(all_voltages, key=lambda x: x[1]) - max_pair = max(all_voltages, key=lambda x: x[1]) - max_disbalance = max(all_disbalances, key=lambda x: abs(x[1])) - - return min_pair, max_pair, max_disbalance - - - -def minimize(b: json) -> json: - def minimize_module(m: json) -> json: - return { - "n": m["NumberOfModule"], - "v": m["Voltage"], - "cv": m["CellVoltages"], - "current": m["Current"], - "pw": m["Power"], - "cycle": m["CycleNumber"], - "soc": m["StateOfCharge"], - "tempavg": m["AverageBMSTemperature"], - "temps": m["GroupedCellsTemperatures"], - "remaining": m["RemainingCapacity"], - "disbalance": max(m["CellVoltages"]) - min(m["CellVoltages"]) - } - - modules = b["modules"] - find_min_max_modules(modules) - - (min_pair, max_pair, max_disbalance) = find_min_max_modules(modules) - - return { - "ts": b["timestamp"], - "cvmin": min_pair, - "cvmax": max_pair, - "stack_disbalance": max_pair[1] - min_pair[1], - "max_module_disbalance": max_disbalance, - "modules": list(map(minimize_module, modules)), - } - def run(argv: list[str]): @@ -87,7 +34,14 @@ def run(argv: list[str]): parser.add_argument("--hass-stack-disbalance", type=str, help="state id", default="input_number.stack_disbalance") parser.add_argument("--hass-max-battery-disbalance", type=str, help="state id", default="input_number.max_bat_disbalance") parser.add_argument("--hass-max-battery-disbalance-id", type=str, help="state id", default="input_text.max_disbalance_id") - parser.add_argument("--hass-token-file", type=str, help="hass token file", default="/var/run/agenix/hass-token") + parser.add_argument("--hass-token", type=str, help="hass token or token file", default="/var/run/agenix/hass-token") + + + parser.add_argument("--mqtt-host", type=str, help="mqtt host", default=None) + parser.add_argument("--mqtt-port", type=int, help="mqtt url", default=1883) + parser.add_argument("--mqtt-user", type=str, help="mqtt login", default="mqtt") + parser.add_argument("--mqtt-password", type=str, help="mqtt password or password file", default="/var/run/agenix/mqtt-user") + args = parser.parse_args(argv[1:]) @@ -128,13 +82,23 @@ def run(argv: list[str]): args.hass_token_file )) + mqtt_host = args.mqtt_host + + if mqtt_host: + reporters.append(MqttReporter( + mqtt_host, + args.mqtt_port, + args.mqtt_user, + args.mqtt_password, + )) + logging.info("About to start polling...") bats = p.scan_for_batteries(2, 10) logging.info("Have battery stack data") for reporter in reporters: - reporter.report_meta(bats) + reporter.report_meta(bats, p) for b in p.poll_parameters(bats.range()): cc += 1 diff --git a/src/pylontechpoller/reporter.py b/src/pylontechpoller/reporter.py index d3f8e4d..9ad0479 100644 --- a/src/pylontechpoller/reporter.py +++ b/src/pylontechpoller/reporter.py @@ -1,16 +1,12 @@ -import datetime -import json import logging -import requests -from pymongo import MongoClient - -from pylontech import to_json_serializable +from pylontech import Pylontech +from pylontech.pylontech import PylontechStackData logger = logging.getLogger(__name__) class Reporter: - def report_meta(self, meta): + def report_meta(self, meta: PylontechStackData, p: Pylontech): pass def report_state(self, state): @@ -19,53 +15,4 @@ def report_state(self, state): def cleanup(self): pass -class MongoReporter(Reporter): - def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days): - mongo = MongoClient(mongo_url) - db = mongo[mongo_db] - self.retention_days = retention_days - self.collection_meta = db[mongo_collection_meta] - self.collection_hist = db[mongo_collection_history] - self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90) - - - - def report_meta(self, meta): - self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)}) - - def report_state(self, state): - self.collection_hist.insert_one(state) - - def cleanup(self): - threshold = datetime.datetime.now() - datetime.timedelta(days= self.retention_days) - self.collection_hist.delete_many({"ts": {"$lt": threshold}}) - -class HassReporter(Reporter): - def __init__(self, hass_url, hass_stack_disbalance, hass_max_battery_disbalance, hass_max_battery_disbalance_id, hass_token_file): - self.hass_url = hass_url - self.hass_stack_disbalance = hass_stack_disbalance - self.hass_max_battery_disbalance = hass_max_battery_disbalance - self.hass_max_battery_disbalance_id = hass_max_battery_disbalance_id - with open(hass_token_file, 'r') as file: - self.hass_token = file.read().strip() - - - def report_state(self, state): - md = state["max_module_disbalance"] - self.update_hass_state(self.hass_stack_disbalance, int(state["stack_disbalance"] * 10000) / 10000.0) - self.update_hass_state(self.hass_max_battery_disbalance, int(md[1] * 10000) / 10000.0) - self.update_hass_state(self.hass_max_battery_disbalance_id, md[0]) - - def update_hass_state(self, id, value): - tpe = id.split('.')[0] - update = { - "entity_id": id, - "value": value - } - - url = f'{self.hass_url}/api/services/{tpe}/set_value' - - response = requests.post(url, data=json.dumps(update), headers={"Authorization": f"Bearer {self.hass_token}"}) - if response.status_code != 200: - logger.error(f"hass state update failed for {id}: {response.status_code} {response.text}") diff --git a/src/pylontechpoller/tools.py b/src/pylontechpoller/tools.py new file mode 100644 index 0000000..076fe1c --- /dev/null +++ b/src/pylontechpoller/tools.py @@ -0,0 +1,55 @@ +import json + + +def find_min_max_modules(modules): + all_voltages = [] + all_disbalances = [] + + for module in modules: + mid = module["NumberOfModule"] + cvs = module["CellVoltages"] + for voltage in cvs: + all_voltages.append((mid, voltage)) + vmax = max(cvs) + vmin = min(cvs) + d = vmax - vmin + all_disbalances.append((mid, d)) + + if not all_voltages: + return None, None + + min_pair = min(all_voltages, key=lambda x: x[1]) + max_pair = max(all_voltages, key=lambda x: x[1]) + max_disbalance = max(all_disbalances, key=lambda x: abs(x[1])) + + return min_pair, max_pair, max_disbalance + +def minimize(b: json) -> json: + def minimize_module(m: json) -> json: + return { + "n": m["NumberOfModule"], + "v": m["Voltage"], + "cv": m["CellVoltages"], + "current": m["Current"], + "pw": m["Power"], + "cycle": m["CycleNumber"], + "soc": m["StateOfCharge"], + "tempavg": m["AverageBMSTemperature"], + "temps": m["GroupedCellsTemperatures"], + "remaining": m["RemainingCapacity"], + "disbalance": max(m["CellVoltages"]) - min(m["CellVoltages"]) + } + + modules = b["modules"] + find_min_max_modules(modules) + + (min_pair, max_pair, max_disbalance) = find_min_max_modules(modules) + + return { + "ts": b["timestamp"], + "cvmin": min_pair, + "cvmax": max_pair, + "stack_disbalance": max_pair[1] - min_pair[1], + "max_module_disbalance": max_disbalance, + "modules": list(map(minimize_module, modules)), + } diff --git a/uv.lock b/uv.lock index 42619cc..88214fe 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 2 requires-python = ">=3.13" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -213,6 +222,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "gitlike-commands" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/30/ad1e2fc1cb3fd55aa1549c6151c1a3ddb55c061bcde5419f3d12ff5120cd/gitlike_commands-0.3.0.tar.gz", hash = "sha256:72f4e65239cb6a4a2c614867c5f914b5d5994edd2863335515b543689b01ff70", size = 6736, upload-time = "2024-01-26T23:31:49.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d7/25dbc939f4f707f33b7743949648923271af660bd56f77bda40b47f031e0/gitlike_commands-0.3.0-py3-none-any.whl", hash = "sha256:c262f8f532639ec8558369bdc2cd904bd0b65638834ed333c42a51be69578f21", size = 7512, upload-time = "2024-01-26T23:31:47.856Z" }, +] + +[[package]] +name = "ha-mqtt-discoverable" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitlike-commands" }, + { name = "paho-mqtt" }, + { name = "pyaml" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/0d/c0bd1ced3e9a915ae6708478de5a15409857d23022a293c8cb1c221f4546/ha_mqtt_discoverable-0.19.2.tar.gz", hash = "sha256:2c0facdfdff5573a4bae7ab40e9b66cc077e65445fb9d6f356e1c74ce00aa9d9", size = 28631, upload-time = "2025-05-31T16:36:56.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/41/1b9e020548c37bb506bf5b7ba2205585caafed691eaf17f650beb87681fe/ha_mqtt_discoverable-0.19.2-py3-none-any.whl", hash = "sha256:84725816a53d4e64f9d81cac6493c60dbd73899300c169b978fff9fdcbae2344", size = 27764, upload-time = "2025-05-31T16:36:55.673Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -270,6 +303,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "paramiko" version = "3.5.1" @@ -293,6 +335,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pyaml" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/40/94f10f32ab952c5cca713d9ac9d8b2fdc37392d90eea403823eeac674c24/pyaml-25.5.0.tar.gz", hash = "sha256:5799560c7b1c9daf35a7a4535f53e2c30323f74cbd7cb4f2e715b16dd681a58a", size = 29812, upload-time = "2025-05-29T05:34:05.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/7d/1b5061beff826f902285827261485a058b943332eba8a5532a0164735205/pyaml-25.5.0-py3-none-any.whl", hash = "sha256:b9e0c4e58a5e8003f8f18e802db49fd0563ada587209b13e429bdcbefa87d035", size = 26422, upload-time = "2025-05-29T05:34:03.594Z" }, +] + [[package]] name = "pycodestyle" version = "2.13.0" @@ -341,6 +395,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + [[package]] name = "pyflakes" version = "3.3.2" @@ -434,11 +531,13 @@ wheels = [ [[package]] name = "python-pylontech-ext" -version = "0.4.5" +version = "0.4.6" source = { editable = "." } dependencies = [ { name = "construct" }, { name = "exscript" }, + { name = "ha-mqtt-discoverable" }, + { name = "paho-mqtt" }, { name = "pymongo" }, { name = "pyserial" }, { name = "requests" }, @@ -458,6 +557,8 @@ test = [ requires-dist = [ { name = "construct" }, { name = "exscript" }, + { name = "ha-mqtt-discoverable" }, + { name = "paho-mqtt" }, { name = "pymongo" }, { name = "pyserial" }, { name = "requests" }, @@ -469,6 +570,23 @@ requires-dist = [ dev = [{ name = "flake8" }] test = [{ name = "pytest" }] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -506,6 +624,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/85/a1808451ac0b36c61dffe8aea21e45c64ba7da28f6cb0d269171298c6281/standard_telnetlib-3.13.0-py3-none-any.whl", hash = "sha256:b268060a3220c80c7887f2ad9df91cd81e865f0c5052332b81d80ffda8677691", size = 9995, upload-time = "2024-10-30T16:01:29.289Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "urllib3" version = "2.4.0" From 38e709b83d50ef648e19d8378a2078b4fa2d8394 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sat, 7 Mar 2026 23:06:10 +0000 Subject: [PATCH 5/8] wip --- src/pylontechpoller/mqtt_reporter.py | 95 +++++++++++---------- src/pylontechpoller/poller.py | 79 +++++++++--------- src/pylontechpoller/reporter.py | 4 +- tests/test_poller.py | 119 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 91 deletions(-) create mode 100644 tests/test_poller.py diff --git a/src/pylontechpoller/mqtt_reporter.py b/src/pylontechpoller/mqtt_reporter.py index e00564d..e119022 100644 --- a/src/pylontechpoller/mqtt_reporter.py +++ b/src/pylontechpoller/mqtt_reporter.py @@ -1,13 +1,12 @@ import os.path -from ha_mqtt_discoverable import Settings, DeviceInfo -from ha_mqtt_discoverable.sensors import SensorInfo, Sensor +import paho.mqtt.client as mqtt +from ha_mqtt_discoverable import DeviceInfo, Settings +from ha_mqtt_discoverable.sensors import Sensor, SensorInfo -from pylontech.pylontech import PylontechModule, Pylontech, PylontechStackData -from pylontechpoller.tools import minimize +from pylontech.pylontech import Pylontech, PylontechStackData from pylontechpoller.reporter import Reporter - -import paho.mqtt.client as mqtt +from pylontechpoller.tools import minimize class MqttReporter(Reporter): @@ -16,15 +15,11 @@ def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): with open(mqtt_password, 'r') as file: mqtt_password = file.read().strip() - client = mqtt.Client(client_id="pylontech-poller") - client.username_pw_set(mqtt_login, mqtt_password) - client.connect(mqtt_host, mqtt_port) - client.loop_start() - self.mqtt_settings = Settings.MQTT(client=client) - # client.enable_logger(logger) - - # self.mqtt_settings = Settings.MQTT(host=mqtt_host, port=mqtt_port, username=mqtt_login, password=mqtt_password, - # client_name="pylontech-poller") + self.client = mqtt.Client(client_id="pylontech-poller") + self.client.username_pw_set(mqtt_login, mqtt_password) + self.client.connect(mqtt_host, mqtt_port) + self.client.loop_start() + self.mqtt_settings = Settings.MQTT(client=self.client) self.device_info = DeviceInfo(name="Pylontech Battery Stack", identifiers="pylontech_battery_stack") @@ -36,7 +31,6 @@ def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): suggested_display_precision=3, device=self.device_info, icon="mdi:scale-unbalanced", - ) self.hass_stack_disbalance_settings = Settings(mqtt=self.mqtt_settings, entity=self.hass_stack_disbalance_info) self.hass_stack_disbalance = Sensor(self.hass_stack_disbalance_settings) @@ -50,58 +44,59 @@ def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): device=self.device_info, icon="mdi:scale-unbalanced", ) - self.hass_max_battery_disbalance_settings = Settings(mqtt=self.mqtt_settings, - entity=self.hass_max_battery_disbalance_info) + self.hass_max_battery_disbalance_settings = Settings( + mqtt=self.mqtt_settings, + entity=self.hass_max_battery_disbalance_info, + ) self.hass_max_battery_disbalance = Sensor(self.hass_max_battery_disbalance_settings) self.hass_max_disbalance_id = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Max disbalance ID", - unique_id=f"max_battery_disbalance_id", + unique_id="max_battery_disbalance_id", device=self.device_info, icon="mdi:battery-alert", - ))) self.bats = {} def report_meta(self, meta: PylontechStackData, p: Pylontech): - moduledata = { m["n"] : m for m in minimize( next(p.poll_parameters(meta.range())) )["modules"]} - cells = {} + moduledata = {m["n"]: m for m in minimize(next(p.poll_parameters(meta.range())))["modules"]} - for id in meta.ids: - m = meta.modules[id] + for module_id in meta.ids: + m = meta.modules[module_id] device_info = DeviceInfo( - name=f"Pylontech Battery {id}", - identifiers=[f"pylontech_battery_{m.serial}", f"pylontech_battery_{id}", ], + name=f"Pylontech Battery {module_id}", + identifiers=[f"pylontech_battery_{m.serial}", f"pylontech_battery_{module_id}"], manufacturer=m.manufacturer_info, sw_version=".".join([str(x) for x in m.fw_version]), - model=m.device_name + model=m.device_name, ) - mdata = moduledata[id] - for cn, c in enumerate(mdata["cv"]): + mdata = moduledata[module_id] + cells = {} + for cn, _ in enumerate(mdata["cv"]): cells[f"cell_{cn}_voltage"] = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( - name=f"Cell {cn} Voltage", - device_class="voltage", - unique_id=f"cell_voltage_{id}_{cn}", - unit_of_measurement="V", - suggested_display_precision=3, - device=device_info, - entity_category="diagnostic", - icon="mdi:gauge", - ))) + name=f"Cell {cn} Voltage", + device_class="voltage", + unique_id=f"cell_voltage_{module_id}_{cn}", + unit_of_measurement="V", + suggested_display_precision=3, + device=device_info, + entity_category="diagnostic", + icon="mdi:gauge", + ))) - self.bats[id] = { + self.bats[module_id] = { "bat_soc": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="SoC", device_class="battery", - unique_id=f"battery_soc_{id}", + unique_id=f"battery_soc_{module_id}", unit_of_measurement="%", suggested_display_precision=1, - device=device_info + device=device_info, ))), "bat_disbalance": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Cell Disbalance", device_class="voltage", - unique_id=f"battery_disbalance_{id}", + unique_id=f"battery_disbalance_{module_id}", unit_of_measurement="V", suggested_display_precision=3, device=device_info, @@ -110,7 +105,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): "bat_voltage": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Voltage", device_class="voltage", - unique_id=f"battery_voltage_{id}", + unique_id=f"battery_voltage_{module_id}", unit_of_measurement="V", suggested_display_precision=3, device=device_info, @@ -119,7 +114,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): "bat_current": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Current", device_class="current", - unique_id=f"battery_current_{id}", + unique_id=f"battery_current_{module_id}", unit_of_measurement="A", suggested_display_precision=3, device=device_info, @@ -128,7 +123,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): "bat_power": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Power", device_class="power", - unique_id=f"battery_power_{id}", + unique_id=f"battery_power_{module_id}", unit_of_measurement="W", suggested_display_precision=2, device=device_info, @@ -136,14 +131,14 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): ))), "bat_cycle": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Cycle", - unique_id=f"battery_cycle_{id}", + unique_id=f"battery_cycle_{module_id}", device=device_info, icon="mdi:battery-sync", ))), "bat_temp": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Temperature", device_class="temperature", - unique_id=f"battery_temperature_{id}", + unique_id=f"battery_temperature_{module_id}", unit_of_measurement="C", suggested_display_precision=1, device=device_info, @@ -166,4 +161,8 @@ def report_state(self, state): s["bat_cycle"].set_state(b["cycle"]) s["bat_temp"].set_state(b["tempavg"]) for cn, c in enumerate(b["cv"]): - s[f"cell_{cn}_voltage"].set_state(c) \ No newline at end of file + s[f"cell_{cn}_voltage"].set_state(c) + + def close(self): + self.client.loop_stop() + self.client.disconnect() diff --git a/src/pylontechpoller/poller.py b/src/pylontechpoller/poller.py index d69eb7b..d528dfc 100644 --- a/src/pylontechpoller/poller.py +++ b/src/pylontechpoller/poller.py @@ -3,27 +3,25 @@ import sys import time -from pylontech import * -from pylontechpoller.mqtt_reporter import MqttReporter +from pylontech import ExscriptTelnetTransport, Pylontech from pylontechpoller.hass_basic_reporter import HassReporter from pylontechpoller.mongo_reporter import MongoReporter +from pylontechpoller.mqtt_reporter import MqttReporter from pylontechpoller.tools import minimize logger = logging.getLogger(__name__) - - def run(argv: list[str]): parser = argparse.ArgumentParser(description="Pylontech RS485 poller") parser.add_argument("source_host", help="Telnet host") - - parser.add_argument("--source-port", help="Telnet host", default=23) + + parser.add_argument("--source-port", type=int, help="Telnet host", default=23) parser.add_argument("--timeout", type=int, help="timeout", default=2) parser.add_argument("--interval", type=int, help="polling interval in msec", default=1000) parser.add_argument("--retention-days", type=int, help="how long to retain history data", default=90) - parser.add_argument("--debug", type=bool, help="verbose output", default=False) + parser.add_argument("--debug", action="store_true", help="verbose output") parser.add_argument("--mongo-url", type=str, help="mongodb url", default=None) parser.add_argument("--mongo-db", type=str, help="target mongo database", default="pylontech") @@ -36,102 +34,99 @@ def run(argv: list[str]): parser.add_argument("--hass-max-battery-disbalance-id", type=str, help="state id", default="input_text.max_disbalance_id") parser.add_argument("--hass-token", type=str, help="hass token or token file", default="/var/run/agenix/hass-token") - parser.add_argument("--mqtt-host", type=str, help="mqtt host", default=None) parser.add_argument("--mqtt-port", type=int, help="mqtt url", default=1883) parser.add_argument("--mqtt-user", type=str, help="mqtt login", default="mqtt") parser.add_argument("--mqtt-password", type=str, help="mqtt password or password file", default="/var/run/agenix/mqtt-user") - - args = parser.parse_args(argv[1:]) level = logging.DEBUG if args.debug else logging.INFO - logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p', level=level) + logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p', + level=level, + ) cc = 0 errs = 0 spinner = ['|', '/', '-', '\\'] - reporters = [] - while True: + reporters = [] try: - logging.debug("Preparing client...") + logger.debug("Preparing client...") p = Pylontech(ExscriptTelnetTransport(host=args.source_host, port=args.source_port, timeout=args.timeout)) - mongo_url = args.mongo_url - - if mongo_url: + if args.mongo_url: reporters.append(MongoReporter( - mongo_url, + args.mongo_url, args.mongo_db, args.mongo_collection_meta, args.mongo_collection_history, - args.retention_days + args.retention_days, )) - hass_url = args.hass_url - - if hass_url: + if args.hass_url: reporters.append(HassReporter( - hass_url, + args.hass_url, args.hass_stack_disbalance, args.hass_max_battery_disbalance, args.hass_max_battery_disbalance_id, - args.hass_token_file + args.hass_token, )) - mqtt_host = args.mqtt_host - - if mqtt_host: + if args.mqtt_host: reporters.append(MqttReporter( - mqtt_host, + args.mqtt_host, args.mqtt_port, args.mqtt_user, args.mqtt_password, )) - logging.info("About to start polling...") + logger.info("About to start polling...") bats = p.scan_for_batteries(2, 10) - - logging.info("Have battery stack data") + logger.info("Have battery stack data") for reporter in reporters: reporter.report_meta(bats, p) for b in p.poll_parameters(bats.range()): cc += 1 - + if sys.stdout.isatty(): sys.stdout.write('\r' + spinner[cc % len(spinner)]) sys.stdout.flush() mb = minimize(b) - # print(print_json(json.dumps(minimize(b)))) for reporter in reporters: reporter.report_state(mb) if cc % 1000 == 0: - logging.info("Updates submitted since startup: %d", cc) + logger.info("Updates submitted since startup: %d", cc) for reporter in reporters: reporter.cleanup() time.sleep(args.interval / 1000.0) errs = 0 except (KeyboardInterrupt, SystemExit): - exit(0) - except BaseException as e: + for reporter in reporters: + reporter.close() + return + except Exception: errs += 1 - logging.error("Exception occured: %s", e) + logger.exception("Exception occurred") + for reporter in reporters: + reporter.close() if errs > 10: - logging.error("Too many exceptions in a row, exiting just in case") - exit(1) - else: - time.sleep(args.interval / 1000.0) + logger.error("Too many exceptions in a row, exiting just in case") + raise SystemExit(1) + time.sleep(args.interval / 1000.0) + + def main(): - import sys run(sys.argv) + if __name__ == "__main__": main() diff --git a/src/pylontechpoller/reporter.py b/src/pylontechpoller/reporter.py index 9ad0479..93f8495 100644 --- a/src/pylontechpoller/reporter.py +++ b/src/pylontechpoller/reporter.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) + class Reporter: def report_meta(self, meta: PylontechStackData, p: Pylontech): pass @@ -15,4 +16,5 @@ def report_state(self, state): def cleanup(self): pass - + def close(self): + pass diff --git a/tests/test_poller.py b/tests/test_poller.py new file mode 100644 index 0000000..8c1df31 --- /dev/null +++ b/tests/test_poller.py @@ -0,0 +1,119 @@ +import pylontechpoller.poller as poller + + +class _FakeBats: + def range(self): + return range(2, 3) + + +class _FakeTransport: + def __init__(self, host, port, timeout): + self.host = host + self.port = port + self.timeout = timeout + + +class _ReporterSpy: + instances = [] + + def __init__(self, *args): + self.args = args + self.meta_calls = 0 + self.state_calls = 0 + self.cleanup_calls = 0 + self.close_calls = 0 + _ReporterSpy.instances.append(self) + + def report_meta(self, meta, p): + self.meta_calls += 1 + + def report_state(self, state): + self.state_calls += 1 + + def cleanup(self): + self.cleanup_calls += 1 + + def close(self): + self.close_calls += 1 + + +class _RetryingPylontech: + attempts = 0 + + def __init__(self, transport): + _RetryingPylontech.attempts += 1 + + def scan_for_batteries(self, start, end): + if _RetryingPylontech.attempts == 1: + raise RuntimeError("first attempt fails") + return _FakeBats() + + def poll_parameters(self, ids): + yield { + "max_module_disbalance": (2, 0.01), + "stack_disbalance": 0.02, + "modules": [], + } + raise KeyboardInterrupt + + +class _SinglePassPylontech: + def __init__(self, transport): + pass + + def scan_for_batteries(self, start, end): + return _FakeBats() + + def poll_parameters(self, ids): + raise KeyboardInterrupt + yield # pragma: no cover + + +def test_run_passes_hass_token(monkeypatch): + _ReporterSpy.instances.clear() + + monkeypatch.setattr(poller, "ExscriptTelnetTransport", _FakeTransport) + monkeypatch.setattr(poller, "Pylontech", _SinglePassPylontech) + monkeypatch.setattr(poller, "HassReporter", _ReporterSpy) + + poller.run([ + "poller", + "battery.local", + "--hass-url", + "http://hass.local", + "--hass-token", + "token-value", + ]) + + assert len(_ReporterSpy.instances) == 1 + hass = _ReporterSpy.instances[0] + assert hass.args[0] == "http://hass.local" + assert hass.args[4] == "token-value" + + +def test_run_does_not_reuse_reporters_across_retries(monkeypatch): + _ReporterSpy.instances.clear() + _RetryingPylontech.attempts = 0 + + monkeypatch.setattr(poller, "ExscriptTelnetTransport", _FakeTransport) + monkeypatch.setattr(poller, "Pylontech", _RetryingPylontech) + monkeypatch.setattr(poller, "MqttReporter", _ReporterSpy) + monkeypatch.setattr(poller, "minimize", lambda payload: payload) + + poller.run([ + "poller", + "battery.local", + "--mqtt-host", + "mqtt.local", + ]) + + assert len(_ReporterSpy.instances) == 2 + first, second = _ReporterSpy.instances + + assert first.meta_calls == 0 + assert first.state_calls == 0 + assert first.close_calls == 1 + + assert second.meta_calls == 1 + assert second.state_calls == 1 + assert second.close_calls == 1 From e22093855721d61e2d1e0de32eb341b7562ece3e Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sat, 7 Mar 2026 23:23:44 +0000 Subject: [PATCH 6/8] wip --- src/pylontechpoller/mongo_reporter.py | 5 +- src/pylontechpoller/mqtt_reporter.py | 14 ++- tests/test_reporters.py | 174 ++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 tests/test_reporters.py diff --git a/src/pylontechpoller/mongo_reporter.py b/src/pylontechpoller/mongo_reporter.py index 3da32ae..4bb954b 100644 --- a/src/pylontechpoller/mongo_reporter.py +++ b/src/pylontechpoller/mongo_reporter.py @@ -6,15 +6,18 @@ from pylontech.pylontech import PylontechStackData from pylontechpoller.reporter import Reporter +SECONDS_PER_DAY = 24 * 3600 + class MongoReporter(Reporter): def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days): + assert retention_days > 0 mongo = MongoClient(mongo_url) db = mongo[mongo_db] self.retention_days = retention_days self.collection_meta = db[mongo_collection_meta] self.collection_hist = db[mongo_collection_history] - self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90) + self.collection_hist.create_index("ts", expireAfterSeconds=retention_days * SECONDS_PER_DAY) def report_meta(self, meta: PylontechStackData, p: Pylontech): self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)}) diff --git a/src/pylontechpoller/mqtt_reporter.py b/src/pylontechpoller/mqtt_reporter.py index e119022..973f42b 100644 --- a/src/pylontechpoller/mqtt_reporter.py +++ b/src/pylontechpoller/mqtt_reporter.py @@ -28,6 +28,7 @@ def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): device_class="voltage", unique_id="stack_disbalance", unit_of_measurement="V", + state_class="measurement", suggested_display_precision=3, device=self.device_info, icon="mdi:scale-unbalanced", @@ -40,6 +41,7 @@ def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password): device_class="voltage", unique_id="max_battery_disbalance", unit_of_measurement="V", + state_class="measurement", suggested_display_precision=3, device=self.device_info, icon="mdi:scale-unbalanced", @@ -78,6 +80,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="voltage", unique_id=f"cell_voltage_{module_id}_{cn}", unit_of_measurement="V", + state_class="measurement", suggested_display_precision=3, device=device_info, entity_category="diagnostic", @@ -90,6 +93,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="battery", unique_id=f"battery_soc_{module_id}", unit_of_measurement="%", + state_class="measurement", suggested_display_precision=1, device=device_info, ))), @@ -98,6 +102,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="voltage", unique_id=f"battery_disbalance_{module_id}", unit_of_measurement="V", + state_class="measurement", suggested_display_precision=3, device=device_info, icon="mdi:scale-unbalanced", @@ -107,6 +112,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="voltage", unique_id=f"battery_voltage_{module_id}", unit_of_measurement="V", + state_class="measurement", suggested_display_precision=3, device=device_info, icon="mdi:gauge", @@ -116,6 +122,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="current", unique_id=f"battery_current_{module_id}", unit_of_measurement="A", + state_class="measurement", suggested_display_precision=3, device=device_info, icon="mdi:current-dc", @@ -125,6 +132,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): device_class="power", unique_id=f"battery_power_{module_id}", unit_of_measurement="W", + state_class="measurement", suggested_display_precision=2, device=device_info, icon="mdi:battery-charging", @@ -132,6 +140,7 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): "bat_cycle": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo( name="Cycle", unique_id=f"battery_cycle_{module_id}", + state_class="measurement", device=device_info, icon="mdi:battery-sync", ))), @@ -139,7 +148,8 @@ def report_meta(self, meta: PylontechStackData, p: Pylontech): name="Temperature", device_class="temperature", unique_id=f"battery_temperature_{module_id}", - unit_of_measurement="C", + unit_of_measurement="°C", + state_class="measurement", suggested_display_precision=1, device=device_info, ))), @@ -156,7 +166,7 @@ def report_state(self, state): s["bat_disbalance"].set_state(b["disbalance"]) s["bat_voltage"].set_state(b["v"]) s["bat_current"].set_state(b["current"]) - s["bat_soc"].set_state(int(b["soc"] * 1000) / 10.0) + s["bat_soc"].set_state(round(b["soc"] * 100, 1)) s["bat_power"].set_state(b["pw"]) s["bat_cycle"].set_state(b["cycle"]) s["bat_temp"].set_state(b["tempavg"]) diff --git a/tests/test_reporters.py b/tests/test_reporters.py new file mode 100644 index 0000000..b3e6120 --- /dev/null +++ b/tests/test_reporters.py @@ -0,0 +1,174 @@ +from types import SimpleNamespace + +import pylontechpoller.mongo_reporter as mongo_reporter +import pylontechpoller.mqtt_reporter as mqtt_reporter + + +class _FakeCollection: + def __init__(self): + self.create_index_calls = [] + + def create_index(self, *args, **kwargs): + self.create_index_calls.append((args, kwargs)) + + def insert_one(self, _): + pass + + def delete_many(self, _): + pass + + +class _FakeDb: + def __init__(self): + self.collections = { + "meta": _FakeCollection(), + "hist": _FakeCollection(), + } + + def __getitem__(self, name): + return self.collections[name] + + +class _FakeMongoClient: + def __init__(self, _url): + self.db = _FakeDb() + + def __getitem__(self, _name): + return self.db + + +class _FakeStateSensor: + def __init__(self): + self.values = [] + + def set_state(self, value): + self.values.append(value) + + +def test_mongo_reporter_uses_configured_retention_for_ttl(monkeypatch): + monkeypatch.setattr(mongo_reporter, "MongoClient", _FakeMongoClient) + + reporter = mongo_reporter.MongoReporter( + "mongodb://localhost:27017", + "pylontech", + "meta", + "hist", + 17, + ) + + calls = reporter.collection_hist.create_index_calls + assert len(calls) == 1 + assert calls[0][0] == ("ts",) + assert calls[0][1]["expireAfterSeconds"] == 17 * mongo_reporter.SECONDS_PER_DAY + + +def test_mqtt_reporter_soc_is_rounded_not_truncated(): + reporter = object.__new__(mqtt_reporter.MqttReporter) + reporter.hass_stack_disbalance = _FakeStateSensor() + reporter.hass_max_battery_disbalance = _FakeStateSensor() + reporter.hass_max_disbalance_id = _FakeStateSensor() + reporter.bats = { + 2: { + "bat_disbalance": _FakeStateSensor(), + "bat_voltage": _FakeStateSensor(), + "bat_current": _FakeStateSensor(), + "bat_soc": _FakeStateSensor(), + "bat_power": _FakeStateSensor(), + "bat_cycle": _FakeStateSensor(), + "bat_temp": _FakeStateSensor(), + "cell_0_voltage": _FakeStateSensor(), + } + } + + reporter.report_state({ + "stack_disbalance": 0.12, + "max_module_disbalance": (2, 0.03), + "modules": [{ + "n": 2, + "disbalance": 0.03, + "v": 50.1, + "current": -4.2, + "soc": 0.67895, + "pw": -210.42, + "cycle": 101, + "tempavg": 24.4, + "cv": [3.31], + }], + }) + + assert reporter.bats[2]["bat_soc"].values == [67.9] + + +def test_mqtt_reporter_uses_state_class_and_celsius_unit(monkeypatch): + sensor_info_calls = [] + + class _FakeClient: + def username_pw_set(self, *_args): + pass + + def connect(self, *_args): + pass + + def loop_start(self): + pass + + def loop_stop(self): + pass + + def disconnect(self): + pass + + class _FakeMqttModule: + @staticmethod + def Client(client_id): + assert client_id == "pylontech-poller" + return _FakeClient() + + class _FakeSettings: + def __init__(self, mqtt, entity): + self.mqtt = mqtt + self.entity = entity + + @staticmethod + def MQTT(client): + return SimpleNamespace(client=client) + + class _FakeSensor: + def __init__(self, settings): + self.settings = settings + + def set_state(self, _value): + pass + + def _fake_sensor_info(**kwargs): + sensor_info_calls.append(kwargs) + return SimpleNamespace(**kwargs) + + monkeypatch.setattr(mqtt_reporter, "mqtt", _FakeMqttModule) + monkeypatch.setattr(mqtt_reporter, "Settings", _FakeSettings) + monkeypatch.setattr(mqtt_reporter, "Sensor", _FakeSensor) + monkeypatch.setattr(mqtt_reporter, "SensorInfo", _fake_sensor_info) + monkeypatch.setattr(mqtt_reporter, "DeviceInfo", lambda **kwargs: SimpleNamespace(**kwargs)) + monkeypatch.setattr(mqtt_reporter, "minimize", lambda payload: payload) + + reporter = mqtt_reporter.MqttReporter("mqtt.local", 1883, "user", "pass") + + module = SimpleNamespace( + serial="SER123", + manufacturer_info="Pylon", + fw_version=[1, 2, 3], + device_name="US2000", + ) + meta = SimpleNamespace(ids=[2], modules={2: module}, range=lambda: range(2, 3)) + + class _FakePylontech: + def poll_parameters(self, _ids): + yield {"modules": [{"n": 2, "cv": [3.31]}]} + + reporter.report_meta(meta, _FakePylontech()) + + by_unique_id = {entry.get("unique_id"): entry for entry in sensor_info_calls} + assert by_unique_id["stack_disbalance"]["state_class"] == "measurement" + assert by_unique_id["battery_soc_2"]["state_class"] == "measurement" + assert by_unique_id["battery_temperature_2"]["state_class"] == "measurement" + assert by_unique_id["battery_temperature_2"]["unit_of_measurement"] == "°C" From e23adecbec34adde2f40e515b5ba7862bb20d5d5 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Wed, 8 Apr 2026 23:29:39 +0100 Subject: [PATCH 7/8] rust version --- rust-mqtt-adapter/.gitignore | 1 + rust-mqtt-adapter/Cargo.lock | 733 +++++++++++++++++++++++++ rust-mqtt-adapter/Cargo.toml | 13 + rust-mqtt-adapter/README.md | 31 ++ rust-mqtt-adapter/src/config.rs | 261 +++++++++ rust-mqtt-adapter/src/error.rs | 23 + rust-mqtt-adapter/src/main.rs | 142 +++++ rust-mqtt-adapter/src/model.rs | 324 +++++++++++ rust-mqtt-adapter/src/mqtt.rs | 870 ++++++++++++++++++++++++++++++ rust-mqtt-adapter/src/protocol.rs | 672 +++++++++++++++++++++++ 10 files changed, 3070 insertions(+) create mode 100644 rust-mqtt-adapter/.gitignore create mode 100644 rust-mqtt-adapter/Cargo.lock create mode 100644 rust-mqtt-adapter/Cargo.toml create mode 100644 rust-mqtt-adapter/README.md create mode 100644 rust-mqtt-adapter/src/config.rs create mode 100644 rust-mqtt-adapter/src/error.rs create mode 100644 rust-mqtt-adapter/src/main.rs create mode 100644 rust-mqtt-adapter/src/model.rs create mode 100644 rust-mqtt-adapter/src/mqtt.rs create mode 100644 rust-mqtt-adapter/src/protocol.rs diff --git a/rust-mqtt-adapter/.gitignore b/rust-mqtt-adapter/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust-mqtt-adapter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust-mqtt-adapter/Cargo.lock b/rust-mqtt-adapter/Cargo.lock new file mode 100644 index 0000000..0f22967 --- /dev/null +++ b/rust-mqtt-adapter/Cargo.lock @@ -0,0 +1,733 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pylontech-mqtt-adapter" +version = "0.1.0" +dependencies = [ + "clap", + "hex", + "rumqttc", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rumqttc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9" +dependencies = [ + "bytes", + "flume", + "futures-util", + "log", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust-mqtt-adapter/Cargo.toml b/rust-mqtt-adapter/Cargo.toml new file mode 100644 index 0000000..7df7881 --- /dev/null +++ b/rust-mqtt-adapter/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pylontech-mqtt-adapter" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Standalone Rust MQTT adapter for Pylontech batteries" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +hex = "0.4" +rumqttc = "0.24" +serde_json = "1.0" +thiserror = "2.0" diff --git a/rust-mqtt-adapter/README.md b/rust-mqtt-adapter/README.md new file mode 100644 index 0000000..bbf6410 --- /dev/null +++ b/rust-mqtt-adapter/README.md @@ -0,0 +1,31 @@ +# pylontech-mqtt-adapter + +Standalone Rust MQTT adapter for the existing Pylontech TCP bridge workflow. + +It does not modify or depend on the Python poller at runtime. It: + +- connects directly to the battery bridge over TCP +- scans a configurable address range +- polls per-module battery values +- publishes Home Assistant MQTT discovery payloads +- publishes stack and per-module JSON state topics + +## Usage + +```bash +nix shell nixpkgs#cargo nixpkgs#rustc -c \ + cargo run --manifest-path rust-mqtt-adapter/Cargo.toml -- \ + 192.168.1.7 \ + --mqtt-host mqtt.local \ + --mqtt-user mqtt \ + --mqtt-password-file /var/run/agenix/mqtt-user +``` + +## Topics + +- Availability: `pylontech/status` +- Stack state: `pylontech/stack/state` +- Module state: `pylontech/module/
/state` +- Discovery: `homeassistant/sensor/.../config` + +Both the topic prefix and discovery prefix are configurable. diff --git a/rust-mqtt-adapter/src/config.rs b/rust-mqtt-adapter/src/config.rs new file mode 100644 index 0000000..ff6d4b6 --- /dev/null +++ b/rust-mqtt-adapter/src/config.rs @@ -0,0 +1,261 @@ +use std::fs; +use std::ops::RangeInclusive; +use std::path::PathBuf; +use std::time::Duration; + +use clap::Parser; + +use crate::error::{AppError, AppResult}; + +const DEFAULT_SOURCE_PORT: u16 = 23; +const DEFAULT_MQTT_PORT: u16 = 1883; +const DEFAULT_TIMEOUT_MILLIS: u64 = 2_000; +const DEFAULT_INTERVAL_MILLIS: u64 = 1_000; +const DEFAULT_MANAGEMENT_INTERVAL_MILLIS: u64 = 30_000; +const DEFAULT_RECONNECT_INITIAL_DELAY_MILLIS: u64 = 1_000; +const DEFAULT_RECONNECT_MAX_DELAY_MILLIS: u64 = 30_000; +const DEFAULT_SCAN_START: u8 = 2; +const DEFAULT_SCAN_END: u8 = 9; +const DEFAULT_DISCOVERY_PREFIX: &str = "homeassistant"; +const DEFAULT_TOPIC_PREFIX: &str = "pylontech"; +const DEFAULT_CLIENT_ID: &str = "pylontech-rs-mqtt-adapter"; + +#[derive(Debug, Parser)] +#[command(about = "Standalone Rust MQTT adapter for Pylontech batteries")] +pub struct CliArgs { + #[arg(help = "TCP bridge host")] + pub source_host: String, + #[arg(long, default_value_t = DEFAULT_SOURCE_PORT, help = "TCP bridge port")] + pub source_port: u16, + #[arg(long, default_value_t = DEFAULT_TIMEOUT_MILLIS, help = "Read/write timeout in milliseconds")] + pub timeout_millis: u64, + #[arg(long, default_value_t = DEFAULT_INTERVAL_MILLIS, help = "Polling interval in milliseconds")] + pub interval_millis: u64, + #[arg(long, default_value_t = DEFAULT_MANAGEMENT_INTERVAL_MILLIS, help = "Management info polling interval in milliseconds")] + pub management_interval_millis: u64, + #[arg(long, default_value_t = DEFAULT_SCAN_START, help = "First module address to probe, inclusive")] + pub scan_start: u8, + #[arg(long, default_value_t = DEFAULT_SCAN_END, help = "Last module address to probe, inclusive")] + pub scan_end: u8, + #[arg(long, default_value_t = DEFAULT_RECONNECT_INITIAL_DELAY_MILLIS, help = "Initial reconnect delay in milliseconds")] + pub reconnect_initial_delay_millis: u64, + #[arg(long, default_value_t = DEFAULT_RECONNECT_MAX_DELAY_MILLIS, help = "Maximum reconnect delay in milliseconds")] + pub reconnect_max_delay_millis: u64, + #[arg(long, help = "MQTT broker host")] + pub mqtt_host: String, + #[arg(long, default_value_t = DEFAULT_MQTT_PORT, help = "MQTT broker port")] + pub mqtt_port: u16, + #[arg(long, help = "MQTT username")] + pub mqtt_user: Option, + #[arg(long, help = "MQTT password")] + pub mqtt_password: Option, + #[arg(long, help = "MQTT password file")] + pub mqtt_password_file: Option, + #[arg(long, default_value = DEFAULT_DISCOVERY_PREFIX, help = "Home Assistant MQTT discovery prefix")] + pub discovery_prefix: String, + #[arg(long, default_value = DEFAULT_TOPIC_PREFIX, help = "Base topic prefix for state and availability topics")] + pub topic_prefix: String, + #[arg(long, default_value = DEFAULT_CLIENT_ID, help = "MQTT client id")] + pub client_id: String, +} + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub source: SourceConfig, + pub polling: PollingConfig, + pub mqtt: MqttConfig, + pub reconnect: ReconnectConfig, +} + +#[derive(Debug, Clone)] +pub struct SourceConfig { + pub host: String, + pub port: u16, + pub timeout: Duration, +} + +#[derive(Debug, Clone)] +pub struct PollingConfig { + pub interval: Duration, + pub management_interval: Duration, + pub scan_start: u8, + pub scan_end: u8, +} + +impl PollingConfig { + pub fn addresses(&self) -> RangeInclusive { + self.scan_start..=self.scan_end + } +} + +#[derive(Debug, Clone)] +pub struct MqttConfig { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub discovery_prefix: String, + pub topic_prefix: String, + pub client_id: String, +} + +#[derive(Debug, Clone)] +pub struct ReconnectConfig { + pub initial_delay: Duration, + pub max_delay: Duration, +} + +impl CliArgs { + pub fn into_config(self) -> AppResult { + if self.source_host.trim().is_empty() { + return Err(AppError::InvalidConfig( + "source host cannot be empty".to_string(), + )); + } + if self.mqtt_host.trim().is_empty() { + return Err(AppError::InvalidConfig( + "mqtt host cannot be empty".to_string(), + )); + } + if self.timeout_millis == 0 { + return Err(AppError::InvalidConfig( + "timeout must be greater than zero".to_string(), + )); + } + if self.interval_millis == 0 { + return Err(AppError::InvalidConfig( + "interval must be greater than zero".to_string(), + )); + } + if self.management_interval_millis == 0 { + return Err(AppError::InvalidConfig( + "management interval must be greater than zero".to_string(), + )); + } + if self.scan_start > self.scan_end { + return Err(AppError::InvalidConfig(format!( + "scan start {} cannot be greater than scan end {}", + self.scan_start, self.scan_end + ))); + } + if self.reconnect_initial_delay_millis == 0 { + return Err(AppError::InvalidConfig( + "reconnect initial delay must be greater than zero".to_string(), + )); + } + if self.reconnect_max_delay_millis == 0 { + return Err(AppError::InvalidConfig( + "reconnect max delay must be greater than zero".to_string(), + )); + } + if self.reconnect_initial_delay_millis > self.reconnect_max_delay_millis { + return Err(AppError::InvalidConfig(format!( + "reconnect initial delay {} cannot be greater than reconnect max delay {}", + self.reconnect_initial_delay_millis, self.reconnect_max_delay_millis + ))); + } + + let password = load_password(self.mqtt_password, self.mqtt_password_file)?; + if password.is_some() && self.mqtt_user.is_none() { + return Err(AppError::InvalidConfig( + "mqtt password requires --mqtt-user".to_string(), + )); + } + + Ok(AppConfig { + source: SourceConfig { + host: self.source_host.trim().to_string(), + port: self.source_port, + timeout: Duration::from_millis(self.timeout_millis), + }, + polling: PollingConfig { + interval: Duration::from_millis(self.interval_millis), + management_interval: Duration::from_millis(self.management_interval_millis), + scan_start: self.scan_start, + scan_end: self.scan_end, + }, + mqtt: MqttConfig { + host: self.mqtt_host.trim().to_string(), + port: self.mqtt_port, + username: self.mqtt_user.map(|value| value.trim().to_string()), + password, + discovery_prefix: normalize_topic_segment( + &self.discovery_prefix, + "discovery prefix", + )?, + topic_prefix: normalize_topic_segment(&self.topic_prefix, "topic prefix")?, + client_id: self.client_id.trim().to_string(), + }, + reconnect: ReconnectConfig { + initial_delay: Duration::from_millis(self.reconnect_initial_delay_millis), + max_delay: Duration::from_millis(self.reconnect_max_delay_millis), + }, + }) + } +} + +fn normalize_topic_segment(value: &str, field_name: &str) -> AppResult { + let normalized = value.trim().trim_matches('/').to_string(); + if normalized.is_empty() { + return Err(AppError::InvalidConfig(format!( + "{field_name} cannot be empty" + ))); + } + Ok(normalized) +} + +fn load_password( + password: Option, + password_file: Option, +) -> AppResult> { + match (password, password_file) { + (Some(_), Some(_)) => Err(AppError::InvalidConfig( + "use either --mqtt-password or --mqtt-password-file".to_string(), + )), + (Some(password), None) => Ok(Some(password)), + (None, Some(path)) => { + let password = fs::read_to_string(path)?; + let password = password.trim().to_string(); + if password.is_empty() { + return Err(AppError::InvalidConfig( + "mqtt password file was empty".to_string(), + )); + } + Ok(Some(password)) + } + (None, None) => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::CliArgs; + + #[test] + fn into_config_rejects_reconnect_initial_delay_above_max() { + let args = CliArgs { + source_host: "battery.local".to_string(), + source_port: 23, + timeout_millis: 2_000, + interval_millis: 1_000, + management_interval_millis: 30_000, + scan_start: 2, + scan_end: 9, + reconnect_initial_delay_millis: 10_000, + reconnect_max_delay_millis: 1_000, + mqtt_host: "mqtt.local".to_string(), + mqtt_port: 1883, + mqtt_user: None, + mqtt_password: None, + mqtt_password_file: None, + discovery_prefix: "homeassistant".to_string(), + topic_prefix: "pylontech".to_string(), + client_id: "client".to_string(), + }; + + let error = args.into_config().unwrap_err(); + assert!(error.to_string().contains( + "reconnect initial delay 10000 cannot be greater than reconnect max delay 1000" + )); + } +} diff --git a/rust-mqtt-adapter/src/error.rs b/rust-mqtt-adapter/src/error.rs new file mode 100644 index 0000000..0f7e3ae --- /dev/null +++ b/rust-mqtt-adapter/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +use crate::protocol::ProtocolError; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("invalid configuration: {0}")] + InvalidConfig(String), + #[error("invalid state: {0}")] + InvalidState(String), + #[error("mqtt disconnected: {0}")] + MqttDisconnected(String), + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + #[error("mqtt client error: {0}")] + MqttClient(#[from] rumqttc::ClientError), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("protocol error: {0}")] + Protocol(#[from] ProtocolError), +} diff --git a/rust-mqtt-adapter/src/main.rs b/rust-mqtt-adapter/src/main.rs new file mode 100644 index 0000000..b1cff06 --- /dev/null +++ b/rust-mqtt-adapter/src/main.rs @@ -0,0 +1,142 @@ +mod config; +mod error; +mod model; +mod mqtt; +mod protocol; + +use std::thread; +use std::time::{Duration, Instant}; + +use clap::Parser; + +use crate::config::{AppConfig, CliArgs}; +use crate::error::{AppError, AppResult}; +use crate::model::StackState; +use crate::mqtt::MqttPublisher; +use crate::protocol::PylontechClient; + +fn main() -> Result<(), AppError> { + let config = CliArgs::parse().into_config()?; + run(config) +} + +fn run(config: AppConfig) -> AppResult<()> { + let mut reconnect_backoff = + ReconnectBackoff::new(config.reconnect.initial_delay, config.reconnect.max_delay); + + loop { + match run_session(&config) { + Ok(()) => { + reconnect_backoff.reset(); + return Ok(()); + } + Err(error) => { + let delay = reconnect_backoff.next_delay(); + eprintln!("session failed: {}; reconnecting in {:?}", error, delay); + thread::sleep(delay); + } + } + } +} + +fn run_session(config: &AppConfig) -> AppResult<()> { + let publisher = MqttPublisher::connect(&config.mqtt)?; + let result = run_session_inner(config, &publisher); + if result.is_err() { + publisher.publish_offline_best_effort(); + } + result +} + +fn run_session_inner(config: &AppConfig, publisher: &MqttPublisher) -> AppResult<()> { + let mut client = PylontechClient::connect(&config.source)?; + let modules = client.scan_modules(config.polling.addresses())?; + if modules.is_empty() { + return Err(AppError::InvalidState(format!( + "no modules discovered in range {}..={}", + config.polling.scan_start, config.polling.scan_end + ))); + } + + publisher.publish_discovery(&modules)?; + eprintln!("published discovery for {} module(s)", modules.len()); + for module in &modules { + let system_parameters = client.get_system_parameters(module.address)?; + publisher.publish_system_parameters(module.address, &system_parameters)?; + } + + let mut next_management_poll_at = Instant::now(); + loop { + publisher.ensure_healthy()?; + + let now = Instant::now(); + if now >= next_management_poll_at { + for module in &modules { + let management_info = client.get_management_info(module.address)?; + publisher.publish_management_info(module.address, &management_info)?; + } + next_management_poll_at = now + config.polling.management_interval; + } + + let mut states = Vec::with_capacity(modules.len()); + for module in &modules { + states.push(client.get_values_single(module.address)?); + } + + let stack = StackState::from_modules(&states)?; + publisher.publish_stack_state(&stack)?; + for state in &states { + publisher.publish_module_state(state)?; + } + + thread::sleep(config.polling.interval); + } +} + +#[derive(Debug, Clone)] +struct ReconnectBackoff { + current: Duration, + initial: Duration, + max: Duration, +} + +impl ReconnectBackoff { + fn new(initial: Duration, max: Duration) -> Self { + Self { + current: initial, + initial, + max, + } + } + + fn next_delay(&mut self) -> Duration { + let delay = self.current; + self.current = self.current.saturating_mul(2).min(self.max); + delay + } + + fn reset(&mut self) { + self.current = self.initial; + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::ReconnectBackoff; + + #[test] + fn reconnect_backoff_doubles_and_caps() { + let mut backoff = ReconnectBackoff::new(Duration::from_secs(1), Duration::from_secs(5)); + + assert_eq!(backoff.next_delay(), Duration::from_secs(1)); + assert_eq!(backoff.next_delay(), Duration::from_secs(2)); + assert_eq!(backoff.next_delay(), Duration::from_secs(4)); + assert_eq!(backoff.next_delay(), Duration::from_secs(5)); + assert_eq!(backoff.next_delay(), Duration::from_secs(5)); + + backoff.reset(); + assert_eq!(backoff.next_delay(), Duration::from_secs(1)); + } +} diff --git a/rust-mqtt-adapter/src/model.rs b/rust-mqtt-adapter/src/model.rs new file mode 100644 index 0000000..2620c6c --- /dev/null +++ b/rust-mqtt-adapter/src/model.rs @@ -0,0 +1,324 @@ +use serde_json::{Map, Value, json}; + +use crate::error::{AppError, AppResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModuleIdentity { + pub address: u8, + pub serial_number: String, + pub manufacturer_name: String, + pub device_name: String, + pub software_version: String, + pub cell_count: u8, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ModuleState { + pub address: u8, + pub cell_voltages: Vec, + pub average_bms_temperature_c: f64, + pub grouped_cells_temperatures_c: Vec, + pub current_a: f64, + pub voltage_v: f64, + pub power_w: f64, + pub remaining_capacity_ah: f64, + pub total_capacity_ah: f64, + pub cycle_number: u16, + pub disbalance_v: f64, + pub soc_ratio: f64, + pub soc_percent: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SystemParameters { + pub cell_high_voltage_limit_v: f64, + pub cell_low_voltage_limit_v: f64, + pub cell_under_voltage_limit_v: f64, + pub charge_high_temperature_limit_c: f64, + pub charge_low_temperature_limit_c: f64, + pub charge_current_limit_a: f64, + pub module_high_voltage_limit_v: f64, + pub module_low_voltage_limit_v: f64, + pub module_under_voltage_limit_v: f64, + pub discharge_high_temperature_limit_c: f64, + pub discharge_low_temperature_limit_c: f64, + pub discharge_current_limit_a: f64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagementStatus { + pub charge_enable: bool, + pub discharge_enable: bool, + pub charge_immediately_2: bool, + pub charge_immediately_1: bool, + pub full_charge_request: bool, + pub should_charge: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ManagementInfo { + pub charge_voltage_limit_v: f64, + pub discharge_voltage_limit_v: f64, + pub charge_current_limit_a: f64, + pub discharge_current_limit_a: f64, + pub status: ManagementStatus, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StackState { + pub stack_disbalance_v: f64, + pub max_module_disbalance_v: f64, + pub max_module_id: u8, +} + +impl ModuleState { + #[allow(clippy::too_many_arguments)] + pub fn new( + address: u8, + cell_voltages: Vec, + average_bms_temperature_c: f64, + grouped_cells_temperatures_c: Vec, + current_a: f64, + voltage_v: f64, + remaining_capacity_ah: f64, + total_capacity_ah: f64, + cycle_number: u16, + ) -> AppResult { + if cell_voltages.is_empty() { + return Err(AppError::InvalidState(format!( + "module {address} had no cell voltages" + ))); + } + if total_capacity_ah <= 0.0 { + return Err(AppError::InvalidState(format!( + "module {address} total capacity must be positive" + ))); + } + + let min_voltage = cell_voltages.iter().copied().fold(f64::INFINITY, f64::min); + let max_voltage = cell_voltages + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let disbalance_v = max_voltage - min_voltage; + let soc_ratio = remaining_capacity_ah / total_capacity_ah; + + Ok(Self { + address, + cell_voltages, + average_bms_temperature_c, + grouped_cells_temperatures_c, + current_a, + voltage_v, + power_w: current_a * voltage_v, + remaining_capacity_ah, + total_capacity_ah, + cycle_number, + disbalance_v, + soc_ratio, + soc_percent: soc_ratio * 100.0, + }) + } + + pub fn to_payload(&self) -> Value { + let mut payload = Map::new(); + payload.insert("address".to_string(), json!(self.address)); + payload.insert("voltage".to_string(), json!(self.voltage_v)); + payload.insert("current".to_string(), json!(self.current_a)); + payload.insert("power".to_string(), json!(self.power_w)); + payload.insert("cycle".to_string(), json!(self.cycle_number)); + payload.insert("soc_ratio".to_string(), json!(self.soc_ratio)); + payload.insert("soc_percent".to_string(), json!(self.soc_percent)); + payload.insert( + "temperature".to_string(), + json!(self.average_bms_temperature_c), + ); + payload.insert( + "grouped_temperatures".to_string(), + json!(self.grouped_cells_temperatures_c), + ); + payload.insert( + "remaining_capacity".to_string(), + json!(self.remaining_capacity_ah), + ); + payload.insert("total_capacity".to_string(), json!(self.total_capacity_ah)); + payload.insert("disbalance".to_string(), json!(self.disbalance_v)); + + for (index, voltage) in self.cell_voltages.iter().enumerate() { + payload.insert(format!("cell_{index}_voltage"), json!(voltage)); + } + + Value::Object(payload) + } +} + +impl SystemParameters { + pub fn to_payload(&self) -> Value { + json!({ + "cell_high_voltage_limit": self.cell_high_voltage_limit_v, + "cell_low_voltage_limit": self.cell_low_voltage_limit_v, + "cell_under_voltage_limit": self.cell_under_voltage_limit_v, + "charge_high_temperature_limit": self.charge_high_temperature_limit_c, + "charge_low_temperature_limit": self.charge_low_temperature_limit_c, + "charge_current_limit": self.charge_current_limit_a, + "module_high_voltage_limit": self.module_high_voltage_limit_v, + "module_low_voltage_limit": self.module_low_voltage_limit_v, + "module_under_voltage_limit": self.module_under_voltage_limit_v, + "discharge_high_temperature_limit": self.discharge_high_temperature_limit_c, + "discharge_low_temperature_limit": self.discharge_low_temperature_limit_c, + "discharge_current_limit": self.discharge_current_limit_a, + }) + } +} + +impl ManagementInfo { + pub fn to_payload(&self) -> Value { + json!({ + "charge_voltage_limit": self.charge_voltage_limit_v, + "discharge_voltage_limit": self.discharge_voltage_limit_v, + "charge_current_limit": self.charge_current_limit_a, + "discharge_current_limit": self.discharge_current_limit_a, + "charge_enable": self.status.charge_enable, + "discharge_enable": self.status.discharge_enable, + "charge_immediately_2": self.status.charge_immediately_2, + "charge_immediately_1": self.status.charge_immediately_1, + "full_charge_request": self.status.full_charge_request, + "should_charge": self.status.should_charge, + }) + } +} + +impl StackState { + pub fn from_modules(modules: &[ModuleState]) -> AppResult { + if modules.is_empty() { + return Err(AppError::InvalidState( + "cannot derive stack state from zero modules".to_string(), + )); + } + + let mut global_min = f64::INFINITY; + let mut global_max = f64::NEG_INFINITY; + let mut max_module_id = 0; + let mut max_module_disbalance = f64::NEG_INFINITY; + + for module in modules { + let module_min = module + .cell_voltages + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let module_max = module + .cell_voltages + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let module_disbalance = module_max - module_min; + + global_min = global_min.min(module_min); + global_max = global_max.max(module_max); + + if module_disbalance > max_module_disbalance { + max_module_disbalance = module_disbalance; + max_module_id = module.address; + } + } + + Ok(Self { + stack_disbalance_v: global_max - global_min, + max_module_disbalance_v: max_module_disbalance, + max_module_id, + }) + } + + pub fn to_payload(&self) -> Value { + json!({ + "stack_disbalance": self.stack_disbalance_v, + "max_module_disbalance": self.max_module_disbalance_v, + "max_module_id": self.max_module_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ManagementInfo, ManagementStatus, ModuleState, StackState, SystemParameters}; + use serde_json::json; + + #[test] + fn stack_state_uses_global_cell_span_and_module_disbalance() { + let first = ModuleState::new( + 2, + vec![3.31, 3.30], + 24.0, + vec![24.0], + -4.0, + 50.0, + 40.0, + 50.0, + 100, + ) + .unwrap(); + let second = ModuleState::new( + 3, + vec![3.29, 3.28], + 25.0, + vec![25.0], + -3.0, + 49.8, + 39.0, + 50.0, + 101, + ) + .unwrap(); + + let stack = StackState::from_modules(&[first, second]).unwrap(); + + assert_eq!(stack.max_module_id, 2); + assert!((stack.max_module_disbalance_v - 0.01).abs() < 1e-9); + assert!((stack.stack_disbalance_v - 0.03).abs() < 1e-9); + } + + #[test] + fn system_parameters_payload_uses_expected_keys() { + let payload = SystemParameters { + cell_high_voltage_limit_v: 3.7, + cell_low_voltage_limit_v: 3.05, + cell_under_voltage_limit_v: 2.9, + charge_high_temperature_limit_c: 33.4, + charge_low_temperature_limit_c: 26.2, + charge_current_limit_a: 10.2, + module_high_voltage_limit_v: 54.0, + module_low_voltage_limit_v: 46.0, + module_under_voltage_limit_v: 44.5, + discharge_high_temperature_limit_c: 33.4, + discharge_low_temperature_limit_c: 26.2, + discharge_current_limit_a: -10.0, + } + .to_payload(); + + assert_eq!(payload["module_high_voltage_limit"], json!(54.0)); + assert_eq!(payload["discharge_current_limit"], json!(-10.0)); + } + + #[test] + fn management_payload_exposes_status_flags() { + let payload = ManagementInfo { + charge_voltage_limit_v: 28.4, + discharge_voltage_limit_v: 23.2, + charge_current_limit_a: 55.5, + discharge_current_limit_a: -55.5, + status: ManagementStatus { + charge_enable: true, + discharge_enable: true, + charge_immediately_2: false, + charge_immediately_1: false, + full_charge_request: false, + should_charge: false, + }, + } + .to_payload(); + + assert_eq!(payload["charge_enable"], json!(true)); + assert_eq!(payload["discharge_current_limit"], json!(-55.5)); + } +} diff --git a/rust-mqtt-adapter/src/mqtt.rs b/rust-mqtt-adapter/src/mqtt.rs new file mode 100644 index 0000000..8d5a161 --- /dev/null +++ b/rust-mqtt-adapter/src/mqtt.rs @@ -0,0 +1,870 @@ +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use rumqttc::{Client, LastWill, MqttOptions, QoS}; +use serde_json::{Map, Value, json}; + +use crate::config::MqttConfig; +use crate::error::AppResult; +use crate::model::{ManagementInfo, ModuleIdentity, ModuleState, StackState, SystemParameters}; + +const MQTT_KEEPALIVE_SECONDS: u64 = 30; +const MQTT_REQUEST_CAPACITY: usize = 32; + +struct SensorDefinition { + component: &'static str, + name: &'static str, + unique_id_prefix: &'static str, + state_key: &'static str, + device_class: Option<&'static str>, + unit_of_measurement: Option<&'static str>, + state_class: Option<&'static str>, + icon: Option<&'static str>, + entity_category: Option<&'static str>, + suggested_display_precision: Option, +} + +const STACK_SENSORS: [SensorDefinition; 3] = [ + SensorDefinition { + component: "sensor", + name: "Stack Disbalance", + unique_id_prefix: "stack_disbalance", + state_key: "stack_disbalance", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: Some("measurement"), + icon: Some("mdi:scale-unbalanced"), + entity_category: None, + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Max Battery Disbalance", + unique_id_prefix: "max_battery_disbalance", + state_key: "max_module_disbalance", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: Some("measurement"), + icon: Some("mdi:scale-unbalanced"), + entity_category: None, + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Max Disbalance ID", + unique_id_prefix: "max_battery_disbalance_id", + state_key: "max_module_id", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:battery-alert"), + entity_category: None, + suggested_display_precision: None, + }, +]; + +const MODULE_SENSORS: [SensorDefinition; 7] = [ + SensorDefinition { + component: "sensor", + name: "SoC", + unique_id_prefix: "battery_soc", + state_key: "soc_percent", + device_class: Some("battery"), + unit_of_measurement: Some("%"), + state_class: Some("measurement"), + icon: None, + entity_category: None, + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Cell Disbalance", + unique_id_prefix: "battery_disbalance", + state_key: "disbalance", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: Some("measurement"), + icon: Some("mdi:scale-unbalanced"), + entity_category: None, + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Voltage", + unique_id_prefix: "battery_voltage", + state_key: "voltage", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: Some("measurement"), + icon: Some("mdi:gauge"), + entity_category: None, + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Current", + unique_id_prefix: "battery_current", + state_key: "current", + device_class: Some("current"), + unit_of_measurement: Some("A"), + state_class: Some("measurement"), + icon: Some("mdi:current-dc"), + entity_category: None, + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Power", + unique_id_prefix: "battery_power", + state_key: "power", + device_class: Some("power"), + unit_of_measurement: Some("W"), + state_class: Some("measurement"), + icon: Some("mdi:battery-charging"), + entity_category: None, + suggested_display_precision: Some(2), + }, + SensorDefinition { + component: "sensor", + name: "Cycle", + unique_id_prefix: "battery_cycle", + state_key: "cycle", + device_class: None, + unit_of_measurement: None, + state_class: Some("measurement"), + icon: Some("mdi:battery-sync"), + entity_category: None, + suggested_display_precision: None, + }, + SensorDefinition { + component: "sensor", + name: "Temperature", + unique_id_prefix: "battery_temperature", + state_key: "temperature", + device_class: Some("temperature"), + unit_of_measurement: Some("°C"), + state_class: Some("measurement"), + icon: None, + entity_category: None, + suggested_display_precision: Some(1), + }, +]; + +const SYSTEM_PARAMETER_SENSORS: [SensorDefinition; 12] = [ + SensorDefinition { + component: "sensor", + name: "Cell High Voltage Limit", + unique_id_prefix: "battery_cell_high_voltage_limit", + state_key: "cell_high_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Cell Low Voltage Limit", + unique_id_prefix: "battery_cell_low_voltage_limit", + state_key: "cell_low_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Cell Under Voltage Limit", + unique_id_prefix: "battery_cell_under_voltage_limit", + state_key: "cell_under_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Charge High Temperature Limit", + unique_id_prefix: "battery_charge_high_temperature_limit", + state_key: "charge_high_temperature_limit", + device_class: Some("temperature"), + unit_of_measurement: Some("°C"), + state_class: None, + icon: Some("mdi:thermometer-high"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Charge Low Temperature Limit", + unique_id_prefix: "battery_charge_low_temperature_limit", + state_key: "charge_low_temperature_limit", + device_class: Some("temperature"), + unit_of_measurement: Some("°C"), + state_class: None, + icon: Some("mdi:thermometer-low"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Charge Current Limit", + unique_id_prefix: "battery_charge_current_limit", + state_key: "charge_current_limit", + device_class: Some("current"), + unit_of_measurement: Some("A"), + state_class: None, + icon: Some("mdi:current-dc"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Module High Voltage Limit", + unique_id_prefix: "battery_module_high_voltage_limit", + state_key: "module_high_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Module Low Voltage Limit", + unique_id_prefix: "battery_module_low_voltage_limit", + state_key: "module_low_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Module Under Voltage Limit", + unique_id_prefix: "battery_module_under_voltage_limit", + state_key: "module_under_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Discharge High Temperature Limit", + unique_id_prefix: "battery_discharge_high_temperature_limit", + state_key: "discharge_high_temperature_limit", + device_class: Some("temperature"), + unit_of_measurement: Some("°C"), + state_class: None, + icon: Some("mdi:thermometer-high"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Discharge Low Temperature Limit", + unique_id_prefix: "battery_discharge_low_temperature_limit", + state_key: "discharge_low_temperature_limit", + device_class: Some("temperature"), + unit_of_measurement: Some("°C"), + state_class: None, + icon: Some("mdi:thermometer-low"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Discharge Current Limit", + unique_id_prefix: "battery_discharge_current_limit", + state_key: "discharge_current_limit", + device_class: Some("current"), + unit_of_measurement: Some("A"), + state_class: None, + icon: Some("mdi:current-dc"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, +]; + +const MANAGEMENT_VALUE_SENSORS: [SensorDefinition; 4] = [ + SensorDefinition { + component: "sensor", + name: "Charge Voltage Limit", + unique_id_prefix: "battery_management_charge_voltage_limit", + state_key: "charge_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Discharge Voltage Limit", + unique_id_prefix: "battery_management_discharge_voltage_limit", + state_key: "discharge_voltage_limit", + device_class: Some("voltage"), + unit_of_measurement: Some("V"), + state_class: None, + icon: Some("mdi:gauge"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(3), + }, + SensorDefinition { + component: "sensor", + name: "Charge Current Limit", + unique_id_prefix: "battery_management_charge_current_limit", + state_key: "charge_current_limit", + device_class: Some("current"), + unit_of_measurement: Some("A"), + state_class: None, + icon: Some("mdi:current-dc"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, + SensorDefinition { + component: "sensor", + name: "Discharge Current Limit", + unique_id_prefix: "battery_management_discharge_current_limit", + state_key: "discharge_current_limit", + device_class: Some("current"), + unit_of_measurement: Some("A"), + state_class: None, + icon: Some("mdi:current-dc"), + entity_category: Some("diagnostic"), + suggested_display_precision: Some(1), + }, +]; + +const MANAGEMENT_FLAG_SENSORS: [SensorDefinition; 6] = [ + SensorDefinition { + component: "binary_sensor", + name: "Charge Enable", + unique_id_prefix: "battery_management_charge_enable", + state_key: "charge_enable", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:battery-charging"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, + SensorDefinition { + component: "binary_sensor", + name: "Discharge Enable", + unique_id_prefix: "battery_management_discharge_enable", + state_key: "discharge_enable", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:battery-arrow-down"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, + SensorDefinition { + component: "binary_sensor", + name: "Charge Immediately 2", + unique_id_prefix: "battery_management_charge_immediately_2", + state_key: "charge_immediately_2", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:flash"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, + SensorDefinition { + component: "binary_sensor", + name: "Charge Immediately 1", + unique_id_prefix: "battery_management_charge_immediately_1", + state_key: "charge_immediately_1", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:flash"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, + SensorDefinition { + component: "binary_sensor", + name: "Full Charge Request", + unique_id_prefix: "battery_management_full_charge_request", + state_key: "full_charge_request", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:battery-plus"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, + SensorDefinition { + component: "binary_sensor", + name: "Should Charge", + unique_id_prefix: "battery_management_should_charge", + state_key: "should_charge", + device_class: None, + unit_of_measurement: None, + state_class: None, + icon: Some("mdi:battery-heart-variant"), + entity_category: Some("diagnostic"), + suggested_display_precision: None, + }, +]; + +pub struct MqttPublisher { + client: Client, + discovery_prefix: String, + topic_prefix: String, + healthy: Arc, + last_error: Arc>>, +} + +impl MqttPublisher { + pub fn connect(config: &MqttConfig) -> AppResult { + let status_topic = format!("{}/status", config.topic_prefix); + let mut options = MqttOptions::new(&config.client_id, &config.host, config.port); + options.set_keep_alive(Duration::from_secs(MQTT_KEEPALIVE_SECONDS)); + options.set_last_will(LastWill::new( + status_topic.clone(), + "offline", + QoS::AtLeastOnce, + true, + )); + + if let Some(username) = &config.username { + let password = config.password.clone().unwrap_or_default(); + options.set_credentials(username, password); + } + + let (client, mut connection) = Client::new(options, MQTT_REQUEST_CAPACITY); + let healthy = Arc::new(AtomicBool::new(true)); + let last_error = Arc::new(Mutex::new(None)); + let healthy_for_thread = Arc::clone(&healthy); + let last_error_for_thread = Arc::clone(&last_error); + thread::spawn(move || { + let mut failure_message = "mqtt event loop stopped unexpectedly".to_string(); + for notification in connection.iter() { + if let Err(error) = notification { + failure_message = format!("mqtt event loop stopped: {}", error); + eprintln!("{}", failure_message); + break; + } + } + *last_error_for_thread + .lock() + .expect("mqtt last_error mutex must not be poisoned") = Some(failure_message); + healthy_for_thread.store(false, Ordering::SeqCst); + }); + + let publisher = Self { + client, + discovery_prefix: config.discovery_prefix.clone(), + topic_prefix: config.topic_prefix.clone(), + healthy, + last_error, + }; + publisher.publish_text(&publisher.availability_topic(), true, "online")?; + Ok(publisher) + } + + pub fn ensure_healthy(&self) -> AppResult<()> { + if self.healthy.load(Ordering::SeqCst) { + return Ok(()); + } + + let error_message = self + .last_error + .lock() + .expect("mqtt last_error mutex must not be poisoned") + .clone() + .unwrap_or_else(|| "mqtt event loop stopped unexpectedly".to_string()); + Err(crate::error::AppError::MqttDisconnected(error_message)) + } + + pub fn publish_offline_best_effort(&self) { + let _ = self + .client + .publish(self.availability_topic(), QoS::AtLeastOnce, true, "offline"); + } + + pub fn publish_discovery(&self, modules: &[ModuleIdentity]) -> AppResult<()> { + self.publish_stack_discovery()?; + for module in modules { + self.publish_module_discovery(module)?; + } + Ok(()) + } + + pub fn publish_stack_state(&self, state: &StackState) -> AppResult<()> { + self.publish_json(&self.stack_state_topic(), false, &state.to_payload()) + } + + pub fn publish_module_state(&self, state: &ModuleState) -> AppResult<()> { + self.publish_json( + &self.module_state_topic(state.address), + false, + &state.to_payload(), + ) + } + + pub fn publish_system_parameters( + &self, + address: u8, + parameters: &SystemParameters, + ) -> AppResult<()> { + self.publish_json( + &self.module_system_topic(address), + true, + ¶meters.to_payload(), + ) + } + + pub fn publish_management_info( + &self, + address: u8, + management_info: &ManagementInfo, + ) -> AppResult<()> { + self.publish_json( + &self.module_management_topic(address), + false, + &management_info.to_payload(), + ) + } + + fn publish_stack_discovery(&self) -> AppResult<()> { + for sensor in STACK_SENSORS { + let payload = discovery_payload( + sensor.component, + sensor.name, + sensor.unique_id_prefix, + &self.stack_state_topic(), + sensor.state_key, + &self.stack_device(), + &self.availability_topic(), + sensor.device_class, + sensor.unit_of_measurement, + sensor.state_class, + sensor.icon, + sensor.entity_category, + sensor.suggested_display_precision, + ); + self.publish_json( + &self.discovery_topic(sensor.component, sensor.unique_id_prefix), + true, + &payload, + )?; + } + + Ok(()) + } + + fn publish_module_discovery(&self, module: &ModuleIdentity) -> AppResult<()> { + let device = self.module_device(module); + let state_topic = self.module_state_topic(module.address); + + for sensor in MODULE_SENSORS { + let unique_id = format!("{}_{}", sensor.unique_id_prefix, module.address); + let payload = discovery_payload( + sensor.component, + sensor.name, + &unique_id, + &state_topic, + sensor.state_key, + &device, + &self.availability_topic(), + sensor.device_class, + sensor.unit_of_measurement, + sensor.state_class, + sensor.icon, + sensor.entity_category, + sensor.suggested_display_precision, + ); + self.publish_json( + &self.discovery_topic(sensor.component, &unique_id), + true, + &payload, + )?; + } + + for cell_index in 0..module.cell_count { + let unique_id = format!("cell_voltage_{}_{}", module.address, cell_index); + let payload = discovery_payload( + "sensor", + &format!("Cell {} Voltage", cell_index), + &unique_id, + &state_topic, + &format!("cell_{}_voltage", cell_index), + &device, + &self.availability_topic(), + Some("voltage"), + Some("V"), + Some("measurement"), + Some("mdi:gauge"), + Some("diagnostic"), + Some(3), + ); + self.publish_json(&self.discovery_topic("sensor", &unique_id), true, &payload)?; + } + + for sensor in SYSTEM_PARAMETER_SENSORS { + let unique_id = format!("{}_{}", sensor.unique_id_prefix, module.address); + let payload = discovery_payload( + sensor.component, + sensor.name, + &unique_id, + &self.module_system_topic(module.address), + sensor.state_key, + &device, + &self.availability_topic(), + sensor.device_class, + sensor.unit_of_measurement, + sensor.state_class, + sensor.icon, + sensor.entity_category, + sensor.suggested_display_precision, + ); + self.publish_json( + &self.discovery_topic(sensor.component, &unique_id), + true, + &payload, + )?; + } + + for sensor in MANAGEMENT_VALUE_SENSORS { + let unique_id = format!("{}_{}", sensor.unique_id_prefix, module.address); + let payload = discovery_payload( + sensor.component, + sensor.name, + &unique_id, + &self.module_management_topic(module.address), + sensor.state_key, + &device, + &self.availability_topic(), + sensor.device_class, + sensor.unit_of_measurement, + sensor.state_class, + sensor.icon, + sensor.entity_category, + sensor.suggested_display_precision, + ); + self.publish_json( + &self.discovery_topic(sensor.component, &unique_id), + true, + &payload, + )?; + } + + for sensor in MANAGEMENT_FLAG_SENSORS { + let unique_id = format!("{}_{}", sensor.unique_id_prefix, module.address); + let payload = discovery_payload( + sensor.component, + sensor.name, + &unique_id, + &self.module_management_topic(module.address), + sensor.state_key, + &device, + &self.availability_topic(), + sensor.device_class, + sensor.unit_of_measurement, + sensor.state_class, + sensor.icon, + sensor.entity_category, + sensor.suggested_display_precision, + ); + self.publish_json( + &self.discovery_topic(sensor.component, &unique_id), + true, + &payload, + )?; + } + + Ok(()) + } + + fn publish_json(&self, topic: &str, retain: bool, payload: &Value) -> AppResult<()> { + self.publish_text(topic, retain, payload.to_string()) + } + + fn publish_text( + &self, + topic: &str, + retain: bool, + payload: impl Into>, + ) -> AppResult<()> { + self.ensure_healthy()?; + self.client + .publish(topic, QoS::AtLeastOnce, retain, payload)?; + Ok(()) + } + + fn availability_topic(&self) -> String { + format!("{}/status", self.topic_prefix) + } + + fn stack_state_topic(&self) -> String { + format!("{}/stack/state", self.topic_prefix) + } + + fn module_state_topic(&self, address: u8) -> String { + format!("{}/module/{}/state", self.topic_prefix, address) + } + + fn module_system_topic(&self, address: u8) -> String { + format!("{}/module/{}/system/state", self.topic_prefix, address) + } + + fn module_management_topic(&self, address: u8) -> String { + format!("{}/module/{}/management/state", self.topic_prefix, address) + } + + fn discovery_topic(&self, component: &str, unique_id: &str) -> String { + format!( + "{}/{}/{}/config", + self.discovery_prefix, component, unique_id + ) + } + + fn stack_device(&self) -> Value { + json!({ + "name": "Pylontech Battery Stack", + "identifiers": ["pylontech_battery_stack"], + }) + } + + fn module_device(&self, module: &ModuleIdentity) -> Value { + let identifiers = vec![ + format!("pylontech_battery_{}", module.serial_number), + format!("pylontech_battery_{}", module.address), + ]; + json!({ + "name": format!("Pylontech Battery {}", module.address), + "identifiers": identifiers, + "manufacturer": module.manufacturer_name, + "model": module.device_name, + "sw_version": module.software_version, + }) + } +} + +fn discovery_payload( + component: &str, + name: &str, + unique_id: &str, + state_topic: &str, + state_key: &str, + device: &Value, + availability_topic: &str, + device_class: Option<&str>, + unit_of_measurement: Option<&str>, + state_class: Option<&str>, + icon: Option<&str>, + entity_category: Option<&str>, + suggested_display_precision: Option, +) -> Value { + let mut payload = BTreeMap::new(); + payload.insert("name".to_string(), json!(name)); + payload.insert("unique_id".to_string(), json!(unique_id)); + payload.insert("state_topic".to_string(), json!(state_topic)); + payload.insert( + "value_template".to_string(), + json!(format!("{{{{ value_json.{state_key} }}}}")), + ); + payload.insert("availability_topic".to_string(), json!(availability_topic)); + payload.insert("payload_available".to_string(), json!("online")); + payload.insert("payload_not_available".to_string(), json!("offline")); + payload.insert("device".to_string(), device.clone()); + + insert_optional(&mut payload, "device_class", device_class); + insert_optional(&mut payload, "unit_of_measurement", unit_of_measurement); + insert_optional(&mut payload, "state_class", state_class); + insert_optional(&mut payload, "icon", icon); + insert_optional(&mut payload, "entity_category", entity_category); + + if component == "binary_sensor" { + payload.insert("payload_on".to_string(), json!("true")); + payload.insert("payload_off".to_string(), json!("false")); + } + + if let Some(precision) = suggested_display_precision { + payload.insert("suggested_display_precision".to_string(), json!(precision)); + } + + let object = payload.into_iter().collect::>(); + Value::Object(object) +} + +fn insert_optional(payload: &mut BTreeMap, key: &str, value: Option<&str>) { + if let Some(value) = value { + payload.insert(key.to_string(), json!(value)); + } +} + +#[cfg(test)] +mod tests { + use super::discovery_payload; + use serde_json::json; + + #[test] + fn discovery_payload_uses_json_value_template() { + let payload = discovery_payload( + "sensor", + "SoC", + "battery_soc_2", + "pylontech/module/2/state", + "soc_percent", + &json!({"name": "Pylontech Battery 2"}), + "pylontech/status", + Some("battery"), + Some("%"), + Some("measurement"), + None, + None, + Some(1), + ); + + assert_eq!( + payload["value_template"], + json!("{{ value_json.soc_percent }}") + ); + assert_eq!(payload["state_topic"], json!("pylontech/module/2/state")); + assert_eq!(payload["device_class"], json!("battery")); + assert_eq!(payload["suggested_display_precision"], json!(1)); + } + + #[test] + fn binary_sensor_payload_uses_true_false() { + let payload = discovery_payload( + "binary_sensor", + "Should Charge", + "battery_management_should_charge_2", + "pylontech/module/2/management/state", + "should_charge", + &json!({"name": "Pylontech Battery 2"}), + "pylontech/status", + None, + None, + None, + None, + Some("diagnostic"), + None, + ); + + assert_eq!(payload["payload_on"], json!("true")); + assert_eq!(payload["payload_off"], json!("false")); + } +} diff --git a/rust-mqtt-adapter/src/protocol.rs b/rust-mqtt-adapter/src/protocol.rs new file mode 100644 index 0000000..5590460 --- /dev/null +++ b/rust-mqtt-adapter/src/protocol.rs @@ -0,0 +1,672 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; + +use thiserror::Error; + +use crate::config::SourceConfig; +use crate::model::{ + ManagementInfo, ManagementStatus, ModuleIdentity, ModuleState, SystemParameters, +}; + +const FRAME_START_BYTE: u8 = b'~'; +const FRAME_END_BYTE: u8 = b'\r'; +const PROTOCOL_VERSION: u8 = 0x20; +const COMMAND_GROUP: u8 = 0x46; +const GET_VALUES_SINGLE_COMMAND: u8 = 0x42; +const GET_SYSTEM_PARAMETERS_COMMAND: u8 = 0x47; +const GET_MANUFACTURER_INFO_COMMAND: u8 = 0x51; +const GET_MANAGEMENT_INFO_COMMAND: u8 = 0x92; +const GET_MODULE_SERIAL_NUMBER_COMMAND: u8 = 0x93; +const DEVICE_NAME_LENGTH: usize = 10; +const SOFTWARE_VERSION_LENGTH: usize = 2; +const MODULE_SERIAL_LENGTH: usize = 16; + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + #[error("hex decode error: {0}")] + Hex(#[from] hex::FromHexError), + #[error("timed out waiting for a frame")] + Timeout, + #[error("invalid frame: {0}")] + InvalidFrame(String), + #[error("checksum mismatch: expected {expected:04X}, actual {actual:04X}")] + ChecksumMismatch { expected: u16, actual: u16 }, + #[error("info length mismatch: declared {declared}, actual {actual}")] + InvalidInfoLength { declared: usize, actual: usize }, + #[error("unexpected end of payload while reading {field}")] + UnexpectedPayloadEnd { field: &'static str }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManufacturerInfo { + pub device_name: String, + pub software_version: String, + pub manufacturer_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResponseFrame { + address: u8, + cid2: u8, + info: Vec, +} + +pub struct PylontechClient { + stream: TcpStream, +} + +impl PylontechClient { + pub fn connect(config: &SourceConfig) -> Result { + let address = format!("{}:{}", config.host, config.port); + let stream = TcpStream::connect(address)?; + stream.set_read_timeout(Some(config.timeout))?; + stream.set_write_timeout(Some(config.timeout))?; + Ok(Self { stream }) + } + + pub fn scan_modules( + &mut self, + addresses: impl IntoIterator, + ) -> Result, ProtocolError> { + let mut modules = Vec::new(); + + for address in addresses { + match self.try_discover_module(address) { + Ok(Some(module)) => { + eprintln!( + "discovered module {} serial={} model={}", + module.address, module.serial_number, module.device_name + ); + modules.push(module); + } + Ok(None) => { + eprintln!("no module at address {}", address); + } + Err(error) => { + eprintln!("failed to probe module {}: {}", address, error); + } + } + } + + Ok(modules) + } + + pub fn get_values_single(&mut self, address: u8) -> Result { + let frame = self.request(address, GET_VALUES_SINGLE_COMMAND, &[address])?; + parse_values_single_payload(address, &frame.info) + } + + pub fn get_system_parameters( + &mut self, + address: u8, + ) -> Result { + let frame = self.request(address, GET_SYSTEM_PARAMETERS_COMMAND, &[address])?; + parse_system_parameters_payload(&frame.info) + } + + pub fn get_management_info(&mut self, address: u8) -> Result { + let frame = self.request(address, GET_MANAGEMENT_INFO_COMMAND, &[address])?; + parse_management_info_payload(&frame.info) + } + + fn try_discover_module( + &mut self, + address: u8, + ) -> Result, ProtocolError> { + let serial_number = match self.try_get_module_serial_number(address)? { + Some(serial_number) => serial_number, + None => return Ok(None), + }; + let manufacturer_info = self.get_manufacturer_info(address)?; + let module_state = self.get_values_single(address)?; + + Ok(Some(ModuleIdentity { + address, + serial_number, + manufacturer_name: manufacturer_info.manufacturer_name, + device_name: manufacturer_info.device_name, + software_version: manufacturer_info.software_version, + cell_count: module_state.cell_voltages.len() as u8, + })) + } + + fn try_get_module_serial_number( + &mut self, + address: u8, + ) -> Result, ProtocolError> { + match self.request(address, GET_MODULE_SERIAL_NUMBER_COMMAND, &[address]) { + Ok(frame) => parse_module_serial_number(&frame.info).map(Some), + Err(ProtocolError::Timeout) => Ok(None), + Err(error) => Err(error), + } + } + + fn get_manufacturer_info(&mut self, address: u8) -> Result { + let frame = self.request(address, GET_MANUFACTURER_INFO_COMMAND, &[address])?; + parse_manufacturer_info(&frame.info) + } + + fn request( + &mut self, + address: u8, + command: u8, + info_bytes: &[u8], + ) -> Result { + let request_frame = encode_command(address, command, info_bytes)?; + self.stream.write_all(&request_frame)?; + self.stream.flush()?; + let raw_frame = self.read_raw_frame()?; + parse_response_frame(&raw_frame) + } + + fn read_raw_frame(&mut self) -> Result, ProtocolError> { + let mut started = false; + let mut frame = Vec::new(); + + loop { + let mut next_byte = [0_u8; 1]; + match self.stream.read_exact(&mut next_byte) { + Ok(()) => { + let byte = next_byte[0]; + if !started { + if byte == FRAME_START_BYTE { + started = true; + frame.push(byte); + } + continue; + } + + frame.push(byte); + if byte == FRAME_END_BYTE { + return Ok(frame); + } + } + Err(error) if is_timeout_error(&error) => return Err(ProtocolError::Timeout), + Err(error) => return Err(ProtocolError::Io(error)), + } + } + } +} + +fn is_timeout_error(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::TimedOut || error.kind() == std::io::ErrorKind::WouldBlock +} + +fn encode_command(address: u8, command: u8, info_bytes: &[u8]) -> Result, ProtocolError> { + let info_hex = hex::encode_upper(info_bytes); + let info_length = encode_info_length(info_hex.len())?; + let frame_body = format!( + "{PROTOCOL_VERSION:02X}{address:02X}{COMMAND_GROUP:02X}{command:02X}{info_length:04X}{info_hex}" + ); + let checksum = frame_checksum(frame_body.as_bytes()); + Ok(format!("~{frame_body}{checksum:04X}\r").into_bytes()) +} + +fn encode_info_length(encoded_info_length: usize) -> Result { + if encoded_info_length > 0x0FFF { + return Err(ProtocolError::InvalidFrame(format!( + "encoded info too large: {} bytes", + encoded_info_length + ))); + } + if encoded_info_length == 0 { + return Ok(0); + } + + let len = encoded_info_length as u16; + let len_sum = (len & 0xF) + ((len >> 4) & 0xF) + ((len >> 8) & 0xF); + let len_modulo = len_sum % 16; + let len_invert_plus_one = 0b1111 - len_modulo + 1; + + Ok((len_invert_plus_one << 12) + len) +} + +fn frame_checksum(frame: &[u8]) -> u16 { + let sum = frame + .iter() + .fold(0_u32, |accumulator, byte| accumulator + u32::from(*byte)); + let complement = !sum; + let wrapped = complement % 0x1_0000; + (wrapped + 1) as u16 +} + +fn parse_response_frame(raw_frame: &[u8]) -> Result { + if raw_frame.len() < 1 + 12 + 4 + 1 { + return Err(ProtocolError::InvalidFrame(format!( + "frame too short: {} bytes", + raw_frame.len() + ))); + } + if raw_frame[0] != FRAME_START_BYTE { + return Err(ProtocolError::InvalidFrame( + "frame did not start with '~'".to_string(), + )); + } + if *raw_frame.last().unwrap() != FRAME_END_BYTE { + return Err(ProtocolError::InvalidFrame( + "frame did not end with carriage return".to_string(), + )); + } + + let frame_data = &raw_frame[1..raw_frame.len() - 5]; + let checksum_bytes = &raw_frame[raw_frame.len() - 5..raw_frame.len() - 1]; + let expected_checksum = parse_hex_u16(checksum_bytes)?; + let actual_checksum = frame_checksum(frame_data); + if expected_checksum != actual_checksum { + return Err(ProtocolError::ChecksumMismatch { + expected: expected_checksum, + actual: actual_checksum, + }); + } + + let version = parse_hex_u8(&frame_data[0..2])?; + if version != PROTOCOL_VERSION { + return Err(ProtocolError::InvalidFrame(format!( + "unexpected protocol version {version:02X}" + ))); + } + + let address = parse_hex_u8(&frame_data[2..4])?; + let cid1 = parse_hex_u8(&frame_data[4..6])?; + if cid1 != COMMAND_GROUP { + return Err(ProtocolError::InvalidFrame(format!( + "unexpected command group {cid1:02X}" + ))); + } + let cid2 = parse_hex_u8(&frame_data[6..8])?; + let info_length = parse_hex_u16(&frame_data[8..12])?; + let info_hex = &frame_data[12..]; + validate_info_length(info_length, info_hex.len())?; + let info = hex::decode(info_hex)?; + + Ok(ResponseFrame { + address, + cid2, + info, + }) +} + +fn validate_info_length( + info_length: u16, + actual_encoded_length: usize, +) -> Result<(), ProtocolError> { + let declared_length = usize::from(info_length & 0x0FFF); + if declared_length != actual_encoded_length { + return Err(ProtocolError::InvalidInfoLength { + declared: declared_length, + actual: actual_encoded_length, + }); + } + + if declared_length == 0 { + return Ok(()); + } + + let expected = encode_info_length(declared_length)?; + if expected != info_length { + return Err(ProtocolError::InvalidFrame(format!( + "invalid length checksum nibble in {info_length:04X}" + ))); + } + + Ok(()) +} + +fn parse_module_serial_number(info: &[u8]) -> Result { + let mut payload = ByteCursor::new(info); + let _command_value = payload.read_u8("CommandValue")?; + let serial_number = payload.read_exact("ModuleSerialNumber", MODULE_SERIAL_LENGTH)?; + payload.ensure_exhausted()?; + Ok(normalize_ascii(serial_number)) +} + +fn parse_values_single_payload( + expected_address: u8, + info: &[u8], +) -> Result { + if info.is_empty() { + return Err(ProtocolError::InvalidFrame( + "values payload was empty".to_string(), + )); + } + + let mut payload = ByteCursor::new(&info[1..]); + + let reported_address = payload.read_u8("NumberOfModule")?; + if reported_address != expected_address { + return Err(ProtocolError::InvalidFrame(format!( + "requested module {expected_address} but response reported {reported_address}" + ))); + } + + let cell_count = payload.read_u8("NumberOfCells")?; + let mut cell_voltages = Vec::with_capacity(cell_count as usize); + for _ in 0..cell_count { + cell_voltages.push(f64::from(payload.read_i16("CellVoltage")?) / 1000.0); + } + + let temperature_count = payload.read_u8("NumberOfTemperatures")?; + if temperature_count == 0 { + return Err(ProtocolError::InvalidFrame(format!( + "module {expected_address} reported zero temperatures" + ))); + } + + let average_bms_temperature_c = + kelvin_tenths_to_celsius(payload.read_i16("AverageBMSTemperature")?); + + let mut grouped_cells_temperatures_c = Vec::with_capacity(temperature_count as usize - 1); + for _ in 0..usize::from(temperature_count - 1) { + grouped_cells_temperatures_c.push(kelvin_tenths_to_celsius( + payload.read_i16("GroupedCellTemperature")?, + )); + } + + let current_a = f64::from(payload.read_i16("Current")?) / 10.0; + let voltage_v = f64::from(payload.read_u16("Voltage")?) / 1000.0; + let remaining_capacity_primary = f64::from(payload.read_u16("RemainingCapacity1")?) / 1000.0; + let user_defined_items = payload.read_u8("UserDefinedItems")?; + let total_capacity_primary = f64::from(payload.read_u16("TotalCapacity1")?) / 1000.0; + let cycle_number = payload.read_u16("CycleNumber")?; + + let (remaining_capacity_ah, total_capacity_ah) = if user_defined_items > 2 { + ( + f64::from(payload.read_u24("RemainingCapacity2")?) / 1000.0, + f64::from(payload.read_u24("TotalCapacity2")?) / 1000.0, + ) + } else { + (remaining_capacity_primary, total_capacity_primary) + }; + + payload.ensure_exhausted()?; + + ModuleState::new( + reported_address, + cell_voltages, + average_bms_temperature_c, + grouped_cells_temperatures_c, + current_a, + voltage_v, + remaining_capacity_ah, + total_capacity_ah, + cycle_number, + ) + .map_err(|error| ProtocolError::InvalidFrame(error.to_string())) +} + +fn parse_system_parameters_payload(info: &[u8]) -> Result { + if info.is_empty() { + return Err(ProtocolError::InvalidFrame( + "system parameters payload was empty".to_string(), + )); + } + + let mut payload = ByteCursor::new(&info[1..]); + let system_parameters = SystemParameters { + cell_high_voltage_limit_v: f64::from(payload.read_u16("CellHighVoltageLimit")?) / 1000.0, + cell_low_voltage_limit_v: f64::from(payload.read_u16("CellLowVoltageLimit")?) / 1000.0, + cell_under_voltage_limit_v: f64::from(payload.read_i16("CellUnderVoltageLimit")?) / 1000.0, + charge_high_temperature_limit_c: kelvin_tenths_to_celsius( + payload.read_i16("ChargeHighTemperatureLimit")?, + ), + charge_low_temperature_limit_c: kelvin_tenths_to_celsius( + payload.read_i16("ChargeLowTemperatureLimit")?, + ), + charge_current_limit_a: f64::from(payload.read_i16("ChargeCurrentLimit")?) / 10.0, + module_high_voltage_limit_v: f64::from(payload.read_u16("ModuleHighVoltageLimit")?) + / 1000.0, + module_low_voltage_limit_v: f64::from(payload.read_u16("ModuleLowVoltageLimit")?) / 1000.0, + module_under_voltage_limit_v: f64::from(payload.read_u16("ModuleUnderVoltageLimit")?) + / 1000.0, + discharge_high_temperature_limit_c: kelvin_tenths_to_celsius( + payload.read_i16("DischargeHighTemperatureLimit")?, + ), + discharge_low_temperature_limit_c: kelvin_tenths_to_celsius( + payload.read_i16("DischargeLowTemperatureLimit")?, + ), + discharge_current_limit_a: f64::from(payload.read_i16("DischargeCurrentLimit")?) / 10.0, + }; + payload.ensure_exhausted()?; + Ok(system_parameters) +} + +fn parse_management_info_payload(info: &[u8]) -> Result { + if info.is_empty() { + return Err(ProtocolError::InvalidFrame( + "management info payload was empty".to_string(), + )); + } + + let mut payload = ByteCursor::new(&info[1..]); + let charge_voltage_limit_v = f64::from(payload.read_u16("ChargeVoltageLimit")?) / 1000.0; + let discharge_voltage_limit_v = f64::from(payload.read_u16("DischargeVoltageLimit")?) / 1000.0; + let charge_current_limit_a = f64::from(payload.read_i16("ChargeCurrentLimit")?) / 10.0; + let discharge_current_limit_a = f64::from(payload.read_i16("DischargeCurrentLimit")?) / 10.0; + let status_byte = payload.read_u8("Status")?; + payload.ensure_exhausted()?; + + let charge_immediately_2 = status_byte & 0b0010_0000 != 0; + let charge_immediately_1 = status_byte & 0b0001_0000 != 0; + let full_charge_request = status_byte & 0b0000_1000 != 0; + + Ok(ManagementInfo { + charge_voltage_limit_v, + discharge_voltage_limit_v, + charge_current_limit_a, + discharge_current_limit_a, + status: ManagementStatus { + charge_enable: status_byte & 0b1000_0000 != 0, + discharge_enable: status_byte & 0b0100_0000 != 0, + charge_immediately_2, + charge_immediately_1, + full_charge_request, + should_charge: charge_immediately_2 || charge_immediately_1 || full_charge_request, + }, + }) +} + +fn parse_manufacturer_info(info: &[u8]) -> Result { + let mut payload = ByteCursor::new(info); + let device_name = normalize_ascii(payload.read_exact("DeviceName", DEVICE_NAME_LENGTH)?); + let version_bytes = payload.read_exact("SoftwareVersion", SOFTWARE_VERSION_LENGTH)?; + let manufacturer_name = normalize_ascii(payload.read_remaining()); + + let software_version = version_bytes + .iter() + .map(u8::to_string) + .collect::>() + .join("."); + + Ok(ManufacturerInfo { + device_name, + software_version, + manufacturer_name, + }) +} + +fn parse_hex_u8(value: &[u8]) -> Result { + if value.len() != 2 { + return Err(ProtocolError::InvalidFrame(format!( + "expected 2 hex digits, got {}", + value.len() + ))); + } + Ok(u8::from_str_radix( + std::str::from_utf8(value) + .map_err(|error| ProtocolError::InvalidFrame(error.to_string()))?, + 16, + ) + .map_err(|error| ProtocolError::InvalidFrame(error.to_string()))?) +} + +fn parse_hex_u16(value: &[u8]) -> Result { + if value.len() != 4 { + return Err(ProtocolError::InvalidFrame(format!( + "expected 4 hex digits, got {}", + value.len() + ))); + } + Ok(u16::from_str_radix( + std::str::from_utf8(value) + .map_err(|error| ProtocolError::InvalidFrame(error.to_string()))?, + 16, + ) + .map_err(|error| ProtocolError::InvalidFrame(error.to_string()))?) +} + +fn kelvin_tenths_to_celsius(value: i16) -> f64 { + (f64::from(value) - 2731.0) / 10.0 +} + +fn normalize_ascii(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .trim_matches(char::from(0)) + .trim() + .to_string() +} + +struct ByteCursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> ByteCursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn read_u8(&mut self, field: &'static str) -> Result { + let bytes = self.read_exact(field, 1)?; + Ok(bytes[0]) + } + + fn read_u16(&mut self, field: &'static str) -> Result { + let bytes = self.read_exact(field, 2)?; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) + } + + fn read_i16(&mut self, field: &'static str) -> Result { + let bytes = self.read_exact(field, 2)?; + Ok(i16::from_be_bytes([bytes[0], bytes[1]])) + } + + fn read_u24(&mut self, field: &'static str) -> Result { + let bytes = self.read_exact(field, 3)?; + Ok((u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2])) + } + + fn read_exact( + &mut self, + field: &'static str, + length: usize, + ) -> Result<&'a [u8], ProtocolError> { + if self.offset + length > self.bytes.len() { + return Err(ProtocolError::UnexpectedPayloadEnd { field }); + } + + let start = self.offset; + let end = start + length; + self.offset = end; + Ok(&self.bytes[start..end]) + } + + fn read_remaining(&mut self) -> &'a [u8] { + let start = self.offset; + self.offset = self.bytes.len(); + &self.bytes[start..] + } + + fn ensure_exhausted(&self) -> Result<(), ProtocolError> { + if self.offset != self.bytes.len() { + return Err(ProtocolError::InvalidFrame(format!( + "payload had {} unexpected trailing bytes", + self.bytes.len() - self.offset + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ + encode_command, parse_management_info_payload, parse_module_serial_number, + parse_response_frame, parse_system_parameters_payload, parse_values_single_payload, + }; + + #[test] + fn encode_command_matches_python_frame_format() { + let encoded = encode_command(2, 0x42, &[0xFF]).unwrap(); + assert_eq!(encoded, b"~20024642E002FFFD09\r"); + } + + #[test] + fn parse_single_module_values_frame() { + let raw = b"~20024600D05E1002080D020D020D020D030D000D010D010D03050B7D0B690B690B690B73FFFA680EFFFF04FFFF00000174E401B198E906\r"; + let frame = parse_response_frame(raw).unwrap(); + let state = parse_values_single_payload(2, frame.info.as_slice()).unwrap(); + + assert_eq!(state.address, 2); + assert_eq!(state.cell_voltages.len(), 8); + assert!((state.cell_voltages[0] - 3.33).abs() < 1e-9); + assert!((state.average_bms_temperature_c - 21.0).abs() < 1e-9); + assert!((state.current_a + 0.6).abs() < 1e-9); + assert!((state.voltage_v - 26.638).abs() < 1e-9); + assert_eq!(state.cycle_number, 0); + assert!((state.remaining_capacity_ah - 95.460).abs() < 1e-9); + assert!((state.total_capacity_ah - 111.0).abs() < 1e-9); + assert!((state.soc_ratio - 0.86).abs() < 1e-9); + } + + #[test] + fn parse_module_serial_payload() { + let info = [ + 0x10, b'S', b'E', b'R', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9', b'0', + b'A', b'B', b'C', + ]; + let serial = parse_module_serial_number(&info).unwrap(); + assert_eq!(serial, "SER1234567890ABC"); + } + + #[test] + fn parse_system_parameters_payload_uses_python_scaling() { + let info = [ + 0x01, 0x0E, 0x74, 0x0B, 0xEA, 0x0B, 0x54, 0x0B, 0xF9, 0x0B, 0xB1, 0x00, 0x66, 0xD2, + 0xF0, 0xB3, 0xB0, 0xAD, 0xD4, 0x0B, 0xF9, 0x0B, 0xB1, 0xFF, 0x9C, + ]; + let system_parameters = parse_system_parameters_payload(&info).unwrap(); + + assert!((system_parameters.cell_high_voltage_limit_v - 3.7).abs() < 1e-9); + assert!((system_parameters.cell_low_voltage_limit_v - 3.05).abs() < 1e-9); + assert!((system_parameters.cell_under_voltage_limit_v - 2.9).abs() < 1e-9); + assert!((system_parameters.charge_high_temperature_limit_c - 33.4).abs() < 1e-9); + assert!((system_parameters.charge_low_temperature_limit_c - 26.2).abs() < 1e-9); + assert!((system_parameters.charge_current_limit_a - 10.2).abs() < 1e-9); + assert!((system_parameters.module_high_voltage_limit_v - 54.0).abs() < 1e-9); + assert!((system_parameters.module_low_voltage_limit_v - 46.0).abs() < 1e-9); + assert!((system_parameters.module_under_voltage_limit_v - 44.5).abs() < 1e-9); + assert!((system_parameters.discharge_high_temperature_limit_c - 33.4).abs() < 1e-9); + assert!((system_parameters.discharge_low_temperature_limit_c - 26.2).abs() < 1e-9); + assert!((system_parameters.discharge_current_limit_a + 10.0).abs() < 1e-9); + } + + #[test] + fn parse_management_payload_uses_python_bit_layout() { + let raw = b"~20024600B014026EF05AA0022BFDD5C0F915\r"; + let frame = parse_response_frame(raw).unwrap(); + let management_info = parse_management_info_payload(frame.info.as_slice()).unwrap(); + + assert!((management_info.charge_voltage_limit_v - 28.4).abs() < 1e-9); + assert!((management_info.discharge_voltage_limit_v - 23.2).abs() < 1e-9); + assert!((management_info.charge_current_limit_a - 55.5).abs() < 1e-9); + assert!((management_info.discharge_current_limit_a + 55.5).abs() < 1e-9); + assert!(management_info.status.charge_enable); + assert!(management_info.status.discharge_enable); + assert!(!management_info.status.charge_immediately_2); + assert!(!management_info.status.charge_immediately_1); + assert!(!management_info.status.full_charge_request); + assert!(!management_info.status.should_charge); + } +} From b7647afb108a14dfde2b654c718805de28a9c9ec Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 9 Apr 2026 01:00:48 +0100 Subject: [PATCH 8/8] rust version --- flake.nix | 15 ++ rust-mqtt-adapter/src/config.rs | 18 +++ rust-mqtt-adapter/src/main.rs | 22 ++- rust-mqtt-adapter/src/mqtt.rs | 14 +- rust-mqtt-adapter/src/protocol.rs | 37 ++++- rust-mqtt-adapter/src/stats.rs | 237 ++++++++++++++++++++++++++++++ 6 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 rust-mqtt-adapter/src/stats.rs diff --git a/flake.nix b/flake.nix index 11f3662..786ec5e 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ config.allowUnfree = true; }; lib = pkgs.lib; + rustManifest = lib.importTOML ./rust-mqtt-adapter/Cargo.toml; workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; overlay = workspace.mkPyprojectOverlay { @@ -61,6 +62,12 @@ pyprojectOverrides ] ); + rustMqttAdapter = pkgs.rustPlatform.buildRustPackage { + pname = rustManifest.package.name; + version = rustManifest.package.version; + src = ./rust-mqtt-adapter; + cargoLock.lockFile = ./rust-mqtt-adapter/Cargo.lock; + }; in { devShells.default = pkgs.mkShell { @@ -68,6 +75,9 @@ git socat uv + cargo + rustc + rustfmt (python313.withPackages (python-pkgs: [ python-pkgs.pyserial @@ -79,11 +89,16 @@ }; packages.default = pythonSet.mkVirtualEnv "pylontechpoller-env" workspace.deps.default; + packages.pylontech-mqtt-adapter = rustMqttAdapter; apps.default = { type = "app"; program = "${self.packages."${system}".default}/bin/poller"; }; + apps.pylontech-mqtt-adapter = { + type = "app"; + program = "${self.packages."${system}".pylontech-mqtt-adapter}/bin/pylontech-mqtt-adapter"; + }; } ); diff --git a/rust-mqtt-adapter/src/config.rs b/rust-mqtt-adapter/src/config.rs index ff6d4b6..f68208a 100644 --- a/rust-mqtt-adapter/src/config.rs +++ b/rust-mqtt-adapter/src/config.rs @@ -14,6 +14,7 @@ const DEFAULT_INTERVAL_MILLIS: u64 = 1_000; const DEFAULT_MANAGEMENT_INTERVAL_MILLIS: u64 = 30_000; const DEFAULT_RECONNECT_INITIAL_DELAY_MILLIS: u64 = 1_000; const DEFAULT_RECONNECT_MAX_DELAY_MILLIS: u64 = 30_000; +const DEFAULT_STATS_INTERVAL_MILLIS: u64 = 300_000; const DEFAULT_SCAN_START: u8 = 2; const DEFAULT_SCAN_END: u8 = 9; const DEFAULT_DISCOVERY_PREFIX: &str = "homeassistant"; @@ -41,6 +42,8 @@ pub struct CliArgs { pub reconnect_initial_delay_millis: u64, #[arg(long, default_value_t = DEFAULT_RECONNECT_MAX_DELAY_MILLIS, help = "Maximum reconnect delay in milliseconds")] pub reconnect_max_delay_millis: u64, + #[arg(long, default_value_t = DEFAULT_STATS_INTERVAL_MILLIS, help = "Periodic liveness stats interval in milliseconds")] + pub stats_interval_millis: u64, #[arg(long, help = "MQTT broker host")] pub mqtt_host: String, #[arg(long, default_value_t = DEFAULT_MQTT_PORT, help = "MQTT broker port")] @@ -65,6 +68,7 @@ pub struct AppConfig { pub polling: PollingConfig, pub mqtt: MqttConfig, pub reconnect: ReconnectConfig, + pub stats: StatsConfig, } #[derive(Debug, Clone)] @@ -105,6 +109,11 @@ pub struct ReconnectConfig { pub max_delay: Duration, } +#[derive(Debug, Clone)] +pub struct StatsConfig { + pub interval: Duration, +} + impl CliArgs { pub fn into_config(self) -> AppResult { if self.source_host.trim().is_empty() { @@ -154,6 +163,11 @@ impl CliArgs { self.reconnect_initial_delay_millis, self.reconnect_max_delay_millis ))); } + if self.stats_interval_millis == 0 { + return Err(AppError::InvalidConfig( + "stats interval must be greater than zero".to_string(), + )); + } let password = load_password(self.mqtt_password, self.mqtt_password_file)?; if password.is_some() && self.mqtt_user.is_none() { @@ -190,6 +204,9 @@ impl CliArgs { initial_delay: Duration::from_millis(self.reconnect_initial_delay_millis), max_delay: Duration::from_millis(self.reconnect_max_delay_millis), }, + stats: StatsConfig { + interval: Duration::from_millis(self.stats_interval_millis), + }, }) } } @@ -243,6 +260,7 @@ mod tests { scan_end: 9, reconnect_initial_delay_millis: 10_000, reconnect_max_delay_millis: 1_000, + stats_interval_millis: 300_000, mqtt_host: "mqtt.local".to_string(), mqtt_port: 1883, mqtt_user: None, diff --git a/rust-mqtt-adapter/src/main.rs b/rust-mqtt-adapter/src/main.rs index b1cff06..e126733 100644 --- a/rust-mqtt-adapter/src/main.rs +++ b/rust-mqtt-adapter/src/main.rs @@ -3,6 +3,7 @@ mod error; mod model; mod mqtt; mod protocol; +mod stats; use std::thread; use std::time::{Duration, Instant}; @@ -14,6 +15,7 @@ use crate::error::{AppError, AppResult}; use crate::model::StackState; use crate::mqtt::MqttPublisher; use crate::protocol::PylontechClient; +use crate::stats::RuntimeStats; fn main() -> Result<(), AppError> { let config = CliArgs::parse().into_config()?; @@ -21,16 +23,19 @@ fn main() -> Result<(), AppError> { } fn run(config: AppConfig) -> AppResult<()> { + let stats = RuntimeStats::new_shared(); + stats.spawn_reporter(config.stats.interval); let mut reconnect_backoff = ReconnectBackoff::new(config.reconnect.initial_delay, config.reconnect.max_delay); loop { - match run_session(&config) { + match run_session(&config, &stats) { Ok(()) => { reconnect_backoff.reset(); return Ok(()); } Err(error) => { + stats.record_recovery(); let delay = reconnect_backoff.next_delay(); eprintln!("session failed: {}; reconnecting in {:?}", error, delay); thread::sleep(delay); @@ -39,17 +44,21 @@ fn run(config: AppConfig) -> AppResult<()> { } } -fn run_session(config: &AppConfig) -> AppResult<()> { - let publisher = MqttPublisher::connect(&config.mqtt)?; - let result = run_session_inner(config, &publisher); +fn run_session(config: &AppConfig, stats: &std::sync::Arc) -> AppResult<()> { + let publisher = MqttPublisher::connect(&config.mqtt, std::sync::Arc::clone(stats))?; + let result = run_session_inner(config, &publisher, stats); if result.is_err() { publisher.publish_offline_best_effort(); } result } -fn run_session_inner(config: &AppConfig, publisher: &MqttPublisher) -> AppResult<()> { - let mut client = PylontechClient::connect(&config.source)?; +fn run_session_inner( + config: &AppConfig, + publisher: &MqttPublisher, + stats: &std::sync::Arc, +) -> AppResult<()> { + let mut client = PylontechClient::connect(&config.source, std::sync::Arc::clone(stats))?; let modules = client.scan_modules(config.polling.addresses())?; if modules.is_empty() { return Err(AppError::InvalidState(format!( @@ -88,6 +97,7 @@ fn run_session_inner(config: &AppConfig, publisher: &MqttPublisher) -> AppResult for state in &states { publisher.publish_module_state(state)?; } + stats.record_successful_cycle(); thread::sleep(config.polling.interval); } diff --git a/rust-mqtt-adapter/src/mqtt.rs b/rust-mqtt-adapter/src/mqtt.rs index 8d5a161..4f0496f 100644 --- a/rust-mqtt-adapter/src/mqtt.rs +++ b/rust-mqtt-adapter/src/mqtt.rs @@ -10,6 +10,7 @@ use serde_json::{Map, Value, json}; use crate::config::MqttConfig; use crate::error::AppResult; use crate::model::{ManagementInfo, ModuleIdentity, ModuleState, StackState, SystemParameters}; +use crate::stats::RuntimeStats; const MQTT_KEEPALIVE_SECONDS: u64 = 30; const MQTT_REQUEST_CAPACITY: usize = 32; @@ -432,10 +433,11 @@ pub struct MqttPublisher { topic_prefix: String, healthy: Arc, last_error: Arc>>, + stats: Arc, } impl MqttPublisher { - pub fn connect(config: &MqttConfig) -> AppResult { + pub fn connect(config: &MqttConfig, stats: Arc) -> AppResult { let status_topic = format!("{}/status", config.topic_prefix); let mut options = MqttOptions::new(&config.client_id, &config.host, config.port); options.set_keep_alive(Duration::from_secs(MQTT_KEEPALIVE_SECONDS)); @@ -477,6 +479,7 @@ impl MqttPublisher { topic_prefix: config.topic_prefix.clone(), healthy, last_error, + stats, }; publisher.publish_text(&publisher.availability_topic(), true, "online")?; Ok(publisher) @@ -497,9 +500,13 @@ impl MqttPublisher { } pub fn publish_offline_best_effort(&self) { - let _ = self + if self .client - .publish(self.availability_topic(), QoS::AtLeastOnce, true, "offline"); + .publish(self.availability_topic(), QoS::AtLeastOnce, true, "offline") + .is_ok() + { + self.stats.record_mqtt_message_sent(); + } } pub fn publish_discovery(&self, modules: &[ModuleIdentity]) -> AppResult<()> { @@ -709,6 +716,7 @@ impl MqttPublisher { self.ensure_healthy()?; self.client .publish(topic, QoS::AtLeastOnce, retain, payload)?; + self.stats.record_mqtt_message_sent(); Ok(()) } diff --git a/rust-mqtt-adapter/src/protocol.rs b/rust-mqtt-adapter/src/protocol.rs index 5590460..f30d31c 100644 --- a/rust-mqtt-adapter/src/protocol.rs +++ b/rust-mqtt-adapter/src/protocol.rs @@ -1,5 +1,6 @@ use std::io::{Read, Write}; use std::net::TcpStream; +use std::sync::Arc; use thiserror::Error; @@ -7,6 +8,7 @@ use crate::config::SourceConfig; use crate::model::{ ManagementInfo, ManagementStatus, ModuleIdentity, ModuleState, SystemParameters, }; +use crate::stats::RuntimeStats; const FRAME_START_BYTE: u8 = b'~'; const FRAME_END_BYTE: u8 = b'\r'; @@ -55,15 +57,16 @@ struct ResponseFrame { pub struct PylontechClient { stream: TcpStream, + stats: Arc, } impl PylontechClient { - pub fn connect(config: &SourceConfig) -> Result { + pub fn connect(config: &SourceConfig, stats: Arc) -> Result { let address = format!("{}:{}", config.host, config.port); let stream = TcpStream::connect(address)?; stream.set_read_timeout(Some(config.timeout))?; stream.set_write_timeout(Some(config.timeout))?; - Ok(Self { stream }) + Ok(Self { stream, stats }) } pub fn scan_modules( @@ -155,10 +158,23 @@ impl PylontechClient { info_bytes: &[u8], ) -> Result { let request_frame = encode_command(address, command, info_bytes)?; - self.stream.write_all(&request_frame)?; - self.stream.flush()?; + if let Err(error) = self.stream.write_all(&request_frame) { + self.stats.record_source_error(); + return Err(ProtocolError::Io(error)); + } + self.stats.record_source_write(request_frame.len()); + if let Err(error) = self.stream.flush() { + self.stats.record_source_error(); + return Err(ProtocolError::Io(error)); + } let raw_frame = self.read_raw_frame()?; - parse_response_frame(&raw_frame) + match parse_response_frame(&raw_frame) { + Ok(frame) => Ok(frame), + Err(error) => { + self.stats.record_source_error(); + Err(error) + } + } } fn read_raw_frame(&mut self) -> Result, ProtocolError> { @@ -180,11 +196,18 @@ impl PylontechClient { frame.push(byte); if byte == FRAME_END_BYTE { + self.stats.record_source_read(frame.len()); return Ok(frame); } } - Err(error) if is_timeout_error(&error) => return Err(ProtocolError::Timeout), - Err(error) => return Err(ProtocolError::Io(error)), + Err(error) if is_timeout_error(&error) => { + self.stats.record_source_timeout(); + return Err(ProtocolError::Timeout); + } + Err(error) => { + self.stats.record_source_error(); + return Err(ProtocolError::Io(error)); + } } } } diff --git a/rust-mqtt-adapter/src/stats.rs b/rust-mqtt-adapter/src/stats.rs new file mode 100644 index 0000000..baecf06 --- /dev/null +++ b/rust-mqtt-adapter/src/stats.rs @@ -0,0 +1,237 @@ +use std::fmt::Write as _; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub struct RuntimeStats { + started_at: Instant, + successful_cycles: AtomicU64, + mqtt_messages_sent: AtomicU64, + recoveries: AtomicU64, + source_bytes_read: AtomicU64, + source_bytes_written: AtomicU64, + source_frames_read: AtomicU64, + source_frames_written: AtomicU64, + source_timeouts: AtomicU64, + source_errors: AtomicU64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatsSnapshot { + pub uptime: Duration, + pub successful_cycles: u64, + pub mqtt_messages_sent: u64, + pub recoveries: u64, + pub source_bytes_read: u64, + pub source_bytes_written: u64, + pub source_frames_read: u64, + pub source_frames_written: u64, + pub source_timeouts: u64, + pub source_errors: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatsDelta { + pub successful_cycles: u64, + pub mqtt_messages_sent: u64, + pub recoveries: u64, + pub source_bytes_read: u64, + pub source_bytes_written: u64, + pub source_frames_read: u64, + pub source_frames_written: u64, + pub source_timeouts: u64, + pub source_errors: u64, +} + +impl RuntimeStats { + pub fn new_shared() -> Arc { + Arc::new(Self { + started_at: Instant::now(), + successful_cycles: AtomicU64::new(0), + mqtt_messages_sent: AtomicU64::new(0), + recoveries: AtomicU64::new(0), + source_bytes_read: AtomicU64::new(0), + source_bytes_written: AtomicU64::new(0), + source_frames_read: AtomicU64::new(0), + source_frames_written: AtomicU64::new(0), + source_timeouts: AtomicU64::new(0), + source_errors: AtomicU64::new(0), + }) + } + + pub fn spawn_reporter(self: &Arc, interval: Duration) { + let stats = Arc::clone(self); + thread::spawn(move || { + let mut previous = stats.snapshot(); + loop { + thread::sleep(interval); + let current = stats.snapshot(); + let delta = current.delta_from(&previous); + eprintln!("{}", format_summary(¤t, &delta)); + previous = current; + } + }); + } + + pub fn snapshot(&self) -> StatsSnapshot { + StatsSnapshot { + uptime: self.started_at.elapsed(), + successful_cycles: self.successful_cycles.load(Ordering::Relaxed), + mqtt_messages_sent: self.mqtt_messages_sent.load(Ordering::Relaxed), + recoveries: self.recoveries.load(Ordering::Relaxed), + source_bytes_read: self.source_bytes_read.load(Ordering::Relaxed), + source_bytes_written: self.source_bytes_written.load(Ordering::Relaxed), + source_frames_read: self.source_frames_read.load(Ordering::Relaxed), + source_frames_written: self.source_frames_written.load(Ordering::Relaxed), + source_timeouts: self.source_timeouts.load(Ordering::Relaxed), + source_errors: self.source_errors.load(Ordering::Relaxed), + } + } + + pub fn record_successful_cycle(&self) { + self.successful_cycles.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_mqtt_message_sent(&self) { + self.mqtt_messages_sent.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_recovery(&self) { + self.recoveries.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_source_write(&self, bytes: usize) { + self.source_bytes_written + .fetch_add(bytes as u64, Ordering::Relaxed); + self.source_frames_written.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_source_read(&self, bytes: usize) { + self.source_bytes_read + .fetch_add(bytes as u64, Ordering::Relaxed); + self.source_frames_read.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_source_timeout(&self) { + self.source_timeouts.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_source_error(&self) { + self.source_errors.fetch_add(1, Ordering::Relaxed); + } +} + +impl StatsSnapshot { + pub fn delta_from(&self, previous: &Self) -> StatsDelta { + StatsDelta { + successful_cycles: self.successful_cycles - previous.successful_cycles, + mqtt_messages_sent: self.mqtt_messages_sent - previous.mqtt_messages_sent, + recoveries: self.recoveries - previous.recoveries, + source_bytes_read: self.source_bytes_read - previous.source_bytes_read, + source_bytes_written: self.source_bytes_written - previous.source_bytes_written, + source_frames_read: self.source_frames_read - previous.source_frames_read, + source_frames_written: self.source_frames_written - previous.source_frames_written, + source_timeouts: self.source_timeouts - previous.source_timeouts, + source_errors: self.source_errors - previous.source_errors, + } + } +} + +pub fn format_summary(snapshot: &StatsSnapshot, delta: &StatsDelta) -> String { + let mut summary = String::new(); + let _ = write!( + summary, + "alive: uptime={} cycles={} (+{}) mqtt_messages={} (+{}) recoveries={} (+{}) source_rx={} (+{}) source_tx={} (+{}) frames_rx={} (+{}) frames_tx={} (+{}) timeouts={} (+{}) source_errors={} (+{})", + format_duration(snapshot.uptime), + snapshot.successful_cycles, + delta.successful_cycles, + snapshot.mqtt_messages_sent, + delta.mqtt_messages_sent, + snapshot.recoveries, + delta.recoveries, + format_bytes(snapshot.source_bytes_read), + format_bytes(delta.source_bytes_read), + format_bytes(snapshot.source_bytes_written), + format_bytes(delta.source_bytes_written), + snapshot.source_frames_read, + delta.source_frames_read, + snapshot.source_frames_written, + delta.source_frames_written, + snapshot.source_timeouts, + delta.source_timeouts, + snapshot.source_errors, + delta.source_errors, + ); + summary +} + +fn format_duration(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} + +fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + + if bytes < 1024 { + return format!("{bytes}B"); + } + + let bytes_f64 = bytes as f64; + if bytes_f64 < MIB { + return format!("{:.1}KiB", bytes_f64 / KIB); + } + + format!("{:.1}MiB", bytes_f64 / MIB) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{StatsDelta, StatsSnapshot, format_summary}; + + #[test] + fn format_summary_includes_totals_and_interval_delta() { + let snapshot = StatsSnapshot { + uptime: Duration::from_secs(301), + successful_cycles: 600, + mqtt_messages_sent: 1200, + recoveries: 2, + source_bytes_read: 12_288, + source_bytes_written: 2_048, + source_frames_read: 900, + source_frames_written: 905, + source_timeouts: 1, + source_errors: 3, + }; + let delta = StatsDelta { + successful_cycles: 300, + mqtt_messages_sent: 600, + recoveries: 1, + source_bytes_read: 4_096, + source_bytes_written: 1_024, + source_frames_read: 450, + source_frames_written: 452, + source_timeouts: 1, + source_errors: 2, + }; + + let summary = format_summary(&snapshot, &delta); + + assert!(summary.contains("alive: uptime=00:05:01")); + assert!(summary.contains("cycles=600 (+300)")); + assert!(summary.contains("mqtt_messages=1200 (+600)")); + assert!(summary.contains("recoveries=2 (+1)")); + assert!(summary.contains("source_rx=12.0KiB (+4.0KiB)")); + assert!(summary.contains("source_tx=2.0KiB (+1.0KiB)")); + assert!(summary.contains("timeouts=1 (+1)")); + assert!(summary.contains("source_errors=3 (+2)")); + } +}