From b5dc9dcb33b39aad4a48ae49408ecd9ab4291dea Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 1 Jun 2025 18:02:56 +0100 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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"