From 34526acb8672dea2bbf95ab3e7ec510bd6104dfb Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Wed, 11 Oct 2023 00:42:06 +0300 Subject: [PATCH 001/370] bugfix meter value logic (#934) --- custom_components/ocpp/api.py | 41 +++++++++++++++---------- tests/test_charge_point.py | 56 ++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 5184b6cc..f434ea21 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1126,29 +1126,40 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): measurand = DEFAULT_MEASURAND unit = DEFAULT_ENERGY_UNIT + if measurand == DEFAULT_MEASURAND and unit is None: + unit = DEFAULT_ENERGY_UNIT + + if self._metrics[csess.meter_start.value].value == 0: + # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + self._charger_reports_session_energy = True + if phase is None: if unit == DEFAULT_POWER_UNIT: self._metrics[measurand].value = float(value) / 1000 self._metrics[measurand].unit = HA_POWER_UNIT - elif unit == DEFAULT_ENERGY_UNIT or "Energy" in str(measurand): - if self._metrics[csess.meter_start.value].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions - self._charger_reports_session_energy = True - if ( - transaction_matches - and self._charger_reports_session_energy - and measurand == DEFAULT_MEASURAND - and connector_id - ): - self._metrics[csess.session_energy.value].value = ( - float(value) / 1000 + elif ( + measurand == DEFAULT_MEASURAND + and self._charger_reports_session_energy + ): + if transaction_matches: + if unit == DEFAULT_ENERGY_UNIT: + value = float(value) / 1000 + unit = HA_ENERGY_UNIT + self._metrics[csess.session_energy.value].value = float( + value ) + self._metrics[csess.session_energy.value].unit = unit self._metrics[csess.session_energy.value].extra_attr[ cstat.id_tag.name ] = self._metrics[cstat.id_tag.value].value - elif ( - transaction_matches or self._charger_reports_session_energy - ): + else: + if unit == DEFAULT_ENERGY_UNIT: + value = float(value) / 1000 + unit = HA_ENERGY_UNIT + self._metrics[measurand].value = float(value) + self._metrics[measurand].unit = unit + elif unit == DEFAULT_ENERGY_UNIT: + if transaction_matches: self._metrics[measurand].value = float(value) / 1000 self._metrics[measurand].unit = HA_ENERGY_UNIT else: diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index c5abe69e..b14bff2c 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -308,7 +308,6 @@ async def test_services(hass, socket_enabled): cp.send_meter_err_phases(), cp.send_meter_line_voltage(), cp.send_meter_periodic_data(), - cp.send_main_meter_clock_data(), # add delay to allow meter data to be processed cp.send_stop_transaction(2), ), @@ -370,6 +369,9 @@ async def test_services(hass, socket_enabled): pass await ws.close() assert int(cs.get_metric("test_cpid", "Frequency")) == int(50) + assert float(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == float( + 1101.452 + ) await asyncio.sleep(1) @@ -410,6 +412,33 @@ async def test_services(hass, socket_enabled): await asyncio.sleep(1) + # test ocpp messages sent from charger that don't support errata 3.9 with meter values with kWh as energy unit + async with websockets.connect( + "ws://127.0.0.1:9000/CP_1_non_er_3.9", + subprotocols=["ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint("CP_1_non_errata_3.9", ws) + try: + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_start_transaction(0), + cp.send_meter_energy_kwh(), + cp.send_meter_clock_data(), + # add delay to allow meter data to be processed + cp.send_stop_transaction(2), + ), + timeout=5, + ) + except asyncio.TimeoutError: + pass + await ws.close() + + assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int(1101) + assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(11) + assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" + # test ocpp rejection messages sent from charger to cms cs.charge_points["test_cpid"].received_boot_notification = False cs.charge_points["test_cpid"].post_connect_success = False @@ -971,6 +1000,31 @@ async def send_meter_err_phases(self): resp = await self.call(request) assert resp is not None + async def send_meter_energy_kwh(self): + """Send periodic energy meter value with kWh unit.""" + while self.active_transactionId == 0: + await asyncio.sleep(1) + request = call.MeterValuesPayload( + connector_id=1, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "unit": "kWh", + "value": "11", + "context": "Sample.Periodic", + "format": "Raw", + "measurand": "Energy.Active.Import.Register", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + async def send_main_meter_clock_data(self): """Send periodic main meter value. Main meter values dont have transaction_id.""" while self.active_transactionId == 0: From 9f5ee20478488642a95fe5bdefe5a9ae95c52e4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:40:36 +0200 Subject: [PATCH 002/370] build(deps): bump black from 23.7.0 to 23.10.1 in /.github/workflows (#950) Bumps [black](https://github.com/psf/black) from 23.7.0 to 23.10.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.7.0...23.10.1) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index cfbc912b..58676460 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.3.3 bandit==1.7.5 -black==23.7.0 +black==23.10.1 flake8==6.1.0 isort==5.12.0 pre-comit-hooks==4.1.0 From 8ec2986ab64c57819cef95ea19a55032691a91c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:42:03 +0200 Subject: [PATCH 003/370] build(deps): bump websockets from 11.0.3 to 12.0 (#946) Bumps [websockets](https://github.com/python-websockets/websockets) from 11.0.3 to 12.0. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/11.0.3...12.0) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_dev.txt | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 318fa82c..1e3e2997 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ homeassistant>=2023.1.0b1 ocpp==0.19.0 -websockets==11.0.3 +websockets==12.0 jsonschema==4.19.0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 1ee66462..63b0f3f0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ -r requirements_dev.txt pytest-homeassistant-custom-component==0.12.50 ocpp==0.19.0 -websockets==11.0.3 +websockets==12.0 pytest-cov \ No newline at end of file From 4f1eb5594e29dcdf85e61835b8323c9563492ada Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:42:32 +0200 Subject: [PATCH 004/370] build(deps): bump crazy-max/ghaction-github-labeler from 4.1.0 to 5.0.0 (#901) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 4.1.0 to 5.0.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v4.1.0...v5.0.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 0ead7faf..2862d4c2 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v3 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v4.1.0 + uses: crazy-max/ghaction-github-labeler@v5.0.0 with: skip-delete: true From edf7417dc5381fbcb917d093a97423ce3b2eaaf2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:42:57 +0200 Subject: [PATCH 005/370] build(deps): bump pyupgrade from 3.10.1 to 3.15.0 in /.github/workflows (#933) Bumps [pyupgrade](https://github.com/asottile/pyupgrade) from 3.10.1 to 3.15.0. - [Commits](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.15.0) --- updated-dependencies: - dependency-name: pyupgrade dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 58676460..63e223c7 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -6,6 +6,6 @@ black==23.10.1 flake8==6.1.0 isort==5.12.0 pre-comit-hooks==4.1.0 -pyupgrade==3.10.1 +pyupgrade==3.15.0 reorder-python-imports==3.10.0 sqlalchemy>=1.4.23 From ba883748f233b6b732ec4fd96fcc331dbef8c948 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:43:25 +0200 Subject: [PATCH 006/370] build(deps): bump actions/upload-artifact from 3.1.2 to 3.1.3 (#897) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3.1.2...v3.1.3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f829f3c8..71a2cc46 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v3.1.3 if: ${{ github.event_name == 'push' }} with: name: ocpp From 15b81f7e68936a8c841906e9456ddec3d972ccf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:43:49 +0200 Subject: [PATCH 007/370] build(deps): bump actions/checkout from 3 to 4 (#895) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/publish_docs_to_wiki.yml | 2 +- .github/workflows/sphinx-build.yml | 2 +- .github/workflows/tests.yaml | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2862d4c2..04105623 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@v5.0.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 71a2cc46..dd4970d0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: 📥 Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🛠️ Set up Python uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/publish_docs_to_wiki.yml b/.github/workflows/publish_docs_to_wiki.yml index 6aa32035..a3e7193f 100644 --- a/.github/workflows/publish_docs_to_wiki.yml +++ b/.github/workflows/publish_docs_to_wiki.yml @@ -18,7 +18,7 @@ jobs: publish_docs_to_wiki: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Pull wiki run: | diff --git a/.github/workflows/sphinx-build.yml b/.github/workflows/sphinx-build.yml index 763e5731..a6028fe9 100644 --- a/.github/workflows/sphinx-build.yml +++ b/.github/workflows/sphinx-build.yml @@ -6,7 +6,7 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ammaraskar/sphinx-action@master with: docs-folder: "docs/" \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5e608577..908a6df5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -17,7 +17,7 @@ jobs: name: Pre-commit steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -42,7 +42,7 @@ jobs: name: HACS steps: - name: Check out the repository - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" - name: HACS validation uses: "hacs/action@main" @@ -55,7 +55,7 @@ jobs: name: Hassfest steps: - name: Check out the repository - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" @@ -64,7 +64,7 @@ jobs: name: Run tests steps: - name: Check out code from GitHub - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} uses: "actions/setup-python@v4.7.0" with: From 7beee1899873f5e750464e796ae674e987a92fd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:47:53 +0200 Subject: [PATCH 008/370] build(deps): bump pre-commit from 3.3.3 to 3.5.0 in /.github/workflows (#942) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.3.3 to 3.5.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.3.3...v3.5.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 63e223c7..d1db949b 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<22.4 -pre-commit==3.3.3 +pre-commit==3.5.0 bandit==1.7.5 black==23.10.1 flake8==6.1.0 From 24b13bbc16684bb7422a55c8962f91993d2d6a4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:48:18 +0200 Subject: [PATCH 009/370] build(deps): bump actions/setup-python from 4.7.0 to 4.7.1 (#923) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.7.0 to 4.7.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.7.0...v4.7.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd4970d0..5fd97d4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 908a6df5..b3f88701 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -66,7 +66,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v4.7.0" + uses: "actions/setup-python@v4.7.1" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 7a0e6eb500cb3e2ade2e479255843379a2f2f993 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:48:54 +0200 Subject: [PATCH 010/370] build(deps): bump reorder-python-imports in /.github/workflows (#925) Bumps [reorder-python-imports](https://github.com/asottile/reorder-python-imports) from 3.10.0 to 3.12.0. - [Commits](https://github.com/asottile/reorder-python-imports/compare/v3.10.0...v3.12.0) --- updated-dependencies: - dependency-name: reorder-python-imports dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index d1db949b..9512c5a1 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -7,5 +7,5 @@ flake8==6.1.0 isort==5.12.0 pre-comit-hooks==4.1.0 pyupgrade==3.15.0 -reorder-python-imports==3.10.0 +reorder-python-imports==3.12.0 sqlalchemy>=1.4.23 From 691ef611ce9fbc30fe2518d85d7ef3ce1b3acc27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Oct 2023 12:49:22 +0200 Subject: [PATCH 011/370] build(deps): bump ocpp from 0.19.0 to 0.21.0 (#945) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.19.0 to 0.21.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.19.0...0.21.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_dev.txt | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 1e3e2997..2fca6db8 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ homeassistant>=2023.1.0b1 -ocpp==0.19.0 +ocpp==0.21.0 websockets==12.0 jsonschema==4.19.0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 63b0f3f0..c80d4bd7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ -r requirements_dev.txt pytest-homeassistant-custom-component==0.12.50 -ocpp==0.19.0 +ocpp==0.21.0 websockets==12.0 pytest-cov \ No newline at end of file From c167f4bd213a61f34dd74843990fb045b691e772 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 28 Oct 2023 23:55:49 +1300 Subject: [PATCH 012/370] revert jsonschema changes (#940) * revert jsonschema changes * remove occp reference in test * remove websockets from test req --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/manifest.json | 10 +++++----- requirements_test.txt | 2 -- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 65145087..96cb6447 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -5,16 +5,16 @@ "persistent_notification" ], "codeowners": [ - "@lbbrhzn" + "@lbbrhzn", + "@drc38" ], "config_flow": true, "documentation": "https://github.com/lbbrhzn/ocpp/blob/main/README.md", "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp>=0.14.1", - "websockets>=10.2", - "jsonschema==4.19.0" + "ocpp>=0.20.0", + "websockets>=10.2" ], - "version": "0.4.36" + "version": "0.4.42" } diff --git a/requirements_test.txt b/requirements_test.txt index c80d4bd7..6b3f5490 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,3 @@ -r requirements_dev.txt pytest-homeassistant-custom-component==0.12.50 -ocpp==0.21.0 -websockets==12.0 pytest-cov \ No newline at end of file From 94fd8d5a508d24f147a9750e32af30ff5a731d48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 17:03:31 +0100 Subject: [PATCH 013/370] build(deps): bump release-drafter/release-drafter from 5.24.0 to 5.25.0 (#955) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 5.24.0 to 5.25.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v5.24.0...v5.25.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 49f39a97..f8d95031 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v5.24.0 + uses: release-drafter/release-drafter@v5.25.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From af4788a8e370c5dec23c0866eef1cdb813f5e9c2 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:06:43 +0100 Subject: [PATCH 014/370] adopt new integration blueprint (#954) * adopt https://github.com/ludeeus/integration_blueprint * replace requirements_test.txt by requirements.txt * update python to 3.11 * use python 3.11.6 * Fix lingering tasks * Increase timeout for test * add back deleted await --------- Co-authored-by: Wessel Lubberhuizen Co-authored-by: drc38 Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .devcontainer.json | 42 +++++ .devcontainer/configuration.yaml | 25 --- .devcontainer/devcontainer.json | 28 --- .github/workflows/constraints.txt | 22 +-- .github/workflows/labeler.yml | 40 ++-- .github/workflows/publish.yml | 102 +++++----- .github/workflows/publish_docs_to_wiki.yml | 76 ++++---- .github/workflows/rebase-pull-requests.txt | 16 +- .github/workflows/release-drafter.yml | 30 +-- .github/workflows/sphinx-build.yml | 22 +-- .github/workflows/stale.yml | 54 +++--- .github/workflows/tests.yaml | 184 +++++++++--------- .pre-commit-config.yaml | 118 ++++++------ .ruff.toml | 48 +++++ .vscode/tasks.json | 24 +-- CONTRIBUTING.md | 210 ++++++++++----------- config/configuration.yaml | 71 +++++++ requirements.txt | 9 + requirements_dev.txt | 4 - requirements_test.txt | 3 - scripts/develop | 24 +++ scripts/lint | 7 + scripts/setup | 7 + tests/test_charge_point.py | 38 ++-- 24 files changed, 667 insertions(+), 537 deletions(-) create mode 100644 .devcontainer.json delete mode 100644 .devcontainer/configuration.yaml delete mode 100644 .devcontainer/devcontainer.json create mode 100644 .ruff.toml create mode 100644 config/configuration.yaml create mode 100644 requirements.txt delete mode 100644 requirements_dev.txt delete mode 100644 requirements_test.txt create mode 100644 scripts/develop create mode 100644 scripts/lint create mode 100644 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 00000000..da12939e --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "lbbrhzn/ocpp", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} \ No newline at end of file diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml deleted file mode 100644 index 531e7372..00000000 --- a/.devcontainer/configuration.yaml +++ /dev/null @@ -1,25 +0,0 @@ -default_config: - -ocpp: - default_authorization_status: 'Invalid' - authorization_list: - - id_tag: 'pulsar' - name: 'My tag' - authorization_status : Accepted - - id_tag: 'CAFEBABE' - name: 'Some other tag' - authorization_status: Blocked - - id_tag: 'DEADBEEF' - name: 'Old tag' - status: Expired - - id_tag: '12341234' - name: 'Invalid tag' - authorization_status: Invalid - -logger: - default: info - logs: - custom_components.ocpp: debug - -# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) -# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 43095e82..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. -{ - "image": "ludeeus/container:integration-debian", - "name": "OCPP integration development", - "context": "..", - "appPort": ["9123:8123", "9000:9000"], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } -} diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 9512c5a1..138c41e7 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,11 +1,11 @@ -# home assistant -pip>=21.0,<22.4 -pre-commit==3.5.0 -bandit==1.7.5 -black==23.10.1 -flake8==6.1.0 -isort==5.12.0 -pre-comit-hooks==4.1.0 -pyupgrade==3.15.0 -reorder-python-imports==3.12.0 -sqlalchemy>=1.4.23 +# home assistant +pip>=21.0,<22.4 +pre-commit==3.5.0 +bandit==1.7.5 +black==23.10.1 +flake8==6.1.0 +isort==5.12.0 +pre-comit-hooks==4.1.0 +pyupgrade==3.15.0 +reorder-python-imports==3.12.0 +sqlalchemy>=1.4.23 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 04105623..566db778 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,20 +1,20 @@ -name: Manage labels - -on: - push: - branches: - - main - - master - -jobs: - labeler: - name: Labeler - runs-on: ubuntu-latest - steps: - - name: Check out the repository - uses: actions/checkout@v4 - - - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.0.0 - with: - skip-delete: true +name: Manage labels + +on: + push: + branches: + - main + - master + +jobs: + labeler: + name: Labeler + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v5.0.0 + with: + skip-delete: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5fd97d4b..2ab16fb5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,52 +1,52 @@ -name: Publish - -on: - release: - types: - - published - push: - branches: - - main - -jobs: - release_zip_file: - name: Publish zip file asset - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: 📥 Checkout the repository - uses: actions/checkout@v4 - - - name: 🛠️ Set up Python - uses: actions/setup-python@v4.7.1 - with: - python-version: "3.x" - - - name: 🔢 Get version - id: version - uses: home-assistant/actions/helpers/version@master - - - name: 🔢 Set version number - run: | - python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - - - name: 📤 Upload zip to action - uses: actions/upload-artifact@v3.1.3 - if: ${{ github.event_name == 'push' }} - with: - name: ocpp - path: ${{ github.workspace }}/custom_components/ocpp - - # Pack the dir as a zip and upload to the release - - name: 📦 ZIP Dir - if: ${{ github.event_name == 'release' }} - run: | - cd ${{ github.workspace }}/custom_components/ocpp - zip ocpp.zip -r ./ - - - name: 📤 Upload zip to release - uses: softprops/action-gh-release@v1 - if: ${{ github.event_name == 'release' }} - with: +name: Publish + +on: + release: + types: + - published + push: + branches: + - main + +jobs: + release_zip_file: + name: Publish zip file asset + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 📥 Checkout the repository + uses: actions/checkout@v4 + + - name: 🛠️ Set up Python + uses: actions/setup-python@v4.7.1 + with: + python-version: "3.x" + + - name: 🔢 Get version + id: version + uses: home-assistant/actions/helpers/version@master + + - name: 🔢 Set version number + run: | + python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} + + - name: 📤 Upload zip to action + uses: actions/upload-artifact@v3.1.3 + if: ${{ github.event_name == 'push' }} + with: + name: ocpp + path: ${{ github.workspace }}/custom_components/ocpp + + # Pack the dir as a zip and upload to the release + - name: 📦 ZIP Dir + if: ${{ github.event_name == 'release' }} + run: | + cd ${{ github.workspace }}/custom_components/ocpp + zip ocpp.zip -r ./ + + - name: 📤 Upload zip to release + uses: softprops/action-gh-release@v1 + if: ${{ github.event_name == 'release' }} + with: files: ${{ github.workspace }}/custom_components/ocpp/ocpp.zip \ No newline at end of file diff --git a/.github/workflows/publish_docs_to_wiki.yml b/.github/workflows/publish_docs_to_wiki.yml index a3e7193f..6da2550d 100644 --- a/.github/workflows/publish_docs_to_wiki.yml +++ b/.github/workflows/publish_docs_to_wiki.yml @@ -1,38 +1,38 @@ -name: Publish docs to Wiki - -on: - push: - paths: - - docs/** - branches: - - main - -env: - USER_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} - USER_NAME: ocpp - USER_EMAIL: ocpp@lbbrhzn.nl - OWNER: ${{ github.event.repository.owner.name }} - REPOSITORY_NAME: ${{ github.event.repository.name }} - -jobs: - publish_docs_to_wiki: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Pull wiki - run: | - mkdir tmp_wiki - cd tmp_wiki - git init - git config user.name $USER_NAME - git config user.email $USER_EMAIL - git pull https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git - - - name: Push wiki - run: | - rsync -av --delete docs/ tmp_wiki/ --exclude .git - cd tmp_wiki - git add . - git commit -m "Update Wiki content" - git push -f --set-upstream https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master +name: Publish docs to Wiki + +on: + push: + paths: + - docs/** + branches: + - main + +env: + USER_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} + USER_NAME: ocpp + USER_EMAIL: ocpp@lbbrhzn.nl + OWNER: ${{ github.event.repository.owner.name }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + +jobs: + publish_docs_to_wiki: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Pull wiki + run: | + mkdir tmp_wiki + cd tmp_wiki + git init + git config user.name $USER_NAME + git config user.email $USER_EMAIL + git pull https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git + + - name: Push wiki + run: | + rsync -av --delete docs/ tmp_wiki/ --exclude .git + cd tmp_wiki + git add . + git commit -m "Update Wiki content" + git push -f --set-upstream https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master diff --git a/.github/workflows/rebase-pull-requests.txt b/.github/workflows/rebase-pull-requests.txt index 88227977..0a87a861 100644 --- a/.github/workflows/rebase-pull-requests.txt +++ b/.github/workflows/rebase-pull-requests.txt @@ -1,9 +1,9 @@ -name: Rebase Pull Requests -on: - push: - branches: '**' -jobs: - rebase: - runs-on: ubuntu-latest - steps: +name: Rebase Pull Requests +on: + push: + branches: '**' +jobs: + rebase: + runs-on: ubuntu-latest + steps: - uses: linhbn123/rebase-pull-requests@v1.0.1 \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index f8d95031..18140fa6 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,15 +1,15 @@ -name: Draft a release note -on: - push: - branches: - - main - - master -jobs: - draft_release: - name: Release Drafter - runs-on: ubuntu-latest - steps: - - name: Run release-drafter - uses: release-drafter/release-drafter@v5.25.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Draft a release note +on: + push: + branches: + - main + - master +jobs: + draft_release: + name: Release Drafter + runs-on: ubuntu-latest + steps: + - name: Run release-drafter + uses: release-drafter/release-drafter@v5.25.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sphinx-build.yml b/.github/workflows/sphinx-build.yml index a6028fe9..5c2d4f3a 100644 --- a/.github/workflows/sphinx-build.yml +++ b/.github/workflows/sphinx-build.yml @@ -1,12 +1,12 @@ -name: "Pull Request Docs Check" -on: -- pull_request - -jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ammaraskar/sphinx-action@master - with: +name: "Pull Request Docs Check" +on: +- pull_request + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ammaraskar/sphinx-action@master + with: docs-folder: "docs/" \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 35f3ba46..310c201d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,27 +1,27 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '41 6 * * *' - -jobs: - stale: - - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v8 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'Stale issue message' - stale-pr-message: 'Stale pull request message' - stale-issue-label: 'no-issue-activity' - stale-pr-label: 'no-pr-activity' +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '41 6 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v8 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Stale issue message' + stale-pr-message: 'Stale pull request message' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b3f88701..3eedda0f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,92 +1,92 @@ -name: Linting - -on: - push: - branches: - - main - - master - - dev - pull_request: - -env: - DEFAULT_PYTHON: "3.10" - -jobs: - pre-commit: - runs-on: "ubuntu-latest" - name: Pre-commit - steps: - - name: Check out the repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - - name: Upgrade pip - run: | - pip install --constraint=.github/workflows/constraints.txt pip - pip --version - - - name: Install Python modules - run: | - pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports - - - name: Run pre-commit on all files - run: | - pre-commit run --all-files --show-diff-on-failure --color=always - - hacs: - runs-on: "ubuntu-latest" - name: HACS - steps: - - name: Check out the repository - uses: "actions/checkout@v4" - - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" - ignore: brands - - hassfest: - runs-on: "ubuntu-latest" - name: Hassfest - steps: - - name: Check out the repository - uses: "actions/checkout@v4" - - - name: Hassfest validation - uses: "home-assistant/actions/hassfest@master" - tests: - runs-on: "ubuntu-latest" - name: Run tests - steps: - - name: Check out code from GitHub - uses: "actions/checkout@v4" - - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v4.7.1" - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Install requirements - run: | - pip install --constraint=.github/workflows/constraints.txt pip - pip install -r requirements_test.txt --constraint=.github/workflows/constraints.txt - - name: Tests suite - run: | - pytest \ - --cov=./ \ - --cov-report=xml \ - --cov-report=term \ - --timeout=30 \ - --durations=10 \ - -n auto \ - -p no:sugar \ - -rA \ - tests - - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true +name: Linting + +on: + push: + branches: + - main + - master + - dev + pull_request: + +env: + DEFAULT_PYTHON: "3.11.6" + +jobs: + pre-commit: + runs-on: "ubuntu-latest" + name: Pre-commit + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v4.7.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Upgrade pip + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip --version + + - name: Install Python modules + run: | + pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports + + - name: Run pre-commit on all files + run: | + pre-commit run --all-files --show-diff-on-failure --color=always + + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: "actions/checkout@v4" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v4" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v4" + - name: Setup Python ${{ env.DEFAULT_PYTHON }} + uses: "actions/setup-python@v4.7.1" + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install requirements + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip install -r requirements.txt --constraint=.github/workflows/constraints.txt + - name: Tests suite + run: | + pytest \ + --cov=./ \ + --cov-report=xml \ + --cov-report=term \ + --timeout=30 \ + --durations=10 \ + -n auto \ + -p no:sugar \ + -rA \ + tests + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 654640f2..0661bcdb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,59 +1,59 @@ -repos: - - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 - hooks: - - id: pyupgrade - args: [--py37-plus] - - repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - args: - - --safe - - --quiet - files: ^((custom_components|homeassistant|script|tests)/.+)?[^/]+\.py$ - - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 - hooks: - - id: codespell - args: - - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - - --skip="./.*,*.csv,*.json" - - --quiet-level=2 - exclude_types: [csv, json] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.0.2 - files: ^(custom_components|homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=bandit.yaml - files: ^(custom_components|homeassistant|script|tests|)/.+\.py$ - - repo: https://github.com/PyCQA/isort - rev: 5.11.5 - hooks: - - id: isort - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 - hooks: - - id: check-executables-have-shebangs - stages: [manual] - - id: check-json - stages: [manual] - - id: requirements-txt-fixer - stages: [manual] - - id: check-ast - stages: [manual] - - id: mixed-line-ending - stages: [manual] - args: - - --fix=lf +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.31.1 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((custom_components|homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v2.1.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(custom_components|homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.7.4 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=bandit.yaml + files: ^(custom_components|homeassistant|script|tests|)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 5.11.5 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + stages: [manual] + - id: requirements-txt-fixer + stages: [manual] + - id: check-ast + stages: [manual] + - id: mixed-line-ending + stages: [manual] + args: + - --fix=lf diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..7a8331a3 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 47f12102..0015dad8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,28 +2,10 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", + "command": "scripts/develop", "problemMatcher": [] } ] -} +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34cbb6a3..0f3bee79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,105 +1,105 @@ -# Contribution guidelines - -Contributing to this project should be as easy and transparent as possible, whether it's: - -- Reporting a bug -- Discussing the current state of the code -- Submitting a fix -- Proposing new features - -## Github is used for everything - -Github is used to host code, to track issues and feature requests, as well as accept pull requests. - -Pull requests are the best way to propose changes to the codebase. - -1. Fork the repo and create your branch from `master`. -2. If you've changed something, update the documentation. -3. Make sure your code lints (using black). -4. Test you contribution. -5. Issue that pull request! - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. - -## Report bugs using Github's [issues](../../issues) - -GitHub issues are used to track public bugs. -Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! - -## Write bug reports with detail, background, and sample code - -**Great Bug Reports** tend to have: - -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can. -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - -People _love_ thorough bug reports. I'm not even kidding. - -## Use a Consistent Coding Style - -Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) -to make sure the code follows the style. - -Or use the `pre-commit` settings implemented in this repository -(see deicated section below). - -## Test your code modification - -This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). - -It comes with development environment in a container, easy to launch -if you use Visual Studio Code. With this container you will have a stand alone -Home Assistant instance running and already configured with the included -[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) -file. - -You can use the `pre-commit` settings implemented in this repository to have -linting tool checking your contributions (see deicated section below). - -You should also verify that existing [tests](./tests) are still working -and you are encouraged to add new ones. -You can run the tests using the following commands from the root folder: - -```bash -# Create a virtual environment -python3 -m venv venv -source venv/bin/activate -# Install requirements -pip install -r requirements_test.txt -# Run tests and get a summary of successes/failures and code coverage -pytest --durations=10 --cov-report term-missing --cov=custom_components.ocpp tests -``` - -If any of the tests fail, make the necessary changes to the tests as part of -your changes to the integration. - -## Pre-commit - -You can use the [pre-commit](https://pre-commit.com/) settings included in the -repostory to have code style and linting checks. - -With `pre-commit` tool already installed, -activate the settings of the repository: - -```console -$ pre-commit install -``` - -Now the pre-commit tests will be done every time you commit. - -You can run the tests on all repository file with the command: - -```console -$ pre-commit run --all-files -``` - -## License - -By contributing, you agree that your contributions will be licensed under its MIT License. +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People _love_ thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) +to make sure the code follows the style. + +Or use the `pre-commit` settings implemented in this repository +(see deicated section below). + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +You can use the `pre-commit` settings implemented in this repository to have +linting tool checking your contributions (see deicated section below). + +You should also verify that existing [tests](./tests) are still working +and you are encouraged to add new ones. +You can run the tests using the following commands from the root folder: + +```bash +# Create a virtual environment +python3 -m venv venv +source venv/bin/activate +# Install requirements +pip install -r requirements.txt +# Run tests and get a summary of successes/failures and code coverage +pytest --durations=10 --cov-report term-missing --cov=custom_components.ocpp tests +``` + +If any of the tests fail, make the necessary changes to the tests as part of +your changes to the integration. + +## Pre-commit + +You can use the [pre-commit](https://pre-commit.com/) settings included in the +repostory to have code style and linting checks. + +With `pre-commit` tool already installed, +activate the settings of the repository: + +```console +$ pre-commit install +``` + +Now the pre-commit tests will be done every time you commit. + +You can run the tests on all repository file with the command: + +```console +$ pre-commit run --all-files +``` + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 00000000..a63ccbfd --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,71 @@ +# Loads default set of integrations. Do not remove. +#default_config: +#automation: +assist_pipeline: +backup: +bluetooth: +config: +conversation: +counter: +dhcp: +energy: +#frontend: +hardware: +history: +homeassistant_alerts: +image_upload: +input_boolean: +input_button: +input_datetime: +input_number: +input_select: +input_text: +logbook: +#logger: +map: +my: +network: +person: +schedule: +#scene: +#script: +stream: +sun: +system_health: +tag: +timer: +usb: +webhook: +zone: + +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + +#automation: !include automations.yaml +#script: !include scripts.yaml +#scene: !include scenes.yaml + +ocpp: + default_authorization_status: 'Invalid' + authorization_list: + - id_tag: 'pulsar' + name: 'My tag' + authorization_status : Accepted + - id_tag: 'CAFEBABE' + name: 'Some other tag' + authorization_status: Blocked + - id_tag: 'DEADBEEF' + name: 'Old tag' + status: Expired + - id_tag: '12341234' + name: 'Invalid tag' + authorization_status: Invalid + +logger: + default: info + logs: + custom_components.cpps: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c78b7c49 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +colorlog==6.7.0 +pip>=21.0,<23.2 +ruff==0.0.292 +ocpp==0.21.0 +websockets==12.0 +jsonschema==4.19.0 +pre-commit +pytest-homeassistant-custom-component==0.13.49 +pytest-cov \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 2fca6db8..00000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -homeassistant>=2023.1.0b1 -ocpp==0.21.0 -websockets==12.0 -jsonschema==4.19.0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 6b3f5490..00000000 --- a/requirements_test.txt +++ /dev/null @@ -1,3 +0,0 @@ --r requirements_dev.txt -pytest-homeassistant-custom-component==0.12.50 -pytest-cov \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 00000000..5bddb789 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Create custom components dir if not present +if [[ ! -d "${PWD}/config/custom_components" ]]; then + mkdir -p "${PWD}/config/custom_components" + hass --config "${PWD}/config" --script ensure_config +fi + +# copy the ocpp integration +rm -rf $PWD/config/custom_components/ocpp +cp -r -l $PWD/custom_components/ocpp $PWD/config/custom_components/ + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100644 index 00000000..9b5b1df0 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 00000000..141d19f9 --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index b14bff2c..51c73bab 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -45,14 +45,14 @@ from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 -@pytest.mark.timeout(60) # Set timeout to 60 seconds for this test +@pytest.mark.timeout(90) # Set timeout for this test async def test_cms_responses(hass, socket_enabled): """Test central system responses to a charger.""" async def test_switches(hass, socket_enabled): """Test switch operations.""" for switch in SWITCHES: - result = await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, service_data={ @@ -60,9 +60,9 @@ async def test_switches(hass, socket_enabled): }, blocking=True, ) - assert result + await asyncio.sleep(1) - result = await hass.services.async_call( + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, service_data={ @@ -70,18 +70,16 @@ async def test_switches(hass, socket_enabled): }, blocking=True, ) - assert result async def test_buttons(hass, socket_enabled): """Test button operations.""" for button in BUTTONS: - result = await hass.services.async_call( + await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.test_cpid_{button.key}"}, blocking=True, ) - assert result async def test_services(hass, socket_enabled): """Test service operations.""" @@ -106,24 +104,22 @@ async def test_services(hass, socket_enabled): if service == csvcs.service_data_transfer: data = {"vendor_id": "ABC"} - result = await hass.services.async_call( + await hass.services.async_call( OCPP_DOMAIN, service.value, service_data=data, blocking=True, ) - assert result for number in NUMBERS: # test setting value of number slider - result = await hass.services.async_call( + await hass.services.async_call( NUMBER_DOMAIN, "set_value", service_data={"value": "10"}, blocking=True, target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.test_cpid_{number.key}"}, ) - assert result # Test MOCK_CONFIG_DATA_2 if True: @@ -154,7 +150,7 @@ async def test_services(hass, socket_enabled): cp2.send_stop_transaction(), cp2.send_meter_periodic_data(), ), - timeout=3, + timeout=5, ) except asyncio.TimeoutError: pass @@ -192,7 +188,7 @@ async def test_services(hass, socket_enabled): cp.send_stop_transaction(), cp.send_meter_periodic_data(), ), - timeout=3, + timeout=5, ) except websockets.exceptions.ConnectionClosedOK: pass @@ -221,7 +217,7 @@ async def test_services(hass, socket_enabled): cp.send_stop_transaction(), cp.send_meter_periodic_data(), ), - timeout=3, + timeout=5, ) except websockets.exceptions.ConnectionClosedOK: pass @@ -309,7 +305,7 @@ async def test_services(hass, socket_enabled): cp.send_meter_line_voltage(), cp.send_meter_periodic_data(), # add delay to allow meter data to be processed - cp.send_stop_transaction(2), + cp.send_stop_transaction(1), ), timeout=5, ) @@ -392,7 +388,7 @@ async def test_services(hass, socket_enabled): cp.send_meter_periodic_data(), cp.send_main_meter_clock_data(), # add delay to allow meter data to be processed - cp.send_stop_transaction(2), + cp.send_stop_transaction(1), ), timeout=5, ) @@ -427,7 +423,7 @@ async def test_services(hass, socket_enabled): cp.send_meter_energy_kwh(), cp.send_meter_clock_data(), # add delay to allow meter data to be processed - cp.send_stop_transaction(2), + cp.send_stop_transaction(1), ), timeout=5, ) @@ -782,8 +778,10 @@ async def send_status_notification(self): async def send_meter_periodic_data(self): """Send periodic meter data notification.""" - while self.active_transactionId == 0: + n = 0 + while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) + n += 1 request = call.MeterValuesPayload( connector_id=1, transaction_id=self.active_transactionId, @@ -1099,8 +1097,10 @@ async def send_stop_transaction(self, delay: int = 0): """Send a stop transaction notification.""" # add delay to allow meter data to be processed await asyncio.sleep(delay) - while self.active_transactionId == 0: + n = 0 + while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) + n += 1 request = call.StopTransactionPayload( meter_stop=54321, timestamp=datetime.now(tz=timezone.utc).isoformat(), From d2b9c3b4fd79bccde73cd93a25eae6fb02c2a9eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Nov 2023 00:04:42 +0100 Subject: [PATCH 015/370] build(deps): bump ruff from 0.0.292 to 0.1.3 (#962) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.0.292 to 0.1.3. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.0.292...v0.1.3) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c78b7c49..d846ea59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.7.0 pip>=21.0,<23.2 -ruff==0.0.292 +ruff==0.1.3 ocpp==0.21.0 websockets==12.0 jsonschema==4.19.0 From 54cd8e7f35466abc6391c671b61d47d0d51a502e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Nov 2023 00:29:05 +0100 Subject: [PATCH 016/370] build(deps-dev): bump jsonschema from 4.19.0 to 4.19.2 (#956) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.19.0 to 4.19.2. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.19.0...v4.19.2) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d846ea59..42f719b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ pip>=21.0,<23.2 ruff==0.1.3 ocpp==0.21.0 websockets==12.0 -jsonschema==4.19.0 +jsonschema==4.19.2 pre-commit pytest-homeassistant-custom-component==0.13.49 pytest-cov \ No newline at end of file From f96e2af1a7c6131bc79b1ba99554752e1863de92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Nov 2023 14:55:22 +0100 Subject: [PATCH 017/370] build(deps): update pip requirement from <23.2,>=21.0 to >=21.0,<23.4 (#961) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...23.3.1) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 42f719b5..d3dc2e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.7.0 -pip>=21.0,<23.2 +pip>=21.0,<23.4 ruff==0.1.3 ocpp==0.21.0 websockets==12.0 From 4444689d70d5583d2d892e40ff4a4c0a9bb04831 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 1 Nov 2023 14:56:22 +0100 Subject: [PATCH 018/370] fix: requirements.txt to reduce vulnerabilities (#959) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3180412 Co-authored-by: snyk-bot --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d3dc2e7d..777641b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ websockets==12.0 jsonschema==4.19.2 pre-commit pytest-homeassistant-custom-component==0.13.49 -pytest-cov \ No newline at end of file +pytest-cov +setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file From 3104c444447aa4b5bbc8d6c1bd549eac4e441066 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Nov 2023 19:01:57 +0100 Subject: [PATCH 019/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.49 to 0.13.72 (#958) * build(deps): bump pytest-homeassistant-custom-component Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.49 to 0.13.72. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.49...0.13.72) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * add mock config entries to hass * add MOCK_CONFIG_DATA_1 * Add assert for unloaded * Remove config add * revert config entry * Try alt platform unload * Try alt setup * revert setup change * manually add mock entry to hass * fix linting error * fix precommit errors --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/__init__.py | 11 ++--------- requirements.txt | 5 ++--- tests/const.py | 7 +++++++ tests/test_charge_point.py | 10 ++++++++-- tests/test_init.py | 12 +++++++++--- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index ecdc21ce..92889216 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -1,6 +1,5 @@ """Custom integration for Chargers that support the Open Charge Point Protocol.""" -import asyncio import logging from homeassistant.config_entries import ConfigEntry @@ -106,14 +105,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: central_sys._server.close() await central_sys._server.wait_closed() - unloaded = all( - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - ) - ) - ) + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/requirements.txt b/requirements.txt index 777641b2..a1ac20c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,5 @@ ocpp==0.21.0 websockets==12.0 jsonschema==4.19.2 pre-commit -pytest-homeassistant-custom-component==0.13.49 -pytest-cov -setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file +pytest-homeassistant-custom-component==0.13.72 +setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/tests/const.py b/tests/const.py index b91c491e..59327bdc 100644 --- a/tests/const.py +++ b/tests/const.py @@ -82,6 +82,13 @@ CONF_WEBSOCKET_PING_TIMEOUT: 1, } +# different port +MOCK_CONFIG_DATA_1 = { + **MOCK_CONFIG_DATA, + CONF_PORT: 9001, + CONF_CPID: "test_cpid_1", +} + # configuration with skip schema validation enabled MOCK_CONFIG_DATA_2 = { **MOCK_CONFIG_DATA, diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 51c73bab..4f587b59 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -125,8 +125,13 @@ async def test_services(hass, socket_enabled): if True: # Create a mock entry so we don't have to go through config flow config_entry2 = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA_2, entry_id="test_cms2" + domain=OCPP_DOMAIN, + data=MOCK_CONFIG_DATA_2, + entry_id="test_cms2", + title="test_cms2", ) + config_entry2.add_to_hass(hass) + assert await async_setup_entry(hass, config_entry2) await hass.async_block_till_done() @@ -161,8 +166,9 @@ async def test_services(hass, socket_enabled): # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms" + domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms", title="test_cms" ) + config_entry.add_to_hass(hass) assert await async_setup_entry(hass, config_entry) await hass.async_block_till_done() diff --git a/tests/test_init.py b/tests/test_init.py index bd2088e6..648cde51 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,7 +11,7 @@ ) from custom_components.ocpp.const import DOMAIN -from .const import MOCK_CONFIG_DATA +from .const import MOCK_CONFIG_DATA_1 # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -23,13 +23,17 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test" + domain=DOMAIN, data=MOCK_CONFIG_DATA_1, entry_id="test_cms1", title="test_cms1" ) + # config_entry.add_to_hass(hass); + hass.config_entries._entries[config_entry.entry_id] = config_entry # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data # call, no code from custom_components/ocpp/api.py actually runs. assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem @@ -39,7 +43,8 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem # Unload the entry and verify that the data has been removed - assert await async_unload_entry(hass, config_entry) + unloaded = await async_unload_entry(hass, config_entry) + assert unloaded assert config_entry.entry_id not in hass.data[DOMAIN] @@ -48,6 +53,7 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): # config_entry = MockConfigEntry( # domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test" # ) +# config_entry.add_to_hass(config_entry) # # # In this case we are testing the condition where async_setup_entry raises # # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates From 538531390dc434bcefffed7f060646ab829a327e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Nov 2023 22:46:20 +0100 Subject: [PATCH 020/370] build(deps): bump myst-parser from 0.18.1 to 2.0.0 (#920) * build(deps): bump myst-parser from 0.18.1 to 2.0.0 Bumps [myst-parser](https://github.com/executablebooks/MyST-Parser) from 0.18.1 to 2.0.0. - [Release notes](https://github.com/executablebooks/MyST-Parser/releases) - [Changelog](https://github.com/executablebooks/MyST-Parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/executablebooks/MyST-Parser/compare/v0.18.1...v2.0.0) --- updated-dependencies: - dependency-name: myst-parser dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * add sphinx version * try different sphinx version * add jija2 and docutils --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 44544bb7..45618d3b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,5 @@ -myst-parser==0.18.1 +myst-parser==2.0.0 +docutils==0.18.1 +Jinja2==3.1.2 +sphinx==6.2.1 sphinx_rtd_theme==1.3.0 \ No newline at end of file From a5f3b3e1ad0927ed7dde268da3da21cf6e1f0b99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:50:21 +0100 Subject: [PATCH 021/370] build(deps): bump docutils from 0.18.1 to 0.20.1 (#966) Bumps [docutils](https://docutils.sourceforge.io/) from 0.18.1 to 0.20.1. --- updated-dependencies: - dependency-name: docutils dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 45618d3b..1d064e22 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==2.0.0 -docutils==0.18.1 +docutils==0.20.1 Jinja2==3.1.2 sphinx==6.2.1 sphinx_rtd_theme==1.3.0 \ No newline at end of file From 4dc12bf4d13bd2675cae34d03b391cf73e97311d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:50:55 +0100 Subject: [PATCH 022/370] build(deps): bump ruff from 0.1.3 to 0.1.4 (#964) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.3 to 0.1.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.3...v0.1.4) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a1ac20c9..9e8c351d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.7.0 pip>=21.0,<23.4 -ruff==0.1.3 +ruff==0.1.4 ocpp==0.21.0 websockets==12.0 jsonschema==4.19.2 From 83bf91ec9b568ba75126c5f79add49dcd31baf2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:55:23 +0100 Subject: [PATCH 023/370] build(deps): bump pytest-homeassistant-custom-component (#967) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.72 to 0.13.75. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.72...0.13.75) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9e8c351d..dd778e94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.21.0 websockets==12.0 jsonschema==4.19.2 pre-commit -pytest-homeassistant-custom-component==0.13.72 +pytest-homeassistant-custom-component==0.13.75 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From a61e940b9e87d4a2edb9b8876b5ce055b8cd9212 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 19:01:32 +0100 Subject: [PATCH 024/370] build(deps): bump ocpp from 0.21.0 to 0.22.0 (#963) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.21.0 to 0.22.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.21.0...0.22.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dd778e94..65409aa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.7.0 pip>=21.0,<23.4 ruff==0.1.4 -ocpp==0.21.0 +ocpp==0.22.0 websockets==12.0 jsonschema==4.19.2 pre-commit From c20566586976b571f3c4f248b186b2404f15e21c Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 08:31:50 +0000 Subject: [PATCH 025/370] make scripts executable --- scripts/develop | 0 scripts/lint | 0 scripts/setup | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/develop mode change 100644 => 100755 scripts/lint mode change 100644 => 100755 scripts/setup diff --git a/scripts/develop b/scripts/develop old mode 100644 new mode 100755 diff --git a/scripts/lint b/scripts/lint old mode 100644 new mode 100755 diff --git a/scripts/setup b/scripts/setup old mode 100644 new mode 100755 From f180e592fe494c9954362d7d3fa4c28d8c2c5026 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 08:54:33 +0000 Subject: [PATCH 026/370] update devcontainer --- .pre-commit-config.yaml | 14 +++++++------- docs/development.md | 2 ++ scripts/setup | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0661bcdb..a81bd6c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 23.11.0 hooks: - id: black args: @@ -13,7 +13,7 @@ repos: - --quiet files: ^((custom_components|homeassistant|script|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 + rev: v2.2.6 hooks: - id: codespell args: @@ -22,7 +22,7 @@ repos: - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: @@ -30,7 +30,7 @@ repos: - pydocstyle==5.0.2 files: ^(custom_components|homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.5 hooks: - id: bandit args: @@ -39,11 +39,11 @@ repos: - --configfile=bandit.yaml files: ^(custom_components|homeassistant|script|tests|)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.11.5 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.5.0 hooks: - id: check-executables-have-shebangs stages: [manual] diff --git a/docs/development.md b/docs/development.md index 67484a43..246f6844 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,3 +3,5 @@ Development It is recommended to use Visual Studio Code, and run home assistant in a devcontainer. See [https://hacs.xyz/docs/developer/devcontainer](https://hacs.xyz/docs/developer/devcontainer) + +Online development is supported through [GitHub Codespaces](https://github.com/features/codespaces) diff --git a/scripts/setup b/scripts/setup index 141d19f9..6e74238d 100755 --- a/scripts/setup +++ b/scripts/setup @@ -5,3 +5,5 @@ set -e cd "$(dirname "$0")/.." python3 -m pip install --requirement requirements.txt +pre-commit install +precommit run --all \ No newline at end of file From d4b58245fd5a6711a5e670f220702cdf2ca99b71 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:18:12 +0000 Subject: [PATCH 027/370] fix linting errors --- CONTRIBUTING.md | 2 +- custom_components/ocpp/api.py | 3 ++- scripts/setup | 2 +- tests/test_config_flow.py | 5 ++++- tests/test_init.py | 11 ++++++++--- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f3bee79..bbfe758a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ your changes to the integration. ## Pre-commit You can use the [pre-commit](https://pre-commit.com/) settings included in the -repostory to have code style and linting checks. +repository to have code style and linting checks. With `pre-commit` tool already installed, activate the settings of the repository: diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index f434ea21..15f8573d 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -370,6 +370,7 @@ def __init__( async def post_connect(self): """Logic to be executed right after a charger connects.""" + # Define custom service handles for charge point async def handle_clear_profile(call): """Handle the clear profile service call.""" @@ -493,7 +494,7 @@ async def handle_data_transfer(call): if self.received_boot_notification is False: await self.trigger_boot_notification() await self.trigger_status_notification() - except (NotImplementedError) as e: + except NotImplementedError as e: _LOGGER.error("Configuration of the charger failed: %s", e) async def get_supported_features(self): diff --git a/scripts/setup b/scripts/setup index 6e74238d..aa662978 100755 --- a/scripts/setup +++ b/scripts/setup @@ -6,4 +6,4 @@ cd "$(dirname "$0")/.." python3 -m pip install --requirement requirements.txt pre-commit install -precommit run --all \ No newline at end of file +pre-commit run --all \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 62b42a6f..ff64c94b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -19,7 +19,10 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch("custom_components.ocpp.async_setup", return_value=True,), patch( + with patch( + "custom_components.ocpp.async_setup", + return_value=True, + ), patch( "custom_components.ocpp.async_setup_entry", return_value=True, ): diff --git a/tests/test_init.py b/tests/test_init.py index 648cde51..8a2f04e6 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,9 @@ """Test ocpp setup process.""" # from homeassistant.exceptions import ConfigEntryNotReady # import pytest +from typing import AsyncGenerator + +from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.ocpp import ( @@ -19,7 +22,9 @@ # Home Assistant using the pytest_homeassistant_custom_component plugin. # Assertions allow you to verify that the return value of whatever is on the left # side of the assertion matches with the right side. -async def test_setup_unload_and_reload_entry(hass, bypass_get_data): +async def test_setup_unload_and_reload_entry( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( @@ -35,12 +40,12 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): await hass.async_block_till_done() assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem # Reload the entry and assert that the data from above is still there assert await async_reload_entry(hass, config_entry) is None assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem # Unload the entry and verify that the data has been removed unloaded = await async_unload_entry(hass, config_entry) From 3fb1623207930f01f5bdc5d6b81f0168041ff1c1 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:22:53 +0000 Subject: [PATCH 028/370] correct pre-commit run in setup script --- scripts/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup b/scripts/setup index 6e74238d..aa662978 100755 --- a/scripts/setup +++ b/scripts/setup @@ -6,4 +6,4 @@ cd "$(dirname "$0")/.." python3 -m pip install --requirement requirements.txt pre-commit install -precommit run --all \ No newline at end of file +pre-commit run --all \ No newline at end of file From 72b97a999d6cfbcac0c0ca4b81bf983828bd8b21 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:33:03 +0000 Subject: [PATCH 029/370] add pylint extension --- .devcontainer.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index da12939e..d34ae950 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -17,15 +17,14 @@ "ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "ms-python.pylint" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, "python.formatting.provider": "black", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "editor.formatOnPaste": false, From e067eee5222e114940ae4cb3c88c3e564634ae92 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 8 Nov 2023 11:45:42 +0100 Subject: [PATCH 030/370] change postCreate to onCreate (#970) --- .devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index d34ae950..f2f59a67 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,7 +1,7 @@ { "name": "lbbrhzn/ocpp", "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", - "postCreateCommand": "scripts/setup", + "onCreateCommand": "scripts/setup", "forwardPorts": [ 8123 ], @@ -38,4 +38,4 @@ "features": { "ghcr.io/devcontainers/features/rust:1": {} } -} \ No newline at end of file +} From 64dab6e64df6503695e4d16f3b317bcc4a5b350e Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 10 Nov 2023 08:56:25 +0100 Subject: [PATCH 031/370] autodetect supported measurands (#968) * autodetect supported measurands * fix ConfigurationStatus check * remove measurands from config flow --- custom_components/ocpp/api.py | 23 +++++++++++++++++- custom_components/ocpp/config_flow.py | 34 +++++---------------------- custom_components/ocpp/const.py | 30 +++++++++++------------ tests/const.py | 30 ++++------------------- tests/test_config_flow.py | 11 +-------- 5 files changed, 48 insertions(+), 80 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 15f8573d..1bb4c2ab 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -430,10 +430,31 @@ async def handle_data_transfer(call): resp = await self.get_configuration(ckey.number_of_connectors.value) self._metrics[cdet.connectors.value].value = resp await self.get_configuration(ckey.heartbeat_interval.value) + + all_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND + ) + + accepted_measurands = [] + key = ckey.meter_values_sampled_data.value + + for measurand in all_measurands.split(","): + _LOGGER.debug(f"'{self.id}' trying measurand '{measurand}'") + req = call.ChangeConfigurationPayload(key=key, value=measurand) + resp = await self.call(req) + if resp.status == ConfigurationStatus.accepted: + _LOGGER.debug(f"'{self.id}' adding measurand '{measurand}'") + accepted_measurands.append(measurand) + + accepted_measurands = ",".join(accepted_measurands) + + _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") + await self.configure( ckey.meter_values_sampled_data.value, - self.entry.data.get(CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND), + accepted_measurands, ) + await self.configure( ckey.meter_value_sample_interval.value, str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 7539d6e2..07c421b7 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -26,8 +26,8 @@ DEFAULT_HOST, DEFAULT_IDLE_INTERVAL, DEFAULT_MAX_CURRENT, - DEFAULT_MEASURAND, DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -38,7 +38,6 @@ DEFAULT_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TRIES, DOMAIN, - MEASURANDS, ) STEP_USER_DATA_SCHEMA = vol.Schema( @@ -51,6 +50,9 @@ vol.Required(CONF_CSID, default=DEFAULT_CSID): str, vol.Required(CONF_CPID, default=DEFAULT_CPID): str, vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, + vol.Required( + CONF_MONITORED_VARIABLES, default=DEFAULT_MONITORED_VARIABLES + ): str, vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, vol.Required( @@ -73,12 +75,6 @@ ): bool, } ) -STEP_USER_MEASURANDS_SCHEMA = vol.Schema( - { - vol.Required(m, default=(True if m == DEFAULT_MEASURAND else False)): bool - for m in MEASURANDS - } -) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -98,27 +94,9 @@ async def async_step_user(self, user_input=None): if user_input is not None: # Todo: validate the user input self._data = user_input - return await self.async_step_measurands() + self._data[CONF_MONITORED_VARIABLES] = DEFAULT_MONITORED_VARIABLES + return self.async_create_entry(title=self._data[CONF_CSID], data=self._data) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - async def async_step_measurands(self, user_input=None): - """Select the measurands to be shown.""" - - errors: dict[str, str] = {} - if user_input is not None: - selected_measurands = [m for m, value in user_input.items() if value] - if set(selected_measurands).issubset(set(MEASURANDS)): - self._data[CONF_MONITORED_VARIABLES] = ",".join(selected_measurands) - return self.async_create_entry( - title=self._data[CONF_CSID], data=self._data - ) - else: - errors["base"] = "measurand" - return self.async_show_form( - step_id="measurands", - data_schema=STEP_USER_MEASURANDS_SCHEMA, - errors=errors, - ) diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 3bf16bbc..cf9fa653 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -70,28 +70,28 @@ # Ocpp supported measurands MEASURANDS = [ - Measurand.energy_active_import_register.value, - Measurand.energy_reactive_import_register.value, + Measurand.current_export.value, + Measurand.current_import.value, + Measurand.current_offered.value, + Measurand.energy_active_export_interval.value, + Measurand.energy_active_export_register.value, Measurand.energy_active_import_interval.value, + Measurand.energy_active_import_register.value, + Measurand.energy_reactive_export_interval.value, + Measurand.energy_reactive_export_register.value, Measurand.energy_reactive_import_interval.value, + Measurand.energy_reactive_import_register.value, + Measurand.frequency.value, + Measurand.power_active_export.value, Measurand.power_active_import.value, - Measurand.power_reactive_import.value, - Measurand.power_offered.value, Measurand.power_factor.value, - Measurand.current_import.value, - Measurand.current_offered.value, - Measurand.voltage.value, - Measurand.frequency.value, + Measurand.power_offered.value, + Measurand.power_reactive_export.value, + Measurand.power_reactive_import.value, Measurand.rpm.value, Measurand.soc.value, Measurand.temperature.value, - Measurand.current_export.value, - Measurand.energy_active_export_register.value, - Measurand.energy_reactive_export_register.value, - Measurand.energy_active_export_interval.value, - Measurand.energy_reactive_export_interval.value, - Measurand.power_active_export.value, - Measurand.power_reactive_export.value, + Measurand.voltage.value, ] DEFAULT_MEASURAND = Measurand.energy_active_import_register.value DEFAULT_MONITORED_VARIABLES = ",".join(MEASURANDS) diff --git a/tests/const.py b/tests/const.py index 59327bdc..a5c52024 100644 --- a/tests/const.py +++ b/tests/const.py @@ -17,8 +17,8 @@ CONF_WEBSOCKET_PING_INTERVAL, CONF_WEBSOCKET_PING_TIMEOUT, CONF_WEBSOCKET_PING_TRIES, + DEFAULT_MONITORED_VARIABLES, ) -from ocpp.v16.enums import Measurand MOCK_CONFIG = { CONF_HOST: "127.0.0.1", @@ -31,6 +31,7 @@ CONF_IDLE_INTERVAL: 900, CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, @@ -38,30 +39,7 @@ CONF_WEBSOCKET_PING_INTERVAL: 1, CONF_WEBSOCKET_PING_TIMEOUT: 1, } -MOCK_CONFIG_2 = { - Measurand.current_export.value: True, - Measurand.current_import.value: True, - Measurand.current_offered.value: True, - Measurand.energy_active_export_register.value: True, - Measurand.energy_active_import_register.value: True, - Measurand.energy_reactive_export_register.value: True, - Measurand.energy_reactive_import_register.value: True, - Measurand.energy_active_export_interval.value: True, - Measurand.energy_active_import_interval.value: True, - Measurand.energy_reactive_export_interval.value: True, - Measurand.energy_reactive_import_interval.value: True, - Measurand.frequency.value: True, - Measurand.power_active_export.value: True, - Measurand.power_active_import.value: True, - Measurand.power_factor.value: True, - Measurand.power_offered.value: True, - Measurand.power_reactive_export.value: True, - Measurand.power_reactive_import.value: True, - Measurand.rpm.value: True, - Measurand.soc.value: True, - Measurand.temperature.value: True, - Measurand.voltage.value: True, -} + MOCK_CONFIG_DATA = { CONF_HOST: "127.0.0.1", CONF_PORT: 9000, @@ -70,7 +48,7 @@ CONF_IDLE_INTERVAL: 900, CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, CONF_SSL: False, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index ff64c94b..1e0af0fa 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -8,7 +8,7 @@ DOMAIN, ) -from .const import MOCK_CONFIG, MOCK_CONFIG_2, MOCK_CONFIG_DATA +from .const import MOCK_CONFIG, MOCK_CONFIG_DATA # from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -49,15 +49,6 @@ async def test_successful_config_flow(hass, bypass_get_data): result["flow_id"], user_input=MOCK_CONFIG ) - # Check that the config flow shows the user form as the first step - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "measurands" - - # Call again for step_id == "measurands" with default measurand - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG_2 - ) - # Check that the config flow is complete and a new entry is created with # the input data assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY From d0584d808025fa2879552a6b523e96e5602c9dd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Nov 2023 14:01:29 +0100 Subject: [PATCH 032/370] build(deps): bump ruff from 0.1.4 to 0.1.5 (#972) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.4 to 0.1.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.4...v0.1.5) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 65409aa3..4f22b993 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.7.0 pip>=21.0,<23.4 -ruff==0.1.4 +ruff==0.1.5 ocpp==0.22.0 websockets==12.0 jsonschema==4.19.2 From b158b23be1ae0c6f02fa2524ccb33af2c0475d50 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 11 Nov 2023 14:07:33 +0100 Subject: [PATCH 033/370] Create SECURITY.md (#969) --- SECURITY.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a744f6c9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +This integration is intended for personal use only. +The integration supports secure connections, but does not perform any validation of client connections. + +## Reporting a Vulnerability + +Reporting a vulnerability can be done by creating an issue at: +](https://github.com/lbbrhzn/ocpp/issues)https://github.com/lbbrhzn/ocpp/issues From 295d494b8852cb061cfeb25ecd6bac4d4536753e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 17:04:32 +0100 Subject: [PATCH 034/370] build(deps): bump ruff from 0.1.5 to 0.1.6 (#978) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.5 to 0.1.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.5...v0.1.6) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4f22b993..a2ac649b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.7.0 pip>=21.0,<23.4 -ruff==0.1.5 +ruff==0.1.6 ocpp==0.22.0 websockets==12.0 jsonschema==4.19.2 From 4551a6b38404d6257661003bf7380c156d70da8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 17:17:31 +0100 Subject: [PATCH 035/370] build(deps): bump pytest-homeassistant-custom-component (#974) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.75 to 0.13.76. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.75...0.13.76) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a2ac649b..cfb2e9f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.22.0 websockets==12.0 jsonschema==4.19.2 pre-commit -pytest-homeassistant-custom-component==0.13.75 +pytest-homeassistant-custom-component==0.13.76 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 673423e1a06e5b3d880681b7157a9e53909c8289 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:43:24 +0100 Subject: [PATCH 036/370] build(deps): bump pytest-homeassistant-custom-component (#981) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.76 to 0.13.77. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.76...0.13.77) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfb2e9f1..37902dc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.22.0 websockets==12.0 jsonschema==4.19.2 pre-commit -pytest-homeassistant-custom-component==0.13.76 +pytest-homeassistant-custom-component==0.13.77 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From d269c79be4d1ca70a09ede01438447a8f9a4ad14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:43:51 +0100 Subject: [PATCH 037/370] build(deps): bump black from 23.10.1 to 23.11.0 in /.github/workflows (#971) Bumps [black](https://github.com/psf/black) from 23.10.1 to 23.11.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.10.1...23.11.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 138c41e7..30f2cb15 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.5.0 bandit==1.7.5 -black==23.10.1 +black==23.11.0 flake8==6.1.0 isort==5.12.0 pre-comit-hooks==4.1.0 From 63b29d38c956eda1a188a0eaa1d8d1c4a6cd0583 Mon Sep 17 00:00:00 2001 From: jonasgustavsson <36690764+jonasgustavsson@users.noreply.github.com> Date: Sun, 26 Nov 2023 14:45:53 +0200 Subject: [PATCH 038/370] Update supported-devices.md (#986) Added notes about Wallbox --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 400d5ff6..49d1ccc0 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -16,6 +16,8 @@ All OCPP 1.6j compatible devices should be supported, but not every device offer ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) (has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) ## [Wallbox Pulsar](https://wallbox.com/en_uk/wallbox-pulsar) +The Wallbox Pulsar Max has been verified. +In the OCPP-config, leave the password field empty. ## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) ## [V2C Trydan](https://v2charge.com/trydan) From 784383c7712f5e004d6b54d44e70ee52c502a0b0 Mon Sep 17 00:00:00 2001 From: jonasgustavsson <36690764+jonasgustavsson@users.noreply.github.com> Date: Sun, 26 Nov 2023 14:46:38 +0200 Subject: [PATCH 039/370] Update installation.md (#984) Improved instructions related to secure connection --- docs/installation.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index d4a18df6..12f1bb45 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,9 +27,15 @@ Installation ### Secure Connection -If using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/letsencrypt), [Duck DNS](https://www.home-assistant.io/integrations/duckdns/) or other add-ons that enables HTTPS you can get a secure WWS connection for OCPP. For more information on how to generate keys [see this blog post](https://www.home-assistant.io/blog/2017/09/27/effortless-encryption-with-lets-encrypt-and-duckdns/). -- The option secure connection enables connection thru WSS (HTTPS). With the option disabled the connection will be WS (HTTP). -- If enabled provide pathways to SSL certificate and key files. The pathways will be ignored if secure connection is disabled. +If you are using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/letsencrypt), [Duck DNS](https://www.home-assistant.io/integrations/duckdns/) or other add-on that enables secure HTTPS for your Home Assistant instance, you can get a secure WSS connection for OCPP. +To use a secure connection: +- Enable the option _Secure connection_ +- Provide the pathways to your HA's SSL certificate and key files. These are typically located in the /config or /ssl folder, and typically named fullchain.pem and privkey.pem respectively. +- If you provide incorrect pathways, the integration will fail to setup with no clear indication of why. + +If you do not use HTTPS for your Home Assistant instance: +- Disable the option _Secure connection_ +- _Path to SSL certificate/key_ will be ignored. @@ -55,6 +61,7 @@ If using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/ - Configure your charger to use the OCPP websocket of your Central System (e.g. ws://homeassistant.local:9000). This is charger specific, so consult your manual. - Some chargers require the protocol section 'ws://' to be removed, or require the url to end with a '/'. +- If you have configured _Secure connection_ in previous step, you should use 'wss://' - Some chargers require the url to be specified as an IP address, i.e. '192.168.178.1:9000' - You may need to reboot your charger before the changes become effective. From 5ad31b4068d1c31069696ecfaa1c6137945638ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:47:20 +0100 Subject: [PATCH 040/370] build(deps): bump jsonschema from 4.19.2 to 4.20.0 (#977) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.19.2 to 4.20.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.19.2...v4.20.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 37902dc8..a319efdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ pip>=21.0,<23.4 ruff==0.1.6 ocpp==0.22.0 websockets==12.0 -jsonschema==4.19.2 +jsonschema==4.20.0 pre-commit pytest-homeassistant-custom-component==0.13.77 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 04e9eabe1cd0da238fb3d06cac925de0a056e76f Mon Sep 17 00:00:00 2001 From: Salvatore Mazzarino Date: Wed, 13 Dec 2023 22:22:31 +0100 Subject: [PATCH 041/370] Include Etrel Inch PRO station in supported devices list (#1011) --- docs/supported-devices.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 49d1ccc0..2bf2ac4e 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -20,6 +20,10 @@ The Wallbox Pulsar Max has been verified. In the OCPP-config, leave the password field empty. ## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) ## [V2C Trydan](https://v2charge.com/trydan) +## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) +To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the +authorization type to either `Central system only` or `Charger whitelist and central system` otherwise the OCPP integration won't +match transactions and it won't report some meter values such as session time. When a charger is not listed as a supported charger it simply means that it has not been reported to work. Whether it will work or not in practice really depends on whether it is compliant with the OCPP standard. Some vendors claim their device is compliant without bothering to do a compliance test, because that takes time and costs money! From 80737e427ba1710e47e4213e0b346fc1cb472d98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:56:54 +0100 Subject: [PATCH 042/370] build(deps): bump actions/upload-artifact from 3.1.3 to 4.0.0 (#1017) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3.1.3 to 4.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3.1.3...v4.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2ab16fb5..91b9aaed 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v3.1.3 + uses: actions/upload-artifact@v4.0.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From d625b3568fb388c6cf900e93e4d771c13147e60c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:57:18 +0100 Subject: [PATCH 043/370] build(deps): bump pytest-homeassistant-custom-component (#1016) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.77 to 0.13.85. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.77...0.13.85) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a319efdf..55e27504 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.22.0 websockets==12.0 jsonschema==4.20.0 pre-commit -pytest-homeassistant-custom-component==0.13.77 +pytest-homeassistant-custom-component==0.13.85 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From b2db880027903368601e95f7ec58f4ac635d5ff9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:57:42 +0100 Subject: [PATCH 044/370] build(deps): bump isort from 5.12.0 to 5.13.2 in /.github/workflows (#1013) Bumps [isort](https://github.com/pycqa/isort) from 5.12.0 to 5.13.2. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.12.0...5.13.2) --- updated-dependencies: - dependency-name: isort dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 30f2cb15..7634de31 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -4,7 +4,7 @@ pre-commit==3.5.0 bandit==1.7.5 black==23.11.0 flake8==6.1.0 -isort==5.12.0 +isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.15.0 reorder-python-imports==3.12.0 From ae1893a35e3f5a751a779866a8ebdf3dbe7c41b3 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:58:08 +0100 Subject: [PATCH 045/370] Update supported-devices.md (#1012) sort alphabetically --- docs/supported-devices.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 2bf2ac4e..15ea6d84 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -4,27 +4,40 @@ Supported devices All OCPP 1.6j compatible devices should be supported, but not every device offers the same level of functionality. So far, we've tried: ## [ABB Terra AC-W7-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w7-g5-r-0) + ## [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) + ## [Alfen - Eve Single Pro-line](https://alfen.com/en/ev-charge-points/alfen-product-range) + ## [Alfen - Eve Single S-line](https://alfen.com/en/ev-charge-points/alfen-product-range) + ## [CTEK Chargestorm Connected 2](https://www.ctek.com/uk/ev-charging/chargestorm%C2%AE-connected-2) [Jonas Karlsson](https://github.com/jonasbkarlsson) has written a [getting started guide](https://github.com/jonasbkarlsson/ocpp/wiki/CTEK-Chargestorm-Connected-2) for connecting CTEK Chargestorm Connected 2. + +## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) +To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the +authorization type to either `Central system only` or `Charger whitelist and central system` otherwise the OCPP integration won't +match transactions and it won't report some meter values such as session time. + ## [EVBox Elvi](https://evbox.com/en/ev-chargers/elvi) + ## [EVLink Wallbox Plus](https://www.se.com/ww/en/product/EVH3S22P0CK/evlink-wallbox-plus---t2-attached-cable---3-phase---32a-22kw/) + ## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) (Ability to configure a custom OCPP server such as HA is being discontinued) + ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) (has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) + +## [V2C Trydan](https://v2charge.com/trydan) + +## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) + ## [Wallbox Pulsar](https://wallbox.com/en_uk/wallbox-pulsar) The Wallbox Pulsar Max has been verified. In the OCPP-config, leave the password field empty. -## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) -## [V2C Trydan](https://v2charge.com/trydan) -## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) -To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the -authorization type to either `Central system only` or `Charger whitelist and central system` otherwise the OCPP integration won't -match transactions and it won't report some meter values such as session time. +## Others When a charger is not listed as a supported charger it simply means that it has not been reported to work. Whether it will work or not in practice really depends on whether it is compliant with the OCPP standard. Some vendors claim their device is compliant without bothering to do a compliance test, because that takes time and costs money! When it is fully compliant, then it should work out of the box, since the ocpp integration is designed to work for fully compliant chargers. Any issues should be reported, and we will do out best to analyze them. In some cases modifications or workarounds may be needed. As long as these workarounds do not break compliance to the OCPP standard they can be added to this repository. From 05bed67df3fc9583c851d88211e6c4a7d850a131 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:58:34 +0100 Subject: [PATCH 046/370] build(deps): bump black from 23.11.0 to 23.12.0 in /.github/workflows (#1009) Bumps [black](https://github.com/psf/black) from 23.11.0 to 23.12.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.11.0...23.12.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 7634de31..63572071 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.5.0 bandit==1.7.5 -black==23.11.0 +black==23.12.0 flake8==6.1.0 isort==5.13.2 pre-comit-hooks==4.1.0 From 061ce0153540aa5d04a6eb8d2b6d88f65a59405c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:59:00 +0100 Subject: [PATCH 047/370] build(deps): bump actions/setup-python from 4.7.1 to 5.0.0 (#998) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.7.1 to 5.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.7.1...v5.0.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 91b9aaed..cd19e8c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3eedda0f..47e2875b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -66,7 +66,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v4.7.1" + uses: "actions/setup-python@v5.0.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From fb27739dee5958fe3650937f1b455bf94ce5efa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:59:59 +0100 Subject: [PATCH 048/370] build(deps): bump sphinx-rtd-theme from 1.3.0 to 2.0.0 (#989) Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 1.3.0 to 2.0.0. - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/1.3.0...2.0.0) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 1d064e22..706c05f2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,4 @@ myst-parser==2.0.0 docutils==0.20.1 Jinja2==3.1.2 sphinx==6.2.1 -sphinx_rtd_theme==1.3.0 \ No newline at end of file +sphinx_rtd_theme==2.0.0 \ No newline at end of file From cbf5cbc1fb7bb5fc2c3802fe5ca38e993565d4cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 19:59:20 +0100 Subject: [PATCH 049/370] build(deps): bump pre-commit from 3.5.0 to 3.6.0 in /.github/workflows (#1007) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.5.0 to 3.6.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.5.0...v3.6.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 63572071..8a1387d6 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<22.4 -pre-commit==3.5.0 +pre-commit==3.6.0 bandit==1.7.5 black==23.12.0 flake8==6.1.0 From fe94b9c76ba9c8084410691fadeae22d42ce3da9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 20:04:48 +0100 Subject: [PATCH 050/370] build(deps): bump ocpp from 0.22.0 to 0.24.0 (#1001) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.22.0 to 0.24.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.22.0...0.24.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 55e27504..26efe33b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.7.0 pip>=21.0,<23.4 ruff==0.1.6 -ocpp==0.22.0 +ocpp==0.24.0 websockets==12.0 jsonschema==4.20.0 pre-commit From 8412f38f045087b3fad9a3ea0633e8df31dbdd6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 20:06:20 +0100 Subject: [PATCH 051/370] build(deps): bump bandit from 1.7.5 to 1.7.6 in /.github/workflows (#1006) Bumps [bandit](https://github.com/PyCQA/bandit) from 1.7.5 to 1.7.6. - [Release notes](https://github.com/PyCQA/bandit/releases) - [Commits](https://github.com/PyCQA/bandit/compare/1.7.5...1.7.6) --- updated-dependencies: - dependency-name: bandit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 8a1387d6..a8acf613 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,7 +1,7 @@ # home assistant pip>=21.0,<22.4 pre-commit==3.6.0 -bandit==1.7.5 +bandit==1.7.6 black==23.12.0 flake8==6.1.0 isort==5.13.2 From f9cb233b6ba4062956d3fcd426fe44255347bb58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 20:21:11 +0100 Subject: [PATCH 052/370] build(deps): bump actions/stale from 8 to 9 (#1003) Bumps [actions/stale](https://github.com/actions/stale) from 8 to 9. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/stale dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 310c201d..c3424111 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'Stale issue message' From f6c708b8cc5fdc597e983d514c0c7324d95c4e99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Dec 2023 20:21:53 +0100 Subject: [PATCH 053/370] build(deps): bump colorlog from 6.7.0 to 6.8.0 (#995) Bumps [colorlog](https://github.com/borntyping/python-colorlog) from 6.7.0 to 6.8.0. - [Release notes](https://github.com/borntyping/python-colorlog/releases) - [Commits](https://github.com/borntyping/python-colorlog/compare/v6.7.0...v6.8.0) --- updated-dependencies: - dependency-name: colorlog dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 26efe33b..370755bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -colorlog==6.7.0 +colorlog==6.8.0 pip>=21.0,<23.4 ruff==0.1.6 ocpp==0.24.0 From 3d699b182e608f324ed9cef0ed0bd4cc81e02111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20L=C3=B3r=C3=A1nt?= Date: Sun, 17 Dec 2023 13:45:03 +0100 Subject: [PATCH 054/370] Update supported-devices.md (#992) ABB Terra AC W22-T-0 added. Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 15ea6d84..0f2aa5ea 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -7,6 +7,8 @@ All OCPP 1.6j compatible devices should be supported, but not every device offer ## [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) +## [ABB Terra AC-W22-T-0](https://new.abb.com/products/6AGC081279/tac-w22-t-0) + ## [Alfen - Eve Single Pro-line](https://alfen.com/en/ev-charge-points/alfen-product-range) ## [Alfen - Eve Single S-line](https://alfen.com/en/ev-charge-points/alfen-product-range) From e26704015e777bfb564bcf74ac24bd648e236c60 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Tue, 26 Dec 2023 00:06:20 +0100 Subject: [PATCH 055/370] add maXpeedingrods Ev Charger (#1023) --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 0f2aa5ea..24c74d22 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -28,6 +28,8 @@ match transactions and it won't report some meter values such as session time. ## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) (Ability to configure a custom OCPP server such as HA is being discontinued) +## [MaXpeedingrods Ev Charger](https://www.maxpeedingrods.com/category/ev-charger.html) + ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) (has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) From b3d1002223b5c3ee81bc0623a5a2299d1a6dcfae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jan 2024 21:43:54 +0100 Subject: [PATCH 056/370] build(deps): bump ruff from 0.1.6 to 0.1.13 (#1044) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.6 to 0.1.13. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.6...v0.1.13) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 370755bb..75342c9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.0 pip>=21.0,<23.4 -ruff==0.1.6 +ruff==0.1.13 ocpp==0.24.0 websockets==12.0 jsonschema==4.20.0 From f4916f05f0cf7a1300f218289249436f985d170f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jan 2024 21:44:21 +0100 Subject: [PATCH 057/370] build(deps): bump jinja2 from 3.1.2 to 3.1.3 (#1042) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 706c05f2..d5da51fe 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==2.0.0 docutils==0.20.1 -Jinja2==3.1.2 +Jinja2==3.1.3 sphinx==6.2.1 sphinx_rtd_theme==2.0.0 \ No newline at end of file From 0a7119551e027b4e7b01d9aa807901ae3fda30e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jan 2024 21:44:43 +0100 Subject: [PATCH 058/370] build(deps): bump pytest-homeassistant-custom-component (#1039) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.85 to 0.13.88. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.85...0.13.88) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 75342c9f..59d088fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.24.0 websockets==12.0 jsonschema==4.20.0 pre-commit -pytest-homeassistant-custom-component==0.13.85 +pytest-homeassistant-custom-component==0.13.88 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 45a792490865b56898ce5a22e97195aafff40bdb Mon Sep 17 00:00:00 2001 From: Michael Unterkalmsteiner Date: Sun, 14 Jan 2024 21:45:07 +0100 Subject: [PATCH 059/370] Update supported-devices.md (#1034) I've installed the device and it is working well with HA. --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 24c74d22..0c5bc08d 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -28,6 +28,8 @@ match transactions and it won't report some meter values such as session time. ## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) (Ability to configure a custom OCPP server such as HA is being discontinued) +## [Garo Entity Pro](https://www.garo.se/en/professional/products/e-mobility/wallbox/entity-pro/wallbox-entity-pro-22-sigi-o) + ## [MaXpeedingrods Ev Charger](https://www.maxpeedingrods.com/category/ev-charger.html) ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) From 5cc81ac01a39f29684cf8765c6d5046031fe02b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jan 2024 21:45:33 +0100 Subject: [PATCH 060/370] build(deps): bump flake8 from 6.1.0 to 7.0.0 in /.github/workflows (#1033) Bumps [flake8](https://github.com/pycqa/flake8) from 6.1.0 to 7.0.0. - [Commits](https://github.com/pycqa/flake8/compare/6.1.0...7.0.0) --- updated-dependencies: - dependency-name: flake8 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index a8acf613..783a4d28 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -3,7 +3,7 @@ pip>=21.0,<22.4 pre-commit==3.6.0 bandit==1.7.6 black==23.12.0 -flake8==6.1.0 +flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.15.0 From 60562bf7e67152a84404d107a2b7d1249301a5f4 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:46:00 +1300 Subject: [PATCH 061/370] Fix deprecated constants (#1032) * Fix deprecated constants * Fix linting * Redo consts * fix linting --- custom_components/ocpp/api.py | 4 +++- custom_components/ocpp/number.py | 4 +++- custom_components/ocpp/switch.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 1bb4c2ab..24486673 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -11,7 +11,7 @@ from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, TIME_MINUTES +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_component, entity_registry import homeassistant.helpers.config_validation as cv @@ -111,6 +111,8 @@ # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) +TIME_MINUTES = UnitOfTime.MINUTES + UFW_SERVICE_DATA_SCHEMA = vol.Schema( { vol.Required("firmware_url"): cv.string, diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 36f42b4b..0c60a526 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -10,7 +10,7 @@ NumberEntityDescription, RestoreNumber, ) -from homeassistant.const import ELECTRIC_CURRENT_AMPERE +from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -35,6 +35,8 @@ class OcppNumberDescription(NumberEntityDescription): initial_value: float | None = None +ELECTRIC_CURRENT_AMPERE = UnitOfElectricCurrent.AMPERE + NUMBERS: Final = [ OcppNumberDescription( key="maximum_current", diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index d0da4997..b1be1615 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -9,7 +9,7 @@ SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import POWER_KILO_WATT +from homeassistant.const import UnitOfPower from homeassistant.helpers.entity import DeviceInfo from ocpp.v16.enums import ChargePointStatus, Measurand @@ -33,6 +33,8 @@ class OcppSwitchDescription(SwitchEntityDescription): default_state: bool = False +POWER_KILO_WATT = UnitOfPower.KILO_WATT + SWITCHES: Final = [ OcppSwitchDescription( key="charge_control", From c5b3e9b99149f12f4f218b415f02994ec9471e93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:16:54 +0100 Subject: [PATCH 062/370] build(deps): bump ocpp from 0.24.0 to 0.25.0 (#1038) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.24.0 to 0.25.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.24.0...0.25.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 59d088fb..cdcea847 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.8.0 pip>=21.0,<23.4 ruff==0.1.13 -ocpp==0.24.0 +ocpp==0.25.0 websockets==12.0 jsonschema==4.20.0 pre-commit From 5915dc3257d4d3ea1efdabffd9f616122eced1a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:17:24 +0100 Subject: [PATCH 063/370] build(deps): bump black from 23.12.0 to 23.12.1 in /.github/workflows (#1021) Bumps [black](https://github.com/psf/black) from 23.12.0 to 23.12.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.12.0...23.12.1) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 783a4d28..2dea642c 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.6.0 bandit==1.7.6 -black==23.12.0 +black==23.12.1 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 From 7eb47e7aa9e66c4c4f18bd00d1082bcba1a73e9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:39:08 +0100 Subject: [PATCH 064/370] build(deps): bump actions/upload-artifact from 4.0.0 to 4.3.0 (#1061) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.0.0 to 4.3.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.0.0...v4.3.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd19e8c9..b3a2f673 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.0.0 + uses: actions/upload-artifact@v4.3.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From ec2e26b8f8e3aba0c620c6e081a67105280dfc3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:39:32 +0100 Subject: [PATCH 065/370] build(deps): bump bandit from 1.7.6 to 1.7.7 in /.github/workflows (#1060) Bumps [bandit](https://github.com/PyCQA/bandit) from 1.7.6 to 1.7.7. - [Release notes](https://github.com/PyCQA/bandit/releases) - [Commits](https://github.com/PyCQA/bandit/compare/1.7.6...1.7.7) --- updated-dependencies: - dependency-name: bandit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 2dea642c..b60fe9ee 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,7 +1,7 @@ # home assistant pip>=21.0,<22.4 pre-commit==3.6.0 -bandit==1.7.6 +bandit==1.7.7 black==23.12.1 flake8==7.0.0 isort==5.13.2 From 954aedb7d79445f22d9cfdc2b47d50a81977c1bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:39:58 +0100 Subject: [PATCH 066/370] build(deps): bump jsonschema from 4.20.0 to 4.21.1 (#1059) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.20.0 to 4.21.1. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.20.0...v4.21.1) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cdcea847..c55a4ce0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ pip>=21.0,<23.4 ruff==0.1.13 ocpp==0.25.0 websockets==12.0 -jsonschema==4.20.0 +jsonschema==4.21.1 pre-commit pytest-homeassistant-custom-component==0.13.88 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From c586f86468ba2796a2d720867ae1e01d79702c3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:40:22 +0100 Subject: [PATCH 067/370] build(deps): bump ruff from 0.1.13 to 0.1.14 (#1057) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.13 to 0.1.14. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.13...v0.1.14) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c55a4ce0..cb77e659 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.0 pip>=21.0,<23.4 -ruff==0.1.13 +ruff==0.1.14 ocpp==0.25.0 websockets==12.0 jsonschema==4.21.1 From 62353b6f9ab781eecd47fe9b5a3e2d5607592022 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 28 Jan 2024 06:40:46 +1300 Subject: [PATCH 068/370] New charge rate service (#1050) * New charge rate service * Fix schema ref * Fix positive_int ref * Fix data string * Change back to dbl quotes * Convert dict profile to string for service call * Tidy up formatting * Further fixes * Fix linting * Fix linting * Add connector id to service * Fix linting * Improve service descriptions * Fix typo * Allow dict or str types * fix linting --- custom_components/ocpp/api.py | 60 ++++++++++++++++++++++++++-- custom_components/ocpp/services.yaml | 21 ++++++++-- tests/test_charge_point.py | 28 +++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 24486673..ec8255c4 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -4,6 +4,7 @@ import asyncio from collections import defaultdict from datetime import datetime, timedelta, timezone +import json import logging from math import sqrt import ssl @@ -142,6 +143,14 @@ vol.Optional("data"): cv.string, } ) +CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("limit_amps"): cv.positive_float, + vol.Optional("limit_watts"): cv.positive_int, + vol.Optional("conn_id"): cv.positive_int, + vol.Optional("custom_profile"): vol.Any(cv.string, dict), + } +) class CentralSystem: @@ -425,6 +434,25 @@ async def handle_data_transfer(call): data = call.data.get("data", "") await self.data_transfer(vendor, message, data) + async def handle_set_charge_rate(call): + """Handle the data transfer service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + amps = call.data.get("limit_amps", None) + watts = call.data.get("limit_watts", None) + id = call.data.get("conn_id", 0) + custom_profile = call.data.get("custom_profile", None) + if custom_profile is not None: + if type(custom_profile) is str: + custom_profile = custom_profile.replace("'", '"') + custom_profile = json.loads(custom_profile) + await self.set_charge_rate(profile=custom_profile, conn_id=id) + elif watts is not None: + await self.set_charge_rate(limit_watts=watts, conn_id=id) + elif amps is not None: + await self.set_charge_rate(limit_amps=amps, conn_id=id) + try: self.status = STATE_OK await asyncio.sleep(2) @@ -493,6 +521,12 @@ async def handle_data_transfer(call): self.hass.services.async_register( DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_set_charge_rate.value, + handle_set_charge_rate, + CHRGR_SERVICE_DATA_SCHEMA, + ) if prof.FW in self._attr_supported_features: self.hass.services.async_register( DOMAIN, @@ -599,8 +633,28 @@ async def clear_profile(self): ) return False - async def set_charge_rate(self, limit_amps: int = 32, limit_watts: int = 22000): + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): """Set a charging profile with defined limit.""" + if profile is not None: # assumes advanced user and correct profile format + req = call.SetChargingProfilePayload( + connector_id=conn_id, cs_charging_profiles=profile + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" + ) + return False + if prof.SMART in self._attr_supported_features: resp = await self.get_configuration( ckey.charging_schedule_allowed_charging_rate_unit.value @@ -621,7 +675,7 @@ async def set_charge_rate(self, limit_amps: int = 32, limit_watts: int = 22000): ) stack_level = int(resp) req = call.SetChargingProfilePayload( - connector_id=0, + connector_id=conn_id, cs_charging_profiles={ om.charging_profile_id.value: 8, om.stack_level.value: stack_level, @@ -647,7 +701,7 @@ async def set_charge_rate(self, limit_amps: int = 32, limit_watts: int = 22000): ) # try a lower stack level for chargers where level < maximum, not <= req = call.SetChargingProfilePayload( - connector_id=0, + connector_id=conn_id, cs_charging_profiles={ om.charging_profile_id.value: 8, om.stack_level.value: stack_level - 1, diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index eba8be3f..df857b0a 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -15,23 +15,36 @@ set_charge_rate: # Field name as shown in UI name: Limit (A) # Description of the field - description: Maximum charge rate in Amps + description: Maximum charge rate in Amps (optional) # Whether or not field is required (default = false) required: false # Advanced fields are only shown when the advanced mode is enabled for the user (default = false) - advanced: true + advanced: false # Example value that can be passed for this field example: 16 # The default field value default: 32 limit_watts: name: Limit (W) - description: Maximum charge rate in Watts + description: Maximum charge rate in Watts (optional) required: false advanced: true example: 1500 default: 22000 - + conn_id: + name: Connector identifier + description: Optional, 0 = all connectors (default), 1 is first connector + required: false + advanced: true + example: 0 + default: 0 + custom_profile: + name: Custom profile + description: Used to send a custom charge profile to charger (for advanced users only use >- or '' to ensure profile is a string variable) + required: false + advanced: true + example: '{"chargingProfileId":8,"stackLevel":0,"chargingProfileKind":"Relative","chargingProfilePurpose":"ChargePointMaxProfile","chargingSchedule":{"chargingRateUnit":"A","chargingSchedulePeriod":[{"startPeriod":0,"limit":16}]}}' + clear_profile: name: Clear charging profiles description: Clears all charging profiles (limits) set (dependent on charger support) diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 4f587b59..059dd017 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -90,6 +90,7 @@ async def test_services(hass, socket_enabled): csvcs.service_get_diagnostics, csvcs.service_clear_profile, csvcs.service_data_transfer, + csvcs.service_set_charge_rate, ] for service in SERVICES: data = {} @@ -103,6 +104,8 @@ async def test_services(hass, socket_enabled): data = {"upload_url": "https://webhook.site/abc"} if service == csvcs.service_data_transfer: data = {"vendor_id": "ABC"} + if service == csvcs.service_set_charge_rate: + data = {"limit_amps": 30} await hass.services.async_call( OCPP_DOMAIN, @@ -110,6 +113,31 @@ async def test_services(hass, socket_enabled): service_data=data, blocking=True, ) + # test additional set charge rate options + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data={"limit_watts": 3000}, + blocking=True, + ) + # test custom charge profile for advanced use + prof = { + "chargingProfileId": 8, + "stackLevel": 6, + "chargingProfileKind": "Relative", + "chargingProfilePurpose": "ChargePointMaxProfile", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16.0}], + }, + } + data = {"custom_profile": str(prof)} + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) for number in NUMBERS: # test setting value of number slider From 6b79028483caf5343e5e4a8c43e57e5a89aa6d94 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 28 Jan 2024 06:41:28 +1300 Subject: [PATCH 069/370] Fix Profiles (#1047) --- custom_components/ocpp/api.py | 2 +- custom_components/ocpp/enums.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index ec8255c4..e751ef69 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -376,7 +376,7 @@ def __init__( self._metrics[csess.session_time.value].unit = TIME_MINUTES self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value - self._attr_supported_features: int = 0 + self._attr_supported_features = prof.NONE self._metrics[cstat.reconnects.value].value: int = 0 async def post_connect(self): diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index b0bd05e1..67f625f9 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -1,5 +1,5 @@ """Additional enumerated values to use in home assistant.""" -from enum import Enum, IntFlag, auto +from enum import Enum, Flag, auto class HAChargerServices(str, Enum): @@ -60,11 +60,10 @@ class HAChargerSession(str, Enum): meter_start = "Energy.Meter.Start" # in kWh -class Profiles(IntFlag): +class Profiles(Flag): """Flags to indicate supported feature profiles.""" - __str__ = Enum.__str__ - + NONE = 0 CORE = auto() # Core FW = auto() # FirmwareManagement SMART = auto() # SmartCharging From 6c4753670bb125829091a9d510fc049c5ee5c657 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:43:35 +0100 Subject: [PATCH 070/370] build(deps): bump ocpp from 0.25.0 to 0.26.0 (#1048) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.25.0 to 0.26.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.25.0...0.26.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb77e659..3e79fe19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.8.0 pip>=21.0,<23.4 ruff==0.1.14 -ocpp==0.25.0 +ocpp==0.26.0 websockets==12.0 jsonschema==4.21.1 pre-commit From 10234a4f28668ac5bb5f79a8dcb46ca48bb24e11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:43:59 +0100 Subject: [PATCH 071/370] build(deps): bump black from 23.12.1 to 24.1.0 in /.github/workflows (#1064) Bumps [black](https://github.com/psf/black) from 23.12.1 to 24.1.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.12.1...24.1.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index b60fe9ee..c55fc55a 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.6.0 bandit==1.7.7 -black==23.12.1 +black==24.1.0 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 From 1035e6c74f50a75b0e0093c2f86a63247d11f2c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 18:44:19 +0100 Subject: [PATCH 072/370] build(deps): bump pytest-homeassistant-custom-component (#1058) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.88 to 0.13.91. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.88...0.13.91) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3e79fe19..236ab267 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==0.26.0 websockets==12.0 jsonschema==4.21.1 pre-commit -pytest-homeassistant-custom-component==0.13.88 +pytest-homeassistant-custom-component==0.13.91 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 459f3999b5ee658776ba74d27d1db6020852e078 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:41:03 +0100 Subject: [PATCH 073/370] build(deps): bump ruff from 0.1.14 to 0.3.4 (#1115) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.14 to 0.3.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.14...v0.3.4) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 236ab267..1cccb3de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.0 pip>=21.0,<23.4 -ruff==0.1.14 +ruff==0.3.4 ocpp==0.26.0 websockets==12.0 jsonschema==4.21.1 From 3e7f59f348c660ebf842ec34816bab7f3aecc2fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:41:20 +0100 Subject: [PATCH 074/370] build(deps): bump black from 24.1.0 to 24.3.0 in /.github/workflows (#1114) Bumps [black](https://github.com/psf/black) from 24.1.0 to 24.3.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.1.0...24.3.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index c55fc55a..81bd8961 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<22.4 pre-commit==3.6.0 bandit==1.7.7 -black==24.1.0 +black==24.3.0 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 From a30968ac8bc0e036b9774b87bf23322325ea0b8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:42:41 +0100 Subject: [PATCH 075/370] build(deps): bump softprops/action-gh-release from 1 to 2 (#1109) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3a2f673..a239007c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: zip ocpp.zip -r ./ - name: 📤 Upload zip to release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: ${{ github.event_name == 'release' }} with: files: ${{ github.workspace }}/custom_components/ocpp/ocpp.zip \ No newline at end of file From 3ac7db6729e5e84f05de2796b21301a9b248fe4a Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 23 Mar 2024 22:45:45 +1300 Subject: [PATCH 076/370] Extra logic if measurand not accepted (#1101) * Extra logic if measurand not accepted * Add clean up of entries * Fix linting --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/api.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index e751ef69..0effcd1e 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -478,12 +478,23 @@ async def handle_set_charge_rate(call): accepted_measurands = ",".join(accepted_measurands) - _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") + if len(accepted_measurands) > 0: + _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") + await self.configure( + ckey.meter_values_sampled_data.value, + accepted_measurands, + ) + else: + _LOGGER.debug(f"'{self.id}' measurands not configurable by OCPP") + resp = await self.get_configuration( + ckey.meter_values_sampled_data.value + ) + accepted_measurands = resp + _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") - await self.configure( - ckey.meter_values_sampled_data.value, - accepted_measurands, - ) + updated_entry = {**self.entry.data} + updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands + self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) await self.configure( ckey.meter_value_sample_interval.value, From e5e6160ac075b3a50696b0352da842fc7c656718 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:48:26 +0100 Subject: [PATCH 077/370] build(deps): bump bandit from 1.7.7 to 1.7.8 in /.github/workflows (#1108) Bumps [bandit](https://github.com/PyCQA/bandit) from 1.7.7 to 1.7.8. - [Release notes](https://github.com/PyCQA/bandit/releases) - [Commits](https://github.com/PyCQA/bandit/compare/1.7.7...1.7.8) --- updated-dependencies: - dependency-name: bandit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 81bd8961..8f0479e3 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,7 +1,7 @@ # home assistant pip>=21.0,<22.4 pre-commit==3.6.0 -bandit==1.7.7 +bandit==1.7.8 black==24.3.0 flake8==7.0.0 isort==5.13.2 From b63106b538f23e5a3b6359a3317b49b80dff7709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:49:47 +0100 Subject: [PATCH 078/370] build(deps): bump pyupgrade from 3.15.0 to 3.15.1 in /.github/workflows (#1090) Bumps [pyupgrade](https://github.com/asottile/pyupgrade) from 3.15.0 to 3.15.1. - [Commits](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.1) --- updated-dependencies: - dependency-name: pyupgrade dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 8f0479e3..23bc89fe 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -6,6 +6,6 @@ black==24.3.0 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 -pyupgrade==3.15.0 +pyupgrade==3.15.1 reorder-python-imports==3.12.0 sqlalchemy>=1.4.23 From 5af20114387e51ee2b5cf4386ca2a0e601db66d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:50:28 +0100 Subject: [PATCH 079/370] build(deps): bump actions/upload-artifact from 4.3.0 to 4.3.1 (#1079) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.0 to 4.3.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.0...v4.3.1) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a239007c..216ac1e8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.3.0 + uses: actions/upload-artifact@v4.3.1 if: ${{ github.event_name == 'push' }} with: name: ocpp From 58cd323d1fe35596fcc4b9c252b247d73dbbcec5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:50:44 +0100 Subject: [PATCH 080/370] build(deps): update pip requirement in /.github/workflows (#1077) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...24.0) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 23bc89fe..161d4782 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ # home assistant -pip>=21.0,<22.4 +pip>=21.0,<24.1 pre-commit==3.6.0 bandit==1.7.8 black==24.3.0 From 0dd11dbd5168a59034b2fbeba67ac263d7ff51ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:50:58 +0100 Subject: [PATCH 081/370] build(deps): bump release-drafter/release-drafter from 5.25.0 to 6.0.0 (#1074) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 5.25.0 to 6.0.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v5.25.0...v6.0.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 18140fa6..fc85faf2 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v5.25.0 + uses: release-drafter/release-drafter@v6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 4feba6bf5a9ac3cf61becb03f6396a94a4a44924 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 23 Mar 2024 22:51:29 +1300 Subject: [PATCH 082/370] remove scaling (#1071) Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index e8ba9887..4571fa37 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -34,7 +34,6 @@ class OcppSensorDescription(SensorEntityDescription): """Class to describe a Sensor entity.""" - scale: int = 1 # used for rounding metric metric: str | None = None @@ -181,8 +180,6 @@ def device_class(self): def native_value(self): """Return the state of the sensor, rounding if a number.""" value = self.central_system.get_metric(self.cp_id, self.metric) - if isinstance(value, float): - value = round(value, self.entity_description.scale) if value is not None: self._attr_native_value = value return self._attr_native_value From a3057eb13cfc63680af4ceb59c94a0ae1e386901 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:51:48 +0100 Subject: [PATCH 083/370] build(deps): bump colorlog from 6.8.0 to 6.8.2 (#1066) Bumps [colorlog](https://github.com/borntyping/python-colorlog) from 6.8.0 to 6.8.2. - [Release notes](https://github.com/borntyping/python-colorlog/releases) - [Commits](https://github.com/borntyping/python-colorlog/compare/v6.8.0...v6.8.2) --- updated-dependencies: - dependency-name: colorlog dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1cccb3de..87f0896c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -colorlog==6.8.0 +colorlog==6.8.2 pip>=21.0,<23.4 ruff==0.3.4 ocpp==0.26.0 From 92481beabef17f243eb628d3469e27a359da74f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:56:55 +0100 Subject: [PATCH 084/370] build(deps): update pip requirement from <23.4,>=21.0 to >=21.0,<24.1 (#1075) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...24.0) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 87f0896c..0de1af1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.8.2 -pip>=21.0,<23.4 +pip>=21.0,<24.1 ruff==0.3.4 ocpp==0.26.0 websockets==12.0 From 39765d171a3f2eae4c71bc590627169a1f5624f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 10:59:57 +0100 Subject: [PATCH 085/370] build(deps): bump pre-commit from 3.6.0 to 3.6.2 in /.github/workflows (#1089) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.6.0 to 3.6.2. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.6.0...v3.6.2) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 161d4782..0a106f81 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<24.1 -pre-commit==3.6.0 +pre-commit==3.6.2 bandit==1.7.8 black==24.3.0 flake8==7.0.0 From 47fa4b7e23bfbf86bbbc911f274503bf2452cc6b Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 23 Mar 2024 11:15:40 +0100 Subject: [PATCH 086/370] Update installation.md (#1118) --- docs/installation.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 12f1bb45..d349335c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -40,10 +40,8 @@ If you do not use HTTPS for your Home Assistant instance: ### Measurands -- Select which measurands you would like to become available as sensor entities. - Most chargers only support a subset of all possible measurands. This depends most on the Feature profiles that are supported by the charger. - -![image](https://user-images.githubusercontent.com/8673442/129494804-cdff0dfb-a421-490c-af1e-e939f01455b4.png) +- The integration will autodetect the supported measurands when the charger connects. ## Add the entities to your Dashboard - On the OCPP integration, click on devices to navigate to your Charge Point device. From 0fa69dd19fcac5913af7f97a988660f977ae3694 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:12:24 +0200 Subject: [PATCH 087/370] build(deps): bump actions/setup-python from 5.0.0 to 5.1.0 (#1123) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.0.0 to 5.1.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.0.0...v5.1.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 216ac1e8..09894234 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 47e2875b..b8788f7d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -66,7 +66,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.0.0" + uses: "actions/setup-python@v5.1.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 3984e6d7a7972efe0c185f41b5c38792efe04492 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:12:55 +0200 Subject: [PATCH 088/370] build(deps): bump pyupgrade from 3.15.1 to 3.15.2 in /.github/workflows (#1121) Bumps [pyupgrade](https://github.com/asottile/pyupgrade) from 3.15.1 to 3.15.2. - [Commits](https://github.com/asottile/pyupgrade/compare/v3.15.1...v3.15.2) --- updated-dependencies: - dependency-name: pyupgrade dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 0a106f81..5b4ffee6 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -6,6 +6,6 @@ black==24.3.0 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 -pyupgrade==3.15.1 +pyupgrade==3.15.2 reorder-python-imports==3.12.0 sqlalchemy>=1.4.23 From db79f8f7a7f43c4704af21003d051980498f1e9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:13:22 +0200 Subject: [PATCH 089/370] build(deps): bump pre-commit from 3.6.2 to 3.7.0 in /.github/workflows (#1120) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.6.2 to 3.7.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.6.2...v3.7.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 5b4ffee6..949799f7 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<24.1 -pre-commit==3.6.2 +pre-commit==3.7.0 bandit==1.7.8 black==24.3.0 flake8==7.0.0 From eadd01474275ea9c9d430e176032a436ee52412d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 12:44:43 +0200 Subject: [PATCH 090/370] build(deps): bump pre-commit from 3.7.0 to 3.7.1 in /.github/workflows (#1159) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.7.0 to 3.7.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.7.0...v3.7.1) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 949799f7..55ef05f0 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<24.1 -pre-commit==3.7.0 +pre-commit==3.7.1 bandit==1.7.8 black==24.3.0 flake8==7.0.0 From c4380ae958bb0d2d5125c3b13baedcc749ea9358 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 12:46:03 +0200 Subject: [PATCH 091/370] build(deps): bump ruff from 0.3.4 to 0.4.4 (#1156) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.3.4 to 0.4.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.3.4...v0.4.4) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0de1af1d..60ccbde4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.3.4 +ruff==0.4.4 ocpp==0.26.0 websockets==12.0 jsonschema==4.21.1 From c1238009a609a5797b76983ef20cc2442ae7e332 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Thu, 16 May 2024 08:34:55 +0200 Subject: [PATCH 092/370] update codecov action (#1163) * update codecov action update codecov action to v4 * Update tests.yaml --- .github/workflows/tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b8788f7d..acd9c834 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -86,7 +86,7 @@ jobs: -rA \ tests - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false From f28956c90c9b340fb8e7b075124f1bbbb83b3439 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Thu, 16 May 2024 18:38:50 +1200 Subject: [PATCH 093/370] remove hertz (#1165) Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index cf9fa653..1b066464 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -118,7 +118,6 @@ UnitOfMeasure.fahrenheit: ha.UnitOfTemperature.FAHRENHEIT, UnitOfMeasure.k: ha.UnitOfTemperature.KELVIN, UnitOfMeasure.percent: ha.PERCENTAGE, - UnitOfMeasure.hertz: ha.UnitOfFrequency.HERTZ, } # Where an occp unit is not reported and only one possibility assign HA unit on device class From 8c50b7f8a77d445456d9e08d6be07e25c840be89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 08:40:32 +0200 Subject: [PATCH 094/370] build(deps): bump actions/upload-artifact from 4.3.1 to 4.3.3 (#1139) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.1 to 4.3.3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.1...v4.3.3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 09894234..a9340da8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v4.3.3 if: ${{ github.event_name == 'push' }} with: name: ocpp From bbbb8ba7afd62bdce82a50f70dbf1dad98e0b9d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 08:43:50 +0200 Subject: [PATCH 095/370] build(deps): bump myst-parser from 2.0.0 to 3.0.1 (#1149) Bumps [myst-parser](https://github.com/executablebooks/MyST-Parser) from 2.0.0 to 3.0.1. - [Release notes](https://github.com/executablebooks/MyST-Parser/releases) - [Changelog](https://github.com/executablebooks/MyST-Parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/executablebooks/MyST-Parser/compare/v2.0.0...v3.0.1) --- updated-dependencies: - dependency-name: myst-parser dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index d5da51fe..18c097a4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -myst-parser==2.0.0 +myst-parser==3.0.1 docutils==0.20.1 Jinja2==3.1.3 sphinx==6.2.1 From 48700a6bc7363687b7c20c2dd1de5f74fd8cfae0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 08:44:08 +0200 Subject: [PATCH 096/370] build(deps): bump black from 24.3.0 to 24.4.2 in /.github/workflows (#1146) Bumps [black](https://github.com/psf/black) from 24.3.0 to 24.4.2. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.3.0...24.4.2) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 55ef05f0..c4e0d1fb 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<24.1 pre-commit==3.7.1 bandit==1.7.8 -black==24.3.0 +black==24.4.2 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 From d0e2da15a3569c6d1c8a57f3cf528df3ffddf83d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 08:48:13 +0200 Subject: [PATCH 097/370] build(deps): bump ocpp from 0.26.0 to 1.0.0 (#1164) Bumps [ocpp](https://github.com/mobilityhouse/ocpp) from 0.26.0 to 1.0.0. - [Release notes](https://github.com/mobilityhouse/ocpp/releases) - [Changelog](https://github.com/mobilityhouse/ocpp/blob/master/CHANGELOG.md) - [Commits](https://github.com/mobilityhouse/ocpp/compare/0.26.0...1.0.0) --- updated-dependencies: - dependency-name: ocpp dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 60ccbde4..3f851ceb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.8.2 pip>=21.0,<24.1 ruff==0.4.4 -ocpp==0.26.0 +ocpp==1.0.0 websockets==12.0 jsonschema==4.21.1 pre-commit From 20a89cab342c2d81de88891d6736ece34309d18c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 07:46:57 +0200 Subject: [PATCH 098/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.91 to 0.13.108 (#1111) --- custom_components/ocpp/__init__.py | 6 ++---- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 92889216..cca7a513 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -90,10 +90,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = central_sys - for platform in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True diff --git a/requirements.txt b/requirements.txt index 3f851ceb..90ab3ce4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.21.1 pre-commit -pytest-homeassistant-custom-component==0.13.91 +pytest-homeassistant-custom-component==0.13.108 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 68658f88cfb9463233591b7688f3377ac932677e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 07:47:43 +0200 Subject: [PATCH 099/370] build(deps): bump jinja2 from 3.1.3 to 3.1.4 (#1166) --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 18c097a4..6e82e9d6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==3.0.1 docutils==0.20.1 -Jinja2==3.1.3 +Jinja2==3.1.4 sphinx==6.2.1 sphinx_rtd_theme==2.0.0 \ No newline at end of file From 6741911fe2eb31cb5a61386c484744a4cc46004b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 07:47:52 +0200 Subject: [PATCH 100/370] build(deps): bump jsonschema from 4.21.1 to 4.22.0 (#1167) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 90ab3ce4..c7748dda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ pip>=21.0,<24.1 ruff==0.4.4 ocpp==1.0.0 websockets==12.0 -jsonschema==4.21.1 +jsonschema==4.22.0 pre-commit pytest-homeassistant-custom-component==0.13.108 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 36079b117ebe8f6443dd16f8e36c3447ca9ce6c5 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 16:10:00 +0200 Subject: [PATCH 101/370] Update tests.yaml (#1169) * Update tests.yaml * Update tests.yaml use continuous integration environment * Update tests.yaml * Update tests.yaml * Update tests.yaml --- .github/workflows/tests.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index acd9c834..cb58bcf6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -62,6 +62,7 @@ jobs: tests: runs-on: "ubuntu-latest" name: Run tests + environment: continuous-integration steps: - name: Check out code from GitHub uses: "actions/checkout@v4" @@ -89,4 +90,5 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + fail_ci_if_error: true + verbose: true From 8d63bff09b112e7f9ef07120694db6393cbe6b7b Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 18 May 2024 03:21:37 +1200 Subject: [PATCH 102/370] Fix deprecation warnings (#1168) * remove payload from call * Update websockets dep warning * Update Action enums * black --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/api.py | 98 ++++++++++++--------------- tests/test_charge_point.py | 124 +++++++++++++++------------------- 2 files changed, 99 insertions(+), 123 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 0effcd1e..73193488 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -17,7 +17,7 @@ from homeassistant.helpers import device_registry, entity_component, entity_registry import homeassistant.helpers.config_validation as cv import voluptuous as vol -import websockets.connection +import websockets.protocol import websockets.server from ocpp.exceptions import NotImplementedError @@ -470,7 +470,7 @@ async def handle_set_charge_rate(call): for measurand in all_measurands.split(","): _LOGGER.debug(f"'{self.id}' trying measurand '{measurand}'") - req = call.ChangeConfigurationPayload(key=key, value=measurand) + req = call.ChangeConfiguration(key=key, value=measurand) resp = await self.call(req) if resp.status == ConfigurationStatus.accepted: _LOGGER.debug(f"'{self.id}' adding measurand '{measurand}'") @@ -567,7 +567,7 @@ async def handle_set_charge_rate(call): async def get_supported_features(self): """Get supported features.""" - req = call.GetConfigurationPayload(key=[ckey.supported_feature_profiles.value]) + req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) resp = await self.call(req) feature_list = (resp.configuration_key[0][om.value.value]).split(",") if feature_list[0] == "": @@ -603,9 +603,7 @@ async def get_supported_features(self): async def trigger_boot_notification(self): """Trigger a boot notification.""" - req = call.TriggerMessagePayload( - requested_message=MessageTrigger.boot_notification - ) + req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) resp = await self.call(req) if resp.status == TriggerMessageStatus.accepted: self.triggered_boot_notification = True @@ -621,7 +619,7 @@ async def trigger_status_notification(self): nof_connectors = int(self._metrics[cdet.connectors.value].value) for id in range(0, nof_connectors + 1): _LOGGER.debug(f"trigger status notification for connector={id}") - req = call.TriggerMessagePayload( + req = call.TriggerMessage( requested_message=MessageTrigger.status_notification, connector_id=int(id), ) @@ -633,7 +631,7 @@ async def trigger_status_notification(self): async def clear_profile(self): """Clear all charging profiles.""" - req = call.ClearChargingProfilePayload() + req = call.ClearChargingProfile() resp = await self.call(req) if resp.status == ClearChargingProfileStatus.accepted: return True @@ -653,7 +651,7 @@ async def set_charge_rate( ): """Set a charging profile with defined limit.""" if profile is not None: # assumes advanced user and correct profile format - req = call.SetChargingProfilePayload( + req = call.SetChargingProfile( connector_id=conn_id, cs_charging_profiles=profile ) resp = await self.call(req) @@ -685,7 +683,7 @@ async def set_charge_rate( ckey.charge_profile_max_stack_level.value ) stack_level = int(resp) - req = call.SetChargingProfilePayload( + req = call.SetChargingProfile( connector_id=conn_id, cs_charging_profiles={ om.charging_profile_id.value: 8, @@ -711,7 +709,7 @@ async def set_charge_rate( "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." ) # try a lower stack level for chargers where level < maximum, not <= - req = call.SetChargingProfilePayload( + req = call.SetChargingProfile( connector_id=conn_id, cs_charging_profiles={ om.charging_profile_id.value: 8, @@ -743,7 +741,7 @@ async def set_availability(self, state: bool = True): else: typ = AvailabilityType.inoperative.value - req = call.ChangeAvailabilityPayload(connector_id=0, type=typ) + req = call.ChangeAvailability(connector_id=0, type=typ) resp = await self.call(req) if resp.status == AvailabilityStatus.accepted: return True @@ -763,7 +761,7 @@ async def start_transaction(self): resp = await self.get_configuration(ckey.authorize_remote_tx_requests.value) if resp is True: await self.configure(ckey.authorize_remote_tx_requests.value, "false") - req = call.RemoteStartTransactionPayload( + req = call.RemoteStartTransaction( connector_id=1, id_tag=self._metrics[cdet.identifier.value].value[:20] ) resp = await self.call(req) @@ -785,9 +783,7 @@ async def stop_transaction(self): """ if self.active_transaction_id == 0: return True - req = call.RemoteStopTransactionPayload( - transaction_id=self.active_transaction_id - ) + req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -801,7 +797,7 @@ async def stop_transaction(self): async def reset(self, typ: str = ResetType.hard): """Hard reset charger unless soft reset requested.""" self._metrics[cstat.reconnects.value].value = 0 - req = call.ResetPayload(typ) + req = call.Reset(typ) resp = await self.call(req) if resp.status == ResetStatus.accepted: return True @@ -812,7 +808,7 @@ async def reset(self, typ: str = ResetType.hard): async def unlock(self, connector_id: int = 1): """Unlock charger if requested.""" - req = call.UnlockConnectorPayload(connector_id) + req = call.UnlockConnector(connector_id) resp = await self.call(req) if resp.status == UnlockStatus.unlocked: return True @@ -834,7 +830,7 @@ async def update_firmware(self, firmware_url: str, wait_time: int = 0): update_time = ( datetime.now(tz=timezone.utc) + timedelta(hours=wait_time) ).strftime("%Y-%m-%dT%H:%M:%SZ") - req = call.UpdateFirmwarePayload(location=url, retrieve_date=update_time) + req = call.UpdateFirmware(location=url, retrieve_date=update_time) resp = await self.call(req) _LOGGER.info("Response: %s", resp) return True @@ -850,7 +846,7 @@ async def get_diagnostics(self, upload_url: str): url = schema(upload_url) except vol.MultipleInvalid as e: _LOGGER.warning("Failed to parse url: %s", e) - req = call.GetDiagnosticsPayload(location=url) + req = call.GetDiagnostics(location=url) resp = await self.call(req) _LOGGER.info("Response: %s", resp) return True @@ -860,9 +856,7 @@ async def get_diagnostics(self, upload_url: str): async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): """Request vendor specific data transfer from charger.""" - req = call.DataTransferPayload( - vendor_id=vendor_id, message_id=message_id, data=data - ) + req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) resp = await self.call(req) if resp.status == DataTransferStatus.accepted: _LOGGER.info( @@ -887,9 +881,9 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = async def get_configuration(self, key: str = ""): """Get Configuration of charger for supported keys else return None.""" if key == "": - req = call.GetConfigurationPayload() + req = call.GetConfiguration() else: - req = call.GetConfigurationPayload(key=[key]) + req = call.GetConfiguration(key=[key]) resp = await self.call(req) if resp.configuration_key is not None: value = resp.configuration_key[0][om.value.value] @@ -914,7 +908,7 @@ async def configure(self, key: str, value: str): If the key has a different value a ChangeConfiguration request is issued. """ - req = call.GetConfigurationPayload(key=[key]) + req = call.GetConfiguration(key=[key]) resp = await self.call(req) @@ -933,7 +927,7 @@ async def configure(self, key: str, value: str): _LOGGER.warning("%s is a read only setting", key) await self.notify_ha(f"Warning: {key} is read-only") - req = call.ChangeConfigurationPayload(key=key, value=value) + req = call.ChangeConfiguration(key=key, value=value) resp = await self.call(req) @@ -1084,7 +1078,7 @@ async def async_update_device_info(self, boot_info: dict): ) def process_phases(self, data): - """Process phase data from meter values payload.""" + """Process phase data from meter values .""" def average_of_nonzero(values): nonzero_values: list = [v for v in values if float(v) != 0.0] @@ -1163,7 +1157,7 @@ def average_of_nonzero(values): self._metrics[metric].value = float(metric_value) self._metrics[metric].unit = metric_unit - @on(Action.MeterValues) + @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): """Request handler for MeterValues Calls.""" @@ -1286,12 +1280,12 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): cstat.id_tag.name ] = self._metrics[cstat.id_tag.value].value self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.MeterValuesPayload() + return call_result.MeterValues() - @on(Action.BootNotification) + @on(Action.boot_notification) def on_boot_notification(self, **kwargs): """Handle a boot notification.""" - resp = call_result.BootNotificationPayload( + resp = call_result.BootNotification( current_time=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), interval=3600, status=RegistrationStatus.accepted.value, @@ -1319,7 +1313,7 @@ def on_boot_notification(self, **kwargs): self.hass.async_create_task(self.post_connect()) return resp - @on(Action.StatusNotification) + @on(Action.status_notification) def on_status_notification(self, connector_id, error_code, status, **kwargs): """Handle a status notification.""" @@ -1353,26 +1347,26 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): if Measurand.power_reactive_export.value in self._metrics: self._metrics[Measurand.power_reactive_export.value].value = 0 self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StatusNotificationPayload() + return call_result.StatusNotification() - @on(Action.FirmwareStatusNotification) + @on(Action.firmware_status_notification) def on_firmware_status(self, status, **kwargs): """Handle firmware status notification.""" self._metrics[cstat.firmware_status.value].value = status self.hass.async_create_task(self.central.update(self.central.cpid)) self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) - return call_result.FirmwareStatusNotificationPayload() + return call_result.FirmwareStatusNotification() - @on(Action.DiagnosticsStatusNotification) + @on(Action.diagnostics_status_notification) def on_diagnostics_status(self, status, **kwargs): """Handle diagnostics status notification.""" _LOGGER.info("Diagnostics upload status: %s", status) self.hass.async_create_task( self.notify_ha(f"Diagnostics upload status: {status}") ) - return call_result.DiagnosticsStatusNotificationPayload() + return call_result.DiagnosticsStatusNotification() - @on(Action.SecurityEventNotification) + @on(Action.security_event_notification) def on_security_event(self, type, timestamp, **kwargs): """Handle security event notification.""" _LOGGER.info( @@ -1384,7 +1378,7 @@ def on_security_event(self, type, timestamp, **kwargs): self.hass.async_create_task( self.notify_ha(f"Security event notification received: {type}") ) - return call_result.SecurityEventNotificationPayload() + return call_result.SecurityEventNotification() def get_authorization_status(self, id_tag): """Get the authorization status for an id_tag.""" @@ -1415,14 +1409,14 @@ def get_authorization_status(self, id_tag): ) return auth_status - @on(Action.Authorize) + @on(Action.authorize) def on_authorize(self, id_tag, **kwargs): """Handle an Authorization request.""" self._metrics[cstat.id_tag.value].value = id_tag auth_status = self.get_authorization_status(id_tag) - return call_result.AuthorizePayload(id_tag_info={om.status.value: auth_status}) + return call_result.Authorize(id_tag_info={om.status.value: auth_status}) - @on(Action.StartTransaction) + @on(Action.start_transaction) def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): """Handle a Start Transaction request.""" @@ -1433,18 +1427,18 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): self._metrics[cstat.stop_reason.value].value = "" self._metrics[csess.transaction_id.value].value = self.active_transaction_id self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 - result = call_result.StartTransactionPayload( + result = call_result.StartTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, transaction_id=self.active_transaction_id, ) else: - result = call_result.StartTransactionPayload( + result = call_result.StartTransaction( id_tag_info={om.status.value: auth_status}, transaction_id=0 ) self.hass.async_create_task(self.central.update(self.central.cpid)) return result - @on(Action.StopTransaction) + @on(Action.stop_transaction) def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): """Stop the current transaction.""" @@ -1475,27 +1469,25 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): if Measurand.power_reactive_export.value in self._metrics: self._metrics[Measurand.power_reactive_export.value].value = 0 self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StopTransactionPayload( + return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} ) - @on(Action.DataTransfer) + @on(Action.data_transfer) def on_data_transfer(self, vendor_id, **kwargs): """Handle a Data transfer request.""" _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) self._metrics[cdet.data_transfer.value].value = datetime.now(tz=timezone.utc) self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} - return call_result.DataTransferPayload(status=DataTransferStatus.accepted.value) + return call_result.DataTransfer(status=DataTransferStatus.accepted.value) - @on(Action.Heartbeat) + @on(Action.heartbeat) def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" now = datetime.now(tz=timezone.utc) self._metrics[cstat.heartbeat.value].value = now self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.HeartbeatPayload( - current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ") - ) + return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) @property def supported_features(self) -> int: diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 059dd017..92abf626 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -529,7 +529,7 @@ def on_get_configuration(self, key, **kwargs): """Handle a get configuration requests.""" if key[0] == ConfigurationKey.supported_feature_profiles.value: if self.accept is True: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ { "key": key[0], @@ -539,7 +539,7 @@ def on_get_configuration(self, key, **kwargs): ] ) else: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ { "key": key[0], @@ -549,26 +549,26 @@ def on_get_configuration(self, key, **kwargs): ] ) if key[0] == ConfigurationKey.heartbeat_interval.value: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] ) if key[0] == ConfigurationKey.number_of_connectors.value: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "1"}] ) if key[0] == ConfigurationKey.web_socket_ping_interval.value: if self.accept is True: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ {"key": key[0], "readonly": False, "value": "60"} ] ) else: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( unknown_key=["WebSocketPingInterval"] ) if key[0] == ConfigurationKey.meter_values_sampled_data.value: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ { "key": key[0], @@ -579,38 +579,38 @@ def on_get_configuration(self, key, **kwargs): ) if key[0] == ConfigurationKey.meter_value_sample_interval.value: if self.accept is True: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ {"key": key[0], "readonly": False, "value": "60"} ] ) else: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] ) if ( key[0] == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value ): - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ {"key": key[0], "readonly": False, "value": "Current"} ] ) if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: if self.accept is True: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[ {"key": key[0], "readonly": False, "value": "false"} ] ) else: - return call_result.GetConfigurationPayload(unknown_key=[key[0]]) + return call_result.GetConfiguration(unknown_key=[key[0]]) if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] ) - return call_result.GetConfigurationPayload( + return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": ""}] ) @@ -618,108 +618,96 @@ def on_get_configuration(self, key, **kwargs): def on_change_configuration(self, **kwargs): """Handle a get configuration request.""" if self.accept is True: - return call_result.ChangeConfigurationPayload(ConfigurationStatus.accepted) + return call_result.ChangeConfiguration(ConfigurationStatus.accepted) else: - return call_result.ChangeConfigurationPayload(ConfigurationStatus.rejected) + return call_result.ChangeConfiguration(ConfigurationStatus.rejected) @on(Action.ChangeAvailability) def on_change_availability(self, **kwargs): """Handle change availability request.""" if self.accept is True: - return call_result.ChangeAvailabilityPayload(AvailabilityStatus.accepted) + return call_result.ChangeAvailability(AvailabilityStatus.accepted) else: - return call_result.ChangeAvailabilityPayload(AvailabilityStatus.rejected) + return call_result.ChangeAvailability(AvailabilityStatus.rejected) @on(Action.UnlockConnector) def on_unlock_connector(self, **kwargs): """Handle unlock request.""" if self.accept is True: - return call_result.UnlockConnectorPayload(UnlockStatus.unlocked) + return call_result.UnlockConnector(UnlockStatus.unlocked) else: - return call_result.UnlockConnectorPayload(UnlockStatus.unlock_failed) + return call_result.UnlockConnector(UnlockStatus.unlock_failed) @on(Action.Reset) def on_reset(self, **kwargs): """Handle change availability request.""" if self.accept is True: - return call_result.ResetPayload(ResetStatus.accepted) + return call_result.Reset(ResetStatus.accepted) else: - return call_result.ResetPayload(ResetStatus.rejected) + return call_result.Reset(ResetStatus.rejected) @on(Action.RemoteStartTransaction) def on_remote_start_transaction(self, **kwargs): """Handle remote start request.""" if self.accept is True: asyncio.create_task(self.send_start_transaction()) - return call_result.RemoteStartTransactionPayload( - RemoteStartStopStatus.accepted - ) + return call_result.RemoteStartTransaction(RemoteStartStopStatus.accepted) else: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.rejected - ) + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) @on(Action.RemoteStopTransaction) def on_remote_stop_transaction(self, **kwargs): """Handle remote stop request.""" if self.accept is True: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.accepted - ) + return call_result.RemoteStopTransaction(RemoteStartStopStatus.accepted) else: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.rejected - ) + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) @on(Action.SetChargingProfile) def on_set_charging_profile(self, **kwargs): """Handle set charging profile request.""" if self.accept is True: - return call_result.SetChargingProfilePayload(ChargingProfileStatus.accepted) + return call_result.SetChargingProfile(ChargingProfileStatus.accepted) else: - return call_result.SetChargingProfilePayload(ChargingProfileStatus.rejected) + return call_result.SetChargingProfile(ChargingProfileStatus.rejected) @on(Action.ClearChargingProfile) def on_clear_charging_profile(self, **kwargs): """Handle clear charging profile request.""" if self.accept is True: - return call_result.ClearChargingProfilePayload( - ClearChargingProfileStatus.accepted - ) + return call_result.ClearChargingProfile(ClearChargingProfileStatus.accepted) else: - return call_result.ClearChargingProfilePayload( - ClearChargingProfileStatus.unknown - ) + return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) @on(Action.TriggerMessage) def on_trigger_message(self, **kwargs): """Handle trigger message request.""" if self.accept is True: - return call_result.TriggerMessagePayload(TriggerMessageStatus.accepted) + return call_result.TriggerMessage(TriggerMessageStatus.accepted) else: - return call_result.TriggerMessagePayload(TriggerMessageStatus.rejected) + return call_result.TriggerMessage(TriggerMessageStatus.rejected) @on(Action.UpdateFirmware) def on_update_firmware(self, **kwargs): """Handle update firmware request.""" - return call_result.UpdateFirmwarePayload() + return call_result.UpdateFirmware() @on(Action.GetDiagnostics) def on_get_diagnostics(self, **kwargs): """Handle get diagnostics request.""" - return call_result.GetDiagnosticsPayload() + return call_result.GetDiagnostics() @on(Action.DataTransfer) def on_data_transfer(self, **kwargs): """Handle get data transfer request.""" if self.accept is True: - return call_result.DataTransferPayload(DataTransferStatus.accepted) + return call_result.DataTransfer(DataTransferStatus.accepted) else: - return call_result.DataTransferPayload(DataTransferStatus.rejected) + return call_result.DataTransfer(DataTransferStatus.rejected) async def send_boot_notification(self): """Send a boot notification.""" - request = call.BootNotificationPayload( + request = call.BootNotification( charge_point_model="Optimus", charge_point_vendor="The Mobility House" ) resp = await self.call(request) @@ -727,35 +715,31 @@ async def send_boot_notification(self): async def send_heartbeat(self): """Send a heartbeat.""" - request = call.HeartbeatPayload() + request = call.Heartbeat() resp = await self.call(request) assert len(resp.current_time) > 0 async def send_authorize(self): """Send an authorize request.""" - request = call.AuthorizePayload(id_tag="test_cp") + request = call.Authorize(id_tag="test_cp") resp = await self.call(request) assert resp.id_tag_info["status"] == AuthorizationStatus.accepted async def send_firmware_status(self): """Send a firmware status notification.""" - request = call.FirmwareStatusNotificationPayload( - status=FirmwareStatus.downloaded - ) + request = call.FirmwareStatusNotification(status=FirmwareStatus.downloaded) resp = await self.call(request) assert resp is not None async def send_diagnostics_status(self): """Send a diagnostics status notification.""" - request = call.DiagnosticsStatusNotificationPayload( - status=DiagnosticsStatus.uploaded - ) + request = call.DiagnosticsStatusNotification(status=DiagnosticsStatus.uploaded) resp = await self.call(request) assert resp is not None async def send_data_transfer(self): """Send a data transfer.""" - request = call.DataTransferPayload( + request = call.DataTransfer( vendor_id="The Mobility House", message_id="Test123", data="Test data transfer", @@ -765,7 +749,7 @@ async def send_data_transfer(self): async def send_start_transaction(self, meter_start: int = 12345): """Send a start transaction notification.""" - request = call.StartTransactionPayload( + request = call.StartTransaction( connector_id=1, id_tag="test_cp", meter_start=meter_start, @@ -777,7 +761,7 @@ async def send_start_transaction(self, meter_start: int = 12345): async def send_status_notification(self): """Send a status notification.""" - request = call.StatusNotificationPayload( + request = call.StatusNotification( connector_id=0, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.suspended_ev, @@ -787,7 +771,7 @@ async def send_status_notification(self): vendor_error_code="Test error", ) resp = await self.call(request) - request = call.StatusNotificationPayload( + request = call.StatusNotification( connector_id=1, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.charging, @@ -797,7 +781,7 @@ async def send_status_notification(self): vendor_error_code="Test error", ) resp = await self.call(request) - request = call.StatusNotificationPayload( + request = call.StatusNotification( connector_id=2, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.available, @@ -816,7 +800,7 @@ async def send_meter_periodic_data(self): while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) n += 1 - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, transaction_id=self.active_transactionId, meter_value=[ @@ -960,7 +944,7 @@ async def send_meter_line_voltage(self): """Send line voltages.""" while self.active_transactionId == 0: await asyncio.sleep(1) - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, transaction_id=self.active_transactionId, meter_value=[ @@ -1002,7 +986,7 @@ async def send_meter_err_phases(self): """Send erroneous voltage phase.""" while self.active_transactionId == 0: await asyncio.sleep(1) - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, transaction_id=self.active_transactionId, meter_value=[ @@ -1036,7 +1020,7 @@ async def send_meter_energy_kwh(self): """Send periodic energy meter value with kWh unit.""" while self.active_transactionId == 0: await asyncio.sleep(1) - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, transaction_id=self.active_transactionId, meter_value=[ @@ -1061,7 +1045,7 @@ async def send_main_meter_clock_data(self): """Send periodic main meter value. Main meter values dont have transaction_id.""" while self.active_transactionId == 0: await asyncio.sleep(1) - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, meter_value=[ { @@ -1084,7 +1068,7 @@ async def send_main_meter_clock_data(self): async def send_meter_clock_data(self): """Send periodic meter data notification.""" self.active_transactionId = 0 - request = call.MeterValuesPayload( + request = call.MeterValues( connector_id=1, transaction_id=self.active_transactionId, meter_value=[ @@ -1135,7 +1119,7 @@ async def send_stop_transaction(self, delay: int = 0): while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) n += 1 - request = call.StopTransactionPayload( + request = call.StopTransaction( meter_stop=54321, timestamp=datetime.now(tz=timezone.utc).isoformat(), transaction_id=self.active_transactionId, @@ -1147,7 +1131,7 @@ async def send_stop_transaction(self, delay: int = 0): async def send_security_event(self): """Send a security event notification.""" - request = call.SecurityEventNotificationPayload( + request = call.SecurityEventNotification( type="SettingSystemTime", timestamp="2022-09-29T20:58:29Z", tech_info="BootNotification", From fee06daf2eae373bfbdacbb78aa2ff8552b88ea2 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 17:34:09 +0200 Subject: [PATCH 103/370] remove unused exception.py (#1170) --- custom_components/ocpp/exception.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 custom_components/ocpp/exception.py diff --git a/custom_components/ocpp/exception.py b/custom_components/ocpp/exception.py deleted file mode 100644 index 7b9b6130..00000000 --- a/custom_components/ocpp/exception.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Implement Exceptions.""" - - -class ConfigurationError(Exception): - """Error used to signal a error while configuring the charger.""" - - pass From 4903c4832dd81a0fc505a5ae931227a8755eaf6d Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 18:35:09 +0200 Subject: [PATCH 104/370] Update requirements.txt (#1172) use a docutils version that is supported by myst-parser and sphynx --- docs/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6e82e9d6..a5a216f7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==3.0.1 -docutils==0.20.1 +docutils==0.18.1 Jinja2==3.1.4 sphinx==6.2.1 -sphinx_rtd_theme==2.0.0 \ No newline at end of file +sphinx_rtd_theme==2.0.0 From 52465adbdc58fa42749a5e62627880f2d707fc9c Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 18:54:34 +0200 Subject: [PATCH 105/370] add simpson & partnets (#1173) --- docs/supported-devices.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 0c5bc08d..ebabe20d 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -25,14 +25,17 @@ match transactions and it won't report some meter values such as session time. ## [EVLink Wallbox Plus](https://www.se.com/ww/en/product/EVH3S22P0CK/evlink-wallbox-plus---t2-attached-cable---3-phase---32a-22kw/) -## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) +## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) (Ability to configure a custom OCPP server such as HA is being discontinued) ## [Garo Entity Pro](https://www.garo.se/en/professional/products/e-mobility/wallbox/entity-pro/wallbox-entity-pro-22-sigi-o) ## [MaXpeedingrods Ev Charger](https://www.maxpeedingrods.com/category/ev-charger.html) -## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) +## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) +All basic functions work properly + +## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) (has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) ## [V2C Trydan](https://v2charge.com/trydan) @@ -40,7 +43,7 @@ match transactions and it won't report some meter values such as session time. ## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) ## [Wallbox Pulsar](https://wallbox.com/en_uk/wallbox-pulsar) -The Wallbox Pulsar Max has been verified. +The Wallbox Pulsar Max has been verified. In the OCPP-config, leave the password field empty. ## Others From 1dc7f38e2cd8053bc7676152e559fa5db165d88d Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 19:05:50 +0200 Subject: [PATCH 106/370] Update supported-devices.md (#1174) add Wallbox Copper SB --- docs/supported-devices.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index ebabe20d..a2e5eec2 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -42,8 +42,8 @@ All basic functions work properly ## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) -## [Wallbox Pulsar](https://wallbox.com/en_uk/wallbox-pulsar) -The Wallbox Pulsar Max has been verified. +## [Wallbox Pulsar & Copper SB](https://wallbox.com/en_uk/wallbox-pulsar) +The Wallbox Pulsar and Copper SB have been verified. In the OCPP-config, leave the password field empty. ## Others From 7df61cec1cc55b5f75714db8453cd360d7879751 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 20:03:24 +0200 Subject: [PATCH 107/370] Update tests.yaml (#1176) use python 3.12.1 --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index cb58bcf6..44a52078 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ on: pull_request: env: - DEFAULT_PYTHON: "3.11.6" + DEFAULT_PYTHON: "3.12.3" jobs: pre-commit: From ab701b40041a43f0642ceea3110aa638fc27d440 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 20:35:31 +0200 Subject: [PATCH 108/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.108 to 0.13.123 (#1171) * build(deps): bump pytest-homeassistant-custom-component Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.108 to 0.13.123. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.108...0.13.123) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Update tests.yaml workaround for codecov token issue --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/tests.yaml | 1 - requirements.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 44a52078..e885930c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -90,5 +90,4 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true verbose: true diff --git a/requirements.txt b/requirements.txt index c7748dda..e396d599 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 pre-commit -pytest-homeassistant-custom-component==0.13.108 +pytest-homeassistant-custom-component==0.13.123 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From a93583857cde38f8fb312df1a683680311ffa725 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 20:44:34 +0200 Subject: [PATCH 109/370] build(deps): bump sphinx from 6.2.1 to 7.3.7 (#1138) * build(deps): bump sphinx from 6.2.1 to 7.3.7 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 6.2.1 to 7.3.7. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v6.2.1...v7.3.7) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Update requirements.txt bump sphinx to 7.1.2 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index a5a216f7..c4fbbdb7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==3.0.1 docutils==0.18.1 Jinja2==3.1.4 -sphinx==6.2.1 +sphinx==7.1.2 sphinx_rtd_theme==2.0.0 From 88a1926c822d9b5208083c72f3bcc6f6d60bafd7 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 21:39:09 +0200 Subject: [PATCH 110/370] remove unused switch property current_power_w (#1177) * Update switch.py remove unused property current_power_w * remove umport --- custom_components/ocpp/switch.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index b1be1615..7f613946 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -12,7 +12,7 @@ from homeassistant.const import UnitOfPower from homeassistant.helpers.entity import DeviceInfo -from ocpp.v16.enums import ChargePointStatus, Measurand +from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem from .const import CONF_CPID, DEFAULT_CPID, DOMAIN, ICON @@ -140,20 +140,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: self.cp_id, self.entity_description.off_action ) self._state = not resp - - @property - def current_power_w(self) -> Any: - """Return the current power usage in W.""" - if self.entity_description.key == "charge_control": - value = self.central_system.get_metric( - self.cp_id, Measurand.power_active_import.value - ) - if ( - self.central_system.get_ha_unit( - self.cp_id, Measurand.power_active_import.value - ) - == POWER_KILO_WATT - ): - value = value * 1000 - return value - return None From e4c72c8b2482f81bdc84a307b0456c6196251323 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 17 May 2024 22:17:13 +0200 Subject: [PATCH 111/370] reintroduce exception.py (#1179) --- custom_components/ocpp/exception.py | 1 + setup.cfg | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 custom_components/ocpp/exception.py diff --git a/custom_components/ocpp/exception.py b/custom_components/ocpp/exception.py new file mode 100644 index 00000000..fe2cb5d8 --- /dev/null +++ b/custom_components/ocpp/exception.py @@ -0,0 +1 @@ +""" This file is imported by home assistant, and can be used to define custom exceptions.""" diff --git a/setup.cfg b/setup.cfg index edc1a331..89281064 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,12 +3,14 @@ exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build doctests = True # To work with Black max-line-length = 88 +# D210 No whitespaces allowed surrounding docstring text # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = + D210, E501, W503, E203, From 127950b649408dac7809fb7a73e7eb5348092318 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 18 May 2024 08:21:09 +1200 Subject: [PATCH 112/370] Update dep versions (#1178) --- custom_components/ocpp/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 96cb6447..0da0793e 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -13,8 +13,8 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp>=0.20.0", - "websockets>=10.2" + "ocpp>=1.0.0", + "websockets>=12.0" ], "version": "0.4.42" } From 96e9a64f7378d8f71f0178a382a0290853987128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 22:55:28 +0200 Subject: [PATCH 113/370] build(deps): bump pytest-homeassistant-custom-component (#1186) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e396d599..48a39980 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 pre-commit -pytest-homeassistant-custom-component==0.13.123 +pytest-homeassistant-custom-component==0.13.125 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 036443400ae352658c43372c7f32706713314781 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 22:55:58 +0200 Subject: [PATCH 114/370] build(deps): bump ruff from 0.4.4 to 0.4.5 (#1183) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 48a39980..5df61e65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.4.4 +ruff==0.4.5 ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 From db6f07cd398d6a05a5970646d30f02bb9862ef88 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Fri, 7 Jun 2024 00:23:54 +0200 Subject: [PATCH 115/370] fix: requirements.txt to reduce vulnerabilities (#1182) From 5ca98e528b76d65cc85f1348f3557c3df1b064b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 00:24:09 +0200 Subject: [PATCH 116/370] build(deps): bump pytest-homeassistant-custom-component (#1197) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5df61e65..0464615d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 pre-commit -pytest-homeassistant-custom-component==0.13.125 +pytest-homeassistant-custom-component==0.13.132 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From 27402bf06157364eeba72a15ab500cabe21c5706 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Fri, 7 Jun 2024 10:36:04 +1200 Subject: [PATCH 117/370] Fix blocking warning (#1198) --- custom_components/ocpp/api.py | 2 +- tests/test_charge_point.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 73193488..b7fbe8bb 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1002,7 +1002,7 @@ async def monitor_connection(self): async def _handle_call(self, msg): try: - await super()._handle_call(msg) + await self.hass.async_create_task(super()._handle_call(msg)) except NotImplementedError as e: response = msg.create_call_error(e).to_json() await self._send(response) diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 92abf626..ab23fe34 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -183,7 +183,7 @@ async def test_services(hass, socket_enabled): cp2.send_stop_transaction(), cp2.send_meter_periodic_data(), ), - timeout=5, + timeout=10, ) except asyncio.TimeoutError: pass From 9bb1adb61b75ecf78f5dbe14d5b191545ac1e23f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 00:44:11 +0200 Subject: [PATCH 118/370] build(deps): bump ruff from 0.4.5 to 0.4.8 (#1196) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0464615d..b5d419e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.4.5 +ruff==0.4.8 ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 From 79a47d1fa9acd991dabf0f9c551f3db929ca7f03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 00:44:22 +0200 Subject: [PATCH 119/370] build(deps): bump reorder-python-imports in /.github/workflows (#1190) --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index c4e0d1fb..20d11db3 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -7,5 +7,5 @@ flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.15.2 -reorder-python-imports==3.12.0 +reorder-python-imports==3.13.0 sqlalchemy>=1.4.23 From 440531f5600956b36f7742f2c9cdc196cad2b774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:50:26 +0200 Subject: [PATCH 120/370] build(deps): bump ruff from 0.4.8 to 0.4.10 (#1213) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b5d419e1..524249b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.4.8 +ruff==0.4.10 ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 From b0557d4c0e1eaf8333948c5c639a61f32a7d0c79 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Tue, 25 Jun 2024 03:50:57 +1200 Subject: [PATCH 121/370] Revert "Fix blocking warning (#1198)" (#1218) --- custom_components/ocpp/api.py | 2 +- tests/test_charge_point.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index b7fbe8bb..73193488 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1002,7 +1002,7 @@ async def monitor_connection(self): async def _handle_call(self, msg): try: - await self.hass.async_create_task(super()._handle_call(msg)) + await super()._handle_call(msg) except NotImplementedError as e: response = msg.create_call_error(e).to_json() await self._send(response) diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index ab23fe34..92abf626 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -183,7 +183,7 @@ async def test_services(hass, socket_enabled): cp2.send_stop_transaction(), cp2.send_meter_periodic_data(), ), - timeout=10, + timeout=5, ) except asyncio.TimeoutError: pass From 009f08f866bec99ca3776c9bc7e5affe76b4366c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:51:27 +0200 Subject: [PATCH 122/370] build(deps): bump pyupgrade from 3.15.2 to 3.16.0 in /.github/workflows (#1200) --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 20d11db3..b8a604ae 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -6,6 +6,6 @@ black==24.4.2 flake8==7.0.0 isort==5.13.2 pre-comit-hooks==4.1.0 -pyupgrade==3.15.2 +pyupgrade==3.16.0 reorder-python-imports==3.13.0 sqlalchemy>=1.4.23 From 831b48f4a3d52ad6912a2ae1676242cce1f77f9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:51:47 +0200 Subject: [PATCH 123/370] build(deps): bump bandit from 1.7.8 to 1.7.9 in /.github/workflows (#1204) --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index b8a604ae..0ff71b8c 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,7 +1,7 @@ # home assistant pip>=21.0,<24.1 pre-commit==3.7.1 -bandit==1.7.8 +bandit==1.7.9 black==24.4.2 flake8==7.0.0 isort==5.13.2 From 69a4188cb8b02f8bda356aa1a781542e418126ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:52:14 +0200 Subject: [PATCH 124/370] build(deps): bump flake8 from 7.0.0 to 7.1.0 in /.github/workflows (#1208) --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 0ff71b8c..bcf4a3c3 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -3,7 +3,7 @@ pip>=21.0,<24.1 pre-commit==3.7.1 bandit==1.7.9 black==24.4.2 -flake8==7.0.0 +flake8==7.1.0 isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.16.0 From e339ebbd147d086b0b318d32cd300de6d39cad54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20R=C3=BCcker?= Date: Mon, 24 Jun 2024 17:53:20 +0200 Subject: [PATCH 125/370] Update supported-devices.md (#1210) --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index a2e5eec2..51dff441 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -9,6 +9,8 @@ All OCPP 1.6j compatible devices should be supported, but not every device offer ## [ABB Terra AC-W22-T-0](https://new.abb.com/products/6AGC081279/tac-w22-t-0) +## [ABB Terra TAC-W22-T-RD-MC-0](https://new.abb.com/products/6AGC081281/tac-w22-t-rd-mc-0) + ## [Alfen - Eve Single Pro-line](https://alfen.com/en/ev-charge-points/alfen-product-range) ## [Alfen - Eve Single S-line](https://alfen.com/en/ev-charge-points/alfen-product-range) From 2ba4b552b378e7bb3788556b8f6359c4ea4dc250 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Wed, 26 Jun 2024 08:44:17 +1200 Subject: [PATCH 126/370] Improve measurand logic (#1217) * improve measurand logic * fix linting --- custom_components/ocpp/api.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 73193488..7527a088 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -464,33 +464,36 @@ async def handle_set_charge_rate(call): all_measurands = self.entry.data.get( CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND ) + key = ckey.meter_values_sampled_data.value + chgr_measurands = await self.get_configuration(key) accepted_measurands = [] - key = ckey.meter_values_sampled_data.value + cfg_ok = [ + ConfigurationStatus.accepted, + ConfigurationStatus.reboot_required, + ] for measurand in all_measurands.split(","): - _LOGGER.debug(f"'{self.id}' trying measurand '{measurand}'") + _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") req = call.ChangeConfiguration(key=key, value=measurand) resp = await self.call(req) - if resp.status == ConfigurationStatus.accepted: - _LOGGER.debug(f"'{self.id}' adding measurand '{measurand}'") + if resp.status in cfg_ok: + _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") accepted_measurands.append(measurand) accepted_measurands = ",".join(accepted_measurands) if len(accepted_measurands) > 0: - _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") + _LOGGER.debug( + f"'{self.id}' allowed measurands: '{accepted_measurands}'" + ) await self.configure( ckey.meter_values_sampled_data.value, accepted_measurands, ) else: - _LOGGER.debug(f"'{self.id}' measurands not configurable by OCPP") - resp = await self.get_configuration( - ckey.meter_values_sampled_data.value - ) - accepted_measurands = resp - _LOGGER.debug(f"'{self.id}' allowed measurands '{accepted_measurands}'") + _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") + _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") updated_entry = {**self.entry.data} updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands From 05f82ae9e00e015a02d82905d0c277991cb5221b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 22:47:45 +0200 Subject: [PATCH 127/370] build(deps): bump pytest-homeassistant-custom-component (#1219) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.132 to 0.13.136. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.132...0.13.136) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 524249b9..f6e2d9d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 pre-commit -pytest-homeassistant-custom-component==0.13.132 +pytest-homeassistant-custom-component==0.13.136 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From e3575a6d90a3a4ae5880c7fec4b961e2b9e1f8ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 22:53:08 +0200 Subject: [PATCH 128/370] build(deps): update pip requirement in /.github/workflows (#1214) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...24.1) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index bcf4a3c3..a6130f8a 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ # home assistant -pip>=21.0,<24.1 +pip>=21.0,<24.2 pre-commit==3.7.1 bandit==1.7.9 black==24.4.2 From 50e0e84dc4d3a6fdfa57353bfa4ba7be86ab14de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jul 2024 19:14:39 +0200 Subject: [PATCH 129/370] build(deps): bump ruff from 0.4.10 to 0.5.1 (#1239) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.10 to 0.5.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.4.10...0.5.1) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f6e2d9d1..49b4df79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.4.10 +ruff==0.5.1 ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 From 03816553f0db66b7ca51f8753f7c2a5ac9b3a57b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jul 2024 19:15:06 +0200 Subject: [PATCH 130/370] build(deps): bump actions/upload-artifact from 4.3.3 to 4.3.4 (#1238) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.3 to 4.3.4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.3...v4.3.4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a9340da8..e4e59c38 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.3.3 + uses: actions/upload-artifact@v4.3.4 if: ${{ github.event_name == 'push' }} with: name: ocpp From 4dc818d27ac20357c9a01f2a538c3ff6d5a13ce3 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Mon, 8 Jul 2024 09:31:49 +0200 Subject: [PATCH 131/370] improve unload robustness (#1240) --- custom_components/ocpp/__init__.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index cca7a513..0d97c5bb 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -98,15 +98,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - central_sys = hass.data[DOMAIN][entry.entry_id] - - central_sys._server.close() - await central_sys._server.wait_closed() - - unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) + unloaded = False + if DOMAIN in hass.data: + central_sys = hass.data[DOMAIN][entry.entry_id] + central_sys._server.close() + await central_sys._server.wait_closed() + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) return unloaded From f99a39a13b4bff8d979565d57b169129fff62393 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Mon, 8 Jul 2024 09:59:48 +0200 Subject: [PATCH 132/370] Improve unload robustness2 (#1241) * improve unload robustness * improve unload robustness --- custom_components/ocpp/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 0d97c5bb..08cd3e18 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -100,12 +100,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unloaded = False if DOMAIN in hass.data: - central_sys = hass.data[DOMAIN][entry.entry_id] - central_sys._server.close() - await central_sys._server.wait_closed() - unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) + if entry.entry_id in hass.data[DOMAIN]: + central_sys = hass.data[DOMAIN][entry.entry_id] + central_sys._server.close() + await central_sys._server.wait_closed() + unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) return unloaded From f77ae386aed8cd1814b57eaebe63af599922edf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:27:58 +0200 Subject: [PATCH 133/370] build(deps): bump ruff from 0.5.1 to 0.6.0 (#1274) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.5.1 to 0.6.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.5.1...0.6.0) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 49b4df79..1ae6276d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.1 -ruff==0.5.1 +ruff==0.6.0 ocpp==1.0.0 websockets==12.0 jsonschema==4.22.0 From 6cd31a2a6e98ac76b56faef577dd19d6c58aa655 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:28:25 +0200 Subject: [PATCH 134/370] build(deps): bump actions/upload-artifact from 4.3.4 to 4.3.6 (#1271) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.4 to 4.3.6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.4...v4.3.6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e4e59c38..40fb9ef2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.3.4 + uses: actions/upload-artifact@v4.3.6 if: ${{ github.event_name == 'push' }} with: name: ocpp From 8e66609a2d6ee84350277a67ffa6425cb0a3f387 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:28:51 +0200 Subject: [PATCH 135/370] build(deps): bump flake8 from 7.1.0 to 7.1.1 in /.github/workflows (#1269) Bumps [flake8](https://github.com/pycqa/flake8) from 7.1.0 to 7.1.1. - [Commits](https://github.com/pycqa/flake8/compare/7.1.0...7.1.1) --- updated-dependencies: - dependency-name: flake8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index a6130f8a..01514d8c 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -3,7 +3,7 @@ pip>=21.0,<24.2 pre-commit==3.7.1 bandit==1.7.9 black==24.4.2 -flake8==7.1.0 +flake8==7.1.1 isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.16.0 From af3a4e2039a2b3b8e4759ba6702fd74b26383b34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:30:13 +0200 Subject: [PATCH 136/370] build(deps): bump pyupgrade from 3.16.0 to 3.17.0 in /.github/workflows (#1260) Bumps [pyupgrade](https://github.com/asottile/pyupgrade) from 3.16.0 to 3.17.0. - [Commits](https://github.com/asottile/pyupgrade/compare/v3.16.0...v3.17.0) --- updated-dependencies: - dependency-name: pyupgrade dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 01514d8c..d4c8bd74 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -6,6 +6,6 @@ black==24.4.2 flake8==7.1.1 isort==5.13.2 pre-comit-hooks==4.1.0 -pyupgrade==3.16.0 +pyupgrade==3.17.0 reorder-python-imports==3.13.0 sqlalchemy>=1.4.23 From 78e8ffbef9ad0daca9b409d151c8e456938548a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:30:37 +0200 Subject: [PATCH 137/370] build(deps): bump actions/setup-python from 5.1.0 to 5.1.1 (#1247) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.1.0 to 5.1.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.1.0...v5.1.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 40fb9ef2..651ae5d8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.1.0 + uses: actions/setup-python@v5.1.1 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e885930c..2869c8ca 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.0 + uses: actions/setup-python@v5.1.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -67,7 +67,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.1.0" + uses: "actions/setup-python@v5.1.1" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 0e6f5063f7bf2c0d29ef4d5f09a2685c29aa2e52 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:32:57 +0200 Subject: [PATCH 138/370] fix: requirements.txt to reduce vulnerabilities (#1246) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-ZIPP-7430899 Co-authored-by: snyk-bot --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 1ae6276d..61a7065a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ jsonschema==4.22.0 pre-commit pytest-homeassistant-custom-component==0.13.136 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 03abb13213358356307f9dd0cd1a22ac53854789 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:33:22 +0200 Subject: [PATCH 139/370] build(deps): bump jsonschema from 4.22.0 to 4.23.0 (#1244) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.22.0 to 4.23.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.22.0...v4.23.0) --- updated-dependencies: - dependency-name: jsonschema dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61a7065a..89261153 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ pip>=21.0,<24.1 ruff==0.6.0 ocpp==1.0.0 websockets==12.0 -jsonschema==4.22.0 +jsonschema==4.23.0 pre-commit pytest-homeassistant-custom-component==0.13.136 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability From cd6d774f82db5cb229a30b0a7731a21cf6e0af2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:33:40 +0200 Subject: [PATCH 140/370] build(deps): bump black from 24.4.2 to 24.8.0 in /.github/workflows (#1268) Bumps [black](https://github.com/psf/black) from 24.4.2 to 24.8.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.4.2...24.8.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index d4c8bd74..960df636 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -2,7 +2,7 @@ pip>=21.0,<24.2 pre-commit==3.7.1 bandit==1.7.9 -black==24.4.2 +black==24.8.0 flake8==7.1.1 isort==5.13.2 pre-comit-hooks==4.1.0 From 1d3b1e9f72e2b77aef69d4024e82f5323f3753aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:33:49 +0200 Subject: [PATCH 141/370] build(deps): update pip requirement from <24.1,>=21.0 to >=21.0,<24.3 (#1257) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...24.2) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 89261153..7b01a88f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.8.2 -pip>=21.0,<24.1 +pip>=21.0,<24.3 ruff==0.6.0 ocpp==1.0.0 websockets==12.0 From 9305279f818d3f0044823a4a5d8da4b2a86e1818 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:38:04 +0200 Subject: [PATCH 142/370] fix: requirements.txt to reduce vulnerabilities (#1276) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-ZIPP-7430899 Co-authored-by: snyk-bot From 39eb6e33a0ac5d48d260d8006f32fdbb1ddc1c97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:38:49 +0200 Subject: [PATCH 143/370] build(deps): bump pre-commit from 3.7.1 to 3.8.0 in /.github/workflows (#1259) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.7.1 to 3.8.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.7.1...v3.8.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 960df636..a9879305 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,6 +1,6 @@ # home assistant pip>=21.0,<24.2 -pre-commit==3.7.1 +pre-commit==3.8.0 bandit==1.7.9 black==24.8.0 flake8==7.1.1 From 284f159e9c843b9be6d0d7eb94e8dca266714e74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 14:43:34 +0200 Subject: [PATCH 144/370] build(deps): update pip requirement in /.github/workflows (#1258) Updates the requirements on [pip](https://github.com/pypa/pip) to permit the latest version. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/21.0...24.2) --- updated-dependencies: - dependency-name: pip dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index a9879305..ac5c64bf 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ # home assistant -pip>=21.0,<24.2 +pip>=21.0,<24.3 pre-commit==3.8.0 bandit==1.7.9 black==24.8.0 From 36dd0464ff19103c7b95f99801b75b524a2cdb19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:09:28 +0200 Subject: [PATCH 145/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.136 to 0.13.144 (#1236) * build(deps): bump pytest-homeassistant-custom-component Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.136 to 0.13.144. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.136...0.13.144) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * improve reload (#1242) * improve reload * Update test_charge_point.py setup config entries through hass * Update test_charge_point.py remove unused import * Update test_charge_point.py use config_entries.async_remove * Update __init__.py reintroduce async_reload_entry * Update test_charge_point.py * Update __init__.py * Update test_charge_point.py * Update test_charge_point.py * Update test_charge_point.py * Update test_charge_point.py * Update test_charge_point.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_charge_point.py * Update test_init.py --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/__init__.py | 3 +-- requirements.txt | 2 +- tests/test_charge_point.py | 16 ++++++++-------- tests/test_init.py | 23 +++++++++-------------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 08cd3e18..fabf43f7 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -115,5 +115,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + await hass.config_entries.async_reload(entry.entry_id) diff --git a/requirements.txt b/requirements.txt index 7b01a88f..3e9e1154 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.136 +pytest-homeassistant-custom-component==0.13.144 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 92abf626..5d02216c 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -15,7 +15,6 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry import websockets -from custom_components.ocpp import async_setup_entry, async_unload_entry from custom_components.ocpp.button import BUTTONS from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs @@ -159,8 +158,7 @@ async def test_services(hass, socket_enabled): title="test_cms2", ) config_entry2.add_to_hass(hass) - - assert await async_setup_entry(hass, config_entry2) + assert await hass.config_entries.async_setup(config_entry2.entry_id) await hass.async_block_till_done() # no subprotocol @@ -189,15 +187,16 @@ async def test_services(hass, socket_enabled): pass await ws2.close() await asyncio.sleep(1) - await async_unload_entry(hass, config_entry2) - await hass.async_block_till_done() + if entry := hass.config_entries.async_get_entry(config_entry2.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms", title="test_cms" ) config_entry.add_to_hass(hass) - assert await async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() cs = hass.data[OCPP_DOMAIN][config_entry.entry_id] @@ -511,8 +510,9 @@ async def test_services(hass, socket_enabled): # test services when charger is unavailable await asyncio.sleep(1) await test_services(hass, socket_enabled) - await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() + if entry := hass.config_entries.async_get_entry(config_entry.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() class ChargePoint(cpclass): diff --git a/tests/test_init.py b/tests/test_init.py index 8a2f04e6..6753bd9b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -6,12 +6,7 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.ocpp import ( - CentralSystem, - async_reload_entry, - async_setup_entry, - async_unload_entry, -) +from custom_components.ocpp import CentralSystem from custom_components.ocpp.const import DOMAIN from .const import MOCK_CONFIG_DATA_1 @@ -30,26 +25,26 @@ async def test_setup_unload_and_reload_entry( config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG_DATA_1, entry_id="test_cms1", title="test_cms1" ) - # config_entry.add_to_hass(hass); - hass.config_entries._entries[config_entry.entry_id] = config_entry + config_entry.add_to_hass(hass) + await hass.async_block_till_done() # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data # call, no code from custom_components/ocpp/api.py actually runs. - assert await async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem # Reload the entry and assert that the data from above is still there - assert await async_reload_entry(hass, config_entry) is None + assert await hass.config_entries.async_reload(config_entry.entry_id) assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem # Unload the entry and verify that the data has been removed - unloaded = await async_unload_entry(hass, config_entry) - assert unloaded + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.entry_id not in hass.data[DOMAIN] From 901a19b67cf4dfc92d5ed825030005f9e8f5ca43 Mon Sep 17 00:00:00 2001 From: scruysberghs <58264733+scruysberghs@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:22:34 +0200 Subject: [PATCH 146/370] bump python version in devcontainer to 3.12 (#1267) --- .devcontainer.json | 4 ++-- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index f2f59a67..457e49d9 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "lbbrhzn/ocpp", - "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye", "onCreateCommand": "scripts/setup", "forwardPorts": [ 8123 @@ -38,4 +38,4 @@ "features": { "ghcr.io/devcontainers/features/rust:1": {} } -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3e9e1154..dd26c40f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.144 +pytest-homeassistant-custom-component==0.13.148 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 36f7e4fecda04ba6da7f633e80b542be3ecd9042 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Aug 2024 10:02:31 +0200 Subject: [PATCH 147/370] build(deps): bump pytest-homeassistant-custom-component (#1282) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.148 to 0.13.154. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.148...0.13.154) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dd26c40f..3e560d54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==12.0 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.148 +pytest-homeassistant-custom-component==0.13.154 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 76382a05d18e20c5642a0f76dc9fd38166569e08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Aug 2024 10:03:06 +0200 Subject: [PATCH 148/370] build(deps): bump ruff from 0.6.0 to 0.6.1 (#1280) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.0 to 0.6.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.0...0.6.1) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3e560d54..2dcc227e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.0 +ruff==0.6.1 ocpp==1.0.0 websockets==12.0 jsonschema==4.23.0 From 7365a24aa01bb2f599a6da08d858cdfd9ef3cd26 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Tue, 20 Aug 2024 20:04:56 +1200 Subject: [PATCH 149/370] Improve get feature error handling (#1283) * Improve get feature error handling * Add back blank list test * fix error * Update test for index error * fix linting * Chnage test return to dict * Change test to use unknown key * Add TypeError catch * Fix linting * Add KeyError also --- custom_components/ocpp/api.py | 5 ++++- tests/test_charge_point.py | 9 ++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 7527a088..9b3daaaf 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -572,7 +572,10 @@ async def get_supported_features(self): """Get supported features.""" req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) resp = await self.call(req) - feature_list = (resp.configuration_key[0][om.value.value]).split(",") + try: + feature_list = (resp.configuration_key[0][om.value.value]).split(",") + except (IndexError, KeyError, TypeError): + feature_list = [""] if feature_list[0] == "": _LOGGER.warning("No feature profiles detected, defaulting to Core") await self.notify_ha("No feature profiles detected, defaulting to Core") diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 5d02216c..ca48448f 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -539,14 +539,9 @@ def on_get_configuration(self, key, **kwargs): ] ) else: + # use to test TypeError handling return call_result.GetConfiguration( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "", - } - ] + unknown_key=["SupportedFeatureProfiles"] ) if key[0] == ConfigurationKey.heartbeat_interval.value: return call_result.GetConfiguration( From 4fb7503d217c4984b3273a259e1b58c48eb22558 Mon Sep 17 00:00:00 2001 From: Matti Laakso Date: Wed, 21 Aug 2024 21:57:54 +0300 Subject: [PATCH 150/370] Workaround missing charge rate unit type in some chargers (#1278) * Workaround missing charge rate unit type in some chargers At least Teison chargers return unknown key when getting supported charge rate unit types. In this case default to ampere. * Refactor and update tests --------- Co-authored-by: Matti Laakso --- custom_components/ocpp/api.py | 4 ++++ tests/test_charge_point.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 9b3daaaf..70825317 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -679,6 +679,10 @@ async def set_charge_rate( resp, ) _LOGGER.info("If more than one unit supported default unit is Amps") + # Some chargers (e.g. Teison) don't support querying charging rate unit + if resp is None: + _LOGGER.warning("Failed to query charging rate unit, assuming Amps") + resp = om.current.value if om.current.value in resp: lim = limit_amps units = ChargingRateUnitType.amps.value diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index ca48448f..224803e2 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -587,11 +587,14 @@ def on_get_configuration(self, key, **kwargs): key[0] == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value ): - return call_result.GetConfiguration( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "Current"} - ] - ) + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "Current"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: if self.accept is True: return call_result.GetConfiguration( From 4c6f266c4a067eeee8559679e145f7f4773566b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 08:49:43 +0200 Subject: [PATCH 151/370] build(deps): bump websockets from 12.0 to 13.0 (#1285) Bumps [websockets](https://github.com/python-websockets/websockets) from 12.0 to 13.0. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/12.0...13.0) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2dcc227e..4a458c5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.8.2 pip>=21.0,<24.3 ruff==0.6.1 ocpp==1.0.0 -websockets==12.0 +websockets==13.0 jsonschema==4.23.0 pre-commit pytest-homeassistant-custom-component==0.13.154 From 29d60f6a5926912053cfbcf66b34703c009fd182 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 23:34:52 +0200 Subject: [PATCH 152/370] build(deps): bump ruff from 0.6.1 to 0.6.3 (#1297) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4a458c5d..77e9d260 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.1 +ruff==0.6.3 ocpp==1.0.0 websockets==13.0 jsonschema==4.23.0 From 03ad261e94f9b1b2b2d64572e9846d27c80fb7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 23:36:04 +0200 Subject: [PATCH 153/370] build(deps): bump actions/setup-python from 5.1.1 to 5.2.0 (#1294) --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 651ae5d8..aea2291e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2869c8ca..2d6d6aa9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -67,7 +67,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.1.1" + uses: "actions/setup-python@v5.2.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From d8f4b7e4c2c0a8d9f80ac3a35b3f3a1c77a39bf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 23:36:56 +0200 Subject: [PATCH 154/370] build(deps): bump pytest-homeassistant-custom-component (#1296) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 77e9d260..74558444 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.0 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.154 +pytest-homeassistant-custom-component==0.13.156 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 14dec5368d44ca039aa6ac9be9f5501ecfbf3fcc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Aug 2024 10:04:12 +0200 Subject: [PATCH 155/370] build(deps): bump websockets from 13.0 to 13.0.1 (#1295) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74558444..08cddf3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.8.2 pip>=21.0,<24.3 ruff==0.6.3 ocpp==1.0.0 -websockets==13.0 +websockets==13.0.1 jsonschema==4.23.0 pre-commit pytest-homeassistant-custom-component==0.13.156 From 73ac441497aaaac2cdabffe6ef96bd9bdbc14452 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 31 Aug 2024 20:05:12 +1200 Subject: [PATCH 156/370] add try except for getting measurands (#1290) --- custom_components/ocpp/api.py | 13 ++++++++----- tests/test_charge_point.py | 27 +++++++++++++-------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 70825317..52629889 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -465,7 +465,13 @@ async def handle_set_charge_rate(call): CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND ) key = ckey.meter_values_sampled_data.value - chgr_measurands = await self.get_configuration(key) + try: + chgr_measurands = await self.get_configuration(key) + except Exception: + _LOGGER.debug( + f"'{self.id}' had error while returning measurands, ignoring" + ) + chgr_measurands = all_measurands accepted_measurands = [] cfg_ok = [ @@ -487,10 +493,7 @@ async def handle_set_charge_rate(call): _LOGGER.debug( f"'{self.id}' allowed measurands: '{accepted_measurands}'" ) - await self.configure( - ckey.meter_values_sampled_data.value, - accepted_measurands, - ) + await self.configure(key, accepted_measurands) else: _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 224803e2..f67df6f3 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -540,9 +540,7 @@ def on_get_configuration(self, key, **kwargs): ) else: # use to test TypeError handling - return call_result.GetConfiguration( - unknown_key=["SupportedFeatureProfiles"] - ) + return call_result.GetConfiguration(unknown_key=[key[0]]) if key[0] == ConfigurationKey.heartbeat_interval.value: return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] @@ -559,19 +557,20 @@ def on_get_configuration(self, key, **kwargs): ] ) else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ConfigurationKey.meter_values_sampled_data.value: + if self.accept is True: return call_result.GetConfiguration( - unknown_key=["WebSocketPingInterval"] + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Energy.Active.Import.Register", + } + ] ) - if key[0] == ConfigurationKey.meter_values_sampled_data.value: - return call_result.GetConfiguration( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "Energy.Active.Import.Register", - } - ] - ) + else: + raise Exception if key[0] == ConfigurationKey.meter_value_sample_interval.value: if self.accept is True: return call_result.GetConfiguration( From a268a86df343329d237bcddf81644a82dddd2f51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Aug 2024 11:06:01 +0200 Subject: [PATCH 157/370] build(deps): bump pytest-homeassistant-custom-component (#1299) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 08cddf3a..45639d6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.156 +pytest-homeassistant-custom-component==0.13.158 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 90ff332a6f30fe5ecd57c63c93fee4da2e4fd02e Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 8 Sep 2024 20:38:42 +1200 Subject: [PATCH 158/370] fix missing n phase (#1312) --- custom_components/ocpp/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 52629889..13334069 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1117,7 +1117,7 @@ def average_of_nonzero(values): self._metrics[measurand].extra_attr[phase] = float(value) self._metrics[measurand].extra_attr[om.context.value] = context - line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value] + line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] From 1d3e5fe7d880715b3531e57ccc955f231e6b8e03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Sep 2024 10:40:47 +0200 Subject: [PATCH 159/370] build(deps): bump ruff from 0.6.3 to 0.6.4 (#1309) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.3 to 0.6.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.3...0.6.4) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45639d6b..9fc4ccd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.3 +ruff==0.6.4 ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 From 93b0cc8bc2f912bbab80570bcd6d8be6a390942a Mon Sep 17 00:00:00 2001 From: rinigus Date: Sun, 8 Sep 2024 11:42:08 +0300 Subject: [PATCH 160/370] Generate random tagId used to start transaction from UI (#1302) Instead of disabling authorization for start_transaction initiated via HA user, use a random generated tag and authorize it to start transaction --- custom_components/ocpp/api.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 13334069..81c87aec 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -7,7 +7,9 @@ import json import logging from math import sqrt +import secrets import ssl +import string import time from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN @@ -378,6 +380,8 @@ def __init__( self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value self._attr_supported_features = prof.NONE self._metrics[cstat.reconnects.value].value: int = 0 + alphabet = string.ascii_uppercase + string.digits + self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) async def post_connect(self): """Logic to be executed right after a charger connects.""" @@ -766,17 +770,9 @@ async def set_availability(self, state: bool = True): return False async def start_transaction(self): - """ - Remote start a transaction. - - Check if authorisation enabled, if it is disable it before remote start - """ - resp = await self.get_configuration(ckey.authorize_remote_tx_requests.value) - if resp is True: - await self.configure(ckey.authorize_remote_tx_requests.value, "false") - req = call.RemoteStartTransaction( - connector_id=1, id_tag=self._metrics[cdet.identifier.value].value[:20] - ) + """Remote start a transaction.""" + _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) + req = call.RemoteStartTransaction(connector_id=1, id_tag=self._remote_id_tag) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -1395,6 +1391,9 @@ def on_security_event(self, type, timestamp, **kwargs): def get_authorization_status(self, id_tag): """Get the authorization status for an id_tag.""" + # authorize if its the tag of this charger used for remote start_transaction + if id_tag == self._remote_id_tag: + return AuthorizationStatus.accepted.value # get the domain wide configuration config = self.hass.data[DOMAIN].get(CONFIG, {}) # get the default authorization status. Use accept if not configured From 334c8da4ccbd7b1f95af5c81e9e20d880ca5f025 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Sep 2024 10:42:38 +0200 Subject: [PATCH 161/370] build(deps): bump pytest-homeassistant-custom-component (#1307) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.158 to 0.13.161. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.158...0.13.161) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9fc4ccd8..ade92327 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.158 +pytest-homeassistant-custom-component==0.13.161 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From f2beba891afd256561f9a1169ef4b593167f9422 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Sep 2024 10:43:07 +0200 Subject: [PATCH 162/370] build(deps): bump actions/upload-artifact from 4.3.6 to 4.4.0 (#1301) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.6 to 4.4.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.6...v4.4.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aea2291e..97d7601d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From 51ae7a9090e5a464f32a38f3fff230c6d813d154 Mon Sep 17 00:00:00 2001 From: Matti Laakso Date: Sun, 8 Sep 2024 11:43:46 +0300 Subject: [PATCH 163/370] Check for empty list in GetConfiguration response (#1300) Spec seems to allow for empty list in GetConfiguration response in addition to the list being absent. --- custom_components/ocpp/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 81c87aec..60b026e8 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -894,7 +894,7 @@ async def get_configuration(self, key: str = ""): else: req = call.GetConfiguration(key=[key]) resp = await self.call(req) - if resp.configuration_key is not None: + if resp.configuration_key: value = resp.configuration_key[0][om.value.value] _LOGGER.debug("Get Configuration for %s: %s", key, value) self._metrics[cdet.config_response.value].value = datetime.now( @@ -902,7 +902,7 @@ async def get_configuration(self, key: str = ""): ) self._metrics[cdet.config_response.value].extra_attr = {key: value} return value - if resp.unknown_key is not None: + if resp.unknown_key: _LOGGER.warning("Get Configuration returned unknown key for: %s", key) await self.notify_ha(f"Warning: charger reports {key} is unknown") return None From 4b06be705dca9ff767a626a8f89be5ec9a789503 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 8 Sep 2024 20:55:46 +1200 Subject: [PATCH 164/370] Set branch coverage to false (#1313) * Set branch coverage to false * amend setup.cfg to 95% --- .coveragerc | 2 +- setup.cfg | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.coveragerc b/.coveragerc index 5d9a60f6..27ff4dfc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -branch = True +branch = False source = custom_components/ocpp [report] diff --git a/setup.cfg b/setup.cfg index 89281064..ab569cf7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,9 +39,6 @@ addopts = -qq --cov=custom_components.ocpp --allow-unix-socket --allow-hosts=127 console_output_style = count asyncio_mode = auto -[coverage:run] -branch = False - [coverage:report] show_missing = true -fail_under = 90 \ No newline at end of file +fail_under = 95 \ No newline at end of file From 5bd31f7d839f3d0b4e5e25793f3e774ecd0332a2 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:43:47 +0200 Subject: [PATCH 165/370] use ruff in pre-commit for linting and formatting (#1314) * ruff fixes * fix linting SIM105 * update ruff taget verdion to py312 * enable ruff in vs code * remove black/flake/etc --- .github/workflows/constraints.txt | 5 -- .pre-commit-config.yaml | 43 +++------------- .ruff.toml | 13 ++--- .vscode/settings.json | 5 +- custom_components/ocpp/api.py | 48 ++++++++--------- custom_components/ocpp/button.py | 1 + custom_components/ocpp/config_flow.py | 1 + custom_components/ocpp/const.py | 1 + custom_components/ocpp/enums.py | 1 + custom_components/ocpp/exception.py | 2 +- custom_components/ocpp/number.py | 1 + custom_components/ocpp/sensor.py | 3 +- custom_components/ocpp/switch.py | 1 + docs/conf.py | 2 +- manage/update_manifest.py | 1 + tests/conftest.py | 17 +++--- tests/const.py | 1 + tests/test_charge_point.py | 74 ++++++++++----------------- tests/test_config_flow.py | 16 +++--- tests/test_init.py | 3 +- 20 files changed, 102 insertions(+), 137 deletions(-) diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index ac5c64bf..646c8a93 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,11 +1,6 @@ # home assistant pip>=21.0,<24.3 pre-commit==3.8.0 -bandit==1.7.9 -black==24.8.0 -flake8==7.1.1 -isort==5.13.2 pre-comit-hooks==4.1.0 pyupgrade==3.17.0 -reorder-python-imports==3.13.0 sqlalchemy>=1.4.23 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a81bd6c5..e658f46a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,44 +4,13 @@ repos: hooks: - id: pyupgrade args: [--py37-plus] - - repo: https://github.com/psf/black - rev: 23.11.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.4 hooks: - - id: black - args: - - --safe - - --quiet - files: ^((custom_components|homeassistant|script|tests)/.+)?[^/]+\.py$ - - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 - hooks: - - id: codespell - args: - - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - - --skip="./.*,*.csv,*.json" - - --quiet-level=2 - exclude_types: [csv, json] - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.0.2 - files: ^(custom_components|homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=bandit.yaml - files: ^(custom_components|homeassistant|script|tests|)/.+\.py$ - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: diff --git a/.ruff.toml b/.ruff.toml index 7a8331a3..3f7141ff 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,8 +1,8 @@ # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml -target-version = "py310" +target-version = "py312" -select = [ +lint.select = [ "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "C", # complexity @@ -26,7 +26,8 @@ select = [ "W", # pycodestyle ] -ignore = [ +lint.ignore = [ + "C901", # function too complex "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line @@ -38,11 +39,11 @@ ignore = [ "E731", # do not assign a lambda expression, use a def ] -[flake8-pytest-style] +[lint.flake8-pytest-style] fixture-parentheses = false -[pyupgrade] +[lint.pyupgrade] keep-runtime-typing = true -[mccabe] +[lint.mccabe] max-complexity = 25 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 531dc634..fbfb5bf6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,9 @@ { - "python.linting.pylintEnabled": true, "python.linting.enabled": true, + "python.linting.enabledWithoutWorkspace": true, + "python.linting.lintOnSave": true, + "python.linting.pylintEnabled": true, + "python.linting.ruffEnabled": true, "python.pythonPath": "/usr/local/bin/python", "python.analysis.extraPaths": [ "/usr/local/lib/python3.9/site-packages" diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 60b026e8..91d953c5 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1,9 +1,10 @@ """Representation of a OCCP Entities.""" + from __future__ import annotations import asyncio from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, UTC import json import logging from math import sqrt @@ -784,8 +785,7 @@ async def start_transaction(self): return False async def stop_transaction(self): - """ - Request remote stop of current transaction. + """Request remote stop of current transaction. Leaves charger in finishing state until unplugged. Use reset() to make the charger available again for remote start @@ -836,9 +836,9 @@ async def update_firmware(self, firmware_url: str, wait_time: int = 0): url = schema(firmware_url) except vol.MultipleInvalid as e: _LOGGER.debug("Failed to parse url: %s", e) - update_time = ( - datetime.now(tz=timezone.utc) + timedelta(hours=wait_time) - ).strftime("%Y-%m-%dT%H:%M:%SZ") + update_time = (datetime.now(tz=UTC) + timedelta(hours=wait_time)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) req = call.UpdateFirmware(location=url, retrieve_date=update_time) resp = await self.call(req) _LOGGER.info("Response: %s", resp) @@ -875,9 +875,7 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = data, resp.data, ) - self._metrics[cdet.data_response.value].value = datetime.now( - tz=timezone.utc - ) + self._metrics[cdet.data_response.value].value = datetime.now(tz=UTC) self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} return True else: @@ -897,9 +895,7 @@ async def get_configuration(self, key: str = ""): if resp.configuration_key: value = resp.configuration_key[0][om.value.value] _LOGGER.debug("Get Configuration for %s: %s", key, value) - self._metrics[cdet.config_response.value].value = datetime.now( - tz=timezone.utc - ) + self._metrics[cdet.config_response.value].value = datetime.now(tz=UTC) self._metrics[cdet.config_response.value].extra_attr = {key: value} return value if resp.unknown_key: @@ -994,7 +990,7 @@ async def monitor_connection(self): self._metrics[cstat.latency_ping.value].value = latency_ping self._metrics[cstat.latency_pong.value].value = latency_pong - except asyncio.TimeoutError as timeout_exception: + except TimeoutError as timeout_exception: _LOGGER.debug( f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) @@ -1027,7 +1023,7 @@ async def run(self, tasks): self.tasks = [asyncio.ensure_future(task) for task in tasks] try: await asyncio.gather(*self.tasks) - except asyncio.TimeoutError: + except TimeoutError: pass except websockets.exceptions.WebSocketException as websocket_exception: _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") @@ -1258,9 +1254,9 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._metrics[measurand].value = float(value) self._metrics[measurand].unit = unit if location is not None: - self._metrics[measurand].extra_attr[ - om.location.value - ] = location + self._metrics[measurand].extra_attr[om.location.value] = ( + location + ) if context is not None: self._metrics[measurand].extra_attr[om.context.value] = context processed_keys.append(idx) @@ -1295,7 +1291,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): def on_boot_notification(self, **kwargs): """Handle a boot notification.""" resp = call_result.BootNotification( - current_time=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), interval=3600, status=RegistrationStatus.accepted.value, ) @@ -1333,12 +1329,12 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): self._metrics[cstat.status_connector.value].value = status self._metrics[cstat.error_code_connector.value].value = error_code if connector_id >= 1: - self._metrics[cstat.status_connector.value].extra_attr[ - connector_id - ] = status - self._metrics[cstat.error_code_connector.value].extra_attr[ - connector_id - ] = error_code + self._metrics[cstat.status_connector.value].extra_attr[connector_id] = ( + status + ) + self._metrics[cstat.error_code_connector.value].extra_attr[connector_id] = ( + error_code + ) if ( status == ChargePointStatus.suspended_ev.value or status == ChargePointStatus.suspended_evse.value @@ -1489,14 +1485,14 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): def on_data_transfer(self, vendor_id, **kwargs): """Handle a Data transfer request.""" _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) - self._metrics[cdet.data_transfer.value].value = datetime.now(tz=timezone.utc) + self._metrics[cdet.data_transfer.value].value = datetime.now(tz=UTC) self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} return call_result.DataTransfer(status=DataTransferStatus.accepted.value) @on(Action.heartbeat) def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" - now = datetime.now(tz=timezone.utc) + now = datetime.now(tz=UTC) self._metrics[cstat.heartbeat.value].value = now self.hass.async_create_task(self.central.update(self.central.cpid)) return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index 043e4d85..cc60bd71 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -1,4 +1,5 @@ """Button platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 07c421b7..65b13bb0 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -1,4 +1,5 @@ """Adds config flow for ocpp.""" + from homeassistant import config_entries import voluptuous as vol diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 1b066464..d8dce89f 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -1,4 +1,5 @@ """Define constants for OCPP integration.""" + import pathlib import homeassistant.components.input_number as input_number diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index 67f625f9..bc3b5ccb 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -1,4 +1,5 @@ """Additional enumerated values to use in home assistant.""" + from enum import Enum, Flag, auto diff --git a/custom_components/ocpp/exception.py b/custom_components/ocpp/exception.py index fe2cb5d8..f98426b6 100644 --- a/custom_components/ocpp/exception.py +++ b/custom_components/ocpp/exception.py @@ -1 +1 @@ -""" This file is imported by home assistant, and can be used to define custom exceptions.""" +"""This file is imported by home assistant, and can be used to define custom exceptions.""" diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 0c60a526..d2aa6ef5 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -1,4 +1,5 @@ """Number platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 4571fa37..536f2b0a 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -1,4 +1,5 @@ """Sensor platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass @@ -162,7 +163,7 @@ def device_class(self): Measurand.rpm, ] or self.metric.lower().startswith("frequency"): device_class = SensorDeviceClass.FREQUENCY - elif self.metric.lower().startswith(tuple(["power.a", "power.o", "power.r"])): + elif self.metric.lower().startswith(("power.a", "power.o", "power.r")): device_class = SensorDeviceClass.POWER elif self.metric.lower().startswith("temperature."): device_class = SensorDeviceClass.TEMPERATURE diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 7f613946..f46f6adc 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -1,4 +1,5 @@ """Switch platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass diff --git a/docs/conf.py b/docs/conf.py index 02224c43..fb30111d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -# Configuration file for the Sphinx documentation builder. +"""Configuration file for the Sphinx documentation builder.""" # -- Project information diff --git a/manage/update_manifest.py b/manage/update_manifest.py index 55208c3d..f52f7761 100644 --- a/manage/update_manifest.py +++ b/manage/update_manifest.py @@ -1,4 +1,5 @@ """Update the manifest file.""" + # https://github.com/hacs/integration/blob/main/manage/update_manifest.py import json import os diff --git a/tests/conftest.py b/tests/conftest.py index c9153a46..390bc87b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Global fixtures for ocpp integration.""" + import asyncio from unittest.mock import patch @@ -20,9 +21,11 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture(name="skip_notifications", autouse=True) def skip_notifications_fixture(): """Skip notification calls.""" - with patch("homeassistant.components.persistent_notification.async_create"), patch( - "homeassistant.components.persistent_notification.async_dismiss" - ), patch("custom_components.ocpp.api.ChargePoint.notify_ha"): + with ( + patch("homeassistant.components.persistent_notification.async_create"), + patch("homeassistant.components.persistent_notification.async_dismiss"), + patch("custom_components.ocpp.api.ChargePoint.notify_ha"), + ): yield @@ -33,9 +36,11 @@ def bypass_get_data_fixture(): """Skip calls to get data from API.""" future = asyncio.Future() future.set_result(websockets.WebSocketServer) - with patch("websockets.server.serve", return_value=future), patch( - "websockets.server.WebSocketServer.close" - ), patch("websockets.server.WebSocketServer.wait_closed"): + with ( + patch("websockets.server.serve", return_value=future), + patch("websockets.server.WebSocketServer.close"), + patch("websockets.server.WebSocketServer.wait_closed"), + ): yield diff --git a/tests/const.py b/tests/const.py index a5c52024..7acb1d5d 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,4 +1,5 @@ """Constants for ocpp tests.""" + from custom_components.ocpp.const import ( CONF_CPID, CONF_CSID, diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index f67df6f3..52a2ff2a 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -1,6 +1,7 @@ """Implement a test by a simulating a chargepoint.""" + import asyncio -from datetime import datetime, timezone # timedelta, +from datetime import datetime, UTC # timedelta, from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button.const import SERVICE_PRESS @@ -42,6 +43,7 @@ ) from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 +import contextlib @pytest.mark.timeout(90) # Set timeout for this test @@ -167,7 +169,7 @@ async def test_services(hass, socket_enabled): ) as ws2: # use a different id for debugging cp2 = ChargePoint("CP_1_no_subprotocol", ws2) - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp2.start(), @@ -183,8 +185,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws2.close() await asyncio.sleep(1) if entry := hass.config_entries.async_get_entry(config_entry2.entry_id): @@ -207,7 +207,7 @@ async def test_services(hass, socket_enabled): ) as ws: # use a different id for debugging cp = ChargePoint("CP_1_no_subprotocol", ws) - try: + with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -223,8 +223,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except websockets.exceptions.ConnectionClosedOK: - pass await ws.close() await asyncio.sleep(1) @@ -236,7 +234,7 @@ async def test_services(hass, socket_enabled): ) as ws: # use a different id for debugging cp = ChargePoint("CP_1_unsupported_subprotocol", ws) - try: + with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -252,8 +250,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except websockets.exceptions.ConnectionClosedOK: - pass await ws.close() await asyncio.sleep(1) @@ -267,7 +263,7 @@ async def test_services(hass, socket_enabled): cp = ChargePoint("CP_1_restore_values", ws) cp.active_transactionId = None # send None values - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -275,13 +271,11 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass # check if None assert cs.get_metric("test_cpid", "Energy.Meter.Start") is None assert cs.get_metric("test_cpid", "Transaction.Id") is None # send new data - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.send_start_transaction(12344), @@ -289,8 +283,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass # save for reference the values for meter_start and transaction_id saved_meter_start = int(cs.get_metric("test_cpid", "Energy.Meter.Start")) saved_transactionId = int(cs.get_metric("test_cpid", "Transaction.Id")) @@ -298,15 +290,13 @@ async def test_services(hass, socket_enabled): cs.del_metric("test_cpid", "Energy.Meter.Start") cs.del_metric("test_cpid", "Transaction.Id") # send new data - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.send_meter_periodic_data(), ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws.close() # check if restored old values from HA when api have lost the values, i.e. simulated reboot of HA @@ -322,7 +312,7 @@ async def test_services(hass, socket_enabled): ) as ws: # use a different id for debugging cp = ChargePoint("CP_1_normal", ws) - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -342,8 +332,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws.close() assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( 1305570 / 1000 @@ -351,13 +339,13 @@ async def test_services(hass, socket_enabled): assert int(cs.get_metric("test_cpid", "Energy.Session")) == int( (54321 - 12345) / 1000 ) - assert int(cs.get_metric("test_cpid", "Current.Import")) == int(0) - assert int(cs.get_metric("test_cpid", "Voltage")) == int(228) + assert int(cs.get_metric("test_cpid", "Current.Import")) == 0 + assert int(cs.get_metric("test_cpid", "Voltage")) == 228 assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None - assert int(cs.get_supported_features("unknown_cpid")) == int(0) + assert int(cs.get_supported_features("unknown_cpid")) == 0 assert ( await asyncio.wait_for( cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 @@ -381,7 +369,7 @@ async def test_services(hass, socket_enabled): subprotocols=["ocpp1.6"], ) as ws: cp = ChargePoint("CP_1_services", ws) - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -394,12 +382,10 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws.close() - assert int(cs.get_metric("test_cpid", "Frequency")) == int(50) - assert float(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == float( - 1101.452 + assert int(cs.get_metric("test_cpid", "Frequency")) == 50 + assert ( + float(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == 1101.452 ) await asyncio.sleep(1) @@ -413,7 +399,7 @@ async def test_services(hass, socket_enabled): ) as ws: # use a different id for debugging cp = ChargePoint("CP_1_non_errata_3.9", ws) - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -425,8 +411,6 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws.close() # Last sent "Energy.Active.Import.Register" value without transaction id should be here. @@ -448,7 +432,7 @@ async def test_services(hass, socket_enabled): ) as ws: # use a different id for debugging cp = ChargePoint("CP_1_non_errata_3.9", ws) - try: + with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( cp.start(), @@ -460,12 +444,10 @@ async def test_services(hass, socket_enabled): ), timeout=5, ) - except asyncio.TimeoutError: - pass await ws.close() - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int(1101) - assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(11) + assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == 1101 + assert int(cs.get_metric("test_cpid", "Energy.Session")) == 11 assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" # test ocpp rejection messages sent from charger to cms @@ -489,7 +471,7 @@ async def test_services(hass, socket_enabled): ), timeout=3, ) - except asyncio.TimeoutError: + except TimeoutError: pass except websockets.exceptions.ConnectionClosedOK: pass @@ -647,7 +629,7 @@ def on_reset(self, **kwargs): def on_remote_start_transaction(self, **kwargs): """Handle remote start request.""" if self.accept is True: - asyncio.create_task(self.send_start_transaction()) + self.task = asyncio.create_task(self.send_start_transaction()) return call_result.RemoteStartTransaction(RemoteStartStopStatus.accepted) else: return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) @@ -750,7 +732,7 @@ async def send_start_transaction(self, meter_start: int = 12345): connector_id=1, id_tag="test_cp", meter_start=meter_start, - timestamp=datetime.now(tz=timezone.utc).isoformat(), + timestamp=datetime.now(tz=UTC).isoformat(), ) resp = await self.call(request) self.active_transactionId = resp.transaction_id @@ -762,7 +744,7 @@ async def send_status_notification(self): connector_id=0, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.suspended_ev, - timestamp=datetime.now(tz=timezone.utc).isoformat(), + timestamp=datetime.now(tz=UTC).isoformat(), info="Test info", vendor_id="The Mobility House", vendor_error_code="Test error", @@ -772,7 +754,7 @@ async def send_status_notification(self): connector_id=1, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.charging, - timestamp=datetime.now(tz=timezone.utc).isoformat(), + timestamp=datetime.now(tz=UTC).isoformat(), info="Test info", vendor_id="The Mobility House", vendor_error_code="Test error", @@ -782,7 +764,7 @@ async def send_status_notification(self): connector_id=2, error_code=ChargePointErrorCode.no_error, status=ChargePointStatus.available, - timestamp=datetime.now(tz=timezone.utc).isoformat(), + timestamp=datetime.now(tz=UTC).isoformat(), info="Test info", vendor_id="The Mobility House", vendor_error_code="Available", @@ -1118,7 +1100,7 @@ async def send_stop_transaction(self, delay: int = 0): n += 1 request = call.StopTransaction( meter_stop=54321, - timestamp=datetime.now(tz=timezone.utc).isoformat(), + timestamp=datetime.now(tz=UTC).isoformat(), transaction_id=self.active_transactionId, reason="EVDisconnected", id_tag="test_cp", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1e0af0fa..e2350dd6 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,4 +1,5 @@ """Test ocpp config flow.""" + from unittest.mock import patch from homeassistant import config_entries, data_entry_flow @@ -19,12 +20,15 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch( - "custom_components.ocpp.async_setup", - return_value=True, - ), patch( - "custom_components.ocpp.async_setup_entry", - return_value=True, + with ( + patch( + "custom_components.ocpp.async_setup", + return_value=True, + ), + patch( + "custom_components.ocpp.async_setup_entry", + return_value=True, + ), ): yield diff --git a/tests/test_init.py b/tests/test_init.py index 6753bd9b..0c6dd6c0 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,7 +1,8 @@ """Test ocpp setup process.""" + # from homeassistant.exceptions import ConfigEntryNotReady # import pytest -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry From 669e6d1deb4309c31619731acbb63ee283aa98e0 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 9 Sep 2024 19:36:30 +1200 Subject: [PATCH 166/370] remove flake etc from workflow (#1315) * remove flake etc from workflow * remove flake etc from setup.cfg --- .github/workflows/tests.yaml | 2 +- setup.cfg | 36 ------------------------------------ 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2d6d6aa9..a502bc3a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -31,7 +31,7 @@ jobs: - name: Install Python modules run: | - pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports + pip install --constraint=.github/workflows/constraints.txt pre-commit - name: Run pre-commit on all files run: | diff --git a/setup.cfg b/setup.cfg index ab569cf7..3239665c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,39 +1,3 @@ -[flake8] -exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build -doctests = True -# To work with Black -max-line-length = 88 -# D210 No whitespaces allowed surrounding docstring text -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - D210, - E501, - W503, - E203, - D202, - W504 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = custom_components.ocpp, tests -combine_as_imports = true - [tool:pytest] addopts = -qq --cov=custom_components.ocpp --allow-unix-socket --allow-hosts=127.0.0.1 console_output_style = count From 3f54b663c174e44abf7a72c2110acc65515cd33b Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:48:54 +1200 Subject: [PATCH 167/370] update to latest blueprint dev container (#1316) --- .devcontainer.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index 457e49d9..476f704c 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,7 +1,7 @@ { "name": "lbbrhzn/ocpp", - "image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye", - "onCreateCommand": "scripts/setup", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 ], @@ -14,28 +14,28 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.python", + "charliermarsh.ruff", "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", + "ms-python.python", "ms-python.vscode-pylance", - "ms-python.pylint" + "ryanluker.vscode-coverage-gutters" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.formatting.provider": "black", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "editor.formatOnPaste": false, + "editor.formatOnPaste": true, "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } } } }, "remoteUser": "vscode", - "features": { - "ghcr.io/devcontainers/features/rust:1": {} - } + "features": {} } \ No newline at end of file From bdcfd640d29ba7839992425e9d8edf53797ada3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 19:10:47 +0200 Subject: [PATCH 168/370] build(deps): bump pytest-homeassistant-custom-component (#1317) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.161 to 0.13.162. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.161...0.13.162) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ade92327..935d5ae7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.161 +pytest-homeassistant-custom-component==0.13.162 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From b963bd0ff5ed5745bbe4c8728671714aaf8c3230 Mon Sep 17 00:00:00 2001 From: rinigus Date: Mon, 9 Sep 2024 22:19:47 +0300 Subject: [PATCH 169/370] Allow to disable autodetection of measurands (#1292) * Allow to disable autodetection of measurands - Add config option to detect measurands automatically, set default to true - Restrict checked measurands to the ones provided by users Disabling autodetection works around ABB Terra AC bugs as in #1275 * Validate user provided list of measurands * Fix linting * Add quirk to set charger measurands to the list provided by user * Use form to select measurands If measurands are not autodetected, allow user to select them. In part, reverts earlier commit 64dab6e64df6503695e4d16f3b317bcc4a5b350e * Add OCPP measurands tags to user form This allows users to consult technical documentation of their chargers and select measurands based on OCPP tags. In addition, it allows us to document working tags for specific chargers in HA OCPP documentation * Document ABB Terra AC chargers configuration * Drop empty line * Add new configuration to tests * Set charger measurands to enabled ones if not autodetecting As a workaround for bugs in some chargers, set measurands to user-provided list while avoiding querying current settings from the charger * Update documentation with simplified ABB configuration --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/api.py | 71 ++++++++++++------- custom_components/ocpp/config_flow.py | 44 ++++++++++-- custom_components/ocpp/const.py | 3 +- custom_components/ocpp/switch.py | 1 - custom_components/ocpp/translations/de.json | 46 ++++++------ custom_components/ocpp/translations/en.json | 49 ++++++------- custom_components/ocpp/translations/es.json | 46 ++++++------ .../ocpp/translations/i-default.json | 49 ++++++------- custom_components/ocpp/translations/nl.json | 44 ++++++------ docs/installation.md | 2 +- docs/supported-devices.md | 43 +++++++++-- docs/user-guide.md | 20 +++++- tests/const.py | 4 +- 13 files changed, 265 insertions(+), 157 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 91d953c5..a2a58005 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -19,10 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_component, entity_registry import homeassistant.helpers.config_validation as cv -import voluptuous as vol -import websockets.protocol -import websockets.server - from ocpp.exceptions import NotImplementedError from ocpp.messages import CallError from ocpp.routing import on @@ -51,6 +47,9 @@ UnitOfMeasure, UnlockStatus, ) +import voluptuous as vol +import websockets.protocol +import websockets.server from .const import ( CONF_AUTH_LIST, @@ -64,6 +63,7 @@ CONF_IDLE_INTERVAL, CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -83,6 +83,7 @@ DEFAULT_IDLE_INTERVAL, DEFAULT_MEASURAND, DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, DEFAULT_PORT, DEFAULT_POWER_UNIT, DEFAULT_SKIP_SCHEMA_VALIDATION, @@ -469,30 +470,46 @@ async def handle_set_charge_rate(call): all_measurands = self.entry.data.get( CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND ) + autodetect_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ) + key = ckey.meter_values_sampled_data.value - try: - chgr_measurands = await self.get_configuration(key) - except Exception: - _LOGGER.debug( - f"'{self.id}' had error while returning measurands, ignoring" - ) - chgr_measurands = all_measurands - - accepted_measurands = [] - cfg_ok = [ - ConfigurationStatus.accepted, - ConfigurationStatus.reboot_required, - ] - - for measurand in all_measurands.split(","): - _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") - req = call.ChangeConfiguration(key=key, value=measurand) - resp = await self.call(req) - if resp.status in cfg_ok: - _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") - accepted_measurands.append(measurand) - - accepted_measurands = ",".join(accepted_measurands) + + if autodetect_measurands: + accepted_measurands = [] + cfg_ok = [ + ConfigurationStatus.accepted, + ConfigurationStatus.reboot_required, + ] + + for measurand in all_measurands.split(","): + _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") + req = call.ChangeConfiguration(key=key, value=measurand) + resp = await self.call(req) + if resp.status in cfg_ok: + _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") + accepted_measurands.append(measurand) + + accepted_measurands = ",".join(accepted_measurands) + else: + accepted_measurands = all_measurands + + # Quirk: + # Workaround for a bug on chargers that have invalid MeterValuesSampledData + # configuration and reboot while the server requests MeterValuesSampledData. + # By setting the configuration directly without checking current configuration + # as done when calling self.configure, the server avoids charger reboot. + # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 + if len(accepted_measurands) > 0: + req = call.ChangeConfiguration(key=key, value=accepted_measurands) + resp = await self.call(req) + _LOGGER.debug( + f"'{self.id}' measurands set manually to {accepted_measurands}" + ) + + chgr_measurands = await self.get_configuration(key) if len(accepted_measurands) > 0: _LOGGER.debug( diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 65b13bb0..802a3a14 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -12,6 +12,7 @@ CONF_MAX_CURRENT, CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -27,8 +28,10 @@ DEFAULT_HOST, DEFAULT_IDLE_INTERVAL, DEFAULT_MAX_CURRENT, + DEFAULT_MEASURAND, DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -39,6 +42,7 @@ DEFAULT_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TRIES, DOMAIN, + MEASURANDS, ) STEP_USER_DATA_SCHEMA = vol.Schema( @@ -52,8 +56,9 @@ vol.Required(CONF_CPID, default=DEFAULT_CPID): str, vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, vol.Required( - CONF_MONITORED_VARIABLES, default=DEFAULT_MONITORED_VARIABLES - ): str, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + default=DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ): bool, vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, vol.Required( @@ -77,6 +82,13 @@ } ) +STEP_USER_MEASURANDS_SCHEMA = vol.Schema( + { + vol.Required(m, default=(True if m == DEFAULT_MEASURAND else False)): bool + for m in MEASURANDS + } +) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" @@ -93,11 +105,33 @@ async def async_step_user(self, user_input=None): errors: dict[str, str] = {} if user_input is not None: - # Todo: validate the user input self._data = user_input - self._data[CONF_MONITORED_VARIABLES] = DEFAULT_MONITORED_VARIABLES - return self.async_create_entry(title=self._data[CONF_CSID], data=self._data) + if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: + self._data[CONF_MONITORED_VARIABLES] = DEFAULT_MONITORED_VARIABLES + return self.async_create_entry( + title=self._data[CONF_CSID], data=self._data + ) + return await self.async_step_measurands() return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_measurands(self, user_input=None): + """Select the measurands to be shown.""" + + errors: dict[str, str] = {} + if user_input is not None: + selected_measurands = [m for m, value in user_input.items() if value] + if set(selected_measurands).issubset(set(MEASURANDS)): + self._data[CONF_MONITORED_VARIABLES] = ",".join(selected_measurands) + return self.async_create_entry( + title=self._data[CONF_CSID], data=self._data + ) + else: + errors["base"] = "measurand" + return self.async_show_form( + step_id="measurands", + data_schema=STEP_USER_MEASURANDS_SCHEMA, + errors=errors, + ) diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index d8dce89f..74e3b2cf 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -5,7 +5,6 @@ import homeassistant.components.input_number as input_number from homeassistant.components.sensor import SensorDeviceClass import homeassistant.const as ha - from ocpp.v16.enums import Measurand, UnitOfMeasure CONF_AUTH_LIST = "authorization_list" @@ -22,6 +21,7 @@ CONF_METER_INTERVAL = "meter_interval" CONF_MODE = ha.CONF_MODE CONF_MONITORED_VARIABLES = ha.CONF_MONITORED_VARIABLES +CONF_MONITORED_VARIABLES_AUTOCONFIG = "monitored_variables_autoconfig" CONF_NAME = ha.CONF_NAME CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT @@ -96,6 +96,7 @@ ] DEFAULT_MEASURAND = Measurand.energy_active_import_register.value DEFAULT_MONITORED_VARIABLES = ",".join(MEASURANDS) +DEFAULT_MONITORED_VARIABLES_AUTOCONFIG = True DEFAULT_ENERGY_UNIT = UnitOfMeasure.wh.value DEFAULT_POWER_UNIT = UnitOfMeasure.w.value HA_ENERGY_UNIT = UnitOfMeasure.kwh.value diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index f46f6adc..fb486896 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -12,7 +12,6 @@ ) from homeassistant.const import UnitOfPower from homeassistant.helpers.entity import DeviceInfo - from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index 2400971d..37b44537 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -24,28 +24,28 @@ "title": "OCPP-Messwerte", "description": "Wähle aus welche Messwerte in Home Assistant angezeigt werden sollen.", "data": { - "Current.Export": "Momentane Stromstärke vom E-Auto", - "Current.Import": "Momentaner Stromstärke zum E-Auto", - "Current.Offered": "Maximale dem E-Auto angebotene Stromstärke", - "Energy.Active.Export.Register": "In das Netz exportierte Wirkenergie", - "Energy.Active.Import.Register": "Aus dem Netz importierte Wirkenergie", - "Energy.Reactive.Export.Register": "In das Netz exportierte Blindenergie", - "Energy.Reactive.Import.Register": "Aus dem Netz importierte Blindenergie", - "Energy.Active.Export.Interval": "Im letzten Intervall in das Netz exportierte Wirkenergie", - "Energy.Active.Import.Interval": "Im letzten Intervall aus dem Netz importierte Wirkenergie", - "Energy.Reactive.Export.Interval": "Im letzten Intervall ins Netz exportierte Blindenergie", - "Energy.Reactive.Import.Interval": "Im letzten Intervall aus dem Netz importierte Blindenergie", - "Frequency": "Netzfrequenz", - "Power.Active.Export": "Von E-Auto exportierte momentane Wirkleistung", - "Power.Active.Import": "Von E-Auto importierte momentane Wirkleistung", - "Power.Factor": "Unmittelbarer Leistungsfaktor des gesamten Energieflusses", - "Power.Offered": "Maximale dem E-Auto angebotene Leistung", - "Power.Reactive.Export": "Von E-Auto exportierte momentane Blindleistung", - "Power.Reactive.Import": "Von E-Auto importierte momentane Blindleistung", - "RPM": "Lüfterdrehzahl in RPM", - "SoC": "Ladezustand des E-Autos in Prozent", - "Temperature": "Temperaturmesswert in der Ladestation", - "Voltage": "Momentane AC-Effektivspannung" + "Current.Export": "Current.Export: Momentane Stromstärke vom E-Auto", + "Current.Import": "Current.Import: Momentaner Stromstärke zum E-Auto", + "Current.Offered": "Current.Offered: Maximale dem E-Auto angebotene Stromstärke", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: In das Netz exportierte Wirkenergie", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Aus dem Netz importierte Wirkenergie", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: In das Netz exportierte Blindenergie", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Aus dem Netz importierte Blindenergie", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Im letzten Intervall in das Netz exportierte Wirkenergie", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Im letzten Intervall aus dem Netz importierte Wirkenergie", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Im letzten Intervall ins Netz exportierte Blindenergie", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Im letzten Intervall aus dem Netz importierte Blindenergie", + "Frequency": "Frequency: Netzfrequenz", + "Power.Active.Export": "Power.Active.Export: Von E-Auto exportierte momentane Wirkleistung", + "Power.Active.Import": "Power.Active.Import: Von E-Auto importierte momentane Wirkleistung", + "Power.Factor": "Power.Factor: Unmittelbarer Leistungsfaktor des gesamten Energieflusses", + "Power.Offered": "Power.Offered: Maximale dem E-Auto angebotene Leistung", + "Power.Reactive.Export": "Power.Reactive.Export: Von E-Auto exportierte momentane Blindleistung", + "Power.Reactive.Import": "Power.Reactive.Import: Von E-Auto importierte momentane Blindleistung", + "RPM": "RPM: Lüfterdrehzahl in RPM", + "SoC": "SoC: Ladezustand des E-Autos in Prozent", + "Temperature": "Temperature: Temperaturmesswert in der Ladestation", + "Voltage": "Voltage: Momentane AC-Effektivspannung" } } }, @@ -57,4 +57,4 @@ "single_instance_allowed": "Es ist nur eine Instanz erlaubt." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 2f3940e5..0015e11b 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -14,6 +14,7 @@ "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", + "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "websocket_close_timeout": "Websocket close timeout (seconds)", "websocket_ping_tries": "Websocket successive times to try connection before closing", @@ -25,30 +26,30 @@ }, "measurands": { "title": "OCPP Measurands", - "description": "Select which measurand(s) should be shown in Home Assistant.", + "description": "Select which measurand(s) should be used in Home Assistant.", "data": { - "Current.Export": "Instantaneous current flow from EV", - "Current.Import": "Instantaneous current flow to EV", - "Current.Offered": "Maximum current offered to EV", - "Energy.Active.Export.Register": "Active energy exported to the grid", - "Energy.Active.Import.Register": "Active energy imported from the grid", - "Energy.Reactive.Export.Register": "Reactive energy exported to the grid", - "Energy.Reactive.Import.Register": "Reactive energy imported from the grid", - "Energy.Active.Export.Interval": "Active energy exported to the grid during last interval", - "Energy.Active.Import.Interval": "Active energy imported from the grid during last interval", - "Energy.Reactive.Export.Interval": "Reactive energy exported to the grid during last interval", - "Energy.Reactive.Import.Interval": "Reactive energy imported from the grid during last interval", - "Frequency": "Powerline frequency", - "Power.Active.Export": "Instantaneous active power exported by EV", - "Power.Active.Import": "Instantaneous active power imported by EV", - "Power.Factor": "Instantaneous power factor of total energy flow", - "Power.Offered": "Maximum power offered to EV", - "Power.Reactive.Export": "Instantaneous reactive power exported by EV", - "Power.Reactive.Import": "Instantaneous reactive power imported by EV", - "RPM": "Fan speed in RPM", - "SoC": "State of charge of EV in percentage", - "Temperature": "Temperature reading inside Charge Point", - "Voltage": "Instantaneous AC RMS supply voltage" + "Current.Export": "Current.Export: Instantaneous current flow from EV", + "Current.Import": "Current.Import: Instantaneous current flow to EV", + "Current.Offered": "Current.Offered: Maximum current offered to EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Active energy exported to the grid", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Active energy imported from the grid", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactive energy exported to the grid", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactive energy imported from the grid", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Active energy exported to the grid during last interval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Active energy imported from the grid during last interval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactive energy exported to the grid during last interval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactive energy imported from the grid during last interval", + "Frequency": "Frequency: Powerline frequency", + "Power.Active.Export": "Power.Active.Export: Instantaneous active power exported by EV", + "Power.Active.Import": "Power.Active.Import: Instantaneous active power imported by EV", + "Power.Factor": "Power.Factor: Instantaneous power factor of total energy flow", + "Power.Offered": "Power.Offered: Maximum power offered to EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantaneous reactive power exported by EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantaneous reactive power imported by EV", + "RPM": "RPM: Fan speed in RPM", + "SoC": "SoC: State of charge of EV in percentage", + "Temperature": "Temperature: Temperature reading inside Charge Point", + "Voltage": "Voltage: Instantaneous AC RMS supply voltage" } } }, @@ -60,4 +61,4 @@ "single_instance_allowed": "Only a single instance is allowed." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index f1c6397e..352eb2ef 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -23,28 +23,28 @@ "title": "Mediciones OCPP", "description": "Seleccione qué medida(s) debe(n) mostrarse en el Asistente de Inicio.", "data": { - "Current.Export": "Flujo de corriente instantáneo del VE", - "Current.Import": "Flujo instantáneo de corriente hacia el VE", - "Current.Offered": "Corriente máxima ofrecida al VE", - "Energy.Active.Export.Register": "Energía activa exportada a la red", - "Energy.Active.Import.Register": "Energía activa importada de la red", - "Energy.Reactive.Export.Register": "Energía reactiva exportada a la red", - "Energy.Reactive.Import.Register": "Energía reactiva importada de la red", - "Energy.Active.Export.Interval": "Energía activa exportada a la red durante el último intervalo", - "Energy.Active.Import.Interval": "Energía activa importada de la red durante el último intervalo", - "Energy.Reactive.Export.Interval": "Energía reactiva exportada a la red durante el último intervalo", - "Energy.Reactive.Import.Interval": "Energía reactiva importada de la red durante el último intervalo", - "Frequency": "Frecuencia de la línea eléctrica", - "Power.Active.Export": "Potencia activa instantánea exportada por el VE", - "Power.Active.Import": "Potencia activa instantánea importada por el VE", - "Power.Factor": "Factor de potencia instantáneo del flujo de energía total", - "Power.Offered": "Potencia máxima ofrecida al VE", - "Power.Reactive.Export": "Potencia reactiva instantánea exportada por el VE", - "Power.Reactive.Import": "Potencia reactiva instantánea importada por el VE", - "RPM": "Velocidad del ventilador en RPM", - "SoC": "Estado de carga del VE en porcentaje", - "Temperature": "Lectura de la temperatura en el interior del punto de carga", - "Voltage": "Tensión de alimentación AC RMS instantánea" + "Current.Export": "Current.Export: Flujo de corriente instantáneo del VE", + "Current.Import": "Current.Import: Flujo instantáneo de corriente hacia el VE", + "Current.Offered": "Current.Offered: Corriente máxima ofrecida al VE", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Energía activa exportada a la red", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Energía activa importada de la red", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Energía reactiva exportada a la red", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Energía reactiva importada de la red", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Energía activa exportada a la red durante el último intervalo", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Energía activa importada de la red durante el último intervalo", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Energía reactiva exportada a la red durante el último intervalo", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Energía reactiva importada de la red durante el último intervalo", + "Frequency": "Frequency: Frecuencia de la línea eléctrica", + "Power.Active.Export": "Power.Active.Export: Potencia activa instantánea exportada por el VE", + "Power.Active.Import": "Power.Active.Import: Potencia activa instantánea importada por el VE", + "Power.Factor": "Power.Factor: Factor de potencia instantáneo del flujo de energía total", + "Power.Offered": "Power.Offered: Potencia máxima ofrecida al VE", + "Power.Reactive.Export": "Power.Reactive.Export: Potencia reactiva instantánea exportada por el VE", + "Power.Reactive.Import": "Power.Reactive.Import: Potencia reactiva instantánea importada por el VE", + "RPM": "RPM: Velocidad del ventilador en RPM", + "SoC": "SoC: Estado de carga del VE en porcentaje", + "Temperature": "Temperature: Lectura de la temperatura en el interior del punto de carga", + "Voltage": "Voltage: Tensión de alimentación AC RMS instantánea" } } }, @@ -56,4 +56,4 @@ "single_instance_allowed": "Sólo se permite una única instancia." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 2f3940e5..0015e11b 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -14,6 +14,7 @@ "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", + "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "websocket_close_timeout": "Websocket close timeout (seconds)", "websocket_ping_tries": "Websocket successive times to try connection before closing", @@ -25,30 +26,30 @@ }, "measurands": { "title": "OCPP Measurands", - "description": "Select which measurand(s) should be shown in Home Assistant.", + "description": "Select which measurand(s) should be used in Home Assistant.", "data": { - "Current.Export": "Instantaneous current flow from EV", - "Current.Import": "Instantaneous current flow to EV", - "Current.Offered": "Maximum current offered to EV", - "Energy.Active.Export.Register": "Active energy exported to the grid", - "Energy.Active.Import.Register": "Active energy imported from the grid", - "Energy.Reactive.Export.Register": "Reactive energy exported to the grid", - "Energy.Reactive.Import.Register": "Reactive energy imported from the grid", - "Energy.Active.Export.Interval": "Active energy exported to the grid during last interval", - "Energy.Active.Import.Interval": "Active energy imported from the grid during last interval", - "Energy.Reactive.Export.Interval": "Reactive energy exported to the grid during last interval", - "Energy.Reactive.Import.Interval": "Reactive energy imported from the grid during last interval", - "Frequency": "Powerline frequency", - "Power.Active.Export": "Instantaneous active power exported by EV", - "Power.Active.Import": "Instantaneous active power imported by EV", - "Power.Factor": "Instantaneous power factor of total energy flow", - "Power.Offered": "Maximum power offered to EV", - "Power.Reactive.Export": "Instantaneous reactive power exported by EV", - "Power.Reactive.Import": "Instantaneous reactive power imported by EV", - "RPM": "Fan speed in RPM", - "SoC": "State of charge of EV in percentage", - "Temperature": "Temperature reading inside Charge Point", - "Voltage": "Instantaneous AC RMS supply voltage" + "Current.Export": "Current.Export: Instantaneous current flow from EV", + "Current.Import": "Current.Import: Instantaneous current flow to EV", + "Current.Offered": "Current.Offered: Maximum current offered to EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Active energy exported to the grid", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Active energy imported from the grid", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactive energy exported to the grid", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactive energy imported from the grid", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Active energy exported to the grid during last interval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Active energy imported from the grid during last interval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactive energy exported to the grid during last interval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactive energy imported from the grid during last interval", + "Frequency": "Frequency: Powerline frequency", + "Power.Active.Export": "Power.Active.Export: Instantaneous active power exported by EV", + "Power.Active.Import": "Power.Active.Import: Instantaneous active power imported by EV", + "Power.Factor": "Power.Factor: Instantaneous power factor of total energy flow", + "Power.Offered": "Power.Offered: Maximum power offered to EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantaneous reactive power exported by EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantaneous reactive power imported by EV", + "RPM": "RPM: Fan speed in RPM", + "SoC": "SoC: State of charge of EV in percentage", + "Temperature": "Temperature: Temperature reading inside Charge Point", + "Voltage": "Voltage: Instantaneous AC RMS supply voltage" } } }, @@ -60,4 +61,4 @@ "single_instance_allowed": "Only a single instance is allowed." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index 3923a1fa..50164f70 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -23,28 +23,28 @@ "title": "OCPP Meetgrootheden", "description": "Selecteer welke meetgrootheden weergegeven worden.", "data": { - "Current.Export": "Instantane stroom van EV", - "Current.Import": "Instantane stroom naar EV", - "Current.Offered": "Maximale stroom aangeboden aan EV", - "Energy.Active.Export.Register": "Actieve energie geexporteerd naar het net", - "Energy.Active.Import.Register": "Actieve energie geimporteed vanaf het net", - "Energy.Reactive.Export.Register": "Reactieve energie gexporteerd naar het net", - "Energy.Reactive.Import.Register": "Reactieve energie geimporteerd van het net", - "Energy.Active.Export.Interval": "Actieve energie geexporteerd naar het net tijdens het laatste meetinterval", - "Energy.Active.Import.Interval": "Actieve energie geimporteerd van het net tijdens het laatste meetinterval", - "Energy.Reactive.Export.Interval": "Reactieve energie geexporteerd naar het net tijdens het laatste meetinterval", - "Energy.Reactive.Import.Interval": "Reactieve energie geimporteerd van het net tijdens het laatste meetinterval", - "Frequency": "Netfrequentie", - "Power.Active.Export": "Instantane actieve vermogen geexporteerd door EV", - "Power.Active.Import": "Instantane active vermorgen geimporteerd door EV", - "Power.Factor": "Instantane vermogensfactor can de totale energiestroom", - "Power.Offered": "Maximale vermogen aangeboden aan EV", - "Power.Reactive.Export": "Instantane reactive vermogen geexporteerd door EV", - "Power.Reactive.Import": "Instantane reactieve vermogen geimporteerd door EV", - "RPM": "Ventilator snelheid in RPM", - "SoC": "Laadstand in procent", - "Temperature": "Temperatuur in het laadpunt", - "Voltage": "Instantane RMS waarde voedingsspanning" + "Current.Export": "Current.Export: Instantane stroom van EV", + "Current.Import": "Current.Import: Instantane stroom naar EV", + "Current.Offered": "Current.Offered: Maximale stroom aangeboden aan EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Actieve energie geexporteerd naar het net", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Actieve energie geimporteed vanaf het net", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactieve energie gexporteerd naar het net", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactieve energie geimporteerd van het net", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Actieve energie geexporteerd naar het net tijdens het laatste meetinterval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Actieve energie geimporteerd van het net tijdens het laatste meetinterval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactieve energie geexporteerd naar het net tijdens het laatste meetinterval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactieve energie geimporteerd van het net tijdens het laatste meetinterval", + "Frequency": "Frequency: Netfrequentie", + "Power.Active.Export": "Power.Active.Export: Instantane actieve vermogen geexporteerd door EV", + "Power.Active.Import": "Power.Active.Import: Instantane active vermorgen geimporteerd door EV", + "Power.Factor": "Power.Factor: Instantane vermogensfactor can de totale energiestroom", + "Power.Offered": "Power.Offered: Maximale vermogen aangeboden aan EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantane reactive vermogen geexporteerd door EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantane reactieve vermogen geimporteerd door EV", + "RPM": "RPM: Ventilator snelheid in RPM", + "SoC": "SoC: Laadstand in procent", + "Temperature": "Temperature: Temperatuur in het laadpunt", + "Voltage": "Voltage: Instantane RMS waarde voedingsspanning" } } }, diff --git a/docs/installation.md b/docs/installation.md index d349335c..ee493166 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -41,7 +41,7 @@ If you do not use HTTPS for your Home Assistant instance: ### Measurands - Most chargers only support a subset of all possible measurands. This depends most on the Feature profiles that are supported by the charger. -- The integration will autodetect the supported measurands when the charger connects. +- The integration will autodetect the supported measurands when the charger connects. This can be disabled for chargers that do not support autodetection. ## Add the entities to your Dashboard - On the OCPP integration, click on devices to navigate to your Charge Point device. diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 51dff441..3739c8e2 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -3,13 +3,48 @@ Supported devices All OCPP 1.6j compatible devices should be supported, but not every device offers the same level of functionality. So far, we've tried: -## [ABB Terra AC-W7-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w7-g5-r-0) +## ABB Terra AC chargers -## [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) +ABB Terra AC chargers with firmware version 1.8.21 and earlier fail to respond correctly when OCPP measurands are automatically detected by the OCPP integration. As of this writing, ABB has been notified, but no corresponding firmware fix is available. -## [ABB Terra AC-W22-T-0](https://new.abb.com/products/6AGC081279/tac-w22-t-0) +### Issue Description -## [ABB Terra TAC-W22-T-RD-MC-0](https://new.abb.com/products/6AGC081281/tac-w22-t-rd-mc-0) +When automatic measurand detection is used in the OCPP integration with ABB Terra AC chargers: + +1. The charger responds as if it supports all proposed measurands. +2. The integration then asks for all measurands to be reported. +3. When the integration tries to query which measurands are available after this configuration, the ABB Terra AC reboots. + +As a result, the ABB charger becomes unusable with the OCPP integration, as the integration checks for available measurands on every charger boot, leading to a boot loop. + +For more details and symptoms, see [Issue #1275](https://github.com/lbbrhzn/ocpp/issues/1275). + +### Workaround + +Fortunately, it is possible to configure the charger using manual configuration and to restore correct settings. + +To use these chargers: + +1. Disable "Automatic detection of OCPP Measurands". + - Note: Automatic detection is enabled by default. Until configuration changes can be made online, you may need to remove the devices from this integration and add them again. + - If "Automatic detection of OCPP Measurands" is disabled during configuration, you will be presented with a list of possible measurands. + +2. When presented with the list of measurands, select only the following: + - `Current.Import` + - `Current.Offered` + - `Energy.Active.Import.Register` + - `Power.Active.Import` + - `Voltage` + +This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC (firmware 1.6.6). + +### [ABB Terra AC-W7-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w7-g5-r-0) + +### [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) + +### [ABB Terra AC-W22-T-0](https://new.abb.com/products/6AGC081279/tac-w22-t-0) + +### [ABB Terra TAC-W22-T-RD-MC-0](https://new.abb.com/products/6AGC081281/tac-w22-t-rd-mc-0) ## [Alfen - Eve Single Pro-line](https://alfen.com/en/ev-charge-points/alfen-product-range) diff --git a/docs/user-guide.md b/docs/user-guide.md index 53af6557..43703844 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -23,6 +23,8 @@ The `Charge point identity` shown above with a default of `charger` is a little Measurands (according to OCPP terminology) are actually metrics provided by the charger. Each charger supports a subset of the available metrics and for each one supported, a sensor entity is available in HA. Some of these sensor entities will give erroneous readings whilst others give no readings at all. Sensor entities not supported by the charger will show as `Unknown` if you try to create a sensor entity for them. Below is a table of the metrics I've found useful for the Wallbox Pulsar Plus. Tables for other chargers will follow as contributions come in from owners of each supported charger. +OCPP integration can automatically detect supported measurands. However, some chargers have faulty firmware that causes the detection mechanism to fail. For such chargers, it is possible to disable automatic measurand detection and manually set the measurands to those supported by the charger. When set manually, selected measurands are not checked for compatibility with the charger and are requested from it. See below for OCPP compliance notes and charger-specific instructions in [supported devices](supported-devices.md). + ## Useful Entities for Wallbox Pulsar Plus ### Metrics @@ -46,6 +48,16 @@ Measurands (according to OCPP terminology) are actually metrics provided by the * `Maximum Current` (sets maximum charging current available) * `Reset` +## Useful Entities for ABB Terra AC + +### Metrics + +* `Current.Import` (instantaneous current flow to EV) +* `Energy.Active.Import.Register` (active energy imported from the grid) +* `Power.Active.Import` (instantaneous active power imported by EV) +* `Voltage` (instantaneous AC RMS supply voltage) + + ## Useful Entities for EVBox Elvi ### Metrics @@ -68,7 +80,7 @@ Measurands (according to OCPP terminology) are actually metrics provided by the ## Useful Entities and Workarounds for United Chargers Grizzl-E -Comments below relate to Grizzl-E firmware version 5.633, tested Oct-Nov 2022. +Comments below relate to Grizzl-E firmware version 5.633, tested Oct-Nov 2022. ### Metrics The Grizzl-E updates these metrics every 30s during charging sessions: @@ -114,6 +126,12 @@ The Grizzl-E updates these metrics every 30s during charging sessions: ### OCPP Compatibility Issues +### ABB Terra AC + +ABB Terra AC firmware 1.8.21 and earlier versions fail to respond correctly when OCPP measurands are automatically detected by the OCPP integration. As of this writing, ABB has been notified, but no corresponding firmware fix is available. As a result, users must configure measurands manually. See the suggested ABB Terra AC configuration in [supported devices](supported-devices.md). + +### Grizzl-E + Grizzl-E firmware has a few OCPP-compliance defects, including responding to certain OCPP server messages with invalid JSON. Symptoms of this problem include repeated reboots of the charger. By editing the OCPP server source code, one can avoid these problematic messages and obtain useful charger behaviour. ChargeLabs (the company working on the Grizzl-E firmware) expects to release version 6 of the firmware in early 2023, which may fix these problems. The workaround consists of: diff --git a/tests/const.py b/tests/const.py index 7acb1d5d..e66520be 100644 --- a/tests/const.py +++ b/tests/const.py @@ -9,6 +9,7 @@ CONF_MAX_CURRENT, CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -32,7 +33,7 @@ CONF_IDLE_INTERVAL: 900, CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, @@ -50,6 +51,7 @@ CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, CONF_SSL: False, From a5b4a1340e21453e0f5446e3032f05153d872dbf Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:19:45 +0200 Subject: [PATCH 170/370] Update manifest.json (#1321) update version key to 0.5.12 --- custom_components/ocpp/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 0da0793e..0d6adbd0 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -16,5 +16,5 @@ "ocpp>=1.0.0", "websockets>=12.0" ], - "version": "0.4.42" + "version": "0.5.12" } From a30db38b7ee53076c50cb059c5332fecc27a5aef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Sep 2024 08:41:27 +0200 Subject: [PATCH 171/370] build(deps): bump ruff from 0.6.4 to 0.6.5 (#1324) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.4 to 0.6.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.4...0.6.5) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 935d5ae7..637fbe7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.4 +ruff==0.6.5 ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 From a84d2fb9906f6ba3c8cfad098467b85ff91aed6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:55:10 +0200 Subject: [PATCH 172/370] build(deps): bump ruff from 0.6.5 to 0.6.6 (#1330) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.5 to 0.6.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.5...0.6.6) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 637fbe7e..dd7fe510 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.5 +ruff==0.6.6 ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 From 6f7bd2b7da8a51ae68953816d2d2c76c7c5b5dda Mon Sep 17 00:00:00 2001 From: Matti Laakso Date: Sun, 22 Sep 2024 15:55:49 +0300 Subject: [PATCH 173/370] Add Teison Smart MINI wallbox to supported devices (#1329) --- docs/supported-devices.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 3739c8e2..8da5d4d2 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -72,6 +72,18 @@ match transactions and it won't report some meter values such as session time. ## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) All basic functions work properly +## [Teison Smart MINI Wallbox](https://www.teison.com/ac_smart_mini_ev_wallbox.html) +Use *My Teison* app to enable webSocket. In the socket URL field enter the address of your Home Assistant server including the port. In the socket port field enter *ocpp1.6* for insecure connection or *socpp1.6* for secure connection with certificates. Once enabled, charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. + +Even though the device accepts all measurands, the working ones are + - `Current.Import` + - `Energy.Active.Import.Register` + - `Power.Active.Import` + - `Temperature` + - `Voltage` + +If the devices loses connection to Home Assistant (due to Wi-Fi disconnection or update, for example) it doesn't seem to reconnect automatically. It is necessary to reboot the charger via Bluetooth for it to reconnect. + ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) (has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) From 8cd5a07823c0a361bca7451170aa9f93c10aac05 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:56:34 +0200 Subject: [PATCH 174/370] Add missing DE translations (#1328) --- custom_components/ocpp/translations/de.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index 37b44537..eff06f1d 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -17,7 +17,11 @@ "websocket_ping_interval": "Websocket-Ping-Intervall (Sekunden)", "websocket_ping_timeout": "Websocket-Ping-Timeout (Sekunden)", "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", - "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil" + "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", + "ssl": "Verschlüsselte Verbindung", + "ssl_certfile_path": "Pfad zum SSL Zertifikat", + "ssl_keyfile_path": "Pfad zum SSL Schlüssel", + "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte" } }, "measurands": { From 173512c0aaf9af9a0c337bba201999e03cf579ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:56:58 +0200 Subject: [PATCH 175/370] build(deps): bump pytest-homeassistant-custom-component (#1327) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.162 to 0.13.163. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.162...0.13.163) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dd7fe510..0e73175d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 pre-commit -pytest-homeassistant-custom-component==0.13.162 +pytest-homeassistant-custom-component==0.13.163 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From e1f65431e5c1f609ea5e57b4599cabdd22cf8ed4 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 22 Sep 2024 17:04:29 +0200 Subject: [PATCH 176/370] fix: requirements.txt to reduce vulnerabilities (#1331) The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-7448482 - https://snyk.io/vuln/SNYK-PYTHON-ZIPP-7430899 Co-authored-by: snyk-bot --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0e73175d..dc9a53fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ websockets==13.0.1 jsonschema==4.23.0 pre-commit pytest-homeassistant-custom-component==0.13.163 -setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability +setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From f41dd52acf51637e05547bf1a791c43bcb30dc64 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 22 Sep 2024 17:10:52 +0200 Subject: [PATCH 177/370] [Snyk] Security upgrade setuptools from 40.5.0 to 65.5.1 (#1332) * fix: requirements.txt to reduce vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3180412 * bump pre-commit to 3.8.0 --------- Co-authored-by: snyk-bot --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dc9a53fb..92c2736f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.6.6 ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 -pre-commit +pre-commit==3.8.0 pytest-homeassistant-custom-component==0.13.163 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 80eb5e1f692160c93515dfe1bdde73247624ef37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:59:24 +0200 Subject: [PATCH 178/370] build(deps): bump ruff from 0.6.6 to 0.6.7 (#1334) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.6 to 0.6.7. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.6...0.6.7) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92c2736f..90b682f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.6 +ruff==0.6.7 ocpp==1.0.0 websockets==13.0.1 jsonschema==4.23.0 From 26ae2ce8a79df7f734680b4c36dc507d2987ed2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:00:34 +0200 Subject: [PATCH 179/370] build(deps): bump websockets from 13.0.1 to 13.1 (#1333) Bumps [websockets](https://github.com/python-websockets/websockets) from 13.0.1 to 13.1. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/13.0.1...13.1) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 90b682f4..d7f9f632 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.8.2 pip>=21.0,<24.3 ruff==0.6.7 ocpp==1.0.0 -websockets==13.0.1 +websockets==13.1 jsonschema==4.23.0 pre-commit==3.8.0 pytest-homeassistant-custom-component==0.13.163 From ce548727f4e02d9b693a2713b28f10361ea6b9a5 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Thu, 26 Sep 2024 18:49:09 +1200 Subject: [PATCH 180/370] fix empty value (#1336) --- custom_components/ocpp/api.py | 30 +++++++++++++++++++----------- tests/test_charge_point.py | 4 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index a2a58005..3108e7eb 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1116,14 +1116,19 @@ def average_of_nonzero(values): value = item.get(om.value.value, None) unit = item.get(om.unit.value, None) context = item.get(om.context.value, None) + # where an empty string is supplied convert to 0 + try: + value = float(value) + except ValueError: + value = 0 if measurand is not None and phase is not None and unit is not None: if measurand not in measurand_data: measurand_data[measurand] = {} measurand_data[measurand][om.unit.value] = unit - measurand_data[measurand][phase] = float(value) + measurand_data[measurand][phase] = value self._metrics[measurand].unit = unit self._metrics[measurand].extra_attr[om.unit.value] = unit - self._metrics[measurand].extra_attr[phase] = float(value) + self._metrics[measurand].extra_attr[phase] = value self._metrics[measurand].extra_attr[om.context.value] = context line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] @@ -1226,6 +1231,11 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): phase = sampled_value.get(om.phase.value, None) location = sampled_value.get(om.location.value, None) context = sampled_value.get(om.context.value, None) + # where an empty string is supplied convert to 0 + try: + value = float(value) + except ValueError: + value = 0 if len(sampled_value.keys()) == 1: # Backwards compatibility measurand = DEFAULT_MEASURAND @@ -1240,7 +1250,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): if phase is None: if unit == DEFAULT_POWER_UNIT: - self._metrics[measurand].value = float(value) / 1000 + self._metrics[measurand].value = value / 1000 self._metrics[measurand].unit = HA_POWER_UNIT elif ( measurand == DEFAULT_MEASURAND @@ -1248,27 +1258,25 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): ): if transaction_matches: if unit == DEFAULT_ENERGY_UNIT: - value = float(value) / 1000 + value = value / 1000 unit = HA_ENERGY_UNIT - self._metrics[csess.session_energy.value].value = float( - value - ) + self._metrics[csess.session_energy.value].value = value self._metrics[csess.session_energy.value].unit = unit self._metrics[csess.session_energy.value].extra_attr[ cstat.id_tag.name ] = self._metrics[cstat.id_tag.value].value else: if unit == DEFAULT_ENERGY_UNIT: - value = float(value) / 1000 + value = value / 1000 unit = HA_ENERGY_UNIT - self._metrics[measurand].value = float(value) + self._metrics[measurand].value = value self._metrics[measurand].unit = unit elif unit == DEFAULT_ENERGY_UNIT: if transaction_matches: - self._metrics[measurand].value = float(value) / 1000 + self._metrics[measurand].value = value / 1000 self._metrics[measurand].unit = HA_ENERGY_UNIT else: - self._metrics[measurand].value = float(value) + self._metrics[measurand].value = value self._metrics[measurand].unit = unit if location is not None: self._metrics[measurand].extra_attr[om.location.value] = ( diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 52a2ff2a..dec050d8 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -831,14 +831,14 @@ async def send_meter_periodic_data(self): "location": "Outlet", }, { - "value": "0.000", + "value": "", "context": "Sample.Periodic", "measurand": "Power.Active.Import", "location": "Outlet", "unit": "kW", }, { - "value": "0.000", + "value": "", "context": "Sample.Periodic", "measurand": "Power.Active.Import", "location": "Outlet", From 1470992cf0bcdae639a4ed00f31f566b1fa62356 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:37:22 +0200 Subject: [PATCH 181/370] build(deps): bump ruff from 0.6.7 to 0.6.8 (#1338) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.7 to 0.6.8. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.7...0.6.8) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d7f9f632..2d24ae6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.7 +ruff==0.6.8 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From 7c309f848737556cf0841e451d7750f4295f7f9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:37:53 +0200 Subject: [PATCH 182/370] build(deps): bump pytest-homeassistant-custom-component (#1337) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.163 to 0.13.165. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.163...0.13.165) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2d24ae6c..2b53301a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==3.8.0 -pytest-homeassistant-custom-component==0.13.163 +pytest-homeassistant-custom-component==0.13.165 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From e656151a8e391c74ee522f3b6a04f12592f96fa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:04:06 +0200 Subject: [PATCH 183/370] build(deps): bump pytest-homeassistant-custom-component (#1346) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.165 to 0.13.169. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.165...0.13.169) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2b53301a..7f04d4e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==3.8.0 -pytest-homeassistant-custom-component==0.13.165 +pytest-homeassistant-custom-component==0.13.169 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 3be10d3dde97b0ebc4f6db787407be594b8fb16b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:20:12 +0200 Subject: [PATCH 184/370] build(deps): bump ruff from 0.6.8 to 0.6.9 (#1350) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.8 to 0.6.9. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.8...0.6.9) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7f04d4e8..c43f3d9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 pip>=21.0,<24.3 -ruff==0.6.8 +ruff==0.6.9 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From 5eca3f159cd13c02784a37925d6b08890b376a20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:21:53 +0200 Subject: [PATCH 185/370] build(deps): bump pytest-homeassistant-custom-component (#1348) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.169 to 0.13.171. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.169...0.13.171) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c43f3d9b..f30fcf53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==3.8.0 -pytest-homeassistant-custom-component==0.13.169 +pytest-homeassistant-custom-component==0.13.171 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From d4fa32b16be6bb80ad6e2c81b8fd90bd38eeeaab Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 6 Oct 2024 06:23:25 +1300 Subject: [PATCH 186/370] switch to uv from pip (#1349) * switch to uv from pip * add virtual env * add venv * relax uv version --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/constraints.txt | 6 ------ .github/workflows/tests.yaml | 15 +++++++++++---- requirements.txt | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) delete mode 100644 .github/workflows/constraints.txt diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt deleted file mode 100644 index 646c8a93..00000000 --- a/.github/workflows/constraints.txt +++ /dev/null @@ -1,6 +0,0 @@ -# home assistant -pip>=21.0,<24.3 -pre-commit==3.8.0 -pre-comit-hooks==4.1.0 -pyupgrade==3.17.0 -sqlalchemy>=1.4.23 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a502bc3a..eaa209f0 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -26,15 +26,19 @@ jobs: - name: Upgrade pip run: | - pip install --constraint=.github/workflows/constraints.txt pip + python -m venv venv + . venv/bin/activate + pip install "$(grep '^uv' < requirements.txt)" pip --version - name: Install Python modules run: | - pip install --constraint=.github/workflows/constraints.txt pre-commit + . venv/bin/activate + uv pip install "$(grep '^pre-commit' < requirements.txt)" - name: Run pre-commit on all files run: | + . venv/bin/activate pre-commit run --all-files --show-diff-on-failure --color=always hacs: @@ -72,10 +76,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements run: | - pip install --constraint=.github/workflows/constraints.txt pip - pip install -r requirements.txt --constraint=.github/workflows/constraints.txt + python -m venv venv + . venv/bin/activate + pip install "$(grep '^uv' < requirements.txt)" + uv pip install -r requirements.txt --prerelease=allow - name: Tests suite run: | + . venv/bin/activate pytest \ --cov=./ \ --cov-report=xml \ diff --git a/requirements.txt b/requirements.txt index f30fcf53..61ba8387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.8.2 -pip>=21.0,<24.3 +uv>=0.4 ruff==0.6.9 ocpp==1.0.0 websockets==13.1 From b4bffb7b3d28e62a0868c13caf23a756968729ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:10:25 +0200 Subject: [PATCH 187/370] build(deps): bump pytest-homeassistant-custom-component (#1354) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.171 to 0.13.172. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.171...0.13.172) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61ba8387..1c4bff8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==3.8.0 -pytest-homeassistant-custom-component==0.13.171 +pytest-homeassistant-custom-component==0.13.172 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 0abb024d362ab7727d1f17fb8f23243db506e302 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:45:33 +0200 Subject: [PATCH 188/370] build(deps): bump sphinx-rtd-theme from 2.0.0 to 3.0.0 (#1355) Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 2.0.0 to 3.0.0. - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/2.0.0...3.0.0) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index c4fbbdb7..0c427f4f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,4 @@ myst-parser==3.0.1 docutils==0.18.1 Jinja2==3.1.4 sphinx==7.1.2 -sphinx_rtd_theme==2.0.0 +sphinx_rtd_theme==3.0.0 From ba791d63f03bd635c64b07ce5e9a1450e3bd1cc5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:48:40 +0200 Subject: [PATCH 189/370] build(deps): bump pre-commit from 3.8.0 to 4.0.0 (#1353) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.8.0 to 4.0.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.8.0...v4.0.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1c4bff8a..a194d8c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.6.9 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 -pre-commit==3.8.0 +pre-commit==4.0.0 pytest-homeassistant-custom-component==0.13.172 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 11ee6786bf729ac7fa4588d97ee574c6f2dd5a97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:51:28 +0200 Subject: [PATCH 190/370] build(deps): bump pre-commit from 4.0.0 to 4.0.1 (#1358) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a194d8c3..b9ae2438 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.6.9 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 -pre-commit==4.0.0 +pre-commit==4.0.1 pytest-homeassistant-custom-component==0.13.172 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 2a77fb0d575b5dcb34012caee619aea01fa01a3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:51:54 +0200 Subject: [PATCH 191/370] build(deps): bump sphinx-rtd-theme from 3.0.0 to 3.0.1 (#1359) Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 3.0.0 to 3.0.1. - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/3.0.0...3.0.1) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0c427f4f..93ff933f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,4 @@ myst-parser==3.0.1 docutils==0.18.1 Jinja2==3.1.4 sphinx==7.1.2 -sphinx_rtd_theme==3.0.0 +sphinx_rtd_theme==3.0.1 From da0fa68843afa170eb48948c68fad849bcd19a0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:52:33 +0200 Subject: [PATCH 192/370] build(deps): bump actions/upload-artifact from 4.4.0 to 4.4.2 (#1360) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.0 to 4.4.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.4.0...v4.4.2) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 97d7601d..260f567a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.4.0 + uses: actions/upload-artifact@v4.4.2 if: ${{ github.event_name == 'push' }} with: name: ocpp From 90ca6fff3c236471b4acafccafda9bb4e27483cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 08:20:00 +0200 Subject: [PATCH 193/370] build(deps): bump pytest-homeassistant-custom-component (#1362) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.172 to 0.13.173. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.172...0.13.173) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b9ae2438..860c805b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.172 +pytest-homeassistant-custom-component==0.13.173 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 71922adaaee05c517a94944186420ff113545fea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 08:20:34 +0200 Subject: [PATCH 194/370] build(deps): bump ruff from 0.6.9 to 0.7.0 (#1364) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.9 to 0.7.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.6.9...0.7.0) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 860c805b..e563e703 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.8.2 uv>=0.4 -ruff==0.6.9 +ruff==0.7.0 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From 3342f86cc3fead8f0db6308637d0c079e53ecb4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 08:20:56 +0200 Subject: [PATCH 195/370] build(deps): bump actions/upload-artifact from 4.4.2 to 4.4.3 (#1361) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.2 to 4.4.3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.4.2...v4.4.3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 260f567a..59e218ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.4.2 + uses: actions/upload-artifact@v4.4.3 if: ${{ github.event_name == 'push' }} with: name: ocpp From 32ab437acba7afa8827cd321e127b6f9db37d5a7 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Thu, 31 Oct 2024 21:15:51 +1300 Subject: [PATCH 196/370] remove websocket dep warning (#1377) --- custom_components/ocpp/api.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 3108e7eb..49ad4eda 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -219,9 +219,7 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self._server = server return self - async def on_connect( - self, websocket: websockets.server.WebSocketServerProtocol, path: str - ): + async def on_connect(self, websocket: websockets.server.WebSocketServerProtocol): """Request handler executed for every new OCPP connection.""" if self.config.get(CONF_SKIP_SCHEMA_VALIDATION, DEFAULT_SKIP_SCHEMA_VALIDATION): _LOGGER.warning("Skipping websocket subprotocol validation") @@ -240,8 +238,8 @@ async def on_connect( ) return await websocket.close() - _LOGGER.info(f"Charger websocket path={path}") - cp_id = path.strip("/") + _LOGGER.info(f"Charger websocket path={websocket.path}") + cp_id = websocket.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] if self.cpid not in self.charge_points: _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") From 399bd3084c8aec903fe57155d64840d8ccc298a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:16:46 +0100 Subject: [PATCH 197/370] build(deps): bump colorlog from 6.8.2 to 6.9.0 (#1375) Bumps [colorlog](https://github.com/borntyping/python-colorlog) from 6.8.2 to 6.9.0. - [Release notes](https://github.com/borntyping/python-colorlog/releases) - [Commits](https://github.com/borntyping/python-colorlog/compare/v6.8.2...v6.9.0) --- updated-dependencies: - dependency-name: colorlog dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e563e703..93cad851 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -colorlog==6.8.2 +colorlog==6.9.0 uv>=0.4 ruff==0.7.0 ocpp==1.0.0 From 5b09f7df205750778d52ad634eaf144412588c7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:17:19 +0100 Subject: [PATCH 198/370] build(deps): bump crazy-max/ghaction-github-labeler from 5.0.0 to 5.1.0 (#1374) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.0.0 to 5.1.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v5.0.0...v5.1.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 566db778..66f2d96a 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.0.0 + uses: crazy-max/ghaction-github-labeler@v5.1.0 with: skip-delete: true From b7c9a6a178d0eecb70dafa521fd4c83bf004f1da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:17:50 +0100 Subject: [PATCH 199/370] build(deps): bump pytest-homeassistant-custom-component (#1373) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.173 to 0.13.175. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.173...0.13.175) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 93cad851..72f15842 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.173 +pytest-homeassistant-custom-component==0.13.175 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 48d661f33ce9d62600f527fe0eb8e6ca83b6e1aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:18:22 +0100 Subject: [PATCH 200/370] build(deps): bump actions/setup-python from 5.2.0 to 5.3.0 (#1369) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 59e218ac..9ccf3523 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.2.0 + uses: actions/setup-python@v5.3.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index eaa209f0..90b869ac 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.2.0 + uses: actions/setup-python@v5.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.2.0" + uses: "actions/setup-python@v5.3.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 2dd026ee4861b5aa2a2a96e3b4f005dbb3ea1d13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:19:31 +0100 Subject: [PATCH 201/370] build(deps): bump ruff from 0.7.0 to 0.7.1 (#1370) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.0 to 0.7.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/commits) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72f15842..d0227a21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.7.0 +ruff==0.7.1 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From 5bb80852bf926af52329e70233e8b1347ced03d4 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:35:28 +0100 Subject: [PATCH 202/370] revert labeler to v5.0.0 (#1378) --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 66f2d96a..566db778 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.1.0 + uses: crazy-max/ghaction-github-labeler@v5.0.0 with: skip-delete: true From f68363e21775b39ca5d33a2c3bfd2a40787aa60f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 Nov 2024 13:25:51 +0100 Subject: [PATCH 203/370] build(deps): bump pytest-homeassistant-custom-component (#1383) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.175 to 0.13.177. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.175...0.13.177) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d0227a21..ccf61c04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.175 +pytest-homeassistant-custom-component==0.13.177 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 4ff916987a7b2e6bad8f4f480f8f8a1a6bc3ed35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 Nov 2024 13:26:14 +0100 Subject: [PATCH 204/370] build(deps): bump ruff from 0.7.1 to 0.7.2 (#1382) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.1 to 0.7.2. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/commits) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ccf61c04..59e9cfe7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.7.1 +ruff==0.7.2 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From b8f190c1c800a672e8dbd5f181e66dc90aaa7fa7 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 6 Nov 2024 22:15:24 +0100 Subject: [PATCH 205/370] use ConfigType instead of Config (#1388) --- custom_components/ocpp/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index fabf43f7..e43985ea 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -3,7 +3,8 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -50,7 +51,7 @@ ) -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup(hass: HomeAssistant, config: ConfigType): """Read configuration from yaml.""" ocpp_config = config.get(DOMAIN, {}) From fb59acddb8fb6ce4fe669a646aec33c2b5fc16a3 Mon Sep 17 00:00:00 2001 From: Arseniy Lartsev <3534650+ars3niy@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:31:49 +0100 Subject: [PATCH 206/370] OCPP 2.0.1 support (#1381) * Create different ChargePoint based on subprotocol This also changes default subprotocol setting from OCPP 1.6 to 1.6 and 2.0.1. * Don't use CentralSystem from ChargePoint * Create base class for both OCPP versions * Move some functions to common ChargePoint class * Move entire post_connect to base ChargePoint * Move more boot notification handling to base ChargePoint * Add to base ChargePoint functions called by api.py * Split off OCPP part of get_supported_features * Initial OCPP 2.0.1 support * Implement get features and connector count for OCPP 2.0.1 * Implement connector status for OCPP 2.0.1 * Configure measurands on charger with OCPP 2.0.1 Also report available measurands to home assistant * Implement set availability and reboot with OCPP 2.0.1 * Implement trigger status notification with OCPP 2.0.1 * Implement get/set variables with OCPP 2.0.1 * Implement same authorisation logic for OCPP 2.0.1 Same as that used for OCPP 1.6, based on configuration.yaml * Support transaction measurements with OCPP 2.0.1 * Report charging state from OCPP 2.0.1 to HA * Report id token from OCPP 2.0.1 transactions to HA * Implement remote start and remote stop with OCPP 2.0.1 * Implement current limit with OCPP 2.0.1 * Clean up unused imports * Add common functions to be reused by OCPP 2.0.1 tests Only use switch and button pressing functions in existing 1.6 tests in this commit. * Add simple OCPP 2.0.1 test Test start-up, remote start and remote stop * Test realistic transaction with OCPP 2.0.1 * Test get/set variable services * Test more 2.0.1 calls and services * Move meter value processing to base ChargePoint * Reuse OCPP 1.6 meter value logic for 2.0.1 * Round energy consumption to 3 decimal places Subtracting to floats causes imprecision --------- Co-authored-by: Arseniy Lartsev --- custom_components/ocpp/api.py | 1444 +---------------- custom_components/ocpp/chargepoint.py | 851 ++++++++++ custom_components/ocpp/const.py | 3 +- custom_components/ocpp/enums.py | 2 + custom_components/ocpp/ocppv16.py | 834 ++++++++++ custom_components/ocpp/ocppv201.py | 688 ++++++++ custom_components/ocpp/services.yaml | 30 +- custom_components/ocpp/translations/en.json | 14 + .../ocpp/translations/i-default.json | 14 + tests/charge_point_test.py | 137 ++ tests/conftest.py | 2 +- ...arge_point.py => test_charge_point_v16.py} | 78 +- tests/test_charge_point_v201.py | 1252 ++++++++++++++ 13 files changed, 3880 insertions(+), 1469 deletions(-) create mode 100644 custom_components/ocpp/chargepoint.py create mode 100644 custom_components/ocpp/ocppv16.py create mode 100644 custom_components/ocpp/ocppv201.py create mode 100644 tests/charge_point_test.py rename tests/{test_charge_point.py => test_charge_point_v16.py} (94%) create mode 100644 tests/test_charge_point_v201.py diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 49ad4eda..9d23c8b9 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1,69 +1,25 @@ -"""Representation of a OCCP Entities.""" +"""Representation of a OCPP Entities.""" from __future__ import annotations -import asyncio -from collections import defaultdict -from datetime import datetime, timedelta, UTC -import json import logging -from math import sqrt -import secrets import ssl -import string -import time -from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTime +from homeassistant.const import STATE_OK from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_component, entity_registry -import homeassistant.helpers.config_validation as cv -from ocpp.exceptions import NotImplementedError -from ocpp.messages import CallError -from ocpp.routing import on -from ocpp.v16 import ChargePoint as cp, call, call_result -from ocpp.v16.enums import ( - Action, - AuthorizationStatus, - AvailabilityStatus, - AvailabilityType, - ChargePointStatus, - ChargingProfileKindType, - ChargingProfilePurposeType, - ChargingProfileStatus, - ChargingRateUnitType, - ClearChargingProfileStatus, - ConfigurationStatus, - DataTransferStatus, - Measurand, - MessageTrigger, - Phase, - RegistrationStatus, - RemoteStartStopStatus, - ResetStatus, - ResetType, - TriggerMessageStatus, - UnitOfMeasure, - UnlockStatus, -) -import voluptuous as vol +from websockets import Subprotocol import websockets.protocol import websockets.server +from .chargepoint import CentralSystemSettings +from .ocppv16 import ChargePoint as ChargePointv16 +from .ocppv201 import ChargePoint as ChargePointv201 + from .const import ( - CONF_AUTH_LIST, - CONF_AUTH_STATUS, CONF_CPID, CONF_CSID, - CONF_DEFAULT_AUTH_STATUS, - CONF_FORCE_SMART_CHARGING, CONF_HOST, - CONF_ID_TAG, - CONF_IDLE_INTERVAL, - CONF_METER_INTERVAL, - CONF_MONITORED_VARIABLES, - CONF_MONITORED_VARIABLES_AUTOCONFIG, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -74,18 +30,10 @@ CONF_WEBSOCKET_PING_INTERVAL, CONF_WEBSOCKET_PING_TIMEOUT, CONF_WEBSOCKET_PING_TRIES, - CONFIG, DEFAULT_CPID, DEFAULT_CSID, - DEFAULT_ENERGY_UNIT, - DEFAULT_FORCE_SMART_CHARGING, DEFAULT_HOST, - DEFAULT_IDLE_INTERVAL, - DEFAULT_MEASURAND, - DEFAULT_METER_INTERVAL, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, DEFAULT_PORT, - DEFAULT_POWER_UNIT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, DEFAULT_SSL_CERTFILE_PATH, @@ -96,18 +44,10 @@ DEFAULT_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TRIES, DOMAIN, - HA_ENERGY_UNIT, - HA_POWER_UNIT, - UNITS_OCCP_TO_HA, + OCPP_2_0, ) from .enums import ( - ConfigurationKey as ckey, - HAChargerDetails as cdet, HAChargerServices as csvcs, - HAChargerSession as csess, - HAChargerStatuses as cstat, - OcppMisc as om, - Profiles as prof, ) _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -116,46 +56,6 @@ # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) -TIME_MINUTES = UnitOfTime.MINUTES - -UFW_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("firmware_url"): cv.string, - vol.Optional("delay_hours"): cv.positive_int, - } -) -CONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - vol.Required("value"): cv.string, - } -) -GCONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - } -) -GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("upload_url"): cv.string, - } -) -TRANS_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("vendor_id"): cv.string, - vol.Optional("message_id"): cv.string, - vol.Optional("data"): cv.string, - } -) -CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional("limit_amps"): cv.positive_float, - vol.Optional("limit_watts"): cv.positive_int, - vol.Optional("conn_id"): cv.positive_int, - vol.Optional("custom_profile"): vol.Any(cv.string, dict), - } -) - class CentralSystem: """Server for handling OCPP connections.""" @@ -166,22 +66,28 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): self.entry = entry self.host = entry.data.get(CONF_HOST, DEFAULT_HOST) self.port = entry.data.get(CONF_PORT, DEFAULT_PORT) - self.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) - self.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) - self.websocket_close_timeout = entry.data.get( + + self.settings = CentralSystemSettings() + self.settings.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) + self.settings.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) + + self.settings.websocket_close_timeout = entry.data.get( CONF_WEBSOCKET_CLOSE_TIMEOUT, DEFAULT_WEBSOCKET_CLOSE_TIMEOUT ) - self.websocket_ping_tries = entry.data.get( + self.settings.websocket_ping_tries = entry.data.get( CONF_WEBSOCKET_PING_TRIES, DEFAULT_WEBSOCKET_PING_TRIES ) - self.websocket_ping_interval = entry.data.get( + self.settings.websocket_ping_interval = entry.data.get( CONF_WEBSOCKET_PING_INTERVAL, DEFAULT_WEBSOCKET_PING_INTERVAL ) - self.websocket_ping_timeout = entry.data.get( + self.settings.websocket_ping_timeout = entry.data.get( CONF_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TIMEOUT ) + self.settings.config = entry.data - self.subprotocol = entry.data.get(CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL) + self.subprotocols: list[Subprotocol] = entry.data.get( + CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL + ).split(",") self._server = None self.config = entry.data self.id = entry.entry_id @@ -210,10 +116,10 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self.on_connect, self.host, self.port, - subprotocols=[self.subprotocol], + subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() ping_timeout=None, - close_timeout=self.websocket_close_timeout, + close_timeout=self.settings.websocket_close_timeout, ssl=self.ssl_context, ) self._server = server @@ -241,14 +147,21 @@ async def on_connect(self, websocket: websockets.server.WebSocketServerProtocol) _LOGGER.info(f"Charger websocket path={websocket.path}") cp_id = websocket.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] - if self.cpid not in self.charge_points: + if self.settings.cpid not in self.charge_points: _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") - charge_point = ChargePoint(cp_id, websocket, self.hass, self.entry, self) - self.charge_points[self.cpid] = charge_point + if websocket.subprotocol and websocket.subprotocol.startswith(OCPP_2_0): + charge_point = ChargePointv201( + cp_id, websocket, self.hass, self.entry, self.settings + ) + else: + charge_point = ChargePointv16( + cp_id, websocket, self.hass, self.entry, self.settings + ) + self.charge_points[self.settings.cpid] = charge_point await charge_point.start() else: _LOGGER.info(f"Charger {cp_id} reconnected to {self.host}:{self.port}.") - charge_point: ChargePoint = self.charge_points[self.cpid] + charge_point = self.charge_points[self.settings.cpid] await charge_point.reconnect(websocket) _LOGGER.info(f"Charger {cp_id} disconnected from {self.host}:{self.port}.") @@ -318,1297 +231,8 @@ async def set_charger_state( resp = await self.charge_points[cp_id].unlock() return resp - async def update(self, cp_id: str): - """Update sensors values in HA.""" - er = entity_registry.async_get(self.hass) - dr = device_registry.async_get(self.hass) - identifiers = {(DOMAIN, cp_id)} - dev = dr.async_get_device(identifiers) - # _LOGGER.info("Device id: %s updating", dev.name) - for ent in entity_registry.async_entries_for_device(er, dev.id): - # _LOGGER.info("Entity id: %s updating", ent.entity_id) - self.hass.async_create_task( - entity_component.async_update_entity(self.hass, ent.entity_id) - ) - def device_info(self): """Return device information.""" return { "identifiers": {(DOMAIN, self.id)}, } - - -class ChargePoint(cp): - """Server side representation of a charger.""" - - def __init__( - self, - id: str, - connection: websockets.server.WebSocketServerProtocol, - hass: HomeAssistant, - entry: ConfigEntry, - central: CentralSystem, - interval_meter_metrics: int = 10, - skip_schema_validation: bool = False, - ): - """Instantiate a ChargePoint.""" - - super().__init__(id, connection) - - for action in self.route_map: - self.route_map[action]["_skip_schema_validation"] = skip_schema_validation - - self.interval_meter_metrics = interval_meter_metrics - self.hass = hass - self.entry = entry - self.central = central - self.status = "init" - # Indicates if the charger requires a reboot to apply new - # configuration. - self._requires_reboot = False - self.preparing = asyncio.Event() - self.active_transaction_id: int = 0 - self.triggered_boot_notification = False - self.received_boot_notification = False - self.post_connect_success = False - self.tasks = None - self._charger_reports_session_energy = False - self._metrics = defaultdict(lambda: Metric(None, None)) - self._metrics[cdet.identifier.value].value = id - self._metrics[csess.session_time.value].unit = TIME_MINUTES - self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value - self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value - self._attr_supported_features = prof.NONE - self._metrics[cstat.reconnects.value].value: int = 0 - alphabet = string.ascii_uppercase + string.digits - self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) - - async def post_connect(self): - """Logic to be executed right after a charger connects.""" - - # Define custom service handles for charge point - async def handle_clear_profile(call): - """Handle the clear profile service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - await self.clear_profile() - - async def handle_update_firmware(call): - """Handle the firmware update service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("firmware_url") - delay = int(call.data.get("delay_hours", 0)) - await self.update_firmware(url, delay) - - async def handle_configure(call): - """Handle the configure service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - value = call.data.get("value") - await self.configure(key, value) - - async def handle_get_configuration(call): - """Handle the get configuration service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - await self.get_configuration(key) - - async def handle_get_diagnostics(call): - """Handle the get get diagnostics service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("upload_url") - await self.get_diagnostics(url) - - async def handle_data_transfer(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - vendor = call.data.get("vendor_id") - message = call.data.get("message_id", "") - data = call.data.get("data", "") - await self.data_transfer(vendor, message, data) - - async def handle_set_charge_rate(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - amps = call.data.get("limit_amps", None) - watts = call.data.get("limit_watts", None) - id = call.data.get("conn_id", 0) - custom_profile = call.data.get("custom_profile", None) - if custom_profile is not None: - if type(custom_profile) is str: - custom_profile = custom_profile.replace("'", '"') - custom_profile = json.loads(custom_profile) - await self.set_charge_rate(profile=custom_profile, conn_id=id) - elif watts is not None: - await self.set_charge_rate(limit_watts=watts, conn_id=id) - elif amps is not None: - await self.set_charge_rate(limit_amps=amps, conn_id=id) - - try: - self.status = STATE_OK - await asyncio.sleep(2) - await self.get_supported_features() - resp = await self.get_configuration(ckey.number_of_connectors.value) - self._metrics[cdet.connectors.value].value = resp - await self.get_configuration(ckey.heartbeat_interval.value) - - all_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND - ) - autodetect_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES_AUTOCONFIG, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, - ) - - key = ckey.meter_values_sampled_data.value - - if autodetect_measurands: - accepted_measurands = [] - cfg_ok = [ - ConfigurationStatus.accepted, - ConfigurationStatus.reboot_required, - ] - - for measurand in all_measurands.split(","): - _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") - req = call.ChangeConfiguration(key=key, value=measurand) - resp = await self.call(req) - if resp.status in cfg_ok: - _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") - accepted_measurands.append(measurand) - - accepted_measurands = ",".join(accepted_measurands) - else: - accepted_measurands = all_measurands - - # Quirk: - # Workaround for a bug on chargers that have invalid MeterValuesSampledData - # configuration and reboot while the server requests MeterValuesSampledData. - # By setting the configuration directly without checking current configuration - # as done when calling self.configure, the server avoids charger reboot. - # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 - if len(accepted_measurands) > 0: - req = call.ChangeConfiguration(key=key, value=accepted_measurands) - resp = await self.call(req) - _LOGGER.debug( - f"'{self.id}' measurands set manually to {accepted_measurands}" - ) - - chgr_measurands = await self.get_configuration(key) - - if len(accepted_measurands) > 0: - _LOGGER.debug( - f"'{self.id}' allowed measurands: '{accepted_measurands}'" - ) - await self.configure(key, accepted_measurands) - else: - _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") - _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") - - updated_entry = {**self.entry.data} - updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands - self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) - - await self.configure( - ckey.meter_value_sample_interval.value, - str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), - ) - await self.configure( - ckey.clock_aligned_data_interval.value, - str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), - ) - # await self.configure( - # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) - # ) - # await self.start_transaction() - - # Register custom services with home assistant - self.hass.services.async_register( - DOMAIN, - csvcs.service_configure.value, - handle_configure, - CONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_configuration.value, - handle_get_configuration, - GCONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_data_transfer.value, - handle_data_transfer, - TRANS_SERVICE_DATA_SCHEMA, - ) - if prof.SMART in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_set_charge_rate.value, - handle_set_charge_rate, - CHRGR_SERVICE_DATA_SCHEMA, - ) - if prof.FW in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, - csvcs.service_update_firmware.value, - handle_update_firmware, - UFW_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_diagnostics.value, - handle_get_diagnostics, - GDIAG_SERVICE_DATA_SCHEMA, - ) - self.post_connect_success = True - _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") - - # nice to have, but not needed for integration to function - # and can cause issues with some chargers - await self.configure(ckey.web_socket_ping_interval.value, "60") - await self.set_availability() - if prof.REM in self._attr_supported_features: - if self.received_boot_notification is False: - await self.trigger_boot_notification() - await self.trigger_status_notification() - except NotImplementedError as e: - _LOGGER.error("Configuration of the charger failed: %s", e) - - async def get_supported_features(self): - """Get supported features.""" - req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) - resp = await self.call(req) - try: - feature_list = (resp.configuration_key[0][om.value.value]).split(",") - except (IndexError, KeyError, TypeError): - feature_list = [""] - if feature_list[0] == "": - _LOGGER.warning("No feature profiles detected, defaulting to Core") - await self.notify_ha("No feature profiles detected, defaulting to Core") - feature_list = [om.feature_profile_core.value] - if self.central.config.get( - CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING - ): - _LOGGER.warning("Force Smart Charging feature profile") - self._attr_supported_features |= prof.SMART - for item in feature_list: - item = item.strip().replace(" ", "") - if item == om.feature_profile_core.value: - self._attr_supported_features |= prof.CORE - elif item == om.feature_profile_firmware.value: - self._attr_supported_features |= prof.FW - elif item == om.feature_profile_smart.value: - self._attr_supported_features |= prof.SMART - elif item == om.feature_profile_reservation.value: - self._attr_supported_features |= prof.RES - elif item == om.feature_profile_remote.value: - self._attr_supported_features |= prof.REM - elif item == om.feature_profile_auth.value: - self._attr_supported_features |= prof.AUTH - else: - _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) - await self.notify_ha( - f"Warning: Unknown feature profile detected ignoring {item}" - ) - self._metrics[cdet.features.value].value = self._attr_supported_features - _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) - - async def trigger_boot_notification(self): - """Trigger a boot notification.""" - req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) - resp = await self.call(req) - if resp.status == TriggerMessageStatus.accepted: - self.triggered_boot_notification = True - return True - else: - self.triggered_boot_notification = False - _LOGGER.warning("Failed with response: %s", resp.status) - return False - - async def trigger_status_notification(self): - """Trigger status notifications for all connectors.""" - return_value = True - nof_connectors = int(self._metrics[cdet.connectors.value].value) - for id in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={id}") - req = call.TriggerMessage( - requested_message=MessageTrigger.status_notification, - connector_id=int(id), - ) - resp = await self.call(req) - if resp.status != TriggerMessageStatus.accepted: - _LOGGER.warning("Failed with response: %s", resp.status) - return_value = False - return return_value - - async def clear_profile(self): - """Clear all charging profiles.""" - req = call.ClearChargingProfile() - resp = await self.call(req) - if resp.status == ClearChargingProfileStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Clear profile failed with response {resp.status}" - ) - return False - - async def set_charge_rate( - self, - limit_amps: int = 32, - limit_watts: int = 22000, - conn_id: int = 0, - profile: dict | None = None, - ): - """Set a charging profile with defined limit.""" - if profile is not None: # assumes advanced user and correct profile format - req = call.SetChargingProfile( - connector_id=conn_id, cs_charging_profiles=profile - ) - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - - if prof.SMART in self._attr_supported_features: - resp = await self.get_configuration( - ckey.charging_schedule_allowed_charging_rate_unit.value - ) - _LOGGER.info( - "Charger supports setting the following units: %s", - resp, - ) - _LOGGER.info("If more than one unit supported default unit is Amps") - # Some chargers (e.g. Teison) don't support querying charging rate unit - if resp is None: - _LOGGER.warning("Failed to query charging rate unit, assuming Amps") - resp = om.current.value - if om.current.value in resp: - lim = limit_amps - units = ChargingRateUnitType.amps.value - else: - lim = limit_watts - units = ChargingRateUnitType.watts.value - resp = await self.get_configuration( - ckey.charge_profile_max_stack_level.value - ) - stack_level = int(resp) - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, - ) - else: - _LOGGER.info("Smart charging is not supported by this charger") - return False - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - else: - _LOGGER.debug( - "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." - ) - # try a lower stack level for chargers where level < maximum, not <= - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level - 1, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, - ) - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - - async def set_availability(self, state: bool = True): - """Change availability.""" - if state is True: - typ = AvailabilityType.operative.value - else: - typ = AvailabilityType.inoperative.value - - req = call.ChangeAvailability(connector_id=0, type=typ) - resp = await self.call(req) - if resp.status == AvailabilityStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set availability failed with response {resp.status}" - ) - return False - - async def start_transaction(self): - """Remote start a transaction.""" - _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) - req = call.RemoteStartTransaction(connector_id=1, id_tag=self._remote_id_tag) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Start transaction failed with response {resp.status}" - ) - return False - - async def stop_transaction(self): - """Request remote stop of current transaction. - - Leaves charger in finishing state until unplugged. - Use reset() to make the charger available again for remote start - """ - if self.active_transaction_id == 0: - return True - req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Stop transaction failed with response {resp.status}" - ) - return False - - async def reset(self, typ: str = ResetType.hard): - """Hard reset charger unless soft reset requested.""" - self._metrics[cstat.reconnects.value].value = 0 - req = call.Reset(typ) - resp = await self.call(req) - if resp.status == ResetStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Reset failed with response {resp.status}") - return False - - async def unlock(self, connector_id: int = 1): - """Unlock charger if requested.""" - req = call.UnlockConnector(connector_id) - resp = await self.call(req) - if resp.status == UnlockStatus.unlocked: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") - return False - - async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(firmware_url) - except vol.MultipleInvalid as e: - _LOGGER.debug("Failed to parse url: %s", e) - update_time = (datetime.now(tz=UTC) + timedelta(hours=wait_time)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - req = call.UpdateFirmware(location=url, retrieve_date=update_time) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp firmware updating") - return False - - async def get_diagnostics(self, upload_url: str): - """Upload diagnostic data to server from charger.""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(upload_url) - except vol.MultipleInvalid as e: - _LOGGER.warning("Failed to parse url: %s", e) - req = call.GetDiagnostics(location=url) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp diagnostics uploading") - return False - - async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): - """Request vendor specific data transfer from charger.""" - req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) - resp = await self.call(req) - if resp.status == DataTransferStatus.accepted: - _LOGGER.info( - "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", - vendor_id, - message_id, - data, - resp.data, - ) - self._metrics[cdet.data_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Data transfer failed with response {resp.status}" - ) - return False - - async def get_configuration(self, key: str = ""): - """Get Configuration of charger for supported keys else return None.""" - if key == "": - req = call.GetConfiguration() - else: - req = call.GetConfiguration(key=[key]) - resp = await self.call(req) - if resp.configuration_key: - value = resp.configuration_key[0][om.value.value] - _LOGGER.debug("Get Configuration for %s: %s", key, value) - self._metrics[cdet.config_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.config_response.value].extra_attr = {key: value} - return value - if resp.unknown_key: - _LOGGER.warning("Get Configuration returned unknown key for: %s", key) - await self.notify_ha(f"Warning: charger reports {key} is unknown") - return None - - async def configure(self, key: str, value: str): - """Configure charger by setting the key to target value. - - First the configuration key is read using GetConfiguration. The key's - value is compared with the target value. If the key is already set to - the correct value nothing is done. - - If the key has a different value a ChangeConfiguration request is issued. - - """ - req = call.GetConfiguration(key=[key]) - - resp = await self.call(req) - - if resp.unknown_key is not None: - if key in resp.unknown_key: - _LOGGER.warning("%s is unknown (not supported)", key) - return - - for key_value in resp.configuration_key: - # If the key already has the targeted value we don't need to set - # it. - if key_value[om.key.value] == key and key_value[om.value.value] == value: - return - - if key_value.get(om.readonly.name, False): - _LOGGER.warning("%s is a read only setting", key) - await self.notify_ha(f"Warning: {key} is read-only") - - req = call.ChangeConfiguration(key=key, value=value) - - resp = await self.call(req) - - if resp.status in [ - ConfigurationStatus.rejected, - ConfigurationStatus.not_supported, - ]: - _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) - await self.notify_ha( - f"Warning: charger reported {resp.status} while setting {key}={value}" - ) - - if resp.status == ConfigurationStatus.reboot_required: - self._requires_reboot = True - await self.notify_ha(f"A reboot is required to apply {key}={value}") - - async def _get_specific_response(self, unique_id, timeout): - # The ocpp library silences CallErrors by default. See - # https://github.com/mobilityhouse/ocpp/issues/104. - # This code 'unsilences' CallErrors by raising them as exception - # upon receiving. - resp = await super()._get_specific_response(unique_id, timeout) - - if isinstance(resp, CallError): - raise resp.to_exception() - - return resp - - async def monitor_connection(self): - """Monitor the connection, by measuring the connection latency.""" - self._metrics[cstat.latency_ping.value].unit = "ms" - self._metrics[cstat.latency_pong.value].unit = "ms" - connection = self._connection - timeout_counter = 0 - while connection.open: - try: - await asyncio.sleep(self.central.websocket_ping_interval) - time0 = time.perf_counter() - latency_ping = self.central.websocket_ping_timeout * 1000 - pong_waiter = await asyncio.wait_for( - connection.ping(), timeout=self.central.websocket_ping_timeout - ) - time1 = time.perf_counter() - latency_ping = round(time1 - time0, 3) * 1000 - latency_pong = self.central.websocket_ping_timeout * 1000 - await asyncio.wait_for( - pong_waiter, timeout=self.central.websocket_ping_timeout - ) - timeout_counter = 0 - time2 = time.perf_counter() - latency_pong = round(time2 - time1, 3) * 1000 - _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", - ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong - - except TimeoutError as timeout_exception: - _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", - ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong - timeout_counter += 1 - if timeout_counter > self.central.websocket_ping_tries: - _LOGGER.debug( - f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", - ) - raise timeout_exception - else: - continue - - async def _handle_call(self, msg): - try: - await super()._handle_call(msg) - except NotImplementedError as e: - response = msg.create_call_error(e).to_json() - await self._send(response) - - async def start(self): - """Start charge point.""" - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) - - async def run(self, tasks): - """Run a specified list of tasks.""" - self.tasks = [asyncio.ensure_future(task) for task in tasks] - try: - await asyncio.gather(*self.tasks) - except TimeoutError: - pass - except websockets.exceptions.WebSocketException as websocket_exception: - _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") - except Exception as other_exception: - _LOGGER.error( - f"Unexpected exception in connection to '{self.id}': '{other_exception}'", - exc_info=True, - ) - finally: - await self.stop() - - async def stop(self): - """Close connection and cancel ongoing tasks.""" - self.status = STATE_UNAVAILABLE - if self._connection.open: - _LOGGER.debug(f"Closing websocket to '{self.id}'") - await self._connection.close() - for task in self.tasks: - task.cancel() - - async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): - """Reconnect charge point.""" - _LOGGER.debug(f"Reconnect websocket to {self.id}") - - await self.stop() - self.status = STATE_OK - self._connection = connection - self._metrics[cstat.reconnects.value].value += 1 - if self.post_connect_success is True: - await self.run([super().start(), self.monitor_connection()]) - else: - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) - - async def async_update_device_info(self, boot_info: dict): - """Update device info asynchronuously.""" - - _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) - identifiers = { - (DOMAIN, self.central.cpid), - (DOMAIN, self.id), - } - serial = boot_info.get(om.charge_point_serial_number.name, None) - if serial is not None: - identifiers.add((DOMAIN, serial)) - - registry = device_registry.async_get(self.hass) - registry.async_get_or_create( - config_entry_id=self.entry.entry_id, - identifiers=identifiers, - name=self.central.cpid, - manufacturer=boot_info.get(om.charge_point_vendor.name, None), - model=boot_info.get(om.charge_point_model.name, None), - suggested_area="Garage", - sw_version=boot_info.get(om.firmware_version.name, None), - ) - - def process_phases(self, data): - """Process phase data from meter values .""" - - def average_of_nonzero(values): - nonzero_values: list = [v for v in values if float(v) != 0.0] - nof_values: int = len(nonzero_values) - average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 - return average - - measurand_data = {} - for item in data: - # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} - measurand = item.get(om.measurand.value, None) - phase = item.get(om.phase.value, None) - value = item.get(om.value.value, None) - unit = item.get(om.unit.value, None) - context = item.get(om.context.value, None) - # where an empty string is supplied convert to 0 - try: - value = float(value) - except ValueError: - value = 0 - if measurand is not None and phase is not None and unit is not None: - if measurand not in measurand_data: - measurand_data[measurand] = {} - measurand_data[measurand][om.unit.value] = unit - measurand_data[measurand][phase] = value - self._metrics[measurand].unit = unit - self._metrics[measurand].extra_attr[om.unit.value] = unit - self._metrics[measurand].extra_attr[phase] = value - self._metrics[measurand].extra_attr[om.context.value] = context - - line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] - line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] - line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] - - for metric, phase_info in measurand_data.items(): - metric_value = None - if metric in [Measurand.voltage.value]: - if not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Line to neutral voltages are averaged - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_neutral_phases] - ) - elif not phase_info.keys().isdisjoint(line_to_line_phases): - # Line to line voltages are averaged and converted to line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_line_phases] - ) / sqrt(3) - elif not phase_info.keys().isdisjoint(line_phases): - # Workaround for chargers that don't follow engineering convention - # Assumes voltages are line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_phases] - ) - else: - if not phase_info.keys().isdisjoint(line_phases): - metric_value = sum( - phase_info.get(phase, 0) for phase in line_phases - ) - elif not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Workaround for some chargers that erroneously use line to neutral for current - metric_value = sum( - phase_info.get(phase, 0) for phase in line_to_neutral_phases - ) - - if metric_value is not None: - metric_unit = phase_info.get(om.unit.value) - _LOGGER.debug( - "process_phases: metric: %s, phase_info: %s value: %f unit :%s", - metric, - phase_info, - metric_value, - metric_unit, - ) - if metric_unit == DEFAULT_POWER_UNIT: - self._metrics[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_POWER_UNIT - elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_ENERGY_UNIT - else: - self._metrics[metric].value = float(metric_value) - self._metrics[metric].unit = metric_unit - - @on(Action.meter_values) - def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Request handler for MeterValues Calls.""" - - transaction_id: int = kwargs.get(om.transaction_id.name, 0) - - # If missing meter_start or active_transaction_id try to restore from HA states. If HA - # does not have values either, generate new ones. - if self._metrics[csess.meter_start.value].value is None: - value = self.get_ha_metric(csess.meter_start.value) - if value is None: - value = self._metrics[DEFAULT_MEASURAND].value - else: - value = float(value) - _LOGGER.debug( - f"{csess.meter_start.value} was None, restored value={value} from HA." - ) - self._metrics[csess.meter_start.value].value = value - if self._metrics[csess.transaction_id.value].value is None: - value = self.get_ha_metric(csess.transaction_id.value) - if value is None: - value = kwargs.get(om.transaction_id.name) - else: - value = int(value) - _LOGGER.debug( - f"{csess.transaction_id.value} was None, restored value={value} from HA." - ) - self._metrics[csess.transaction_id.value].value = value - self.active_transaction_id = value - - transaction_matches: bool = False - # match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0 - if transaction_id == self.active_transaction_id and transaction_id != 0: - transaction_matches = True - elif transaction_id != 0: - _LOGGER.warning("Unknown transaction detected with id=%i", transaction_id) - - for bucket in meter_value: - unprocessed = bucket[om.sampled_value.name] - processed_keys = [] - for idx, sampled_value in enumerate(bucket[om.sampled_value.name]): - measurand = sampled_value.get(om.measurand.value, None) - value = sampled_value.get(om.value.value, None) - unit = sampled_value.get(om.unit.value, None) - phase = sampled_value.get(om.phase.value, None) - location = sampled_value.get(om.location.value, None) - context = sampled_value.get(om.context.value, None) - # where an empty string is supplied convert to 0 - try: - value = float(value) - except ValueError: - value = 0 - - if len(sampled_value.keys()) == 1: # Backwards compatibility - measurand = DEFAULT_MEASURAND - unit = DEFAULT_ENERGY_UNIT - - if measurand == DEFAULT_MEASURAND and unit is None: - unit = DEFAULT_ENERGY_UNIT - - if self._metrics[csess.meter_start.value].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. - self._charger_reports_session_energy = True - - if phase is None: - if unit == DEFAULT_POWER_UNIT: - self._metrics[measurand].value = value / 1000 - self._metrics[measurand].unit = HA_POWER_UNIT - elif ( - measurand == DEFAULT_MEASURAND - and self._charger_reports_session_energy - ): - if transaction_matches: - if unit == DEFAULT_ENERGY_UNIT: - value = value / 1000 - unit = HA_ENERGY_UNIT - self._metrics[csess.session_energy.value].value = value - self._metrics[csess.session_energy.value].unit = unit - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - else: - if unit == DEFAULT_ENERGY_UNIT: - value = value / 1000 - unit = HA_ENERGY_UNIT - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit - elif unit == DEFAULT_ENERGY_UNIT: - if transaction_matches: - self._metrics[measurand].value = value / 1000 - self._metrics[measurand].unit = HA_ENERGY_UNIT - else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit - if location is not None: - self._metrics[measurand].extra_attr[om.location.value] = ( - location - ) - if context is not None: - self._metrics[measurand].extra_attr[om.context.value] = context - processed_keys.append(idx) - for idx in sorted(processed_keys, reverse=True): - unprocessed.pop(idx) - # _LOGGER.debug("Meter data not yet processed: %s", unprocessed) - if unprocessed is not None: - self.process_phases(unprocessed) - if transaction_matches: - self._metrics[csess.session_time.value].value = round( - ( - int(time.time()) - - float(self._metrics[csess.transaction_id.value].value) - ) - / 60 - ) - self._metrics[csess.session_time.value].unit = "min" - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = float( - self._metrics[DEFAULT_MEASURAND].value or 0 - ) - float(self._metrics[csess.meter_start.value].value) - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.MeterValues() - - @on(Action.boot_notification) - def on_boot_notification(self, **kwargs): - """Handle a boot notification.""" - resp = call_result.BootNotification( - current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - interval=3600, - status=RegistrationStatus.accepted.value, - ) - self.received_boot_notification = True - _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) - # update metrics - self._metrics[cdet.model.value].value = kwargs.get( - om.charge_point_model.name, None - ) - self._metrics[cdet.vendor.value].value = kwargs.get( - om.charge_point_vendor.name, None - ) - self._metrics[cdet.firmware_version.value].value = kwargs.get( - om.firmware_version.name, None - ) - self._metrics[cdet.serial.value].value = kwargs.get( - om.charge_point_serial_number.name, None - ) - - self.hass.async_create_task(self.async_update_device_info(kwargs)) - self.hass.async_create_task(self.central.update(self.central.cpid)) - if self.triggered_boot_notification is False: - self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) - self.hass.async_create_task(self.post_connect()) - return resp - - @on(Action.status_notification) - def on_status_notification(self, connector_id, error_code, status, **kwargs): - """Handle a status notification.""" - - if connector_id == 0 or connector_id is None: - self._metrics[cstat.status.value].value = status - self._metrics[cstat.error_code.value].value = error_code - elif connector_id == 1: - self._metrics[cstat.status_connector.value].value = status - self._metrics[cstat.error_code_connector.value].value = error_code - if connector_id >= 1: - self._metrics[cstat.status_connector.value].extra_attr[connector_id] = ( - status - ) - self._metrics[cstat.error_code_connector.value].extra_attr[connector_id] = ( - error_code - ) - if ( - status == ChargePointStatus.suspended_ev.value - or status == ChargePointStatus.suspended_evse.value - ): - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StatusNotification() - - @on(Action.firmware_status_notification) - def on_firmware_status(self, status, **kwargs): - """Handle firmware status notification.""" - self._metrics[cstat.firmware_status.value].value = status - self.hass.async_create_task(self.central.update(self.central.cpid)) - self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) - return call_result.FirmwareStatusNotification() - - @on(Action.diagnostics_status_notification) - def on_diagnostics_status(self, status, **kwargs): - """Handle diagnostics status notification.""" - _LOGGER.info("Diagnostics upload status: %s", status) - self.hass.async_create_task( - self.notify_ha(f"Diagnostics upload status: {status}") - ) - return call_result.DiagnosticsStatusNotification() - - @on(Action.security_event_notification) - def on_security_event(self, type, timestamp, **kwargs): - """Handle security event notification.""" - _LOGGER.info( - "Security event notification received: %s at %s [techinfo: %s]", - type, - timestamp, - kwargs.get(om.tech_info.name, "none"), - ) - self.hass.async_create_task( - self.notify_ha(f"Security event notification received: {type}") - ) - return call_result.SecurityEventNotification() - - def get_authorization_status(self, id_tag): - """Get the authorization status for an id_tag.""" - # authorize if its the tag of this charger used for remote start_transaction - if id_tag == self._remote_id_tag: - return AuthorizationStatus.accepted.value - # get the domain wide configuration - config = self.hass.data[DOMAIN].get(CONFIG, {}) - # get the default authorization status. Use accept if not configured - default_auth_status = config.get( - CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value - ) - # get the authorization list - auth_list = config.get(CONF_AUTH_LIST, {}) - # search for the entry, based on the id_tag - auth_status = None - for auth_entry in auth_list: - id_entry = auth_entry.get(CONF_ID_TAG, None) - if id_tag == id_entry: - # get the authorization status, use the default if not configured - auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) - _LOGGER.debug( - f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" - ) - break - - if auth_status is None: - auth_status = default_auth_status - _LOGGER.debug( - f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" - ) - return auth_status - - @on(Action.authorize) - def on_authorize(self, id_tag, **kwargs): - """Handle an Authorization request.""" - self._metrics[cstat.id_tag.value].value = id_tag - auth_status = self.get_authorization_status(id_tag) - return call_result.Authorize(id_tag_info={om.status.value: auth_status}) - - @on(Action.start_transaction) - def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): - """Handle a Start Transaction request.""" - - auth_status = self.get_authorization_status(id_tag) - if auth_status == AuthorizationStatus.accepted.value: - self.active_transaction_id = int(time.time()) - self._metrics[cstat.id_tag.value].value = id_tag - self._metrics[cstat.stop_reason.value].value = "" - self._metrics[csess.transaction_id.value].value = self.active_transaction_id - self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 - result = call_result.StartTransaction( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, - transaction_id=self.active_transaction_id, - ) - else: - result = call_result.StartTransaction( - id_tag_info={om.status.value: auth_status}, transaction_id=0 - ) - self.hass.async_create_task(self.central.update(self.central.cpid)) - return result - - @on(Action.stop_transaction) - def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): - """Stop the current transaction.""" - - if transaction_id != self.active_transaction_id: - _LOGGER.error( - "Stop transaction received for unknown transaction id=%i", - transaction_id, - ) - self.active_transaction_id = 0 - self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = int( - meter_stop - ) / 1000 - float(self._metrics[csess.meter_start.value].value) - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StopTransaction( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value} - ) - - @on(Action.data_transfer) - def on_data_transfer(self, vendor_id, **kwargs): - """Handle a Data transfer request.""" - _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) - self._metrics[cdet.data_transfer.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} - return call_result.DataTransfer(status=DataTransferStatus.accepted.value) - - @on(Action.heartbeat) - def on_heartbeat(self, **kwargs): - """Handle a Heartbeat.""" - now = datetime.now(tz=UTC) - self._metrics[cstat.heartbeat.value].value = now - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) - - @property - def supported_features(self) -> int: - """Flag of Ocpp features that are supported.""" - return self._attr_supported_features - - def get_metric(self, measurand: str): - """Return last known value for given measurand.""" - return self._metrics[measurand].value - - def get_ha_metric(self, measurand: str): - """Return last known value in HA for given measurand.""" - entity_id = "sensor." + "_".join( - [self.central.cpid.lower(), measurand.lower().replace(".", "_")] - ) - try: - value = self.hass.states.get(entity_id).state - except Exception as e: - _LOGGER.debug(f"An error occurred when getting entity state from HA: {e}") - return None - if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN: - return None - return value - - def get_extra_attr(self, measurand: str): - """Return last known extra attributes for given measurand.""" - return self._metrics[measurand].extra_attr - - def get_unit(self, measurand: str): - """Return unit of given measurand.""" - return self._metrics[measurand].unit - - def get_ha_unit(self, measurand: str): - """Return home assistant unit of given measurand.""" - return self._metrics[measurand].ha_unit - - async def notify_ha(self, msg: str, title: str = "Ocpp integration"): - """Notify user via HA web frontend.""" - await self.hass.services.async_call( - PN_DOMAIN, - "create", - service_data={ - "title": title, - "message": msg, - }, - blocking=False, - ) - return True - - -class Metric: - """Metric class.""" - - def __init__(self, value, unit): - """Initialize a Metric.""" - self._value = value - self._unit = unit - self._extra_attr = {} - - @property - def value(self): - """Get the value of the metric.""" - return self._value - - @value.setter - def value(self, value): - """Set the value of the metric.""" - self._value = value - - @property - def unit(self): - """Get the unit of the metric.""" - return self._unit - - @unit.setter - def unit(self, unit: str): - """Set the unit of the metric.""" - self._unit = unit - - @property - def ha_unit(self): - """Get the home assistant unit of the metric.""" - return UNITS_OCCP_TO_HA.get(self._unit, self._unit) - - @property - def extra_attr(self): - """Get the extra attributes of the metric.""" - return self._extra_attr - - @extra_attr.setter - def extra_attr(self, extra_attr: dict): - """Set the unit of the metric.""" - self._extra_attr = extra_attr diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py new file mode 100644 index 00000000..2655fd11 --- /dev/null +++ b/custom_components/ocpp/chargepoint.py @@ -0,0 +1,851 @@ +"""Common classes for charge points of all OCPP versions.""" + +import asyncio +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum +import json +import logging +from math import sqrt +import secrets +import string +import time +from types import MappingProxyType +from typing import Any + +from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import UnitOfTime +from homeassistant.helpers import device_registry, entity_component, entity_registry +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +import websockets.server + +from ocpp.charge_point import ChargePoint as cp +from ocpp.v16 import call as callv16 +from ocpp.v16 import call_result as call_resultv16 +from ocpp.v16.enums import UnitOfMeasure, AuthorizationStatus, Measurand, Phase +from ocpp.v201 import call as callv201 +from ocpp.v201 import call_result as call_resultv201 +from ocpp.messages import CallError +from ocpp.exceptions import NotImplementedError + +from .enums import ( + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CONF_AUTH_LIST, + CONF_AUTH_STATUS, + CONF_DEFAULT_AUTH_STATUS, + CONF_ID_TAG, + CONF_MONITORED_VARIABLES, + CONFIG, + DEFAULT_ENERGY_UNIT, + DEFAULT_POWER_UNIT, + DEFAULT_MEASURAND, + DOMAIN, + HA_ENERGY_UNIT, + HA_POWER_UNIT, + UNITS_OCCP_TO_HA, +) + +UFW_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("firmware_url"): cv.string, + vol.Optional("delay_hours"): cv.positive_int, + } +) +CONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): cv.string, + vol.Required("value"): cv.string, + } +) +GCONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): cv.string, + } +) +GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("upload_url"): cv.string, + } +) +TRANS_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("vendor_id"): cv.string, + vol.Optional("message_id"): cv.string, + vol.Optional("data"): cv.string, + } +) +CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("limit_amps"): cv.positive_float, + vol.Optional("limit_watts"): cv.positive_int, + vol.Optional("conn_id"): cv.positive_int, + vol.Optional("custom_profile"): vol.Any(cv.string, dict), + } +) + +TIME_MINUTES = UnitOfTime.MINUTES +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class CentralSystemSettings: + """A subset of CentralSystem properties needed by a ChargePoint.""" + + websocket_close_timeout: int + websocket_ping_interval: int + websocket_ping_timeout: int + websocket_ping_tries: int + csid: str + cpid: str + config: MappingProxyType[str, Any] + + +class Metric: + """Metric class.""" + + def __init__(self, value, unit): + """Initialize a Metric.""" + self._value = value + self._unit = unit + self._extra_attr = {} + + @property + def value(self): + """Get the value of the metric.""" + return self._value + + @value.setter + def value(self, value): + """Set the value of the metric.""" + self._value = value + + @property + def unit(self): + """Get the unit of the metric.""" + return self._unit + + @unit.setter + def unit(self, unit: str): + """Set the unit of the metric.""" + self._unit = unit + + @property + def ha_unit(self): + """Get the home assistant unit of the metric.""" + return UNITS_OCCP_TO_HA.get(self._unit, self._unit) + + @property + def extra_attr(self): + """Get the extra attributes of the metric.""" + return self._extra_attr + + @extra_attr.setter + def extra_attr(self, extra_attr: dict): + """Set the unit of the metric.""" + self._extra_attr = extra_attr + + +class OcppVersion(str, Enum): + """OCPP version choice.""" + + V16 = "1.6" + V201 = "2.0.1" + + +class SetVariableResult(Enum): + """A response to successful SetVariable call.""" + + accepted = 0 + reboot_required = 1 + + +@dataclass +class MeasurandValue: + """Version-independent representation of a measurand.""" + + measurand: str + value: float + phase: str | None + unit: str | None + context: str | None + location: str | None + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id, + connection, + version: OcppVersion, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int, + skip_schema_validation: bool, + ): + """Instantiate a ChargePoint.""" + + super().__init__(id, connection, 10) + if version == OcppVersion.V16: + self._call = callv16 + self._call_result = call_resultv16 + self._ocpp_version = "1.6" + elif version == OcppVersion.V201: + self._call = callv201 + self._call_result = call_resultv201 + self._ocpp_version = "2.0.1" + + for action in self.route_map: + self.route_map[action]["_skip_schema_validation"] = skip_schema_validation + + self.interval_meter_metrics = interval_meter_metrics + self.hass = hass + self.entry = entry + self.central = central + self.status = "init" + # Indicates if the charger requires a reboot to apply new + # configuration. + self._requires_reboot = False + self.preparing = asyncio.Event() + self.active_transaction_id: int = 0 + self.triggered_boot_notification = False + self.received_boot_notification = False + self.post_connect_success = False + self.tasks = None + self._charger_reports_session_energy = False + self._metrics = defaultdict(lambda: Metric(None, None)) + self._metrics[cdet.identifier.value].value = id + self._metrics[csess.session_time.value].unit = TIME_MINUTES + self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value + self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value + self._attr_supported_features = prof.NONE + self._metrics[cstat.reconnects.value].value = 0 + alphabet = string.ascii_uppercase + string.digits + self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + return 0 + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + pass + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + return "" + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + pass + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + pass + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + return prof.NONE + + async def fetch_supported_features(self): + """Get supported features.""" + self._attr_supported_features = await self.get_supported_features() + self._metrics[cdet.features.value].value = self._attr_supported_features + _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) + + async def post_connect(self): + """Logic to be executed right after a charger connects.""" + + # Define custom service handles for charge point + async def handle_clear_profile(call): + """Handle the clear profile service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + await self.clear_profile() + + async def handle_update_firmware(call): + """Handle the firmware update service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + url = call.data.get("firmware_url") + delay = int(call.data.get("delay_hours", 0)) + await self.update_firmware(url, delay) + + async def handle_get_diagnostics(call): + """Handle the get get diagnostics service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + url = call.data.get("upload_url") + await self.get_diagnostics(url) + + async def handle_data_transfer(call): + """Handle the data transfer service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + vendor = call.data.get("vendor_id") + message = call.data.get("message_id", "") + data = call.data.get("data", "") + await self.data_transfer(vendor, message, data) + + async def handle_set_charge_rate(call): + """Handle the data transfer service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + amps = call.data.get("limit_amps", None) + watts = call.data.get("limit_watts", None) + id = call.data.get("conn_id", 0) + custom_profile = call.data.get("custom_profile", None) + if custom_profile is not None: + if type(custom_profile) is str: + custom_profile = custom_profile.replace("'", '"') + custom_profile = json.loads(custom_profile) + await self.set_charge_rate(profile=custom_profile, conn_id=id) + elif watts is not None: + await self.set_charge_rate(limit_watts=watts, conn_id=id) + elif amps is not None: + await self.set_charge_rate(limit_amps=amps, conn_id=id) + + """Logic to be executed right after a charger connects.""" + + try: + self.status = STATE_OK + await asyncio.sleep(2) + await self.fetch_supported_features() + num_connectors: int = await self.get_number_of_connectors() + self._metrics[cdet.connectors.value].value = num_connectors + await self.get_heartbeat_interval() + + accepted_measurands: str = await self.get_supported_measurands() + updated_entry = {**self.entry.data} + updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands + self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) + + await self.set_standard_configuration() + + # Register custom services with home assistant + self.register_version_specific_services() + self.hass.services.async_register( + DOMAIN, + csvcs.service_data_transfer.value, + handle_data_transfer, + TRANS_SERVICE_DATA_SCHEMA, + ) + if prof.SMART in self._attr_supported_features: + self.hass.services.async_register( + DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_set_charge_rate.value, + handle_set_charge_rate, + CHRGR_SERVICE_DATA_SCHEMA, + ) + if prof.FW in self._attr_supported_features: + self.hass.services.async_register( + DOMAIN, + csvcs.service_update_firmware.value, + handle_update_firmware, + UFW_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_diagnostics.value, + handle_get_diagnostics, + GDIAG_SERVICE_DATA_SCHEMA, + ) + self.post_connect_success = True + _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") + + # nice to have, but not needed for integration to function + # and can cause issues with some chargers + await self.set_availability() + if prof.REM in self._attr_supported_features: + if self.received_boot_notification is False: + await self.trigger_boot_notification() + await self.trigger_status_notification() + except NotImplementedError as e: + _LOGGER.error("Configuration of the charger failed: %s", e) + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + pass + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + pass + + async def clear_profile(self): + """Clear all charging profiles.""" + pass + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + pass + + async def set_availability(self, state: bool = True) -> bool: + """Change availability.""" + return False + + async def start_transaction(self) -> bool: + """Remote start a transaction.""" + return False + + async def stop_transaction(self) -> bool: + """Request remote stop of current transaction. + + Leaves charger in finishing state until unplugged. + Use reset() to make the charger available again for remote start + """ + return False + + async def reset(self, typ: str | None = None) -> bool: + """Hard reset charger unless soft reset requested.""" + return False + + async def unlock(self, connector_id: int = 1) -> bool: + """Unlock charger if requested.""" + return False + + async def update_firmware(self, firmware_url: str, wait_time: int = 0): + """Update charger with new firmware if available.""" + """where firmware_url is the http or https url of the new firmware""" + """and wait_time is hours from now to wait before install""" + pass + + async def get_diagnostics(self, upload_url: str): + """Upload diagnostic data to server from charger.""" + pass + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + pass + + async def get_configuration(self, key: str = "") -> str | None: + """Get Configuration of charger for supported keys else return None.""" + return None + + async def configure(self, key: str, value: str) -> SetVariableResult | None: + """Configure charger by setting the key to target value.""" + return None + + async def _get_specific_response(self, unique_id, timeout): + # The ocpp library silences CallErrors by default. See + # https://github.com/mobilityhouse/ocpp/issues/104. + # This code 'unsilences' CallErrors by raising them as exception + # upon receiving. + resp = await super()._get_specific_response(unique_id, timeout) + + if isinstance(resp, CallError): + raise resp.to_exception() + + return resp + + async def monitor_connection(self): + """Monitor the connection, by measuring the connection latency.""" + self._metrics[cstat.latency_ping.value].unit = "ms" + self._metrics[cstat.latency_pong.value].unit = "ms" + connection = self._connection + timeout_counter = 0 + while connection.open: + try: + await asyncio.sleep(self.central.websocket_ping_interval) + time0 = time.perf_counter() + latency_ping = self.central.websocket_ping_timeout * 1000 + pong_waiter = await asyncio.wait_for( + connection.ping(), timeout=self.central.websocket_ping_timeout + ) + time1 = time.perf_counter() + latency_ping = round(time1 - time0, 3) * 1000 + latency_pong = self.central.websocket_ping_timeout * 1000 + await asyncio.wait_for( + pong_waiter, timeout=self.central.websocket_ping_timeout + ) + timeout_counter = 0 + time2 = time.perf_counter() + latency_pong = round(time2 - time1, 3) * 1000 + _LOGGER.debug( + f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + ) + self._metrics[cstat.latency_ping.value].value = latency_ping + self._metrics[cstat.latency_pong.value].value = latency_pong + + except TimeoutError as timeout_exception: + _LOGGER.debug( + f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + ) + self._metrics[cstat.latency_ping.value].value = latency_ping + self._metrics[cstat.latency_pong.value].value = latency_pong + timeout_counter += 1 + if timeout_counter > self.central.websocket_ping_tries: + _LOGGER.debug( + f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", + ) + raise timeout_exception + else: + continue + + async def _handle_call(self, msg): + try: + await super()._handle_call(msg) + except NotImplementedError as e: + response = msg.create_call_error(e).to_json() + await self._send(response) + + async def start(self): + """Start charge point.""" + await self.run( + [super().start(), self.post_connect(), self.monitor_connection()] + ) + + async def run(self, tasks): + """Run a specified list of tasks.""" + self.tasks = [asyncio.ensure_future(task) for task in tasks] + try: + await asyncio.gather(*self.tasks) + except TimeoutError: + pass + except websockets.exceptions.WebSocketException as websocket_exception: + _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") + except Exception as other_exception: + _LOGGER.error( + f"Unexpected exception in connection to '{self.id}': '{other_exception}'", + exc_info=True, + ) + finally: + await self.stop() + + async def stop(self): + """Close connection and cancel ongoing tasks.""" + self.status = STATE_UNAVAILABLE + if self._connection.open: + _LOGGER.debug(f"Closing websocket to '{self.id}'") + await self._connection.close() + for task in self.tasks: + task.cancel() + + async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): + """Reconnect charge point.""" + _LOGGER.debug(f"Reconnect websocket to {self.id}") + + await self.stop() + self.status = STATE_OK + self._connection = connection + self._metrics[cstat.reconnects.value].value += 1 + if self.post_connect_success is True: + await self.run([super().start(), self.monitor_connection()]) + else: + await self.run( + [super().start(), self.post_connect(), self.monitor_connection()] + ) + + async def async_update_device_info( + self, serial: str, vendor: str, model: str, firmware_version: str + ): + """Update device info asynchronuously.""" + + self._metrics[cdet.model.value].value = model + self._metrics[cdet.vendor.value].value = vendor + self._metrics[cdet.firmware_version.value].value = firmware_version + self._metrics[cdet.serial.value].value = serial + + identifiers = { + (DOMAIN, self.central.cpid), + (DOMAIN, self.id), + } + if serial is not None: + identifiers.add((DOMAIN, serial)) + + registry = device_registry.async_get(self.hass) + registry.async_get_or_create( + config_entry_id=self.entry.entry_id, + identifiers=identifiers, + name=self.central.cpid, + manufacturer=vendor, + model=model, + suggested_area="Garage", + sw_version=firmware_version, + ) + + def _register_boot_notification(self): + self.hass.async_create_task(self.update(self.central.cpid)) + if self.triggered_boot_notification is False: + self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) + self.hass.async_create_task(self.post_connect()) + + async def update(self, cp_id: str): + """Update sensors values in HA.""" + er = entity_registry.async_get(self.hass) + dr = device_registry.async_get(self.hass) + identifiers = {(DOMAIN, cp_id)} + dev = dr.async_get_device(identifiers) + # _LOGGER.info("Device id: %s updating", dev.name) + for ent in entity_registry.async_entries_for_device(er, dev.id): + # _LOGGER.info("Entity id: %s updating", ent.entity_id) + self.hass.async_create_task( + entity_component.async_update_entity(self.hass, ent.entity_id) + ) + + def get_authorization_status(self, id_tag): + """Get the authorization status for an id_tag.""" + # authorize if its the tag of this charger used for remote start_transaction + if id_tag == self._remote_id_tag: + return AuthorizationStatus.accepted.value + # get the domain wide configuration + config = self.hass.data[DOMAIN].get(CONFIG, {}) + # get the default authorization status. Use accept if not configured + default_auth_status = config.get( + CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value + ) + # get the authorization list + auth_list = config.get(CONF_AUTH_LIST, {}) + # search for the entry, based on the id_tag + auth_status = None + for auth_entry in auth_list: + id_entry = auth_entry.get(CONF_ID_TAG, None) + if id_tag == id_entry: + # get the authorization status, use the default if not configured + auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) + _LOGGER.debug( + f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" + ) + break + + if auth_status is None: + auth_status = default_auth_status + _LOGGER.debug( + f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" + ) + return auth_status + + def process_phases(self, data: list[MeasurandValue]): + """Process phase data from meter values .""" + + def average_of_nonzero(values): + nonzero_values: list = [v for v in values if v != 0.0] + nof_values: int = len(nonzero_values) + average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 + return average + + measurand_data = {} + for item in data: + # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} + measurand = item.measurand + phase = item.phase + value = item.value + unit = item.unit + context = item.context + if measurand is not None and phase is not None and unit is not None: + if measurand not in measurand_data: + measurand_data[measurand] = {} + measurand_data[measurand][om.unit.value] = unit + measurand_data[measurand][phase] = value + self._metrics[measurand].unit = unit + self._metrics[measurand].extra_attr[om.unit.value] = unit + self._metrics[measurand].extra_attr[phase] = value + self._metrics[measurand].extra_attr[om.context.value] = context + + line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] + line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] + line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] + + for metric, phase_info in measurand_data.items(): + metric_value = None + if metric in [Measurand.voltage.value]: + if not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Line to neutral voltages are averaged + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_to_neutral_phases] + ) + elif not phase_info.keys().isdisjoint(line_to_line_phases): + # Line to line voltages are averaged and converted to line to neutral + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_to_line_phases] + ) / sqrt(3) + elif not phase_info.keys().isdisjoint(line_phases): + # Workaround for chargers that don't follow engineering convention + # Assumes voltages are line to neutral + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_phases] + ) + else: + if not phase_info.keys().isdisjoint(line_phases): + metric_value = sum( + phase_info.get(phase, 0) for phase in line_phases + ) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Workaround for some chargers that erroneously use line to neutral for current + metric_value = sum( + phase_info.get(phase, 0) for phase in line_to_neutral_phases + ) + + if metric_value is not None: + metric_unit = phase_info.get(om.unit.value) + _LOGGER.debug( + "process_phases: metric: %s, phase_info: %s value: %f unit :%s", + metric, + phase_info, + metric_value, + metric_unit, + ) + if metric_unit == DEFAULT_POWER_UNIT: + self._metrics[metric].value = metric_value / 1000 + self._metrics[metric].unit = HA_POWER_UNIT + elif metric_unit == DEFAULT_ENERGY_UNIT: + self._metrics[metric].value = metric_value / 1000 + self._metrics[metric].unit = HA_ENERGY_UNIT + else: + self._metrics[metric].value = metric_value + self._metrics[metric].unit = metric_unit + + @staticmethod + def get_energy_kwh(measurand_value: MeasurandValue) -> float: + """Convert energy value from charger to kWh.""" + if (measurand_value.unit == "Wh") or (measurand_value.unit is None): + return measurand_value.value / 1000 + return measurand_value.value + + def process_measurands( + self, meter_values: list[list[MeasurandValue]], is_transaction: bool + ): + """Process all value from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" + for bucket in meter_values: + unprocessed: list[MeasurandValue] = [] + for idx in range(len(bucket)): + sampled_value: MeasurandValue = bucket[idx] + measurand = sampled_value.measurand + value = sampled_value.value + unit = sampled_value.unit + phase = sampled_value.phase + location = sampled_value.location + context = sampled_value.context + # where an empty string is supplied convert to 0 + + if sampled_value.measurand is None: # Backwards compatibility + measurand = DEFAULT_MEASURAND + unit = DEFAULT_ENERGY_UNIT + + if measurand == DEFAULT_MEASURAND and unit is None: + unit = DEFAULT_ENERGY_UNIT + + if unit == DEFAULT_ENERGY_UNIT: + value = ChargePoint.get_energy_kwh(sampled_value) + unit = HA_ENERGY_UNIT + + if unit == DEFAULT_POWER_UNIT: + value = value / 1000 + unit = HA_POWER_UNIT + + if self._metrics[csess.meter_start.value].value == 0: + # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + self._charger_reports_session_energy = True + + if phase is None: + if ( + measurand == DEFAULT_MEASURAND + and self._charger_reports_session_energy + ): + if is_transaction: + self._metrics[csess.session_energy.value].value = value + self._metrics[csess.session_energy.value].unit = unit + self._metrics[csess.session_energy.value].extra_attr[ + cstat.id_tag.name + ] = self._metrics[cstat.id_tag.value].value + else: + self._metrics[measurand].value = value + self._metrics[measurand].unit = unit + else: + self._metrics[measurand].value = value + self._metrics[measurand].unit = unit + if ( + is_transaction + and (measurand == DEFAULT_MEASURAND) + and (self._metrics[csess.meter_start].value is not None) + and (self._metrics[csess.meter_start].unit == unit) + ): + meter_start = self._metrics[csess.meter_start].value + self._metrics[csess.session_energy.value].value = ( + round(1000 * (value - meter_start)) / 1000 + ) + self._metrics[csess.session_energy.value].unit = unit + if location is not None: + self._metrics[measurand].extra_attr[om.location.value] = ( + location + ) + if context is not None: + self._metrics[measurand].extra_attr[om.context.value] = context + else: + unprocessed.append(sampled_value) + self.process_phases(unprocessed) + + @property + def supported_features(self) -> int: + """Flag of Ocpp features that are supported.""" + return self._attr_supported_features + + def get_metric(self, measurand: str): + """Return last known value for given measurand.""" + return self._metrics[measurand].value + + def get_ha_metric(self, measurand: str): + """Return last known value in HA for given measurand.""" + entity_id = "sensor." + "_".join( + [self.central.cpid.lower(), measurand.lower().replace(".", "_")] + ) + try: + value = self.hass.states.get(entity_id).state + except Exception as e: + _LOGGER.debug(f"An error occurred when getting entity state from HA: {e}") + return None + if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN: + return None + return value + + def get_extra_attr(self, measurand: str): + """Return last known extra attributes for given measurand.""" + return self._metrics[measurand].extra_attr + + def get_unit(self, measurand: str): + """Return unit of given measurand.""" + return self._metrics[measurand].unit + + def get_ha_unit(self, measurand: str): + """Return home assistant unit of given measurand.""" + return self._metrics[measurand].ha_unit + + async def notify_ha(self, msg: str, title: str = "Ocpp integration"): + """Notify user via HA web frontend.""" + await self.hass.services.async_call( + PN_DOMAIN, + "create", + service_data={ + "title": title, + "message": msg, + }, + blocking=False, + ) + return True diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 74e3b2cf..3a924868 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -49,7 +49,8 @@ DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") -DEFAULT_SUBPROTOCOL = "ocpp1.6" +DEFAULT_SUBPROTOCOL = "ocpp1.6,ocpp2.0.1" +OCPP_2_0 = "ocpp2.0" DEFAULT_METER_INTERVAL = 60 DEFAULT_IDLE_INTERVAL = 900 DEFAULT_WEBSOCKET_CLOSE_TIMEOUT = 10 diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index bc3b5ccb..8539ba8d 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -15,7 +15,9 @@ class HAChargerServices(str, Enum): service_unlock = "unlock" service_update_firmware = "update_firmware" service_configure = "configure" + service_configure_v201 = "configure_v201" service_get_configuration = "get_configuration" + service_get_configuration_v201 = "get_configuration_v201" service_get_diagnostics = "get_diagnostics" service_clear_profile = "clear_profile" service_data_transfer = "data_transfer" diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py new file mode 100644 index 00000000..4efc7569 --- /dev/null +++ b/custom_components/ocpp/ocppv16.py @@ -0,0 +1,834 @@ +"""Representation of a OCPP 1.6 charging station.""" + +from datetime import datetime, timedelta, UTC +import logging + +from homeassistant.const import STATE_UNAVAILABLE +import time + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import voluptuous as vol +import websockets.server + +from ocpp.routing import on +from ocpp.v16 import call, call_result +from ocpp.v16.enums import ( + Action, + AuthorizationStatus, + AvailabilityStatus, + AvailabilityType, + ChargePointStatus, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingProfileStatus, + ChargingRateUnitType, + ClearChargingProfileStatus, + ConfigurationStatus, + DataTransferStatus, + Measurand, + MessageTrigger, + RegistrationStatus, + RemoteStartStopStatus, + ResetStatus, + ResetType, + TriggerMessageStatus, + UnlockStatus, +) + +from .chargepoint import CentralSystemSettings, OcppVersion, MeasurandValue +from .chargepoint import ChargePoint as cp +from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA + +from .enums import ( + ConfigurationKey as ckey, + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CONF_FORCE_SMART_CHARGING, + CONF_IDLE_INTERVAL, + CONF_METER_INTERVAL, + CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_FORCE_SMART_CHARGING, + DEFAULT_IDLE_INTERVAL, + DEFAULT_MEASURAND, + DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DOMAIN, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id: str, + connection: websockets.server.WebSocketServerProtocol, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int = 10, + skip_schema_validation: bool = False, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + OcppVersion.V16, + hass, + entry, + central, + interval_meter_metrics, + skip_schema_validation, + ) + + async def get_number_of_connectors(self): + """Return number of connectors on this charger.""" + return await self.get_configuration(ckey.number_of_connectors.value) + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + await self.get_configuration(ckey.heartbeat_interval.value) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + all_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND + ) + autodetect_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ) + + key = ckey.meter_values_sampled_data.value + + if autodetect_measurands: + accepted_measurands = [] + cfg_ok = [ + ConfigurationStatus.accepted, + ConfigurationStatus.reboot_required, + ] + + for measurand in all_measurands.split(","): + _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") + req = call.ChangeConfiguration(key=key, value=measurand) + resp = await self.call(req) + if resp.status in cfg_ok: + _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") + accepted_measurands.append(measurand) + + accepted_measurands = ",".join(accepted_measurands) + else: + accepted_measurands = all_measurands + + # Quirk: + # Workaround for a bug on chargers that have invalid MeterValuesSampledData + # configuration and reboot while the server requests MeterValuesSampledData. + # By setting the configuration directly without checking current configuration + # as done when calling self.configure, the server avoids charger reboot. + # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 + if len(accepted_measurands) > 0: + req = call.ChangeConfiguration(key=key, value=accepted_measurands) + resp = await self.call(req) + _LOGGER.debug( + f"'{self.id}' measurands set manually to {accepted_measurands}" + ) + + chgr_measurands = await self.get_configuration(key) + + if len(accepted_measurands) > 0: + _LOGGER.debug(f"'{self.id}' allowed measurands: '{accepted_measurands}'") + await self.configure(key, accepted_measurands) + else: + _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") + _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") + + return accepted_measurands + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + await self.configure( + ckey.meter_value_sample_interval.value, + str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), + ) + await self.configure( + ckey.clock_aligned_data_interval.value, + str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), + ) + # await self.configure( + # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) + # ) + # await self.start_transaction() + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + + async def handle_configure(call): + """Handle the configure service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + key = call.data.get("ocpp_key") + value = call.data.get("value") + await self.configure(key, value) + + async def handle_get_configuration(call): + """Handle the get configuration service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + key = call.data.get("ocpp_key") + await self.get_configuration(key) + + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure.value, + handle_configure, + CONF_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration.value, + handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + ) + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + features = prof.NONE + req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) + resp = await self.call(req) + try: + feature_list = (resp.configuration_key[0][om.value.value]).split(",") + except (IndexError, KeyError, TypeError): + feature_list = [""] + if feature_list[0] == "": + _LOGGER.warning("No feature profiles detected, defaulting to Core") + await self.notify_ha("No feature profiles detected, defaulting to Core") + feature_list = [om.feature_profile_core.value] + + if self.central.config.get( + CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING + ): + _LOGGER.warning("Force Smart Charging feature profile") + features |= prof.SMART + + for item in feature_list: + item = item.strip().replace(" ", "") + if item == om.feature_profile_core.value: + features |= prof.CORE + elif item == om.feature_profile_firmware.value: + features |= prof.FW + elif item == om.feature_profile_smart.value: + features |= prof.SMART + elif item == om.feature_profile_reservation.value: + features |= prof.RES + elif item == om.feature_profile_remote.value: + features |= prof.REM + elif item == om.feature_profile_auth.value: + features |= prof.AUTH + else: + _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) + await self.notify_ha( + f"Warning: Unknown feature profile detected ignoring {item}" + ) + return features + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) + resp = await self.call(req) + if resp.status == TriggerMessageStatus.accepted: + self.triggered_boot_notification = True + return True + else: + self.triggered_boot_notification = False + _LOGGER.warning("Failed with response: %s", resp.status) + return False + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + return_value = True + nof_connectors = int(self._metrics[cdet.connectors.value].value) + for id in range(0, nof_connectors + 1): + _LOGGER.debug(f"trigger status notification for connector={id}") + req = call.TriggerMessage( + requested_message=MessageTrigger.status_notification, + connector_id=int(id), + ) + resp = await self.call(req) + if resp.status != TriggerMessageStatus.accepted: + _LOGGER.warning("Failed with response: %s", resp.status) + return_value = False + return return_value + + async def clear_profile(self): + """Clear all charging profiles.""" + req = call.ClearChargingProfile() + resp = await self.call(req) + if resp.status == ClearChargingProfileStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Clear profile failed with response {resp.status}" + ) + return False + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + if profile is not None: # assumes advanced user and correct profile format + req = call.SetChargingProfile( + connector_id=conn_id, cs_charging_profiles=profile + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" + ) + return False + + if prof.SMART in self._attr_supported_features: + resp = await self.get_configuration( + ckey.charging_schedule_allowed_charging_rate_unit.value + ) + _LOGGER.info( + "Charger supports setting the following units: %s", + resp, + ) + _LOGGER.info("If more than one unit supported default unit is Amps") + # Some chargers (e.g. Teison) don't support querying charging rate unit + if resp is None: + _LOGGER.warning("Failed to query charging rate unit, assuming Amps") + resp = om.current.value + if om.current.value in resp: + lim = limit_amps + units = ChargingRateUnitType.amps.value + else: + lim = limit_watts + units = ChargingRateUnitType.watts.value + resp = await self.get_configuration( + ckey.charge_profile_max_stack_level.value + ) + stack_level = int(resp) + req = call.SetChargingProfile( + connector_id=conn_id, + cs_charging_profiles={ + om.charging_profile_id.value: 8, + om.stack_level.value: stack_level, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, + om.charging_schedule.value: { + om.charging_rate_unit.value: units, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: lim} + ], + }, + }, + ) + else: + _LOGGER.info("Smart charging is not supported by this charger") + return False + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + else: + _LOGGER.debug( + "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." + ) + # try a lower stack level for chargers where level < maximum, not <= + req = call.SetChargingProfile( + connector_id=conn_id, + cs_charging_profiles={ + om.charging_profile_id.value: 8, + om.stack_level.value: stack_level - 1, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, + om.charging_schedule.value: { + om.charging_rate_unit.value: units, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: lim} + ], + }, + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" + ) + return False + + async def set_availability(self, state: bool = True): + """Change availability.""" + if state is True: + typ = AvailabilityType.operative.value + else: + typ = AvailabilityType.inoperative.value + + req = call.ChangeAvailability(connector_id=0, type=typ) + resp = await self.call(req) + if resp.status == AvailabilityStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set availability failed with response {resp.status}" + ) + return False + + async def start_transaction(self): + """Remote start a transaction.""" + _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) + req = call.RemoteStartTransaction(connector_id=1, id_tag=self._remote_id_tag) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Start transaction failed with response {resp.status}" + ) + return False + + async def stop_transaction(self): + """Request remote stop of current transaction. + + Leaves charger in finishing state until unplugged. + Use reset() to make the charger available again for remote start + """ + if self.active_transaction_id == 0: + return True + req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Stop transaction failed with response {resp.status}" + ) + return False + + async def reset(self, typ: str = ResetType.hard): + """Hard reset charger unless soft reset requested.""" + self._metrics[cstat.reconnects.value].value = 0 + req = call.Reset(typ) + resp = await self.call(req) + if resp.status == ResetStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Reset failed with response {resp.status}") + return False + + async def unlock(self, connector_id: int = 1): + """Unlock charger if requested.""" + req = call.UnlockConnector(connector_id) + resp = await self.call(req) + if resp.status == UnlockStatus.unlocked: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") + return False + + async def update_firmware(self, firmware_url: str, wait_time: int = 0): + """Update charger with new firmware if available.""" + """where firmware_url is the http or https url of the new firmware""" + """and wait_time is hours from now to wait before install""" + if prof.FW in self._attr_supported_features: + schema = vol.Schema(vol.Url()) + try: + url = schema(firmware_url) + except vol.MultipleInvalid as e: + _LOGGER.debug("Failed to parse url: %s", e) + update_time = (datetime.now(tz=UTC) + timedelta(hours=wait_time)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + req = call.UpdateFirmware(location=url, retrieve_date=update_time) + resp = await self.call(req) + _LOGGER.info("Response: %s", resp) + return True + else: + _LOGGER.warning("Charger does not support ocpp firmware updating") + return False + + async def get_diagnostics(self, upload_url: str): + """Upload diagnostic data to server from charger.""" + if prof.FW in self._attr_supported_features: + schema = vol.Schema(vol.Url()) + try: + url = schema(upload_url) + except vol.MultipleInvalid as e: + _LOGGER.warning("Failed to parse url: %s", e) + req = call.GetDiagnostics(location=url) + resp = await self.call(req) + _LOGGER.info("Response: %s", resp) + return True + else: + _LOGGER.warning("Charger does not support ocpp diagnostics uploading") + return False + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) + resp = await self.call(req) + if resp.status == DataTransferStatus.accepted: + _LOGGER.info( + "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", + vendor_id, + message_id, + data, + resp.data, + ) + self._metrics[cdet.data_response.value].value = datetime.now(tz=UTC) + self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Data transfer failed with response {resp.status}" + ) + return False + + async def get_configuration(self, key: str = ""): + """Get Configuration of charger for supported keys else return None.""" + if key == "": + req = call.GetConfiguration() + else: + req = call.GetConfiguration(key=[key]) + resp = await self.call(req) + if resp.configuration_key: + value = resp.configuration_key[0][om.value.value] + _LOGGER.debug("Get Configuration for %s: %s", key, value) + self._metrics[cdet.config_response.value].value = datetime.now(tz=UTC) + self._metrics[cdet.config_response.value].extra_attr = {key: value} + return value + if resp.unknown_key: + _LOGGER.warning("Get Configuration returned unknown key for: %s", key) + await self.notify_ha(f"Warning: charger reports {key} is unknown") + return None + + async def configure(self, key: str, value: str): + """Configure charger by setting the key to target value. + + First the configuration key is read using GetConfiguration. The key's + value is compared with the target value. If the key is already set to + the correct value nothing is done. + + If the key has a different value a ChangeConfiguration request is issued. + + """ + req = call.GetConfiguration(key=[key]) + + resp = await self.call(req) + + if resp.unknown_key is not None: + if key in resp.unknown_key: + _LOGGER.warning("%s is unknown (not supported)", key) + return + + for key_value in resp.configuration_key: + # If the key already has the targeted value we don't need to set + # it. + if key_value[om.key.value] == key and key_value[om.value.value] == value: + return + + if key_value.get(om.readonly.name, False): + _LOGGER.warning("%s is a read only setting", key) + await self.notify_ha(f"Warning: {key} is read-only") + + req = call.ChangeConfiguration(key=key, value=value) + + resp = await self.call(req) + + if resp.status in [ + ConfigurationStatus.rejected, + ConfigurationStatus.not_supported, + ]: + _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) + await self.notify_ha( + f"Warning: charger reported {resp.status} while setting {key}={value}" + ) + + if resp.status == ConfigurationStatus.reboot_required: + self._requires_reboot = True + await self.notify_ha(f"A reboot is required to apply {key}={value}") + + async def async_update_device_info_v16(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + await self.async_update_device_info( + boot_info.get(om.charge_point_serial_number.name, None), + boot_info.get(om.charge_point_vendor.name, None), + boot_info.get(om.charge_point_model.name, None), + boot_info.get(om.firmware_version.name, None), + ) + + @on(Action.meter_values) + def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): + """Request handler for MeterValues Calls.""" + + transaction_id: int = kwargs.get(om.transaction_id.name, 0) + + # If missing meter_start or active_transaction_id try to restore from HA states. If HA + # does not have values either, generate new ones. + if self._metrics[csess.meter_start.value].value is None: + value = self.get_ha_metric(csess.meter_start.value) + if value is None: + value = self._metrics[DEFAULT_MEASURAND].value + else: + value = float(value) + _LOGGER.debug( + f"{csess.meter_start.value} was None, restored value={value} from HA." + ) + self._metrics[csess.meter_start.value].value = value + if self._metrics[csess.transaction_id.value].value is None: + value = self.get_ha_metric(csess.transaction_id.value) + if value is None: + value = kwargs.get(om.transaction_id.name) + else: + value = int(value) + _LOGGER.debug( + f"{csess.transaction_id.value} was None, restored value={value} from HA." + ) + self._metrics[csess.transaction_id.value].value = value + self.active_transaction_id = value + + transaction_matches: bool = False + # match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0 + if transaction_id == self.active_transaction_id and transaction_id != 0: + transaction_matches = True + elif transaction_id != 0: + _LOGGER.warning("Unknown transaction detected with id=%i", transaction_id) + + meter_values: list[list[MeasurandValue]] = [] + for bucket in meter_value: + measurands: list[MeasurandValue] = [] + for sampled_value in bucket[om.sampled_value.name]: + measurand = sampled_value.get(om.measurand.value, None) + value = sampled_value.get(om.value.value, None) + # where an empty string is supplied convert to 0 + try: + value = float(value) + except ValueError: + value = 0 + unit = sampled_value.get(om.unit.value, None) + phase = sampled_value.get(om.phase.value, None) + location = sampled_value.get(om.location.value, None) + context = sampled_value.get(om.context.value, None) + measurands.append( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + meter_values.append(measurands) + self.process_measurands(meter_values, transaction_matches) + + if transaction_matches: + self._metrics[csess.session_time.value].value = round( + ( + int(time.time()) + - float(self._metrics[csess.transaction_id.value].value) + ) + / 60 + ) + self._metrics[csess.session_time.value].unit = "min" + if ( + self._metrics[csess.meter_start.value].value is not None + and not self._charger_reports_session_energy + ): + self._metrics[csess.session_energy.value].value = float( + self._metrics[DEFAULT_MEASURAND].value or 0 + ) - float(self._metrics[csess.meter_start.value].value) + self._metrics[csess.session_energy.value].extra_attr[ + cstat.id_tag.name + ] = self._metrics[cstat.id_tag.value].value + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.MeterValues() + + @on(Action.boot_notification) + def on_boot_notification(self, **kwargs): + """Handle a boot notification.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interval=3600, + status=RegistrationStatus.accepted.value, + ) + self.received_boot_notification = True + _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) + + self.hass.async_create_task(self.async_update_device_info_v16(kwargs)) + self._register_boot_notification() + return resp + + @on(Action.status_notification) + def on_status_notification(self, connector_id, error_code, status, **kwargs): + """Handle a status notification.""" + + if connector_id == 0 or connector_id is None: + self._metrics[cstat.status.value].value = status + self._metrics[cstat.error_code.value].value = error_code + elif connector_id == 1: + self._metrics[cstat.status_connector.value].value = status + self._metrics[cstat.error_code_connector.value].value = error_code + if connector_id >= 1: + self._metrics[cstat.status_connector.value].extra_attr[connector_id] = ( + status + ) + self._metrics[cstat.error_code_connector.value].extra_attr[connector_id] = ( + error_code + ) + if ( + status == ChargePointStatus.suspended_ev.value + or status == ChargePointStatus.suspended_evse.value + ): + if Measurand.current_import.value in self._metrics: + self._metrics[Measurand.current_import.value].value = 0 + if Measurand.power_active_import.value in self._metrics: + self._metrics[Measurand.power_active_import.value].value = 0 + if Measurand.power_reactive_import.value in self._metrics: + self._metrics[Measurand.power_reactive_import.value].value = 0 + if Measurand.current_export.value in self._metrics: + self._metrics[Measurand.current_export.value].value = 0 + if Measurand.power_active_export.value in self._metrics: + self._metrics[Measurand.power_active_export.value].value = 0 + if Measurand.power_reactive_export.value in self._metrics: + self._metrics[Measurand.power_reactive_export.value].value = 0 + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.StatusNotification() + + @on(Action.firmware_status_notification) + def on_firmware_status(self, status, **kwargs): + """Handle firmware status notification.""" + self._metrics[cstat.firmware_status.value].value = status + self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) + return call_result.FirmwareStatusNotification() + + @on(Action.diagnostics_status_notification) + def on_diagnostics_status(self, status, **kwargs): + """Handle diagnostics status notification.""" + _LOGGER.info("Diagnostics upload status: %s", status) + self.hass.async_create_task( + self.notify_ha(f"Diagnostics upload status: {status}") + ) + return call_result.DiagnosticsStatusNotification() + + @on(Action.security_event_notification) + def on_security_event(self, type, timestamp, **kwargs): + """Handle security event notification.""" + _LOGGER.info( + "Security event notification received: %s at %s [techinfo: %s]", + type, + timestamp, + kwargs.get(om.tech_info.name, "none"), + ) + self.hass.async_create_task( + self.notify_ha(f"Security event notification received: {type}") + ) + return call_result.SecurityEventNotification() + + @on(Action.authorize) + def on_authorize(self, id_tag, **kwargs): + """Handle an Authorization request.""" + self._metrics[cstat.id_tag.value].value = id_tag + auth_status = self.get_authorization_status(id_tag) + return call_result.Authorize(id_tag_info={om.status.value: auth_status}) + + @on(Action.start_transaction) + def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): + """Handle a Start Transaction request.""" + + auth_status = self.get_authorization_status(id_tag) + if auth_status == AuthorizationStatus.accepted.value: + self.active_transaction_id = int(time.time()) + self._metrics[cstat.id_tag.value].value = id_tag + self._metrics[cstat.stop_reason.value].value = "" + self._metrics[csess.transaction_id.value].value = self.active_transaction_id + self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 + result = call_result.StartTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, + transaction_id=self.active_transaction_id, + ) + else: + result = call_result.StartTransaction( + id_tag_info={om.status.value: auth_status}, transaction_id=0 + ) + self.hass.async_create_task(self.update(self.central.cpid)) + return result + + @on(Action.stop_transaction) + def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): + """Stop the current transaction.""" + + if transaction_id != self.active_transaction_id: + _LOGGER.error( + "Stop transaction received for unknown transaction id=%i", + transaction_id, + ) + self.active_transaction_id = 0 + self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) + if ( + self._metrics[csess.meter_start.value].value is not None + and not self._charger_reports_session_energy + ): + self._metrics[csess.session_energy.value].value = int( + meter_stop + ) / 1000 - float(self._metrics[csess.meter_start.value].value) + if Measurand.current_import.value in self._metrics: + self._metrics[Measurand.current_import.value].value = 0 + if Measurand.power_active_import.value in self._metrics: + self._metrics[Measurand.power_active_import.value].value = 0 + if Measurand.power_reactive_import.value in self._metrics: + self._metrics[Measurand.power_reactive_import.value].value = 0 + if Measurand.current_export.value in self._metrics: + self._metrics[Measurand.current_export.value].value = 0 + if Measurand.power_active_export.value in self._metrics: + self._metrics[Measurand.power_active_export.value].value = 0 + if Measurand.power_reactive_export.value in self._metrics: + self._metrics[Measurand.power_reactive_export.value].value = 0 + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.StopTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value} + ) + + @on(Action.data_transfer) + def on_data_transfer(self, vendor_id, **kwargs): + """Handle a Data transfer request.""" + _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) + self._metrics[cdet.data_transfer.value].value = datetime.now(tz=UTC) + self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} + return call_result.DataTransfer(status=DataTransferStatus.accepted.value) + + @on(Action.heartbeat) + def on_heartbeat(self, **kwargs): + """Handle a Heartbeat.""" + now = datetime.now(tz=UTC) + self._metrics[cstat.heartbeat.value].value = now + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py new file mode 100644 index 00000000..f1ea7bd3 --- /dev/null +++ b/custom_components/ocpp/ocppv201.py @@ -0,0 +1,688 @@ +"""Representation of a OCPP 2.0.1 charging station.""" + +import asyncio +from datetime import datetime, UTC +import logging + +import ocpp.exceptions +from ocpp.exceptions import OCPPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant, SupportsResponse, ServiceResponse +from homeassistant.exceptions import ServiceValidationError, HomeAssistantError +import websockets.server + +from ocpp.routing import on +from ocpp.v201 import call, call_result +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 +from ocpp.v201.enums import ( + ConnectorStatusType, + GetVariableStatusType, + IdTokenType, + MeasurandType, + OperationalStatusType, + ResetType, + ResetStatusType, + SetVariableStatusType, + AuthorizationStatusType, + TransactionEventType, + ReadingContextType, + RequestStartStopStatusType, + ChargingStateType, + ChargingProfilePurposeType, + ChargingRateUnitType, + ChargingProfileKindType, + ChargingProfileStatus, +) + +from .chargepoint import ( + CentralSystemSettings, + OcppVersion, + SetVariableResult, + MeasurandValue, +) +from .chargepoint import ChargePoint as cp +from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA + +from .enums import Profiles + +from .enums import ( + HAChargerStatuses as cstat, + HAChargerServices as csvcs, + HAChargerSession as csess, +) + +from .const import ( + DEFAULT_METER_INTERVAL, + DOMAIN, + HA_ENERGY_UNIT, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class InventoryReport: + """Cached full inventory report for a charger.""" + + evse_count: int = 0 + connector_count: list[int] = [] + smart_charging_available: bool = False + reservation_available: bool = False + local_auth_available: bool = False + tx_updated_measurands: list[MeasurandType] = [] + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + _inventory: InventoryReport | None = None + _wait_inventory: asyncio.Event | None = None + _connector_status: list[list[ConnectorStatusType | None]] = [] + _tx_start_time: datetime | None = None + + def __init__( + self, + id: str, + connection: websockets.server.WebSocketServerProtocol, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int = 10, + skip_schema_validation: bool = False, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + OcppVersion.V201, + hass, + entry, + central, + interval_meter_metrics, + skip_schema_validation, + ) + + async def async_update_device_info_v201(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + await self.async_update_device_info( + boot_info.get("serial_number", None), + boot_info.get("vendor_name", None), + boot_info.get("model", None), + boot_info.get("firmware_version", None), + ) + + async def _get_inventory(self): + if self._inventory is not None: + return + self._wait_inventory = asyncio.Event() + req = call.GetBaseReport(1, "FullInventory") + resp: call_result.GetBaseReport | None = None + try: + resp: call_result.GetBaseReport = await self.call(req) + except ocpp.exceptions.NotImplementedError: + self._inventory = InventoryReport() + except OCPPError: + self._inventory = None + if (resp is not None) and (resp.status == "Accepted"): + await asyncio.wait_for(self._wait_inventory.wait(), self._response_timeout) + self._wait_inventory = None + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + await self._get_inventory() + return self._inventory.evse_count if self._inventory else 0 + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedInterval"}, + "attribute_value": str(DEFAULT_METER_INTERVAL), + } + ] + ) + await self.call(req) + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + + async def handle_configure(call) -> ServiceResponse: + """Handle the configure service call.""" + key = call.data.get("ocpp_key") + value = call.data.get("value") + result: SetVariableResult = await self.configure(key, value) + return {"reboot_required": result == SetVariableResult.reboot_required} + + async def handle_get_configuration(call) -> ServiceResponse: + """Handle the get configuration service call.""" + key = call.data.get("ocpp_key") + value = await self.get_configuration(key) + return {"value": value} + + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure_v201.value, + handle_configure, + CONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration_v201.value, + handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + await self._get_inventory() + if self._inventory: + measurands: str = ",".join( + measurand.value for measurand in self._inventory.tx_updated_measurands + ) + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedMeasurands"}, + "attribute_value": measurands, + } + ] + ) + await self.call(req) + return measurands + return "" + + async def get_supported_features(self) -> Profiles: + """Get comma-separated list of measurands supported by the charger.""" + await self._get_inventory() + features = Profiles.CORE + if self._inventory and self._inventory.smart_charging_available: + features |= Profiles.SMART + if self._inventory and self._inventory.reservation_available: + features |= Profiles.RES + if self._inventory and self._inventory.local_auth_available: + features |= Profiles.AUTH + + fw_req = call.UpdateFirmware( + 1, + { + "location": "dummy://dummy", + "retrieveDateTime": datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "signature": "☺", + }, + ) + try: + await self.call(fw_req) + features |= Profiles.FW + except OCPPError as e: + _LOGGER.info("Firmware update not supported: %s", e) + + trigger_req = call.TriggerMessage("StatusNotification") + try: + await self.call(trigger_req) + features |= Profiles.REM + except OCPPError as e: + _LOGGER.info("TriggerMessage not supported: %s", e) + + return features + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + if not self._inventory: + return + for evse_id in range(1, self._inventory.evse_count + 1): + for connector_id in range( + 1, self._inventory.connector_count[evse_id - 1] + 1 + ): + req = call.TriggerMessage( + "StatusNotification", + evse={"id": evse_id, "connector_id": connector_id}, + ) + await self.call(req) + + async def clear_profile(self): + """Clear all charging profiles.""" + req: call.ClearChargingProfile = call.ClearChargingProfile( + None, + { + "charging_profile_Purpose": ChargingProfilePurposeType.charging_station_max_profile.value + }, + ) + await self.call(req) + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + req: call.SetChargingProfile + if profile: + req = call.SetChargingProfile(0, profile) + else: + period: dict = {"start_period": 0} + schedule: dict = {"id": 1} + if limit_amps < 32: + period["limit"] = limit_amps + schedule["charging_rate_unit"] = ChargingRateUnitType.amps.value + elif limit_watts < 22000: + period["limit"] = limit_watts + schedule["charging_rate_unit"] = ChargingRateUnitType.watts.value + else: + await self.clear_profile() + return + + schedule["charging_schedule_period"] = [period] + req = call.SetChargingProfile( + 0, + { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [schedule], + }, + ) + + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatus.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + + async def set_availability(self, state: bool = True): + """Change availability.""" + req: call.ChangeAvailability = call.ChangeAvailability( + OperationalStatusType.operative.value + if state + else OperationalStatusType.inoperative.value + ) + await self.call(req) + + async def start_transaction(self) -> bool: + """Remote start a transaction.""" + req: call.RequestStartTransaction = call.RequestStartTransaction( + id_token={ + "id_token": self._remote_id_tag, + "type": IdTokenType.central.value, + }, + remote_start_id=1, + ) + resp: call_result.RequestStartTransaction = await self.call(req) + return resp.status == RequestStartStopStatusType.accepted.value + + async def stop_transaction(self) -> bool: + """Request remote stop of current transaction.""" + req: call.RequestStopTransaction = call.RequestStopTransaction( + transaction_id=self._metrics[csess.transaction_id.value].value + ) + resp: call_result.RequestStopTransaction = await self.call(req) + return resp.status == RequestStartStopStatusType.accepted.value + + async def reset(self, typ: str = ""): + """Hard reset charger unless soft reset requested.""" + req: call.Reset = call.Reset(ResetType.immediate) + resp = await self.call(req) + if resp.status != ResetStatusType.accepted.value: + status_suffix: str = f": {resp.status_info}" if resp.status_info else "" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": resp.status + status_suffix}, + ) + + @staticmethod + def _parse_ocpp_key(key: str) -> tuple: + try: + [c, v] = key.split("/") + except ValueError: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_ocpp_key", + ) + [cname, paren, cinstance] = c.partition("(") + cinstance = cinstance.partition(")")[0] + [vname, paren, vinstance] = v.partition("(") + vinstance = vinstance.partition(")")[0] + component: dict = {"name": cname} + if cinstance: + component["instance"] = cinstance + variable: dict = {"name": vname} + if vinstance: + variable["instance"] = vinstance + return component, variable + + async def get_configuration(self, key: str = "") -> str | None: + """Get Configuration of charger for supported keys else return None.""" + component, variable = self._parse_ocpp_key(key) + req: call.GetVariables = call.GetVariables( + [{"component": component, "variable": variable}] + ) + try: + resp: call_result.GetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.get_variable_result[0] + if result["attribute_status"] != GetVariableStatusType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_variables_error", + translation_placeholders={"message": str(result)}, + ) + return result["attribute_value"] + + async def configure(self, key: str, value: str) -> SetVariableResult: + """Configure charger by setting the key to target value.""" + component, variable = self._parse_ocpp_key(key) + req: call.SetVariables = call.SetVariables( + [{"component": component, "variable": variable, "attribute_value": value}] + ) + try: + resp: call_result.SetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.set_variable_result[0] + if result["attribute_status"] == SetVariableStatusType.accepted: + return SetVariableResult.accepted + elif result["attribute_status"] == SetVariableStatusType.reboot_required: + return SetVariableResult.reboot_required + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={"message": str(result)}, + ) + + @on("BootNotification") + def on_boot_notification(self, charging_station, reason, **kwargs): + """Perform OCPP callback.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).isoformat(), + interval=10, + status="Accepted", + ) + + self.hass.async_create_task( + self.async_update_device_info_v201(charging_station) + ) + self._inventory = None + self._register_boot_notification() + return resp + + @on("Heartbeat") + def on_heartbeat(self, **kwargs): + """Perform OCPP callback.""" + return call_result.Heartbeat(current_time=datetime.now(tz=UTC).isoformat()) + + def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): + evse_status_str: str = evse_status_v16.value + + if evse_id == 1: + self._metrics[cstat.status_connector.value].value = evse_status_str + else: + self._metrics[cstat.status_connector.value].extra_attr[evse_id] = ( + evse_status_str + ) + self.hass.async_create_task(self.update(self.central.cpid)) + + @on("StatusNotification") + def on_status_notification( + self, timestamp: str, connector_status: str, evse_id: int, connector_id: int + ): + """Perform OCPP callback.""" + if evse_id > len(self._connector_status): + self._connector_status += [[]] * (evse_id - len(self._connector_status)) + if connector_id > len(self._connector_status[evse_id - 1]): + self._connector_status[evse_id - 1] += [None] * ( + connector_id - len(self._connector_status[evse_id - 1]) + ) + + evse: list[ConnectorStatusType] = self._connector_status[evse_id - 1] + evse[connector_id - 1] = ConnectorStatusType(connector_status) + evse_status: ConnectorStatusType | None = None + for status in evse: + if status is None: + evse_status = status + break + else: + evse_status = status + if status != ConnectorStatusType.available: + break + evse_status_v16: ChargePointStatusv16 | None + if evse_status is None: + evse_status_v16 = None + elif evse_status == ConnectorStatusType.available: + evse_status_v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusType.faulted: + evse_status_v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusType.unavailable: + evse_status_v16 = ChargePointStatusv16.unavailable + else: + evse_status_v16 = ChargePointStatusv16.preparing + + if evse_status_v16: + self._report_evse_status(evse_id, evse_status_v16) + + return call_result.StatusNotification() + + @on("FirmwareStatusNotification") + @on("MeterValues") + @on("LogStatusNotification") + @on("NotifyEvent") + def ack(self, **kwargs): + """Perform OCPP callback.""" + return call_result.StatusNotification() + + @on("NotifyReport") + def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): + """Perform OCPP callback.""" + if self._wait_inventory is None: + return call_result.NotifyReport() + if self._inventory is None: + self._inventory = InventoryReport() + reports: list[dict] = kwargs.get("report_data", []) + for report_data in reports: + component: dict = report_data["component"] + variable: dict = report_data["variable"] + component_name = component["name"] + variable_name = variable["name"] + value: str | None = None + for attribute in report_data["variable_attribute"]: + if (("type" not in attribute) or (attribute["type"] == "Actual")) and ( + "value" in attribute + ): + value = attribute["value"] + break + bool_value: bool = value and (value.casefold() == "true".casefold()) + + if (component_name == "SmartChargingCtrlr") and ( + variable_name == "Available" + ): + self._inventory.smart_charging_available = bool_value + elif (component_name == "ReservationCtrlr") and ( + variable_name == "Available" + ): + self._inventory.reservation_available = bool_value + elif (component_name == "LocalAuthListCtrlr") and ( + variable_name == "Available" + ): + self._inventory.local_auth_available = bool_value + elif (component_name == "EVSE") and ("evse" in component): + self._inventory.evse_count = max( + self._inventory.evse_count, component["evse"]["id"] + ) + self._inventory.connector_count += [0] * ( + self._inventory.evse_count - len(self._inventory.connector_count) + ) + elif ( + (component_name == "Connector") + and ("evse" in component) + and ("connector_id" in component["evse"]) + ): + evse_id = component["evse"]["id"] + self._inventory.evse_count = max(self._inventory.evse_count, evse_id) + self._inventory.connector_count += [0] * ( + self._inventory.evse_count - len(self._inventory.connector_count) + ) + self._inventory.connector_count[evse_id - 1] = max( + self._inventory.connector_count[evse_id - 1], + component["evse"]["connector_id"], + ) + elif ( + (component_name == "SampledDataCtrlr") + and (variable_name == "TxUpdatedMeasurands") + and ("variable_characteristics" in report_data) + ): + characteristics: dict = report_data["variable_characteristics"] + values: str = characteristics.get("values_list", "") + self._inventory.tx_updated_measurands = [ + MeasurandType(s) for s in values.split(",") + ] + + if not kwargs.get("tbc", False): + self._wait_inventory.set() + return call_result.NotifyReport() + + @on("Authorize") + def on_authorize(self, id_token: dict, **kwargs): + """Perform OCPP callback.""" + status: str = AuthorizationStatusType.unknown.value + token_type: str = id_token["type"] + token: str = id_token["id_token"] + if ( + (token_type == IdTokenType.iso14443) + or (token_type == IdTokenType.iso15693) + or (token_type == IdTokenType.central) + ): + status = self.get_authorization_status(token) + return call_result.Authorize(id_token_info={"status": status}) + + def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): + converted_values: list[list[MeasurandValue]] = [] + for meter_value in meter_values: + measurands: list[MeasurandValue] = [] + for sampled_value in meter_value["sampled_value"]: + measurand: str = sampled_value.get( + "measurand", MeasurandType.energy_active_import_register.value + ) + value: float = sampled_value["value"] + context: str = sampled_value.get("context", None) + phase: str = sampled_value.get("phase", None) + location: str = sampled_value.get("location", None) + unit_struct: dict = sampled_value.get("unit_of_measure", {}) + unit: str = unit_struct.get("unit", None) + multiplier: int = unit_struct.get("multiplier", 0) + if multiplier != 0: + value *= pow(10, multiplier) + measurands.append( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + converted_values.append(measurands) + + if (tx_event_type == TransactionEventType.started.value) or ( + (tx_event_type == TransactionEventType.updated.value) + and (self._metrics[csess.meter_start].value is None) + ): + energy_measurand = MeasurandType.energy_active_import_register.value + for meter_value in converted_values: + for measurand_item in meter_value: + if measurand_item.measurand == energy_measurand: + energy_value = ChargePoint.get_energy_kwh(measurand_item) + energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None + self._metrics[csess.meter_start].value = energy_value + self._metrics[csess.meter_start].unit = energy_unit + + self.process_measurands(converted_values, True) + + if tx_event_type == TransactionEventType.ended.value: + measurands_in_tx: set[str] = set() + tx_end_context = ReadingContextType.transaction_end.value + for meter_value in converted_values: + for measurand_item in meter_value: + if measurand_item.context == tx_end_context: + measurands_in_tx.add(measurand_item.measurand) + if self._inventory: + for measurand in self._inventory.tx_updated_measurands: + if ( + (measurand not in measurands_in_tx) + and (measurand in self._metrics) + and not measurand.startswith("Energy") + ): + self._metrics[measurand].value = 0 + + @on("TransactionEvent") + def on_transaction_event( + self, event_type, timestamp, trigger_reason, seq_no, transaction_info, **kwargs + ): + """Perform OCPP callback.""" + offline: bool = kwargs.get("offline", False) + meter_values: list[dict] = kwargs.get("meter_value", []) + self._set_meter_values(event_type, meter_values) + t = datetime.fromisoformat(timestamp) + + if "charging_state" in transaction_info: + state = transaction_info["charging_state"] + evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 + evse_status_v16: ChargePointStatusv16 | None = None + if state == ChargingStateType.idle: + evse_status_v16 = ChargePointStatusv16.available + elif state == ChargingStateType.ev_connected: + evse_status_v16 = ChargePointStatusv16.preparing + elif state == ChargingStateType.suspended_evse: + evse_status_v16 = ChargePointStatusv16.suspended_evse + elif state == ChargingStateType.suspended_ev: + evse_status_v16 = ChargePointStatusv16.suspended_ev + elif state == ChargingStateType.charging: + evse_status_v16 = ChargePointStatusv16.charging + if evse_status_v16: + self._report_evse_status(evse_id, evse_status_v16) + + response = call_result.TransactionEvent() + id_token = kwargs.get("id_token") + if id_token: + response.id_token_info = {"status": AuthorizationStatusType.accepted} + id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] + self._metrics[cstat.id_tag.value].value = id_tag_string + + if event_type == TransactionEventType.started.value: + self._tx_start_time = t + tx_id: str = transaction_info["transaction_id"] + self._metrics[csess.transaction_id.value].value = tx_id + self._metrics[csess.session_time].value = 0 + self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + else: + if self._tx_start_time: + duration_minutes: int = ((t - self._tx_start_time).seconds + 59) // 60 + self._metrics[csess.session_time].value = duration_minutes + self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + if event_type == TransactionEventType.ended.value: + self._metrics[csess.transaction_id.value].value = "" + self._metrics[cstat.id_tag.value].value = "" + + if not offline: + self.hass.async_create_task(self.update(self.central.cpid)) + + return response diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index df857b0a..8c3e057f 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -84,9 +84,26 @@ configure: advanced: true example: "60" +configure_v201: + name: Configure charger features + description: Change supported Ocpp v2.0.1 configuration values + fields: + ocpp_key: + name: [()]/[()] + description: [()]/[()] + required: true + advanced: true + example: "OCPPCommCtrlr/WebSocketPingInterval" + value: + name: Key value + description: Value to write to key + required: true + advanced: true + example: "60" + get_configuration: name: Get configuration values for charger - description: Change supported Ocpp v1.6 configuration values + description: Get supported Ocpp v1.6 configuration values fields: ocpp_key: name: Configuration key name @@ -95,6 +112,17 @@ get_configuration: advanced: true example: "WebSocketPingInterval" +get_configuration_v201: + name: Get configuration values for charger + description: Get supported Ocpp v2.0.1 configuration values + fields: + ocpp_key: + name: [()]/[()] + description: [()]/[()] + required: true + advanced: true + example: "OCPPCommCtrlr/WebSocketPingInterval" + get_diagnostics: name: Request diagnostic data from charger description: Specify server url to upload diagnostic data to (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 0015e11b..d610bd07 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -60,5 +60,19 @@ "abort": { "single_instance_allowed": "Only a single instance is allowed." } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" + } } } \ No newline at end of file diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 0015e11b..d610bd07 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -60,5 +60,19 @@ "abort": { "single_instance_allowed": "Only a single instance is allowed." } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" + } } } \ No newline at end of file diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py new file mode 100644 index 00000000..02bea1ef --- /dev/null +++ b/tests/charge_point_test.py @@ -0,0 +1,137 @@ +"""Implement common functions for simulating charge points of any OCPP version.""" + +import asyncio + +from homeassistant.core import HomeAssistant +from websockets import Subprotocol + +from custom_components.ocpp import CentralSystem +from .const import CONF_PORT +import contextlib +from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN +from custom_components.ocpp.enums import HAChargerServices as csvcs +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from ocpp.charge_point import ChargePoint +from pytest_homeassistant_custom_component.common import MockConfigEntry +from typing import Any +from collections.abc import Callable, Awaitable +import websockets + + +async def set_switch(hass: HomeAssistant, cs: CentralSystem, key: str, on: bool): + """Toggle a switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON if on else SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cs.settings.cpid}_{key}"}, + blocking=True, + ) + + +async def set_number(hass: HomeAssistant, cs: CentralSystem, key: str, value: int): + """Set a numeric slider.""" + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + service_data={"value": str(value)}, + blocking=True, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cs.settings.cpid}_{key}"}, + ) + + +set_switch.__test__ = False + + +async def press_button(hass: HomeAssistant, cs: CentralSystem, key: str): + """Press a button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.{cs.settings.cpid}_{key}"}, + blocking=True, + ) + + +press_button.__test__ = False + + +async def create_configuration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> CentralSystem: + """Create an integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return hass.data[OCPP_DOMAIN][config_entry.entry_id] + + +create_configuration.__test__ = False + + +async def remove_configuration(hass: HomeAssistant, config_entry: MockConfigEntry): + """Remove an integration.""" + if entry := hass.config_entries.async_get_entry(config_entry.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + +remove_configuration.__test__ = False + + +async def wait_ready(hass: HomeAssistant): + """Wait until charge point is connected and initialised.""" + hass.services.async_remove(OCPP_DOMAIN, csvcs.service_data_transfer) + while not hass.services.has_service(OCPP_DOMAIN, csvcs.service_data_transfer): + await asyncio.sleep(0.1) + + +def _check_complete( + test_routine: Callable[[ChargePoint], Awaitable], +) -> Callable[[ChargePoint, list[bool]], Awaitable]: + async def extended_routine(cp: ChargePoint, completed: list[bool]): + await test_routine(cp) + completed.append(True) + + return extended_routine + + +async def run_charge_point_test( + config_entry: MockConfigEntry, + identity: str, + subprotocols: list[str] | None, + charge_point: Callable[[websockets.WebSocketClientProtocol], ChargePoint], + parallel_tests: list[Callable[[ChargePoint], Awaitable]], +) -> Any: + """Connect web socket client to the CSMS and run a number of tests in parallel.""" + completed: list[list[bool]] = [[] for _ in parallel_tests] + async with websockets.connect( + f"ws://127.0.0.1:{config_entry.data[CONF_PORT]}/{identity}", + subprotocols=[Subprotocol(s) for s in subprotocols] + if subprotocols is not None + else None, + ) as ws: + cp = charge_point(ws) + with contextlib.suppress(asyncio.TimeoutError): + test_results = [ + _check_complete(parallel_tests[i])(cp, completed[i]) + for i in range(len(parallel_tests)) + ] + await asyncio.wait_for( + asyncio.gather(*([cp.start()] + test_results)), + timeout=5, + ) + await ws.close() + for test_completed in completed: + assert test_completed == [True] + + +run_charge_point_test.__test__ = False diff --git a/tests/conftest.py b/tests/conftest.py index 390bc87b..4264d5c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def skip_notifications_fixture(): with ( patch("homeassistant.components.persistent_notification.async_create"), patch("homeassistant.components.persistent_notification.async_dismiss"), - patch("custom_components.ocpp.api.ChargePoint.notify_ha"), + patch("custom_components.ocpp.chargepoint.ChargePoint.notify_ha"), ): yield diff --git a/tests/test_charge_point.py b/tests/test_charge_point_v16.py similarity index 94% rename from tests/test_charge_point.py rename to tests/test_charge_point_v16.py index dec050d8..4da5d22d 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point_v16.py @@ -1,17 +1,8 @@ -"""Implement a test by a simulating a chargepoint.""" +"""Implement a test by a simulating an OCPP 1.6 chargepoint.""" import asyncio from datetime import datetime, UTC # timedelta, -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.button.const import SERVICE_PRESS -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.const import ATTR_ENTITY_ID import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry import websockets @@ -43,46 +34,27 @@ ) from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 +from .charge_point_test import set_switch, press_button, set_number import contextlib @pytest.mark.timeout(90) # Set timeout for this test -async def test_cms_responses(hass, socket_enabled): +async def test_cms_responses_v16(hass, socket_enabled): """Test central system responses to a charger.""" - async def test_switches(hass, socket_enabled): + async def test_switches(hass, cs, socket_enabled): """Test switch operations.""" for switch in SWITCHES: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) - + await set_switch(hass, cs, switch.key, True) await asyncio.sleep(1) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) + await set_switch(hass, cs, switch.key, False) - async def test_buttons(hass, socket_enabled): + async def test_buttons(hass, cs, socket_enabled): """Test button operations.""" for button in BUTTONS: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.test_cpid_{button.key}"}, - blocking=True, - ) + await press_button(hass, cs, button.key) - async def test_services(hass, socket_enabled): + async def test_services(hass, cs, socket_enabled): """Test service operations.""" SERVICES = [ csvcs.service_update_firmware, @@ -142,13 +114,7 @@ async def test_services(hass, socket_enabled): for number in NUMBERS: # test setting value of number slider - await hass.services.async_call( - NUMBER_DOMAIN, - "set_value", - service_data={"value": "10"}, - blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.test_cpid_{number.key}"}, - ) + await set_number(hass, cs, number.key, 10) # Test MOCK_CONFIG_DATA_2 if True: @@ -373,11 +339,11 @@ async def test_services(hass, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), + cs.charge_points[cs.settings.cpid].trigger_boot_notification(), + cs.charge_points[cs.settings.cpid].trigger_status_notification(), + test_switches(hass, cs, socket_enabled), + test_services(hass, cs, socket_enabled), + test_buttons(hass, cs, socket_enabled), cp.send_meter_clock_data(), ), timeout=5, @@ -463,11 +429,11 @@ async def test_services(hass, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), + cs.charge_points[cs.settings.cpid].trigger_boot_notification(), + cs.charge_points[cs.settings.cpid].trigger_status_notification(), + test_switches(hass, cs, socket_enabled), + test_services(hass, cs, socket_enabled), + test_buttons(hass, cs, socket_enabled), ), timeout=3, ) @@ -479,7 +445,7 @@ async def test_services(hass, socket_enabled): await asyncio.sleep(1) # test ping timeout, change cpid to start new connection - cs.cpid = "CP_3_test" + cs.settings.cpid = "CP_3_test" async with websockets.connect( "ws://127.0.0.1:9000/CP_3", subprotocols=["ocpp1.6"], @@ -491,7 +457,7 @@ async def test_services(hass, socket_enabled): # test services when charger is unavailable await asyncio.sleep(1) - await test_services(hass, socket_enabled) + await test_services(hass, cs, socket_enabled) if entry := hass.config_entries.async_get_entry(config_entry.entry_id): await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py new file mode 100644 index 00000000..cdea9634 --- /dev/null +++ b/tests/test_charge_point_v201.py @@ -0,0 +1,1252 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, timedelta, UTC + +from homeassistant.core import HomeAssistant, ServiceResponse +from homeassistant.exceptions import HomeAssistantError +from ocpp.v16.enums import Measurand + +from custom_components.ocpp import CentralSystem +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + Profiles, +) +from .charge_point_test import ( + set_switch, + set_number, + press_button, + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) +from .const import MOCK_CONFIG_DATA +from custom_components.ocpp.const import ( + DEFAULT_METER_INTERVAL, + DOMAIN as OCPP_DOMAIN, + CONF_PORT, + CONF_MONITORED_VARIABLES, + MEASURANDS, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + GetVariableResultType, + SetVariableResultType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, +) +from ocpp.v201.enums import ( + Action, + AuthorizationStatusType, + BootReasonType, + ChangeAvailabilityStatusType, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingProfileStatus, + ChargingRateUnitType, + ChargingStateType, + ClearChargingProfileStatusType, + ConnectorStatusType, + DataType, + FirmwareStatusType, + GenericDeviceModelStatusType, + GetVariableStatusType, + IdTokenType, + MeasurandType, + MutabilityType, + OperationalStatusType, + PhaseType, + ReadingContextType, + RegistrationStatusType, + ReportBaseType, + RequestStartStopStatusType, + ResetStatusType, + ResetType, + SetVariableStatusType, + ReasonType, + TransactionEventType, + MessageTriggerType, + TriggerMessageStatusType, + TriggerReasonType, + UpdateFirmwareStatusType, +) +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 + + +supported_measurands = [ + measurand + for measurand in MEASURANDS + if (measurand != Measurand.rpm.value) and (measurand != Measurand.temperature.value) +] + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + remote_starts: list[call.RequestStartTransaction] = [] + remote_stops: list[str] = [] + task: asyncio.Task | None = None + remote_start_tx_id: str = "remotestart" + operative: bool | None = None + tx_updated_interval: int | None = None + tx_updated_measurands: list[str] | None = None + tx_start_time: datetime | None = None + component_instance_used: str | None = None + variable_instance_used: str | None = None + charge_profiles_set: list[call.SetChargingProfile] = [] + charge_profiles_cleared: list[call.ClearChargingProfile] = [] + accept_reset: bool = True + resets: list[call.Reset] = [] + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + assert report_base == ReportBaseType.full_inventory.value + self.task = asyncio.create_task(self._send_full_inventory(request_id)) + return call_result.GetBaseReport(GenericDeviceModelStatusType.accepted.value) + + @on(Action.RequestStartTransaction) + def _on_remote_start( + self, id_token: dict, remote_start_id: int, **kwargs + ) -> call_result.RequestStartTransaction: + self.remote_starts.append( + call.RequestStartTransaction(id_token, remote_start_id, *kwargs) + ) + self.task = asyncio.create_task( + self._start_transaction_remote_start(id_token, remote_start_id) + ) + return call_result.RequestStartTransaction( + RequestStartStopStatusType.accepted.value + ) + + @on(Action.RequestStopTransaction) + def _on_remote_stop(self, transaction_id: str, **kwargs): + assert transaction_id == self.remote_start_tx_id + self.remote_stops.append(transaction_id) + return call_result.RequestStopTransaction( + RequestStartStopStatusType.accepted.value + ) + + @on(Action.SetVariables) + def _on_set_variables(self, set_variable_data: list[dict], **kwargs): + result: list[SetVariableResultType] = [] + for input in set_variable_data: + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + self.tx_updated_interval = int(input["attribute_value"]) + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedMeasurands"} + ): + self.tx_updated_measurands = input["attribute_value"].split(",") + + attr_result: SetVariableStatusType + if input["variable"] == {"name": "RebootRequired"}: + attr_result = SetVariableStatusType.reboot_required + elif input["variable"] == {"name": "BadVariable"}: + attr_result = SetVariableStatusType.unknown_variable + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + else: + attr_result = SetVariableStatusType.accepted + self.component_instance_used = input["component"].get("instance", None) + self.variable_instance_used = input["variable"].get("instance", None) + + result.append( + SetVariableResultType( + attr_result, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + ) + ) + return call_result.SetVariables(result) + + @on(Action.GetVariables) + def _on_get_variables(self, get_variable_data: list[dict], **kwargs): + result: list[GetVariableResultType] = [] + for input in get_variable_data: + value: str | None = None + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + value = str(self.tx_updated_interval) + elif input["variable"]["name"] == "TestInstance": + value = ( + input["component"]["instance"] + "," + input["variable"]["instance"] + ) + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + result.append( + GetVariableResultType( + GetVariableStatusType.accepted + if value is not None + else GetVariableStatusType.unknown_variable, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + attribute_value=value, + ) + ) + return call_result.GetVariables(result) + + @on(Action.ChangeAvailability) + def _on_change_availability(self, operational_status: str, **kwargs): + if operational_status == OperationalStatusType.operative.value: + self.operative = True + elif operational_status == OperationalStatusType.inoperative.value: + self.operative = False + else: + assert False + return call_result.ChangeAvailability( + ChangeAvailabilityStatusType.accepted.value + ) + + @on(Action.SetChargingProfile) + def _on_set_charging_profile(self, evse_id: int, charging_profile: dict, **kwargs): + self.charge_profiles_set.append( + call.SetChargingProfile(evse_id, charging_profile) + ) + unit = charging_profile["charging_schedule"][0]["charging_rate_unit"] + limit = charging_profile["charging_schedule"][0]["charging_schedule_period"][0][ + "limit" + ] + if (unit == ChargingRateUnitType.amps.value) and (limit < 6): + return call_result.SetChargingProfile(ChargingProfileStatus.rejected.value) + return call_result.SetChargingProfile(ChargingProfileStatus.accepted.value) + + @on(Action.ClearChargingProfile) + def _on_clear_charging_profile(self, **kwargs): + self.charge_profiles_cleared.append( + call.ClearChargingProfile( + kwargs.get("charging_profile_id", None), + kwargs.get("charging_profile_criteria", None), + ) + ) + return call_result.ClearChargingProfile( + ClearChargingProfileStatusType.accepted.value + ) + + @on(Action.Reset) + def _on_reset(self, type: str, **kwargs): + self.resets.append(call.Reset(type, kwargs.get("evse_id", None))) + return call_result.Reset( + ResetStatusType.accepted.value + if self.accept_reset + else ResetStatusType.rejected.value + ) + + async def _start_transaction_remote_start( + self, id_token: dict, remote_start_id: int + ): + # As if AuthorizeRemoteStart is set + authorize_resp: call_result.Authorize = await self.call( + call.Authorize(id_token) + ) + assert ( + authorize_resp.id_token_info["status"] + == AuthorizationStatusType.accepted.value + ) + + self.tx_start_time = datetime.now(tz=UTC) + request = call.TransactionEvent( + TransactionEventType.started.value, + self.tx_start_time.isoformat(), + TriggerReasonType.remote_start.value, + 0, + transaction_info={ + "transaction_id": self.remote_start_tx_id, + "remote_start_id": remote_start_id, + }, + meter_value=[ + { + "timestamp": self.tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + ], + }, + ], + id_token=id_token, + ) + await self.call(request) + + async def _send_full_inventory(self, request_id: int): + # Cannot send all at once because of a bug in python ocpp module + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 0, + [ + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ) + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 1, + [ + ReportDataType( + ComponentType("ReservationCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 2, + [ + ReportDataType( + ComponentType("LocalAuthListCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 3, + [ + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 4, + [ + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 5, + [ + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataType.member_list, + False, + values_list=",".join(supported_measurands), + ), + ), + ], + ) + ) + + +async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + cpid: str = cs.settings.cpid + + await set_switch(hass, cs, "charge_control", True) + assert len(cp.remote_starts) == 1 + assert cp.remote_starts[0].id_token == { + "id_token": cs.charge_points[cpid]._remote_id_tag, + "type": IdTokenType.central.value, + } + while cs.get_metric(cpid, csess.transaction_id.value) is None: + await asyncio.sleep(0.1) + assert cs.get_metric(cpid, csess.transaction_id.value) == cp.remote_start_tx_id + + tx_start_time = cp.tx_start_time + await cp.call( + call.StatusNotification( + tx_start_time.isoformat(), ConnectorStatusType.occupied, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.cable_plugged_in.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + }, + ) + ) + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 1.1, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 2.2, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 3.3, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.1, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.2, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.3, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.energy_active_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0.1, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh", "multiplier": 3}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 50, + "measurand": Measurand.frequency.value, + "unit_of_measure": {"unit": "Hz"}, + }, + { + "value": 0, + "measurand": Measurand.power_active_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1518, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 8418, + "measurand": Measurand.power_offered.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1, + "measurand": Measurand.power_factor.value, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 69, + "measurand": Measurand.soc.value, + "unit_of_measure": {"unit": "percent"}, + }, + { + "value": 229.9, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l1_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l2_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230.4, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l3_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + # Not among enabled measurands, will be ignored + "value": 1111, + "measurand": MeasurandType.energy_active_net.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.charging + ) + assert cs.get_metric(cpid, Measurand.current_export.value) == 0 + assert cs.get_metric(cpid, Measurand.current_import.value) == 6.6 + assert cs.get_metric(cpid, Measurand.current_offered.value) == 36.6 + assert cs.get_metric(cpid, Measurand.energy_active_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.1 + assert cs.get_metric(cpid, Measurand.energy_reactive_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_reactive_import_register.value) == 0 + assert cs.get_metric(cpid, Measurand.frequency.value) == 50 + assert cs.get_metric(cpid, Measurand.power_active_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 1.518 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 8.418 + assert cs.get_metric(cpid, Measurand.power_reactive_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 69 + assert cs.get_metric(cpid, Measurand.voltage.value) == 230.1 + assert cs.get_metric(cpid, csess.session_energy) == 0 + assert cs.get_metric(cpid, csess.session_time) == 0 + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + (tx_start_time + timedelta(seconds=60)).isoformat(), + TriggerReasonType.meter_value_periodic.value, + 3, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=60)).isoformat(), + "sampled_value": [ + { + "value": 256, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.156 + assert cs.get_metric(cpid, csess.session_time) == 1 + + await set_switch(hass, cs, "charge_control", False) + assert len(cp.remote_stops) == 1 + + await cp.call( + call.TransactionEvent( + TransactionEventType.ended.value, + (tx_start_time + timedelta(seconds=120)).isoformat(), + TriggerReasonType.remote_stop.value, + 4, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.ev_connected.value, + "stopped_reason": ReasonType.remote.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=120)).isoformat(), + "sampled_value": [ + { + "value": 333, + "context": ReadingContextType.transaction_end, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, Measurand.current_import.value) == 0 + assert cs.get_metric(cpid, Measurand.current_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.333 + assert cs.get_metric(cpid, Measurand.frequency.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 0 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 0 + assert cs.get_metric(cpid, Measurand.voltage.value) == 0 + assert cs.get_metric(cpid, csess.session_energy) == 0.233 + assert cs.get_metric(cpid, csess.session_time) == 2 + + # Now with energy reading in Started transaction event + await cp.call( + call.TransactionEvent( + TransactionEventType.started.value, + tx_start_time.isoformat(), + TriggerReasonType.cable_plugged_in.value, + 0, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.ev_connected.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1000, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1234, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.234 + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.suspended_ev.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_ev + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.suspended_evse.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_evse + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.ended.value, + tx_start_time.isoformat(), + TriggerReasonType.ev_communication_lost.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.idle.value, + "stopped_reason": ReasonType.ev_disconnected.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.available + ) + + +async def _set_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str, value: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + try: + response = await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_configure_v201, + service_data={"ocpp_key": key, "value": value}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _get_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + try: + response = await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_configuration_v201, + service_data={"ocpp_key": key}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _test_services(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + service_response: ServiceResponse + error: HomeAssistantError + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.tx_updated_interval == 17 + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/RebootRequired", "17" + ) + assert service_response == {"reboot_required": True} + + service_response, error = await _set_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestVariable(VarInstance)", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.component_instance_used == "CompInstance" + assert cp.variable_instance_used == "VarInstance" + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariable", "17" + ) + assert error is not None + assert str(error).startswith("Failed to set variable") + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable", "17" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + service_response, error = await _set_variable( + hass, cs, cp, "does not compute", "17" + ) + assert error is not None + assert str(error) == "Invalid OCPP key" + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval" + ) + assert service_response == {"value": "17"} + + service_response, error = await _get_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestInstance(VarInstance)" + ) + assert service_response == {"value": "CompInstance,VarInstance"} + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariale" + ) + assert error is not None + assert str(error).startswith("Failed to get variable") + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + +async def _set_charge_rate_service( + hass: HomeAssistant, data: dict +) -> HomeAssistantError: + try: + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) + except HomeAssistantError as e: + return e + return None + + +async def _test_charge_profiles( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint +): + error: HomeAssistantError = await _set_charge_rate_service( + hass, {"limit_watts": 3000} + ) + assert error is None + assert len(cp.charge_profiles_set) == 1 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 3000}], + "charging_rate_unit": ChargingRateUnitType.watts.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"limit_amps": 16}) + assert error is None + assert len(cp.charge_profiles_set) == 2 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 16}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service( + hass, + { + "custom_profile": """{ + 'id': 2, + 'stack_level': 1, + 'charging_profile_purpose': 'TxProfile', + 'charging_profile_kind': 'Relative', + 'charging_schedule': [{ + 'id': 1, + 'charging_rate_unit': 'A', + 'charging_schedule_period': [{'start_period': 0, 'limit': 6}] + }] + }""" + }, + ) + assert error is None + assert len(cp.charge_profiles_set) == 3 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 2, + "stack_level": 1, + "charging_profile_purpose": ChargingProfilePurposeType.tx_profile.value, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 6}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + await set_number(hass, cs, "maximum_current", 12) + assert len(cp.charge_profiles_set) == 4 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 12}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"limit_amps": 5}) + assert error is not None + assert str(error).startswith("Failed to set variable: Rejected") + + assert len(cp.charge_profiles_cleared) == 0 + await set_number(hass, cs, "maximum_current", 32) + assert len(cp.charge_profiles_cleared) == 1 + assert cp.charge_profiles_cleared[-1].charging_profile_id is None + assert cp.charge_profiles_cleared[-1].charging_profile_criteria == { + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value + } + + +async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + boot_res: call_result.BootNotification = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + assert boot_res.status == RegistrationStatusType.accepted.value + assert boot_res.status_info is None + datetime.fromisoformat(boot_res.current_time) + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.available, 1, 1 + ) + ) + + heartbeat_resp: call_result.Heartbeat = await cp.call(call.Heartbeat()) + datetime.fromisoformat(heartbeat_resp.current_time) + + await wait_ready(hass) + + # Junk report to be ignored + await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) + + cpid: str = cs.settings.cpid + assert cs.get_metric(cpid, cdet.serial.value) == "SERIAL" + assert cs.get_metric(cpid, cdet.model.value) == "MODEL" + assert cs.get_metric(cpid, cdet.vendor.value) == "VENDOR" + assert cs.get_metric(cpid, cdet.firmware_version.value) == "VERSION" + assert ( + cs.get_metric(cpid, cdet.features.value) + == Profiles.CORE | Profiles.SMART | Profiles.RES | Profiles.AUTH + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.available.value + ) + assert cp.tx_updated_interval == DEFAULT_METER_INTERVAL + assert cp.tx_updated_measurands == supported_measurands + + while cp.operative is None: + await asyncio.sleep(0.1) + assert cp.operative + + await _test_transaction(hass, cs, cp) + await _test_services(hass, cs, cp) + await _test_charge_profiles(hass, cs, cp) + + await press_button(hass, cs, "reset") + assert len(cp.resets) == 1 + assert cp.resets[0].type == ResetType.immediate.value + assert cp.resets[0].evse_id is None + + error: HomeAssistantError = None + cp.accept_reset = False + try: + await press_button(hass, cs, "reset") + except HomeAssistantError as e: + error = e + assert error is not None + assert str(error) == "OCPP call failed: Rejected" + + await set_switch(hass, cs, "availability", False) + assert not cp.operative + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.unavailable, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.unavailable.value + ) + + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.faulted, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.faulted.value + ) + + await cp.call(call.FirmwareStatusNotification(FirmwareStatusType.installed.value)) + + +class ChargePointAllFeatures(ChargePoint): + """A charge point which also supports UpdateFirmware and TriggerMessage.""" + + triggered_status_notification: list[EVSEType] = [] + + @on(Action.UpdateFirmware) + def _on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + return call_result.UpdateFirmware(UpdateFirmwareStatusType.rejected.value) + + @on(Action.TriggerMessage) + def _on_trigger_message(self, requested_message: str, **kwargs): + if (requested_message == MessageTriggerType.status_notification) and ( + "evse" in kwargs + ): + self.triggered_status_notification.append( + EVSEType(kwargs["evse"]["id"], kwargs["evse"]["connector_id"]) + ) + return call_result.TriggerMessage(TriggerMessageStatusType.rejected.value) + + +async def _extra_features_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePointAllFeatures, +): + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + await wait_ready(hass) + + assert ( + cs.get_metric(cs.settings.cpid, cdet.features.value) + == Profiles.CORE + | Profiles.SMART + | Profiles.RES + | Profiles.AUTH + | Profiles.FW + | Profiles.REM + ) + + while len(cp.triggered_status_notification) < 1: + await asyncio.sleep(0.1) + assert cp.triggered_status_notification[0].id == 1 + assert cp.triggered_status_notification[0].connector_id == 1 + + +class ChargePointReportUnsupported(ChargePointAllFeatures): + """A charge point which does not support GetBaseReport.""" + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.NotImplementedError("This is not implemented") + + +class ChargePointReportFailing(ChargePointAllFeatures): + """A charge point which keeps failing GetBaseReport.""" + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.InternalError("Test failure") + + +async def _unsupported_base_report_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePoint, +): + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + await wait_ready(hass) + assert ( + cs.get_metric(cs.settings.cpid, cdet.features.value) + == Profiles.CORE | Profiles.REM | Profiles.FW + ) + + +@pytest.mark.timeout(90) +async def test_cms_responses_v201(hass, socket_enabled): + """Test central system responses to a charger.""" + + # Should not have to do this ideally, however web socket in the CSMS + # restarts if measurands reported by the charger differ from the list + # from the configuration, which a real charger can deal with but this + # test cannot + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_MONITORED_VARIABLES] = ",".join(supported_measurands) + + config_data[CONF_PORT] = 9010 + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + ) + cs: CentralSystem = await create_configuration(hass, config_entry) + await run_charge_point_test( + config_entry, + "CP_2", + ["ocpp2.0.1"], + lambda ws: ChargePoint("CP_2_client", ws), + [lambda cp: _run_test(hass, cs, cp)], + ) + + await run_charge_point_test( + config_entry, + "CP_2_allfeatures", + ["ocpp2.0.1"], + lambda ws: ChargePointAllFeatures("CP_2_allfeatures_client", ws), + [lambda cp: _extra_features_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) + config_data[CONF_MONITORED_VARIABLES] = "" + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + ) + cs = await create_configuration(hass, config_entry) + + await run_charge_point_test( + config_entry, + "CP_2_noreport", + ["ocpp2.0.1"], + lambda ws: ChargePointReportUnsupported("CP_2_noreport_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + await run_charge_point_test( + config_entry, + "CP_2_report_fail", + ["ocpp2.0.1"], + lambda ws: ChargePointReportFailing("CP_2_report_fail_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) From 6a226e317ceb6f26d4399d9149b28a1d366b13c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:41:07 +0100 Subject: [PATCH 207/370] build(deps): bump ruff from 0.7.2 to 0.8.0 (#1401) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.2 to 0.8.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.7.2...0.8.0) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 59e9cfe7..2f7f83fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.7.2 +ruff==0.8.0 ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 From 8f22f123a449b0300cce1cd9dde92ac7ec93155b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:41:36 +0100 Subject: [PATCH 208/370] build(deps): bump pytest-homeassistant-custom-component (#1400) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.177 to 0.13.183. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.177...0.13.183) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2f7f83fe..3530361e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.177 +pytest-homeassistant-custom-component==0.13.183 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From e8c37937fe3fe35afd11a7e68dad8edfc1c94a8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:42:04 +0100 Subject: [PATCH 209/370] build(deps): bump codecov/codecov-action from 4 to 5 (#1397) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 90b869ac..adf9f1ac 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -94,7 +94,7 @@ jobs: -rA \ tests - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} verbose: true From a2a073dfc3b456f1f52d83d183ada85a0d023847 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:46:22 +0100 Subject: [PATCH 210/370] build(deps): bump crazy-max/ghaction-github-labeler from 5.0.0 to 5.1.0 (#1379) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.0.0 to 5.1.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v5.0.0...v5.1.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 566db778..66f2d96a 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.0.0 + uses: crazy-max/ghaction-github-labeler@v5.1.0 with: skip-delete: true From 353405e80fb2d56a4edc613de26eb19ec2183710 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:53:50 +0100 Subject: [PATCH 211/370] Update manifest.json (#1405) use websockets<14 bump version to 0.6.1 --- custom_components/ocpp/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 0d6adbd0..3037e129 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -14,7 +14,7 @@ "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ "ocpp>=1.0.0", - "websockets>=12.0" + "websockets>=12.0,<14" ], - "version": "0.5.12" + "version": "0.6.1" } From 1416c4b7c324f1c070c4fff5e30d4856a6dfd1ba Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Mon, 25 Nov 2024 16:11:20 +0100 Subject: [PATCH 212/370] Update labeler.yml (#1406) revert labeler to v5.0.0 --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 66f2d96a..566db778 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.1.0 + uses: crazy-max/ghaction-github-labeler@v5.0.0 with: skip-delete: true From 8e56e13db2dfe84dbb5bfe45efdbf3e6a5321f55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:57:19 +0100 Subject: [PATCH 213/370] build(deps): bump sphinx-rtd-theme from 3.0.1 to 3.0.2 (#1408) Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 3.0.1 to 3.0.2. - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/3.0.1...3.0.2) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 93ff933f..709ccb7b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,4 @@ myst-parser==3.0.1 docutils==0.18.1 Jinja2==3.1.4 sphinx==7.1.2 -sphinx_rtd_theme==3.0.1 +sphinx_rtd_theme==3.0.2 From 2e7a015cb672255d7bb74c662aeb5b5bb628967e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:58:04 +0100 Subject: [PATCH 214/370] build(deps): bump pytest-homeassistant-custom-component (#1407) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.183 to 0.13.184. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.183...0.13.184) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3530361e..91dfa4e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==13.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.183 +pytest-homeassistant-custom-component==0.13.184 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 1c62f4005e7d9b3ab431ceb9a2505e29c1242360 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:44:08 +0100 Subject: [PATCH 215/370] build(deps): bump websockets from 13.1 to 14.1 (#1395) * build(deps): bump websockets from 13.1 to 14.1 Bumps [websockets](https://github.com/python-websockets/websockets) from 13.1 to 14.1. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/13.1...14.1) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * update for >14 * fix linting * suppress close * drop restriction --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/manifest.json | 2 +- requirements.txt | 2 +- tests/test_charge_point_v16.py | 68 ++++++++++++---------------- 3 files changed, 30 insertions(+), 42 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 3037e129..9f826d71 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -14,7 +14,7 @@ "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ "ocpp>=1.0.0", - "websockets>=12.0,<14" + "websockets>=12.0" ], "version": "0.6.1" } diff --git a/requirements.txt b/requirements.txt index 91dfa4e5..cbb8ea3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.9.0 uv>=0.4 ruff==0.8.0 ocpp==1.0.0 -websockets==13.1 +websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 pytest-homeassistant-custom-component==0.13.184 diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 4da5d22d..ffc60a23 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -130,12 +130,18 @@ async def test_services(hass, cs, socket_enabled): await hass.async_block_till_done() # no subprotocol + # NB each new config entry will trigger async_update_entry + # if the charger measurands differ from the config entry + # which causes the websocket server to close/restart with a + # ConnectionClosedOK exception, hence it needs to be passed/suppressed async with websockets.connect( "ws://127.0.0.1:9002/CP_1_nosub", ) as ws2: # use a different id for debugging cp2 = ChargePoint("CP_1_no_subprotocol", ws2) - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): await asyncio.wait_for( asyncio.gather( cp2.start(), @@ -177,17 +183,8 @@ async def test_services(hass, cs, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(), - cp.send_stop_transaction(), - cp.send_meter_periodic_data(), ), - timeout=5, + timeout=3, ) await ws.close() @@ -204,17 +201,8 @@ async def test_services(hass, cs, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(), - cp.send_stop_transaction(), - cp.send_meter_periodic_data(), ), - timeout=5, + timeout=3, ) await ws.close() @@ -235,7 +223,7 @@ async def test_services(hass, cs, socket_enabled): cp.start(), cp.send_meter_periodic_data(), ), - timeout=5, + timeout=3, ) # check if None assert cs.get_metric("test_cpid", "Energy.Meter.Start") is None @@ -247,7 +235,7 @@ async def test_services(hass, cs, socket_enabled): cp.send_start_transaction(12344), cp.send_meter_periodic_data(), ), - timeout=5, + timeout=3, ) # save for reference the values for meter_start and transaction_id saved_meter_start = int(cs.get_metric("test_cpid", "Energy.Meter.Start")) @@ -261,7 +249,7 @@ async def test_services(hass, cs, socket_enabled): asyncio.gather( cp.send_meter_periodic_data(), ), - timeout=5, + timeout=3, ) await ws.close() @@ -423,9 +411,11 @@ async def test_services(hass, cs, socket_enabled): "ws://127.0.0.1:9000/CP_1_error", subprotocols=["ocpp1.6"], ) as ws: - cp = ChargePoint("CP_1_error", ws) - cp.accept = False - try: + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + cp = ChargePoint("CP_1_error", ws) + cp.accept = False await asyncio.wait_for( asyncio.gather( cp.start(), @@ -437,23 +427,21 @@ async def test_services(hass, cs, socket_enabled): ), timeout=3, ) - except TimeoutError: - pass - except websockets.exceptions.ConnectionClosedOK: - pass await ws.close() await asyncio.sleep(1) + + # setting state no longer available with websockets >14 # test ping timeout, change cpid to start new connection - cs.settings.cpid = "CP_3_test" - async with websockets.connect( - "ws://127.0.0.1:9000/CP_3", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint("CP_3_test", ws) - ws.state = 3 # CLOSED = 3 - await asyncio.sleep(3) - await ws.close() + # cs.settings.cpid = "CP_3_test" + # async with websockets.connect( + # "ws://127.0.0.1:9000/CP_3", + # subprotocols=["ocpp1.6"], + # ) as ws: + # cp = ChargePoint("CP_3_test", ws) + # ws.state = 3 # CLOSED = 3 + # await asyncio.sleep(3) + # await ws.close() # test services when charger is unavailable await asyncio.sleep(1) From a55913cec503110b6b2a5270bcd5f57e145ac19a Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Wed, 27 Nov 2024 21:22:15 +1300 Subject: [PATCH 216/370] add pytest fixture loop scope (#1412) --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 3239665c..d3d043b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,7 @@ addopts = -qq --cov=custom_components.ocpp --allow-unix-socket --allow-hosts=127.0.0.1 console_output_style = count asyncio_mode = auto +asyncio_default_fixture_loop_scope = function [coverage:report] show_missing = true From 51bc1422520d22efd816d5f23bb92709967d1ebf Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 1 Dec 2024 07:54:30 +0100 Subject: [PATCH 217/370] Correct reactive power device class (#1417) * reactive power * fix * exclude energy reactive --- custom_components/ocpp/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 536f2b0a..d577ee09 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -156,6 +156,8 @@ def device_class(self): device_class = SensorDeviceClass.CURRENT elif self.metric.lower().startswith("voltage"): device_class = SensorDeviceClass.VOLTAGE + elif self.metric.lower().startswith("energy.r"): + device_class = None elif self.metric.lower().startswith("energy."): device_class = SensorDeviceClass.ENERGY elif self.metric in [ @@ -163,8 +165,10 @@ def device_class(self): Measurand.rpm, ] or self.metric.lower().startswith("frequency"): device_class = SensorDeviceClass.FREQUENCY - elif self.metric.lower().startswith(("power.a", "power.o", "power.r")): + elif self.metric.lower().startswith(("power.a", "power.o")): device_class = SensorDeviceClass.POWER + elif self.metric.lower().startswith("power.r"): + device_class = SensorDeviceClass.REACTIVE_POWER elif self.metric.lower().startswith("temperature."): device_class = SensorDeviceClass.TEMPERATURE elif self.metric.lower().startswith("timestamp.") or self.metric in [ From b5924d0ac6c398ca2273d42d0e7457257349fd31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:01:26 +0100 Subject: [PATCH 218/370] build(deps): bump ruff from 0.8.0 to 0.8.1 (#1416) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.0 to 0.8.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.8.0...0.8.1) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cbb8ea3b..6a8e4abc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.0 +ruff==0.8.1 ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 From f4ed62ef53909a6c4b11f90a9a9c8ec8c126bdaf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:01:54 +0100 Subject: [PATCH 219/370] build(deps): bump pytest-homeassistant-custom-component (#1415) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.184 to 0.13.186. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.184...0.13.186) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6a8e4abc..ee608fdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.184 +pytest-homeassistant-custom-component==0.13.186 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From eb8346fafa63f22c80ef6bc566b7fddf20a0fc99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:15:56 +0100 Subject: [PATCH 220/370] build(deps): bump actions/upload-artifact from 4.4.3 to 4.5.0 (#1434) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.3 to 4.5.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.4.3...v4.5.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ccf3523..a12a32d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.5.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From 482c91f16b42484580dc571de4b57e1106c9332c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:16:24 +0100 Subject: [PATCH 221/370] build(deps): bump ruff from 0.8.1 to 0.8.3 (#1428) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.1 to 0.8.3. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.8.1...0.8.3) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ee608fdf..f6074294 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.1 +ruff==0.8.3 ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 From 78a9f2af456f9c6fd3777b07f0f6e688979a40db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:18:46 +0100 Subject: [PATCH 222/370] build(deps): bump pytest-homeassistant-custom-component (#1433) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.186 to 0.13.194. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.186...0.13.194) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f6074294..4f220231 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.186 +pytest-homeassistant-custom-component==0.13.194 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From f23ae03dcef469df65e551fcc25ff04de7b2fdfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Dec 2024 13:10:28 +0100 Subject: [PATCH 223/370] build(deps): bump jinja2 from 3.1.4 to 3.1.5 (#1441) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 709ccb7b..1b47fb32 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==3.0.1 docutils==0.18.1 -Jinja2==3.1.4 +Jinja2==3.1.5 sphinx==7.1.2 sphinx_rtd_theme==3.0.2 From 9bddf55cf164b8acb61e7f1fe0e2fae87809d376 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Dec 2024 13:11:06 +0100 Subject: [PATCH 224/370] build(deps): bump ruff from 0.8.3 to 0.8.4 (#1436) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.3 to 0.8.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.8.3...0.8.4) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4f220231..34a9a8d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.3 +ruff==0.8.4 ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 From 85b794d9dc96c02b5d03123240352cd32dcbd129 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Thu, 26 Dec 2024 01:18:39 +1300 Subject: [PATCH 225/370] switch to new websockets asyncio (#1439) * switch to new websockets asyncio * add errors raised * fix error raise * extra detail on nosub test * add test for autoconfig * remove old tests * update deprecated enums in v16 tests * extra test for reboot required * update pre-commit * fix key --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- custom_components/ocpp/api.py | 56 +++++---- custom_components/ocpp/chargepoint.py | 12 +- custom_components/ocpp/manifest.json | 2 +- custom_components/ocpp/ocppv16.py | 4 +- custom_components/ocpp/ocppv201.py | 4 +- tests/charge_point_test.py | 7 +- tests/conftest.py | 8 +- tests/const.py | 1 + tests/test_charge_point_v16.py | 173 ++++++++++---------------- 10 files changed, 124 insertions(+), 145 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e658f46a..261aa8f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 9d23c8b9..f1f058cc 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -8,9 +8,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OK from homeassistant.core import HomeAssistant -from websockets import Subprotocol -import websockets.protocol +from websockets import Subprotocol, NegotiationError import websockets.server +from websockets.asyncio.server import ServerConnection from .chargepoint import CentralSystemSettings from .ocppv16 import ChargePoint as ChargePointv16 @@ -21,7 +21,6 @@ CONF_CSID, CONF_HOST, CONF_PORT, - CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, CONF_SSL_CERTFILE_PATH, CONF_SSL_KEYFILE_PATH, @@ -34,7 +33,6 @@ DEFAULT_CSID, DEFAULT_HOST, DEFAULT_PORT, - DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, DEFAULT_SSL_CERTFILE_PATH, DEFAULT_SSL_KEYFILE_PATH, @@ -112,10 +110,11 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): """Create instance and start listening for OCPP connections on given port.""" self = CentralSystem(hass, entry) - server = await websockets.server.serve( + server = await websockets.serve( self.on_connect, self.host, self.port, + select_subprotocol=self.select_subprotocol, subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() ping_timeout=None, @@ -125,27 +124,38 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self._server = server return self - async def on_connect(self, websocket: websockets.server.WebSocketServerProtocol): + def select_subprotocol( + self, connection: ServerConnection, subprotocols + ) -> Subprotocol | None: + """Override default subprotocol selection.""" + + # Server offers at least one subprotocol but client doesn't offer any. + # Default to None + if not subprotocols: + return None + + # Server and client both offer subprotocols. Look for a shared one. + proposed_subprotocols = set(subprotocols) + for subprotocol in proposed_subprotocols: + if subprotocol in self.subprotocols: + return subprotocol + + # No common subprotocol was found. + raise NegotiationError( + "invalid subprotocol; expected one of " + ", ".join(self.subprotocols) + ) + + async def on_connect(self, websocket: ServerConnection): """Request handler executed for every new OCPP connection.""" - if self.config.get(CONF_SKIP_SCHEMA_VALIDATION, DEFAULT_SKIP_SCHEMA_VALIDATION): - _LOGGER.warning("Skipping websocket subprotocol validation") + if websocket.subprotocol is not None: + _LOGGER.info("Websocket Subprotocol matched: %s", websocket.subprotocol) else: - if websocket.subprotocol is not None: - _LOGGER.info("Websocket Subprotocol matched: %s", websocket.subprotocol) - else: - # In the websockets lib if no subprotocols are supported by the - # client and the server, it proceeds without a subprotocol, - # so we have to manually close the connection. - _LOGGER.warning( - "Protocols mismatched | expected Subprotocols: %s," - " but client supports %s | Closing connection", - websocket.available_subprotocols, - websocket.request_headers.get("Sec-WebSocket-Protocol", ""), - ) - return await websocket.close() + _LOGGER.info( + "Websocket Subprotocol not provided by charger: default to ocpp1.6" + ) - _LOGGER.info(f"Charger websocket path={websocket.path}") - cp_id = websocket.path.strip("/") + _LOGGER.info(f"Charger websocket path={websocket.request.path}") + cp_id = websocket.request.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] if self.settings.cpid not in self.charge_points: _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 2655fd11..7fd22e3f 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -21,7 +21,9 @@ from homeassistant.helpers import device_registry, entity_component, entity_registry import homeassistant.helpers.config_validation as cv import voluptuous as vol -import websockets.server +from websockets.asyncio.server import ServerConnection +from websockets.exceptions import WebSocketException +from websockets.protocol import State from ocpp.charge_point import ChargePoint as cp from ocpp.v16 import call as callv16 @@ -471,7 +473,7 @@ async def monitor_connection(self): self._metrics[cstat.latency_pong.value].unit = "ms" connection = self._connection timeout_counter = 0 - while connection.open: + while connection.state is State.OPEN: try: await asyncio.sleep(self.central.websocket_ping_interval) time0 = time.perf_counter() @@ -529,7 +531,7 @@ async def run(self, tasks): await asyncio.gather(*self.tasks) except TimeoutError: pass - except websockets.exceptions.WebSocketException as websocket_exception: + except WebSocketException as websocket_exception: _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") except Exception as other_exception: _LOGGER.error( @@ -542,13 +544,13 @@ async def run(self, tasks): async def stop(self): """Close connection and cancel ongoing tasks.""" self.status = STATE_UNAVAILABLE - if self._connection.open: + if self._connection.state is State.OPEN: _LOGGER.debug(f"Closing websocket to '{self.id}'") await self._connection.close() for task in self.tasks: task.cancel() - async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): + async def reconnect(self, connection: ServerConnection): """Reconnect charge point.""" _LOGGER.debug(f"Reconnect websocket to {self.id}") diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 9f826d71..f0f1ef50 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -14,7 +14,7 @@ "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ "ocpp>=1.0.0", - "websockets>=12.0" + "websockets>=13.1" ], "version": "0.6.1" } diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 4efc7569..b49ecf32 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant import voluptuous as vol -import websockets.server +from websockets.asyncio.server import ServerConnection from ocpp.routing import on from ocpp.v16 import call, call_result @@ -74,7 +74,7 @@ class ChargePoint(cp): def __init__( self, id: str, - connection: websockets.server.WebSocketServerProtocol, + connection: ServerConnection, hass: HomeAssistant, entry: ConfigEntry, central: CentralSystemSettings, diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index f1ea7bd3..3114984f 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -11,7 +11,7 @@ from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, SupportsResponse, ServiceResponse from homeassistant.exceptions import ServiceValidationError, HomeAssistantError -import websockets.server +from websockets.asyncio.server import ServerConnection from ocpp.routing import on from ocpp.v201 import call, call_result @@ -85,7 +85,7 @@ class ChargePoint(cp): def __init__( self, id: str, - connection: websockets.server.WebSocketServerProtocol, + connection: ServerConnection, hass: HomeAssistant, entry: ConfigEntry, central: CentralSystemSettings, diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index 02bea1ef..0398c276 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -23,7 +23,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from typing import Any from collections.abc import Callable, Awaitable -import websockets +from websockets import connect +from websockets.asyncio.client import ClientConnection async def set_switch(hass: HomeAssistant, cs: CentralSystem, key: str, on: bool): @@ -108,12 +109,12 @@ async def run_charge_point_test( config_entry: MockConfigEntry, identity: str, subprotocols: list[str] | None, - charge_point: Callable[[websockets.WebSocketClientProtocol], ChargePoint], + charge_point: Callable[[ClientConnection], ChargePoint], parallel_tests: list[Callable[[ChargePoint], Awaitable]], ) -> Any: """Connect web socket client to the CSMS and run a number of tests in parallel.""" completed: list[list[bool]] = [[] for _ in parallel_tests] - async with websockets.connect( + async with connect( f"ws://127.0.0.1:{config_entry.data[CONF_PORT]}/{identity}", subprotocols=[Subprotocol(s) for s in subprotocols] if subprotocols is not None diff --git a/tests/conftest.py b/tests/conftest.py index 4264d5c2..9ce3d091 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,11 +35,11 @@ def skip_notifications_fixture(): def bypass_get_data_fixture(): """Skip calls to get data from API.""" future = asyncio.Future() - future.set_result(websockets.WebSocketServer) + future.set_result(websockets.asyncio.server.Server) with ( - patch("websockets.server.serve", return_value=future), - patch("websockets.server.WebSocketServer.close"), - patch("websockets.server.WebSocketServer.wait_closed"), + patch("websockets.asyncio.server.serve", return_value=future), + patch("websockets.asyncio.server.Server.close"), + patch("websockets.asyncio.server.Server.wait_closed"), ): yield diff --git a/tests/const.py b/tests/const.py index e66520be..69897707 100644 --- a/tests/const.py +++ b/tests/const.py @@ -76,6 +76,7 @@ CONF_PORT: 9002, CONF_CPID: "test_cpid_2", CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, } # separate entry for switch so tests can run concurrently diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index ffc60a23..3e63c39e 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -117,96 +117,68 @@ async def test_services(hass, cs, socket_enabled): await set_number(hass, cs, number.key, 10) # Test MOCK_CONFIG_DATA_2 - if True: - # Create a mock entry so we don't have to go through config flow - config_entry2 = MockConfigEntry( - domain=OCPP_DOMAIN, - data=MOCK_CONFIG_DATA_2, - entry_id="test_cms2", - title="test_cms2", - ) - config_entry2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry2.entry_id) - await hass.async_block_till_done() - - # no subprotocol - # NB each new config entry will trigger async_update_entry - # if the charger measurands differ from the config entry - # which causes the websocket server to close/restart with a - # ConnectionClosedOK exception, hence it needs to be passed/suppressed - async with websockets.connect( - "ws://127.0.0.1:9002/CP_1_nosub", - ) as ws2: - # use a different id for debugging - cp2 = ChargePoint("CP_1_no_subprotocol", ws2) - with contextlib.suppress( - asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK - ): - await asyncio.wait_for( - asyncio.gather( - cp2.start(), - cp2.send_boot_notification(), - cp2.send_authorize(), - cp2.send_heartbeat(), - cp2.send_status_notification(), - cp2.send_firmware_status(), - cp2.send_data_transfer(), - cp2.send_start_transaction(), - cp2.send_stop_transaction(), - cp2.send_meter_periodic_data(), - ), - timeout=5, - ) - await ws2.close() - await asyncio.sleep(1) - if entry := hass.config_entries.async_get_entry(config_entry2.entry_id): - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms", title="test_cms" + config_entry2 = MockConfigEntry( + domain=OCPP_DOMAIN, + data=MOCK_CONFIG_DATA_2, + entry_id="test_cms2", + title="test_cms2", ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + config_entry2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry2.entry_id) await hass.async_block_till_done() - cs = hass.data[OCPP_DOMAIN][config_entry.entry_id] - - # no subprotocol + # no subprotocol central system assumes ocpp1.6 charge point + # NB each new config entry will trigger async_update_entry + # if the charger measurands differ from the config entry + # which causes the websocket server to close/restart with a + # ConnectionClosedOK exception, hence it needs to be passed/suppressed async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_unsup", - ) as ws: + "ws://127.0.0.1:9002/CP_1_nosub", + ) as ws2: # use a different id for debugging - cp = ChargePoint("CP_1_no_subprotocol", ws) - with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): + assert ws2.subprotocol is None + cp2 = ChargePoint("CP_1_no_subprotocol", ws2) + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): await asyncio.wait_for( asyncio.gather( - cp.start(), + cp2.start(), + cp2.send_boot_notification(), + cp2.send_authorize(), + cp2.send_heartbeat(), + cp2.send_status_notification(), + cp2.send_firmware_status(), + cp2.send_data_transfer(), + cp2.send_start_transaction(), + cp2.send_stop_transaction(), + cp2.send_meter_periodic_data(), ), - timeout=3, + timeout=5, ) - await ws.close() - + await ws2.close() await asyncio.sleep(1) + if entry := hass.config_entries.async_get_entry(config_entry2.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() - # unsupported subprotocol - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_unsup", - subprotocols=["ocpp0.0"], - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_unsupported_subprotocol", ws) - with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): - await asyncio.wait_for( - asyncio.gather( - cp.start(), - ), - timeout=3, - ) - await ws.close() + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms", title="test_cms" + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - await asyncio.sleep(1) + cs = hass.data[OCPP_DOMAIN][config_entry.entry_id] + + # unsupported subprotocol raises websockets exception + with pytest.raises(websockets.exceptions.InvalidStatus): + await websockets.connect( + "ws://127.0.0.1:9000/CP_1_unsup", + subprotocols=["ocpp0.0"], + ) # test restore feature of meter_start and active_tranasction_id. async with websockets.connect( @@ -431,18 +403,6 @@ async def test_services(hass, cs, socket_enabled): await asyncio.sleep(1) - # setting state no longer available with websockets >14 - # test ping timeout, change cpid to start new connection - # cs.settings.cpid = "CP_3_test" - # async with websockets.connect( - # "ws://127.0.0.1:9000/CP_3", - # subprotocols=["ocpp1.6"], - # ) as ws: - # cp = ChargePoint("CP_3_test", ws) - # ws.state = 3 # CLOSED = 3 - # await asyncio.sleep(3) - # await ws.close() - # test services when charger is unavailable await asyncio.sleep(1) await test_services(hass, cs, socket_enabled) @@ -460,7 +420,7 @@ def __init__(self, id, connection, response_timeout=30): self.active_transactionId: int = 0 self.accept: bool = True - @on(Action.GetConfiguration) + @on(Action.get_configuration) def on_get_configuration(self, key, **kwargs): """Handle a get configuration requests.""" if key[0] == ConfigurationKey.supported_feature_profiles.value: @@ -547,15 +507,20 @@ def on_get_configuration(self, key, **kwargs): configuration_key=[{"key": key[0], "readonly": False, "value": ""}] ) - @on(Action.ChangeConfiguration) - def on_change_configuration(self, **kwargs): + @on(Action.change_configuration) + def on_change_configuration(self, key, **kwargs): """Handle a get configuration request.""" if self.accept is True: - return call_result.ChangeConfiguration(ConfigurationStatus.accepted) + if key == ConfigurationKey.meter_values_sampled_data.value: + return call_result.ChangeConfiguration( + ConfigurationStatus.reboot_required + ) + else: + return call_result.ChangeConfiguration(ConfigurationStatus.accepted) else: return call_result.ChangeConfiguration(ConfigurationStatus.rejected) - @on(Action.ChangeAvailability) + @on(Action.change_availability) def on_change_availability(self, **kwargs): """Handle change availability request.""" if self.accept is True: @@ -563,7 +528,7 @@ def on_change_availability(self, **kwargs): else: return call_result.ChangeAvailability(AvailabilityStatus.rejected) - @on(Action.UnlockConnector) + @on(Action.unlock_connector) def on_unlock_connector(self, **kwargs): """Handle unlock request.""" if self.accept is True: @@ -571,7 +536,7 @@ def on_unlock_connector(self, **kwargs): else: return call_result.UnlockConnector(UnlockStatus.unlock_failed) - @on(Action.Reset) + @on(Action.reset) def on_reset(self, **kwargs): """Handle change availability request.""" if self.accept is True: @@ -579,7 +544,7 @@ def on_reset(self, **kwargs): else: return call_result.Reset(ResetStatus.rejected) - @on(Action.RemoteStartTransaction) + @on(Action.remote_start_transaction) def on_remote_start_transaction(self, **kwargs): """Handle remote start request.""" if self.accept is True: @@ -588,7 +553,7 @@ def on_remote_start_transaction(self, **kwargs): else: return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) - @on(Action.RemoteStopTransaction) + @on(Action.remote_stop_transaction) def on_remote_stop_transaction(self, **kwargs): """Handle remote stop request.""" if self.accept is True: @@ -596,7 +561,7 @@ def on_remote_stop_transaction(self, **kwargs): else: return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) - @on(Action.SetChargingProfile) + @on(Action.set_charging_profile) def on_set_charging_profile(self, **kwargs): """Handle set charging profile request.""" if self.accept is True: @@ -604,7 +569,7 @@ def on_set_charging_profile(self, **kwargs): else: return call_result.SetChargingProfile(ChargingProfileStatus.rejected) - @on(Action.ClearChargingProfile) + @on(Action.clear_charging_profile) def on_clear_charging_profile(self, **kwargs): """Handle clear charging profile request.""" if self.accept is True: @@ -612,7 +577,7 @@ def on_clear_charging_profile(self, **kwargs): else: return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) - @on(Action.TriggerMessage) + @on(Action.trigger_message) def on_trigger_message(self, **kwargs): """Handle trigger message request.""" if self.accept is True: @@ -620,17 +585,17 @@ def on_trigger_message(self, **kwargs): else: return call_result.TriggerMessage(TriggerMessageStatus.rejected) - @on(Action.UpdateFirmware) + @on(Action.update_firmware) def on_update_firmware(self, **kwargs): """Handle update firmware request.""" return call_result.UpdateFirmware() - @on(Action.GetDiagnostics) + @on(Action.get_diagnostics) def on_get_diagnostics(self, **kwargs): """Handle get diagnostics request.""" return call_result.GetDiagnostics() - @on(Action.DataTransfer) + @on(Action.data_transfer) def on_data_transfer(self, **kwargs): """Handle get data transfer request.""" if self.accept is True: From 1c488072efd5a9fa7a72e386dad3c2ec567dcac1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Dec 2024 13:28:54 +0100 Subject: [PATCH 226/370] build(deps): bump pytest-homeassistant-custom-component (#1440) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.194 to 0.13.195. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.194...0.13.195) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 34a9a8d3..6931fc0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.194 +pytest-homeassistant-custom-component==0.13.195 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 95b754d6dcfbc75283e99bab8a056f027f480ed3 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Thu, 26 Dec 2024 05:47:02 +1300 Subject: [PATCH 227/370] add executor to ssl to avoid blocking (#1435) * add executor to ssl avoid blocking * additional funcs to handle await in init * add self to entry * add await to central system instance * add yield * return self from awaitable * move ssl to create method * Update websockets serve --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/api.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index f1f058cc..eeefba2f 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -5,6 +5,7 @@ import logging import ssl +from functools import partial from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OK from homeassistant.core import HomeAssistant @@ -90,26 +91,31 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): self.config = entry.data self.id = entry.entry_id self.charge_points = {} - if entry.data.get(CONF_SSL, DEFAULT_SSL): + + @staticmethod + async def create(hass: HomeAssistant, entry: ConfigEntry): + """Create instance and start listening for OCPP connections on given port.""" + self = CentralSystem(hass, entry) + + if self.entry.data.get(CONF_SSL, DEFAULT_SSL): self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # see https://community.home-assistant.io/t/certificate-authority-and-self-signed-certificate-for-ssl-tls/196970 - localhost_certfile = entry.data.get( + localhost_certfile = self.entry.data.get( CONF_SSL_CERTFILE_PATH, DEFAULT_SSL_CERTFILE_PATH ) - localhost_keyfile = entry.data.get( + localhost_keyfile = self.entry.data.get( CONF_SSL_KEYFILE_PATH, DEFAULT_SSL_KEYFILE_PATH ) - self.ssl_context.load_cert_chain( - localhost_certfile, keyfile=localhost_keyfile + await self.hass.async_add_executor_job( + partial( + self.ssl_context.load_cert_chain, + localhost_certfile, + keyfile=localhost_keyfile, + ) ) else: self.ssl_context = None - @staticmethod - async def create(hass: HomeAssistant, entry: ConfigEntry): - """Create instance and start listening for OCPP connections on given port.""" - self = CentralSystem(hass, entry) - server = await websockets.serve( self.on_connect, self.host, From d351da0c7cc7f4a0dcc135e6ddffe2d9a5814b1b Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Wed, 25 Dec 2024 17:55:37 +0100 Subject: [PATCH 228/370] Update manifest.json (#1443) bump websocket version bump version --- custom_components/ocpp/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index f0f1ef50..660e1a41 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -14,7 +14,7 @@ "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ "ocpp>=1.0.0", - "websockets>=13.1" + "websockets>=14.1" ], - "version": "0.6.1" + "version": "0.6.3" } From d7d71a9be73162f73cbc021ed3ae22d39a8fa1ef Mon Sep 17 00:00:00 2001 From: Marco Fretz Date: Fri, 3 Jan 2025 21:29:02 +0100 Subject: [PATCH 229/370] pin occp dependency (#1462) --- custom_components/ocpp/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 660e1a41..4f77b7a3 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp>=1.0.0", + "ocpp==1.0.0", "websockets>=14.1" ], "version": "0.6.3" From 88b0d23729745a065741df9e6ecda4ad3a6c696d Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:31:04 +1300 Subject: [PATCH 230/370] Fix via device identifier (#1465) --- custom_components/ocpp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index e43985ea..77c3e707 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -86,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): identifiers={(DOMAIN, entry.data.get(CONF_CPID, DEFAULT_CPID))}, name=entry.data.get(CONF_CPID, DEFAULT_CPID), model="Unknown", - via_device=((DOMAIN), central_sys.id), + via_device=(DOMAIN, entry.data.get(CONF_CSID, DEFAULT_CSID)), ) hass.data[DOMAIN][entry.entry_id] = central_sys From 9df0d1794f9ea5617374cdac084e6104adf445d4 Mon Sep 17 00:00:00 2001 From: DOliana Date: Sun, 5 Jan 2025 10:33:37 +0100 Subject: [PATCH 231/370] add FAQ to support page (#1459) Co-authored-by: Denis Oliana <16098907+DOliana@users.noreply.github.com> --- docs/support.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/support.md b/docs/support.md index f44c9994..e450d829 100644 --- a/docs/support.md +++ b/docs/support.md @@ -1,4 +1,40 @@ -Support +# Support ======= +- [General](#general) +- [FAQ](#faq) + - [too many notifications in home assistant](#too-many-notifications-in-home-assistant) + +## General + If you need help, check out our [forum](https://github.com/lbbrhzn/ocpp/discussions) or submit an [issue](https://github.com/lbbrhzn/ocpp/issues). + +## FAQ + +### too many notifications in home assistant + +The OCPP sends a notification when the charger is rebooted. This can be due to a bad network connection. The notifications can be managed with automations in home assistant. (see https://github.com/lbbrhzn/ocpp/discussions/938) + +Example: + +``` +trigger: + - platform: persistent_notification + update_type: + - added + notification_id: "" +condition: + - condition: template + value_template: "{{ trigger.notification.title | lower == \"ocpp integration\" }}" +action: + - delay: + hours: 0 + minutes: 10 + seconds: 0 + milliseconds: 0 + - service: persistent_notification.dismiss + data: + notification_id: "{{ trigger.notification.notification_id }}" +mode: parallel +max: 10 +``` \ No newline at end of file From 01b7cb156e66ab37bf4a469aa8d9f79a54b98216 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jan 2025 10:34:02 +0100 Subject: [PATCH 232/370] build(deps): bump ruff from 0.8.4 to 0.8.5 (#1460) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.4 to 0.8.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.8.4...0.8.5) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6931fc0c..8fc7e897 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.4 +ruff==0.8.5 ocpp==1.0.0 websockets==14.1 jsonschema==4.23.0 From e74c918833e59e42ecc6644312e77cf571f02313 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:35:39 +1300 Subject: [PATCH 233/370] Update to use FlowResultType enums (#1446) From HA 2025.1 the constants have been removed --- tests/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e2350dd6..e3d7d548 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -44,7 +44,7 @@ async def test_successful_config_flow(hass, bypass_get_data): ) # Check that the config flow shows the user form as the first step - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" # If a user were to enter `test_username` for username and `test_password` @@ -55,7 +55,7 @@ async def test_successful_config_flow(hass, bypass_get_data): # Check that the config flow is complete and a new entry is created with # the input data - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "test_csid" assert result["data"] == MOCK_CONFIG_DATA assert result["result"] From 22d09ea1da35a2c06e3769dd15927b5456428f8c Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 5 Jan 2025 10:36:08 +0100 Subject: [PATCH 234/370] Fix missing state class for reactive power sensors after #1417 (#1451) * fix state_class * add test for reactive sensor * Revert test_init.py * Add HA unit * Add OCPP to HA unit mapping * add assert vor reactive unit * add separate sensor test --------- Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/const.py | 3 ++- custom_components/ocpp/sensor.py | 1 + tests/test_charge_point_v16.py | 4 +++- tests/test_sensor.py | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/test_sensor.py diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 3a924868..6bf52c49 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -113,7 +113,7 @@ UnitOfMeasure.kw: ha.UnitOfPower.KILO_WATT, UnitOfMeasure.va: ha.UnitOfApparentPower.VOLT_AMPERE, UnitOfMeasure.kva: UnitOfMeasure.kva, - UnitOfMeasure.var: UnitOfMeasure.var, + UnitOfMeasure.var: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, UnitOfMeasure.kvar: UnitOfMeasure.kvar, UnitOfMeasure.a: ha.UnitOfElectricCurrent.AMPERE, UnitOfMeasure.v: ha.UnitOfElectricPotential.VOLT, @@ -130,5 +130,6 @@ SensorDeviceClass.FREQUENCY: ha.UnitOfFrequency.HERTZ, SensorDeviceClass.BATTERY: ha.PERCENTAGE, SensorDeviceClass.POWER: ha.UnitOfPower.KILO_WATT, + SensorDeviceClass.REACTIVE_POWER: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, SensorDeviceClass.ENERGY: ha.UnitOfEnergy.KILO_WATT_HOUR, } diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index d577ee09..f4299a58 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -137,6 +137,7 @@ def state_class(self): SensorDeviceClass.CURRENT, SensorDeviceClass.VOLTAGE, SensorDeviceClass.POWER, + SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.TEMPERATURE, SensorDeviceClass.BATTERY, SensorDeviceClass.FREQUENCY, diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 3e63c39e..d511a525 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -268,6 +268,8 @@ async def test_services(hass, cs, socket_enabled): assert int(cs.get_metric("test_cpid", "Current.Import")) == 0 assert int(cs.get_metric("test_cpid", "Voltage")) == 228 assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" + assert cs.get_ha_unit("test_cpid", "Power.Reactive.Import") == "var" + assert cs.get_unit("test_cpid", "Power.Reactive.Import") == "var" assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None @@ -821,7 +823,7 @@ async def send_meter_periodic_data(self): "value": "89.00", "context": "Sample.Periodic", "measurand": "Power.Reactive.Import", - "unit": "W", + "unit": "var", }, { "value": "0.010", diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 00000000..ec921e7f --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,35 @@ +"""Test sensor for ocpp integration.""" + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN + +from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.components.sensor.const import ( + SensorDeviceClass, + SensorStateClass, + ATTR_STATE_CLASS, +) +from .const import MOCK_CONFIG_DATA + + +async def test_sensor(hass, socket_enabled): + """Test sensor.""" + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=MOCK_CONFIG_DATA, + entry_id="test_cms", + title="test_cms", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test reactive power sensor + state = hass.states.get("sensor.test_cpid_power_reactive_import") + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.REACTIVE_POWER + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + # Test reactive energx sensor, not having own device class yet + state = hass.states.get("sensor.test_cpid_energy_reactive_import_register") + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None From cd28cef152643463770a3d3db76f1914b3be3585 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:36:45 +1300 Subject: [PATCH 235/370] Update to ocpp 2.0.0 to fix blocking (#1423) * test pre-release candidate * bump to rc3 * Try async validation false * Tidy up * Revert "Tidy up" This reverts commit 3c5c4bd27561652d4f962bf28f3544765a1dad9e. * Add note * Update ocpp to 2.0.0rc3 * Ocpp 2.0.0 * Ocpp 2.0.0 * Update v201 Type Enums * fix datatype import * fix enums in test * fix datatypes and @Action * fix find replace errors * fix token_type * other fixes --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/manifest.json | 2 +- custom_components/ocpp/ocppv201.py | 151 +++++++------- requirements.txt | 2 +- tests/test_charge_point_v201.py | 301 ++++++++++++++------------- 4 files changed, 237 insertions(+), 219 deletions(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 4f77b7a3..bbe363bd 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp==1.0.0", + "ocpp>=2.0.0", "websockets>=14.1" ], "version": "0.6.3" diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 3114984f..e9e6063c 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -17,23 +17,24 @@ from ocpp.v201 import call, call_result from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 from ocpp.v201.enums import ( - ConnectorStatusType, - GetVariableStatusType, - IdTokenType, - MeasurandType, - OperationalStatusType, - ResetType, - ResetStatusType, - SetVariableStatusType, - AuthorizationStatusType, - TransactionEventType, - ReadingContextType, - RequestStartStopStatusType, - ChargingStateType, - ChargingProfilePurposeType, - ChargingRateUnitType, - ChargingProfileKindType, - ChargingProfileStatus, + Action, + ConnectorStatusEnumType, + GetVariableStatusEnumType, + IdTokenEnumType, + MeasurandEnumType, + OperationalStatusEnumType, + ResetEnumType, + ResetStatusEnumType, + SetVariableStatusEnumType, + AuthorizationStatusEnumType, + TransactionEventEnumType, + ReadingContextEnumType, + RequestStartStopStatusEnumType, + ChargingStateEnumType, + ChargingProfilePurposeEnumType, + ChargingRateUnitEnumType, + ChargingProfileKindEnumType, + ChargingProfileStatusEnumType, ) from .chargepoint import ( @@ -71,7 +72,7 @@ class InventoryReport: smart_charging_available: bool = False reservation_available: bool = False local_auth_available: bool = False - tx_updated_measurands: list[MeasurandType] = [] + tx_updated_measurands: list[MeasurandEnumType] = [] class ChargePoint(cp): @@ -79,7 +80,7 @@ class ChargePoint(cp): _inventory: InventoryReport | None = None _wait_inventory: asyncio.Event | None = None - _connector_status: list[list[ConnectorStatusType | None]] = [] + _connector_status: list[list[ConnectorStatusEnumType | None]] = [] _tx_start_time: datetime | None = None def __init__( @@ -254,7 +255,7 @@ async def clear_profile(self): req: call.ClearChargingProfile = call.ClearChargingProfile( None, { - "charging_profile_Purpose": ChargingProfilePurposeType.charging_station_max_profile.value + "charging_profile_Purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value }, ) await self.call(req) @@ -275,10 +276,10 @@ async def set_charge_rate( schedule: dict = {"id": 1} if limit_amps < 32: period["limit"] = limit_amps - schedule["charging_rate_unit"] = ChargingRateUnitType.amps.value + schedule["charging_rate_unit"] = ChargingRateUnitEnumType.amps.value elif limit_watts < 22000: period["limit"] = limit_watts - schedule["charging_rate_unit"] = ChargingRateUnitType.watts.value + schedule["charging_rate_unit"] = ChargingRateUnitEnumType.watts.value else: await self.clear_profile() return @@ -289,14 +290,14 @@ async def set_charge_rate( { "id": 1, "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, - "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [schedule], }, ) resp: call_result.SetChargingProfile = await self.call(req) - if resp.status != ChargingProfileStatus.accepted: + if resp.status != ChargingProfileStatusEnumType.accepted: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_variables_error", @@ -308,9 +309,9 @@ async def set_charge_rate( async def set_availability(self, state: bool = True): """Change availability.""" req: call.ChangeAvailability = call.ChangeAvailability( - OperationalStatusType.operative.value + OperationalStatusEnumType.operative.value if state - else OperationalStatusType.inoperative.value + else OperationalStatusEnumType.inoperative.value ) await self.call(req) @@ -319,12 +320,12 @@ async def start_transaction(self) -> bool: req: call.RequestStartTransaction = call.RequestStartTransaction( id_token={ "id_token": self._remote_id_tag, - "type": IdTokenType.central.value, + "type": IdTokenEnumType.central.value, }, remote_start_id=1, ) resp: call_result.RequestStartTransaction = await self.call(req) - return resp.status == RequestStartStopStatusType.accepted.value + return resp.status == RequestStartStopStatusEnumType.accepted.value async def stop_transaction(self) -> bool: """Request remote stop of current transaction.""" @@ -332,13 +333,13 @@ async def stop_transaction(self) -> bool: transaction_id=self._metrics[csess.transaction_id.value].value ) resp: call_result.RequestStopTransaction = await self.call(req) - return resp.status == RequestStartStopStatusType.accepted.value + return resp.status == RequestStartStopStatusEnumType.accepted.value async def reset(self, typ: str = ""): """Hard reset charger unless soft reset requested.""" - req: call.Reset = call.Reset(ResetType.immediate) + req: call.Reset = call.Reset(ResetEnumType.immediate) resp = await self.call(req) - if resp.status != ResetStatusType.accepted.value: + if resp.status != ResetStatusEnumType.accepted.value: status_suffix: str = f": {resp.status_info}" if resp.status_info else "" raise HomeAssistantError( translation_domain=DOMAIN, @@ -382,7 +383,7 @@ async def get_configuration(self, key: str = "") -> str | None: translation_placeholders={"message": str(e)}, ) result: dict = resp.get_variable_result[0] - if result["attribute_status"] != GetVariableStatusType.accepted: + if result["attribute_status"] != GetVariableStatusEnumType.accepted: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="get_variables_error", @@ -405,9 +406,9 @@ async def configure(self, key: str, value: str) -> SetVariableResult: translation_placeholders={"message": str(e)}, ) result: dict = resp.set_variable_result[0] - if result["attribute_status"] == SetVariableStatusType.accepted: + if result["attribute_status"] == SetVariableStatusEnumType.accepted: return SetVariableResult.accepted - elif result["attribute_status"] == SetVariableStatusType.reboot_required: + elif result["attribute_status"] == SetVariableStatusEnumType.reboot_required: return SetVariableResult.reboot_required else: raise HomeAssistantError( @@ -416,7 +417,7 @@ async def configure(self, key: str, value: str) -> SetVariableResult: translation_placeholders={"message": str(result)}, ) - @on("BootNotification") + @on(Action.boot_notification) def on_boot_notification(self, charging_station, reason, **kwargs): """Perform OCPP callback.""" resp = call_result.BootNotification( @@ -432,7 +433,7 @@ def on_boot_notification(self, charging_station, reason, **kwargs): self._register_boot_notification() return resp - @on("Heartbeat") + @on(Action.heartbeat) def on_heartbeat(self, **kwargs): """Perform OCPP callback.""" return call_result.Heartbeat(current_time=datetime.now(tz=UTC).isoformat()) @@ -448,7 +449,7 @@ def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv1 ) self.hass.async_create_task(self.update(self.central.cpid)) - @on("StatusNotification") + @on(Action.status_notification) def on_status_notification( self, timestamp: str, connector_status: str, evse_id: int, connector_id: int ): @@ -460,25 +461,25 @@ def on_status_notification( connector_id - len(self._connector_status[evse_id - 1]) ) - evse: list[ConnectorStatusType] = self._connector_status[evse_id - 1] - evse[connector_id - 1] = ConnectorStatusType(connector_status) - evse_status: ConnectorStatusType | None = None + evse: list[ConnectorStatusEnumType] = self._connector_status[evse_id - 1] + evse[connector_id - 1] = ConnectorStatusEnumType(connector_status) + evse_status: ConnectorStatusEnumType | None = None for status in evse: if status is None: evse_status = status break else: evse_status = status - if status != ConnectorStatusType.available: + if status != ConnectorStatusEnumType.available: break evse_status_v16: ChargePointStatusv16 | None if evse_status is None: evse_status_v16 = None - elif evse_status == ConnectorStatusType.available: + elif evse_status == ConnectorStatusEnumType.available: evse_status_v16 = ChargePointStatusv16.available - elif evse_status == ConnectorStatusType.faulted: + elif evse_status == ConnectorStatusEnumType.faulted: evse_status_v16 = ChargePointStatusv16.faulted - elif evse_status == ConnectorStatusType.unavailable: + elif evse_status == ConnectorStatusEnumType.unavailable: evse_status_v16 = ChargePointStatusv16.unavailable else: evse_status_v16 = ChargePointStatusv16.preparing @@ -488,15 +489,15 @@ def on_status_notification( return call_result.StatusNotification() - @on("FirmwareStatusNotification") - @on("MeterValues") - @on("LogStatusNotification") - @on("NotifyEvent") + @on(Action.firmware_status_notification) + @on(Action.meter_values) + @on(Action.log_status_notification) + @on(Action.notify_event) def ack(self, **kwargs): """Perform OCPP callback.""" return call_result.StatusNotification() - @on("NotifyReport") + @on(Action.notify_report) def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): """Perform OCPP callback.""" if self._wait_inventory is None: @@ -559,23 +560,23 @@ def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): characteristics: dict = report_data["variable_characteristics"] values: str = characteristics.get("values_list", "") self._inventory.tx_updated_measurands = [ - MeasurandType(s) for s in values.split(",") + MeasurandEnumType(s) for s in values.split(",") ] if not kwargs.get("tbc", False): self._wait_inventory.set() return call_result.NotifyReport() - @on("Authorize") + @on(Action.authorize) def on_authorize(self, id_token: dict, **kwargs): """Perform OCPP callback.""" - status: str = AuthorizationStatusType.unknown.value + status: str = AuthorizationStatusEnumType.unknown.value token_type: str = id_token["type"] token: str = id_token["id_token"] if ( - (token_type == IdTokenType.iso14443) - or (token_type == IdTokenType.iso15693) - or (token_type == IdTokenType.central) + (token_type == IdTokenEnumType.iso14443) + or (token_type == IdTokenEnumType.iso15693) + or (token_type == IdTokenEnumType.central) ): status = self.get_authorization_status(token) return call_result.Authorize(id_token_info={"status": status}) @@ -586,7 +587,7 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): measurands: list[MeasurandValue] = [] for sampled_value in meter_value["sampled_value"]: measurand: str = sampled_value.get( - "measurand", MeasurandType.energy_active_import_register.value + "measurand", MeasurandEnumType.energy_active_import_register.value ) value: float = sampled_value["value"] context: str = sampled_value.get("context", None) @@ -602,11 +603,11 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): ) converted_values.append(measurands) - if (tx_event_type == TransactionEventType.started.value) or ( - (tx_event_type == TransactionEventType.updated.value) + if (tx_event_type == TransactionEventEnumType.started.value) or ( + (tx_event_type == TransactionEventEnumType.updated.value) and (self._metrics[csess.meter_start].value is None) ): - energy_measurand = MeasurandType.energy_active_import_register.value + energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: for measurand_item in meter_value: if measurand_item.measurand == energy_measurand: @@ -617,9 +618,9 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): self.process_measurands(converted_values, True) - if tx_event_type == TransactionEventType.ended.value: + if tx_event_type == TransactionEventEnumType.ended.value: measurands_in_tx: set[str] = set() - tx_end_context = ReadingContextType.transaction_end.value + tx_end_context = ReadingContextEnumType.transaction_end.value for meter_value in converted_values: for measurand_item in meter_value: if measurand_item.context == tx_end_context: @@ -633,9 +634,15 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): ): self._metrics[measurand].value = 0 - @on("TransactionEvent") + @on(Action.transaction_event) def on_transaction_event( - self, event_type, timestamp, trigger_reason, seq_no, transaction_info, **kwargs + self, + event_type, + timestamp, + trigger_reason, + seq_no, + transaction_info, + **kwargs, ): """Perform OCPP callback.""" offline: bool = kwargs.get("offline", False) @@ -647,15 +654,15 @@ def on_transaction_event( state = transaction_info["charging_state"] evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 evse_status_v16: ChargePointStatusv16 | None = None - if state == ChargingStateType.idle: + if state == ChargingStateEnumType.idle: evse_status_v16 = ChargePointStatusv16.available - elif state == ChargingStateType.ev_connected: + elif state == ChargingStateEnumType.ev_connected: evse_status_v16 = ChargePointStatusv16.preparing - elif state == ChargingStateType.suspended_evse: + elif state == ChargingStateEnumType.suspended_evse: evse_status_v16 = ChargePointStatusv16.suspended_evse - elif state == ChargingStateType.suspended_ev: + elif state == ChargingStateEnumType.suspended_ev: evse_status_v16 = ChargePointStatusv16.suspended_ev - elif state == ChargingStateType.charging: + elif state == ChargingStateEnumType.charging: evse_status_v16 = ChargePointStatusv16.charging if evse_status_v16: self._report_evse_status(evse_id, evse_status_v16) @@ -663,11 +670,11 @@ def on_transaction_event( response = call_result.TransactionEvent() id_token = kwargs.get("id_token") if id_token: - response.id_token_info = {"status": AuthorizationStatusType.accepted} + response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] self._metrics[cstat.id_tag.value].value = id_tag_string - if event_type == TransactionEventType.started.value: + if event_type == TransactionEventEnumType.started.value: self._tx_start_time = t tx_id: str = transaction_info["transaction_id"] self._metrics[csess.transaction_id.value].value = tx_id @@ -678,7 +685,7 @@ def on_transaction_event( duration_minutes: int = ((t - self._tx_start_time).seconds + 59) // 60 self._metrics[csess.session_time].value = duration_minutes self._metrics[csess.session_time].unit = UnitOfTime.MINUTES - if event_type == TransactionEventType.ended.value: + if event_type == TransactionEventEnumType.ended.value: self._metrics[csess.transaction_id.value].value = "" self._metrics[cstat.id_tag.value].value = "" diff --git a/requirements.txt b/requirements.txt index 8fc7e897..b6f8e8ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.9.0 uv>=0.4 ruff==0.8.5 -ocpp==1.0.0 +ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index cdea9634..b7073738 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -35,6 +35,7 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry +import ocpp from ocpp.routing import on import ocpp.exceptions from ocpp.v201 import ChargePoint as cpclass, call, call_result @@ -50,38 +51,38 @@ ) from ocpp.v201.enums import ( Action, - AuthorizationStatusType, - BootReasonType, - ChangeAvailabilityStatusType, - ChargingProfileKindType, - ChargingProfilePurposeType, - ChargingProfileStatus, - ChargingRateUnitType, - ChargingStateType, - ClearChargingProfileStatusType, - ConnectorStatusType, - DataType, - FirmwareStatusType, - GenericDeviceModelStatusType, - GetVariableStatusType, - IdTokenType, - MeasurandType, - MutabilityType, - OperationalStatusType, - PhaseType, - ReadingContextType, - RegistrationStatusType, - ReportBaseType, - RequestStartStopStatusType, - ResetStatusType, - ResetType, - SetVariableStatusType, - ReasonType, - TransactionEventType, - MessageTriggerType, - TriggerMessageStatusType, - TriggerReasonType, - UpdateFirmwareStatusType, + AuthorizationStatusEnumType, + BootReasonEnumType, + ChangeAvailabilityStatusEnumType, + ChargingProfileKindEnumType, + ChargingProfilePurposeEnumType, + ChargingProfileStatusEnumType, + ChargingRateUnitEnumType, + ChargingStateEnumType, + ClearChargingProfileStatusEnumType, + ConnectorStatusEnumType, + DataEnumType, + FirmwareStatusEnumType, + GenericDeviceModelStatusEnumType, + GetVariableStatusEnumType, + IdTokenEnumType, + MeasurandEnumType, + MutabilityEnumType, + OperationalStatusEnumType, + PhaseEnumType, + ReadingContextEnumType, + RegistrationStatusEnumType, + ReportBaseEnumType, + RequestStartStopStatusEnumType, + ResetStatusEnumType, + ResetEnumType, + SetVariableStatusEnumType, + ReasonEnumType, + TransactionEventEnumType, + MessageTriggerEnumType, + TriggerMessageStatusEnumType, + TriggerReasonEnumType, + UpdateFirmwareStatusEnumType, ) from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 @@ -111,13 +112,15 @@ class ChargePoint(cpclass): accept_reset: bool = True resets: list[call.Reset] = [] - @on(Action.GetBaseReport) + @on(Action.get_base_report) def _on_base_report(self, request_id: int, report_base: str, **kwargs): - assert report_base == ReportBaseType.full_inventory.value + assert report_base == ReportBaseEnumType.full_inventory.value self.task = asyncio.create_task(self._send_full_inventory(request_id)) - return call_result.GetBaseReport(GenericDeviceModelStatusType.accepted.value) + return call_result.GetBaseReport( + GenericDeviceModelStatusEnumType.accepted.value + ) - @on(Action.RequestStartTransaction) + @on(Action.request_start_transaction) def _on_remote_start( self, id_token: dict, remote_start_id: int, **kwargs ) -> call_result.RequestStartTransaction: @@ -128,18 +131,18 @@ def _on_remote_start( self._start_transaction_remote_start(id_token, remote_start_id) ) return call_result.RequestStartTransaction( - RequestStartStopStatusType.accepted.value + RequestStartStopStatusEnumType.accepted.value ) - @on(Action.RequestStopTransaction) + @on(Action.request_stop_transaction) def _on_remote_stop(self, transaction_id: str, **kwargs): assert transaction_id == self.remote_start_tx_id self.remote_stops.append(transaction_id) return call_result.RequestStopTransaction( - RequestStartStopStatusType.accepted.value + RequestStartStopStatusEnumType.accepted.value ) - @on(Action.SetVariables) + @on(Action.set_variables) def _on_set_variables(self, set_variable_data: list[dict], **kwargs): result: list[SetVariableResultType] = [] for input in set_variable_data: @@ -152,15 +155,15 @@ def _on_set_variables(self, set_variable_data: list[dict], **kwargs): ): self.tx_updated_measurands = input["attribute_value"].split(",") - attr_result: SetVariableStatusType + attr_result: SetVariableStatusEnumType if input["variable"] == {"name": "RebootRequired"}: - attr_result = SetVariableStatusType.reboot_required + attr_result = SetVariableStatusEnumType.reboot_required elif input["variable"] == {"name": "BadVariable"}: - attr_result = SetVariableStatusType.unknown_variable + attr_result = SetVariableStatusEnumType.unknown_variable elif input["variable"] == {"name": "VeryBadVariable"}: raise ocpp.exceptions.InternalError() else: - attr_result = SetVariableStatusType.accepted + attr_result = SetVariableStatusEnumType.accepted self.component_instance_used = input["component"].get("instance", None) self.variable_instance_used = input["variable"].get("instance", None) @@ -173,7 +176,7 @@ def _on_set_variables(self, set_variable_data: list[dict], **kwargs): ) return call_result.SetVariables(result) - @on(Action.GetVariables) + @on(Action.get_variables) def _on_get_variables(self, get_variable_data: list[dict], **kwargs): result: list[GetVariableResultType] = [] for input in get_variable_data: @@ -190,9 +193,9 @@ def _on_get_variables(self, get_variable_data: list[dict], **kwargs): raise ocpp.exceptions.InternalError() result.append( GetVariableResultType( - GetVariableStatusType.accepted + GetVariableStatusEnumType.accepted if value is not None - else GetVariableStatusType.unknown_variable, + else GetVariableStatusEnumType.unknown_variable, ComponentType(input["component"]["name"]), VariableType(input["variable"]["name"]), attribute_value=value, @@ -200,19 +203,19 @@ def _on_get_variables(self, get_variable_data: list[dict], **kwargs): ) return call_result.GetVariables(result) - @on(Action.ChangeAvailability) + @on(Action.change_availability) def _on_change_availability(self, operational_status: str, **kwargs): - if operational_status == OperationalStatusType.operative.value: + if operational_status == OperationalStatusEnumType.operative.value: self.operative = True - elif operational_status == OperationalStatusType.inoperative.value: + elif operational_status == OperationalStatusEnumType.inoperative.value: self.operative = False else: assert False return call_result.ChangeAvailability( - ChangeAvailabilityStatusType.accepted.value + ChangeAvailabilityStatusEnumType.accepted.value ) - @on(Action.SetChargingProfile) + @on(Action.set_charging_profile) def _on_set_charging_profile(self, evse_id: int, charging_profile: dict, **kwargs): self.charge_profiles_set.append( call.SetChargingProfile(evse_id, charging_profile) @@ -221,11 +224,15 @@ def _on_set_charging_profile(self, evse_id: int, charging_profile: dict, **kwarg limit = charging_profile["charging_schedule"][0]["charging_schedule_period"][0][ "limit" ] - if (unit == ChargingRateUnitType.amps.value) and (limit < 6): - return call_result.SetChargingProfile(ChargingProfileStatus.rejected.value) - return call_result.SetChargingProfile(ChargingProfileStatus.accepted.value) + if (unit == ChargingRateUnitEnumType.amps.value) and (limit < 6): + return call_result.SetChargingProfile( + ChargingProfileStatusEnumType.rejected.value + ) + return call_result.SetChargingProfile( + ChargingProfileStatusEnumType.accepted.value + ) - @on(Action.ClearChargingProfile) + @on(Action.clear_charging_profile) def _on_clear_charging_profile(self, **kwargs): self.charge_profiles_cleared.append( call.ClearChargingProfile( @@ -234,16 +241,16 @@ def _on_clear_charging_profile(self, **kwargs): ) ) return call_result.ClearChargingProfile( - ClearChargingProfileStatusType.accepted.value + ClearChargingProfileStatusEnumType.accepted.value ) - @on(Action.Reset) + @on(Action.reset) def _on_reset(self, type: str, **kwargs): self.resets.append(call.Reset(type, kwargs.get("evse_id", None))) return call_result.Reset( - ResetStatusType.accepted.value + ResetStatusEnumType.accepted.value if self.accept_reset - else ResetStatusType.rejected.value + else ResetStatusEnumType.rejected.value ) async def _start_transaction_remote_start( @@ -255,14 +262,14 @@ async def _start_transaction_remote_start( ) assert ( authorize_resp.id_token_info["status"] - == AuthorizationStatusType.accepted.value + == AuthorizationStatusEnumType.accepted.value ) self.tx_start_time = datetime.now(tz=UTC) request = call.TransactionEvent( - TransactionEventType.started.value, + TransactionEventEnumType.started.value, self.tx_start_time.isoformat(), - TriggerReasonType.remote_start.value, + TriggerReasonEnumType.remote_start.value, 0, transaction_info={ "transaction_id": self.remote_start_tx_id, @@ -297,7 +304,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("Available"), [ VariableAttributeType( - value="true", mutability=MutabilityType.read_only + value="true", mutability=MutabilityEnumType.read_only ) ], ) @@ -316,7 +323,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("Available"), [ VariableAttributeType( - value="true", mutability=MutabilityType.read_only + value="true", mutability=MutabilityEnumType.read_only ) ], ), @@ -335,7 +342,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("Available"), [ VariableAttributeType( - value="true", mutability=MutabilityType.read_only + value="true", mutability=MutabilityEnumType.read_only ) ], ), @@ -354,7 +361,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("Available"), [ VariableAttributeType( - value="true", mutability=MutabilityType.read_only + value="true", mutability=MutabilityEnumType.read_only ) ], ), @@ -373,7 +380,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("Available"), [ VariableAttributeType( - value="true", mutability=MutabilityType.read_only + value="true", mutability=MutabilityEnumType.read_only ) ], ), @@ -392,7 +399,7 @@ async def _send_full_inventory(self, request_id: int): VariableType("TxUpdatedMeasurands"), [VariableAttributeType(value="", persistent=True)], VariableCharacteristicsType( - DataType.member_list, + DataEnumType.member_list, False, values_list=",".join(supported_measurands), ), @@ -409,7 +416,7 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo assert len(cp.remote_starts) == 1 assert cp.remote_starts[0].id_token == { "id_token": cs.charge_points[cpid]._remote_id_tag, - "type": IdTokenType.central.value, + "type": IdTokenEnumType.central.value, } while cs.get_metric(cpid, csess.transaction_id.value) is None: await asyncio.sleep(0.1) @@ -418,7 +425,7 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo tx_start_time = cp.tx_start_time await cp.call( call.StatusNotification( - tx_start_time.isoformat(), ConnectorStatusType.occupied, 1, 1 + tx_start_time.isoformat(), ConnectorStatusEnumType.occupied, 1, 1 ) ) assert ( @@ -428,9 +435,9 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, tx_start_time.isoformat(), - TriggerReasonType.cable_plugged_in.value, + TriggerReasonEnumType.cable_plugged_in.value, 1, transaction_info={ "transaction_id": cp.remote_start_tx_id, @@ -439,13 +446,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo ) await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, tx_start_time.isoformat(), - TriggerReasonType.charging_state_changed.value, + TriggerReasonEnumType.charging_state_changed.value, 2, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.charging.value, + "charging_state": ChargingStateEnumType.charging.value, }, meter_value=[ { @@ -454,55 +461,55 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo { "value": 0, "measurand": Measurand.current_export.value, - "phase": PhaseType.l1.value, + "phase": PhaseEnumType.l1.value, "unit_of_measure": {"unit": "A"}, }, { "value": 0, "measurand": Measurand.current_export.value, - "phase": PhaseType.l2.value, + "phase": PhaseEnumType.l2.value, "unit_of_measure": {"unit": "A"}, }, { "value": 0, "measurand": Measurand.current_export.value, - "phase": PhaseType.l3.value, + "phase": PhaseEnumType.l3.value, "unit_of_measure": {"unit": "A"}, }, { "value": 1.1, "measurand": Measurand.current_import.value, - "phase": PhaseType.l1.value, + "phase": PhaseEnumType.l1.value, "unit_of_measure": {"unit": "A"}, }, { "value": 2.2, "measurand": Measurand.current_import.value, - "phase": PhaseType.l2.value, + "phase": PhaseEnumType.l2.value, "unit_of_measure": {"unit": "A"}, }, { "value": 3.3, "measurand": Measurand.current_import.value, - "phase": PhaseType.l3.value, + "phase": PhaseEnumType.l3.value, "unit_of_measure": {"unit": "A"}, }, { "value": 12.1, "measurand": Measurand.current_offered.value, - "phase": PhaseType.l1.value, + "phase": PhaseEnumType.l1.value, "unit_of_measure": {"unit": "A"}, }, { "value": 12.2, "measurand": Measurand.current_offered.value, - "phase": PhaseType.l2.value, + "phase": PhaseEnumType.l2.value, "unit_of_measure": {"unit": "A"}, }, { "value": 12.3, "measurand": Measurand.current_offered.value, - "phase": PhaseType.l3.value, + "phase": PhaseEnumType.l3.value, "unit_of_measure": {"unit": "A"}, }, { @@ -567,25 +574,25 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo { "value": 229.9, "measurand": Measurand.voltage.value, - "phase": PhaseType.l1_n.value, + "phase": PhaseEnumType.l1_n.value, "unit_of_measure": {"unit": "V"}, }, { "value": 230, "measurand": Measurand.voltage.value, - "phase": PhaseType.l2_n.value, + "phase": PhaseEnumType.l2_n.value, "unit_of_measure": {"unit": "V"}, }, { "value": 230.4, "measurand": Measurand.voltage.value, - "phase": PhaseType.l3_n.value, + "phase": PhaseEnumType.l3_n.value, "unit_of_measure": {"unit": "V"}, }, { # Not among enabled measurands, will be ignored "value": 1111, - "measurand": MeasurandType.energy_active_net.value, + "measurand": MeasurandEnumType.energy_active_net.value, "unit_of_measure": {"unit": "Wh"}, }, ], @@ -617,13 +624,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, (tx_start_time + timedelta(seconds=60)).isoformat(), - TriggerReasonType.meter_value_periodic.value, + TriggerReasonEnumType.meter_value_periodic.value, 3, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.charging.value, + "charging_state": ChargingStateEnumType.charging.value, }, meter_value=[ { @@ -647,14 +654,14 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.ended.value, + TransactionEventEnumType.ended.value, (tx_start_time + timedelta(seconds=120)).isoformat(), - TriggerReasonType.remote_stop.value, + TriggerReasonEnumType.remote_stop.value, 4, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.ev_connected.value, - "stopped_reason": ReasonType.remote.value, + "charging_state": ChargingStateEnumType.ev_connected.value, + "stopped_reason": ReasonEnumType.remote.value, }, meter_value=[ { @@ -662,7 +669,7 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo "sampled_value": [ { "value": 333, - "context": ReadingContextType.transaction_end, + "context": ReadingContextEnumType.transaction_end, "measurand": Measurand.energy_active_import_register.value, "unit_of_measure": {"unit": "Wh"}, }, @@ -686,13 +693,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo # Now with energy reading in Started transaction event await cp.call( call.TransactionEvent( - TransactionEventType.started.value, + TransactionEventEnumType.started.value, tx_start_time.isoformat(), - TriggerReasonType.cable_plugged_in.value, + TriggerReasonEnumType.cable_plugged_in.value, 0, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.ev_connected.value, + "charging_state": ChargingStateEnumType.ev_connected.value, }, meter_value=[ { @@ -714,13 +721,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo ) await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, tx_start_time.isoformat(), - TriggerReasonType.charging_state_changed.value, + TriggerReasonEnumType.charging_state_changed.value, 1, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.charging.value, + "charging_state": ChargingStateEnumType.charging.value, }, meter_value=[ { @@ -740,13 +747,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, tx_start_time.isoformat(), - TriggerReasonType.charging_state_changed.value, + TriggerReasonEnumType.charging_state_changed.value, 1, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.suspended_ev.value, + "charging_state": ChargingStateEnumType.suspended_ev.value, }, ) ) @@ -757,13 +764,13 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.updated.value, + TransactionEventEnumType.updated.value, tx_start_time.isoformat(), - TriggerReasonType.charging_state_changed.value, + TriggerReasonEnumType.charging_state_changed.value, 1, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.suspended_evse.value, + "charging_state": ChargingStateEnumType.suspended_evse.value, }, ) ) @@ -774,14 +781,14 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo await cp.call( call.TransactionEvent( - TransactionEventType.ended.value, + TransactionEventEnumType.ended.value, tx_start_time.isoformat(), - TriggerReasonType.ev_communication_lost.value, + TriggerReasonEnumType.ev_communication_lost.value, 2, transaction_info={ "transaction_id": cp.remote_start_tx_id, - "charging_state": ChargingStateType.idle.value, - "stopped_reason": ReasonType.ev_disconnected.value, + "charging_state": ChargingStateEnumType.idle.value, + "stopped_reason": ReasonEnumType.ev_disconnected.value, }, ) ) @@ -917,13 +924,13 @@ async def _test_charge_profiles( assert cp.charge_profiles_set[-1].charging_profile == { "id": 1, "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, - "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [ { "id": 1, "charging_schedule_period": [{"start_period": 0, "limit": 3000}], - "charging_rate_unit": ChargingRateUnitType.watts.value, + "charging_rate_unit": ChargingRateUnitEnumType.watts.value, }, ], } @@ -935,13 +942,13 @@ async def _test_charge_profiles( assert cp.charge_profiles_set[-1].charging_profile == { "id": 1, "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, - "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [ { "id": 1, "charging_schedule_period": [{"start_period": 0, "limit": 16}], - "charging_rate_unit": ChargingRateUnitType.amps.value, + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, }, ], } @@ -968,13 +975,13 @@ async def _test_charge_profiles( assert cp.charge_profiles_set[-1].charging_profile == { "id": 2, "stack_level": 1, - "charging_profile_purpose": ChargingProfilePurposeType.tx_profile.value, - "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_profile_purpose": ChargingProfilePurposeEnumType.tx_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [ { "id": 1, "charging_schedule_period": [{"start_period": 0, "limit": 6}], - "charging_rate_unit": ChargingRateUnitType.amps.value, + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, }, ], } @@ -985,13 +992,13 @@ async def _test_charge_profiles( assert cp.charge_profiles_set[-1].charging_profile == { "id": 1, "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value, - "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [ { "id": 1, "charging_schedule_period": [{"start_period": 0, "limit": 12}], - "charging_rate_unit": ChargingRateUnitType.amps.value, + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, }, ], } @@ -1005,7 +1012,7 @@ async def _test_charge_profiles( assert len(cp.charge_profiles_cleared) == 1 assert cp.charge_profiles_cleared[-1].charging_profile_id is None assert cp.charge_profiles_cleared[-1].charging_profile_criteria == { - "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value } @@ -1018,15 +1025,15 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): "vendor_name": "VENDOR", "firmware_version": "VERSION", }, - BootReasonType.power_up.value, + BootReasonEnumType.power_up.value, ) ) - assert boot_res.status == RegistrationStatusType.accepted.value + assert boot_res.status == RegistrationStatusEnumType.accepted.value assert boot_res.status_info is None datetime.fromisoformat(boot_res.current_time) await cp.call( call.StatusNotification( - datetime.now(tz=UTC).isoformat(), ConnectorStatusType.available, 1, 1 + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.available, 1, 1 ) ) @@ -1049,7 +1056,7 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): ) assert ( cs.get_metric(cpid, cstat.status_connector.value) - == ConnectorStatusType.available.value + == ConnectorStatusEnumType.available.value ) assert cp.tx_updated_interval == DEFAULT_METER_INTERVAL assert cp.tx_updated_measurands == supported_measurands @@ -1064,7 +1071,7 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): await press_button(hass, cs, "reset") assert len(cp.resets) == 1 - assert cp.resets[0].type == ResetType.immediate.value + assert cp.resets[0].type == ResetEnumType.immediate.value assert cp.resets[0].evse_id is None error: HomeAssistantError = None @@ -1080,25 +1087,27 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): assert not cp.operative await cp.call( call.StatusNotification( - datetime.now(tz=UTC).isoformat(), ConnectorStatusType.unavailable, 1, 1 + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.unavailable, 1, 1 ) ) assert ( cs.get_metric(cpid, cstat.status_connector.value) - == ConnectorStatusType.unavailable.value + == ConnectorStatusEnumType.unavailable.value ) await cp.call( call.StatusNotification( - datetime.now(tz=UTC).isoformat(), ConnectorStatusType.faulted, 1, 1 + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.faulted, 1, 1 ) ) assert ( cs.get_metric(cpid, cstat.status_connector.value) - == ConnectorStatusType.faulted.value + == ConnectorStatusEnumType.faulted.value ) - await cp.call(call.FirmwareStatusNotification(FirmwareStatusType.installed.value)) + await cp.call( + call.FirmwareStatusNotification(FirmwareStatusEnumType.installed.value) + ) class ChargePointAllFeatures(ChargePoint): @@ -1106,19 +1115,19 @@ class ChargePointAllFeatures(ChargePoint): triggered_status_notification: list[EVSEType] = [] - @on(Action.UpdateFirmware) + @on(Action.update_firmware) def _on_update_firmware(self, request_id: int, firmware: dict, **kwargs): - return call_result.UpdateFirmware(UpdateFirmwareStatusType.rejected.value) + return call_result.UpdateFirmware(UpdateFirmwareStatusEnumType.rejected.value) - @on(Action.TriggerMessage) + @on(Action.trigger_message) def _on_trigger_message(self, requested_message: str, **kwargs): - if (requested_message == MessageTriggerType.status_notification) and ( + if (requested_message == MessageTriggerEnumType.status_notification) and ( "evse" in kwargs ): self.triggered_status_notification.append( EVSEType(kwargs["evse"]["id"], kwargs["evse"]["connector_id"]) ) - return call_result.TriggerMessage(TriggerMessageStatusType.rejected.value) + return call_result.TriggerMessage(TriggerMessageStatusEnumType.rejected.value) async def _extra_features_test( @@ -1134,7 +1143,7 @@ async def _extra_features_test( "vendor_name": "VENDOR", "firmware_version": "VERSION", }, - BootReasonType.power_up.value, + BootReasonEnumType.power_up.value, ) ) await wait_ready(hass) @@ -1158,7 +1167,7 @@ async def _extra_features_test( class ChargePointReportUnsupported(ChargePointAllFeatures): """A charge point which does not support GetBaseReport.""" - @on(Action.GetBaseReport) + @on(Action.get_base_report) def _on_base_report(self, request_id: int, report_base: str, **kwargs): raise ocpp.exceptions.NotImplementedError("This is not implemented") @@ -1166,7 +1175,7 @@ def _on_base_report(self, request_id: int, report_base: str, **kwargs): class ChargePointReportFailing(ChargePointAllFeatures): """A charge point which keeps failing GetBaseReport.""" - @on(Action.GetBaseReport) + @on(Action.get_base_report) def _on_base_report(self, request_id: int, report_base: str, **kwargs): raise ocpp.exceptions.InternalError("Test failure") @@ -1184,7 +1193,7 @@ async def _unsupported_base_report_test( "vendor_name": "VENDOR", "firmware_version": "VERSION", }, - BootReasonType.power_up.value, + BootReasonEnumType.power_up.value, ) ) await wait_ready(hass) @@ -1210,6 +1219,8 @@ async def test_cms_responses_v201(hass, socket_enabled): domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" ) cs: CentralSystem = await create_configuration(hass, config_entry) + # threading in async validation causes tests to fail + ocpp.messages.ASYNC_VALIDATION = False await run_charge_point_test( config_entry, "CP_2", From 686bed6ced94f12b403d705578a12f41fbee6f0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jan 2025 10:40:51 +0100 Subject: [PATCH 236/370] build(deps): bump pytest-homeassistant-custom-component (#1467) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.195 to 0.13.201. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.195...0.13.201) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b6f8e8ac..8dad1482 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.195 +pytest-homeassistant-custom-component==0.13.201 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 34f16e29cbc438f644302c511941c28646f0bb0f Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 5 Jan 2025 10:47:36 +0100 Subject: [PATCH 237/370] Update manifest.json (#1468) bump version to v0.7.0 --- custom_components/ocpp/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index bbe363bd..52acfc81 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -16,5 +16,5 @@ "ocpp>=2.0.0", "websockets>=14.1" ], - "version": "0.6.3" + "version": "0.7.0" } From a601693f5b8159a71ce22b57913c73311d8f63ed Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 5 Jan 2025 11:52:04 +0100 Subject: [PATCH 238/370] Create update-version.yml (#1469) add github action to update version field in manifest.json on publish --- .github/workflows/update-version.yml | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/update-version.yml diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml new file mode 100644 index 00000000..095ddcdf --- /dev/null +++ b/.github/workflows/update-version.yml @@ -0,0 +1,29 @@ +name: Update Version in manifest.json + +on: + release: + types: [published] + +jobs: + update-version: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Update version in manifest.json + run: | + VERSION=${GITHUB_REF#refs/tags/} + MANIFEST=custom_components/ocpp/manifest.json + jq --arg version "$VERSION" '.version = $version' $MANIFEST > tmp.json && mv tmp.json $MANIFEST + + - name: Commit changes + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add $MANIFEST + git commit -m "Update version in manifest.json to $VERSION" + git push From cc32c3c5b4d64d97a538eb2e6538406d4835f9f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:16:45 +0100 Subject: [PATCH 239/370] build(deps): bump pytest-homeassistant-custom-component (#1475) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8dad1482..cdc1f8e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.201 +pytest-homeassistant-custom-component==0.13.202 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 249f5e2d35cd4a3d2435090dbc0a24d6a17521bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:17:34 +0100 Subject: [PATCH 240/370] build(deps): bump actions/checkout from 2 to 4 (#1472) --- .github/workflows/update-version.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml index 095ddcdf..8e16ca43 100644 --- a/.github/workflows/update-version.yml +++ b/.github/workflows/update-version.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Update version in manifest.json run: | From 76287e532642b671c05089a8b2043bb566570fa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:18:09 +0100 Subject: [PATCH 241/370] build(deps): bump ruff from 0.8.5 to 0.8.6 (#1473) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cdc1f8e0..c6eda683 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.5 +ruff==0.8.6 ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 From f683f33affa6ec6f3cc78eb1efd2195a3e54ef0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Jan 2025 22:30:07 +0100 Subject: [PATCH 242/370] build(deps): bump pytest-homeassistant-custom-component (#1481) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.202 to 0.13.203. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.202...0.13.203) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c6eda683..7be733f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.202 +pytest-homeassistant-custom-component==0.13.203 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 883aea86da106383d0fb57b3923676a72ca7b7bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Jan 2025 22:30:37 +0100 Subject: [PATCH 243/370] build(deps): bump ruff from 0.8.6 to 0.9.0 (#1479) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.6 to 0.9.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.8.6...0.9.0) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7be733f2..9a73de73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.8.6 +ruff==0.9.0 ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 From 6e5a4192d626550ed6f1ac5f4c76fadd10d090d7 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:31:50 +1300 Subject: [PATCH 244/370] remove via device from platforms (#1470) --- custom_components/ocpp/button.py | 1 - custom_components/ocpp/number.py | 1 - custom_components/ocpp/sensor.py | 1 - custom_components/ocpp/switch.py | 1 - 4 files changed, 4 deletions(-) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index cc60bd71..f3b17efe 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -79,7 +79,6 @@ def __init__( self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), ) @property diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index d2aa6ef5..0f17ec01 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -93,7 +93,6 @@ def __init__( self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), ) self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index f4299a58..14a04c42 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -104,7 +104,6 @@ def __init__( self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), ) self._attr_icon = ICON self._attr_native_unit_of_measurement = None diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index fb486896..5a33450b 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -98,7 +98,6 @@ def __init__( self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), ) @property From 88cafb75c5abd92b248e6e2e0e7949ce620cfd97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jan 2025 09:29:22 +0100 Subject: [PATCH 245/370] build(deps): bump ruff from 0.9.0 to 0.9.2 (#1488) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9a73de73..bdb75b5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.9.0 +ruff==0.9.2 ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 From 77c35a7d633bda8e8b8d90c45f67091730871249 Mon Sep 17 00:00:00 2001 From: Tomakava <20898335+Tomakava@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:30:09 +0200 Subject: [PATCH 246/370] AvailabilityStatus scheduled is expected when charger transaction is in progress (#1525) --- custom_components/ocpp/ocppv16.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index b49ecf32..730c6345 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -392,7 +392,10 @@ async def set_availability(self, state: bool = True): req = call.ChangeAvailability(connector_id=0, type=typ) resp = await self.call(req) - if resp.status == AvailabilityStatus.accepted: + if resp.status in [ + AvailabilityStatus.accepted, + AvailabilityStatus.scheduled, + ]: return True else: _LOGGER.warning("Failed with response: %s", resp.status) From 7394cb17eb3cbb960cab2d2d9c625f55bfe052de Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:47:00 +1300 Subject: [PATCH 247/370] Multiple chargers with central services (#1498) * Allow multiple chargers per cs * improve multidevice logic * add config entry migration * fix translations * fix service tests and unloading * Update manifest.json * fix config migration when keys missing * minor tidy ups * fix id refs and add cp_id as identifier * add minor version * add minor_version to tests * remove change of dr name * fix up tests * prevent entry duplicates * Pending changes exported from your codespace * patch discovery flow data in tests * mock for discovery * separate tests * allow multiple chargers to connect * update v201 test cpid * fix platform cpid references * fix v201 test config entry setup * split v16 tests * fix params for config setup fixture * add hass to setup config entry * use different port values * fix port * add port to setup * fix dbl parenthesis * shift cpid to after ws connection * shift cpid for restore values * rename nosub test * Move services to each charger * update test services to use cpid * fix unload * fix linting * switch to central services * update services.yaml * Update services.yaml * Update services.yaml * Update services.yaml * rework config flow * fix linting * Extra tidy ups * add split * fix linting * fix test measurand input * fix linting * add test for dup discovery flow * add invalid data test * tidy up failed config flow test * use cp_id for migration * update tests for cp_id and 2 charger connections * fix on connect issues (#80) * fix on connect issues * fix entity state test * Improve error handling for charger setup Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add decorator for handle calls (#81) * add decorator for handle calls * fix linting * fix linting * fix linting * revert device registry setup logic (#82) * revert device registry setup logic * fix services test * fix ref * get new cs in test * fix linting * remove id check * amend config flow data updates * avoid breaking change by setting default cp in service call * add test for no devid * fix linting, use empty str for devid * use dummy str for devid * switch devid required to false * move devid call in try block --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- custom_components/ocpp/__init__.py | 145 ++++- custom_components/ocpp/api.py | 344 ++++++++--- custom_components/ocpp/button.py | 24 +- custom_components/ocpp/chargepoint.py | 205 +------ custom_components/ocpp/config_flow.py | 116 +++- custom_components/ocpp/const.py | 43 +- custom_components/ocpp/enums.py | 2 - custom_components/ocpp/manifest.json | 2 +- custom_components/ocpp/number.py | 44 +- custom_components/ocpp/ocppv16.py | 109 ++-- custom_components/ocpp/ocppv201.py | 53 +- custom_components/ocpp/sensor.py | 71 +-- custom_components/ocpp/services.yaml | 93 +-- custom_components/ocpp/switch.py | 32 +- custom_components/ocpp/translations/de.json | 20 +- custom_components/ocpp/translations/en.json | 21 +- custom_components/ocpp/translations/es.json | 14 +- .../ocpp/translations/i-default.json | 24 +- custom_components/ocpp/translations/nl.json | 14 +- tests/charge_point_test.py | 21 +- tests/conftest.py | 2 + tests/const.py | 130 ++++- tests/test_charge_point_v16.py | 536 ++++++++++++------ tests/test_charge_point_v201.py | 136 +++-- tests/test_config_flow.py | 184 ++++-- tests/test_init.py | 44 +- tests/test_sensor.py | 62 +- 27 files changed, 1633 insertions(+), 858 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 77c3e707..bad84d53 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -15,14 +15,47 @@ from .const import ( CONF_AUTH_LIST, CONF_AUTH_STATUS, - CONF_CPID, - CONF_CSID, + CONF_CPIDS, CONF_DEFAULT_AUTH_STATUS, CONF_ID_TAG, CONF_NAME, + CONF_CPID, + CONF_IDLE_INTERVAL, + CONF_MAX_CURRENT, + CONF_METER_INTERVAL, + CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_SKIP_SCHEMA_VALIDATION, + CONF_FORCE_SMART_CHARGING, + CONF_HOST, + CONF_PORT, + CONF_CSID, + CONF_SSL, + CONF_SSL_CERTFILE_PATH, + CONF_SSL_KEYFILE_PATH, + CONF_WEBSOCKET_CLOSE_TIMEOUT, + CONF_WEBSOCKET_PING_TRIES, + CONF_WEBSOCKET_PING_INTERVAL, + CONF_WEBSOCKET_PING_TIMEOUT, CONFIG, DEFAULT_CPID, + DEFAULT_IDLE_INTERVAL, + DEFAULT_MAX_CURRENT, + DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_SKIP_SCHEMA_VALIDATION, + DEFAULT_FORCE_SMART_CHARGING, + DEFAULT_HOST, + DEFAULT_PORT, DEFAULT_CSID, + DEFAULT_SSL, + DEFAULT_SSL_CERTFILE_PATH, + DEFAULT_SSL_KEYFILE_PATH, + DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, + DEFAULT_WEBSOCKET_PING_TRIES, + DEFAULT_WEBSOCKET_PING_INTERVAL, + DEFAULT_WEBSOCKET_PING_TIMEOUT, DOMAIN, PLATFORMS, ) @@ -72,42 +105,120 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): dr = device_registry.async_get(hass) - """ Create Central System Device """ + # Create Central System device dr.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.data.get(CONF_CSID, DEFAULT_CSID))}, - name=entry.data.get(CONF_CSID, DEFAULT_CSID), + identifiers={(DOMAIN, central_sys.id)}, + name=central_sys.id, model="OCPP Central System", ) - """ Create Charge Point Device """ - dr.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.data.get(CONF_CPID, DEFAULT_CPID))}, - name=entry.data.get(CONF_CPID, DEFAULT_CPID), - model="Unknown", - via_device=(DOMAIN, entry.data.get(CONF_CSID, DEFAULT_CSID)), - ) + # Create charger devices + for cp_data in entry.data[CONF_CPIDS]: + for cp_id, cp_settings in cp_data.items(): + cpid = cp_settings[CONF_CPID] + dr.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, cp_id), (DOMAIN, cpid)}, + name=cpid, + suggested_area="Garage", + via_device=(DOMAIN, central_sys.id), + ) hass.data[DOMAIN][entry.entry_id] = central_sys - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + if entry.data[CONF_CPIDS]: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + old_data = {**config_entry.data} + csid_data = {} + cpid_data = {} + cpid_keys = { + CONF_CPID: DEFAULT_CPID, + CONF_IDLE_INTERVAL: DEFAULT_IDLE_INTERVAL, + CONF_MAX_CURRENT: DEFAULT_MAX_CURRENT, + CONF_METER_INTERVAL: DEFAULT_METER_INTERVAL, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + CONF_SKIP_SCHEMA_VALIDATION: DEFAULT_SKIP_SCHEMA_VALIDATION, + CONF_FORCE_SMART_CHARGING: DEFAULT_FORCE_SMART_CHARGING, + } + csid_keys = { + CONF_HOST: DEFAULT_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_CSID: DEFAULT_CSID, + CONF_SSL: DEFAULT_SSL, + CONF_SSL_CERTFILE_PATH: DEFAULT_SSL_CERTFILE_PATH, + CONF_SSL_KEYFILE_PATH: DEFAULT_SSL_KEYFILE_PATH, + CONF_WEBSOCKET_CLOSE_TIMEOUT: DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, + CONF_WEBSOCKET_PING_TRIES: DEFAULT_WEBSOCKET_PING_TRIES, + CONF_WEBSOCKET_PING_INTERVAL: DEFAULT_WEBSOCKET_PING_INTERVAL, + CONF_WEBSOCKET_PING_TIMEOUT: DEFAULT_WEBSOCKET_PING_TIMEOUT, + } + for key, value in cpid_keys.items(): + cpid_data.update({key: old_data.get(key, value)}) + + for key, value in csid_keys.items(): + csid_data.update({key: old_data.get(key, value)}) + + new_data = csid_data + cp_id = hass.states.get(f"sensor.{cpid_data[CONF_CPID]}_id") + if cp_id is None: + return False + new_data.update({CONF_CPIDS: [{cp_id: cpid_data}]}) + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=0, version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unloaded = False if DOMAIN in hass.data: if entry.entry_id in hass.data[DOMAIN]: + # Close server central_sys = hass.data[DOMAIN][entry.entry_id] central_sys._server.close() await central_sys._server.wait_closed() - unloaded = await hass.config_entries.async_unload_platforms( - entry, PLATFORMS - ) + # Unload services + # print(hass.services.async_services_for_domain(DOMAIN)) + for service in hass.services.async_services_for_domain(DOMAIN): + hass.services.async_remove(DOMAIN, service) + # Unload platforms if a charger connected + if central_sys.connections == 0: + unloaded = True + else: + unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + # Remove entry if unloaded: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index eeefba2f..cdc2dc58 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -2,52 +2,34 @@ from __future__ import annotations +import json import logging import ssl from functools import partial -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OK -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry, SOURCE_INTEGRATION_DISCOVERY +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from websockets import Subprotocol, NegotiationError import websockets.server from websockets.asyncio.server import ServerConnection -from .chargepoint import CentralSystemSettings from .ocppv16 import ChargePoint as ChargePointv16 from .ocppv201 import ChargePoint as ChargePointv201 from .const import ( - CONF_CPID, - CONF_CSID, - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_SSL_CERTFILE_PATH, - CONF_SSL_KEYFILE_PATH, - CONF_SUBPROTOCOL, - CONF_WEBSOCKET_CLOSE_TIMEOUT, - CONF_WEBSOCKET_PING_INTERVAL, - CONF_WEBSOCKET_PING_TIMEOUT, - CONF_WEBSOCKET_PING_TRIES, - DEFAULT_CPID, - DEFAULT_CSID, - DEFAULT_HOST, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_SSL_CERTFILE_PATH, - DEFAULT_SSL_KEYFILE_PATH, - DEFAULT_SUBPROTOCOL, - DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, - DEFAULT_WEBSOCKET_PING_INTERVAL, - DEFAULT_WEBSOCKET_PING_TIMEOUT, - DEFAULT_WEBSOCKET_PING_TRIES, + CentralSystemSettings, DOMAIN, OCPP_2_0, + ChargerSystemSettings, ) from .enums import ( HAChargerServices as csvcs, ) +from .chargepoint import SetVariableResult _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) @@ -55,6 +37,50 @@ # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) +UFW_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("firmware_url"): cv.string, + vol.Optional("delay_hours"): cv.positive_int, + } +) +CONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("ocpp_key"): cv.string, + vol.Required("value"): cv.string, + } +) +GCONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("ocpp_key"): cv.string, + } +) +GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("upload_url"): cv.string, + } +) +TRANS_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("vendor_id"): cv.string, + vol.Optional("message_id"): cv.string, + vol.Optional("data"): cv.string, + } +) +CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Optional("limit_amps"): cv.positive_float, + vol.Optional("limit_watts"): cv.positive_int, + vol.Optional("conn_id"): cv.positive_int, + vol.Optional("custom_profile"): vol.Any(cv.string, dict), + } +) + class CentralSystem: """Server for handling OCPP connections.""" @@ -63,49 +89,69 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): """Instantiate instance of a CentralSystem.""" self.hass = hass self.entry = entry - self.host = entry.data.get(CONF_HOST, DEFAULT_HOST) - self.port = entry.data.get(CONF_PORT, DEFAULT_PORT) - - self.settings = CentralSystemSettings() - self.settings.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) - self.settings.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) + self.settings = CentralSystemSettings(**entry.data) + self.subprotocols = self.settings.subprotocols + self._server = None + self.id = self.settings.csid + self.charge_points = {} # uses cp_id as reference to charger instance + self.cpids = {} # dict of {cpid:cp_id} + self.connections = 0 - self.settings.websocket_close_timeout = entry.data.get( - CONF_WEBSOCKET_CLOSE_TIMEOUT, DEFAULT_WEBSOCKET_CLOSE_TIMEOUT + # Register custom services with home assistant + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure.value, + self.handle_configure, + CONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) - self.settings.websocket_ping_tries = entry.data.get( - CONF_WEBSOCKET_PING_TRIES, DEFAULT_WEBSOCKET_PING_TRIES + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration.value, + self.handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.ONLY, ) - self.settings.websocket_ping_interval = entry.data.get( - CONF_WEBSOCKET_PING_INTERVAL, DEFAULT_WEBSOCKET_PING_INTERVAL + self.hass.services.async_register( + DOMAIN, + csvcs.service_data_transfer.value, + self.handle_data_transfer, + TRANS_SERVICE_DATA_SCHEMA, ) - self.settings.websocket_ping_timeout = entry.data.get( - CONF_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TIMEOUT + self.hass.services.async_register( + DOMAIN, + csvcs.service_clear_profile.value, + self.handle_clear_profile, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_set_charge_rate.value, + self.handle_set_charge_rate, + CHRGR_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_update_firmware.value, + self.handle_update_firmware, + UFW_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_diagnostics.value, + self.handle_get_diagnostics, + GDIAG_SERVICE_DATA_SCHEMA, ) - self.settings.config = entry.data - - self.subprotocols: list[Subprotocol] = entry.data.get( - CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL - ).split(",") - self._server = None - self.config = entry.data - self.id = entry.entry_id - self.charge_points = {} @staticmethod async def create(hass: HomeAssistant, entry: ConfigEntry): """Create instance and start listening for OCPP connections on given port.""" self = CentralSystem(hass, entry) - if self.entry.data.get(CONF_SSL, DEFAULT_SSL): + if self.settings.ssl: self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # see https://community.home-assistant.io/t/certificate-authority-and-self-signed-certificate-for-ssl-tls/196970 - localhost_certfile = self.entry.data.get( - CONF_SSL_CERTFILE_PATH, DEFAULT_SSL_CERTFILE_PATH - ) - localhost_keyfile = self.entry.data.get( - CONF_SSL_KEYFILE_PATH, DEFAULT_SSL_KEYFILE_PATH - ) + localhost_certfile = self.settings.certfile + localhost_keyfile = self.settings.keyfile await self.hass.async_add_executor_job( partial( self.ssl_context.load_cert_chain, @@ -118,8 +164,8 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): server = await websockets.serve( self.on_connect, - self.host, - self.port, + self.settings.host, + self.settings.port, select_subprotocol=self.select_subprotocol, subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() @@ -163,76 +209,135 @@ async def on_connect(self, websocket: ServerConnection): _LOGGER.info(f"Charger websocket path={websocket.request.path}") cp_id = websocket.request.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] - if self.settings.cpid not in self.charge_points: - _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") + if cp_id not in self.charge_points: + try: + config_flow = False + for cfg in self.settings.cpids: + if cfg.get(cp_id): + config_flow = True + cp_settings = ChargerSystemSettings(**list(cfg.values())[0]) + _LOGGER.info( + f"Charger match found for {cp_settings.cpid}:{cp_id}" + ) + _LOGGER.debug(f"Central settings: {self.settings}") + + if not config_flow: + # discovery_info for flow + info = {"cp_id": cp_id, "entry": self.entry} + await self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + # use return to wait for config entry to reload after discovery + return + + self.cpids.update({cp_settings.cpid: cp_id}) + except Exception as e: + _LOGGER.error(f"Failed to setup charger {cp_id}: {str(e)}") + return + if websocket.subprotocol and websocket.subprotocol.startswith(OCPP_2_0): charge_point = ChargePointv201( - cp_id, websocket, self.hass, self.entry, self.settings + cp_id, websocket, self.hass, self.entry, self.settings, cp_settings ) else: charge_point = ChargePointv16( - cp_id, websocket, self.hass, self.entry, self.settings + cp_id, websocket, self.hass, self.entry, self.settings, cp_settings ) - self.charge_points[self.settings.cpid] = charge_point + self.charge_points[cp_id] = charge_point + self.connections += 1 + _LOGGER.info( + f"Charger {cp_settings.cpid}:{cp_id} connected to {self.settings.host}:{self.settings.port}." + ) + _LOGGER.info( + f"{self.connections} charger(s): {self.cpids} now connected to central system:{self.settings.csid}." + ) await charge_point.start() else: - _LOGGER.info(f"Charger {cp_id} reconnected to {self.host}:{self.port}.") - charge_point = self.charge_points[self.settings.cpid] + _LOGGER.info( + f"Charger {cp_id} reconnected to {self.settings.host}:{self.settings.port}." + ) + charge_point = self.charge_points[cp_id] await charge_point.reconnect(websocket) - _LOGGER.info(f"Charger {cp_id} disconnected from {self.host}:{self.port}.") - def get_metric(self, cp_id: str, measurand: str): + def get_metric(self, id: str, measurand: str): """Return last known value for given measurand.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id]._metrics[measurand].value return None - def del_metric(self, cp_id: str, measurand: str): + def del_metric(self, id: str, measurand: str): """Set given measurand to None.""" - if cp_id in self.charge_points: + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + + if self.cpids.get(cp_id) in self.charge_points: self.charge_points[cp_id]._metrics[measurand].value = None return None - def get_unit(self, cp_id: str, measurand: str): + def get_unit(self, id: str, measurand: str): """Return unit of given measurand.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id]._metrics[measurand].unit return None - def get_ha_unit(self, cp_id: str, measurand: str): + def get_ha_unit(self, id: str, measurand: str): """Return home assistant unit of given measurand.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id]._metrics[measurand].ha_unit return None - def get_extra_attr(self, cp_id: str, measurand: str): + def get_extra_attr(self, id: str, measurand: str): """Return last known extra attributes for given measurand.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id]._metrics[measurand].extra_attr return None - def get_available(self, cp_id: str): + def get_available(self, id: str): """Return whether the charger is available.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id].status == STATE_OK return False - def get_supported_features(self, cp_id: str): + def get_supported_features(self, id: str): """Return what profiles the charger supports.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return self.charge_points[cp_id].supported_features return 0 - async def set_max_charge_rate_amps(self, cp_id: str, value: float): + async def set_max_charge_rate_amps(self, id: str, value: float): """Set the maximum charge rate in amps.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + if cp_id in self.charge_points: return await self.charge_points[cp_id].set_charge_rate(limit_amps=value) return False - async def set_charger_state( - self, cp_id: str, service_name: str, state: bool = True - ): + async def set_charger_state(self, id: str, service_name: str, state: bool = True): """Carry out requested service/state change on connected charger.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) + resp = False if cp_id in self.charge_points: if service_name == csvcs.service_availability.name: @@ -252,3 +357,82 @@ def device_info(self): return { "identifiers": {(DOMAIN, self.id)}, } + + def check_charger_available(func): + """Check charger is available before executing service with Decorator.""" + + async def wrapper(self, call, *args, **kwargs): + try: + cp_id = self.cpids.get(call.data["devid"], call.data["devid"]) + cp = self.charge_points[cp_id] + except KeyError: + cp = list(self.charge_points.values())[0] + if cp.status == STATE_UNAVAILABLE: + _LOGGER.warning(f"{cp_id}: charger is currently unavailable") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unavailable", + translation_placeholders={"message": cp_id}, + ) + return await func(self, call, cp, *args, **kwargs) + + return wrapper + + # Define custom service handles for charge point + @check_charger_available + async def handle_clear_profile(self, call, cp): + """Handle the clear profile service call.""" + await cp.clear_profile() + + @check_charger_available + async def handle_update_firmware(self, call, cp): + """Handle the firmware update service call.""" + url = call.data.get("firmware_url") + delay = int(call.data.get("delay_hours", 0)) + await cp.update_firmware(url, delay) + + @check_charger_available + async def handle_get_diagnostics(self, call, cp): + """Handle the get get diagnostics service call.""" + url = call.data.get("upload_url") + await cp.get_diagnostics(url) + + @check_charger_available + async def handle_data_transfer(self, call, cp): + """Handle the data transfer service call.""" + vendor = call.data.get("vendor_id") + message = call.data.get("message_id", "") + data = call.data.get("data", "") + await cp.data_transfer(vendor, message, data) + + @check_charger_available + async def handle_set_charge_rate(self, call, cp): + """Handle the data transfer service call.""" + amps = call.data.get("limit_amps", None) + watts = call.data.get("limit_watts", None) + id = call.data.get("conn_id", 0) + custom_profile = call.data.get("custom_profile", None) + if custom_profile is not None: + if type(custom_profile) is str: + custom_profile = custom_profile.replace("'", '"') + custom_profile = json.loads(custom_profile) + await cp.set_charge_rate(profile=custom_profile, conn_id=id) + elif watts is not None: + await cp.set_charge_rate(limit_watts=watts, conn_id=id) + elif amps is not None: + await cp.set_charge_rate(limit_amps=amps, conn_id=id) + + @check_charger_available + async def handle_configure(self, call, cp) -> ServiceResponse: + """Handle the configure service call.""" + key = call.data.get("ocpp_key") + value = call.data.get("value") + result: SetVariableResult = await cp.configure(key, value) + return {"reboot_required": result == SetVariableResult.reboot_required} + + @check_charger_available + async def handle_get_configuration(self, call, cp) -> ServiceResponse: + """Handle the get configuration service call.""" + key = call.data.get("ocpp_key") + value = await cp.get_configuration(key) + return {"value": value} diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index f3b17efe..d5763c86 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .api import CentralSystem -from .const import CONF_CPID, DEFAULT_CPID, DOMAIN +from .const import CONF_CPID, CONF_CPIDS, DOMAIN from .enums import HAChargerServices @@ -47,12 +47,14 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the Button platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - entities = [] + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] - for ent in BUTTONS: - entities.append(ChargePointButton(central_system, cp_id, ent)) + for ent in BUTTONS: + cpx = ChargePointButton(central_system, cpid, ent) + entities.append(cpx) async_add_devices(entities, False) @@ -66,28 +68,28 @@ class ChargePointButton(ButtonEntity): def __init__( self, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppButtonDescription, ): """Instantiate instance of a ChargePointButton.""" - self.cp_id = cp_id + self.cpid = cpid self.central_system = central_system self.entity_description = description self._attr_unique_id = ".".join( - [BUTTON_DOMAIN, DOMAIN, self.cp_id, self.entity_description.key] + [BUTTON_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] ) self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, + identifiers={(DOMAIN, self.cpid)}, ) @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] async def async_press(self) -> None: """Triggers the charger press action service.""" await self.central_system.set_charger_state( - self.cp_id, self.entity_description.press_action + self.cpid, self.entity_description.press_action ) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 7fd22e3f..b501484c 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -4,14 +4,11 @@ from collections import defaultdict from dataclasses import dataclass from enum import Enum -import json import logging from math import sqrt import secrets import string import time -from types import MappingProxyType -from typing import Any from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -19,8 +16,6 @@ from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import UnitOfTime from homeassistant.helpers import device_registry, entity_component, entity_registry -import homeassistant.helpers.config_validation as cv -import voluptuous as vol from websockets.asyncio.server import ServerConnection from websockets.exceptions import WebSocketException from websockets.protocol import State @@ -36,7 +31,6 @@ from .enums import ( HAChargerDetails as cdet, - HAChargerServices as csvcs, HAChargerSession as csess, HAChargerStatuses as cstat, OcppMisc as om, @@ -44,11 +38,14 @@ ) from .const import ( + CentralSystemSettings, + ChargerSystemSettings, CONF_AUTH_LIST, CONF_AUTH_STATUS, CONF_DEFAULT_AUTH_STATUS, CONF_ID_TAG, CONF_MONITORED_VARIABLES, + CONF_CPIDS, CONFIG, DEFAULT_ENERGY_UNIT, DEFAULT_POWER_UNIT, @@ -59,61 +56,11 @@ UNITS_OCCP_TO_HA, ) -UFW_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("firmware_url"): cv.string, - vol.Optional("delay_hours"): cv.positive_int, - } -) -CONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - vol.Required("value"): cv.string, - } -) -GCONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - } -) -GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("upload_url"): cv.string, - } -) -TRANS_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("vendor_id"): cv.string, - vol.Optional("message_id"): cv.string, - vol.Optional("data"): cv.string, - } -) -CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional("limit_amps"): cv.positive_float, - vol.Optional("limit_watts"): cv.positive_int, - vol.Optional("conn_id"): cv.positive_int, - vol.Optional("custom_profile"): vol.Any(cv.string, dict), - } -) - TIME_MINUTES = UnitOfTime.MINUTES _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) -class CentralSystemSettings: - """A subset of CentralSystem properties needed by a ChargePoint.""" - - websocket_close_timeout: int - websocket_ping_interval: int - websocket_ping_timeout: int - websocket_ping_tries: int - csid: str - cpid: str - config: MappingProxyType[str, Any] - - class Metric: """Metric class.""" @@ -190,14 +137,13 @@ class ChargePoint(cp): def __init__( self, - id, + id, # is charger cp_id not HA cpid connection, version: OcppVersion, hass: HomeAssistant, entry: ConfigEntry, central: CentralSystemSettings, - interval_meter_metrics: int, - skip_schema_validation: bool, + charger: ChargerSystemSettings, ): """Instantiate a ChargePoint.""" @@ -212,12 +158,14 @@ def __init__( self._ocpp_version = "2.0.1" for action in self.route_map: - self.route_map[action]["_skip_schema_validation"] = skip_schema_validation + self.route_map[action]["_skip_schema_validation"] = ( + charger.skip_schema_validation + ) - self.interval_meter_metrics = interval_meter_metrics self.hass = hass self.entry = entry - self.central = central + self.cs_settings = central + self.settings = charger self.status = "init" # Indicates if the charger requires a reboot to apply new # configuration. @@ -255,10 +203,6 @@ async def set_standard_configuration(self): """Send configuration values to the charger.""" pass - def register_version_specific_services(self): - """Register HA services that differ depending on OCPP version.""" - pass - async def get_supported_features(self) -> prof: """Get features supported by the charger.""" return prof.NONE @@ -272,62 +216,6 @@ async def fetch_supported_features(self): async def post_connect(self): """Logic to be executed right after a charger connects.""" - # Define custom service handles for charge point - async def handle_clear_profile(call): - """Handle the clear profile service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - await self.clear_profile() - - async def handle_update_firmware(call): - """Handle the firmware update service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("firmware_url") - delay = int(call.data.get("delay_hours", 0)) - await self.update_firmware(url, delay) - - async def handle_get_diagnostics(call): - """Handle the get get diagnostics service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("upload_url") - await self.get_diagnostics(url) - - async def handle_data_transfer(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - vendor = call.data.get("vendor_id") - message = call.data.get("message_id", "") - data = call.data.get("data", "") - await self.data_transfer(vendor, message, data) - - async def handle_set_charge_rate(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - amps = call.data.get("limit_amps", None) - watts = call.data.get("limit_watts", None) - id = call.data.get("conn_id", 0) - custom_profile = call.data.get("custom_profile", None) - if custom_profile is not None: - if type(custom_profile) is str: - custom_profile = custom_profile.replace("'", '"') - custom_profile = json.loads(custom_profile) - await self.set_charge_rate(profile=custom_profile, conn_id=id) - elif watts is not None: - await self.set_charge_rate(limit_watts=watts, conn_id=id) - elif amps is not None: - await self.set_charge_rate(limit_amps=amps, conn_id=id) - - """Logic to be executed right after a charger connects.""" - try: self.status = STATE_OK await asyncio.sleep(2) @@ -338,42 +226,17 @@ async def handle_set_charge_rate(call): accepted_measurands: str = await self.get_supported_measurands() updated_entry = {**self.entry.data} - updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands + for i in range(len(updated_entry[CONF_CPIDS])): + if self.id in updated_entry[CONF_CPIDS][i]: + updated_entry[CONF_CPIDS][i][self.id][CONF_MONITORED_VARIABLES] = ( + accepted_measurands + ) + break + # if an entry differs this will unload/reload and stop/restart the central system/websocket self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) await self.set_standard_configuration() - # Register custom services with home assistant - self.register_version_specific_services() - self.hass.services.async_register( - DOMAIN, - csvcs.service_data_transfer.value, - handle_data_transfer, - TRANS_SERVICE_DATA_SCHEMA, - ) - if prof.SMART in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_set_charge_rate.value, - handle_set_charge_rate, - CHRGR_SERVICE_DATA_SCHEMA, - ) - if prof.FW in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, - csvcs.service_update_firmware.value, - handle_update_firmware, - UFW_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_diagnostics.value, - handle_get_diagnostics, - GDIAG_SERVICE_DATA_SCHEMA, - ) self.post_connect_success = True _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") @@ -475,37 +338,37 @@ async def monitor_connection(self): timeout_counter = 0 while connection.state is State.OPEN: try: - await asyncio.sleep(self.central.websocket_ping_interval) + await asyncio.sleep(self.cs_settings.websocket_ping_interval) time0 = time.perf_counter() - latency_ping = self.central.websocket_ping_timeout * 1000 + latency_ping = self.cs_settings.websocket_ping_timeout * 1000 pong_waiter = await asyncio.wait_for( - connection.ping(), timeout=self.central.websocket_ping_timeout + connection.ping(), timeout=self.cs_settings.websocket_ping_timeout ) time1 = time.perf_counter() latency_ping = round(time1 - time0, 3) * 1000 - latency_pong = self.central.websocket_ping_timeout * 1000 + latency_pong = self.cs_settings.websocket_ping_timeout * 1000 await asyncio.wait_for( - pong_waiter, timeout=self.central.websocket_ping_timeout + pong_waiter, timeout=self.cs_settings.websocket_ping_timeout ) timeout_counter = 0 time2 = time.perf_counter() latency_pong = round(time2 - time1, 3) * 1000 _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) self._metrics[cstat.latency_ping.value].value = latency_ping self._metrics[cstat.latency_pong.value].value = latency_pong except TimeoutError as timeout_exception: _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) self._metrics[cstat.latency_ping.value].value = latency_ping self._metrics[cstat.latency_pong.value].value = latency_pong timeout_counter += 1 - if timeout_counter > self.central.websocket_ping_tries: + if timeout_counter > self.cs_settings.websocket_ping_tries: _LOGGER.debug( - f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", + f"Connection to '{self.id}' timed out after '{self.cs_settings.websocket_ping_tries}' ping tries", ) raise timeout_exception else: @@ -575,35 +438,27 @@ async def async_update_device_info( self._metrics[cdet.firmware_version.value].value = firmware_version self._metrics[cdet.serial.value].value = serial - identifiers = { - (DOMAIN, self.central.cpid), - (DOMAIN, self.id), - } - if serial is not None: - identifiers.add((DOMAIN, serial)) + identifiers = {(DOMAIN, self.id), (DOMAIN, self.settings.cpid)} registry = device_registry.async_get(self.hass) registry.async_get_or_create( config_entry_id=self.entry.entry_id, identifiers=identifiers, - name=self.central.cpid, manufacturer=vendor, model=model, - suggested_area="Garage", sw_version=firmware_version, ) def _register_boot_notification(self): - self.hass.async_create_task(self.update(self.central.cpid)) if self.triggered_boot_notification is False: self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) self.hass.async_create_task(self.post_connect()) - async def update(self, cp_id: str): + async def update(self, cpid: str): """Update sensors values in HA.""" er = entity_registry.async_get(self.hass) dr = device_registry.async_get(self.hass) - identifiers = {(DOMAIN, cp_id)} + identifiers = {(DOMAIN, cpid), (DOMAIN, self.id)} dev = dr.async_get_device(identifiers) # _LOGGER.info("Device id: %s updating", dev.name) for ent in entity_registry.async_entries_for_device(er, dev.id): @@ -816,7 +671,7 @@ def get_metric(self, measurand: str): def get_ha_metric(self, measurand: str): """Return last known value in HA for given measurand.""" entity_id = "sensor." + "_".join( - [self.central.cpid.lower(), measurand.lower().replace(".", "_")] + [self.settings.cpid.lower(), measurand.lower().replace(".", "_")] ) try: value = self.hass.states.get(entity_id).state diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 802a3a14..67e6c2c5 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -1,10 +1,17 @@ """Adds config flow for ocpp.""" -from homeassistant import config_entries +from typing import Any +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + CONN_CLASS_LOCAL_PUSH, +) import voluptuous as vol from .const import ( CONF_CPID, + CONF_CPIDS, CONF_CSID, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -45,7 +52,7 @@ MEASURANDS, ) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_USER_CS_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, @@ -53,14 +60,6 @@ vol.Required(CONF_SSL_CERTFILE_PATH, default=DEFAULT_SSL_CERTFILE_PATH): str, vol.Required(CONF_SSL_KEYFILE_PATH, default=DEFAULT_SSL_KEYFILE_PATH): str, vol.Required(CONF_CSID, default=DEFAULT_CSID): str, - vol.Required(CONF_CPID, default=DEFAULT_CPID): str, - vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, - vol.Required( - CONF_MONITORED_VARIABLES_AUTOCONFIG, - default=DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, - ): bool, - vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, - vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, vol.Required( CONF_WEBSOCKET_CLOSE_TIMEOUT, default=DEFAULT_WEBSOCKET_CLOSE_TIMEOUT ): int, @@ -73,6 +72,19 @@ vol.Required( CONF_WEBSOCKET_PING_TIMEOUT, default=DEFAULT_WEBSOCKET_PING_TIMEOUT ): int, + } +) + +STEP_USER_CP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CPID, default=DEFAULT_CPID): str, + vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, + vol.Required( + CONF_MONITORED_VARIABLES_AUTOCONFIG, + default=DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ): bool, + vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, + vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, vol.Required( CONF_SKIP_SCHEMA_VALIDATION, default=DEFAULT_SKIP_SCHEMA_VALIDATION ): bool, @@ -90,31 +102,73 @@ ) -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + VERSION = 2 + MINOR_VERSION = 0 + CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize.""" - self._data = {} + self._data: dict[str, Any] = {} + self._cp_id: str + self._entry: ConfigEntry + self._measurands: str = "" - async def async_step_user(self, user_input=None): - """Handle user initiated configuration.""" + async def async_step_user(self, user_input=None) -> ConfigFlowResult: + """Handle user central system initiated configuration.""" errors: dict[str, str] = {} if user_input is not None: + # Don't allow servers to use same websocket port + self._async_abort_entries_match({CONF_PORT: user_input[CONF_PORT]}) self._data = user_input + # Add placeholder for cpid settings + self._data[CONF_CPIDS] = [] + return self.async_create_entry(title=self._data[CONF_CSID], data=self._data) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_CS_DATA_SCHEMA, errors=errors + ) + + async def async_step_integration_discovery( + self, discovery_info=None + ) -> ConfigFlowResult: + """Handle charger discovery initiated configuration.""" + + self._entry = discovery_info["entry"] + self._cp_id = discovery_info["cp_id"] + self._data = {**self._entry.data} + + await self.async_set_unique_id(self._cp_id) + # Abort the flow if a config entry with the same unique ID exists + self._abort_if_unique_id_configured() + return await self.async_step_cp_user() + + async def async_step_cp_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure charger by user.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Don't allow duplicate cpids to be used + self._async_abort_entries_match({CONF_CPID: user_input[CONF_CPID]}) + self._data[CONF_CPIDS].append({self._cp_id: user_input}) if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: - self._data[CONF_MONITORED_VARIABLES] = DEFAULT_MONITORED_VARIABLES - return self.async_create_entry( - title=self._data[CONF_CSID], data=self._data + self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( + DEFAULT_MONITORED_VARIABLES + ) + return self.async_update_reload_and_abort( + self._entry, + data_updates=self._data, ) - return await self.async_step_measurands() + else: + return await self.async_step_measurands() return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="cp_user", data_schema=STEP_USER_CP_DATA_SCHEMA, errors=errors ) async def async_step_measurands(self, user_input=None): @@ -123,13 +177,23 @@ async def async_step_measurands(self, user_input=None): errors: dict[str, str] = {} if user_input is not None: selected_measurands = [m for m, value in user_input.items() if value] - if set(selected_measurands).issubset(set(MEASURANDS)): - self._data[CONF_MONITORED_VARIABLES] = ",".join(selected_measurands) - return self.async_create_entry( - title=self._data[CONF_CSID], data=self._data + if not set(selected_measurands).issubset(set(MEASURANDS)): + errors["base"] = "no_measurands_selected" + return self.async_show_form( + step_id="measurands", + data_schema=STEP_USER_MEASURANDS_SCHEMA, + errors=errors, ) else: - errors["base"] = "measurand" + self._measurands = ",".join(selected_measurands) + self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( + self._measurands + ) + return self.async_update_reload_and_abort( + self._entry, + data_updates=self._data, + ) + return self.async_show_form( step_id="measurands", data_schema=STEP_USER_MEASURANDS_SCHEMA, diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 6bf52c49..52395600 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -2,6 +2,7 @@ import pathlib +from dataclasses import dataclass, field import homeassistant.components.input_number as input_number from homeassistant.components.sensor import SensorDeviceClass import homeassistant.const as ha @@ -11,6 +12,7 @@ CONF_AUTH_STATUS = "authorization_status" CONF_CPI = "charge_point_identity" CONF_CPID = "cpid" +CONF_CPIDS = "cpids" CONF_CSID = "csid" CONF_DEFAULT_AUTH_STATUS = "default_authorization_status" CONF_HOST = ha.CONF_HOST @@ -49,7 +51,7 @@ DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") -DEFAULT_SUBPROTOCOL = "ocpp1.6,ocpp2.0.1" +DEFAULT_SUBPROTOCOLS = ["ocpp1.6", "ocpp2.0.1"] OCPP_2_0 = "ocpp2.0" DEFAULT_METER_INTERVAL = 60 DEFAULT_IDLE_INTERVAL = 900 @@ -133,3 +135,42 @@ SensorDeviceClass.REACTIVE_POWER: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, SensorDeviceClass.ENERGY: ha.UnitOfEnergy.KILO_WATT_HOUR, } + + +@dataclass +class ChargerSystemSettings: + """CentralSystem configuration passed to a ChargePoint.""" + + cpid: str + max_current: int + idle_interval: int + meter_interval: int + monitored_variables: str + monitored_variables_autoconfig: bool + skip_schema_validation: bool + force_smart_charging: bool + connection: int | None = None # number of this connection in central server + + +@dataclass +class CentralSystemSettings: + """CentralSystem configuration values.""" + + csid: str + host: str + port: str + ssl: bool + ssl_certfile_path: str + ssl_keyfile_path: str + websocket_close_timeout: int + websocket_ping_interval: int + websocket_ping_timeout: int + websocket_ping_tries: int + cpids: list = field(default_factory=list) # holds cpid config flow settings + subprotocols: list = field(default_factory=lambda: DEFAULT_SUBPROTOCOLS) + + # def __post_init__(self): + # i = 0 + # for id in self.cpids: + # self.cpids[i] = ChargerSystemSettings(**id) + # i =+ 1 diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index 8539ba8d..bc3b5ccb 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -15,9 +15,7 @@ class HAChargerServices(str, Enum): service_unlock = "unlock" service_update_firmware = "update_firmware" service_configure = "configure" - service_configure_v201 = "configure_v201" service_get_configuration = "get_configuration" - service_get_configuration_v201 = "get_configuration_v201" service_get_diagnostics = "get_diagnostics" service_clear_profile = "clear_profile" service_data_transfer = "data_transfer" diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 52acfc81..4ef3978a 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -16,5 +16,5 @@ "ocpp>=2.0.0", "websockets>=14.1" ], - "version": "0.7.0" + "version": "0.8.0" } diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 0f17ec01..4fb8762e 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -19,9 +19,9 @@ from .api import CentralSystem from .const import ( CONF_CPID, + CONF_CPIDS, CONF_MAX_CURRENT, DATA_UPDATED, - DEFAULT_CPID, DEFAULT_MAX_CURRENT, DOMAIN, ICON, @@ -56,20 +56,26 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - entities = [] - - for ent in NUMBERS: - if ent.key == "maximum_current": - ent.initial_value = entry.data.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) - ent.native_max_value = entry.data.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) - entities.append(OcppNumber(hass, central_system, cp_id, ent)) + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] + + for ent in NUMBERS: + if ent.key == "maximum_current": + ent.initial_value = entry.data.get( + CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT + ) + ent.native_max_value = entry.data.get( + CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT + ) + cpx = ChargePointNumber(hass, central_system, cpid, ent) + entities.append(cpx) async_add_devices(entities, False) -class OcppNumber(RestoreNumber, NumberEntity): +class ChargePointNumber(RestoreNumber, NumberEntity): """Individual slider for setting charge rate.""" _attr_has_entity_name = True @@ -79,20 +85,20 @@ def __init__( self, hass: HomeAssistant, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppNumberDescription, ): """Initialize a Number instance.""" - self.cp_id = cp_id + self.cpid = cpid self._hass = hass self.central_system = central_system self.entity_description = description self._attr_unique_id = ".".join( - [NUMBER_DOMAIN, self.cp_id, self.entity_description.key] + [NUMBER_DOMAIN, self.cpid, self.entity_description.key] ) self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, + identifiers={(DOMAIN, self.cpid)}, ) self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False @@ -115,19 +121,19 @@ def _schedule_immediate_update(self): # def available(self) -> bool: # """Return if entity is available.""" # if not ( - # Profiles.SMART & self.central_system.get_supported_features(self.cp_id) + # Profiles.SMART & self.central_system.get_supported_features(self.cpid) # ): # return False - # return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + # return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] async def async_set_native_value(self, value): """Set new value.""" num_value = float(value) if self.central_system.get_available( - self.cp_id - ) and Profiles.SMART & self.central_system.get_supported_features(self.cp_id): + self.cpid + ) and Profiles.SMART & self.central_system.get_supported_features(self.cpid): resp = await self.central_system.set_max_charge_rate_amps( - self.cp_id, num_value + self.cpid, num_value ) if resp is True: self._attr_native_value = num_value diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 730c6345..3e4950b7 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta, UTC import logging -from homeassistant.const import STATE_UNAVAILABLE import time from homeassistant.config_entries import ConfigEntry @@ -36,14 +35,16 @@ UnlockStatus, ) -from .chargepoint import CentralSystemSettings, OcppVersion, MeasurandValue +from .chargepoint import ( + OcppVersion, + MeasurandValue, + SetVariableResult, +) from .chargepoint import ChargePoint as cp -from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA from .enums import ( ConfigurationKey as ckey, HAChargerDetails as cdet, - HAChargerServices as csvcs, HAChargerSession as csess, HAChargerStatuses as cstat, OcppMisc as om, @@ -51,16 +52,9 @@ ) from .const import ( - CONF_FORCE_SMART_CHARGING, - CONF_IDLE_INTERVAL, - CONF_METER_INTERVAL, - CONF_MONITORED_VARIABLES, - CONF_MONITORED_VARIABLES_AUTOCONFIG, - DEFAULT_FORCE_SMART_CHARGING, - DEFAULT_IDLE_INTERVAL, + CentralSystemSettings, + ChargerSystemSettings, DEFAULT_MEASURAND, - DEFAULT_METER_INTERVAL, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, DOMAIN, ) @@ -78,8 +72,7 @@ def __init__( hass: HomeAssistant, entry: ConfigEntry, central: CentralSystemSettings, - interval_meter_metrics: int = 10, - skip_schema_validation: bool = False, + charger: ChargerSystemSettings, ): """Instantiate a ChargePoint.""" @@ -90,8 +83,7 @@ def __init__( hass, entry, central, - interval_meter_metrics, - skip_schema_validation, + charger, ) async def get_number_of_connectors(self): @@ -104,13 +96,8 @@ async def get_heartbeat_interval(self): async def get_supported_measurands(self) -> str: """Get comma-separated list of measurands supported by the charger.""" - all_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND - ) - autodetect_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES_AUTOCONFIG, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, - ) + all_measurands = self.settings.monitored_variables + autodetect_measurands = self.settings.monitored_variables_autoconfig key = ckey.meter_values_sampled_data.value @@ -161,48 +148,11 @@ async def set_standard_configuration(self): """Send configuration values to the charger.""" await self.configure( ckey.meter_value_sample_interval.value, - str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), + str(self.settings.meter_interval), ) await self.configure( ckey.clock_aligned_data_interval.value, - str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), - ) - # await self.configure( - # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) - # ) - # await self.start_transaction() - - def register_version_specific_services(self): - """Register HA services that differ depending on OCPP version.""" - - async def handle_configure(call): - """Handle the configure service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - value = call.data.get("value") - await self.configure(key, value) - - async def handle_get_configuration(call): - """Handle the get configuration service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - await self.get_configuration(key) - - self.hass.services.async_register( - DOMAIN, - csvcs.service_configure.value, - handle_configure, - CONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_configuration.value, - handle_get_configuration, - GCONF_SERVICE_DATA_SCHEMA, + str(self.settings.idle_interval), ) async def get_supported_features(self) -> prof: @@ -219,9 +169,7 @@ async def get_supported_features(self) -> prof: await self.notify_ha("No feature profiles detected, defaulting to Core") feature_list = [om.feature_profile_core.value] - if self.central.config.get( - CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING - ): + if self.settings.force_smart_charging: _LOGGER.warning("Force Smart Charging feature profile") features |= prof.SMART @@ -261,7 +209,10 @@ async def trigger_boot_notification(self): async def trigger_status_notification(self): """Trigger status notifications for all connectors.""" return_value = True - nof_connectors = int(self._metrics[cdet.connectors.value].value) + try: + nof_connectors = int(self._metrics[cdet.connectors.value].value) + except TypeError: + nof_connectors = 1 for id in range(0, nof_connectors + 1): _LOGGER.debug(f"trigger status notification for connector={id}") req = call.TriggerMessage( @@ -519,7 +470,7 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ) return False - async def get_configuration(self, key: str = ""): + async def get_configuration(self, key: str = "") -> str: """Get Configuration of charger for supported keys else return None.""" if key == "": req = call.GetConfiguration() @@ -535,7 +486,7 @@ async def get_configuration(self, key: str = ""): if resp.unknown_key: _LOGGER.warning("Get Configuration returned unknown key for: %s", key) await self.notify_ha(f"Warning: charger reports {key} is unknown") - return None + return "Unknown" async def configure(self, key: str, value: str): """Configure charger by setting the key to target value. @@ -554,7 +505,7 @@ async def configure(self, key: str, value: str): if resp.unknown_key is not None: if key in resp.unknown_key: _LOGGER.warning("%s is unknown (not supported)", key) - return + return "Unknown" for key_value in resp.configuration_key: # If the key already has the targeted value we don't need to set @@ -578,15 +529,19 @@ async def configure(self, key: str, value: str): await self.notify_ha( f"Warning: charger reported {resp.status} while setting {key}={value}" ) + return resp.status if resp.status == ConfigurationStatus.reboot_required: self._requires_reboot = True await self.notify_ha(f"A reboot is required to apply {key}={value}") + return SetVariableResult.reboot_required + + return SetVariableResult.accepted async def async_update_device_info_v16(self, boot_info: dict): """Update device info asynchronuously.""" - _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + _LOGGER.debug("Updating device info %s: %s", self.settings.cpid, boot_info) await self.async_update_device_info( boot_info.get(om.charge_point_serial_number.name, None), boot_info.get(om.charge_point_vendor.name, None), @@ -671,7 +626,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._metrics[csess.session_energy.value].extra_attr[ cstat.id_tag.name ] = self._metrics[cstat.id_tag.value].value - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @on(Action.boot_notification) @@ -722,14 +677,14 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): self._metrics[Measurand.power_active_export.value].value = 0 if Measurand.power_reactive_export.value in self._metrics: self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @on(Action.firmware_status_notification) def on_firmware_status(self, status, **kwargs): """Handle firmware status notification.""" self._metrics[cstat.firmware_status.value].value = status - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) return call_result.FirmwareStatusNotification() @@ -782,7 +737,7 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): result = call_result.StartTransaction( id_tag_info={om.status.value: auth_status}, transaction_id=0 ) - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return result @on(Action.stop_transaction) @@ -815,7 +770,7 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): self._metrics[Measurand.power_active_export.value].value = 0 if Measurand.power_reactive_export.value in self._metrics: self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} ) @@ -833,5 +788,5 @@ def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" now = datetime.now(tz=UTC) self._metrics[cstat.heartbeat.value].value = now - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index e9e6063c..494719af 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime -from homeassistant.core import HomeAssistant, SupportsResponse, ServiceResponse +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError, HomeAssistantError from websockets.asyncio.server import ServerConnection @@ -38,24 +38,22 @@ ) from .chargepoint import ( - CentralSystemSettings, OcppVersion, SetVariableResult, MeasurandValue, ) from .chargepoint import ChargePoint as cp -from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA from .enums import Profiles from .enums import ( HAChargerStatuses as cstat, - HAChargerServices as csvcs, HAChargerSession as csess, ) from .const import ( - DEFAULT_METER_INTERVAL, + CentralSystemSettings, + ChargerSystemSettings, DOMAIN, HA_ENERGY_UNIT, ) @@ -90,8 +88,7 @@ def __init__( hass: HomeAssistant, entry: ConfigEntry, central: CentralSystemSettings, - interval_meter_metrics: int = 10, - skip_schema_validation: bool = False, + charger: ChargerSystemSettings, ): """Instantiate a ChargePoint.""" @@ -102,14 +99,13 @@ def __init__( hass, entry, central, - interval_meter_metrics, - skip_schema_validation, + charger, ) async def async_update_device_info_v201(self, boot_info: dict): """Update device info asynchronuously.""" - _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + _LOGGER.debug("Updating device info %s: %s", self.settings.cpid, boot_info) await self.async_update_device_info( boot_info.get("serial_number", None), boot_info.get("vendor_name", None), @@ -145,43 +141,12 @@ async def set_standard_configuration(self): { "component": {"name": "SampledDataCtrlr"}, "variable": {"name": "TxUpdatedInterval"}, - "attribute_value": str(DEFAULT_METER_INTERVAL), + "attribute_value": str(self.settings.meter_interval), } ] ) await self.call(req) - def register_version_specific_services(self): - """Register HA services that differ depending on OCPP version.""" - - async def handle_configure(call) -> ServiceResponse: - """Handle the configure service call.""" - key = call.data.get("ocpp_key") - value = call.data.get("value") - result: SetVariableResult = await self.configure(key, value) - return {"reboot_required": result == SetVariableResult.reboot_required} - - async def handle_get_configuration(call) -> ServiceResponse: - """Handle the get configuration service call.""" - key = call.data.get("ocpp_key") - value = await self.get_configuration(key) - return {"value": value} - - self.hass.services.async_register( - DOMAIN, - csvcs.service_configure_v201.value, - handle_configure, - CONF_SERVICE_DATA_SCHEMA, - supports_response=SupportsResponse.OPTIONAL, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_configuration_v201.value, - handle_get_configuration, - GCONF_SERVICE_DATA_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - async def get_supported_measurands(self) -> str: """Get comma-separated list of measurands supported by the charger.""" await self._get_inventory() @@ -447,7 +412,7 @@ def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv1 self._metrics[cstat.status_connector.value].extra_attr[evse_id] = ( evse_status_str ) - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) @on(Action.status_notification) def on_status_notification( @@ -690,6 +655,6 @@ def on_transaction_event( self._metrics[cstat.id_tag.value].value = "" if not offline: - self.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.update(self.settings.cpid)) return response diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 14a04c42..5943537f 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -21,9 +21,9 @@ from .api import CentralSystem from .const import ( CONF_CPID, + CONF_CPIDS, DATA_UPDATED, DEFAULT_CLASS_UNITS_HA, - DEFAULT_CPID, DOMAIN, ICON, Measurand, @@ -41,38 +41,43 @@ class OcppSensorDescription(SensorEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the sensor platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) entities = [] - SENSORS = [] - for metric in list( - set(entry.data[CONF_MONITORED_VARIABLES].split(",") + list(HAChargerSession)) - ): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, + # setup all chargers added to config + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] + SENSORS = [] + for metric in list( + set( + cp_id_settings[CONF_MONITORED_VARIABLES].split(",") + + list(HAChargerSession) ) - ) - for metric in list(HAChargerStatuses) + list(HAChargerDetails): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - entity_category=EntityCategory.DIAGNOSTIC, + ): + SENSORS.append( + OcppSensorDescription( + key=metric.lower(), + name=metric.replace(".", " "), + metric=metric, + ) + ) + for metric in list(HAChargerStatuses) + list(HAChargerDetails): + SENSORS.append( + OcppSensorDescription( + key=metric.lower(), + name=metric.replace(".", " "), + metric=metric, + entity_category=EntityCategory.DIAGNOSTIC, + ) ) - ) - for ent in SENSORS: - entities.append( - ChargePointMetric( + for ent in SENSORS: + cpx = ChargePointMetric( hass, central_system, - cp_id, + cpid, ent, ) - ) + entities.append(cpx) async_add_devices(entities, False) @@ -87,23 +92,23 @@ def __init__( self, hass: HomeAssistant, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppSensorDescription, ): """Instantiate instance of a ChargePointMetrics.""" self.central_system = central_system - self.cp_id = cp_id + self.cpid = cpid self.entity_description = description self.metric = self.entity_description.metric self._hass = hass self._extra_attr = {} self._last_reset = homeassistant.util.dt.utc_from_timestamp(0) self._attr_unique_id = ".".join( - [DOMAIN, self.cp_id, self.entity_description.key, SENSOR_DOMAIN] + [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] ) self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, + identifiers={(DOMAIN, self.cpid)}, ) self._attr_icon = ICON self._attr_native_unit_of_measurement = None @@ -111,7 +116,7 @@ def __init__( @property def available(self) -> bool: """Return if sensor is available.""" - return self.central_system.get_available(self.cp_id) + return self.central_system.get_available(self.cpid) @property def should_poll(self): @@ -124,7 +129,7 @@ def should_poll(self): @property def extra_state_attributes(self): """Return the state attributes.""" - return self.central_system.get_extra_attr(self.cp_id, self.metric) + return self.central_system.get_extra_attr(self.cpid, self.metric) @property def state_class(self): @@ -184,7 +189,7 @@ def device_class(self): @property def native_value(self): """Return the state of the sensor, rounding if a number.""" - value = self.central_system.get_metric(self.cp_id, self.metric) + value = self.central_system.get_metric(self.cpid, self.metric) if value is not None: self._attr_native_value = value return self._attr_native_value @@ -192,7 +197,7 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the native unit of measurement.""" - value = self.central_system.get_ha_unit(self.cp_id, self.metric) + value = self.central_system.get_ha_unit(self.cpid, self.metric) if value is not None: self._attr_native_unit_of_measurement = value else: diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index 8c3e057f..cd82fb00 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -4,13 +4,19 @@ set_charge_rate: name: Set maximum charge rate # Description of the service description: Sets the maximum charge rate in Amps or Watts (dependent on charger support) - # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. + # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. #target: # entity: # integration: ocpp # Different fields that your service accepts fields: # Key of the field + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger limit_amps: # Field name as shown in UI name: Limit (A) @@ -32,7 +38,7 @@ set_charge_rate: example: 1500 default: 22000 conn_id: - name: Connector identifier + name: Connector identifier description: Optional, 0 = all connectors (default), 1 is first connector required: false advanced: true @@ -48,11 +54,24 @@ set_charge_rate: clear_profile: name: Clear charging profiles description: Clears all charging profiles (limits) set (dependent on charger support) + fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger update_firmware: name: Update charger firmware description: Specify server to download firmware and time to delay updating (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger firmware_url: name: Url of firmware description: Full url of firmware file (http or https) @@ -66,34 +85,23 @@ update_firmware: advanced: true example: 12 default: 0 - + configure: name: Configure charger features - description: Change supported Ocpp v1.6 configuration values + description: Change supported Ocpp configuration values fields: - ocpp_key: - name: Configuration key name - description: Write-enabled key name supported - required: true - advanced: true - example: "WebSocketPingInterval" - value: - name: Key value - description: Value to write to key - required: true + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false advanced: true - example: "60" - -configure_v201: - name: Configure charger features - description: Change supported Ocpp v2.0.1 configuration values - fields: + example: charger ocpp_key: - name: [()]/[()] - description: [()]/[()] + name: Write-enabled configuration key name + description: v1.6- Key name supported v2.0.1- Component name/Key name required: true advanced: true - example: "OCPPCommCtrlr/WebSocketPingInterval" + example: "WebSocketPingInterval" value: name: Key value description: Value to write to key @@ -103,41 +111,48 @@ configure_v201: get_configuration: name: Get configuration values for charger - description: Get supported Ocpp v1.6 configuration values + description: Get supported Ocpp configuration values fields: - ocpp_key: - name: Configuration key name - description: Key name supported - required: true + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false advanced: true - example: "WebSocketPingInterval" - -get_configuration_v201: - name: Get configuration values for charger - description: Get supported Ocpp v2.0.1 configuration values - fields: + example: charger ocpp_key: - name: [()]/[()] - description: [()]/[()] + name: Configuration key name + description: v1.6- Key name v2.0.1- Component name/Key name required: true advanced: true - example: "OCPPCommCtrlr/WebSocketPingInterval" + example: "WebSocketPingInterval" get_diagnostics: name: Request diagnostic data from charger description: Specify server url to upload diagnostic data to (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger upload_url: name: Url for upload description: Full url to upload to required: true advanced: true example: "https://webhook.site/abc" - + data_transfer: name: Request data transfer from charger description: Note this is specific to charger, see manufacturer's documentation fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger vendor_id: name: vendorId description: Defined by charger manufacturer @@ -155,4 +170,4 @@ data_transfer: description: Defined by charger manufacturer required: false advanced: true - example: "ABC" \ No newline at end of file + example: "ABC" diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 5a33450b..99ec4bd5 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -15,7 +15,7 @@ from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem -from .const import CONF_CPID, DEFAULT_CPID, DOMAIN, ICON +from .const import CONF_CPID, CONF_CPIDS, DOMAIN, ICON from .enums import HAChargerServices, HAChargerStatuses @@ -63,14 +63,16 @@ class OcppSwitchDescription(SwitchEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): - """Configure the sensor platform.""" + """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - entities = [] + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] - for ent in SWITCHES: - entities.append(ChargePointSwitch(central_system, cp_id, ent)) + for ent in SWITCHES: + cpx = ChargePointSwitch(central_system, cpid, ent) + entities.append(cpx) async_add_devices(entities, False) @@ -84,26 +86,26 @@ class ChargePointSwitch(SwitchEntity): def __init__( self, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppSwitchDescription, ): """Instantiate instance of a ChargePointSwitch.""" - self.cp_id = cp_id + self.cpid = cpid self.central_system = central_system self.entity_description = description self._state = self.entity_description.default_state self._attr_unique_id = ".".join( - [SWITCH_DOMAIN, DOMAIN, self.cp_id, self.entity_description.key] + [SWITCH_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] ) self._attr_name = self.entity_description.name self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, + identifiers={(DOMAIN, self.cpid)}, ) @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] @property def is_on(self) -> bool: @@ -111,7 +113,7 @@ def is_on(self) -> bool: """Test metric state against condition if present""" if self.entity_description.metric_state is not None: resp = self.central_system.get_metric( - self.cp_id, self.entity_description.metric_state + self.cpid, self.entity_description.metric_state ) if resp in self.entity_description.metric_condition: self._state = True @@ -122,7 +124,7 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._state = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.on_action + self.cpid, self.entity_description.on_action ) async def async_turn_off(self, **kwargs: Any) -> None: @@ -132,10 +134,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: resp = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.off_action, False + self.cpid, self.entity_description.off_action, False ) else: resp = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.off_action + self.cpid, self.entity_description.off_action ) self._state = not resp diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index eff06f1d..8d87f7dd 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -8,19 +8,25 @@ "host": "Host-Adresse des Zentralsystems", "port": "Portnummer des Zentralsystems", "csid": "Identität des Zentralsystems", - "cpid": "Identität der Ladestation", - "max_current": "Maximaler Ladestrom", - "meter_interval": "Abtastintervall Laden (Sekunden)", - "idle_interval": "Abtastintervall Leerlauf (Sekunden)", "websocket_close_timeout": "Timeout beim Schließen des Websockets (Sekunden)", "websocket_ping_tries": "Verbindungsversuche vor dem Schließen des Websockets", "websocket_ping_interval": "Websocket-Ping-Intervall (Sekunden)", "websocket_ping_timeout": "Websocket-Ping-Timeout (Sekunden)", - "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", - "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", "ssl": "Verschlüsselte Verbindung", "ssl_certfile_path": "Pfad zum SSL Zertifikat", - "ssl_keyfile_path": "Pfad zum SSL Schlüssel", + "ssl_keyfile_path": "Pfad zum SSL Schlüssel" + } + }, + "cp_user": { + "title": "OCPP-Konfiguration", + "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier nach: https://github.com/lbbrhzn/ocpp", + "data": { + "cpid": "Identität der Ladestation", + "max_current": "Maximaler Ladestrom", + "meter_interval": "Abtastintervall Laden (Sekunden)", + "idle_interval": "Abtastintervall Leerlauf (Sekunden)", + "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", + "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte" } }, diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index d610bd07..36a8562d 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "OCPP Configuration", + "title": "OCPP Central System Configuration", "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", "data": { "host": "Central system host address", @@ -11,15 +11,21 @@ "ssl_certfile_path": "Path to SSL certificate or (None)", "ssl_keyfile_path": "Path to SSL key or (None)", "csid": "Central system identity", + "websocket_close_timeout": "Websocket close timeout (seconds)", + "websocket_ping_tries": "Websocket successive times to try connection before closing", + "websocket_ping_interval": "Websocket ping interval (seconds)", + "websocket_ping_timeout": "Websocket ping timeout (seconds)" + } + }, + "cp_user": { + "title": "OCPP Charger Configuration", + "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", - "websocket_close_timeout": "Websocket close timeout (seconds)", - "websocket_ping_tries": "Websocket successive times to try connection before closing", - "websocket_ping_interval": "Websocket ping interval (seconds)", - "websocket_ping_timeout": "Websocket ping timeout (seconds)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile" } @@ -55,10 +61,11 @@ }, "error": { "auth": "Username/Password is wrong.", - "measurand": "Unknown measurand" + "no_measurands_selected": "No measurand selected: please select at least one" }, "abort": { - "single_instance_allowed": "Only a single instance is allowed." + "single_instance_allowed": "Only a single instance is allowed", + "reauth_successful": "New charger configured" } }, "exceptions": { diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index 352eb2ef..f40ebdc0 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -8,13 +8,19 @@ "host": "Dirección de host del sistema central", "port": "Número de puerto del sistema central", "csid": "Nombre del sistema central", - "cpid": "Nombre del punto de carga", - "meter_interval": "Intervalo de mediciones (segundos)", - "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "websocket_close_timeout": "Tiempo de espera Websocket (segundos)", "websocket_ping_tries": "Reintentos de conexión Websocket", "websocket_ping_interval": "Intervalo ping Websocket (segundos)", - "websocket_ping_timeout": "Tiempo de espera ping Websocket (segundos)", + "websocket_ping_timeout": "Tiempo de espera ping Websocket (segundos)" + } + }, + "cp_user": { + "title": "Configuración OCPP", + "description": "Si necesitas ayuda con la configuración puedes hechar un vistazo en: https://github.com/lbbrhzn/ocpp", + "data": { + "cpid": "Nombre del punto de carga", + "meter_interval": "Intervalo de mediciones (segundos)", + "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "skip_schema_validation": "Omitir validación esquema OCPP", "force_smart_charging": "Forzar perfil de función Smart Charging" } diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index d610bd07..e01afa80 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "OCPP Configuration", + "title": "OCPP Central System Configuration", "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", "data": { "host": "Central system host address", @@ -11,15 +11,21 @@ "ssl_certfile_path": "Path to SSL certificate or (None)", "ssl_keyfile_path": "Path to SSL key or (None)", "csid": "Central system identity", + "websocket_close_timeout": "Websocket close timeout (seconds)", + "websocket_ping_tries": "Websocket successive times to try connection before closing", + "websocket_ping_interval": "Websocket ping interval (seconds)", + "websocket_ping_timeout": "Websocket ping timeout (seconds)" + } + }, + "cp_user": { + "title": "OCPP Charger Configuration", + "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", - "websocket_close_timeout": "Websocket close timeout (seconds)", - "websocket_ping_tries": "Websocket successive times to try connection before closing", - "websocket_ping_interval": "Websocket ping interval (seconds)", - "websocket_ping_timeout": "Websocket ping timeout (seconds)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile" } @@ -55,10 +61,11 @@ }, "error": { "auth": "Username/Password is wrong.", - "measurand": "Unknown measurand" + "no_measurands_selected": "No measurand selected: please select at least one" }, "abort": { - "single_instance_allowed": "Only a single instance is allowed." + "single_instance_allowed": "Only a single instance is allowed", + "reauth_successful": "New charger configured" } }, "exceptions": { @@ -73,6 +80,9 @@ }, "set_variables_error": { "message": "Failed to set variable: {message}" + }, + "unavailable": { + "message": "Charger is unavailable: {message}" } } } \ No newline at end of file diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index 50164f70..ce4cc92c 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -8,13 +8,19 @@ "host": "Host", "port": "Poort", "csid": "Central system identifier", - "cpid": "Charge point identifier", - "max_current": "Maximale laadstroom", - "meter_interval": "Meetinterval (secondes)", "websocket_close_timeout": "Websocket close timeout (secondes)", "websocket_ping_tries": "Websocket successive times to try connection before closing", "websocket_ping_interval": "Websocket ping interval (secondes)", - "websocket_ping_timeout": "Websocket ping timeout (secondes)", + "websocket_ping_timeout": "Websocket ping timeout (secondes)" + } + }, + "cp_user": { + "title": "OCPP Configuratie", + "description": "Voor hulp bij configuratie zie: https://github.com/lbbrhzn/ocpp", + "data": { + "cpid": "Charge point identifier", + "max_current": "Maximale laadstroom", + "meter_interval": "Meetinterval (secondes)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Functieprofiel Smart Charging forceren" } diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index 0398c276..3882636d 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -9,7 +9,6 @@ from .const import CONF_PORT import contextlib from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN -from custom_components.ocpp.enums import HAChargerServices as csvcs from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button.const import SERVICE_PRESS from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN @@ -27,36 +26,36 @@ from websockets.asyncio.client import ClientConnection -async def set_switch(hass: HomeAssistant, cs: CentralSystem, key: str, on: bool): +async def set_switch(hass: HomeAssistant, cpid: str, key: str, on: bool): """Toggle a switch.""" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON if on else SERVICE_TURN_OFF, - service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cs.settings.cpid}_{key}"}, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}_{key}"}, blocking=True, ) -async def set_number(hass: HomeAssistant, cs: CentralSystem, key: str, value: int): +async def set_number(hass: HomeAssistant, cpid: str, key: str, value: int): """Set a numeric slider.""" await hass.services.async_call( NUMBER_DOMAIN, "set_value", service_data={"value": str(value)}, blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cs.settings.cpid}_{key}"}, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_{key}"}, ) set_switch.__test__ = False -async def press_button(hass: HomeAssistant, cs: CentralSystem, key: str): +async def press_button(hass: HomeAssistant, cpid: str, key: str): """Press a button.""" await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.{cs.settings.cpid}_{key}"}, + {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.{cpid}_{key}"}, blocking=True, ) @@ -83,15 +82,15 @@ async def remove_configuration(hass: HomeAssistant, config_entry: MockConfigEntr if entry := hass.config_entries.async_get_entry(config_entry.entry_id): await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[OCPP_DOMAIN] remove_configuration.__test__ = False -async def wait_ready(hass: HomeAssistant): +async def wait_ready(cp: ChargePoint): """Wait until charge point is connected and initialised.""" - hass.services.async_remove(OCPP_DOMAIN, csvcs.service_data_transfer) - while not hass.services.has_service(OCPP_DOMAIN, csvcs.service_data_transfer): + while not cp.post_connect_success: await asyncio.sleep(0.1) @@ -128,7 +127,7 @@ async def run_charge_point_test( ] await asyncio.wait_for( asyncio.gather(*([cp.start()] + test_results)), - timeout=5, + timeout=30, ) await ws.close() for test_completed in completed: diff --git a/tests/conftest.py b/tests/conftest.py index 9ce3d091..f6843c20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,7 @@ def skip_notifications_fixture(): # This fixture, when used, will result in calls to websockets to be bypassed. To have the call # return a value, we would add the `return_value=` parameter to the patch call. +# include patch for hass.states.get for use with migration to return cp_id @pytest.fixture(name="bypass_get_data") def bypass_get_data_fixture(): """Skip calls to get data from API.""" @@ -40,6 +41,7 @@ def bypass_get_data_fixture(): patch("websockets.asyncio.server.serve", return_value=future), patch("websockets.asyncio.server.Server.close"), patch("websockets.asyncio.server.Server.wait_closed"), + patch("homeassistant.core.StateMachine.get", return_value="test_cp_id"), ): yield diff --git a/tests/const.py b/tests/const.py index 69897707..c70e2208 100644 --- a/tests/const.py +++ b/tests/const.py @@ -2,6 +2,7 @@ from custom_components.ocpp.const import ( CONF_CPID, + CONF_CPIDS, CONF_CSID, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -22,38 +23,62 @@ DEFAULT_MONITORED_VARIABLES, ) -MOCK_CONFIG = { +MOCK_CONFIG_CS = { CONF_HOST: "127.0.0.1", - CONF_PORT: 9000, + CONF_PORT: 9005, CONF_SSL: False, CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", + CONF_CSID: "test_csid_flow", + CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, + CONF_WEBSOCKET_PING_TRIES: 0, + CONF_WEBSOCKET_PING_INTERVAL: 1, + CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [], +} + +MOCK_CONFIG_CP = { CONF_CPID: "test_cpid", - CONF_CSID: "test_csid", CONF_IDLE_INTERVAL: 900, CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, CONF_MONITORED_VARIABLES_AUTOCONFIG: True, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, +} + +MOCK_CONFIG_FLOW = { + CONF_HOST: "127.0.0.1", + CONF_PORT: 9005, + CONF_CSID: "test_csid_flow", + CONF_SSL: False, + CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", + CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, CONF_WEBSOCKET_PING_TRIES: 0, CONF_WEBSOCKET_PING_INTERVAL: 1, CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [ + { + "test_cp_id": { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], } +# test_cpid configuration with skip schema validation enabled, and auto config false MOCK_CONFIG_DATA = { CONF_HOST: "127.0.0.1", CONF_PORT: 9000, - CONF_CPID: "test_cpid", CONF_CSID: "test_csid", - CONF_IDLE_INTERVAL: 900, - CONF_MAX_CURRENT: 32, - CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, - CONF_MONITORED_VARIABLES_AUTOCONFIG: True, - CONF_SKIP_SCHEMA_VALIDATION: False, - CONF_FORCE_SMART_CHARGING: True, CONF_SSL: False, CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", @@ -61,33 +86,86 @@ CONF_WEBSOCKET_PING_TRIES: 0, CONF_WEBSOCKET_PING_INTERVAL: 1, CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [], +} + +# Mock a charger that can be appended to config data +MOCK_CONFIG_CP_APPEND = { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, } -# different port +# different port with skip schema validation enabled, and auto config false MOCK_CONFIG_DATA_1 = { **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_1", CONF_PORT: 9001, - CONF_CPID: "test_cpid_1", + CONF_CPIDS: [ + { + "CP_1_nosub": { + CONF_CPID: "test_cpid_9001", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], } -# configuration with skip schema validation enabled +# allow many chargers to connect MOCK_CONFIG_DATA_2 = { **MOCK_CONFIG_DATA, - CONF_PORT: 9002, - CONF_CPID: "test_cpid_2", - CONF_SKIP_SCHEMA_VALIDATION: True, - CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_CSID: "test_csid_2", } -# separate entry for switch so tests can run concurrently -MOCK_CONFIG_SWITCH = { +# empty monitored variables +MOCK_CONFIG_DATA_3 = { + **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_3", + CONF_CPIDS: [ + { + "test_cpid": { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: "", + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], +} + + +MOCK_CONFIG_MIGRATION_FLOW = { CONF_HOST: "127.0.0.1", - CONF_PORT: 9001, - CONF_CPID: "test_cpid_2", - CONF_CSID: "test_csid_2", - CONF_MAX_CURRENT: 32, + CONF_PORT: 9005, + CONF_CSID: "test_migration_flow", + CONF_SSL: False, + CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", + CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", + CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, + CONF_WEBSOCKET_PING_TRIES: 0, + CONF_WEBSOCKET_PING_INTERVAL: 1, + CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPID: "test_cpid_migration_flow", CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, } -DEFAULT_NAME = "test" diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index d511a525..76d641ee 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -1,14 +1,22 @@ """Implement a test by a simulating an OCPP 1.6 chargepoint.""" import asyncio +import contextlib from datetime import datetime, UTC # timedelta, import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.exceptions import HomeAssistantError import websockets +from custom_components.ocpp.api import CentralSystem from custom_components.ocpp.button import BUTTONS -from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN +from custom_components.ocpp.const import ( + DOMAIN as OCPP_DOMAIN, + CONF_CPIDS, + CONF_CPID, + CONF_PORT, +) from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs from custom_components.ocpp.number import NUMBERS from custom_components.ocpp.switch import SWITCHES @@ -33,112 +41,185 @@ UnlockStatus, ) -from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 -from .charge_point_test import set_switch, press_button, set_number -import contextlib +from .const import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_CP_APPEND, +) +from .charge_point_test import ( + set_switch, + press_button, + set_number, + create_configuration, + remove_configuration, +) +SERVICES = [ + csvcs.service_update_firmware, + csvcs.service_configure, + csvcs.service_get_configuration, + csvcs.service_get_diagnostics, + csvcs.service_clear_profile, + csvcs.service_data_transfer, + csvcs.service_set_charge_rate, +] -@pytest.mark.timeout(90) # Set timeout for this test -async def test_cms_responses_v16(hass, socket_enabled): - """Test central system responses to a charger.""" - async def test_switches(hass, cs, socket_enabled): - """Test switch operations.""" - for switch in SWITCHES: - await set_switch(hass, cs, switch.key, True) - await asyncio.sleep(1) - await set_switch(hass, cs, switch.key, False) - - async def test_buttons(hass, cs, socket_enabled): - """Test button operations.""" - for button in BUTTONS: - await press_button(hass, cs, button.key) - - async def test_services(hass, cs, socket_enabled): - """Test service operations.""" - SERVICES = [ - csvcs.service_update_firmware, - csvcs.service_configure, - csvcs.service_get_configuration, - csvcs.service_get_diagnostics, - csvcs.service_clear_profile, - csvcs.service_data_transfer, - csvcs.service_set_charge_rate, - ] - for service in SERVICES: - data = {} - if service == csvcs.service_update_firmware: - data = {"firmware_url": "http://www.charger.com/firmware.bin"} - if service == csvcs.service_configure: - data = {"ocpp_key": "WebSocketPingInterval", "value": "60"} - if service == csvcs.service_get_configuration: - data = {"ocpp_key": "UnknownKeyTest"} - if service == csvcs.service_get_diagnostics: - data = {"upload_url": "https://webhook.site/abc"} - if service == csvcs.service_data_transfer: - data = {"vendor_id": "ABC"} - if service == csvcs.service_set_charge_rate: - data = {"limit_amps": 30} +SERVICES_ERROR = [ + csvcs.service_configure, + csvcs.service_get_configuration, + csvcs.service_clear_profile, + csvcs.service_data_transfer, + csvcs.service_set_charge_rate, +] + + +async def test_switches(hass, cpid, socket_enabled): + """Test switch operations.""" + for switch in SWITCHES: + await set_switch(hass, cpid, switch.key, True) + await asyncio.sleep(1) + await set_switch(hass, cpid, switch.key, False) + + +test_switches.__test__ = False + + +async def test_buttons(hass, cpid, socket_enabled): + """Test button operations.""" + for button in BUTTONS: + await press_button(hass, cpid, button.key) + +test_buttons.__test__ = False + + +async def test_services(hass, cpid, serv_list, socket_enabled): + """Test service operations.""" + + for service in serv_list: + data = {"devid": cpid} + if service == csvcs.service_update_firmware: + data.update({"firmware_url": "http://www.charger.com/firmware.bin"}) + if service == csvcs.service_configure: + data.update({"ocpp_key": "WebSocketPingInterval", "value": "60"}) await hass.services.async_call( OCPP_DOMAIN, service.value, service_data=data, blocking=True, + return_response=True, ) - # test additional set charge rate options - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_set_charge_rate, - service_data={"limit_watts": 3000}, - blocking=True, - ) - # test custom charge profile for advanced use - prof = { - "chargingProfileId": 8, - "stackLevel": 6, - "chargingProfileKind": "Relative", - "chargingProfilePurpose": "ChargePointMaxProfile", - "chargingSchedule": { - "chargingRateUnit": "A", - "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16.0}], - }, - } - data = {"custom_profile": str(prof)} + break + if service == csvcs.service_get_configuration: + data.update({"ocpp_key": "UnknownKeyTest"}) + await hass.services.async_call( + OCPP_DOMAIN, + service.value, + service_data=data, + blocking=True, + return_response=True, + ) + break + if service == csvcs.service_get_diagnostics: + data.update({"upload_url": "https://webhook.site/abc"}) + if service == csvcs.service_data_transfer: + data.update({"vendor_id": "ABC"}) + if service == csvcs.service_set_charge_rate: + data.update({"limit_amps": 30}) + await hass.services.async_call( OCPP_DOMAIN, - csvcs.service_set_charge_rate, + service.value, service_data=data, blocking=True, ) + # test additional set charge rate options + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data={"devid": cpid, "limit_watts": 3000}, + blocking=True, + ) + # test custom charge profile for advanced use + prof = { + "chargingProfileId": 8, + "stackLevel": 6, + "chargingProfileKind": "Relative", + "chargingProfilePurpose": "ChargePointMaxProfile", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16.0}], + }, + } + data = {"devid": cpid, "custom_profile": str(prof)} + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) - for number in NUMBERS: - # test setting value of number slider - await set_number(hass, cs, number.key, 10) + for number in NUMBERS: + # test setting value of number slider + await set_number(hass, cpid, number.key, 10) - # Test MOCK_CONFIG_DATA_2 + +test_services.__test__ = False + + +@pytest.fixture +async def setup_config_entry(hass, request) -> CentralSystem: + """Setup/teardown mock config entry and central system.""" # Create a mock entry so we don't have to go through config flow - config_entry2 = MockConfigEntry( + # Both version and minor need to match config flow so as not to trigger migration flow + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append( + {request.param["cp_id"]: MOCK_CONFIG_CP_APPEND.copy()} + ) + config_data[CONF_PORT] = request.param["port"] + config_entry = MockConfigEntry( domain=OCPP_DOMAIN, - data=MOCK_CONFIG_DATA_2, - entry_id="test_cms2", - title="test_cms2", + data=config_data, + entry_id=request.param["cms"], + title=request.param["cms"], + version=2, + minor_version=0, ) - config_entry2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry2.entry_id) - await hass.async_block_till_done() + yield await create_configuration(hass, config_entry) + # tear down + await remove_configuration(hass, config_entry) + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9001, "cp_id": "CP_1_nosub", "cms": "cms_nosub"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_nosub"]) +@pytest.mark.parametrize("port", [9001]) +async def test_cms_responses_nosub_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger with no subprotocol.""" # no subprotocol central system assumes ocpp1.6 charge point # NB each new config entry will trigger async_update_entry # if the charger measurands differ from the config entry # which causes the websocket server to close/restart with a # ConnectionClosedOK exception, hence it needs to be passed/suppressed - async with websockets.connect( - "ws://127.0.0.1:9002/CP_1_nosub", - ) as ws2: - # use a different id for debugging + + async with ( + websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", # this is the charger cp_id ie CP_1_nosub in the cs + ) as ws2 + ): assert ws2.subprotocol is None - cp2 = ChargePoint("CP_1_no_subprotocol", ws2) + # Note this mocks a real charger and is not the charger representation in the cs, which is accessed by cp_id + cp2 = ChargePoint( + f"{cp_id}_client", ws2 + ) # uses a different id for debugging, would normally be cp_id with contextlib.suppress( asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK ): @@ -155,38 +236,55 @@ async def test_services(hass, cs, socket_enabled): cp2.send_stop_transaction(), cp2.send_meter_periodic_data(), ), - timeout=5, + timeout=10, ) await ws2.close() - await asyncio.sleep(1) - if entry := hass.config_entries.async_get_entry(config_entry2.entry_id): - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms", title="test_cms" - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - cs = hass.data[OCPP_DOMAIN][config_entry.entry_id] +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9002, "cp_id": "CP_1_unsup", "cms": "cms_unsup"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_unsup"]) +@pytest.mark.parametrize("port", [9002]) +async def test_cms_responses_unsupp_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system unsupported protocol.""" # unsupported subprotocol raises websockets exception with pytest.raises(websockets.exceptions.InvalidStatus): await websockets.connect( - "ws://127.0.0.1:9000/CP_1_unsup", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp0.0"], ) - # test restore feature of meter_start and active_tranasction_id. + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9003, "cp_id": "CP_1_restore_values", "cms": "cms_restore_values"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_restore_values"]) +@pytest.mark.parametrize("port", [9003]) +async def test_cms_responses_restore_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system restoring values for a charger.""" + + cs = setup_config_entry + async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_res_vals", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: # use a different id for debugging - cp = ChargePoint("CP_1_restore_values", ws) + cp = ChargePoint(f"{cp_id}_client", ws) cp.active_transactionId = None # send None values with contextlib.suppress(asyncio.TimeoutError): @@ -197,28 +295,36 @@ async def test_services(hass, cs, socket_enabled): ), timeout=3, ) + # cpid set in cs after websocket connection + cpid = cs.charge_points[cp_id].settings.cpid + # check if None - assert cs.get_metric("test_cpid", "Energy.Meter.Start") is None - assert cs.get_metric("test_cpid", "Transaction.Id") is None + assert cs.get_metric(cpid, "Energy.Meter.Start") is None + assert cs.get_metric(cpid, "Transaction.Id") is None + # send new data with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( + cp.start(), cp.send_start_transaction(12344), cp.send_meter_periodic_data(), ), timeout=3, ) + # save for reference the values for meter_start and transaction_id - saved_meter_start = int(cs.get_metric("test_cpid", "Energy.Meter.Start")) - saved_transactionId = int(cs.get_metric("test_cpid", "Transaction.Id")) + saved_meter_start = int(cs.get_metric(cpid, "Energy.Meter.Start")) + saved_transactionId = int(cs.get_metric(cpid, "Transaction.Id")) + # delete current values from api memory - cs.del_metric("test_cpid", "Energy.Meter.Start") - cs.del_metric("test_cpid", "Transaction.Id") + cs.del_metric(cpid, "Energy.Meter.Start") + cs.del_metric(cpid, "Transaction.Id") # send new data with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( + cp.start(), cp.send_meter_periodic_data(), ), timeout=3, @@ -226,18 +332,33 @@ async def test_services(hass, cs, socket_enabled): await ws.close() # check if restored old values from HA when api have lost the values, i.e. simulated reboot of HA - assert int(cs.get_metric("test_cpid", "Energy.Meter.Start")) == saved_meter_start - assert int(cs.get_metric("test_cpid", "Transaction.Id")) == saved_transactionId + assert int(cs.get_metric(cpid, "Energy.Meter.Start")) == saved_meter_start + assert int(cs.get_metric(cpid, "Transaction.Id")) == saved_transactionId + - await asyncio.sleep(1) +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9004, "cp_id": "CP_1_norm", "cms": "cms_norm"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_norm"]) +@pytest.mark.parametrize("port", [9004]) +async def test_cms_responses_normal_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger under normal operation.""" + + cs = setup_config_entry # test ocpp messages sent from charger to cms async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_norm", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.5", "ocpp1.6"], ) as ws: # use a different id for debugging - cp = ChargePoint("CP_1_normal", ws) + cp = ChargePoint(f"{cp_id}_client", ws) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( @@ -256,20 +377,20 @@ async def test_services(hass, cs, socket_enabled): # add delay to allow meter data to be processed cp.send_stop_transaction(1), ), - timeout=5, + timeout=8, ) await ws.close() - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( + + cpid = cs.charge_points[cp_id].settings.cpid + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == int( 1305570 / 1000 ) - assert int(cs.get_metric("test_cpid", "Energy.Session")) == int( - (54321 - 12345) / 1000 - ) - assert int(cs.get_metric("test_cpid", "Current.Import")) == 0 - assert int(cs.get_metric("test_cpid", "Voltage")) == 228 - assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" - assert cs.get_ha_unit("test_cpid", "Power.Reactive.Import") == "var" - assert cs.get_unit("test_cpid", "Power.Reactive.Import") == "var" + assert int(cs.get_metric(cpid, "Energy.Session")) == int((54321 - 12345) / 1000) + assert int(cs.get_metric(cpid, "Current.Import")) == 0 + # assert int(cs.get_metric(cpid, "Voltage")) == 228 + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" + assert cs.get_ha_unit(cpid, "Power.Reactive.Import") == "var" + assert cs.get_unit(cpid, "Power.Reactive.Import") == "var" assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None @@ -288,45 +409,87 @@ async def test_services(hass, cs, socket_enabled): is False ) - await asyncio.sleep(1) + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9005, "cp_id": "CP_1_services", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_services"]) +@pytest.mark.parametrize("port", [9005]) +async def test_cms_responses_actions_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to actions and multi charger under normal operation.""" + # start clean entry for services + cs = setup_config_entry + # test ocpp messages sent from cms to charger, through HA switches/services # should reconnect as already started above # test processing of clock aligned meter data async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_serv", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: - cp = ChargePoint("CP_1_services", ws) + cp = ChargePoint(f"{cp_id}_client", ws) with contextlib.suppress(asyncio.TimeoutError): + cp_task = asyncio.create_task(cp.start()) + await asyncio.sleep(5) + # Allow charger time to connect bfore running services await asyncio.wait_for( asyncio.gather( - cp.start(), - cs.charge_points[cs.settings.cpid].trigger_boot_notification(), - cs.charge_points[cs.settings.cpid].trigger_status_notification(), - test_switches(hass, cs, socket_enabled), - test_services(hass, cs, socket_enabled), - test_buttons(hass, cs, socket_enabled), cp.send_meter_clock_data(), + cs.charge_points[cp_id].trigger_boot_notification(), + cs.charge_points[cp_id].trigger_status_notification(), + test_switches( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + test_services( + hass, + cs.charge_points[cp_id].settings.cpid, + SERVICES, + socket_enabled, + ), + test_buttons( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), ), timeout=5, ) + cp_task.cancel() await ws.close() - assert int(cs.get_metric("test_cpid", "Frequency")) == 50 - assert ( - float(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == 1101.452 - ) - await asyncio.sleep(1) + # cpid set in cs after websocket connection + cpid = cs.charge_points[cp_id].settings.cpid + + assert int(cs.get_metric(cpid, "Frequency")) == 50 + assert float(cs.get_metric(cpid, "Energy.Active.Import.Register")) == 1101.452 + + # add new charger to config entry + cp_id = "CP_1_non_er_3.9" + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + entry.data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "cpid2" + # reload required to setup new charger in HA, normally happens with discovery flow + assert await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[OCPP_DOMAIN][entry.entry_id] # test ocpp messages sent from charger that don't support errata 3.9 # i.e. "Energy.Meter.Start" starts from 0 for each session and "Energy.Active.Import.Register" # reports starting from 0 Wh for every new transaction id. Total main meter values are without transaction id. + async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_non_er_3.9", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: # use a different id for debugging - cp = ChargePoint("CP_1_non_errata_3.9", ws) + cp = ChargePoint(f"{cp_id}_client", ws) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( @@ -341,25 +504,24 @@ async def test_services(hass, cs, socket_enabled): ) await ws.close() + cpid = cs.charge_points[cp_id].settings.cpid # Last sent "Energy.Active.Import.Register" value without transaction id should be here. - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == int( 67230012 / 1000 ) - assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" # Last sent "Energy.Active.Import.Register" value with transaction id should be here. - assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(1305570 / 1000) - assert cs.get_unit("test_cpid", "Energy.Session") == "kWh" - - await asyncio.sleep(1) + assert int(cs.get_metric(cpid, "Energy.Session")) == int(1305570 / 1000) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" # test ocpp messages sent from charger that don't support errata 3.9 with meter values with kWh as energy unit async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_non_er_3.9", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: # use a different id for debugging - cp = ChargePoint("CP_1_non_errata_3.9", ws) + cp = ChargePoint(f"{cp_id}_client", ws) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.gather( @@ -374,43 +536,89 @@ async def test_services(hass, cs, socket_enabled): ) await ws.close() - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == 1101 - assert int(cs.get_metric("test_cpid", "Energy.Session")) == 11 - assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == 1101 + assert int(cs.get_metric(cpid, "Energy.Session")) == 11 + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9006, "cp_id": "CP_1_error", "cms": "cms_error"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_error"]) +@pytest.mark.parametrize("port", [9006]) +async def test_cms_responses_errors_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to actions and multi charger under error operation.""" + # start clean entry for services + cs = setup_config_entry # test ocpp rejection messages sent from charger to cms - cs.charge_points["test_cpid"].received_boot_notification = False - cs.charge_points["test_cpid"].post_connect_success = False + # use SERVICES_ERROR as only Core and Smart profiles enabled + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + cp = ChargePoint(f"{cp_id}_client", ws) + cp.accept = False + # Allow charger time to connect before running services + await asyncio.wait_for( + cp.start(), + timeout=5, + ) + await ws.close() + # if monitored variables differ cs will restart and charger needs to reconnect async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_error", + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: with contextlib.suppress( asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK ): - cp = ChargePoint("CP_1_error", ws) + cp = ChargePoint(f"{cp_id}_client", ws) cp.accept = False + cp_task = asyncio.create_task(cp.start()) + await asyncio.sleep(5) + # Allow charger time to reconnect bfore running services await asyncio.wait_for( asyncio.gather( - cp.start(), - cs.charge_points[cs.settings.cpid].trigger_boot_notification(), - cs.charge_points[cs.settings.cpid].trigger_status_notification(), - test_switches(hass, cs, socket_enabled), - test_services(hass, cs, socket_enabled), - test_buttons(hass, cs, socket_enabled), + cs.charge_points[cp_id].trigger_boot_notification(), + cs.charge_points[cp_id].trigger_status_notification(), + test_switches( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + test_services( + hass, + "xxx", # Test with incorrect devid supplied + SERVICES_ERROR, + socket_enabled, + ), + test_buttons( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), ), - timeout=3, + timeout=10, ) + await cs.charge_points[cp_id].stop() + cp_task.cancel() await ws.close() - await asyncio.sleep(1) - # test services when charger is unavailable - await asyncio.sleep(1) - await test_services(hass, cs, socket_enabled) - if entry := hass.config_entries.async_get_entry(config_entry.entry_id): - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await test_services( + hass, cs.charge_points[cp_id].settings.cpid, SERVICES_ERROR, socket_enabled + ) class ChargePoint(cpclass): @@ -468,7 +676,7 @@ def on_get_configuration(self, key, **kwargs): ] ) else: - raise Exception + pass if key[0] == ConfigurationKey.meter_value_sample_interval.value: if self.accept is True: return call_result.GetConfiguration( diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index b7073738..7cc1a9ff 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -7,6 +7,7 @@ from homeassistant.exceptions import HomeAssistantError from ocpp.v16.enums import Measurand +from custom_components.ocpp.const import CONF_CPIDS, CONF_CPID, DOMAIN from custom_components.ocpp import CentralSystem from custom_components.ocpp.enums import ( HAChargerDetails as cdet, @@ -24,12 +25,11 @@ remove_configuration, wait_ready, ) -from .const import MOCK_CONFIG_DATA +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_3, MOCK_CONFIG_CP_APPEND from custom_components.ocpp.const import ( DEFAULT_METER_INTERVAL, DOMAIN as OCPP_DOMAIN, CONF_PORT, - CONF_MONITORED_VARIABLES, MEASURANDS, ) import pytest @@ -410,12 +410,13 @@ async def _send_full_inventory(self, request_id: int): async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): - cpid: str = cs.settings.cpid + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid - await set_switch(hass, cs, "charge_control", True) + await set_switch(hass, cpid, "charge_control", True) assert len(cp.remote_starts) == 1 assert cp.remote_starts[0].id_token == { - "id_token": cs.charge_points[cpid]._remote_id_tag, + "id_token": cs.charge_points[cs.cpids[cpid]]._remote_id_tag, "type": IdTokenEnumType.central.value, } while cs.get_metric(cpid, csess.transaction_id.value) is None: @@ -649,7 +650,7 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo assert cs.get_metric(cpid, csess.session_energy) == 0.156 assert cs.get_metric(cpid, csess.session_time) == 1 - await set_switch(hass, cs, "charge_control", False) + await set_switch(hass, cpid, "charge_control", False) assert len(cp.remote_stops) == 1 await cp.call( @@ -803,11 +804,13 @@ async def _set_variable( ) -> tuple[ServiceResponse, HomeAssistantError]: response: ServiceResponse | None = None error: HomeAssistantError | None = None + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid try: response = await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_configure_v201, - service_data={"ocpp_key": key, "value": value}, + DOMAIN, + csvcs.service_configure, + service_data={"devid": cpid, "ocpp_key": key, "value": value}, blocking=True, return_response=True, ) @@ -821,11 +824,13 @@ async def _get_variable( ) -> tuple[ServiceResponse, HomeAssistantError]: response: ServiceResponse | None = None error: HomeAssistantError | None = None + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid try: response = await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_get_configuration_v201, - service_data={"ocpp_key": key}, + DOMAIN, + csvcs.service_get_configuration, + service_data={"devid": cpid, "ocpp_key": key}, blocking=True, return_response=True, ) @@ -902,7 +907,7 @@ async def _set_charge_rate_service( ) -> HomeAssistantError: try: await hass.services.async_call( - OCPP_DOMAIN, + DOMAIN, csvcs.service_set_charge_rate, service_data=data, blocking=True, @@ -915,9 +920,13 @@ async def _set_charge_rate_service( async def _test_charge_profiles( hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint ): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + error: HomeAssistantError = await _set_charge_rate_service( - hass, {"limit_watts": 3000} + hass, {"devid": cpid, "limit_watts": 3000} ) + assert error is None assert len(cp.charge_profiles_set) == 1 assert cp.charge_profiles_set[-1].evse_id == 0 @@ -935,7 +944,7 @@ async def _test_charge_profiles( ], } - error = await _set_charge_rate_service(hass, {"limit_amps": 16}) + error = await _set_charge_rate_service(hass, {"devid": cpid, "limit_amps": 16}) assert error is None assert len(cp.charge_profiles_set) == 2 assert cp.charge_profiles_set[-1].evse_id == 0 @@ -956,6 +965,7 @@ async def _test_charge_profiles( error = await _set_charge_rate_service( hass, { + "devid": cpid, "custom_profile": """{ 'id': 2, 'stack_level': 1, @@ -966,7 +976,7 @@ async def _test_charge_profiles( 'charging_rate_unit': 'A', 'charging_schedule_period': [{'start_period': 0, 'limit': 6}] }] - }""" + }""", }, ) assert error is None @@ -986,7 +996,7 @@ async def _test_charge_profiles( ], } - await set_number(hass, cs, "maximum_current", 12) + await set_number(hass, cpid, "maximum_current", 12) assert len(cp.charge_profiles_set) == 4 assert cp.charge_profiles_set[-1].evse_id == 0 assert cp.charge_profiles_set[-1].charging_profile == { @@ -1003,12 +1013,12 @@ async def _test_charge_profiles( ], } - error = await _set_charge_rate_service(hass, {"limit_amps": 5}) + error = await _set_charge_rate_service(hass, {"devid": cpid, "limit_amps": 5}) assert error is not None assert str(error).startswith("Failed to set variable: Rejected") assert len(cp.charge_profiles_cleared) == 0 - await set_number(hass, cs, "maximum_current", 32) + await set_number(hass, cpid, "maximum_current", 32) assert len(cp.charge_profiles_cleared) == 1 assert cp.charge_profiles_cleared[-1].charging_profile_id is None assert cp.charge_profiles_cleared[-1].charging_profile_criteria == { @@ -1040,12 +1050,14 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): heartbeat_resp: call_result.Heartbeat = await cp.call(call.Heartbeat()) datetime.fromisoformat(heartbeat_resp.current_time) - await wait_ready(hass) + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + await wait_ready(cs.charge_points[cp_id]) # Junk report to be ignored await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) - cpid: str = cs.settings.cpid assert cs.get_metric(cpid, cdet.serial.value) == "SERIAL" assert cs.get_metric(cpid, cdet.model.value) == "MODEL" assert cs.get_metric(cpid, cdet.vendor.value) == "VENDOR" @@ -1069,7 +1081,7 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): await _test_services(hass, cs, cp) await _test_charge_profiles(hass, cs, cp) - await press_button(hass, cs, "reset") + await press_button(hass, cpid, "reset") assert len(cp.resets) == 1 assert cp.resets[0].type == ResetEnumType.immediate.value assert cp.resets[0].evse_id is None @@ -1077,13 +1089,13 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): error: HomeAssistantError = None cp.accept_reset = False try: - await press_button(hass, cs, "reset") + await press_button(hass, cpid, "reset") except HomeAssistantError as e: error = e assert error is not None assert str(error) == "OCPP call failed: Rejected" - await set_switch(hass, cs, "availability", False) + await set_switch(hass, cpid, "availability", False) assert not cp.operative await cp.call( call.StatusNotification( @@ -1135,6 +1147,9 @@ async def _extra_features_test( cs: CentralSystem, cp: ChargePointAllFeatures, ): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + await cp.call( call.BootNotification( { @@ -1146,10 +1161,13 @@ async def _extra_features_test( BootReasonEnumType.power_up.value, ) ) - await wait_ready(hass) + await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric(cs.settings.cpid, cdet.features.value) + cs.get_metric( + cpid, + cdet.features.value, + ) == Profiles.CORE | Profiles.SMART | Profiles.RES @@ -1185,6 +1203,9 @@ async def _unsupported_base_report_test( cs: CentralSystem, cp: ChargePoint, ): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + await cp.call( call.BootNotification( { @@ -1196,14 +1217,17 @@ async def _unsupported_base_report_test( BootReasonEnumType.power_up.value, ) ) - await wait_ready(hass) + await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric(cs.settings.cpid, cdet.features.value) + cs.get_metric( + cpid, + cdet.features.value, + ) == Profiles.CORE | Profiles.REM | Profiles.FW ) -@pytest.mark.timeout(90) +@pytest.mark.timeout(150) async def test_cms_responses_v201(hass, socket_enabled): """Test central system responses to a charger.""" @@ -1211,50 +1235,88 @@ async def test_cms_responses_v201(hass, socket_enabled): # restarts if measurands reported by the charger differ from the list # from the configuration, which a real charger can deal with but this # test cannot + # config_data[CONF_MONITORED_VARIABLES] = ",".join(supported_measurands) + cp_id = "CP_2" config_data = MOCK_CONFIG_DATA.copy() - config_data[CONF_MONITORED_VARIABLES] = ",".join(supported_measurands) + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" config_data[CONF_PORT] = 9010 + config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_cms", + title="test_v201_cms", + version=2, + minor_version=0, ) cs: CentralSystem = await create_configuration(hass, config_entry) # threading in async validation causes tests to fail ocpp.messages.ASYNC_VALIDATION = False await run_charge_point_test( config_entry, - "CP_2", + cp_id, ["ocpp2.0.1"], lambda ws: ChargePoint("CP_2_client", ws), [lambda cp: _run_test(hass, cs, cp)], ) + # add second charger to config entry + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + cp_id2 = "CP_2_allfeatures" + entry.data[CONF_CPIDS].append({cp_id2: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id2][CONF_CPID] = "test_v201_cpid2" + # need to reload to setup sensors etc for new charger + await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[DOMAIN][entry.entry_id] + await run_charge_point_test( config_entry, - "CP_2_allfeatures", + cp_id2, ["ocpp2.0.1"], lambda ws: ChargePointAllFeatures("CP_2_allfeatures_client", ws), [lambda cp: _extra_features_test(hass, cs, cp)], ) await remove_configuration(hass, config_entry) - config_data[CONF_MONITORED_VARIABLES] = "" + + cp_id = "CP_2_noreport" + config_data = MOCK_CONFIG_DATA_3.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_data[CONF_PORT] = 9011 + config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_cms", + title="test_v201_cms", + version=2, + minor_version=0, ) cs = await create_configuration(hass, config_entry) await run_charge_point_test( config_entry, - "CP_2_noreport", + cp_id, ["ocpp2.0.1"], lambda ws: ChargePointReportUnsupported("CP_2_noreport_client", ws), [lambda cp: _unsupported_base_report_test(hass, cs, cp)], ) + cp_id2 = "CP_2_report_fail" + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + entry.data[CONF_CPIDS].append({cp_id2: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id2][CONF_CPID] = "test_v201_cpid2" + # need to reload to setup sensors etc for new charger + await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[DOMAIN][entry.entry_id] + await run_charge_point_test( config_entry, - "CP_2_report_fail", + cp_id2, ["ocpp2.0.1"], lambda ws: ChargePointReportFailing("CP_2_report_fail_client", ws), [lambda cp: _unsupported_base_report_test(hass, cs, cp)], diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e3d7d548..745c86c4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,16 +2,21 @@ from unittest.mock import patch +from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant import config_entries, data_entry_flow +from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import ( # BINARY_SENSOR,; PLATFORMS,; SENSOR,; SWITCH, - DOMAIN, -) - -from .const import MOCK_CONFIG, MOCK_CONFIG_DATA +from custom_components.ocpp.const import DOMAIN -# from pytest_homeassistant_custom_component.common import MockConfigEntry +from .const import ( + MOCK_CONFIG_CS, + MOCK_CONFIG_CP, + MOCK_CONFIG_FLOW, + CONF_CPIDS, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_MONITORED_VARIABLES, +) # This fixture bypasses the actual setup of the integration @@ -47,42 +52,155 @@ async def test_successful_config_flow(hass, bypass_get_data): assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - # If a user were to enter `test_username` for username and `test_password` - # for password, it would result in this function call + # Remove cpids key as it gets added in flow + config = MOCK_CONFIG_CS.copy() + config.pop(CONF_CPIDS) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG + result["flow_id"], user_input=config ) # Check that the config flow is complete and a new entry is created with # the input data assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "test_csid" - assert result["data"] == MOCK_CONFIG_DATA + assert result["title"] == "test_csid_flow" + assert result["data"] == MOCK_CONFIG_CS assert result["result"] -# In this case, we want to simulate a failure during the config flow. -# We use the `error_on_get_data` mock instead of `bypass_get_data` -# (note the function parameters) to raise an Exception during -# validation of the input config. -# async def test_failed_config_flow(hass, error_on_get_data): -# """Test a failed config flow due to credential validation failure.""" -# -# result = await hass.config_entries.flow.async_init( -# DOMAIN, context={"source": config_entries.SOURCE_USER} -# ) -# -# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -# assert result["step_id"] == "user" -# -# result = await hass.config_entries.flow.async_configure( -# result["flow_id"], user_input=MOCK_CONFIG -# ) -# -# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -# assert result["errors"] == {"base": "auth"} -# -# +async def test_successful_discovery_flow(hass, bypass_get_data): + """Test a discovery config flow.""" + # Mock the config flow for the central system + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_CS, + entry_id="test_cms_disc", + title="test_cms_disc", + version=2, + minor_version=0, + ) + # Need to ensure data entry exists as skipped init.py setup + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + info = {"cp_id": "test_cp_id", "entry": entry} + # data here is discovery_info not user_input + result_disc = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + + # Check that the config flow shows the user form as the first step + assert result_disc["type"] == data_entry_flow.FlowResultType.FORM + assert result_disc["step_id"] == "cp_user" + result_disc["discovery_info"] = info + + # Switch to manual measurand selection to test full flow + cp_input = MOCK_CONFIG_CP.copy() + cp_input[CONF_MONITORED_VARIABLES_AUTOCONFIG] = False + result_cp = await hass.config_entries.flow.async_configure( + result_disc["flow_id"], user_input=cp_input + ) + + measurand_input = {value: True for value in DEFAULT_MONITORED_VARIABLES.split(",")} + result_meas = await hass.config_entries.flow.async_configure( + result_cp["flow_id"], user_input=measurand_input + ) + + # Check that the config flow is complete and a new entry is created with + # the input data + flow_output = MOCK_CONFIG_FLOW.copy() + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( + False + ) + assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + assert entry.data == flow_output + + # Test different CP IDs are allowed + info2 = {"cp_id": "different_cp_id", "entry": entry} + result2_disc = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info2, + ) + # Check that the config flow shows the user form as the first step + assert result2_disc["type"] == data_entry_flow.FlowResultType.FORM + assert result2_disc["step_id"] == "cp_user" + result2_disc["discovery_info"] = info2 + + cp2_input = MOCK_CONFIG_CP.copy() + result_cp2 = await hass.config_entries.flow.async_configure( + result2_disc["flow_id"], user_input=cp2_input + ) + + assert result_cp2["type"] == data_entry_flow.FlowResultType.ABORT + # Check there are 2 cpid entries + assert len(entry.data[CONF_CPIDS]) == 2 + + +async def test_duplicate_cpid_discovery_flow(hass, bypass_get_data): + """Test discovery flow with duplicate CP ID.""" + # Setup first charger + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_CS, + entry_id="test_cms_disc", + title="test_cms_disc", + version=2, + ) + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Try to add same CP ID twice + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + info = {"cp_id": "test_cp_id", "entry": entry} + + # First discovery should succeed + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Second discovery with same CP ID should abort + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "already_in_progress" + + +async def test_failed_config_flow(hass, error_on_get_data): + """Test failed config flow scenarios.""" + # Test invalid central system configuration + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + # Test with invalid input data, includes cpids + invalid_config = MOCK_CONFIG_CS.copy() + + with pytest.raises(InvalidData): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=invalid_config + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + + # # Our config flow also has an options flow, so we must test it as well. # async def test_options_flow(hass): # """Test an options flow.""" diff --git a/tests/test_init.py b/tests/test_init.py index 0c6dd6c0..965b75f7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,7 +10,7 @@ from custom_components.ocpp import CentralSystem from custom_components.ocpp.const import DOMAIN -from .const import MOCK_CONFIG_DATA_1 +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_1, MOCK_CONFIG_MIGRATION_FLOW # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -24,7 +24,12 @@ async def test_setup_unload_and_reload_entry( """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG_DATA_1, entry_id="test_cms1", title="test_cms1" + domain=DOMAIN, + data=MOCK_CONFIG_DATA_1, + entry_id="test_cms1", + title="test_cms1", + version=2, + minor_version=0, ) config_entry.add_to_hass(hass) await hass.async_block_till_done() @@ -49,6 +54,41 @@ async def test_setup_unload_and_reload_entry( assert config_entry.entry_id not in hass.data[DOMAIN] +async def test_migration_entry( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): + """Test entry migration.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_MIGRATION_FLOW, + entry_id="test_migration", + title="test_migration", + version=1, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data + # call, no code from custom_components/ocpp/api.py actually runs. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + # check migration has created new entry with correct keys + assert config_entry.data.keys() == MOCK_CONFIG_DATA.keys() + # check versions match + assert config_entry.version == 2 + assert config_entry.minor_version == 0 + + # Unload the entry and verify that the data has been removed + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[DOMAIN] + + # async def test_setup_entry_exception(hass, error_on_get_data): # """Test ConfigEntryNotReady when API raises an exception during entry setup.""" # config_entry = MockConfigEntry( diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ec921e7f..67d4ee56 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,5 +1,7 @@ """Test sensor for ocpp integration.""" +import asyncio +import websockets from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN @@ -10,26 +12,54 @@ SensorStateClass, ATTR_STATE_CLASS, ) -from .const import MOCK_CONFIG_DATA +from .const import ( + MOCK_CONFIG_DATA, + CONF_CPIDS, + MOCK_CONFIG_CP_APPEND, + CONF_PORT, + CONF_CPID, +) +from .charge_point_test import create_configuration, remove_configuration async def test_sensor(hass, socket_enabled): """Test sensor.""" + + cp_id = "CP_1_sens" + cpid = "test_cpid_sens" + data = MOCK_CONFIG_DATA.copy() + cp_data = MOCK_CONFIG_CP_APPEND.copy() + cp_data[CONF_CPID] = cpid + data[CONF_CPIDS].append({cp_id: cp_data}) + data[CONF_PORT] = 9015 config_entry = MockConfigEntry( domain=OCPP_DOMAIN, - data=MOCK_CONFIG_DATA, - entry_id="test_cms", - title="test_cms", + data=data, + entry_id="test_cms_sens", + title="test_cms_sens", + version=2, + minor_version=0, ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - # Test reactive power sensor - state = hass.states.get("sensor.test_cpid_power_reactive_import") - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.REACTIVE_POWER - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - # Test reactive energx sensor, not having own device class yet - state = hass.states.get("sensor.test_cpid_energy_reactive_import_register") - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None + + # start clean entry for server + await create_configuration(hass, config_entry) + + # connect to websocket to trigger charger setup + async with websockets.connect( + f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", + subprotocols=["ocpp1.6"], + ): + # Wait for setup to complete + await asyncio.sleep(1) + # Test reactive power sensor + state = hass.states.get(f"sensor.{cpid}_power_reactive_import") + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.REACTIVE_POWER + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + # Test reactive energy sensor, not having own device class yet + state = hass.states.get(f"sensor.{cpid}_energy_reactive_import_register") + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None + + await remove_configuration(hass, config_entry) From 43d0e2dc45ec6a755903ac85c17c25e3bb54383f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:54:59 +0100 Subject: [PATCH 248/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.203 to 0.13.208 (#1506) * build(deps): bump pytest-homeassistant-custom-component Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.203 to 0.13.208. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.203...0.13.208) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump to python 3.13 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .github/workflows/tests.yaml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index adf9f1ac..5ad7358e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ on: pull_request: env: - DEFAULT_PYTHON: "3.12.3" + DEFAULT_PYTHON: "3.13" jobs: pre-commit: diff --git a/requirements.txt b/requirements.txt index bdb75b5a..ee664bca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 pre-commit==4.0.1 -pytest-homeassistant-custom-component==0.13.203 +pytest-homeassistant-custom-component==0.13.208 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 52f53aed7e6812ab26ab13949b17cab51f3b27c6 Mon Sep 17 00:00:00 2001 From: altserg <79573763+altserg@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:06:27 +0100 Subject: [PATCH 249/370] Fixed #1452 (#1476) * Fixed #1452 * Fixed #1452, fixed linting issues --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/ocppv16.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 3e4950b7..b4f12695 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -222,7 +222,14 @@ async def trigger_status_notification(self): resp = await self.call(req) if resp.status != TriggerMessageStatus.accepted: _LOGGER.warning("Failed with response: %s", resp.status) - return_value = False + _LOGGER.warning( + "Forcing number of connectors to %d, charger returned %d", + id - 1, + nof_connectors, + ) + self._metrics[cdet.connectors.value].value = max(1, id - 1) + return_value = id > 1 + break return return_value async def clear_profile(self): From b9d722d35f6f3cb4eb0d395b917486004c3e4b1e Mon Sep 17 00:00:00 2001 From: altserg <79573763+altserg@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:11:46 +0100 Subject: [PATCH 250/370] Fixed #1466 (#1477) * Fixed #1466 * Fixed #1466, fixed linting issues * Attept to pass the automatic test suite * More elegant way to fix the issue. Now the Active.Import.Register sensor also gets updated * 1. Updated the fix to pass the automatic test system. 2. Fixed linting. --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- custom_components/ocpp/chargepoint.py | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index b501484c..db024624 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -23,7 +23,13 @@ from ocpp.charge_point import ChargePoint as cp from ocpp.v16 import call as callv16 from ocpp.v16 import call_result as call_resultv16 -from ocpp.v16.enums import UnitOfMeasure, AuthorizationStatus, Measurand, Phase +from ocpp.v16.enums import ( + UnitOfMeasure, + AuthorizationStatus, + Measurand, + Phase, + ReadingContext, +) from ocpp.v201 import call as callv201 from ocpp.v201 import call_result as call_resultv201 from ocpp.messages import CallError @@ -626,15 +632,19 @@ def process_measurands( measurand == DEFAULT_MEASURAND and self._charger_reports_session_energy ): - if is_transaction: - self._metrics[csess.session_energy.value].value = value - self._metrics[csess.session_energy.value].unit = unit - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value + # Ignore messages with Transaction Begin context + if context != ReadingContext.transaction_begin.value: + if is_transaction: + self._metrics[csess.session_energy.value].value = value + self._metrics[csess.session_energy.value].unit = unit + self._metrics[csess.session_energy.value].extra_attr[ + cstat.id_tag.name + ] = self._metrics[cstat.id_tag.value].value + else: + self._metrics[measurand].value = value + self._metrics[measurand].unit = unit else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit + continue else: self._metrics[measurand].value = value self._metrics[measurand].unit = unit From 34310f0650b2ad1011b4feaa4fe220b8bf1ef05e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:35:53 +0100 Subject: [PATCH 251/370] build(deps): bump release-drafter/release-drafter from 6.0.0 to 6.1.0 (#1493) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.0.0 to 6.1.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v6.0.0...v6.1.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index fc85faf2..c391fea5 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v6.0.0 + uses: release-drafter/release-drafter@v6.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 42c4f499dd50307f2b9c280fc09cc4b2ef0faae1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:36:19 +0100 Subject: [PATCH 252/370] build(deps): bump pre-commit from 4.0.1 to 4.1.0 (#1494) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.0.1 to 4.1.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.0.1...v4.1.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ee664bca..2baf6fd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.9.2 ocpp==2.0.0 websockets==14.1 jsonschema==4.23.0 -pre-commit==4.0.1 +pre-commit==4.1.0 pytest-homeassistant-custom-component==0.13.208 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 9c51a1f3af026fca61b0375d032d45a01af5e604 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:38:45 +0100 Subject: [PATCH 253/370] build(deps): bump actions/setup-python from 5.3.0 to 5.4.0 (#1502) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a12a32d6..c4ba47b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5ad7358e..f298b910 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.3.0" + uses: "actions/setup-python@v5.4.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From f3d4732b650c4277a0be84fa6848820c3099b868 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:39:17 +0100 Subject: [PATCH 254/370] build(deps): bump crazy-max/ghaction-github-labeler from 5.0.0 to 5.2.0 (#1507) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.0.0 to 5.2.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v5.0.0...v5.2.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 566db778..7e4ab791 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.0.0 + uses: crazy-max/ghaction-github-labeler@v5.2.0 with: skip-delete: true From 0eda93eed8b329c2c88159026628b81bd71442fc Mon Sep 17 00:00:00 2001 From: Marc Smaak Date: Sun, 2 Mar 2025 10:42:22 +0100 Subject: [PATCH 255/370] Charge automation (#1496) * Create Charge_automation.md Initial draft * Update Charge_automation.md extended with smart meter input * Update Charge_automation.md Updated after coderabit check --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/Charge_automation.md | 106 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/Charge_automation.md diff --git a/docs/Charge_automation.md b/docs/Charge_automation.md new file mode 100644 index 00000000..9f9fbeed --- /dev/null +++ b/docs/Charge_automation.md @@ -0,0 +1,106 @@ +## Dynamically Adjusting EV Charge Current: A Smart Approach + +Dynamically adjusting the charge current of an electric vehicle (EV) within a home automation system offers significant advantages: + +* **Preventing Overload:** + * By monitoring real-time energy consumption, you can automatically reduce the EV's charging rate to prevent overloading the household's electrical circuits and potentially tripping the main fuse. + +* **Optimizing Solar Power Usage:** + * When the solar panel production is available in Home Assistant, you can prioritize charging the EV with excess solar energy. + +* **Demand Response:** + * When you have dynamic energy pricing, you can adjust charging rates based on time-of-use electricity pricing. + +This page provides several examples and hints to illustrate some of the many potential use cases." + +## Adjusting the charge current + +When the OCPP integration is added to your Home Assistant, you get a slider to control the maximum charge current named: +number._maximum_current + +While using this entity in your automation might seem logical, it could potentially lead to permanent damage to your charger in the long run. +This entity controls the OCPP ChargePointMaxProfile, which configures the maximum power or current available for the entire charging station. +This setting is typically written to non-volatile storage (like EEPROM or flash memory) to persist across reboots. +Frequent writes to these types of memory can accelerate wear, potentially shortening the lifespan of your charger. Ten updates per day is no problem at all, 1 update per 10s could break your charger somewhere between 3 days and 3 years depending on the HW solution. + +⚠️ **Warning**: Using the maximum current slider in automations can lead to permanent hardware damage due to frequent writes to non-volatile memory. + +### TXprofile + +Therefore, it is better to utilize a specific profile that is active exclusively during the current charging session. This approach allows you to adjust the charge current downwards while still respecting the upper limit defined by the ChargePointMaxProfile. + +Essentially, the slider in your GUI maintains control over the absolute maximum current the charger can utilize. + +To dynamically set the session-specific charge current within an automation, use the following action code snippet: + + - action: ocpp.set_charge_rate + data: + custom_profile: | + { + "transactionId": {{ states('sensor._transaction_id') | int }}, + "chargingProfileId": 1, + "stackLevel": 0, + "chargingProfilePurpose": "TxProfile", + "chargingProfileKind": "Relative", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [ + {"startPeriod": 0, "limit": {{ states('entity_charge_limit') | int }}} + ] + } + } + conn_id: 1 + + +Where entity_charge_limit refers to your chosen entity (e.g., a number or sensor) that holds the desired current value. + +## solar current +The solar system usually reports its production in Watts or kW. To convert this to amps available for your EV charger, simply divide the watts by the mains voltage (e.g., 230V for the EU) + +You can create a template sensor for this: + + - platform: template + sensors: + solaredge_amps: + value_template: "{{ (states('sensor.solaredge_power') | round(0, 'floor')| float) / 230 }}" + unit_of_measurement: 'A' + device_class: current + +This sensor contains the solar current in amps rounded down to the nearest whole number. It could be directly used in the action above. + +## Smart-meter +The paragraph above suggests that nearly all the solar current is prioritized for the EV charger. However, this can lead to situations where other high-demand appliances, such as a washing machine or hot tub, still draw power from the grid even when solar energy is available. + +To avoid this, you can use the data provided by your smart meter sensors. By integrating smart meter data into your home automation system, you can dynamically adjust the EV charging rate based on real-time energy consumption. This ensures that the EV primarily charges using excess solar power while minimizing reliance on grid electricity during periods of high household demand.you might have it as power. + +For this it is important to know the current you deliver or receive from the grid. Depending on you smart meter sensors you might have this current available in a sensor or you might have this as power. The example below uses power to and from the grid and converts it to a current which is negative when receiving from the grid. +In this example the power is in kW and the current is calculated using at actual mains voltage. +This template sensor gives the right value: + + - platform: template + sensors: + grid_current_available: # Calculate the current still available to use, can be negative + value_template: >- + {{((states('sensor.p1_meter_p1_returned') | float - states('sensor.p1_meter_p1_power') | float )*1000 + / (states('sensor.p1_meter_p1_voltage') | float )) }} + unit_of_measurement: 'A' + device_class: current + +For solar charging a positive grid current avaiable means you can increase your EV charge number with this number, when negative you need to decrease. This means you need to know the actual charge current and modify this. To do this easily you can use a variable inside your automation to store the actual charge current. + + variables: + actual_charge_current: "{{ (states('sensor.charge_current') | float) }}" + new_amps: "{{ actual_charge_current + (states('sensor.grid_current_available') | float) }} + +This could lead to a negative charge current, to avoid this create a new variable with a minimum value: + + charge_current: "{{ [new_amps, 0] | max }}" + +The `max` filter ensures the charge current never goes below 0 amps, which would be invalid for the charging station. + +## maximum charge +A simular solution could be used to check how much power is still available from the grid substracting all power used by other appliances in your house. This way you can charge you EV as fast as possible without overloading your main fuse. + +:exclamation: By specificatiomn your main fuse can withand 1.2 its rate current for at least 1 hour + +So a response time up to a minute is usually no problem if the overload is limited. From d40f92c986306fcdecada7249c81036572b00a5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:43:00 +0100 Subject: [PATCH 256/370] build(deps): bump actions/upload-artifact from 4.5.0 to 4.6.1 (#1526) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.5.0 to 4.6.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.5.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4ba47b2..2d84d86b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.1 if: ${{ github.event_name == 'push' }} with: name: ocpp From 73b7cbcab8164b4a2c1ac4a49d035a30821a760d Mon Sep 17 00:00:00 2001 From: BJReplay <37993507+BJReplay@users.noreply.github.com> Date: Sun, 2 Mar 2025 20:44:40 +1100 Subject: [PATCH 257/370] Add EN+ Caro (#1519) * Add EN+ Caro * Update supported-devices.md coderabbitai suggested changes. * Update supported-devices.md Added note that voltage appears to be a figure captured once, rather than reported as it fluctuates: --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- docs/supported-devices.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 8da5d4d2..66e6af6a 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -53,6 +53,32 @@ This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC ( ## [CTEK Chargestorm Connected 2](https://www.ctek.com/uk/ev-charging/chargestorm%C2%AE-connected-2) [Jonas Karlsson](https://github.com/jonasbkarlsson) has written a [getting started guide](https://github.com/jonasbkarlsson/ocpp/wiki/CTEK-Chargestorm-Connected-2) for connecting CTEK Chargestorm Connected 2. +## [EN+ Caro Series Home Wallbox](https://www.en-plustech.com/product/caro-series-wallbox/) +Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant. If required, re-configure this with the correct Charge point identity (by removing and re-adding the integration) to change from the default `charger` Charge point identity before configuring the charger. + +Connect to the charger's access point (AP) by powering down the charger (i.e. switch off the charger's isolator or circuit breaker) and powering it back on a few seconds later. The charger's access point becomes available for 15 minutes, and the SSID matches the charger's serial number (starting with SN). Log in to the configuration interface on the IP address 192.168.4.1. + +If doing this from a phone, you may need to set the phone to _Flight Mode_ first, and enable WiFi if required to enable the rest of the configuration to complete. + +The username and password for the web interface are provided in the charger manual (case sensitive). + +Configure the network mode to WiFi or Ethernet, and (in the field with an icon that looks like a router with three aerials) enter the address of your Home Assistant server including the port and protocol (e.g. `ws://myhomeassistant.tld:9000` or `wss://myhomeassistant.tld:9000` if you are using secure sockets). + +The charger user interface will append the serial number when you leave the field - this is correct and expected. + +Save, and the charger will reboot. + +Reconnect to the charger's SSID, and log in again to 192.168.4.1 to confirm that the Network Status is online. This confirms that the charger has an internet connection via Ethernet or WiFi and is connected to your OCPP server in Home Assistant. Once enabled, the charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. + +Even though the device accepts all measurands, the key working ones are + - `Current.Import` + - `Current.Offered` + - `Energy.Active.Import.Register` + - `Voltage` - although this shows a constant voltage, so should be considered spurious. + - `Transaction.ID` + +You may wish to disable sensors that show Unknown after you've completed a charging session, as they will never provide data with the current firmware 1.4.859. + ## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the authorization type to either `Central system only` or `Charger whitelist and central system` otherwise the OCPP integration won't From e8e34ce04262411e3d2787a3b8f263cea78cff8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:48:29 +0100 Subject: [PATCH 258/370] build(deps): bump websockets from 14.1 to 15.0 (#1515) Bumps [websockets](https://github.com/python-websockets/websockets) from 14.1 to 15.0. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/14.1...15.0) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2baf6fd3..43545d23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.9.0 uv>=0.4 ruff==0.9.2 ocpp==2.0.0 -websockets==14.1 +websockets==15.0 jsonschema==4.23.0 pre-commit==4.1.0 pytest-homeassistant-custom-component==0.13.208 From 3aa3b10f6eef318dd9d6274df8050ebfbdd94785 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sun, 2 Mar 2025 22:52:44 +1300 Subject: [PATCH 259/370] remove extra post_connect calls (#1527) * remove extra post_connect calls * fix tests --------- Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> --- .devcontainer.json | 2 +- custom_components/ocpp/chargepoint.py | 9 ++------- tests/test_charge_point_v16.py | 15 +++++++++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index 476f704c..c2f0edcd 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "lbbrhzn/ocpp", - "image": "mcr.microsoft.com/devcontainers/python:3.12", + "image": "mcr.microsoft.com/devcontainers/python:3.13", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index db024624..89be9b5e 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -224,7 +224,6 @@ async def post_connect(self): try: self.status = STATE_OK - await asyncio.sleep(2) await self.fetch_supported_features() num_connectors: int = await self.get_number_of_connectors() self._metrics[cdet.connectors.value].value = num_connectors @@ -389,9 +388,7 @@ async def _handle_call(self, msg): async def start(self): """Start charge point.""" - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) + await self.run([super().start(), self.monitor_connection()]) async def run(self, tasks): """Run a specified list of tasks.""" @@ -430,9 +427,7 @@ async def reconnect(self, connection: ServerConnection): if self.post_connect_success is True: await self.run([super().start(), self.monitor_connection()]) else: - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) + await self.run([super().start(), self.monitor_connection()]) async def async_update_device_info( self, serial: str, vendor: str, model: str, firmware_version: str diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 76d641ee..baf59795 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -51,6 +51,7 @@ set_number, create_configuration, remove_configuration, + wait_ready, ) SERVICES = [ @@ -307,6 +308,7 @@ async def test_cms_responses_restore_v16( await asyncio.wait_for( asyncio.gather( cp.start(), + cp.send_boot_notification(), cp.send_start_transaction(12344), cp.send_meter_periodic_data(), ), @@ -325,6 +327,7 @@ async def test_cms_responses_restore_v16( await asyncio.wait_for( asyncio.gather( cp.start(), + cp.send_boot_notification(), cp.send_meter_periodic_data(), ), timeout=3, @@ -436,8 +439,9 @@ async def test_cms_responses_actions_v16( cp = ChargePoint(f"{cp_id}_client", ws) with contextlib.suppress(asyncio.TimeoutError): cp_task = asyncio.create_task(cp.start()) - await asyncio.sleep(5) - # Allow charger time to connect bfore running services + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # Confirm charger completed post_connect before running services await asyncio.wait_for( asyncio.gather( cp.send_meter_clock_data(), @@ -494,6 +498,7 @@ async def test_cms_responses_actions_v16( await asyncio.wait_for( asyncio.gather( cp.start(), + cp.send_boot_notification(), cp.send_start_transaction(0), cp.send_meter_periodic_data(), cp.send_main_meter_clock_data(), @@ -526,6 +531,7 @@ async def test_cms_responses_actions_v16( await asyncio.wait_for( asyncio.gather( cp.start(), + cp.send_boot_notification(), cp.send_start_transaction(0), cp.send_meter_energy_kwh(), cp.send_meter_clock_data(), @@ -585,8 +591,9 @@ async def test_cms_responses_errors_v16( cp = ChargePoint(f"{cp_id}_client", ws) cp.accept = False cp_task = asyncio.create_task(cp.start()) - await asyncio.sleep(5) - # Allow charger time to reconnect bfore running services + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # Confirm charger completed post_connect before running services await asyncio.wait_for( asyncio.gather( cs.charge_points[cp_id].trigger_boot_notification(), From 5dd4dd3c24fb74302b66be184abbabe6b4c88ed6 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:58:37 +1300 Subject: [PATCH 260/370] add post connect backstop, fix ssl references (#1530) --- custom_components/ocpp/__init__.py | 7 +++++-- custom_components/ocpp/api.py | 4 ++-- custom_components/ocpp/chargepoint.py | 15 ++++++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index bad84d53..7c1e689b 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -137,7 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_migrate_entry(hass, config_entry: ConfigEntry): """Migrate old entry.""" - _LOGGER.debug( + _LOGGER.info( "Migrating configuration from version %s.%s", config_entry.version, config_entry.minor_version, @@ -182,6 +182,9 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): new_data = csid_data cp_id = hass.states.get(f"sensor.{cpid_data[CONF_CPID]}_id") if cp_id is None: + _LOGGER.warning( + "Could not find charger id during migration, try a clean install" + ) return False new_data.update({CONF_CPIDS: [{cp_id: cpid_data}]}) @@ -189,7 +192,7 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): config_entry, data=new_data, minor_version=0, version=2 ) - _LOGGER.debug( + _LOGGER.info( "Migration to configuration version %s.%s successful", config_entry.version, config_entry.minor_version, diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index cdc2dc58..a621a09d 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -150,8 +150,8 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): if self.settings.ssl: self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # see https://community.home-assistant.io/t/certificate-authority-and-self-signed-certificate-for-ssl-tls/196970 - localhost_certfile = self.settings.certfile - localhost_keyfile = self.settings.keyfile + localhost_certfile = self.settings.ssl_certfile_path + localhost_keyfile = self.settings.ssl_keyfile_path await self.hass.async_add_executor_job( partial( self.ssl_context.load_cert_chain, diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 89be9b5e..9513ee0b 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -341,6 +341,11 @@ async def monitor_connection(self): self._metrics[cstat.latency_pong.value].unit = "ms" connection = self._connection timeout_counter = 0 + # Add backstop to start post connect for non-compliant chargers + # after 10s to allow for when a boot notification has not been received + await asyncio.sleep(10) + if not self.post_connect_success: + self.hass.async_create_task(self.post_connect()) while connection.state is State.OPEN: try: await asyncio.sleep(self.cs_settings.websocket_ping_interval) @@ -388,6 +393,7 @@ async def _handle_call(self, msg): async def start(self): """Start charge point.""" + # post connect now handled on receiving boot notification or with backstop in monitor connection await self.run([super().start(), self.monitor_connection()]) async def run(self, tasks): @@ -424,10 +430,8 @@ async def reconnect(self, connection: ServerConnection): self.status = STATE_OK self._connection = connection self._metrics[cstat.reconnects.value].value += 1 - if self.post_connect_success is True: - await self.run([super().start(), self.monitor_connection()]) - else: - await self.run([super().start(), self.monitor_connection()]) + # post connect now handled on receiving boot notification or with backstop in monitor connection + await self.run([super().start(), self.monitor_connection()]) async def async_update_device_info( self, serial: str, vendor: str, model: str, firmware_version: str @@ -453,7 +457,8 @@ async def async_update_device_info( def _register_boot_notification(self): if self.triggered_boot_notification is False: self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) - self.hass.async_create_task(self.post_connect()) + if not self.post_connect_success: + self.hass.async_create_task(self.post_connect()) async def update(self, cpid: str): """Update sensors values in HA.""" From d984d795f966dbfc18dbee8c03c8ab487a31a895 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:23:44 +0100 Subject: [PATCH 261/370] build(deps): bump pytest-homeassistant-custom-component (#1536) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 43545d23..e9c7c81e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0 jsonschema==4.23.0 pre-commit==4.1.0 -pytest-homeassistant-custom-component==0.13.208 +pytest-homeassistant-custom-component==0.13.219 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 93f3076deedd42f6ece89cf3f2610b5a2372baa3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:29:47 +0100 Subject: [PATCH 262/370] build(deps): bump ruff from 0.9.2 to 0.9.9 (#1538) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e9c7c81e..51c1dfe7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.9.2 +ruff==0.9.9 ocpp==2.0.0 websockets==15.0 jsonschema==4.23.0 From ee2d01f95fae87f79bee2e6765ed729c6ce909f1 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Tue, 4 Mar 2025 23:56:22 +1300 Subject: [PATCH 263/370] fix case issue with migration (#1541) --- custom_components/ocpp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 7c1e689b..5c7bf1fe 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -180,7 +180,7 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): csid_data.update({key: old_data.get(key, value)}) new_data = csid_data - cp_id = hass.states.get(f"sensor.{cpid_data[CONF_CPID]}_id") + cp_id = hass.states.get(f"sensor.{cpid_data[CONF_CPID].lower()}_id") if cp_id is None: _LOGGER.warning( "Could not find charger id during migration, try a clean install" From 80fc9d4d6180a37d053c18559427ba55c4e6765c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:52:45 +0100 Subject: [PATCH 264/370] build(deps): bump pytest-homeassistant-custom-component (#1547) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 51c1dfe7..5fca202f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0 jsonschema==4.23.0 pre-commit==4.1.0 -pytest-homeassistant-custom-component==0.13.219 +pytest-homeassistant-custom-component==0.13.220 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 6d177c51618924b29e2d587f4c66fc1737fb1f75 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Fri, 7 Mar 2025 19:43:23 +1300 Subject: [PATCH 265/370] fix sensor device class (#1557) --- custom_components/ocpp/sensor.py | 6 +++--- tests/test_charge_point_v16.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 5943537f..fc743281 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -163,7 +163,7 @@ def device_class(self): device_class = SensorDeviceClass.VOLTAGE elif self.metric.lower().startswith("energy.r"): device_class = None - elif self.metric.lower().startswith("energy."): + elif self.metric.lower().startswith("energy"): device_class = SensorDeviceClass.ENERGY elif self.metric in [ Measurand.frequency, @@ -174,9 +174,9 @@ def device_class(self): device_class = SensorDeviceClass.POWER elif self.metric.lower().startswith("power.r"): device_class = SensorDeviceClass.REACTIVE_POWER - elif self.metric.lower().startswith("temperature."): + elif self.metric.lower().startswith("temperature"): device_class = SensorDeviceClass.TEMPERATURE - elif self.metric.lower().startswith("timestamp.") or self.metric in [ + elif self.metric.lower().startswith("timestamp") or self.metric in [ HAChargerDetails.config_response.value, HAChargerDetails.data_response.value, HAChargerStatuses.heartbeat.value, diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index baf59795..42387c68 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -464,7 +464,7 @@ async def test_cms_responses_actions_v16( socket_enabled, ), ), - timeout=5, + timeout=10, ) cp_task.cancel() await ws.close() From 95b162935d8a56e02cf0286274853bddef9b4807 Mon Sep 17 00:00:00 2001 From: Mark Hetherington Date: Fri, 7 Mar 2025 17:44:29 +1100 Subject: [PATCH 266/370] Add ZJ Beny to supported devices (#1556) --- docs/supported-devices.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 66e6af6a..99e9b30c 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -121,6 +121,9 @@ If the devices loses connection to Home Assistant (due to Wi-Fi disconnection or The Wallbox Pulsar and Copper SB have been verified. In the OCPP-config, leave the password field empty. +## [ZJ Beny BCP-A2N-P](https://ultipower.com.au/products/zj-beny-home-charging-station-ac-7kw-32a-type-2-ocpp) +Note that there are different models with similar model names, some of which support OCPP and some with other features. + ## Others When a charger is not listed as a supported charger it simply means that it has not been reported to work. Whether it will work or not in practice really depends on whether it is compliant with the OCPP standard. Some vendors claim their device is compliant without bothering to do a compliance test, because that takes time and costs money! From e78f1ff564756869b0285262e9232d625cb1a549 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 07:45:03 +0100 Subject: [PATCH 267/370] build(deps): bump jinja2 from 3.1.5 to 3.1.6 (#1555) --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 1b47fb32..24dc069c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ myst-parser==3.0.1 docutils==0.18.1 -Jinja2==3.1.5 +Jinja2==3.1.6 sphinx==7.1.2 sphinx_rtd_theme==3.0.2 From 0f04c12bd910501e142bf6891e7d4b0c05352ded Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 07:45:26 +0100 Subject: [PATCH 268/370] build(deps): bump pytest-homeassistant-custom-component (#1554) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5fca202f..67cce9b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0 jsonschema==4.23.0 pre-commit==4.1.0 -pytest-homeassistant-custom-component==0.13.220 +pytest-homeassistant-custom-component==0.13.221 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From bbfddb874a61dda2c40bb662c306a0a7bae9849d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 07:45:56 +0100 Subject: [PATCH 269/370] build(deps): bump websockets from 15.0 to 15.0.1 (#1553) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 67cce9b3..706b7a25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ colorlog==6.9.0 uv>=0.4 ruff==0.9.9 ocpp==2.0.0 -websockets==15.0 +websockets==15.0.1 jsonschema==4.23.0 pre-commit==4.1.0 pytest-homeassistant-custom-component==0.13.221 From f47f956f09b15dab6dbab0c26e8e0993156f72d1 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 7 Mar 2025 17:43:27 +0100 Subject: [PATCH 270/370] Fix temperature unit (#1560) --- custom_components/ocpp/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 52395600..bd2ca551 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -134,6 +134,7 @@ SensorDeviceClass.POWER: ha.UnitOfPower.KILO_WATT, SensorDeviceClass.REACTIVE_POWER: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, SensorDeviceClass.ENERGY: ha.UnitOfEnergy.KILO_WATT_HOUR, + SensorDeviceClass.TEMPERATURE: ha.UnitOfTemperature.CELSIUS, } From 45eb2ce950ed196332a7fbe446c898a1afb6db35 Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Sat, 8 Mar 2025 11:52:11 +0000 Subject: [PATCH 271/370] Expose TriggerMessage Service in HA (#1543) --- custom_components/ocpp/api.py | 18 ++++++++++++++++++ custom_components/ocpp/chargepoint.py | 7 +++++++ custom_components/ocpp/enums.py | 1 + custom_components/ocpp/ocppv16.py | 12 ++++++++++++ custom_components/ocpp/services.yaml | 17 +++++++++++++++++ tests/test_charge_point_v16.py | 15 +++++++++++++-- 6 files changed, 68 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index a621a09d..467021a4 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -80,6 +80,12 @@ vol.Optional("custom_profile"): vol.Any(cv.string, dict), } ) +CUSTMSG_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("requested_message"): cv.string, + } +) class CentralSystem: @@ -118,6 +124,12 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): self.handle_data_transfer, TRANS_SERVICE_DATA_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_trigger_custom_message.value, + self.handle_trigger_custom_message, + CUSTMSG_SERVICE_DATA_SCHEMA, + ) self.hass.services.async_register( DOMAIN, csvcs.service_clear_profile.value, @@ -379,6 +391,12 @@ async def wrapper(self, call, *args, **kwargs): return wrapper # Define custom service handles for charge point + @check_charger_available + async def handle_trigger_custom_message(self, call, cp): + """Handle the message request with a custom message.""" + requested_message = call.data.get("requested_message") + await cp.trigger_custom_message(requested_message) + @check_charger_available async def handle_clear_profile(self, call, cp): """Handle the clear profile service call.""" diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 9513ee0b..6c846373 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -263,6 +263,13 @@ async def trigger_status_notification(self): """Trigger status notifications for all connectors.""" pass + async def trigger_custom_message( + self, + requested_message: str = "StatusNotification", + ): + """Trigger message request with a custom message.""" + pass + async def clear_profile(self): """Clear all charging profiles.""" pass diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index bc3b5ccb..241ed524 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -17,6 +17,7 @@ class HAChargerServices(str, Enum): service_configure = "configure" service_get_configuration = "get_configuration" service_get_diagnostics = "get_diagnostics" + service_trigger_custom_message = "trigger_custom_message" service_clear_profile = "clear_profile" service_data_transfer = "data_transfer" diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index b4f12695..f4093f20 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -232,6 +232,18 @@ async def trigger_status_notification(self): break return return_value + async def trigger_custom_message( + self, + requested_message: str = "StatusNotification", + ): + """Trigger Custom Message.""" + req = call.TriggerMessage(requested_message) + resp = await self.call(req) + if resp.status != TriggerMessageStatus.accepted: + _LOGGER.warning("Failed with response: %s", resp.status) + return False + return True + async def clear_profile(self): """Clear all charging profiles.""" req = call.ClearChargingProfile() diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index cd82fb00..6d9b5b04 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -51,6 +51,23 @@ set_charge_rate: advanced: true example: '{"chargingProfileId":8,"stackLevel":0,"chargingProfileKind":"Relative","chargingProfilePurpose":"ChargePointMaxProfile","chargingSchedule":{"chargingRateUnit":"A","chargingSchedulePeriod":[{"startPeriod":0,"limit":16}]}}' +trigger_custom_message: + name: Trigger Message + description: Request a specific message from the charger + fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger + requested_message: + name: Requested Message + description: Message Requested from the charger + required: true + advanced: true + example: "StatusNotification" + clear_profile: name: Clear charging profiles description: Clears all charging profiles (limits) set (dependent on charger support) diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 42387c68..cc99d9a7 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -59,6 +59,7 @@ csvcs.service_configure, csvcs.service_get_configuration, csvcs.service_get_diagnostics, + csvcs.service_trigger_custom_message, csvcs.service_clear_profile, csvcs.service_data_transfer, csvcs.service_set_charge_rate, @@ -68,6 +69,7 @@ SERVICES_ERROR = [ csvcs.service_configure, csvcs.service_get_configuration, + csvcs.service_trigger_custom_message, csvcs.service_clear_profile, csvcs.service_data_transfer, csvcs.service_set_charge_rate, @@ -127,6 +129,8 @@ async def test_services(hass, cpid, serv_list, socket_enabled): data.update({"vendor_id": "ABC"}) if service == csvcs.service_set_charge_rate: data.update({"limit_amps": 30}) + if service == csvcs.service_trigger_custom_message: + data.update({"requested_message:": "StatusNotification"}) await hass.services.async_call( OCPP_DOMAIN, @@ -159,6 +163,13 @@ async def test_services(hass, cpid, serv_list, socket_enabled): service_data=data, blocking=True, ) + # test custom message request for MeterValues + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_trigger_custom_message, + service_data={"devid": cpid, "requested_message": "MeterValues"}, + blocking=True, + ) for number in NUMBERS: # test setting value of number slider @@ -445,8 +456,8 @@ async def test_cms_responses_actions_v16( await asyncio.wait_for( asyncio.gather( cp.send_meter_clock_data(), - cs.charge_points[cp_id].trigger_boot_notification(), - cs.charge_points[cp_id].trigger_status_notification(), + # cs.charge_points[cp_id].trigger_boot_notification(), + # cs.charge_points[cp_id].trigger_status_notification(), test_switches( hass, cs.charge_points[cp_id].settings.cpid, From f5694f670b9cfa5f23e94b79e41392aff2f154a9 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Mon, 10 Mar 2025 04:49:49 +1300 Subject: [PATCH 272/370] fix slider max value (#1563) --- custom_components/ocpp/number.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 4fb8762e..e3a501dd 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -63,12 +63,8 @@ async def async_setup_entry(hass, entry, async_add_devices): for ent in NUMBERS: if ent.key == "maximum_current": - ent.initial_value = entry.data.get( - CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT - ) - ent.native_max_value = entry.data.get( - CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT - ) + ent.initial_value = cp_id_settings[CONF_MAX_CURRENT] + ent.native_max_value = cp_id_settings[CONF_MAX_CURRENT] cpx = ChargePointNumber(hass, central_system, cpid, ent) entities.append(cpx) From ce7be5b4de29ac7038eea79011067ea8c0427214 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sun, 9 Mar 2025 17:10:26 +0100 Subject: [PATCH 273/370] Update update-version.yml (#1564) * Update update-version.yml * Update update-version.yml * Update update-version.yml --- .github/workflows/update-version.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml index 8e16ca43..196bb9b9 100644 --- a/.github/workflows/update-version.yml +++ b/.github/workflows/update-version.yml @@ -12,16 +12,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Update version in manifest.json + - name: Update version in manifest.json and commit + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} run: | VERSION=${GITHUB_REF#refs/tags/} MANIFEST=custom_components/ocpp/manifest.json jq --arg version "$VERSION" '.version = $version' $MANIFEST > tmp.json && mv tmp.json $MANIFEST - - - name: Commit changes - env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - run: | git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' git add $MANIFEST From cb3477c06a0632aedfb334887588ef0b3804d170 Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:02:51 +0000 Subject: [PATCH 274/370] Update supported-devices.md (#1568) --- docs/supported-devices.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 99e9b30c..faa3eb70 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -98,6 +98,24 @@ match transactions and it won't report some meter values such as session time. ## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) All basic functions work properly +## [SyncEV Compact EVCP](https://sync.energy/support/instruction-manuals) +These are a discontinued (but cheap) 7kw 1PH smart charger, with an OCPP implementation that's seemingly quite close to standard, and tolerent. +Mine works well with the plugin, OCPP setup is done through the local AP-Wifi. The admin panel password is admin. +A few plugin tweaks to get full functionality... + - Force SMART mode, to allow setting charge rates (use action ocpp.set_charge_rate) and retreiving meter values (use action ocpp.trigger_custom_message) + - Manually specify the Measurands + - Voltage + - Temperature + - Current.Offered + - Current.Import + - Power.Active.Import + - Energy.Active.Import.Register + - Create an automation triggering action: ocpp.trigger_custom_message with requested_message set to MeterValues on a schedule of your choice to retrieve the Measurands. + - Optionally create an automation updating the hearbeat interval (you have to set a value different to the one in the chargepoint) when the chargepoint reboots. + - I haven't tested using secure mode. + - If you have problems with charging profiles, check your firmware version is 1.6.3 (the latest in Mar 2025) + - Firmware updates can be done through the app, by reconnecting the charger to the original OCPP backend (wss://cpc.uk.charge.ampeco.tech:443/syncev/) and if it says you're on the latest, call them (+44 1952 983 940) to get it updated. + ## [Teison Smart MINI Wallbox](https://www.teison.com/ac_smart_mini_ev_wallbox.html) Use *My Teison* app to enable webSocket. In the socket URL field enter the address of your Home Assistant server including the port. In the socket port field enter *ocpp1.6* for insecure connection or *socpp1.6* for secure connection with certificates. Once enabled, charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. From f8c9861c0c5a56a606be09a7b62deac189f3194a Mon Sep 17 00:00:00 2001 From: Mark Hetherington Date: Tue, 11 Mar 2025 06:07:35 +1100 Subject: [PATCH 275/370] Prevent reference sharing between entry and data (#1571) * Prevent reference sharing between entry and data Change detection doesn't work when updating the self._data also updates self._entry.data because the reference to the cpid key is the same list. This prevents the data being persisted to disk. * cleanup testing code * Whitespace cleanup * Whitespace cleanup --- custom_components/ocpp/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 67e6c2c5..58e33c50 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for ocpp.""" from typing import Any +from copy import deepcopy from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -140,6 +141,7 @@ async def async_step_integration_discovery( self._entry = discovery_info["entry"] self._cp_id = discovery_info["cp_id"] self._data = {**self._entry.data} + self._data = deepcopy(self._data) await self.async_set_unique_id(self._cp_id) # Abort the flow if a config entry with the same unique ID exists From fc66ef2b2c9f7cb5211309764b3394994d2a556b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Mar 2025 16:45:46 +0100 Subject: [PATCH 276/370] build(deps): bump pytest-homeassistant-custom-component (#1578) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 706b7a25..fb06d147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 pre-commit==4.1.0 -pytest-homeassistant-custom-component==0.13.221 +pytest-homeassistant-custom-component==0.13.223 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From bbfab825c19a1bd2f71bf01805a55f5abc3148ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Z=C3=B6llner?= Date: Sat, 15 Mar 2025 16:46:39 +0100 Subject: [PATCH 277/370] Update supported-devices.md (#1575) --- docs/supported-devices.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index faa3eb70..33f040e5 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -95,6 +95,8 @@ match transactions and it won't report some meter values such as session time. ## [MaXpeedingrods Ev Charger](https://www.maxpeedingrods.com/category/ev-charger.html) +## [Mennekes Amtron Charge Control](https://www.mennekes.de/emobility/produkte/charge-control/) + ## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) All basic functions work properly From a063d999a5194121b38f35d2fdc5da55040cb654 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Mar 2025 16:47:07 +0100 Subject: [PATCH 278/370] build(deps): bump ruff from 0.9.9 to 0.11.0 (#1583) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fb06d147..637546be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.9.9 +ruff==0.11.0 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 From 518642cd50d05226e4983d67e8916f281157c61a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:20:06 +0100 Subject: [PATCH 279/370] build(deps): bump pytest-homeassistant-custom-component (#1587) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 637546be..cc112e3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 pre-commit==4.1.0 -pytest-homeassistant-custom-component==0.13.223 +pytest-homeassistant-custom-component==0.13.224 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 8624b656d4303187aff093945afeb09187ff5459 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:20:17 +0100 Subject: [PATCH 280/370] build(deps): bump ruff from 0.11.0 to 0.11.2 (#1597) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cc112e3d..80c845c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.11.0 +ruff==0.11.2 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 From 162c3400f1d700978d55205edb0c341c6d69a847 Mon Sep 17 00:00:00 2001 From: BJReplay <37993507+BJReplay@users.noreply.github.com> Date: Sat, 22 Mar 2025 23:20:27 +1100 Subject: [PATCH 281/370] Update supported-devices.md (#1596) --- docs/supported-devices.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 33f040e5..c5d1d7e2 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -54,7 +54,17 @@ This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC ( [Jonas Karlsson](https://github.com/jonasbkarlsson) has written a [getting started guide](https://github.com/jonasbkarlsson/ocpp/wiki/CTEK-Chargestorm-Connected-2) for connecting CTEK Chargestorm Connected 2. ## [EN+ Caro Series Home Wallbox](https://www.en-plustech.com/product/caro-series-wallbox/) -Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant. If required, re-configure this with the correct Charge point identity (by removing and re-adding the integration) to change from the default `charger` Charge point identity before configuring the charger. +This charger is often white-labelled by other vendors, including [cord](https://www.cord-ev.com/cord-one.html) and [EV Switch](https://www.evswitchstore.com.au/pages/ev-charger-range). + +Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant if the OCPP integration does not discover your charger, and also to request a firmware update for versions earlier than 1.0.25.130. + +For firmware versions earlier than 1.0.25.130 the only way you can update firmware is by connecting to the evchargo OCPP server at `wss://ocpp16.evchargo.com:33033/` and emailing your serial number to `support@en-plus.com.cn` requesting that your firmware is updated. + +You will probably want to update your firmware if it is earlier than 1.0.25.130 before configuring your charger to connect to your own OCPP server. + +Firmware 1.0.25.130 has a firmware update option on the configuration interface (on IP address 192.168.4.1) which you can access by power-cycling the charger and connecting to its access point (see below). + +If you have already installed the OCPP integration and have the default `charger` charge point installed, then you will need to re-configure this with the correct charge point identity (by removing and re-adding the OCPP integration) to change from the default `charger` charge point identity before configuring the charger. Connect to the charger's access point (AP) by powering down the charger (i.e. switch off the charger's isolator or circuit breaker) and powering it back on a few seconds later. The charger's access point becomes available for 15 minutes, and the SSID matches the charger's serial number (starting with SN). Log in to the configuration interface on the IP address 192.168.4.1. @@ -74,10 +84,10 @@ Even though the device accepts all measurands, the key working ones are - `Current.Import` - `Current.Offered` - `Energy.Active.Import.Register` - - `Voltage` - although this shows a constant voltage, so should be considered spurious. + - `Voltage` - although this shows a constant voltage or zero unless a charging session is in progress. - `Transaction.ID` -You may wish to disable sensors that show Unknown after you've completed a charging session, as they will never provide data with the current firmware 1.4.859. +You may wish to disable sensors that show `Unknown` after you've completed a charging session, as they will never provide data with the current firmware 1.0.25.130. ## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the From 055a0e0127c28f8070d71252db4e132618b27a4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:20:42 +0100 Subject: [PATCH 282/370] build(deps): bump actions/upload-artifact from 4.6.1 to 4.6.2 (#1592) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d84d86b..182c009b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 if: ${{ github.event_name == 'push' }} with: name: ocpp From a29ff0b1e8118c04b5f26039b7191a350e1ee2af Mon Sep 17 00:00:00 2001 From: bverkron Date: Sat, 22 Mar 2025 05:21:30 -0700 Subject: [PATCH 283/370] Grizzl-E doc updates for firmware 3.x.x on Mini / Ultimate models (#1586) --- docs/supported-devices.md | 11 ++++++++++- docs/user-guide.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index c5d1d7e2..a5a252ba 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -141,7 +141,16 @@ Even though the device accepts all measurands, the working ones are If the devices loses connection to Home Assistant (due to Wi-Fi disconnection or update, for example) it doesn't seem to reconnect automatically. It is necessary to reboot the charger via Bluetooth for it to reconnect. ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) -(has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) + +Grizzl-E chargers with firmware 3.x.x work mostly without issue, such as the following: +* Grizzl-E Mini Connect 2024 +* Grizzl-E Ultimate + +Known issue: In firmware 03.09.0 amperage changes are accepted but not applied. This is due to the firmware accepting but not handling a value of `ChargePointMaxProfile` in `ChargerProfilePurpose`. United Chargers has stated that this will be addressed in firmware version 03.11.0. + +Supported OCPP requests for the 3.x.x firmware are documented in a PDF on their site in under https://grizzl-e.com/connect-to-third-party-ocpp-backend/ + +Other Grizzl-E chargers on the 5.x.x firmware have some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) ## [V2C Trydan](https://v2charge.com/trydan) diff --git a/docs/user-guide.md b/docs/user-guide.md index 43703844..d7ef4ba3 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -132,7 +132,7 @@ ABB Terra AC firmware 1.8.21 and earlier versions fail to respond correctly when ### Grizzl-E -Grizzl-E firmware has a few OCPP-compliance defects, including responding to certain OCPP server messages with invalid JSON. Symptoms of this problem include repeated reboots of the charger. By editing the OCPP server source code, one can avoid these problematic messages and obtain useful charger behaviour. ChargeLabs (the company working on the Grizzl-E firmware) expects to release version 6 of the firmware in early 2023, which may fix these problems. +Grizzl-E firmware 5.x has a few OCPP-compliance defects, including responding to certain OCPP server messages with invalid JSON. Firmware 3.x.x on chargers such as the Mini Connect and Ultimate does not seem to have these issues. Symptoms of this problem include repeated reboots of the charger. By editing the OCPP server source code, one can avoid these problematic messages and obtain useful charger behaviour. ChargeLabs (the company working on the Grizzl-E firmware) expects to release version 6 of the firmware in early 2023, which may fix these problems. The workaround consists of: - checking the *Skip OCPP schema validation* checkbox during OCPP server configuration From 56223e8ba19af1be737d55830171741879e80335 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:22:42 +0100 Subject: [PATCH 284/370] build(deps): bump pre-commit from 4.1.0 to 4.2.0 (#1589) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80c845c6..61fdff09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.11.2 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 -pre-commit==4.1.0 +pre-commit==4.2.0 pytest-homeassistant-custom-component==0.13.224 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 081921462384d20bc154679d366dcd261a122869 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:00:12 +0200 Subject: [PATCH 285/370] build(deps): bump actions/setup-python from 5.4.0 to 5.5.0 (#1604) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.4.0...v5.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 182c009b..9804854e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f298b910..727488ed 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.4.0" + uses: "actions/setup-python@v5.5.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 3122ef608ce0179b6f38b541ce43b187044f96cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:00:45 +0200 Subject: [PATCH 286/370] build(deps): bump crazy-max/ghaction-github-labeler from 5.2.0 to 5.3.0 (#1608) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7e4ab791..a00b7514 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.2.0 + uses: crazy-max/ghaction-github-labeler@v5.3.0 with: skip-delete: true From 7f888a5a6ffb71b75773fa1126a0fb4ae6f5247f Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:18:48 +0200 Subject: [PATCH 287/370] Update requirements.txt (#1616) Bump pytest-homeassistant-custom-component to latest version to work around dependency conflict --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61fdff09..151fd857 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 pre-commit==4.2.0 -pytest-homeassistant-custom-component==0.13.224 +pytest-homeassistant-custom-component==0.13.233 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 6066107afc7c5e8cd6258f10d6e04c73e5312e4a Mon Sep 17 00:00:00 2001 From: Tomakava <20898335+Tomakava@users.noreply.github.com> Date: Fri, 30 May 2025 04:29:27 +0300 Subject: [PATCH 288/370] Update supported-devices.md (#1574) (#1628) --- docs/supported-devices.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index a5a252ba..ea3f5aee 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -107,6 +107,11 @@ match transactions and it won't report some meter values such as session time. ## [Mennekes Amtron Charge Control](https://www.mennekes.de/emobility/produkte/charge-control/) +## [Morek Smart AC Charger](https://ev.morek.eu/products-morek-quick-charge-11-22-kw/) +Successful connection requires firmware version **A0-MEV-V2.0.9** or newer. + +The "Charger idle sampling interval" is not supported. Set this to **0** to avoid a "ClockAlignedDataInterval is read-only" warning. + ## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) All basic functions work properly From a867907d33d1206d4e0482d9a07478019a9fc0e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 03:30:16 +0200 Subject: [PATCH 289/370] build(deps): bump ruff from 0.11.2 to 0.11.5 (#1615) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 151fd857..cc410848 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.11.2 +ruff==0.11.5 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.23.0 From af0cf5c487ae277224eabb9866e779a0bcde5d1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 03:30:53 +0200 Subject: [PATCH 290/370] build(deps): bump actions/setup-python from 5.5.0 to 5.6.0 (#1624) --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9804854e..caf11163 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 727488ed..33af9f79 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v4" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.5.0" + uses: "actions/setup-python@v5.6.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 040cd89bf3b779da45cf5e893e41e0371afb414b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 03:31:22 +0200 Subject: [PATCH 291/370] build(deps): bump jsonschema from 4.23.0 to 4.24.0 (#1630) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cc410848..7791283c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ uv>=0.4 ruff==0.11.5 ocpp==2.0.0 websockets==15.0.1 -jsonschema==4.23.0 +jsonschema==4.24.0 pre-commit==4.2.0 pytest-homeassistant-custom-component==0.13.233 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From 759ba76b697ba98819e43f9bcf4a0fafb087310c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:50:42 +1200 Subject: [PATCH 292/370] build(deps): bump ruff from 0.11.5 to 0.12.1 (#1647) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.11.5 to 0.12.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.11.5...0.12.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.12.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7791283c..cdd6743f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.11.5 +ruff==0.12.1 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.24.0 From 4499dd3a54b8a8277c19864731e85d2a94a0470d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:46:29 +1200 Subject: [PATCH 293/370] build(deps): bump ruff from 0.12.1 to 0.12.2 (#1653) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.12.1 to 0.12.2. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.12.1...0.12.2) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cdd6743f..68b435fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.12.1 +ruff==0.12.2 ocpp==2.0.0 websockets==15.0.1 jsonschema==4.24.0 From 54d42587849754a85db39677f25348a9a55e002f Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:45:25 +1200 Subject: [PATCH 294/370] Revert "Prevent reference sharing between entry and data (#1571)" (#1655) This reverts commit f8c9861c0c5a56a606be09a7b62deac189f3194a. --- custom_components/ocpp/config_flow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 58e33c50..67e6c2c5 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -1,7 +1,6 @@ """Adds config flow for ocpp.""" from typing import Any -from copy import deepcopy from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -141,7 +140,6 @@ async def async_step_integration_discovery( self._entry = discovery_info["entry"] self._cp_id = discovery_info["cp_id"] self._data = {**self._entry.data} - self._data = deepcopy(self._data) await self.async_set_unique_id(self._cp_id) # Abort the flow if a config entry with the same unique ID exists From 960d90723ee3f988da468ccaf88ba7eaac08f859 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:46:08 +1200 Subject: [PATCH 295/370] Update requirements.txt (#1648) * Update requirements.txt * Use stop transaction meter value for session energy * increase stop trans delay * Update comment * add delay to stop transaction --- requirements.txt | 2 +- tests/test_charge_point_v16.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 68b435fc..c920ed35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.0.0 websockets==15.0.1 jsonschema==4.24.0 pre-commit==4.2.0 -pytest-homeassistant-custom-component==0.13.233 +pytest-homeassistant-custom-component==0.13.256 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index cc99d9a7..919cbe13 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -514,7 +514,7 @@ async def test_cms_responses_actions_v16( cp.send_meter_periodic_data(), cp.send_main_meter_clock_data(), # add delay to allow meter data to be processed - cp.send_stop_transaction(1), + cp.send_stop_transaction(2), ), timeout=5, ) @@ -528,6 +528,7 @@ async def test_cms_responses_actions_v16( assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" # Last sent "Energy.Active.Import.Register" value with transaction id should be here. + # Meter value sent with stop transaction should not be used to calculate session energy assert int(cs.get_metric(cpid, "Energy.Session")) == int(1305570 / 1000) assert cs.get_unit(cpid, "Energy.Session") == "kWh" @@ -547,7 +548,7 @@ async def test_cms_responses_actions_v16( cp.send_meter_energy_kwh(), cp.send_meter_clock_data(), # add delay to allow meter data to be processed - cp.send_stop_transaction(1), + cp.send_stop_transaction(2), ), timeout=5, ) From 2c800b14d8b710be746bbcc10fb8ec1acc762b80 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:46:54 +1200 Subject: [PATCH 296/370] support for v2.1 (#1666) --- custom_components/ocpp/chargepoint.py | 5 +++++ custom_components/ocpp/const.py | 4 ++-- custom_components/ocpp/manifest.json | 2 +- custom_components/ocpp/ocppv201.py | 5 ++--- requirements.txt | 2 +- tests/test_charge_point_v201.py | 14 +++++++------- tests/test_config_flow.py | 2 +- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 6c846373..d8fbe8f9 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -117,6 +117,7 @@ class OcppVersion(str, Enum): V16 = "1.6" V201 = "2.0.1" + V21 = "2.1" class SetVariableResult(Enum): @@ -162,6 +163,10 @@ def __init__( self._call = callv201 self._call_result = call_resultv201 self._ocpp_version = "2.0.1" + elif version == OcppVersion.V21: + self._call = callv201 + self._call_result = call_resultv201 + self._ocpp_version = "2.1" for action in self.route_map: self.route_map[action]["_skip_schema_validation"] = ( diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index bd2ca551..a27066bf 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -51,8 +51,8 @@ DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") -DEFAULT_SUBPROTOCOLS = ["ocpp1.6", "ocpp2.0.1"] -OCPP_2_0 = "ocpp2.0" +DEFAULT_SUBPROTOCOLS = ["ocpp1.6", "ocpp2.0.1", "ocpp2.1"] +OCPP_2_0 = "ocpp2" DEFAULT_METER_INTERVAL = 60 DEFAULT_IDLE_INTERVAL = 900 DEFAULT_WEBSOCKET_CLOSE_TIMEOUT = 10 diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 4ef3978a..f39890fe 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp>=2.0.0", + "ocpp>=2.1.0", "websockets>=14.1" ], "version": "0.8.0" diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 494719af..171662c3 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -1,4 +1,4 @@ -"""Representation of a OCPP 2.0.1 charging station.""" +"""Representation of a OCPP 2.0.1 or 2.1 charging station.""" import asyncio from datetime import datetime, UTC @@ -38,7 +38,6 @@ ) from .chargepoint import ( - OcppVersion, SetVariableResult, MeasurandValue, ) @@ -95,7 +94,7 @@ def __init__( super().__init__( id, connection, - OcppVersion.V201, + connection.subprotocol.replace("ocpp", ""), hass, entry, central, diff --git a/requirements.txt b/requirements.txt index c920ed35..897ccfea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorlog==6.9.0 uv>=0.4 ruff==0.12.2 -ocpp==2.0.0 +ocpp==2.1.0 websockets==15.0.1 jsonschema==4.24.0 pre-commit==4.2.0 diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index 7cc1a9ff..b00dae95 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -1262,20 +1262,20 @@ async def test_cms_responses_v201(hass, socket_enabled): [lambda cp: _run_test(hass, cs, cp)], ) - # add second charger to config entry + # add v2.1 charger to config entry entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] - cp_id2 = "CP_2_allfeatures" - entry.data[CONF_CPIDS].append({cp_id2: MOCK_CONFIG_CP_APPEND.copy()}) - entry.data[CONF_CPIDS][-1][cp_id2][CONF_CPID] = "test_v201_cpid2" + cp_id3 = "CP_2_1_allfeatures" + entry.data[CONF_CPIDS].append({cp_id3: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id3][CONF_CPID] = "test_v21_cpid3" # need to reload to setup sensors etc for new charger await hass.config_entries.async_reload(entry.entry_id) cs = hass.data[DOMAIN][entry.entry_id] await run_charge_point_test( config_entry, - cp_id2, - ["ocpp2.0.1"], - lambda ws: ChargePointAllFeatures("CP_2_allfeatures_client", ws), + cp_id3, + ["ocpp2.1"], + lambda ws: ChargePointAllFeatures("CP_2_1_allfeatures_client", ws), [lambda cp: _extra_features_test(hass, cs, cp)], ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 745c86c4..2f69e6cf 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -105,7 +105,7 @@ async def test_successful_discovery_flow(hass, bypass_get_data): result_disc["flow_id"], user_input=cp_input ) - measurand_input = {value: True for value in DEFAULT_MONITORED_VARIABLES.split(",")} + measurand_input = dict.fromkeys(DEFAULT_MONITORED_VARIABLES.split(","), True) result_meas = await hass.config_entries.flow.async_configure( result_cp["flow_id"], user_input=measurand_input ) From ee6a66b68cfba7a1cdef01bff818743e5b6b80c4 Mon Sep 17 00:00:00 2001 From: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:57:03 +0200 Subject: [PATCH 297/370] use same devcontainer as home assistant core (#1677) * use same devcontainer as home assistant core * remove old devcontainer.json * Add Dockerfile * update port settings * fix location of schemas * fix location of setup script * add bootstrap script * make bootstrap executable * fix Dockerfile * Update Dockerfile.dev * allow prereleases --- .devcontainer.json | 41 -- .devcontainer/devcontainer.json | 81 ++++ Dockerfile | 43 +++ Dockerfile.dev | 58 +++ scripts/bootstrap | 9 + scripts/json_schemas/manifest_schema.json | 449 ++++++++++++++++++++++ scripts/setup | 2 +- 7 files changed, 641 insertions(+), 42 deletions(-) delete mode 100644 .devcontainer.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100755 scripts/bootstrap create mode 100644 scripts/json_schemas/manifest_schema.json diff --git a/.devcontainer.json b/.devcontainer.json deleted file mode 100644 index c2f0edcd..00000000 --- a/.devcontainer.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "lbbrhzn/ocpp", - "image": "mcr.microsoft.com/devcontainers/python:3.13", - "postCreateCommand": "scripts/setup", - "forwardPorts": [ - 8123 - ], - "portsAttributes": { - "8123": { - "label": "Home Assistant", - "onAutoForward": "notify" - } - }, - "customizations": { - "vscode": { - "extensions": [ - "charliermarsh.ruff", - "github.vscode-pull-request-github", - "ms-python.python", - "ms-python.vscode-pylance", - "ryanluker.vscode-coverage-gutters" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "editor.formatOnType": false, - "files.trimTrailingWhitespace": true, - "python.analysis.typeCheckingMode": "basic", - "python.analysis.autoImportCompletions": true, - "python.defaultInterpreterPath": "/usr/local/bin/python", - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff" - } - } - } - }, - "remoteUser": "vscode", - "features": {} -} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..8c3f3e9d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,81 @@ +{ + "name": "Home Assistant Dev", + "context": "..", + "dockerFile": "../Dockerfile.dev", + "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && scripts/setup", + "postStartCommand": "scripts/bootstrap", + "containerEnv": { + "PYTHONASYNCIODEBUG": "1" + }, + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "appPort": [ + "9000:9000", // OCPP + "8123:8123", // Home Assistant + "5683:5683/udp" // Shelly integration + ], + "runArgs": [ + "-e", + "GIT_EDITOR=code --wait", + "--security-opt", + "label=disable" + ], + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-python.pylint", + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github", + "GitHub.copilot" + ], + // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json + "settings": { + "python.experiments.optOutFrom": [ + "pythonTestAdapter" + ], + "python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python", + "python.pythonPath": "/home/vscode/.local/ha-venv/bin/python", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.testing.pytestArgs": [ + "--no-cov" + ], + "pylint.importStrategy": "fromEnvironment", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "json.schemas": [ + { + "fileMatch": [ + "homeassistant/components/*/manifest.json" + ], + "url": "${containerWorkspaceFolder}/scripts/json_schemas/manifest_schema.json" + } + ] + } + } + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b63cd1c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${BUILD_FROM} + +# Synchronize with homeassistant/core.py:async_stop +ENV \ + S6_SERVICES_GRACETIME=240000 \ + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true + +ARG QEMU_CPU + +# Home Assistant S6-Overlay +COPY rootfs / + +# Needs to be redefined inside the FROM statement to be set for RUN commands +ARG BUILD_ARCH +# Get go2rtc binary +RUN \ + case "${BUILD_ARCH}" in \ + "aarch64") go2rtc_suffix='arm64' ;; \ + "armhf") go2rtc_suffix='armv6' ;; \ + "armv7") go2rtc_suffix='arm' ;; \ + *) go2rtc_suffix=${BUILD_ARCH} ;; \ + esac \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && chmod +x /bin/go2rtc \ + # Verify go2rtc can be executed + && go2rtc --version + +# Install uv +RUN pip3 install uv==0.7.1 + +WORKDIR /usr/src + +RUN \ + uv pip install \ + -r requirements.txt + + +WORKDIR /config \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..a37ca960 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,58 @@ +FROM mcr.microsoft.com/vscode/devcontainers/base:debian + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN \ + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + # Additional library needed by some tests and accordingly by VScode Tests Discovery + bluez \ + ffmpeg \ + libudev-dev \ + libavformat-dev \ + libavcodec-dev \ + libavdevice-dev \ + libavutil-dev \ + libgammu-dev \ + libswscale-dev \ + libswresample-dev \ + libavfilter-dev \ + libpcap-dev \ + libturbojpeg0 \ + libyaml-dev \ + libxml2 \ + git \ + cmake \ + autoconf \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc + +WORKDIR /usr/src + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +RUN uv python install 3.13.2 + +USER vscode +ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" +RUN uv venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +WORKDIR /tmp + +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ + && uv pip install -e ~/hass-release/ + +# Install Python dependencies from requirements +COPY requirements.txt ./ +RUN uv pip install --prerelease=allow -r requirements.txt + +WORKDIR /workspaces + +# Set the default shell to bash instead of sh +ENV SHELL=/bin/bash \ No newline at end of file diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 00000000..7bc0759e --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,9 @@ +#!/bin/sh +# Resolve all dependencies that the application requires to run. + +# Stop on errors +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install -r requirements.txt diff --git a/scripts/json_schemas/manifest_schema.json b/scripts/json_schemas/manifest_schema.json new file mode 100644 index 00000000..3a382cc5 --- /dev/null +++ b/scripts/json_schemas/manifest_schema.json @@ -0,0 +1,449 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Home Assistant integration manifest", + "description": "The manifest for a Home Assistant integration", + "type": "object", + "if": { + "properties": { + "integration_type": { + "const": "virtual" + } + }, + "required": [ + "integration_type" + ] + }, + "then": { + "oneOf": [ + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "iot_standards": { + "description": "The IoT standards which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-standards", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "homekit", + "zigbee", + "zwave" + ] + } + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "integration_type", + "iot_standards" + ] + }, + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "supported_by": { + "description": "The integration which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#supported-by", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "integration_type", + "supported_by" + ] + } + ] + }, + "else": { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "type": "string", + "default": "hub", + "enum": [ + "device", + "entity", + "hardware", + "helper", + "hub", + "service", + "system" + ] + }, + "config_flow": { + "description": "Whether the integration is configurable from the UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#config-flow", + "type": "boolean" + }, + "mqtt": { + "description": "A list of topics to subscribe for the discovery of devices via MQTT.\nThis requires to specify \"mqtt\" in either the \"dependencies\" or \"after_dependencies\".\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#mqtt", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "zeroconf": { + "description": "A list containing service domains to search for devices to discover via Zeroconf. Items can either be strings, which discovers all devices in the specific service domain, and/or objects which include filters. (useful for generic service domains like _http._tcp.local.)\nA device is discovered if it matches one of the items, but inside the individual item all properties have to be matched.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#zeroconf", + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "type": "string", + "pattern": "^.*\\.local\\.$", + "description": "Service domain to search for devices." + }, + { + "type": "object", + "properties": { + "type": { + "description": "The service domain to search for devices.", + "examples": [ + "_http._tcp.local." + ], + "type": "string", + "pattern": "^.*\\.local\\.$" + }, + "name": { + "description": "The name or name pattern of the devices to filter.", + "type": "string" + }, + "properties": { + "description": "The properties of the Zeroconf advertisement to filter.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "ssdp": { + "description": "A list of matchers to find devices discoverable via SSDP/UPnP. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#ssdp", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the SSDP discovery.", + "type": "object", + "properties": { + "st": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "manufacturer": { + "type": "string" + }, + "modelDescription": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + } + }, + "bluetooth": { + "description": "A list of matchers to find devices discoverable via Bluetooth. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#bluetooth", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the bluetooth discovery", + "type": "object", + "properties": { + "connectable": { + "description": "Whether the device needs to be connected to or it works with just advertisement data.", + "type": "boolean" + }, + "local_name": { + "description": "The name or a name pattern of the device to match.", + "type": "string", + "pattern": "^([^*]+|[^*]{3,}[*].*)$" + }, + "service_uuid": { + "description": "The 128-bit service data UUID to match.", + "type": "string", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + }, + "service_data_uuid": { + "description": "The 16-bit service data UUID to match, converted into the corresponding 128-bit UUID by replacing the 3rd and 4th byte of `00000000-0000-1000-8000-00805f9b34fb` with the 16-bit UUID.", + "examples": [ + "0000fd3d-0000-1000-8000-00805f9b34fb" + ], + "type": "string", + "pattern": "0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb" + }, + "manufacturer_id": { + "description": "The Manufacturer ID to match.", + "type": "integer" + }, + "manufacturer_data_start": { + "description": "The start bytes of the manufacturer data to match.", + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + }, + "additionalProperties": false + }, + "uniqueItems": true + }, + "homekit": { + "description": "A list of model names to find devices which are discoverable via HomeKit. A device is discovered if the model name of the device starts with any of the specified model names.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#homekit", + "type": "object", + "properties": { + "models": { + "description": "The model names to search for.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "models" + ], + "additionalProperties": false + }, + "dhcp": { + "description": "A list of matchers to find devices discoverable via DHCP. In order to be discovered, the device has to match all properties of any of the matchers.\nYou can specify an item with \"registered_devices\" set to true to check for devices with MAC addresses specified in the device registry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dhcp", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "registered_devices": { + "description": "Whether the MAC addresses of devices in the device registry should be used for discovery, useful if the discovery is used to update the IP address of already registered devices.", + "const": true + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hostname": { + "description": "The hostname or hostname pattern to match.", + "type": "string" + }, + "macaddress": { + "description": "The MAC address or MAC address pattern to match.", + "type": "string", + "maxLength": 12 + } + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "usb": { + "description": "A list of matchers to find devices discoverable via USB. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#usb", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "vid": { + "description": "The vendor ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "pid": { + "description": "The product ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "description": { + "description": "The USB device description to match.", + "type": "string" + }, + "manufacturer": { + "description": "The manufacturer to match.", + "type": "string" + }, + "serial_number": { + "description": "The serial number to match.", + "type": "string" + }, + "known_devices": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "documentation": { + "description": "The website containing the documentation for the integration. It has to be in the format \"https://www.home-assistant.io/integrations/[domain]\"\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#documentation", + "type": "string", + "pattern": "^https://www.home-assistant.io/integrations/[0-9a-z_]+$", + "format": "uri" + }, + "quality_scale": { + "description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale", + "type": "string", + "enum": [ + "bronze", + "silver", + "gold", + "platinum", + "internal", + "legacy" + ] + }, + "requirements": { + "description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements", + "type": "array", + "items": { + "type": "string", + "pattern": ".+==.+" + }, + "uniqueItems": true + }, + "dependencies": { + "description": "A list of integrations which need to be loaded before this integration can be set up.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "after_dependencies": { + "description": "A list of integrations which need to be loaded before this integration is set up when it is configured. The integration will still be set up when the \"after_dependencies\" are not configured.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#after-dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "codeowners": { + "description": "A list of GitHub usernames or GitHub team names of the integration owners.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#code-owners", + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "pattern": "^@.+$" + }, + "uniqueItems": true + }, + "loggers": { + "description": "A list of logger names used by the requirements.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#loggers", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "disabled": { + "description": "The reason for the integration being disabled.", + "type": "string" + }, + "iot_class": { + "description": "The IoT class of the integration, describing how the integration connects to the device or service.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-class", + "type": "string", + "enum": [ + "assumed_state", + "cloud_polling", + "cloud_push", + "local_polling", + "local_push", + "calculated" + ] + }, + "single_config_entry": { + "description": "Whether the integration only supports a single config entry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#single-config-entry-only", + "const": true + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "codeowners", + "documentation" + ], + "dependencies": { + "mqtt": { + "anyOf": [ + { + "required": [ + "dependencies" + ] + }, + { + "required": [ + "after_dependencies" + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/scripts/setup b/scripts/setup index aa662978..555e977b 100755 --- a/scripts/setup +++ b/scripts/setup @@ -4,6 +4,6 @@ set -e cd "$(dirname "$0")/.." -python3 -m pip install --requirement requirements.txt +scripts/bootstrap pre-commit install pre-commit run --all \ No newline at end of file From c90f14f1bd25ed725d252c5656549e61c4833325 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Wed, 20 Aug 2025 03:57:05 +1200 Subject: [PATCH 298/370] add ocpp versions supported (#1678) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 467993fc..42be6094 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![OCPP](https://github.com/home-assistant/brands/raw/master/custom_integrations/ocpp/icon.png) -This is a Home Assistant integration for Electric Vehicle chargers that support the Open Charge Point Protocol. +This is a Home Assistant integration for Electric Vehicle chargers that support the following Open Charge Point Protocols 1.6j, 2.0.1 and 2.1 (experimental). * based on the [Python OCPP Package](https://github.com/mobilityhouse/ocpp). * HACS compliant repository From bde7f756fd9dab75296284d523eb43bbe3c79aeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:58:15 +0200 Subject: [PATCH 299/370] build(deps): bump actions/checkout from 4 to 5 (#1685) --- .github/workflows/labeler.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/publish_docs_to_wiki.yml | 2 +- .github/workflows/sphinx-build.yml | 2 +- .github/workflows/tests.yaml | 8 ++++---- .github/workflows/update-version.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index a00b7514..2444aa1a 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@v5.3.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index caf11163..01cda609 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: 📥 Checkout the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 🛠️ Set up Python uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/publish_docs_to_wiki.yml b/.github/workflows/publish_docs_to_wiki.yml index 6da2550d..1dd63552 100644 --- a/.github/workflows/publish_docs_to_wiki.yml +++ b/.github/workflows/publish_docs_to_wiki.yml @@ -18,7 +18,7 @@ jobs: publish_docs_to_wiki: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Pull wiki run: | diff --git a/.github/workflows/sphinx-build.yml b/.github/workflows/sphinx-build.yml index 5c2d4f3a..f208a2f5 100644 --- a/.github/workflows/sphinx-build.yml +++ b/.github/workflows/sphinx-build.yml @@ -6,7 +6,7 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ammaraskar/sphinx-action@master with: docs-folder: "docs/" \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 33af9f79..f9f0b8a5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -17,7 +17,7 @@ jobs: name: Pre-commit steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 @@ -46,7 +46,7 @@ jobs: name: HACS steps: - name: Check out the repository - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: HACS validation uses: "hacs/action@main" @@ -59,7 +59,7 @@ jobs: name: Hassfest steps: - name: Check out the repository - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" @@ -69,7 +69,7 @@ jobs: environment: continuous-integration steps: - name: Check out code from GitHub - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Setup Python ${{ env.DEFAULT_PYTHON }} uses: "actions/setup-python@v5.6.0" with: diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml index 196bb9b9..00af34b9 100644 --- a/.github/workflows/update-version.yml +++ b/.github/workflows/update-version.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Update version in manifest.json and commit env: From 735d7a8249f56c91bdc84b1eff769b14e9a78c81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:58:42 +0200 Subject: [PATCH 300/370] build(deps): bump pre-commit from 4.2.0 to 4.3.0 (#1686) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 897ccfea..13e2701e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.12.2 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.24.0 -pre-commit==4.2.0 +pre-commit==4.3.0 pytest-homeassistant-custom-component==0.13.256 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From e4d587c37e0ad0eedf301d5c34b704be45ad746b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:59:20 +0200 Subject: [PATCH 301/370] build(deps): bump ruff from 0.12.2 to 0.12.9 (#1687) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 13e2701e..e381cd56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.12.2 +ruff==0.12.9 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.24.0 From d3ef669df1bd76f7481511aba59b1483a8af6ed8 Mon Sep 17 00:00:00 2001 From: mutesplash <49622611+mutesplash@users.noreply.github.com> Date: Sun, 31 Aug 2025 11:06:50 -0400 Subject: [PATCH 302/370] Correct custom component name for debuggging (#1697) --- config/configuration.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/configuration.yaml b/config/configuration.yaml index a63ccbfd..7a99ba9e 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -65,7 +65,7 @@ ocpp: logger: default: info logs: - custom_components.cpps: debug + custom_components.ocpp: debug # If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) # debugpy: From 6e459f189f32460a58cb4064d9647cf1bbbf271c Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 5 Sep 2025 10:50:38 +0200 Subject: [PATCH 303/370] Support for multiple connectors per charger (#1689) * Update .gitignore for HA dev. Use symlinks for integration. * Handle multiple connectors, initial framework. * Fix sensor updates and placement on proper devices. * Add test for v2x multi connector. Handle meter values properly. max_current working per connector. * Fix per connector set charge rate. Clean up swenglish comments. * Remove separate connector device breaking change. Fix test race condition. CodeRabbit fixes. * Fix max_current per connector bug. Remove ChargerSession sensors (used for debugging). Fix Charge Control naming when only one connector. More CodeRabbit suggestions. * Fix Connector naming. More CodeRabbit suggestions. * Add more tests. More CodeRabbit suggestions. * Test fixes. CodeRabbit suggestions. * Fix tests. * Split stop transaction test in separate tests. * Change entity id to include charger. Add session sensors to connectors. Clear charger-level entities after connector creation. Add num_connector changes to reload logic. Remove not used functions from chargepoint.py. Revert to original config flow due to automatic discovery of number of connectors. Add more tests. * Change port on test to fix crash. * Add missing sensors. Convert features value in sensor. Add num_connectors to config entry. Migration to config 2.1. --------- Co-authored-by: Jan Thunqvist Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .gitignore | 4 + custom_components/ocpp/__init__.py | 32 + custom_components/ocpp/api.py | 277 ++- custom_components/ocpp/button.py | 114 +- custom_components/ocpp/chargepoint.py | 404 ++-- custom_components/ocpp/config_flow.py | 16 +- custom_components/ocpp/const.py | 3 + custom_components/ocpp/enums.py | 10 +- custom_components/ocpp/number.py | 155 +- custom_components/ocpp/ocppv16.py | 693 ++++--- custom_components/ocpp/ocppv201.py | 510 +++-- custom_components/ocpp/sensor.py | 220 ++- custom_components/ocpp/switch.py | 155 +- scripts/develop | 48 +- tests/charge_point_test.py | 2 +- tests/const.py | 25 + tests/test_api_paths.py | 413 ++++ tests/test_charge_point_core.py | 330 ++++ tests/test_charge_point_v16.py | 2532 ++++++++++++++++++++++--- tests/test_charge_point_v201.py | 22 +- tests/test_charge_point_v201_multi.py | 372 ++++ tests/test_config_flow.py | 10 +- tests/test_connector_aware_metrics.py | 258 +++ tests/test_init.py | 45 +- tests/test_sensor.py | 60 +- 25 files changed, 5762 insertions(+), 948 deletions(-) create mode 100644 tests/test_api_paths.py create mode 100644 tests/test_charge_point_core.py create mode 100644 tests/test_charge_point_v201_multi.py create mode 100644 tests/test_connector_aware_metrics.py diff --git a/.gitignore b/.gitignore index b6e47617..d70fba6b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# HA Development +/config/ +.DS_Store diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 5c7bf1fe..c07323c2 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -25,6 +25,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_SKIP_SCHEMA_VALIDATION, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -44,6 +45,7 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_FORCE_SMART_CHARGING, DEFAULT_HOST, @@ -192,6 +194,36 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): config_entry, data=new_data, minor_version=0, version=2 ) + if config_entry.version == 2 and config_entry.minor_version == 0: + data = {**config_entry.data} + cpids = data.get(CONF_CPIDS, []) + + changed = False + for idx, cp_map in enumerate(cpids): + if not isinstance(cp_map, dict) or not cp_map: + continue + cp_id, cp_data = next(iter(cp_map.items())) + if CONF_NUM_CONNECTORS not in cp_data: + cp_data = {**cp_data, CONF_NUM_CONNECTORS: DEFAULT_NUM_CONNECTORS} + cpids[idx] = {cp_id: cp_data} + changed = True + + if changed: + data[CONF_CPIDS] = cpids + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + else: + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + _LOGGER.info( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 467021a4..e4df8061 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -2,8 +2,10 @@ from __future__ import annotations +import contextlib import json import logging +import re import ssl from functools import partial @@ -28,6 +30,7 @@ ) from .enums import ( HAChargerServices as csvcs, + HAChargerStatuses as cstat, ) from .chargepoint import SetVariableResult @@ -88,6 +91,10 @@ ) +def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", str(s).lower()) + + class CentralSystem: """Server for handling OCPP connections.""" @@ -188,6 +195,15 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self._server = server return self + @staticmethod + def _norm_conn(connector_id: int | None) -> int: + if connector_id is None: + return 0 + try: + return int(connector_id) + except Exception: + return 0 + def select_subprotocol( self, connection: ServerConnection, subprotocols ) -> Subprotocol | None: @@ -273,59 +289,228 @@ async def on_connect(self, websocket: ServerConnection): charge_point = self.charge_points[cp_id] await charge_point.reconnect(websocket) - def get_metric(self, id: str, measurand: str): - """Return last known value for given measurand.""" - # allow id to be either cpid or cp_id + def _get_metrics(self, id: str): + """Return metrics.""" cp_id = self.cpids.get(id, id) + cp = self.charge_points.get(cp_id) + n_connectors = getattr(cp, "num_connectors", 1) or 1 + return ( + (cp_id, cp._metrics, cp, n_connectors) + if cp is not None + else (None, None, None, None) + ) + + def get_metric(self, id: str, measurand: str, connector_id: int | None = None): + """Return last known value for given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) + if cp is None: + return None + + def _try_val(key): + with contextlib.suppress(Exception): + val = m[key].value + return val + return None + + # 1) Explicit connector_id (including 0): just get it + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_val((conn, measurand)) + + # 2) No connector_id: try CHARGER level (conn=0) + val = _try_val((0, measurand)) + if val is not None: + return val + + # 3) Legacy "flat" key (before the connector support) + with contextlib.suppress(Exception): + val = m[measurand].value + if val is not None: + return val + + # 4) Fallback to connector 1 (old tests often expect this) + if n_connectors >= 1: + val = _try_val((1, measurand)) + if val is not None: + return val + + # 5) Last resort: find the first connector 2..N with value + for c in range(2, int(n_connectors) + 1): + val = _try_val((c, measurand)) + if val is not None: + return val - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].value return None - def del_metric(self, id: str, measurand: str): + def del_metric(self, id: str, measurand: str, connector_id: int | None = None): """Set given measurand to None.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + cp_id, m, cp, n_connectors = self._get_metrics(id) + + if m is None: + return None - if self.cpids.get(cp_id) in self.charge_points: - self.charge_points[cp_id]._metrics[measurand].value = None + conn = self._norm_conn(connector_id) + try: + m[(conn, measurand)].value = None + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + m[measurand].value = None return None - def get_unit(self, id: str, measurand: str): + def get_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return unit of given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + cp_id, m, cp, n_connectors = self._get_metrics(id) + + if cp is None: + return None + + def _try_unit(key): + with contextlib.suppress(Exception): + return m[key].unit + return None + + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_unit((conn, measurand)) + + val = _try_unit((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].unit + if val is not None: + return val + + if n_connectors >= 1: + val = _try_unit((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_unit((c, measurand)) + if val is not None: + return val - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].unit return None - def get_ha_unit(self, id: str, measurand: str): + def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return home assistant unit of given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + cp_id, m, cp, n_connectors = self._get_metrics(id) + + if cp is None: + return None + + def _try_ha_unit(key): + with contextlib.suppress(Exception): + return m[key].ha_unit + return None + + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_ha_unit((conn, measurand)) + + val = _try_ha_unit((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].ha_unit + if val is not None: + return val + + if n_connectors >= 1: + val = _try_ha_unit((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_ha_unit((c, measurand)) + if val is not None: + return val - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].ha_unit return None - def get_extra_attr(self, id: str, measurand: str): - """Return last known extra attributes for given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = None): + """Return extra attributes for given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) + + if cp is None: + return None + + def _try_extra(key): + with contextlib.suppress(Exception): + return m[key].extra_attr + return None + + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_extra((conn, measurand)) + + val = _try_extra((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].extra_attr + if val is not None: + return val + + if n_connectors >= 1: + val = _try_extra((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_extra((c, measurand)) + if val is not None: + return val - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].extra_attr return None - def get_available(self, id: str): - """Return whether the charger is available.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + def get_available(self, id: str, connector_id: int | None = None): + """Return whether the charger (or a specific connector) is available.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - if cp_id in self.charge_points: - return self.charge_points[cp_id].status == STATE_OK - return False + if cp is None: + return None + + if self._norm_conn(connector_id) == 0: + return cp.status == STATE_OK + + status_val = None + with contextlib.suppress(Exception): + status_val = m[ + (self._norm_conn(connector_id), cstat.status_connector.value) + ].value + + if not status_val: + try: + flat = m[cstat.status_connector.value] + if hasattr(flat, "extra_attr"): + status_val = flat.extra_attr.get( + self._norm_conn(connector_id) + ) or getattr(flat, "value", None) + except Exception: + pass + + if not status_val: + return cp.status == STATE_OK + + ok_statuses_norm = { + "available", + "preparing", + "charging", + "suspendedev", + "suspendedevse", + "finishing", + "occupied", + "reserved", + } + + ret = _norm(status_val) in ok_statuses_norm + return ret def get_supported_features(self, id: str): """Return what profiles the charger supports.""" @@ -336,16 +521,26 @@ def get_supported_features(self, id: str): return self.charge_points[cp_id].supported_features return 0 - async def set_max_charge_rate_amps(self, id: str, value: float): + async def set_max_charge_rate_amps( + self, id: str, value: float, connector_id: int = 0 + ): """Set the maximum charge rate in amps.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) if cp_id in self.charge_points: - return await self.charge_points[cp_id].set_charge_rate(limit_amps=value) + return await self.charge_points[cp_id].set_charge_rate( + limit_amps=value, conn_id=connector_id + ) return False - async def set_charger_state(self, id: str, service_name: str, state: bool = True): + async def set_charger_state( + self, + id: str, + service_name: str, + state: bool = True, + connector_id: int | None = 1, + ): """Carry out requested service/state change on connected charger.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) @@ -353,15 +548,19 @@ async def set_charger_state(self, id: str, service_name: str, state: bool = True resp = False if cp_id in self.charge_points: if service_name == csvcs.service_availability.name: - resp = await self.charge_points[cp_id].set_availability(state) + resp = await self.charge_points[cp_id].set_availability( + state, connector_id=connector_id + ) if service_name == csvcs.service_charge_start.name: - resp = await self.charge_points[cp_id].start_transaction() + resp = await self.charge_points[cp_id].start_transaction( + connector_id=connector_id + ) if service_name == csvcs.service_charge_stop.name: resp = await self.charge_points[cp_id].stop_transaction() if service_name == csvcs.service_reset.name: resp = await self.charge_points[cp_id].reset() if service_name == csvcs.service_unlock.name: - resp = await self.charge_points[cp_id].unlock() + resp = await self.charge_points[cp_id].unlock(connector_id=connector_id) return resp def device_info(self): diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index d5763c86..f3e53cbc 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -11,10 +11,17 @@ ButtonEntity, ButtonEntityDescription, ) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, DOMAIN +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, +) from .enums import HAChargerServices @@ -23,6 +30,7 @@ class OcppButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" press_action: str | None = None + per_connector: bool = False BUTTONS: Final = [ @@ -32,29 +40,83 @@ class OcppButtonDescription(ButtonEntityDescription): device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_reset.name, + per_connector=False, ), OcppButtonDescription( key="unlock", name="Unlock", - device_class=ButtonDeviceClass.UPDATE, + device_class=None, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_unlock.name, + per_connector=True, ), ] async def async_setup_entry(hass, entry, async_add_devices): """Configure the Button platform.""" + central_system: CentralSystem = hass.data[DOMAIN][entry.entry_id] + entities: list[ChargePointButton] = [] + ent_reg = er.async_get(hass) - central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - for ent in BUTTONS: - cpx = ChargePointButton(central_system, cpid, ent) - entities.append(cpx) + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in BUTTONS: + if not desc.per_connector: + continue + uid_flat = ".".join([BUTTON_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(BUTTON_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in BUTTONS: + if desc.per_connector: + if num_connectors > 1: + for connector_id in range(1, num_connectors + 1): + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=connector_id, + op_connector_id=connector_id, + ) + ) + else: + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=None, + op_connector_id=1, + ) + ) + else: + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=None, + op_connector_id=None, + ) + ) async_add_devices(entities, False) @@ -62,7 +124,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointButton(ButtonEntity): """Individual button for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppButtonDescription def __init__( @@ -70,26 +132,46 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppButtonDescription, + connector_id: int | None = None, + op_connector_id: int | None = None, ): """Instantiate instance of a ChargePointButton.""" self.cpid = cpid self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [BUTTON_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] - ) + self.connector_id = connector_id + self._op_connector_id = op_connector_id + parts = [BUTTON_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{BUTTON_DOMAIN}.{object_id}" @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid, self._op_connector_id) async def async_press(self) -> None: """Triggers the charger press action service.""" await self.central_system.set_charger_state( - self.cpid, self.entity_description.press_action + self.cpid, + self.entity_description.press_action, + connector_id=self._op_connector_id, ) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index d8fbe8f9..df6ad74c 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -2,6 +2,7 @@ import asyncio from collections import defaultdict +from collections.abc import MutableMapping from dataclasses import dataclass from enum import Enum import logging @@ -16,6 +17,7 @@ from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import UnitOfTime from homeassistant.helpers import device_registry, entity_component, entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_send from websockets.asyncio.server import ServerConnection from websockets.exceptions import WebSocketException from websockets.protocol import State @@ -24,7 +26,6 @@ from ocpp.v16 import call as callv16 from ocpp.v16 import call_result as call_resultv16 from ocpp.v16.enums import ( - UnitOfMeasure, AuthorizationStatus, Measurand, Phase, @@ -51,9 +52,12 @@ CONF_DEFAULT_AUTH_STATUS, CONF_ID_TAG, CONF_MONITORED_VARIABLES, + CONF_NUM_CONNECTORS, CONF_CPIDS, CONFIG, + DATA_UPDATED, DEFAULT_ENERGY_UNIT, + DEFAULT_NUM_CONNECTORS, DEFAULT_POWER_UNIT, DEFAULT_MEASURAND, DOMAIN, @@ -112,6 +116,85 @@ def extra_attr(self, extra_attr: dict): self._extra_attr = extra_attr +class _ConnectorAwareMetrics(MutableMapping): + """Backwards compatible mapping for metrics. + + - m["Power.Active.Import"] -> Metric for connector 0 (flat access) + - m[(2, "Power.Active.Import")] -> Metric for connector 2 (per connector) + - m[2] -> dict[str -> Metric] for connector 2 + + Iteration, len, keys(), values(), items() operate on connector 0 (flat view). + """ + + def __init__(self): + self._by_conn = defaultdict(lambda: defaultdict(lambda: Metric(None, None))) + + def __getitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return self._by_conn[conn][meas] + if isinstance(key, int): + return self._by_conn[key] + return self._by_conn[0][key] + + def __setitem__(self, key, value): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[conn][meas] = value + return + if isinstance(key, int): + if not isinstance(value, dict): + raise TypeError("Connector mapping must be dict[str, Metric].") + self._by_conn[key] = value + return + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[0][key] = value + + def __delitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + del self._by_conn[conn][meas] + return + if isinstance(key, int): + del self._by_conn[key] + return + del self._by_conn[0][key] + + def __iter__(self): + return iter(self._by_conn[0]) + + def __len__(self): + return len(self._by_conn[0]) + + def get(self, key, default=None): + if key in self: + return self[key] + return default + + def keys(self): + return self._by_conn[0].keys() + + def values(self): + return self._by_conn[0].values() + + def items(self): + return self._by_conn[0].items() + + def clear(self): + self._by_conn.clear() + + def __contains__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return meas in self._by_conn.get(conn, {}) + if isinstance(key, int): + return key in self._by_conn + return key in self._by_conn[0] + + class OcppVersion(str, Enum): """OCPP version choice.""" @@ -188,19 +271,32 @@ def __init__( self.post_connect_success = False self.tasks = None self._charger_reports_session_energy = False - self._metrics = defaultdict(lambda: Metric(None, None)) - self._metrics[cdet.identifier.value].value = id - self._metrics[csess.session_time.value].unit = TIME_MINUTES - self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value - self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value + + # Connector-aware, but backwards compatible: + self._metrics: _ConnectorAwareMetrics = _ConnectorAwareMetrics() + + # Init standard metrics for connector 0 + self._metrics[(0, cdet.identifier.value)].value = id + self._metrics[(0, cstat.reconnects.value)].value = 0 + self._attr_supported_features = prof.NONE - self._metrics[cstat.reconnects.value].value = 0 alphabet = string.ascii_uppercase + string.digits self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) + self.num_connectors: int = DEFAULT_NUM_CONNECTORS + + def _init_connector_slots(self, conn_id: int) -> None: + """Ensure connector-scoped metrics exist and carry the right units.""" + _ = self._metrics[(conn_id, cstat.status_connector.value)] + _ = self._metrics[(conn_id, cstat.error_code_connector.value)] + _ = self._metrics[(conn_id, csess.transaction_id.value)] + + self._metrics[(conn_id, csess.session_time.value)].unit = TIME_MINUTES + self._metrics[(conn_id, csess.session_energy.value)].unit = HA_ENERGY_UNIT + self._metrics[(conn_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" - return 0 + return self.num_connectors async def get_heartbeat_interval(self): """Retrieve heartbeat interval from the charger and store it.""" @@ -221,26 +317,33 @@ async def get_supported_features(self) -> prof: async def fetch_supported_features(self): """Get supported features.""" self._attr_supported_features = await self.get_supported_features() - self._metrics[cdet.features.value].value = self._attr_supported_features - _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) + self._metrics[(0, cdet.features.value)].value = self._attr_supported_features + _LOGGER.debug( + "Feature profiles returned: %s", self._attr_supported_features.labels() + ) async def post_connect(self): """Logic to be executed right after a charger connects.""" - try: self.status = STATE_OK await self.fetch_supported_features() num_connectors: int = await self.get_number_of_connectors() - self._metrics[cdet.connectors.value].value = num_connectors + self.num_connectors = num_connectors + for conn in range(1, self.num_connectors + 1): + self._init_connector_slots(conn) + self._metrics[(0, cdet.connectors.value)].value = self.num_connectors await self.get_heartbeat_interval() accepted_measurands: str = await self.get_supported_measurands() updated_entry = {**self.entry.data} for i in range(len(updated_entry[CONF_CPIDS])): if self.id in updated_entry[CONF_CPIDS][i]: - updated_entry[CONF_CPIDS][i][self.id][CONF_MONITORED_VARIABLES] = ( - accepted_measurands - ) + s = updated_entry[CONF_CPIDS][i][self.id] + if s.get(CONF_MONITORED_VARIABLES) != accepted_measurands or s.get( + CONF_NUM_CONNECTORS + ) != int(self.num_connectors): + s[CONF_MONITORED_VARIABLES] = accepted_measurands + s[CONF_NUM_CONNECTORS] = int(self.num_connectors) break # if an entry differs this will unload/reload and stop/restart the central system/websocket self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) @@ -248,7 +351,7 @@ async def post_connect(self): await self.set_standard_configuration() self.post_connect_success = True - _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") + _LOGGER.debug("'%s' post connection setup completed successfully", self.id) # nice to have, but not needed for integration to function # and can cause issues with some chargers @@ -293,7 +396,7 @@ async def set_availability(self, state: bool = True) -> bool: """Change availability.""" return False - async def start_transaction(self) -> bool: + async def start_transaction(self, connector_id: int = 1) -> bool: """Remote start a transaction.""" return False @@ -314,9 +417,11 @@ async def unlock(self, connector_id: int = 1) -> bool: return False async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" + """Update charger with new firmware if available. + + - firmware_url is the http or https url of the new firmware + - wait_time is hours from now to wait before install + """ pass async def get_diagnostics(self, upload_url: str): @@ -349,8 +454,8 @@ async def _get_specific_response(self, unique_id, timeout): async def monitor_connection(self): """Monitor the connection, by measuring the connection latency.""" - self._metrics[cstat.latency_ping.value].unit = "ms" - self._metrics[cstat.latency_pong.value].unit = "ms" + self._metrics[(0, cstat.latency_ping.value)].unit = "ms" + self._metrics[(0, cstat.latency_pong.value)].unit = "ms" connection = self._connection timeout_counter = 0 # Add backstop to start post connect for non-compliant chargers @@ -378,15 +483,15 @@ async def monitor_connection(self): _LOGGER.debug( f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong except TimeoutError as timeout_exception: _LOGGER.debug( f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong timeout_counter += 1 if timeout_counter > self.cs_settings.websocket_ping_tries: _LOGGER.debug( @@ -405,7 +510,6 @@ async def _handle_call(self, msg): async def start(self): """Start charge point.""" - # post connect now handled on receiving boot notification or with backstop in monitor connection await self.run([super().start(), self.monitor_connection()]) async def run(self, tasks): @@ -441,19 +545,19 @@ async def reconnect(self, connection: ServerConnection): await self.stop() self.status = STATE_OK self._connection = connection - self._metrics[cstat.reconnects.value].value += 1 + self._metrics[(0, cstat.reconnects.value)].value += 1 # post connect now handled on receiving boot notification or with backstop in monitor connection await self.run([super().start(), self.monitor_connection()]) async def async_update_device_info( self, serial: str, vendor: str, model: str, firmware_version: str ): - """Update device info asynchronuously.""" + """Update device info asynchronously.""" - self._metrics[cdet.model.value].value = model - self._metrics[cdet.vendor.value].value = vendor - self._metrics[cdet.firmware_version.value].value = firmware_version - self._metrics[cdet.serial.value].value = serial + self._metrics[(0, cdet.model.value)].value = model + self._metrics[(0, cdet.vendor.value)].value = vendor + self._metrics[(0, cdet.firmware_version.value)].value = firmware_version + self._metrics[(0, cdet.serial.value)].value = serial identifiers = {(DOMAIN, self.id), (DOMAIN, self.settings.cpid)} @@ -473,24 +577,43 @@ def _register_boot_notification(self): self.hass.async_create_task(self.post_connect()) async def update(self, cpid: str): - """Update sensors values in HA.""" + """Update sensors values in HA (charger + connector child devices).""" er = entity_registry.async_get(self.hass) dr = device_registry.async_get(self.hass) identifiers = {(DOMAIN, cpid), (DOMAIN, self.id)} - dev = dr.async_get_device(identifiers) - # _LOGGER.info("Device id: %s updating", dev.name) - for ent in entity_registry.async_entries_for_device(er, dev.id): - # _LOGGER.info("Entity id: %s updating", ent.entity_id) - self.hass.async_create_task( - entity_component.async_update_entity(self.hass, ent.entity_id) - ) + root_dev = dr.async_get_device(identifiers) + if root_dev is None: + return + + to_visit = [root_dev.id] + visited = set() + updated_entities = 0 + found_children = 0 + + while to_visit: + dev_id = to_visit.pop(0) + if dev_id in visited: + continue + visited.add(dev_id) + + for ent in entity_registry.async_entries_for_device(er, dev_id): + self.hass.async_create_task( + entity_component.async_update_entity(self.hass, ent.entity_id) + ) + updated_entities += 1 + + for dev in dr.devices.values(): + if dev.via_device_id == dev_id and dev.id not in visited: + found_children += 1 + to_visit.append(dev.id) + + async_dispatcher_send(self.hass, DATA_UPDATED) def get_authorization_status(self, id_tag): """Get the authorization status for an id_tag.""" # authorize if its the tag of this charger used for remote start_transaction if id_tag == self._remote_id_tag: return AuthorizationStatus.accepted.value - # get the domain wide configuration config = self.hass.data[DOMAIN].get(CONFIG, {}) # get the default authorization status. Use accept if not configured default_auth_status = config.get( @@ -517,8 +640,8 @@ def get_authorization_status(self, id_tag): ) return auth_status - def process_phases(self, data: list[MeasurandValue]): - """Process phase data from meter values .""" + def process_phases(self, data: list[MeasurandValue], connector_id: int = 0): + """Process phase data from meter values.""" def average_of_nonzero(values): nonzero_values: list = [v for v in values if v != 0.0] @@ -539,10 +662,14 @@ def average_of_nonzero(values): measurand_data[measurand] = {} measurand_data[measurand][om.unit.value] = unit measurand_data[measurand][phase] = value - self._metrics[measurand].unit = unit - self._metrics[measurand].extra_attr[om.unit.value] = unit - self._metrics[measurand].extra_attr[phase] = value - self._metrics[measurand].extra_attr[om.context.value] = context + self._metrics[(connector_id, measurand)].unit = unit + self._metrics[(connector_id, measurand)].extra_attr[om.unit.value] = ( + unit + ) + self._metrics[(connector_id, measurand)].extra_attr[phase] = value + self._metrics[(connector_id, measurand)].extra_attr[ + om.context.value + ] = context line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] @@ -580,22 +707,15 @@ def average_of_nonzero(values): if metric_value is not None: metric_unit = phase_info.get(om.unit.value) - _LOGGER.debug( - "process_phases: metric: %s, phase_info: %s value: %f unit :%s", - metric, - phase_info, - metric_value, - metric_unit, - ) if metric_unit == DEFAULT_POWER_UNIT: - self._metrics[metric].value = metric_value / 1000 - self._metrics[metric].unit = HA_POWER_UNIT + self._metrics[(connector_id, metric)].value = metric_value / 1000 + self._metrics[(connector_id, metric)].unit = HA_POWER_UNIT elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[metric].value = metric_value / 1000 - self._metrics[metric].unit = HA_ENERGY_UNIT + self._metrics[(connector_id, metric)].value = metric_value / 1000 + self._metrics[(connector_id, metric)].unit = HA_ENERGY_UNIT else: - self._metrics[metric].value = metric_value - self._metrics[metric].unit = metric_unit + self._metrics[(connector_id, metric)].value = metric_value + self._metrics[(connector_id, metric)].unit = metric_unit @staticmethod def get_energy_kwh(measurand_value: MeasurandValue) -> float: @@ -605,116 +725,128 @@ def get_energy_kwh(measurand_value: MeasurandValue) -> float: return measurand_value.value def process_measurands( - self, meter_values: list[list[MeasurandValue]], is_transaction: bool + self, + meter_values: list[list[MeasurandValue]], + is_transaction: bool, + connector_id: int = 0, ): - """Process all value from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" + """Process all values from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" for bucket in meter_values: unprocessed: list[MeasurandValue] = [] - for idx in range(len(bucket)): - sampled_value: MeasurandValue = bucket[idx] + for sampled_value in bucket: measurand = sampled_value.measurand value = sampled_value.value unit = sampled_value.unit phase = sampled_value.phase location = sampled_value.location context = sampled_value.context - # where an empty string is supplied convert to 0 - if sampled_value.measurand is None: # Backwards compatibility + # If the measurand is missing: treat as EAIR but respect existing unit + if measurand is None: measurand = DEFAULT_MEASURAND - unit = DEFAULT_ENERGY_UNIT + if unit is None: + unit = DEFAULT_ENERGY_UNIT + # If EAIR and unit missing, assume Wh (charger not sending unit) if measurand == DEFAULT_MEASURAND and unit is None: unit = DEFAULT_ENERGY_UNIT - if unit == DEFAULT_ENERGY_UNIT: - value = ChargePoint.get_energy_kwh(sampled_value) + # Normalize units + if unit == DEFAULT_ENERGY_UNIT or ( + measurand == DEFAULT_MEASURAND and unit is None + ): + # Wh → kWh + value = ChargePoint.get_energy_kwh( + MeasurandValue(measurand, value, phase, unit, context, location) + ) unit = HA_ENERGY_UNIT - - if unit == DEFAULT_POWER_UNIT: + elif unit == DEFAULT_POWER_UNIT: + # W → kW value = value / 1000 unit = HA_POWER_UNIT - if self._metrics[csess.meter_start.value].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + # Only flag if meter_start explicitly is 0 (not None) + if self._metrics[(connector_id, csess.meter_start.value)].value == 0: self._charger_reports_session_energy = True if phase is None: + # Set main measurand + self._metrics[(connector_id, measurand)].value = value + self._metrics[(connector_id, measurand)].unit = unit + + if location is not None: + self._metrics[(connector_id, measurand)].extra_attr[ + om.location.value + ] = location + if context is not None: + self._metrics[(connector_id, measurand)].extra_attr[ + om.context.value + ] = context + + # Energy.Session is calculated here only for OCPP 2.x (not 1.6) if ( measurand == DEFAULT_MEASURAND - and self._charger_reports_session_energy + and is_transaction + and self._ocpp_version != "1.6" ): - # Ignore messages with Transaction Begin context - if context != ReadingContext.transaction_begin.value: - if is_transaction: - self._metrics[csess.session_energy.value].value = value - self._metrics[csess.session_energy.value].unit = unit - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit - else: - continue - else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit if ( - is_transaction - and (measurand == DEFAULT_MEASURAND) - and (self._metrics[csess.meter_start].value is not None) - and (self._metrics[csess.meter_start].unit == unit) + self._charger_reports_session_energy + and context != ReadingContext.transaction_begin.value ): - meter_start = self._metrics[csess.meter_start].value - self._metrics[csess.session_energy.value].value = ( - round(1000 * (value - meter_start)) / 1000 - ) - self._metrics[csess.session_energy.value].unit = unit - if location is not None: - self._metrics[measurand].extra_attr[om.location.value] = ( - location - ) - if context is not None: - self._metrics[measurand].extra_attr[om.context.value] = context + # The charger reports session energy directly (2.x case) + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = value + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + self._metrics[ + (connector_id, csess.session_energy.value) + ].extra_attr[cstat.id_tag.name] = self._metrics[ + (connector_id, cstat.id_tag.value) + ].value + else: + # Derive: EAIR_kWh - meter_start_kWh + ms_val = self._metrics[ + (connector_id, csess.meter_start.value) + ].value + if ms_val is not None: + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = ( + round(1000 * (float(value) - float(ms_val))) / 1000 + ) + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT else: + # Handle phase values separately unprocessed.append(sampled_value) - self.process_phases(unprocessed) + + # Sum/calculate phase values + self.process_phases(unprocessed, connector_id) @property def supported_features(self) -> int: """Flag of Ocpp features that are supported.""" return self._attr_supported_features - def get_metric(self, measurand: str): - """Return last known value for given measurand.""" - return self._metrics[measurand].value - - def get_ha_metric(self, measurand: str): - """Return last known value in HA for given measurand.""" - entity_id = "sensor." + "_".join( - [self.settings.cpid.lower(), measurand.lower().replace(".", "_")] - ) - try: - value = self.hass.states.get(entity_id).state - except Exception as e: - _LOGGER.debug(f"An error occurred when getting entity state from HA: {e}") - return None - if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN: - return None - return value - - def get_extra_attr(self, measurand: str): - """Return last known extra attributes for given measurand.""" - return self._metrics[measurand].extra_attr - - def get_unit(self, measurand: str): - """Return unit of given measurand.""" - return self._metrics[measurand].unit - - def get_ha_unit(self, measurand: str): - """Return home assistant unit of given measurand.""" - return self._metrics[measurand].ha_unit + def get_ha_metric(self, measurand: str, connector_id: int | None = None): + """Return last known value in HA for given measurand, or None if not available.""" + base = self.settings.cpid.lower() + meas_slug = measurand.lower().replace(".", "_") + + candidates: list[str] = [] + if connector_id and connector_id > 0: + candidates.append(f"sensor.{base}_connector_{connector_id}_{meas_slug}") + else: + candidates.append(f"sensor.{base}_{meas_slug}") + + for entity_id in candidates: + st = self.hass.states.get(entity_id) + if st and st.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return st.state + return None async def notify_ha(self, msg: str, title: str = "Ocpp integration"): """Notify user via HA web frontend.""" diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 67e6c2c5..4556c305 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -20,6 +20,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -39,6 +40,7 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -106,7 +108,7 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" VERSION = 2 - MINOR_VERSION = 0 + MINOR_VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): @@ -115,6 +117,7 @@ def __init__(self): self._cp_id: str self._entry: ConfigEntry self._measurands: str = "" + self._detected_num_connectors: int = DEFAULT_NUM_CONNECTORS async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle user central system initiated configuration.""" @@ -141,6 +144,10 @@ async def async_step_integration_discovery( self._cp_id = discovery_info["cp_id"] self._data = {**self._entry.data} + self._detected_num_connectors = discovery_info.get( + CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS + ) + await self.async_set_unique_id(self._cp_id) # Abort the flow if a config entry with the same unique ID exists self._abort_if_unique_id_configured() @@ -155,7 +162,12 @@ async def async_step_cp_user( if user_input is not None: # Don't allow duplicate cpids to be used self._async_abort_entries_match({CONF_CPID: user_input[CONF_CPID]}) - self._data[CONF_CPIDS].append({self._cp_id: user_input}) + + cp_data = { + **user_input, + CONF_NUM_CONNECTORS: self._detected_num_connectors, + } + self._data[CONF_CPIDS].append({self._cp_id: cp_data}) if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( DEFAULT_MONITORED_VARIABLES diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index a27066bf..fbb5b2ad 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -25,6 +25,7 @@ CONF_MONITORED_VARIABLES = ha.CONF_MONITORED_VARIABLES CONF_MONITORED_VARIABLES_AUTOCONFIG = "monitored_variables_autoconfig" CONF_NAME = ha.CONF_NAME +CONF_NUM_CONNECTORS = "num_connectors" CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT CONF_SKIP_SCHEMA_VALIDATION = "skip_schema_validation" @@ -45,6 +46,7 @@ DEFAULT_CPID = "charger" DEFAULT_HOST = "0.0.0.0" DEFAULT_MAX_CURRENT = 32 +DEFAULT_NUM_CONNECTORS = 1 DEFAULT_PORT = 9000 DEFAULT_SKIP_SCHEMA_VALIDATION = False DEFAULT_FORCE_SMART_CHARGING = False @@ -151,6 +153,7 @@ class ChargerSystemSettings: skip_schema_validation: bool force_smart_charging: bool connection: int | None = None # number of this connection in central server + num_connectors: int = DEFAULT_NUM_CONNECTORS @dataclass diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index 241ed524..9a206f60 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -1,6 +1,6 @@ """Additional enumerated values to use in home assistant.""" -from enum import Enum, Flag, auto +from enum import Enum, IntFlag, auto class HAChargerServices(str, Enum): @@ -62,7 +62,7 @@ class HAChargerSession(str, Enum): meter_start = "Energy.Meter.Start" # in kWh -class Profiles(Flag): +class Profiles(IntFlag): """Flags to indicate supported feature profiles.""" NONE = 0 @@ -73,6 +73,12 @@ class Profiles(Flag): REM = auto() # RemoteTrigger AUTH = auto() # LocalAuthListManagement + def labels(self): + """Get labels for profiles.""" + if self == Profiles.NONE: + return "NONE" + return "|".join([p.name for p in Profiles if p & self]) + class OcppMisc(str, Enum): """Miscellaneous strings used in ocpp v1.6 responses.""" diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index e3a501dd..86d2bd94 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -13,6 +13,7 @@ ) from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -21,8 +22,10 @@ CONF_CPID, CONF_CPIDS, CONF_MAX_CURRENT, + CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_MAX_CURRENT, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, ) @@ -54,19 +57,85 @@ class OcppNumberDescription(NumberEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" - central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[ChargePointNumber] = [] + ent_reg = er.async_get(hass) + for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - for ent in NUMBERS: - if ent.key == "maximum_current": - ent.initial_value = cp_id_settings[CONF_MAX_CURRENT] - ent.native_max_value = cp_id_settings[CONF_MAX_CURRENT] - cpx = ChargePointNumber(hass, central_system, cpid, ent) - entities.append(cpx) + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in NUMBERS: + uid_flat = ".".join([NUMBER_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(NUMBER_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in NUMBERS: + if desc.key == "maximum_current": + max_cur = float( + cp_id_settings.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) + ) + ent_initial = max_cur + ent_max = max_cur + else: + ent_initial = desc.initial_value + ent_max = desc.native_max_value + + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointNumber( + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, + initial_value=ent_initial, + native_min_value=desc.native_min_value, + native_max_value=ent_max, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, + ), + connector_id=conn_id, + op_connector_id=conn_id, + ) + ) + else: + entities.append( + ChargePointNumber( + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, + initial_value=ent_initial, + native_min_value=desc.native_min_value, + native_max_value=ent_max, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, + ), + connector_id=None, + op_connector_id=0, + ) + ) async_add_devices(entities, False) @@ -74,7 +143,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointNumber(RestoreNumber, NumberEntity): """Individual slider for setting charge rate.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppNumberDescription def __init__( @@ -83,22 +152,42 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppNumberDescription, + connector_id: int | None = None, + op_connector_id: int | None = None, ): """Initialize a Number instance.""" self.cpid = cpid self._hass = hass self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [NUMBER_DOMAIN, self.cpid, self.entity_description.key] + self.connector_id = connector_id + self._op_connector_id = ( + op_connector_id if op_connector_id is not None else (connector_id or 1) ) + + parts = [NUMBER_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{NUMBER_DOMAIN}.{object_id}" self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False - self._attr_available = True async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -113,24 +202,24 @@ async def async_added_to_hass(self) -> None: def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) - # @property - # def available(self) -> bool: - # """Return if entity is available.""" - # if not ( - # Profiles.SMART & self.central_system.get_supported_features(self.cpid) - # ): - # return False - # return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + @property + def available(self) -> bool: + """Return if entity is available.""" + features = self.central_system.get_supported_features(self.cpid) + has_smart = bool(features & Profiles.SMART) + return bool( + self.central_system.get_available(self.cpid, self._op_connector_id) + and has_smart + ) async def async_set_native_value(self, value): - """Set new value.""" + """Set new value for max current (station-wide when _op_connector_id==0, otherwise per-connector).""" num_value = float(value) - if self.central_system.get_available( - self.cpid - ) and Profiles.SMART & self.central_system.get_supported_features(self.cpid): - resp = await self.central_system.set_max_charge_rate_amps( - self.cpid, num_value - ) - if resp is True: - self._attr_native_value = num_value - self.async_write_ha_state() + resp = await self.central_system.set_max_charge_rate_amps( + self.cpid, + num_value, + connector_id=self._op_connector_id, + ) + if resp is True: + self._attr_native_value = num_value + self.async_write_ha_state() diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index f4093f20..f1076723 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfTime import voluptuous as vol from websockets.asyncio.server import ServerConnection @@ -55,13 +56,30 @@ CentralSystemSettings, ChargerSystemSettings, DEFAULT_MEASURAND, + DEFAULT_ENERGY_UNIT, DOMAIN, + HA_ENERGY_UNIT, ) _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) +def _to_message_trigger(name: str) -> MessageTrigger | None: + if isinstance(name, MessageTrigger): + return name + key = str(name).strip().replace(" ", "").replace("_", "").lower() + mapping = { + "bootnotification": MessageTrigger.boot_notification, + "heartbeat": MessageTrigger.heartbeat, + "metervalues": MessageTrigger.meter_values, + "statusnotification": MessageTrigger.status_notification, + "diagnosticsstatusnotification": MessageTrigger.diagnostics_status_notification, + "firmwarestatusnotification": MessageTrigger.firmware_status_notification, + } + return mapping.get(key) + + class ChargePoint(cp): """Server side representation of a charger.""" @@ -85,10 +103,52 @@ def __init__( central, charger, ) + self._active_tx: dict[int, int] = {} # connector_id -> transaction_id - async def get_number_of_connectors(self): + def _profile_ids_for_connector(self, conn_id: int) -> tuple[int, int]: + """Return (profile_id, stack_level) that is stable and unique per connector.""" + return 1000 + max(1, int(conn_id)), 1 + + async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" - return await self.get_configuration(ckey.number_of_connectors.value) + resp = None + + try: + req = call.GetConfiguration(key=["NumberOfConnectors"]) + resp = await self.call(req) + except Exception: + resp = None + + cfg = None + if resp is not None: + cfg = getattr(resp, "configuration_key", None) + + if ( + cfg is None + and isinstance(resp, list | tuple) + and len(resp) >= 3 + and isinstance(resp[2], dict) + ): + cfg = resp[2].get("configurationKey") or resp[2].get( + "configuration_key" + ) + + if cfg: + for kv in cfg: + k = getattr(kv, "key", None) + v = getattr(kv, "value", None) + if k is None and isinstance(kv, dict): + k = kv.get("key") + v = kv.get("value") + if k == "NumberOfConnectors" and v not in (None, ""): + try: + n = int(str(v).strip()) + if n > 0: + return n + except (ValueError, TypeError): + pass + + return 1 async def get_heartbeat_interval(self): """Retrieve heartbeat interval from the charger and store it.""" @@ -210,52 +270,67 @@ async def trigger_status_notification(self): """Trigger status notifications for all connectors.""" return_value = True try: - nof_connectors = int(self._metrics[cdet.connectors.value].value) - except TypeError: + nof_connectors = int(self._metrics[0][cdet.connectors.value].value or 1) + except Exception: nof_connectors = 1 - for id in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={id}") + for cid in range(0, nof_connectors + 1): + _LOGGER.debug(f"trigger status notification for connector={cid}") req = call.TriggerMessage( requested_message=MessageTrigger.status_notification, - connector_id=int(id), + connector_id=int(cid), ) resp = await self.call(req) if resp.status != TriggerMessageStatus.accepted: _LOGGER.warning("Failed with response: %s", resp.status) _LOGGER.warning( "Forcing number of connectors to %d, charger returned %d", - id - 1, + cid - 1, nof_connectors, ) - self._metrics[cdet.connectors.value].value = max(1, id - 1) - return_value = id > 1 + self._metrics[0][cdet.connectors.value].value = max(1, cid - 1) + return_value = cid > 1 break return return_value async def trigger_custom_message( self, - requested_message: str = "StatusNotification", + requested_message: str | MessageTrigger = "StatusNotification", ): """Trigger Custom Message.""" - req = call.TriggerMessage(requested_message) + trig = _to_message_trigger(requested_message) + if trig is None: + _LOGGER.warning("Unsupported TriggerMessage: %s", requested_message) + return False + + req = call.TriggerMessage(requested_message=trig) resp = await self.call(req) if resp.status != TriggerMessageStatus.accepted: _LOGGER.warning("Failed with response: %s", resp.status) return False return True - async def clear_profile(self): - """Clear all charging profiles.""" - req = call.ClearChargingProfile() + async def clear_profile( + self, + conn_id: int | None = None, + purpose: ChargingProfilePurposeType | None = None, + ) -> bool: + """Clear charging profiles (per connector and/or purpose).""" + target_connector = int(conn_id) if conn_id is not None else None + req = call.ClearChargingProfile( + connector_id=target_connector, + charging_profile_purpose=purpose.value if purpose is not None else None, + ) resp = await self.call(req) - if resp.status == ClearChargingProfileStatus.accepted: + if resp.status in ( + ClearChargingProfileStatus.accepted, + ClearChargingProfileStatus.unknown, + ): return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Clear profile failed with response {resp.status}" - ) - return False + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Clear profile failed with response {resp.status}" + ) + return False async def set_charge_rate( self, @@ -263,104 +338,108 @@ async def set_charge_rate( limit_watts: int = 22000, conn_id: int = 0, profile: dict | None = None, - ): - """Set a charging profile with defined limit.""" - if profile is not None: # assumes advanced user and correct profile format + ) -> bool: + """Set charge rate.""" + if profile is not None: req = call.SetChargingProfile( - connector_id=conn_id, cs_charging_profiles=profile + connector_id=int(conn_id), + cs_charging_profiles=profile, ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - - if prof.SMART in self._attr_supported_features: - resp = await self.get_configuration( - ckey.charging_schedule_allowed_charging_rate_unit.value - ) - _LOGGER.info( - "Charger supports setting the following units: %s", - resp, + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" ) - _LOGGER.info("If more than one unit supported default unit is Amps") - # Some chargers (e.g. Teison) don't support querying charging rate unit - if resp is None: - _LOGGER.warning("Failed to query charging rate unit, assuming Amps") - resp = om.current.value - if om.current.value in resp: - lim = limit_amps - units = ChargingRateUnitType.amps.value - else: - lim = limit_watts - units = ChargingRateUnitType.watts.value - resp = await self.get_configuration( + return False + + resp_units = await self.get_configuration( + ckey.charging_schedule_allowed_charging_rate_unit.value + ) + if resp_units is None: + _LOGGER.warning("Failed to query charging rate unit, assuming Amps") + resp_units = om.current.value + + use_amps = om.current.value in resp_units + limit_val = float(limit_amps if use_amps else limit_watts) + unit_val = ( + ChargingRateUnitType.amps.value + if use_amps + else ChargingRateUnitType.watts.value + ) + + conn_id = int(conn_id or 0) + is_station_level = conn_id == 0 + + if is_station_level: + purpose = ChargingProfilePurposeType.charge_point_max_profile + resp_stack = await self.get_configuration( ckey.charge_profile_max_stack_level.value ) - stack_level = int(resp) - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, - ) + try: + stack_level = int(resp_stack) + except Exception: + stack_level = 1 + profile_id = 8 else: - _LOGGER.info("Smart charging is not supported by this charger") - return False + purpose = ChargingProfilePurposeType.tx_default_profile + profile_id, stack_level = self._profile_ids_for_connector(conn_id) + + is_default = (limit_amps >= 32) and (limit_watts >= 22000) + if is_default: + return await self.clear_profile( + conn_id=None if is_station_level else conn_id, + purpose=purpose, + ) + + cs_profile = { + om.charging_profile_id.value: profile_id, + om.stack_level.value: stack_level, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: purpose.value, + om.charging_schedule.value: { + om.charging_rate_unit.value: unit_val, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: limit_val} + ], + }, + } + + req = call.SetChargingProfile( + connector_id=conn_id, + cs_charging_profiles=cs_profile, + ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.debug( - "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." - ) - # try a lower stack level for chargers where level < maximum, not <= - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level - 1, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, + + if is_station_level and resp.status != ChargingProfileStatus.accepted: + _LOGGER.debug("Station profile rejected, trying lower stack level …") + cs_profile[om.stack_level.value] = max(1, stack_level - 1) + resp = await self.call( + call.SetChargingProfile( + connector_id=0, + cs_charging_profiles=cs_profile, + ) ) - resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - async def set_availability(self, state: bool = True): + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" + ) + return False + + async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" if state is True: typ = AvailabilityType.operative.value else: typ = AvailabilityType.inoperative.value - req = call.ChangeAvailability(connector_id=0, type=typ) + req = call.ChangeAvailability(connector_id=int(connector_id or 0), type=typ) resp = await self.call(req) if resp.status in [ AvailabilityStatus.accepted, @@ -374,10 +453,12 @@ async def set_availability(self, state: bool = True): ) return False - async def start_transaction(self): + async def start_transaction(self, connector_id: int = 1): """Remote start a transaction.""" _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) - req = call.RemoteStartTransaction(connector_id=1, id_tag=self._remote_id_tag) + req = call.RemoteStartTransaction( + connector_id=connector_id, id_tag=self._remote_id_tag + ) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -394,9 +475,14 @@ async def stop_transaction(self): Leaves charger in finishing state until unplugged. Use reset() to make the charger available again for remote start """ - if self.active_transaction_id == 0: + if self.active_transaction_id == 0 and not any(self._active_tx.values()): return True - req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) + tx_id = self.active_transaction_id or next( + (v for v in self._active_tx.values() if v), 0 + ) + if tx_id == 0: + return True + req = call.RemoteStopTransaction(transaction_id=tx_id) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -409,7 +495,7 @@ async def stop_transaction(self): async def reset(self, typ: str = ResetType.hard): """Hard reset charger unless soft reset requested.""" - self._metrics[cstat.reconnects.value].value = 0 + self._metrics[0][cstat.reconnects.value].value = 0 req = call.Reset(typ) resp = await self.call(req) if resp.status == ResetStatus.accepted: @@ -431,40 +517,58 @@ async def unlock(self, connector_id: int = 1): return False async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(firmware_url) - except vol.MultipleInvalid as e: - _LOGGER.debug("Failed to parse url: %s", e) - update_time = (datetime.now(tz=UTC) + timedelta(hours=wait_time)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - req = call.UpdateFirmware(location=url, retrieve_date=update_time) + """Update charger with new firmware if available. + + - firmware_url: http/https URL of the new firmware + - wait_time: hours from now to wait before install + """ + features = int(self._attr_supported_features or 0) + if not (features & prof.FW): + _LOGGER.warning("Charger does not support OCPP firmware updating") + return False + + schema = vol.Schema(vol.Url()) + try: + url = schema(firmware_url) + except vol.MultipleInvalid as e: + _LOGGER.warning("Failed to parse url: %s", e) + return False + + try: + retrieve_time = ( + datetime.now(tz=UTC) + timedelta(hours=max(0, int(wait_time or 0))) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + retrieve_time = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + try: + req = call.UpdateFirmware(location=str(url), retrieve_date=retrieve_time) resp = await self.call(req) - _LOGGER.info("Response: %s", resp) + _LOGGER.info("UpdateFirmware response: %s", resp) return True - else: - _LOGGER.warning("Charger does not support ocpp firmware updating") + except Exception as e: + _LOGGER.error("UpdateFirmware failed: %s", e) return False async def get_diagnostics(self, upload_url: str): """Upload diagnostic data to server from charger.""" - if prof.FW in self._attr_supported_features: + features = int(self._attr_supported_features or 0) + if features & prof.FW: schema = vol.Schema(vol.Url()) try: url = schema(upload_url) except vol.MultipleInvalid as e: _LOGGER.warning("Failed to parse url: %s", e) - req = call.GetDiagnostics(location=url) + return + req = call.GetDiagnostics(location=str(url)) resp = await self.call(req) _LOGGER.info("Response: %s", resp) return True else: - _LOGGER.warning("Charger does not support ocpp diagnostics uploading") + _LOGGER.debug( + "Charger %s does not support ocpp diagnostics uploading", + self.id, + ) return False async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): @@ -479,8 +583,10 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = data, resp.data, ) - self._metrics[cdet.data_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} + self._metrics[0][cdet.data_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_response.value].extra_attr = { + message_id: resp.data + } return True else: _LOGGER.warning("Failed with response: %s", resp.status) @@ -499,8 +605,8 @@ async def get_configuration(self, key: str = "") -> str: if resp.configuration_key: value = resp.configuration_key[0][om.value.value] _LOGGER.debug("Get Configuration for %s: %s", key, value) - self._metrics[cdet.config_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.config_response.value].extra_attr = {key: value} + self._metrics[0][cdet.config_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.config_response.value].extra_attr = {key: value} return value if resp.unknown_key: _LOGGER.warning("Get Configuration returned unknown key for: %s", key) @@ -570,81 +676,134 @@ async def async_update_device_info_v16(self, boot_info: dict): @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Request handler for MeterValues Calls.""" + """Handle MeterValues (per connector). + + - EAIR (Energy.Active.Import.Register) **without** transactionId is treated as main meter, + written to connector 0 (aggregate). + - EAIR **with** transactionId is written to the proper connector (connector_id) and used + to update Energy.Session (kWh). + - Other measurands handled via process_measurands(). + """ + transaction_id: int | None = kwargs.get(om.transaction_id.name, None) + tx_has_id: bool = transaction_id not in (None, 0) - transaction_id: int = kwargs.get(om.transaction_id.name, 0) + active_tx_for_conn: int = int(self._active_tx.get(connector_id, 0) or 0) # If missing meter_start or active_transaction_id try to restore from HA states. If HA # does not have values either, generate new ones. - if self._metrics[csess.meter_start.value].value is None: - value = self.get_ha_metric(csess.meter_start.value) - if value is None: - value = self._metrics[DEFAULT_MEASURAND].value + if self._metrics[(connector_id, csess.meter_start.value)].value is None: + restored = self.get_ha_metric(csess.meter_start.value, connector_id) + if restored is None: + restored = self._metrics[(connector_id, DEFAULT_MEASURAND)].value else: - value = float(value) - _LOGGER.debug( - f"{csess.meter_start.value} was None, restored value={value} from HA." - ) - self._metrics[csess.meter_start.value].value = value - if self._metrics[csess.transaction_id.value].value is None: - value = self.get_ha_metric(csess.transaction_id.value) - if value is None: - value = kwargs.get(om.transaction_id.name) + try: + restored = float(restored) + except (ValueError, TypeError): + restored = None + if restored is not None: + self._metrics[(connector_id, csess.meter_start.value)].value = restored + + if self._metrics[(connector_id, csess.transaction_id.value)].value is None: + restored_tx = self.get_ha_metric(csess.transaction_id.value, connector_id) + candidate: int | None + if restored_tx is not None: + try: + candidate = int(restored_tx) + except (ValueError, TypeError): + candidate = None else: - value = int(value) - _LOGGER.debug( - f"{csess.transaction_id.value} was None, restored value={value} from HA." - ) - self._metrics[csess.transaction_id.value].value = value - self.active_transaction_id = value + candidate = transaction_id if tx_has_id else None + + if candidate is not None and candidate != 0: + self._metrics[ + (connector_id, csess.transaction_id.value) + ].value = candidate + self._active_tx[connector_id] = candidate + active_tx_for_conn = candidate - transaction_matches: bool = False - # match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0 - if transaction_id == self.active_transaction_id and transaction_id != 0: - transaction_matches = True - elif transaction_id != 0: - _LOGGER.warning("Unknown transaction detected with id=%i", transaction_id) + if tx_has_id: + transaction_matches = transaction_id == active_tx_for_conn + else: + transaction_matches = active_tx_for_conn not in (None, 0) meter_values: list[list[MeasurandValue]] = [] for bucket in meter_value: measurands: list[MeasurandValue] = [] - for sampled_value in bucket[om.sampled_value.name]: + for sampled_value in bucket.get(om.sampled_value.name, []): measurand = sampled_value.get(om.measurand.value, None) - value = sampled_value.get(om.value.value, None) + v = sampled_value.get(om.value.value, None) # where an empty string is supplied convert to 0 try: - value = float(value) - except ValueError: - value = 0 + v = float(v) + except (ValueError, TypeError): + v = 0.0 unit = sampled_value.get(om.unit.value, None) phase = sampled_value.get(om.phase.value, None) location = sampled_value.get(om.location.value, None) context = sampled_value.get(om.context.value, None) measurands.append( - MeasurandValue(measurand, value, phase, unit, context, location) + MeasurandValue(measurand, v, phase, unit, context, location) ) meter_values.append(measurands) - self.process_measurands(meter_values, transaction_matches) - if transaction_matches: - self._metrics[csess.session_time.value].value = round( - ( - int(time.time()) - - float(self._metrics[csess.transaction_id.value].value) - ) - / 60 + # Write main meter value (EAIR) to connector 0 if this message is missing transactionId + if not tx_has_id: + for bucket in meter_values: + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand == DEFAULT_MEASURAND: + eair_kwh = cp.get_energy_kwh(item) # Wh→kWh if necessary + # Aggregate (connector 0) carries the latest main meter value + self._metrics[(0, DEFAULT_MEASURAND)].value = eair_kwh + self._metrics[(0, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + if item.location is not None: + self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ + om.location.value + ] = item.location + if item.context is not None: + self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ + om.context.value + ] = item.context + + self.process_measurands(meter_values, transaction_matches, connector_id) + + # Update session time if ongoing transaction + if active_tx_for_conn not in (None, 0): + tx_start = float( + self._metrics[(connector_id, csess.transaction_id.value)].value + or time.time() ) - self._metrics[csess.session_time.value].unit = "min" - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = float( - self._metrics[DEFAULT_MEASURAND].value or 0 - ) - float(self._metrics[csess.meter_start.value].value) - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value + self._metrics[(connector_id, csess.session_time.value)].value = round( + (int(time.time()) - tx_start) / 60 + ) + self._metrics[ + (connector_id, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + + # Update Energy.Session ONLY from EAIR in this message if txId exists and matches + if tx_has_id and transaction_matches: + eair_kwh_in_msg: float | None = None + for bucket in meter_values: + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand == DEFAULT_MEASURAND: + eair_kwh_in_msg = cp.get_energy_kwh(item) + if eair_kwh_in_msg is not None: + try: + meter_start_kwh = float( + self._metrics[(connector_id, csess.meter_start.value)].value + or 0.0 + ) + except Exception: + meter_start_kwh = 0.0 + session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh) + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = session_kwh + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -668,41 +827,36 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): """Handle a status notification.""" if connector_id == 0 or connector_id is None: - self._metrics[cstat.status.value].value = status - self._metrics[cstat.error_code.value].value = error_code - elif connector_id == 1: - self._metrics[cstat.status_connector.value].value = status - self._metrics[cstat.error_code_connector.value].value = error_code - if connector_id >= 1: - self._metrics[cstat.status_connector.value].extra_attr[connector_id] = ( - status - ) - self._metrics[cstat.error_code_connector.value].extra_attr[connector_id] = ( - error_code - ) - if ( - status == ChargePointStatus.suspended_ev.value - or status == ChargePointStatus.suspended_evse.value - ): - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 + self._metrics[(0, cstat.status.value)].value = status + self._metrics[(0, cstat.error_code.value)].value = error_code + else: + self._metrics[(connector_id, cstat.status_connector.value)].value = status + self._metrics[ + (connector_id, cstat.error_code_connector.value) + ].value = error_code + + if status in ( + ChargePointStatus.suspended_ev.value, + ChargePointStatus.suspended_evse.value, + ): + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + if meas in self._metrics[connector_id]: + self._metrics[(connector_id, meas)].value = 0 + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @on(Action.firmware_status_notification) def on_firmware_status(self, status, **kwargs): """Handle firmware status notification.""" - self._metrics[cstat.firmware_status.value].value = status + self._metrics[0][cstat.firmware_status.value].value = status self.hass.async_create_task(self.update(self.settings.cpid)) self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) return call_result.FirmwareStatusNotification() @@ -733,7 +887,7 @@ def on_security_event(self, type, timestamp, **kwargs): @on(Action.authorize) def on_authorize(self, id_tag, **kwargs): """Handle an Authorization request.""" - self._metrics[cstat.id_tag.value].value = id_tag + self._metrics[0][cstat.id_tag.value].value = id_tag auth_status = self.get_authorization_status(id_tag) return call_result.Authorize(id_tag_info={om.status.value: auth_status}) @@ -743,52 +897,111 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): auth_status = self.get_authorization_status(id_tag) if auth_status == AuthorizationStatus.accepted.value: - self.active_transaction_id = int(time.time()) - self._metrics[cstat.id_tag.value].value = id_tag - self._metrics[cstat.stop_reason.value].value = "" - self._metrics[csess.transaction_id.value].value = self.active_transaction_id - self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 + tx_id = int(time.time()) + self._active_tx[connector_id] = tx_id + self.active_transaction_id = tx_id + self._metrics[(connector_id, cstat.id_tag.value)].value = id_tag + self._metrics[(connector_id, cstat.stop_reason.value)].value = "" + self._metrics[(connector_id, csess.transaction_id.value)].value = tx_id + try: + meter_start_kwh = float(meter_start) / 1000.0 + except Exception: + meter_start_kwh = 0.0 + self._metrics[ + (connector_id, csess.meter_start.value) + ].value = meter_start_kwh + self._metrics[(connector_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + self._metrics[(connector_id, csess.session_time.value)].value = 0 + self._metrics[ + (connector_id, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + self._metrics[(connector_id, csess.session_energy.value)].value = 0.0 + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + result = call_result.StartTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, - transaction_id=self.active_transaction_id, + transaction_id=tx_id, ) else: result = call_result.StartTransaction( - id_tag_info={om.status.value: auth_status}, transaction_id=0 + id_tag_info={om.status.value: auth_status}, + transaction_id=0, ) + self.hass.async_create_task(self.update(self.settings.cpid)) return result @on(Action.stop_transaction) def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): """Stop the current transaction.""" - - if transaction_id != self.active_transaction_id: + conn = next( + (c for c, tx in self._active_tx.items() if tx == transaction_id), None + ) + if conn is None: _LOGGER.error( "Stop transaction received for unknown transaction id=%i", transaction_id, ) + conn = 1 + + self._active_tx[conn] = 0 self.active_transaction_id = 0 - self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = int( - meter_stop - ) / 1000 - float(self._metrics[csess.meter_start.value].value) - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 + + self._metrics[(conn, cstat.stop_reason.value)].value = kwargs.get( + om.reason.name, None + ) + + use_eair_from_tx = bool(self._charger_reports_session_energy) + + if use_eair_from_tx: + sess_val = self._metrics[(conn, csess.session_energy.value)].value + if sess_val is None: + last_eair = self._metrics[(conn, DEFAULT_MEASURAND)].value + last_unit = self._metrics[(conn, DEFAULT_MEASURAND)].unit + try: + if last_eair is not None: + if last_unit == DEFAULT_ENERGY_UNIT: + eair_kwh = float(last_eair) / 1000.0 + elif last_unit == HA_ENERGY_UNIT: + eair_kwh = float(last_eair) + else: + eair_kwh = float(last_eair) + self._metrics[ + (conn, csess.session_energy.value) + ].value = eair_kwh + self._metrics[ + (conn, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + except Exception: + pass + else: + try: + meter_stop_kwh = float(meter_stop) / 1000.0 + except Exception: + meter_stop_kwh = 0.0 + try: + meter_start_kwh = float( + self._metrics[(conn, csess.meter_start.value)].value or 0.0 + ) + except Exception: + meter_start_kwh = 0.0 + + session_kwh = max(0.0, meter_stop_kwh - meter_start_kwh) + self._metrics[(conn, csess.session_energy.value)].value = session_kwh + self._metrics[(conn, csess.session_energy.value)].unit = HA_ENERGY_UNIT + + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + self._metrics[(conn, meas)].value = 0 self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} @@ -798,14 +1011,14 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): def on_data_transfer(self, vendor_id, **kwargs): """Handle a Data transfer request.""" _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) - self._metrics[cdet.data_transfer.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} + self._metrics[0][cdet.data_transfer.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} return call_result.DataTransfer(status=DataTransferStatus.accepted.value) @on(Action.heartbeat) def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" now = datetime.now(tz=UTC) - self._metrics[cstat.heartbeat.value].value = now + self._metrics[0][cstat.heartbeat.value].value = now self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 171662c3..65d28e05 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -1,18 +1,19 @@ """Representation of a OCPP 2.0.1 or 2.1 charging station.""" import asyncio +import contextlib from datetime import datetime, UTC +from dataclasses import dataclass, field import logging -import ocpp.exceptions -from ocpp.exceptions import OCPPError - from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError, HomeAssistantError from websockets.asyncio.server import ServerConnection +import ocpp.exceptions +from ocpp.exceptions import OCPPError from ocpp.routing import on from ocpp.v201 import call, call_result from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 @@ -61,15 +62,16 @@ logging.getLogger(DOMAIN).setLevel(logging.INFO) +@dataclass class InventoryReport: """Cached full inventory report for a charger.""" evse_count: int = 0 - connector_count: list[int] = [] + connector_count: list[int] = field(default_factory=list) smart_charging_available: bool = False reservation_available: bool = False local_auth_available: bool = False - tx_updated_measurands: list[MeasurandEnumType] = [] + tx_updated_measurands: list[MeasurandEnumType] = field(default_factory=list) class ChargePoint(cp): @@ -77,8 +79,13 @@ class ChargePoint(cp): _inventory: InventoryReport | None = None _wait_inventory: asyncio.Event | None = None - _connector_status: list[list[ConnectorStatusEnumType | None]] = [] - _tx_start_time: datetime | None = None + _connector_status: list[list[ConnectorStatusEnumType | None]] + _tx_start_time: dict[int, datetime] + _global_to_evse: dict[int, tuple[int, int]] # global_idx -> (evse_id, connector_id) + _evse_to_global: dict[tuple[int, int], int] # (evse_id, connector_id) -> global_idx + _pending_status_notifications: list[ + tuple[str, str, int, int] + ] # (timestamp, connector_status, evse_id, connector_id) def __init__( self, @@ -100,6 +107,115 @@ def __init__( central, charger, ) + self._tx_start_time = {} + self._global_to_evse: dict[int, tuple[int, int]] = {} + self._evse_to_global: dict[tuple[int, int], int] = {} + self._pending_status_notifications: list[tuple[str, str, int, int]] = [] + self._connector_status = [] + + # --- Connector mapping helpers (EVSE <-> global index) --- + def _build_connector_map(self) -> bool: + if not self._inventory or self._inventory.evse_count == 0: + return False + if self._evse_to_global and self._global_to_evse: + return True + + g = 1 + self._evse_to_global.clear() + self._global_to_evse.clear() + for evse_id in range(1, self._inventory.evse_count + 1): + count = 0 + if len(self._inventory.connector_count) >= evse_id: + count = int(self._inventory.connector_count[evse_id - 1] or 0) + for conn_id in range(1, count + 1): + self._evse_to_global[(evse_id, conn_id)] = g + self._global_to_evse[g] = (evse_id, conn_id) + g += 1 + return bool(self._evse_to_global) + + def _ensure_connector_map(self) -> bool: + if self._evse_to_global and self._global_to_evse: + return True + return self._build_connector_map() + + def _pair_to_global(self, evse_id: int, conn_id: int) -> int: + """Return global index for (evse_id, conn_id).""" + # Exact match available + idx = self._evse_to_global.get((evse_id, conn_id)) + if idx is not None: + return idx + # Build from inventory if we have it + if self._inventory and not self._evse_to_global: + self._build_connector_map() + idx = self._evse_to_global.get( + (evse_id, conn_id) + ) or self._evse_to_global.get((evse_id, 1)) + if idx is not None: + return idx + # Allocate a unique index to avoid collisions until inventory arrives + new_idx = max(self._global_to_evse.keys(), default=0) + 1 + self._global_to_evse[new_idx] = (evse_id, conn_id) + self._evse_to_global[(evse_id, conn_id)] = new_idx + return new_idx + + def _global_to_pair(self, global_idx: int) -> tuple[int, int]: + """Return (evse_id, connector_id) for a global index. Fallback: (global_idx,1).""" + return self._global_to_evse.get(global_idx, (global_idx, 1)) + + def _apply_status_notification( + self, timestamp: str, connector_status: str, evse_id: int, connector_id: int + ): + """Update per connector and evse aggregated.""" + if evse_id > len(self._connector_status): + needed = evse_id - len(self._connector_status) + self._connector_status.extend([[] for _ in range(needed)]) + if connector_id > len(self._connector_status[evse_id - 1]): + self._connector_status[evse_id - 1] += [None] * ( + connector_id - len(self._connector_status[evse_id - 1]) + ) + + evse_list = self._connector_status[evse_id - 1] + evse_list[connector_id - 1] = ConnectorStatusEnumType(connector_status) + + global_idx = self._pair_to_global(evse_id, connector_id) + self._metrics[ + (global_idx, cstat.status_connector.value) + ].value = ConnectorStatusEnumType(connector_status).value + + evse_status: ConnectorStatusEnumType | None = None + for st in evse_list: + if st is None: + evse_status = None + break + evse_status = st + if st != ConnectorStatusEnumType.available: + break + if evse_status is not None: + if evse_status == ConnectorStatusEnumType.available: + v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusEnumType.faulted: + v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusEnumType.unavailable: + v16 = ChargePointStatusv16.unavailable + else: + v16 = ChargePointStatusv16.preparing + self._report_evse_status(evse_id, v16) + + def _flush_pending_status_notifications(self): + """Flush buffered status notifications when the map is ready.""" + if not self._ensure_connector_map(): + return + pending = self._pending_status_notifications + self._pending_status_notifications = [] + for t, st, evse_id, conn_id in pending: + self._apply_status_notification(t, st, evse_id, conn_id) + self.hass.async_create_task(self.update(self.settings.cpid)) + + def _total_connectors(self) -> int: + """Total physical connectors across all EVSE.""" + if not self._inventory: + return 0 + return sum(self._inventory.connector_count or [0]) async def async_update_device_info_v201(self, boot_info: dict): """Update device info asynchronuously.""" @@ -119,7 +235,7 @@ async def _get_inventory(self): req = call.GetBaseReport(1, "FullInventory") resp: call_result.GetBaseReport | None = None try: - resp: call_result.GetBaseReport = await self.call(req) + resp = await self.call(req) except ocpp.exceptions.NotImplementedError: self._inventory = InventoryReport() except OCPPError: @@ -127,11 +243,13 @@ async def _get_inventory(self): if (resp is not None) and (resp.status == "Accepted"): await asyncio.wait_for(self._wait_inventory.wait(), self._response_timeout) self._wait_inventory = None + if self._inventory: + self._build_connector_map() async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" await self._get_inventory() - return self._inventory.evse_count if self._inventory else 0 + return self._total_connectors() async def set_standard_configuration(self): """Send configuration values to the charger.""" @@ -167,7 +285,7 @@ async def get_supported_measurands(self) -> str: return "" async def get_supported_features(self) -> Profiles: - """Get comma-separated list of measurands supported by the charger.""" + """Get feature profiles supported by the charger.""" await self._get_inventory() features = Profiles.CORE if self._inventory and self._inventory.smart_charging_available: @@ -219,47 +337,78 @@ async def clear_profile(self): req: call.ClearChargingProfile = call.ClearChargingProfile( None, { - "charging_profile_Purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value }, ) await self.call(req) async def set_charge_rate( self, - limit_amps: int = 32, - limit_watts: int = 22000, + limit_amps: int | None = None, + limit_watts: int | None = None, conn_id: int = 0, profile: dict | None = None, ): - """Set a charging profile with defined limit.""" - req: call.SetChargingProfile - if profile: - req = call.SetChargingProfile(0, profile) - else: - period: dict = {"start_period": 0} - schedule: dict = {"id": 1} - if limit_amps < 32: - period["limit"] = limit_amps - schedule["charging_rate_unit"] = ChargingRateUnitEnumType.amps.value - elif limit_watts < 22000: - period["limit"] = limit_watts - schedule["charging_rate_unit"] = ChargingRateUnitEnumType.watts.value - else: + """Set a charging profile with defined limit (OCPP 2.x). + + - conn_id=0 (default) targets the Charging Station (evse_id=0). + - conn_id>0 targets the specific EVSE corresponding to the global connector index. + """ + + evse_target = 0 + if conn_id and conn_id > 0: + with contextlib.suppress(Exception): + evse_target, _ = self._global_to_pair(int(conn_id)) + if profile is not None: + req = call.SetChargingProfile(evse_target, profile) + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatusEnumType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + return + + if limit_watts is not None: + if float(limit_watts) >= 22000: await self.clear_profile() return + period_limit = int(limit_watts) + unit_value = ChargingRateUnitEnumType.watts.value - schedule["charging_schedule_period"] = [period] - req = call.SetChargingProfile( - 0, - { - "id": 1, - "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, - "charging_profile_kind": ChargingProfileKindEnumType.relative.value, - "charging_schedule": [schedule], - }, + elif limit_amps is not None: + if float(limit_amps) >= 32: + await self.clear_profile() + return + period_limit = ( + int(limit_amps) if float(limit_amps).is_integer() else float(limit_amps) ) + unit_value = ChargingRateUnitEnumType.amps.value + + else: + await self.clear_profile() + return + schedule: dict = { + "id": 1, + "charging_rate_unit": unit_value, + "charging_schedule_period": [{"start_period": 0, "limit": period_limit}], + } + + charging_profile: dict = { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [schedule], + } + + req: call.SetChargingProfile = call.SetChargingProfile( + evse_target, charging_profile + ) resp: call_result.SetChargingProfile = await self.call(req) if resp.status != ChargingProfileStatusEnumType.accepted: raise HomeAssistantError( @@ -270,18 +419,34 @@ async def set_charge_rate( }, ) - async def set_availability(self, state: bool = True): + async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" - req: call.ChangeAvailability = call.ChangeAvailability( + status = ( OperationalStatusEnumType.operative.value if state else OperationalStatusEnumType.inoperative.value ) - await self.call(req) + if not connector_id: + await self.call(call.ChangeAvailability(status)) + return + + evse_id = None + with contextlib.suppress(Exception): + evse_id, _ = self._global_to_pair(int(connector_id)) + + if evse_id: + await self.call(call.ChangeAvailability(status, evse={"id": evse_id})) + else: + await self.call(call.ChangeAvailability(status)) - async def start_transaction(self) -> bool: + async def start_transaction(self, connector_id: int = 1) -> bool: """Remote start a transaction.""" + evse_id = connector_id + if connector_id and connector_id > 0: + evse_id, _ = self._global_to_pair(connector_id) + req: call.RequestStartTransaction = call.RequestStartTransaction( + evse_id=evse_id, id_token={ "id_token": self._remote_id_tag, "type": IdTokenEnumType.central.value, @@ -292,9 +457,20 @@ async def start_transaction(self) -> bool: return resp.status == RequestStartStopStatusEnumType.accepted.value async def stop_transaction(self) -> bool: - """Request remote stop of current transaction.""" + """Request remote stop of current transaction (default EVSE 1).""" + await self._get_inventory() + tx_id = "" + total = self._total_connectors() or 1 + for g in range(1, total + 1): + val = self._metrics[(g, csess.transaction_id.value)].value + if val: + tx_id = val + break + if not tx_id: + _LOGGER.info("No active transaction found to stop") + return False req: call.RequestStopTransaction = call.RequestStopTransaction( - transaction_id=self._metrics[csess.transaction_id.value].value + transaction_id=tx_id ) resp: call_result.RequestStopTransaction = await self.call(req) return resp.status == RequestStartStopStatusEnumType.accepted.value @@ -403,14 +579,8 @@ def on_heartbeat(self, **kwargs): return call_result.Heartbeat(current_time=datetime.now(tz=UTC).isoformat()) def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): - evse_status_str: str = evse_status_v16.value - - if evse_id == 1: - self._metrics[cstat.status_connector.value].value = evse_status_str - else: - self._metrics[cstat.status_connector.value].extra_attr[evse_id] = ( - evse_status_str - ) + """Report EVSE-level status on the global connector.""" + self._metrics[(0, cstat.status_connector.value)].value = evse_status_v16.value self.hass.async_create_task(self.update(self.settings.cpid)) @on(Action.status_notification) @@ -418,117 +588,146 @@ def on_status_notification( self, timestamp: str, connector_status: str, evse_id: int, connector_id: int ): """Perform OCPP callback.""" - if evse_id > len(self._connector_status): - self._connector_status += [[]] * (evse_id - len(self._connector_status)) - if connector_id > len(self._connector_status[evse_id - 1]): - self._connector_status[evse_id - 1] += [None] * ( - connector_id - len(self._connector_status[evse_id - 1]) + if not self._ensure_connector_map(): + self._pending_status_notifications.append( + (timestamp, connector_status, evse_id, connector_id) ) + return call_result.StatusNotification() - evse: list[ConnectorStatusEnumType] = self._connector_status[evse_id - 1] - evse[connector_id - 1] = ConnectorStatusEnumType(connector_status) - evse_status: ConnectorStatusEnumType | None = None - for status in evse: - if status is None: - evse_status = status - break - else: - evse_status = status - if status != ConnectorStatusEnumType.available: - break - evse_status_v16: ChargePointStatusv16 | None - if evse_status is None: - evse_status_v16 = None - elif evse_status == ConnectorStatusEnumType.available: - evse_status_v16 = ChargePointStatusv16.available - elif evse_status == ConnectorStatusEnumType.faulted: - evse_status_v16 = ChargePointStatusv16.faulted - elif evse_status == ConnectorStatusEnumType.unavailable: - evse_status_v16 = ChargePointStatusv16.unavailable - else: - evse_status_v16 = ChargePointStatusv16.preparing - - if evse_status_v16: - self._report_evse_status(evse_id, evse_status_v16) - + self._apply_status_notification( + timestamp, connector_status, evse_id, connector_id + ) + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @on(Action.firmware_status_notification) + def on_firmware_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.FirmwareStatusNotification() + @on(Action.meter_values) + def on_meter_values(self, **kwargs): + """Perform OCPP callback.""" + return call_result.MeterValues() + @on(Action.log_status_notification) + def on_log_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.LogStatusNotification() + @on(Action.notify_event) - def ack(self, **kwargs): + def on_notify_event(self, **kwargs): """Perform OCPP callback.""" - return call_result.StatusNotification() + return call_result.NotifyEvent() @on(Action.notify_report) def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): - """Perform OCPP callback.""" + """Handle OCPP 2.x inventory/report updates.""" if self._wait_inventory is None: return call_result.NotifyReport() + if self._inventory is None: self._inventory = InventoryReport() - reports: list[dict] = kwargs.get("report_data", []) + + reports: list[dict] = kwargs.get("report_data", []) or [] for report_data in reports: - component: dict = report_data["component"] - variable: dict = report_data["variable"] - component_name = component["name"] - variable_name = variable["name"] + component: dict = report_data.get("component", {}) or {} + variable: dict = report_data.get("variable", {}) or {} + component_name: str = str(component.get("name", "") or "") + variable_name: str = str(variable.get("name", "") or "") + value: str | None = None - for attribute in report_data["variable_attribute"]: - if (("type" not in attribute) or (attribute["type"] == "Actual")) and ( - "value" in attribute + for attr in report_data.get("variable_attribute", []) or []: + if ("type" not in attr) or ( + str(attr.get("type", "")).casefold() == "actual" ): - value = attribute["value"] - break - bool_value: bool = value and (value.casefold() == "true".casefold()) + if "value" in attr: + v = attr.get("value") + value = str(v) if v is not None else None + break + + bool_value: bool = False + if value is not None and str(value).strip(): + bool_value = str(value).strip().casefold() == "true" if (component_name == "SmartChargingCtrlr") and ( variable_name == "Available" ): self._inventory.smart_charging_available = bool_value - elif (component_name == "ReservationCtrlr") and ( + continue + if (component_name == "ReservationCtrlr") and ( variable_name == "Available" ): self._inventory.reservation_available = bool_value - elif (component_name == "LocalAuthListCtrlr") and ( + continue + if (component_name == "LocalAuthListCtrlr") and ( variable_name == "Available" ): self._inventory.local_auth_available = bool_value - elif (component_name == "EVSE") and ("evse" in component): - self._inventory.evse_count = max( - self._inventory.evse_count, component["evse"]["id"] - ) - self._inventory.connector_count += [0] * ( - self._inventory.evse_count - len(self._inventory.connector_count) - ) - elif ( + continue + + if (component_name == "EVSE") and ("evse" in component): + evse_id = int(component["evse"].get("id", 0) or 0) + if evse_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + continue + + if ( (component_name == "Connector") and ("evse" in component) and ("connector_id" in component["evse"]) ): - evse_id = component["evse"]["id"] - self._inventory.evse_count = max(self._inventory.evse_count, evse_id) - self._inventory.connector_count += [0] * ( - self._inventory.evse_count - len(self._inventory.connector_count) - ) - self._inventory.connector_count[evse_id - 1] = max( - self._inventory.connector_count[evse_id - 1], - component["evse"]["connector_id"], - ) - elif ( - (component_name == "SampledDataCtrlr") - and (variable_name == "TxUpdatedMeasurands") - and ("variable_characteristics" in report_data) + evse_id = int(component["evse"].get("id", 0) or 0) + conn_id = int(component["evse"].get("connector_id", 0) or 0) + if evse_id > 0 and conn_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + self._inventory.connector_count[evse_id - 1] = max( + self._inventory.connector_count[evse_id - 1], conn_id + ) + continue + + if (component_name == "SampledDataCtrlr") and ( + variable_name == "TxUpdatedMeasurands" ): - characteristics: dict = report_data["variable_characteristics"] - values: str = characteristics.get("values_list", "") + characteristics: dict = ( + report_data.get("variable_characteristics", {}) or {} + ) + values: str = str(characteristics.get("values_list", "") or "") + meas_list = [ + s.strip() for s in values.split(",") if s is not None and s.strip() + ] self._inventory.tx_updated_measurands = [ - MeasurandEnumType(s) for s in values.split(",") + MeasurandEnumType(s) for s in meas_list ] + continue if not kwargs.get("tbc", False): + if hasattr(self, "_build_connector_map"): + self._build_connector_map() + if hasattr(self, "_flush_pending_status_notifications"): + self._flush_pending_status_notifications() self._wait_inventory.set() + return call_result.NotifyReport() @on(Action.authorize) @@ -545,7 +744,14 @@ def on_authorize(self, id_token: dict, **kwargs): status = self.get_authorization_status(token) return call_result.Authorize(id_token_info={"status": status}) - def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): + def _set_meter_values( + self, + tx_event_type: str, + meter_values: list[dict], + evse_id: int, + connector_id: int, + ): + global_idx: int = self._pair_to_global(evse_id, connector_id) converted_values: list[list[MeasurandValue]] = [] for meter_value in meter_values: measurands: list[MeasurandValue] = [] @@ -569,18 +775,22 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): if (tx_event_type == TransactionEventEnumType.started.value) or ( (tx_event_type == TransactionEventEnumType.updated.value) - and (self._metrics[csess.meter_start].value is None) + and (self._metrics[(global_idx, csess.meter_start.value)].value is None) ): energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: for measurand_item in meter_value: if measurand_item.measurand == energy_measurand: - energy_value = ChargePoint.get_energy_kwh(measurand_item) + energy_value = cp.get_energy_kwh(measurand_item) energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None - self._metrics[csess.meter_start].value = energy_value - self._metrics[csess.meter_start].unit = energy_unit + self._metrics[ + (global_idx, csess.meter_start.value) + ].value = energy_value + self._metrics[ + (global_idx, csess.meter_start.value) + ].unit = energy_unit - self.process_measurands(converted_values, True) + self.process_measurands(converted_values, True, global_idx) if tx_event_type == TransactionEventEnumType.ended.value: measurands_in_tx: set[str] = set() @@ -593,10 +803,10 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): for measurand in self._inventory.tx_updated_measurands: if ( (measurand not in measurands_in_tx) - and (measurand in self._metrics) + and ((global_idx, measurand) in self._metrics) and not measurand.startswith("Energy") ): - self._metrics[measurand].value = 0 + self._metrics[(global_idx, measurand)].value = 0 @on(Action.transaction_event) def on_transaction_event( @@ -609,14 +819,18 @@ def on_transaction_event( **kwargs, ): """Perform OCPP callback.""" + evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 + evse_conn_id: int = ( + kwargs["evse"].get("connector_id", 1) if "evse" in kwargs else 1 + ) + global_idx: int = self._pair_to_global(evse_id, evse_conn_id) offline: bool = kwargs.get("offline", False) meter_values: list[dict] = kwargs.get("meter_value", []) - self._set_meter_values(event_type, meter_values) - t = datetime.fromisoformat(timestamp) + self._set_meter_values(event_type, meter_values, evse_id, evse_conn_id) + t = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) if "charging_state" in transaction_info: state = transaction_info["charging_state"] - evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 evse_status_v16: ChargePointStatusv16 | None = None if state == ChargingStateEnumType.idle: evse_status_v16 = ChargePointStatusv16.available @@ -636,22 +850,30 @@ def on_transaction_event( if id_token: response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] - self._metrics[cstat.id_tag.value].value = id_tag_string + self._metrics[(global_idx, cstat.id_tag.value)].value = id_tag_string if event_type == TransactionEventEnumType.started.value: - self._tx_start_time = t + self._tx_start_time[global_idx] = t tx_id: str = transaction_info["transaction_id"] - self._metrics[csess.transaction_id.value].value = tx_id - self._metrics[csess.session_time].value = 0 - self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + self._metrics[(global_idx, csess.transaction_id.value)].value = tx_id + self._metrics[(global_idx, csess.session_time.value)].value = 0 + self._metrics[ + (global_idx, csess.session_time.value) + ].unit = UnitOfTime.MINUTES else: - if self._tx_start_time: - duration_minutes: int = ((t - self._tx_start_time).seconds + 59) // 60 - self._metrics[csess.session_time].value = duration_minutes - self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + if self._tx_start_time.get(global_idx): + elapsed = (t - self._tx_start_time[global_idx]).total_seconds() + duration_minutes: int = int((elapsed + 59) // 60) + self._metrics[ + (global_idx, csess.session_time.value) + ].value = duration_minutes + self._metrics[ + (global_idx, csess.session_time.value) + ].unit = UnitOfTime.MINUTES if event_type == TransactionEventEnumType.ended.value: - self._metrics[csess.transaction_id.value].value = "" - self._metrics[cstat.id_tag.value].value = "" + self._metrics[(global_idx, csess.transaction_id.value)].value = "" + self._metrics[(global_idx, cstat.id_tag.value)].value = "" + self._tx_start_time.pop(global_idx, None) if not offline: self.hass.async_create_task(self.update(self.settings.cpid)) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index fc743281..186b2d8a 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass - import homeassistant from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -15,6 +14,7 @@ ) from homeassistant.const import CONF_MONITORED_VARIABLES from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory @@ -22,8 +22,10 @@ from .const import ( CONF_CPID, CONF_CPIDS, + CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_CLASS_UNITS_HA, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, Measurand, @@ -41,43 +43,139 @@ class OcppSensorDescription(SensorEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the sensor platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[ChargePointMetric] = [] + ent_reg = er.async_get(hass) + # setup all chargers added to config for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - SENSORS = [] - for metric in list( - set( - cp_id_settings[CONF_MONITORED_VARIABLES].split(",") - + list(HAChargerSession) - ) - ): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - ) + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + configured = [ + m.strip() + for m in str(cp_id_settings.get(CONF_MONITORED_VARIABLES, "")).split(",") + if m and m.strip() + ] + default_measurands: list[str] = [] + measurands = sorted(configured or default_measurands) + + CHARGER_ONLY = [ + HAChargerStatuses.status.value, + HAChargerStatuses.error_code.value, + HAChargerStatuses.firmware_status.value, + HAChargerStatuses.heartbeat.value, + HAChargerStatuses.id_tag.value, + HAChargerStatuses.latency_ping.value, + HAChargerStatuses.latency_pong.value, + HAChargerStatuses.reconnects.value, + HAChargerDetails.identifier.value, + HAChargerDetails.vendor.value, + HAChargerDetails.model.value, + HAChargerDetails.serial.value, + HAChargerDetails.firmware_version.value, + HAChargerDetails.features.value, + HAChargerDetails.connectors.value, + HAChargerDetails.config_response.value, + HAChargerDetails.data_response.value, + HAChargerDetails.data_transfer.value, + ] + + CONNECTOR_ONLY = measurands + [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + HAChargerStatuses.stop_reason.value, + HAChargerSession.transaction_id.value, + HAChargerSession.session_time.value, + HAChargerSession.session_energy.value, + HAChargerSession.meter_start.value, + ] + + def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: + ms = str(metric).strip() + return OcppSensorDescription( + key=ms.lower(), + name=ms.replace(".", " "), + metric=ms, + entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, ) - for metric in list(HAChargerStatuses) + list(HAChargerDetails): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - entity_category=EntityCategory.DIAGNOSTIC, + + def _uid(cpid: str, key: str, connector_id: int | None) -> str: + """Mirror ChargePointMetric unique_id construction.""" + key = key.lower() + parts = [DOMAIN, cpid, key, SENSOR_DOMAIN] + if connector_id is not None: + parts.insert(2, f"conn{connector_id}") + return ".".join(parts) + + if num_connectors > 1: + for metric in CONNECTOR_ONLY: + uid = _uid(cpid, metric, connector_id=None) + stale_eid = ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, uid) + if stale_eid: + # Remove the old entity so it doesn't linger as 'unavailable' + ent_reg.async_remove(stale_eid) + + # Root/charger-entities + for metric in CHARGER_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc(metric, cat_diag=True), + connector_id=None, ) ) - for ent in SENSORS: - cpx = ChargePointMetric( - hass, - central_system, - cpid, - ent, - ) - entities.append(cpx) + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + for metric in CONNECTOR_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], + ), + connector_id=conn_id, + ) + ) + else: + for metric in CONNECTOR_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], + ), + connector_id=None, + ) + ) async_add_devices(entities, False) @@ -85,7 +183,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointMetric(RestoreSensor, SensorEntity): """Individual sensor for charge point metrics.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSensorDescription def __init__( @@ -94,42 +192,61 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppSensorDescription, + connector_id: int | None = None, ): """Instantiate instance of a ChargePointMetrics.""" self.central_system = central_system self.cpid = cpid self.entity_description = description self.metric = self.entity_description.metric + self.connector_id = connector_id self._hass = hass self._extra_attr = {} self._last_reset = homeassistant.util.dt.utc_from_timestamp(0) - self._attr_unique_id = ".".join( - [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] - ) + parts = [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] + if self.connector_id is not None: + parts.insert(2, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SENSOR_DOMAIN}.{object_id}" self._attr_icon = ICON self._attr_native_unit_of_measurement = None @property def available(self) -> bool: """Return if sensor is available.""" - return self.central_system.get_available(self.cpid) + return self.central_system.get_available(self.cpid, self.connector_id) @property - def should_poll(self): + def should_poll(self) -> bool: """Return True if entity has to be polled for state. False if entity pushes its state to HA. """ - return True + return False @property def extra_state_attributes(self): """Return the state attributes.""" - return self.central_system.get_extra_attr(self.cpid, self.metric) + return self.central_system.get_extra_attr( + self.cpid, self.metric, self.connector_id + ) @property def state_class(self): @@ -148,6 +265,7 @@ def state_class(self): ] or self.metric in [ HAChargerStatuses.latency_ping.value, HAChargerStatuses.latency_pong.value, + HAChargerSession.session_time.value, ]: state_class = SensorStateClass.MEASUREMENT @@ -189,7 +307,19 @@ def device_class(self): @property def native_value(self): """Return the state of the sensor, rounding if a number.""" - value = self.central_system.get_metric(self.cpid, self.metric) + value = self.central_system.get_metric( + self.cpid, self.metric, self.connector_id + ) + + # Special case for features - show profiles as labels from IntFlag + if self.metric == HAChargerDetails.features.value and value is not None: + if hasattr(value, "labels"): + self._attr_native_value = value.labels() + else: + self._attr_native_value = str(value) + + return self._attr_native_value + if value is not None: self._attr_native_value = value return self._attr_native_value @@ -197,7 +327,9 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the native unit of measurement.""" - value = self.central_system.get_ha_unit(self.cpid, self.metric) + value = self.central_system.get_ha_unit( + self.cpid, self.metric, self.connector_id + ) if value is not None: self._attr_native_unit_of_measurement = value else: @@ -217,6 +349,8 @@ async def async_added_to_hass(self) -> None: self._hass, DATA_UPDATED, self._schedule_immediate_update ) + self.async_schedule_update_ha_state(True) + @callback def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 99ec4bd5..ba8054aa 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -3,19 +3,26 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final +from typing import Final from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import UnitOfPower +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, DOMAIN, ICON +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, + ICON, +) from .enums import HAChargerServices, HAChargerStatuses @@ -29,13 +36,12 @@ class OcppSwitchDescription(SwitchEntityDescription): on_action: str | None = None off_action: str | None = None metric_state: str | None = None - metric_condition: str | None = None + metric_condition: list[str] | None = None default_state: bool = False + per_connector: bool = False -POWER_KILO_WATT = UnitOfPower.KILO_WATT - -SWITCHES: Final = [ +SWITCHES: Final[list[OcppSwitchDescription]] = [ OcppSwitchDescription( key="charge_control", name="Charge Control", @@ -48,6 +54,7 @@ class OcppSwitchDescription(SwitchEntityDescription): ChargePointStatus.suspended_evse.value, ChargePointStatus.suspended_ev.value, ], + per_connector=True, ), OcppSwitchDescription( key="availability", @@ -55,9 +62,10 @@ class OcppSwitchDescription(SwitchEntityDescription): icon=ICON, on_action=HAChargerServices.service_availability.name, off_action=HAChargerServices.service_availability.name, - metric_state=HAChargerStatuses.status_connector.value, + metric_state=HAChargerStatuses.status.value, # charger-level status metric_condition=[ChargePointStatus.available.value], default_state=True, + per_connector=False, ), ] @@ -65,14 +73,58 @@ class OcppSwitchDescription(SwitchEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] - for charger in entry.data[CONF_CPIDS]: - cp_id_settings = list(charger.values())[0] - cpid = cp_id_settings[CONF_CPID] + entities: list[ChargePointSwitch] = [] + ent_reg = er.async_get(hass) - for ent in SWITCHES: - cpx = ChargePointSwitch(central_system, cpid, ent) - entities.append(cpx) + for charger in entry.data[CONF_CPIDS]: + cp_settings = list(charger.values())[0] + cpid = cp_settings[CONF_CPID] + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + flatten_single = num_connectors == 1 + + if num_connectors > 1: + for desc in SWITCHES: + if not desc.per_connector: + continue + # unique_id used when flattened: "..." + uid_flat = ".".join([SWITCH_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in SWITCHES: + if desc.per_connector: + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointSwitch( + central_system, + cpid, + desc, + connector_id=conn_id, + flatten_single=flatten_single, + ) + ) + else: + entities.append( + ChargePointSwitch( + central_system, + cpid, + desc, + connector_id=None, + flatten_single=False, + ) + ) async_add_devices(entities, False) @@ -80,7 +132,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointSwitch(SwitchEntity): """Individual switch for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSwitchDescription def __init__( @@ -88,56 +140,91 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppSwitchDescription, + connector_id: int | None = None, + flatten_single: bool = False, ): """Instantiate instance of a ChargePointSwitch.""" self.cpid = cpid self.central_system = central_system self.entity_description = description + self.connector_id = connector_id + self._flatten_single = flatten_single self._state = self.entity_description.default_state - self._attr_unique_id = ".".join( - [SWITCH_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] - ) + parts = [SWITCH_DOMAIN, DOMAIN, cpid] + if self.connector_id and not self._flatten_single: + parts.append(f"conn{self.connector_id}") + parts.append(description.key) + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id and not self._flatten_single: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None and not flatten_single: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SWITCH_DOMAIN}.{object_id}" @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + target_conn = ( + self.connector_id if self.entity_description.per_connector else None + ) + return bool(self.central_system.get_available(self.cpid, target_conn)) @property def is_on(self) -> bool: """Return true if the switch is on.""" """Test metric state against condition if present""" if self.entity_description.metric_state is not None: + metric_conn = ( + self.connector_id + if ( + self.entity_description.metric_state + == HAChargerStatuses.status_connector.value + or self.entity_description.per_connector + ) + else None + ) resp = self.central_system.get_metric( - self.cpid, self.entity_description.metric_state + self.cpid, self.entity_description.metric_state, metric_conn ) - if resp in self.entity_description.metric_condition: - self._state = True + if self.entity_description.metric_condition is not None: + self._state = resp in self.entity_description.metric_condition else: - self._state = False - return self._state # type: ignore [no-any-return] + self._state = bool(resp) + return self._state - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs): """Turn the switch on.""" + target_conn = self.connector_id if self.entity_description.per_connector else 0 self._state = await self.central_system.set_charger_state( - self.cpid, self.entity_description.on_action + self.cpid, self.entity_description.on_action, True, connector_id=target_conn ) - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - """Response is True if successful but State is False""" + target_conn = self.connector_id if self.entity_description.per_connector else 0 if self.entity_description.off_action is None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: resp = await self.central_system.set_charger_state( - self.cpid, self.entity_description.off_action, False + self.cpid, + self.entity_description.off_action, + False, + connector_id=target_conn, ) else: resp = await self.central_system.set_charger_state( - self.cpid, self.entity_description.off_action + self.cpid, self.entity_description.off_action, connector_id=target_conn ) self._state = not resp diff --git a/scripts/develop b/scripts/develop index 5bddb789..002d933f 100755 --- a/scripts/develop +++ b/scripts/develop @@ -1,24 +1,44 @@ #!/usr/bin/env bash - set -e cd "$(dirname "$0")/.." -# Create config dir if not present -if [[ ! -d "${PWD}/config" ]]; then - mkdir -p "${PWD}/config" - hass --config "${PWD}/config" --script ensure_config +# Create/prepare config +mkdir -p "${PWD}/config" +if [[ ! -f "${PWD}/config/configuration.yaml" ]]; then + /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --script ensure_config +fi + +# Ensure custom components dir exists +mkdir -p "${PWD}/config/custom_components" + +# Link the ocpp integration (overwrite if exists) +src="${PWD}/custom_components/ocpp" +dst="${PWD}/config/custom_components/ocpp" +if [[ ! -d "$src" ]]; then + echo "Missing integration sources at: $src" >&2 + exit 1 fi -# Create custom components dir if not present -if [[ ! -d "${PWD}/config/custom_components" ]]; then - mkdir -p "${PWD}/config/custom_components" - hass --config "${PWD}/config" --script ensure_config +# Remove existing dir/symlink/file at destination +if [[ -e "$dst" || -L "$dst" ]]; then + rm -rf -- "$dst" fi -# copy the ocpp integration -rm -rf $PWD/config/custom_components/ocpp -cp -r -l $PWD/custom_components/ocpp $PWD/config/custom_components/ +# Create the symlink +ln -s "$src" "$dst" + +# Install debugpy if missing +if ! /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + /home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy +fi -# Start Home Assistant -hass --config "${PWD}/config" --debug +# Start Home Assistant (prefer debugger if available) +DEBUGPY_LISTEN="${DEBUGPY_LISTEN:-0.0.0.0:5678}" +if /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + exec /home/vscode/.local/ha-venv/bin/python -m debugpy --listen "${DEBUGPY_LISTEN}" \ + -m homeassistant --config "${PWD}/config" --debug +else + echo "debugpy not available; starting Home Assistant without debugger" >&2 + exec /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --debug +fi \ No newline at end of file diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index 3882636d..a9f3db95 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -41,7 +41,7 @@ async def set_number(hass: HomeAssistant, cpid: str, key: str, value: int): await hass.services.async_call( NUMBER_DOMAIN, "set_value", - service_data={"value": str(value)}, + service_data={"value": value}, blocking=True, target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_{key}"}, ) diff --git a/tests/const.py b/tests/const.py index c70e2208..a3f76911 100644 --- a/tests/const.py +++ b/tests/const.py @@ -11,6 +11,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -115,6 +116,30 @@ CONF_METER_INTERVAL: 60, CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, + CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], +} + +# different port with skip schema validation enabled, auto config false +# and multiple connector support +MOCK_CONFIG_DATA_1_MC = { + **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_1_mc", + CONF_PORT: 9001, + CONF_CPIDS: [ + { + "CP_1_mc": { + CONF_CPID: "test_cpid_9001", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, CONF_SKIP_SCHEMA_VALIDATION: True, CONF_FORCE_SMART_CHARGING: True, } diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py new file mode 100644 index 00000000..3e32d4da --- /dev/null +++ b/tests/test_api_paths.py @@ -0,0 +1,413 @@ +"""Test exceptions paths in api.py.""" + +import contextlib +from types import SimpleNamespace + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from websockets import NegotiationError + +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, + HAChargerStatuses as cstat, +) +from custom_components.ocpp.chargepoint import Metric as M +from custom_components.ocpp.chargepoint import SetVariableResult + +from .test_charge_point_v16 import MOCK_CONFIG_DATA + + +class DummyCP: + """Minimal fake ChargePoint for exercising CentralSystem API paths.""" + + def __init__(self, *, status=STATE_OK, num_connectors=3, supported_features=0b101): + """Initialize.""" + self.status = status + self.num_connectors = num_connectors + self.supported_features = supported_features + self._metrics = {} + # service call sinks + self.calls = [] + + # ---- services the API calls into ---- + async def set_charge_rate(self, **kw): + """Set charge rate.""" + self.calls.append(("set_charge_rate", kw)) + return True + + async def set_availability(self, state, connector_id=None): + """Set availability.""" + self.calls.append( + ("set_availability", {"state": state, "connector_id": connector_id}) + ) + return True + + async def start_transaction(self, connector_id=None): + """Start transaction.""" + self.calls.append(("start_transaction", {"connector_id": connector_id})) + return True + + async def stop_transaction(self): + """Stop transaction.""" + self.calls.append(("stop_transaction", {})) + return True + + async def reset(self): + """Reset.""" + self.calls.append(("reset", {})) + return True + + async def unlock(self, connector_id=None): + """Unlock.""" + self.calls.append(("unlock", {"connector_id": connector_id})) + return True + + async def trigger_custom_message(self, requested_message): + """Trigger custom message.""" + self.calls.append( + ("trigger_custom_message", {"requested_message": requested_message}) + ) + return True + + async def clear_profile(self): + """Clear profile.""" + self.calls.append(("clear_profile", {})) + return True + + async def update_firmware(self, url, delay): + """Update firmware.""" + self.calls.append(("update_firmware", {"url": url, "delay": delay})) + return True + + async def get_diagnostics(self, url): + """Get diagnostics.""" + self.calls.append(("get_diagnostics", {"url": url})) + return True + + async def data_transfer(self, vendor, message, data): + """Handle data transfer.""" + self.calls.append( + ("data_transfer", {"vendor": vendor, "message": message, "data": data}) + ) + return True + + async def configure(self, key, value): + """Configure.""" + self.calls.append(("configure", {"key": key, "value": value})) + # alternate responses by key to cover both branches + return ( + SetVariableResult.reboot_required + if key == "needs_reboot" + else SetVariableResult.accepted + ) + + async def get_configuration(self, key): + """Get configuration.""" + self.calls.append(("get_configuration", {"key": key})) + return f"value-for:{key}" + + +def _install_dummy_cp( + cs: CentralSystem, *, cpid="test_cpid", cp_id="CP_DUMMY", **kw +) -> DummyCP: + cp = DummyCP(**kw) + cs.charge_points[cp_id] = cp + cs.cpids[cpid] = cp_id + return cp + + +@pytest.mark.asyncio +async def test_select_subprotocol_variants(hass): + """Test select subprotocol variants.""" + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # client offers none -> None + assert cs.select_subprotocol(None, []) is None + + # overlap -> pick shared + shared = cs.subprotocols[0] + assert cs.select_subprotocol(None, [shared, "other"]) == shared + + with pytest.raises(NegotiationError): + cs.select_subprotocol(None, ["nope1", "nope2"]) + + +@pytest.mark.asyncio +async def test_get_metric_all_fallbacks(hass): + """Test all fallbacks in get_metric.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Voltage" + # 1) explicit connector + cp._metrics[(2, meas)] = M(230.0, "V") + assert cs.get_metric("test_cpid", meas, connector_id=2) == 230.0 + + # 2) charger level (0) + cp._metrics[(0, meas)] = M(231.0, "V") + assert cs.get_metric("test_cpid", meas) == 231.0 + + # 3) flat legacy key + cp._metrics[meas] = M(232.0, "V") + # delete (0,measurand) so flat is used + cp._metrics.pop((0, meas), None) + assert cs.get_metric("test_cpid", meas) == 232.0 + + # 4) fallback connector 1 + cp._metrics.pop(meas, None) + cp._metrics[(1, meas)] = M(233.0, "V") + assert cs.get_metric("test_cpid", meas) == 233.0 + + # 5) scan 2..N + # del_metric: remove via (0, meas) and flat fallback + + # Make sure earlier fallbacks don't win + for k in [(0, meas), (1, meas), (2, meas)]: + if k in cp._metrics: + cp._metrics[k].value = None + + # Also remove/neutralize the legacy flat key if present + with contextlib.suppress(KeyError): + cp._metrics.pop(meas) + + # Now seed the value only on connector 3 + cp._metrics[(3, meas)] = cp._metrics.get((3, meas), M(None, None)) + cp._metrics[(3, meas)].value = 234.0 + cp._metrics[(3, meas)].unit = "V" + + # Ensure the CS thinks there are at least 3 connectors + srv = cs.charge_points[cs.cpids["test_cpid"]] + srv.num_connectors = max(getattr(srv, "num_connectors", 1) or 1, 3) + + assert cs.get_metric("test_cpid", meas) == 234.0 + + +@pytest.mark.asyncio +async def test_get_units_and_attrs_fallbacks(hass): + """Test fallbacks in get_units and get_extra_attrs.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Power.Active.Import" + # units via (3, meas) + cp._metrics[(3, meas)] = M(10.0, "W") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "W" + cp._metrics[(3, meas)].extra_attr = {"ctx": "Sample.Periodic"} + + # ensure earlier probes are empty/missing so it scans to c>=2 + assert cs.get_unit("test_cpid", meas) == "W" + assert cs.get_ha_unit("test_cpid", meas) == "W" + assert cs.get_extra_attr("test_cpid", meas) == {"ctx": "Sample.Periodic"} + + # explicit connector wins + cp._metrics[(1, meas)] = M(11.0, "kW") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "kW" + cp._metrics[(1, meas)].extra_attr = {"src": "conn1"} + assert cs.get_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_ha_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_extra_attr("test_cpid", meas, connector_id=1) == {"src": "conn1"} + + +@pytest.mark.asyncio +async def test_get_available_paths(hass): + """Test paths in get_available.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # charger unavailable by status for connector 0 + cp = _install_dummy_cp(cs, status=STATE_UNAVAILABLE) + assert cs.get_available("test_cpid", connector_id=0) is False + + # specific connector via per-connector metric + meas = cstat.status_connector.value + cp._metrics[(1, meas)] = M("Charging", None) + assert cs.get_available("test_cpid", connector_id=1) is True + + # via flat extra_attr aggregator + cp2 = _install_dummy_cp(cs, cpid="agg", cp_id="CP_AGG", status=STATE_OK) + flat = M("Available", None) + flat.extra_attr = {2: "Finishing"} + cp2._metrics[meas] = flat + assert cs.get_available("agg", connector_id=2) is True + + # fall back to charger status if no info + assert cs.get_available("agg", connector_id=3) is True # charger STATE_OK + + +@pytest.mark.asyncio +async def test_supported_features_and_device_info(hass): + """Test supported features and device info.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs) + assert cs.get_supported_features("test_cpid") == cp.supported_features + assert cs.get_supported_features("unknown") == 0 + assert cs.device_info() == {"identifiers": {(DOMAIN, cs.id)}} + + +@pytest.mark.asyncio +async def test_setters_when_missing_and_present(hass): + """Test set_charger_state various conditions.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # missing -> False + assert await cs.set_max_charge_rate_amps("missing", 10.0) is False + + # present -> routes and returns True + cp = _install_dummy_cp(cs) + assert await cs.set_max_charge_rate_amps("test_cpid", 16.0, connector_id=2) is True + assert ("set_charge_rate", {"limit_amps": 16.0, "conn_id": 2}) in cp.calls + + # set_charger_state branches + await cs.set_charger_state( + "test_cpid", csvcs.service_availability.name, True, connector_id=1 + ) + await cs.set_charger_state( + "test_cpid", csvcs.service_charge_start.name, connector_id=2 + ) + await cs.set_charger_state("test_cpid", csvcs.service_charge_stop.name) + await cs.set_charger_state("test_cpid", csvcs.service_reset.name) + await cs.set_charger_state("test_cpid", csvcs.service_unlock.name, connector_id=3) + kinds = [ + k + for k, _ in cp.calls + if k + in { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + ] + assert set(kinds) == { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + + +@pytest.mark.asyncio +async def test_check_charger_available_decorator_and_services(hass): + """Test the check_charger_available and services when cp not available.""" + + # 1) CentralSystem without websocket + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # 2) Register two CP: one OK and one UNAVAILABLE + _install_dummy_cp(cs, cpid="ok", cp_id="CP_OK", status=STATE_OK) + _install_dummy_cp(cs, cpid="bad", cp_id="CP_BAD", status=STATE_UNAVAILABLE) + + # 3) Minimal hass.data-structure (some handlers read config) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("config", {}) + + # 4) Unavailable -> should throw HomeAssistantError + with pytest.raises(HomeAssistantError): + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "bad"}), + ) + + # 5) Available -> handlers reach CP methods without exception + await cs.handle_trigger_custom_message( + SimpleNamespace( + data={"devid": "ok", "requested_message": "StatusNotification"} + ), + ) + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "ok"}), + ) + await cs.handle_update_firmware( + SimpleNamespace( + data={"devid": "ok", "firmware_url": "http://x/fw.bin", "delay_hours": 2} + ), + ) + await cs.handle_get_diagnostics( + SimpleNamespace(data={"devid": "ok", "upload_url": "http://u/diag"}), + ) + await cs.handle_data_transfer( + SimpleNamespace( + data={"devid": "ok", "vendor_id": "V", "message_id": "M", "data": "D"} + ), + ) + + # 6) set_charge_rate – test all three variants + await cs.handle_set_charge_rate( + SimpleNamespace( + data={"devid": "ok", "custom_profile": "{'foo': 1, 'bar': 'x'}"} + ), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_watts": 3500, "conn_id": 1}), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_amps": 10.5}), + ) + + # 7) configure + get_configuration – check return format + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "needs_reboot", "value": "1"}), + ) + assert resp == {"reboot_required": True} + + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "just_apply", "value": "x"}), + ) + assert resp == {"reboot_required": False} + + resp = await cs.handle_get_configuration( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "Foo"}), + ) + assert resp == {"value": "value-for:Foo"} + + +def test_del_metric_variants(hass): + """Test the del_metric function.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cpid = "test_cpid" + cp = _install_dummy_cp(cs, cpid=cpid, num_connectors=3) + + # --- Case A: connector-scoped metric exists -> set to None + meas_conn = "Voltage" + cp._metrics[(1, meas_conn)] = M(230.0, "V") + # sanity + assert cs.get_metric(cpid, meas_conn, connector_id=1) == 230.0 + + cs.del_metric(cpid, meas_conn, connector_id=1) + assert cs.get_metric(cpid, meas_conn, connector_id=1) is None + + # --- Case B: (0, meas) missing => fallback to legacy flat key when conn==0 + meas_flat = "Power.Active.Import" + if (0, meas_flat) in cp._metrics: + del cp._metrics[(0, meas_flat)] + cp._metrics[meas_flat] = M(123.0, "W") + assert cs.get_metric(cpid, meas_flat) == 123.0 + + cs.del_metric(cpid, meas_flat, connector_id=0) + assert cs.get_metric(cpid, meas_flat) is None + assert cp._metrics[meas_flat].value is None + + # --- Case C: unknown cpid -> returns None, no exception + assert cs.del_metric("unknown_cpid", "Voltage") is None diff --git a/tests/test_charge_point_core.py b/tests/test_charge_point_core.py new file mode 100644 index 00000000..951fc39b --- /dev/null +++ b/tests/test_charge_point_core.py @@ -0,0 +1,330 @@ +"""Test various chargepoint core functions/exceptions.""" + +import asyncio +import math +from types import SimpleNamespace +from unittest.mock import patch +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from websockets.protocol import State + +from homeassistant.setup import async_setup_component + +from custom_components.ocpp.chargepoint import ( + ChargePoint, + OcppVersion, + Metric, + _ConnectorAwareMetrics as CAM, + MeasurandValue, +) +from custom_components.ocpp.const import ( + DOMAIN, + CentralSystemSettings, + ChargerSystemSettings, + DEFAULT_ENERGY_UNIT, + DEFAULT_POWER_UNIT, + HA_ENERGY_UNIT, + HA_POWER_UNIT, +) +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerSession as csess, +) +from ocpp.messages import CallError +from ocpp.charge_point import ChargePoint as LibCP +from ocpp.exceptions import NotImplementedError as OcppNotImplementedError + +from .const import CONF_SSL_CERTFILE_PATH, CONF_SSL_KEYFILE_PATH + + +# ----------------------------- +# Helpers to build a CP instance +# ----------------------------- +def _mk_entry_data(): + return { + "host": "127.0.0.1", + "port": 0, + "csid": "cs", + "cpids": [{"CP_A": {"cpid": "test_cpid"}}], + "subprotocols": ["ocpp1.6"], + "websocket_close_timeout": 5, + "ssl": False, + # required ping fields: + "websocket_ping_interval": 0.0, + "websocket_ping_timeout": 0.01, + "websocket_ping_tries": 0, + "ssl_certfile_path": CONF_SSL_CERTFILE_PATH, + "ssl_keyfile_path": CONF_SSL_KEYFILE_PATH, + } + + +def _mk_cp(hass, *, version=OcppVersion.V201): + entry = MockConfigEntry(domain=DOMAIN, data=_mk_entry_data()) + centr = CentralSystemSettings(**entry.data) + chg = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + # Minimal fake connection + conn = SimpleNamespace(state=State.CLOSED, close=lambda: asyncio.sleep(0)) + cp = ChargePoint("CP_A", conn, version, hass, entry, centr, chg) + cp._metrics[(0, csess.meter_start.value)].value = None + return cp + + +def test_connector_aware_metrics_core(): + """Test _ConnectorAwareMetrics API.""" + m = CAM() + + # set/get flat + m["Voltage"] = Metric(230.0, "V") + assert isinstance(m["Voltage"], Metric) + assert m["Voltage"].value == 230.0 + + # set/get per connector + m[(2, "Voltage")] = Metric(231.0, "V") + assert m[(2, "Voltage")].value == 231.0 + + # connector mapping view + assert isinstance(m[2], dict) + assert "Voltage" in m[2] + + # __contains__ for tuple/int/str keys + assert "Voltage" in m + assert (2, "Voltage") in m + assert 2 in m + + # type checks + with pytest.raises(TypeError): + m[(3, "X")] = ("not", "metric") + with pytest.raises(TypeError): + m[3] = Metric(1, "A") + + +@pytest.mark.asyncio +async def test_get_specific_response_raises_callerror(hass, monkeypatch): + """Test _get_specific_response “unsilence” of CallError.""" + cp = _mk_cp(hass) + + async def fake_super(self, unique_id, timeout): + # Simulate that the lib returns a CallError object + # (which is normally "silenced" in the lib). + return CallError(unique_id, "SomeError", "details") + + # Patch the lib's _get_specific_response so that our wrapper is hit + monkeypatch.setattr(LibCP, "_get_specific_response", fake_super, raising=True) + + with pytest.raises(Exception) as ei: + await cp._get_specific_response("uid-1", 1) + assert "SomeError" in str(ei.value) + + +@pytest.mark.asyncio +async def test_async_update_device_info_updates_metrics_and_registry(hass): + """Test async_update_device_info.""" + await async_setup_component(hass, "device_tracker", {}) + + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="e1", title="e1") + entry.add_to_hass(hass) + await hass.async_block_till_done() + + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + class DummyConn: + """Dummy connection.""" + + state = None + + cp = ChargePoint( + id="CP_ID", + connection=DummyConn(), + version=OcppVersion.V201, + hass=hass, + entry=entry, + central=central, + charger=charger, + ) + + await cp.async_update_device_info( + serial="SER123", + vendor="Acme", + model="Model X", + firmware_version="1.2.3", + ) + + assert cp._metrics[(0, cdet.model.value)].value == "Model X" + assert cp._metrics[(0, cdet.vendor.value)].value == "Acme" + assert cp._metrics[(0, cdet.firmware_version.value)].value == "1.2.3" + assert cp._metrics[(0, cdet.serial.value)].value == "SER123" + + from homeassistant.helpers import device_registry + + dr = device_registry.async_get(hass) + dev = dr.async_get_device({(DOMAIN, "CP_ID"), (DOMAIN, "test_cpid")}) + assert dev is not None + assert dev.manufacturer == "Acme" + assert dev.model == "Model X" + assert dev.sw_version == "1.2.3" + + +def test_get_ha_metric_prefers_exact_entity(hass): + """Test get_ha_metric lookup logic.""" + cp = _mk_cp(hass) + # Seed states + hass.states.async_set("sensor.test_cpid_voltage", "n/a") + hass.states.async_set("sensor.test_cpid_connector_1_voltage", "229.5") + + # With connector_id=1 we should resolve the child entity + assert cp.get_ha_metric("Voltage", connector_id=1) == "229.5" + # With connector_id=None -> root entity + assert cp.get_ha_metric("Voltage", connector_id=None) == "n/a" + + +def _mv(measurand, value, phase=None, unit=None, context=None, location=None): + return MeasurandValue(measurand, value, phase, unit, context, location) + + +def test_process_phases_voltage_and_current_branches(hass): + """Test process_phases: l-l → l-n conversion, summation and unit normalization.""" + cp = _mk_cp(hass) + + # Voltage line-to-line values (should average and divide by sqrt(3)) + bucket = [ + _mv("Voltage", 400.0, phase="L1-L2", unit="V"), + _mv("Voltage", 399.0, phase="L2-L3", unit="V"), + _mv("Voltage", 401.0, phase="L3-L1", unit="V"), + ] + cp.process_phases(bucket, connector_id=1) + v_ln = cp._metrics[(1, "Voltage")].value + assert pytest.approx(v_ln, rel=1e-3) == (400.0 + 399.0 + 401.0) / 3 / math.sqrt(3) + assert cp._metrics[(1, "Voltage")].unit == "V" + + # Power.Active.Import in W should become kW when aggregated + bucket2 = [ + _mv("Power.Active.Import", 1000.0, phase="L1", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 2000.0, phase="L2", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 3000.0, phase="L3", unit=DEFAULT_POWER_UNIT), + ] + cp.process_phases(bucket2, connector_id=2) + p_kw = cp._metrics[(2, "Power.Active.Import")].value + assert p_kw == (1000 + 2000 + 3000) / 1000 # -> 6 kW + assert cp._metrics[(2, "Power.Active.Import")].unit == HA_POWER_UNIT + + +def test_get_energy_kwh_and_session_derive(hass): + """Test get_energy_kwh + process_measurands path (EAIR Wh → kWh, derive Energy.Session).""" + cp = _mk_cp(hass, version=OcppVersion.V201) # != 1.6 to enable session derive + + # Starting meter (kWh) + cp._metrics[(1, csess.meter_start.value)].value = 10.0 + cp._metrics[(1, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + # Send EAIR in Wh (should normalize to kWh) + mv = _mv( + "Energy.Active.Import.Register", 10500.0, unit=DEFAULT_ENERGY_UNIT + ) # 10.5 kWh + cp.process_measurands([[mv]], is_transaction=True, connector_id=1) + + # EAIR normalized + assert cp._metrics[(1, "Energy.Active.Import.Register")].value == 10.5 + assert cp._metrics[(1, "Energy.Active.Import.Register")].unit == HA_ENERGY_UNIT + + # Session energy derived = EAIR - meter_start + assert cp._metrics[(1, csess.session_energy.value)].value == pytest.approx( + 0.5, 1e-12 + ) + assert cp._metrics[(1, csess.session_energy.value)].unit == HA_ENERGY_UNIT + + +@pytest.mark.asyncio +async def test_handle_call_wraps_notimplementederror_and_sends(hass): + """Test _handle_call Path: NotImplementedError → _send(...).""" + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + conn = SimpleNamespace(state=State.OPEN, close=lambda: None) + + cp = ChargePoint( + "CP_ID", + conn, + OcppVersion.V201, + hass, + SimpleNamespace(entry_id="e1", data={}), + central, + charger, + ) + + # Patch the PARENT _handle_call to raise the OCPP NotImplementedError + async def parent_raises(self, msg): + raise OcppNotImplementedError("nope") + + sent = {} + + async def fake_send(payload): + sent["payload"] = payload + + class DummyMsg: + """Dummy message class.""" + + def create_call_error(self, exc): + """Create call error.""" + assert isinstance(exc, OcppNotImplementedError) + return SimpleNamespace(to_json=lambda: "ERR_JSON") + + with ( + patch.object(LibCP, "_handle_call", parent_raises, create=True), + patch.object(cp, "_send", fake_send), + ): + # Wrapper should CATCH the OCPP NotImplementedError and send CallError JSON + await cp._handle_call(DummyMsg()) + + assert sent.get("payload") == "ERR_JSON" diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 919cbe13..c2d8c945 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -3,6 +3,11 @@ import asyncio import contextlib from datetime import datetime, UTC # timedelta, +import inspect +import logging +import re +import time +from types import SimpleNamespace import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -11,15 +16,27 @@ from custom_components.ocpp.api import CentralSystem from custom_components.ocpp.button import BUTTONS +from custom_components.ocpp.chargepoint import Metric as M from custom_components.ocpp.const import ( DOMAIN as OCPP_DOMAIN, CONF_CPIDS, CONF_CPID, + CONF_NUM_CONNECTORS, CONF_PORT, + DEFAULT_ENERGY_UNIT, + DEFAULT_MEASURAND, + HA_ENERGY_UNIT, +) +from custom_components.ocpp.enums import ( + ConfigurationKey, + HAChargerServices as csvcs, + HAChargerStatuses as cstat, + HAChargerSession as csess, + Profiles as prof, ) -from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs from custom_components.ocpp.number import NUMBERS from custom_components.ocpp.switch import SWITCHES +from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP from ocpp.routing import on from ocpp.v16 import ChargePoint as cpclass, call, call_result from ocpp.v16.enums import ( @@ -34,6 +51,7 @@ DataTransferStatus, DiagnosticsStatus, FirmwareStatus, + Measurand, RegistrationStatus, RemoteStartStopStatus, ResetStatus, @@ -54,6 +72,7 @@ wait_ready, ) + SERVICES = [ csvcs.service_update_firmware, csvcs.service_configure, @@ -76,6 +95,39 @@ ] +async def wait_for_num_connectors( + hass, cp_id: str, expected: int, timeout: float = 5.0 +): + """Wait until server side CP has num_connectors == expected. + + Returns the actual CentralSystem instance (after possible reload). + """ + deadline = time.monotonic() + timeout + last_seen = None + + while time.monotonic() < deadline: + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + cs = hass.data[OCPP_DOMAIN][entry.entry_id] + + srv = cs.charge_points.get(cp_id) + if srv is not None: + last_seen = getattr(srv, "num_connectors", None) + if last_seen == expected: + return cs + + for item in entry.data.get(CONF_CPIDS, []): + if isinstance(item, dict) and cp_id in item: + last_seen = item[cp_id].get(CONF_NUM_CONNECTORS) + if last_seen == expected: + return cs + + await asyncio.sleep(0.05) + + raise AssertionError( + f"num_connectors never became {expected} (last seen: {last_seen})" + ) + + async def test_switches(hass, cpid, socket_enabled): """Test switch operations.""" for switch in SWITCHES: @@ -373,26 +425,27 @@ async def test_cms_responses_normal_v16( ) as ws: # use a different id for debugging cp = ChargePoint(f"{cp_id}_client", ws) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_security_event(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(12345), - cp.send_meter_err_phases(), - cp.send_meter_line_voltage(), - cp.send_meter_periodic_data(), - # add delay to allow meter data to be processed - cp.send_stop_transaction(1), - ), - timeout=8, - ) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed + await cp.send_stop_transaction(1) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() cpid = cs.charge_points[cp_id].settings.cpid @@ -478,6 +531,8 @@ async def test_cms_responses_actions_v16( timeout=10, ) cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() # cpid set in cs after websocket connection @@ -631,6 +686,8 @@ async def test_cms_responses_errors_v16( ) await cs.charge_points[cp_id].stop() cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() # test services when charger is unavailable @@ -640,207 +697,2230 @@ async def test_cms_responses_errors_v16( ) -class ChargePoint(cpclass): - """Representation of real client Charge Point.""" +@pytest.mark.timeout(40) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_norm_mc"]) +@pytest.mark.parametrize("port", [9007]) +async def test_cms_responses_normal_multiple_connectors_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger. - def __init__(self, id, connection, response_timeout=30): - """Init extra variables for testing.""" - super().__init__(id, connection) - self.active_transactionId: int = 0 - self.accept: bool = True + Normal operation with multiple connectors. + """ - @on(Action.get_configuration) - def on_get_configuration(self, key, **kwargs): - """Handle a get configuration requests.""" - if key[0] == ConfigurationKey.supported_feature_profiles.value: - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "Core,FirmwareManagement,LocalAuthListManagement,Reservation,SmartCharging,RemoteTrigger,Dummy", - } - ] - ) - else: - # use to test TypeError handling - return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.heartbeat_interval.value: - return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] - ) - if key[0] == ConfigurationKey.number_of_connectors.value: - return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": False, "value": "1"}] - ) - if key[0] == ConfigurationKey.web_socket_ping_interval.value: - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "60"} - ] - ) - else: - return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.meter_values_sampled_data.value: - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "Energy.Active.Import.Register", - } - ] - ) - else: - pass - if key[0] == ConfigurationKey.meter_value_sample_interval.value: - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "60"} - ] - ) - else: - return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] - ) - if ( - key[0] - == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value - ): - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "Current"} - ] - ) - else: - return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: - if self.accept is True: - return call_result.GetConfiguration( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "false"} - ] - ) - else: - return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: - return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] - ) - return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": False, "value": ""}] - ) + cs = setup_config_entry + num_connectors = 2 - @on(Action.change_configuration) - def on_change_configuration(self, key, **kwargs): - """Handle a get configuration request.""" - if self.accept is True: - if key == ConfigurationKey.meter_values_sampled_data.value: - return call_result.ChangeConfiguration( - ConfigurationStatus.reboot_required - ) - else: - return call_result.ChangeConfiguration(ConfigurationStatus.accepted) - else: - return call_result.ChangeConfiguration(ConfigurationStatus.rejected) + # test ocpp messages sent from charger to cms + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=num_connectors) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=num_connectors) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed + await cp.send_stop_transaction(1) - @on(Action.change_availability) - def on_change_availability(self, **kwargs): - """Handle change availability request.""" - if self.accept is True: - return call_result.ChangeAvailability(AvailabilityStatus.accepted) - else: - return call_result.ChangeAvailability(AvailabilityStatus.rejected) + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() - @on(Action.unlock_connector) - def on_unlock_connector(self, **kwargs): - """Handle unlock request.""" - if self.accept is True: - return call_result.UnlockConnector(UnlockStatus.unlocked) - else: - return call_result.UnlockConnector(UnlockStatus.unlock_failed) + cpid = cs.charge_points[cp_id].settings.cpid - @on(Action.reset) - def on_reset(self, **kwargs): - """Handle change availability request.""" - if self.accept is True: - return call_result.Reset(ResetStatus.accepted) - else: - return call_result.Reset(ResetStatus.rejected) + assert int( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + ) == int(1305570 / 1000) + assert int(cs.get_metric(cpid, "Energy.Session", connector_id=1)) == int( + (54321 - 12345) / 1000 + ) + assert int(cs.get_metric(cpid, "Current.Import", connector_id=1)) == 0 + # assert int(cs.get_metric(cpid, "Voltage")) == 228 + assert cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) == "kWh" + assert cs.get_ha_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None + assert int(cs.get_supported_features("unknown_cpid")) == 0 + assert ( + await asyncio.wait_for( + cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 + ) + is False + ) + assert ( + await asyncio.wait_for( + cs.set_charger_state("unknown_cpid", csvcs.service_clear_profile, False), + timeout=1, + ) + is False + ) - @on(Action.remote_start_transaction) - def on_remote_start_transaction(self, **kwargs): - """Handle remote start request.""" - if self.accept is True: - self.task = asyncio.create_task(self.send_start_transaction()) - return call_result.RemoteStartTransaction(RemoteStartStopStatus.accepted) - else: - return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) - @on(Action.remote_stop_transaction) - def on_remote_stop_transaction(self, **kwargs): - """Handle remote stop request.""" - if self.accept is True: - return call_result.RemoteStopTransaction(RemoteStartStopStatus.accepted) - else: - return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9009, "cp_id": "CP_1_clear", "cms": "cms_clear"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_clear"]) +@pytest.mark.parametrize("port", [9009]) +async def test_clear_profile_v16(hass, socket_enabled, cp_id, port, setup_config_entry): + """Verify that HA's clear_profile service triggers OCPP 1.6 ClearChargingProfile.""" - @on(Action.set_charging_profile) - def on_set_charging_profile(self, **kwargs): - """Handle set charging profile request.""" - if self.accept is True: - return call_result.SetChargingProfile(ChargingProfileStatus.accepted) - else: - return call_result.SetChargingProfile(ChargingProfileStatus.rejected) + cs: CentralSystem = setup_config_entry - @on(Action.clear_charging_profile) - def on_clear_charging_profile(self, **kwargs): - """Handle clear charging profile request.""" - if self.accept is True: - return call_result.ClearChargingProfile(ClearChargingProfileStatus.accepted) - else: - return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) - @on(Action.trigger_message) - def on_trigger_message(self, **kwargs): - """Handle trigger message request.""" - if self.accept is True: - return call_result.TriggerMessage(TriggerMessageStatus.accepted) - else: - return call_result.TriggerMessage(TriggerMessageStatus.rejected) + # Make CP ready so HA can run services + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) - @on(Action.update_firmware) - def on_update_firmware(self, **kwargs): - """Handle update firmware request.""" - return call_result.UpdateFirmware() + cpid = cs.charge_points[cp_id].settings.cpid - @on(Action.get_diagnostics) - def on_get_diagnostics(self, **kwargs): - """Handle get diagnostics request.""" - return call_result.GetDiagnostics() + # Minimal clear: no filters -> clears any CS/CP max profiles + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_clear_profile.value, + service_data={"devid": cpid}, + blocking=True, + ) - @on(Action.data_transfer) - def on_data_transfer(self, **kwargs): - """Handle get data transfer request.""" - if self.accept is True: - return call_result.DataTransfer(DataTransferStatus.accepted) - else: - return call_result.DataTransfer(DataTransferStatus.rejected) + # Let the request propagate + await asyncio.sleep(0.05) - async def send_boot_notification(self): - """Send a boot notification.""" - request = call.BootNotification( - charge_point_model="Optimus", charge_point_vendor="The Mobility House" - ) - resp = await self.call(request) - assert resp.status == RegistrationStatus.accepted + # Assert the CP handler was called + assert cp.last_clear_profile_kwargs is not None + # Common default: empty dict (no id/purpose/stack/connector filters) + assert isinstance(cp.last_clear_profile_kwargs, dict) - async def send_heartbeat(self): + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +async def set_report_session_energyreport( + cs: CentralSystem, cp_id: str, should_report: bool +): + """Set report session energy report True/False.""" + cs.charge_points[cp_id]._charger_reports_session_energy = should_report + + +set_report_session_energyreport.__test__ = False + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9010, "cp_id": "CP_1_stop_paths", "cms": "cms_stop_paths"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths"]) +@pytest.mark.parametrize("port", [9010]) +async def test_stop_transaction_paths_v16_a( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO A: _charger_reports_session_energy = True and SessionEnergy is None + # Use last Energy.Active.Import.Register to populate SessionEnergy. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + + # Ensure there is an active tx so stop is accepted + await cp.send_start_transaction(meter_start=0) + + # Force SessionEnergy to be None before stop + m = cs.charge_points[cp_id]._metrics + m[(1, "Energy.Session")].value = None # connector 1 + + # Case A1: last EAIR in Wh → should convert to kWh + m[(1, "Energy.Active.Import.Register")].value = 1300000 # Wh + m[(1, "Energy.Active.Import.Register")].unit = "Wh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 1300000 / 1000.0 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9021, "cp_id": "CP_1_stop_paths_a1", "cms": "cms_stop_paths_a1"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_a1"]) +@pytest.mark.parametrize("port", [9021]) +async def test_stop_transaction_paths_v16_a1( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO A (variant): charger reports session energy AND last EAIR already kWh. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + await cp.send_start_transaction(meter_start=0) + + m = cs.charge_points[cp_id]._metrics + m[(1, "Energy.Session")].value = None + m[(1, "Energy.Active.Import.Register")].value = 42.5 # already kWh + m[(1, "Energy.Active.Import.Register")].unit = "kWh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 42.5 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9022, "cp_id": "CP_1_stop_paths_b", "cms": "cms_stop_paths_b"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_b"]) +@pytest.mark.parametrize("port", [9022]) +async def test_stop_transaction_paths_v16_b( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO B: charger reports session energy BUT SessionEnergy already set → do not overwrite. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + await cp.send_start_transaction(meter_start=0) + + m = cs.charge_points[cp_id]._metrics + # Pre-set SessionEnergy (should remain unchanged) + m[(1, "Energy.Session")].value = 7.777 + m[(1, "Energy.Session")].unit = "kWh" + + # Set EAIR to a different value to ensure we would notice an overwrite + m[(1, "Energy.Active.Import.Register")].value = 999999 + m[(1, "Energy.Active.Import.Register")].unit = "Wh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 7.777 # unchanged + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9023, "cp_id": "CP_1_stop_paths_c", "cms": "cms_stop_paths_c"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_c"]) +@pytest.mark.parametrize("port", [9023]) +async def test_stop_transaction_paths_v16_c( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + await cp.send_start_transaction(12345) + await set_report_session_energyreport(cs, cp_id, False) + await cp.send_stop_transaction(1) + + cpid = cs.charge_points[cp_id].settings.cpid + + # Expect session = 54.321 - 12.345 = 41.976 kWh + sess = float(cs.get_metric(cpid, "Energy.Session")) + assert round(sess, 3) == round(54.321 - 12.345, 3) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # After stop, these measurands must be zeroed + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert float(cs.get_metric(cpid, meas)) == 0.0 + + # Optional: stop reason captured + assert cs.get_metric(cpid, "Stop.Reason") is not None + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9061, "cp_id": "CP_1_meter_paths", "cms": "cms_meter_paths"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_meter_paths"]) +@pytest.mark.parametrize("port", [9061]) +async def test_on_meter_values_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise important branches of ocppv16.on_meter_values, deterministically.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # 1) Start a transaction with meter_start = 10000 Wh (10.0 kWh) + await cp.send_start_transaction(meter_start=10000) + + async def _wait_until(cond, timeout=2.0, step=0.01): + import time + + end = time.monotonic() + timeout + while time.monotonic() < end: + if cond(): + return True + await asyncio.sleep(step) + return False + + assert await _wait_until( + lambda: ( + srv._metrics[(1, "Energy.Meter.Start")].value == 10.0 + and (srv.active_transaction_id or 0) != 0 + ), + timeout=2.0, + ), "Server never persisted meter_start=10.0 and active_transaction_id" + + active_tx = srv.active_transaction_id + assert active_tx != 0 + + # 2) MAIN METER without tx id -> updates connector 0 in kWh + await cp.send_main_meter_clock_data() + agg_eair = float( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=0) + ) + assert agg_eair == pytest.approx(67230012 / 1000.0, rel=1e-6) + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=0) + == "kWh" + ) + + # 3) Set meter_start to 12.5 kWh + m = srv._metrics + m[(1, "Energy.Meter.Start")].value = 12.5 + m[(1, "Energy.Meter.Start")].unit = "kWh" + m[(1, "Transaction.Id")].value = active_tx + + # 4) Send MV with tx id and EAIR=15000 Wh (15.0 kWh) + empty PAI -> 0.0 + mv = call.MeterValues( + connector_id=1, + transaction_id=active_tx, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + { + "value": "", + "measurand": "Power.Active.Import", + "unit": "W", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp = await cp.call(mv) + assert resp is not None + + # meter_start reset from 12.5 kWh → session = 15.0 - 12.5 = 2.5 kWh + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert sess == pytest.approx(2.5, rel=1e-6) + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + # Empty string → 0.0 + pai = float(cs.get_metric(cpid, "Power.Active.Import", connector_id=1)) + assert pai == 0.0 + + # Tx id reset + tx_restored = int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) + assert tx_restored == active_tx + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9018, "cp_id": "CP_1_mv_restore", "cms": "cms_mv_restore"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_mv_restore"]) +@pytest.mark.parametrize("port", [9018]) +async def test_on_meter_values_restore_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Cover both restore branches in on_meter_values. + + - restored (meter_start) is not None + - restored_tx (transaction_id) is not None + Then verify SessionEnergy behavior. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Ensure the metric slots look "missing" so both restore branches run. + srv._metrics[(1, "Energy.Meter.Start")].value = None + srv._metrics[(1, "Transaction.Id")].value = None + + # Patch get_ha_metric so both restores succeed. + def fake_get_ha_metric(name: str, connector_id: int | None = None): + if name == "Energy.Meter.Start" and connector_id == 1: + return "12.5" # kWh + if name == "Transaction.Id" and connector_id == 1: + return "123456" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # (1) Send a MeterValues WITHOUT transaction_id -> updates aggregate EAIR (conn 0) + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", # Wh -> 15.0 kWh + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + resp = await cp.call(mv_no_tx) + assert resp is not None + + # Verify both restore branches happened. + assert srv._metrics[(1, "Energy.Meter.Start")].value == 12.5 + assert srv._metrics[(1, "Transaction.Id")].value == 123456 + assert srv._active_tx.get(1, 0) == 123456 + + # Aggregate EAIR (connector 0) updated to 15.0 kWh with attrs. + assert srv._metrics[(0, "Energy.Active.Import.Register")].value == 15.0 + assert srv._metrics[(0, "Energy.Active.Import.Register")].unit == "kWh" + assert ( + srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get( + "location" + ) + == "Inlet" + ) + assert ( + srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get("context") + == "Sample.Clock" + ) + + # (2) Send a MeterValues WITH matching transaction_id and EAIR=16.0 kWh + mv_with_tx = call.MeterValues( + connector_id=1, + transaction_id=123456, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "16.0", + "measurand": "Energy.Active.Import.Register", + "unit": "kWh", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp2 = await cp.call(mv_with_tx) + assert resp2 is not None + + # SessionEnergy = 16.0 − 12.5 = 3.5 kWh + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert pytest.approx(sess, rel=1e-6) == 3.5 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9013, "cp_id": "CP_1_extra", "cms": "cms_extra"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra"]) +@pytest.mark.parametrize("port", [9013]) +async def test_api_get_extra_attr_paths( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise CentralSystem.get_extra_attr() without driving full post-connect. + + We connect briefly to ensure the CS has a server-side CP object, then we + seed _metrics extra_attr directly and verify lookup order: + - explicit connector_id returns that connector's attrs, + - no connector_id prefers aggregate (conn 0), + - if conn 0 is missing, fallback to conn 1 succeeds. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # Start a minimal CP so CS creates/keeps the server-side object + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Grab server-side CP and seed metrics directly + cp_srv = cs.charge_points[cp_id] + cpid = cp_srv.settings.cpid + + meas = "Energy.Active.Import.Register" + + # Seed aggregate (connector 0) extra_attr + cp_srv._metrics[(0, meas)].extra_attr = { + "location": "Inlet", + "context": "Sample.Clock", + } + + # (A) No connector_id -> prefers aggregate (0) + attrs = cs.get_extra_attr(cpid, measurand=meas) + assert attrs == {"location": "Inlet", "context": "Sample.Clock"} + + # (B) Explicit connector 1 -> returns that connector's attrs + cp_srv._metrics[(1, meas)].extra_attr = {"custom": "c1", "context": "Override"} + attrs_c1 = cs.get_extra_attr(cpid, measurand=meas, connector_id=1) + assert attrs_c1 == {"custom": "c1", "context": "Override"} + + # (C) Fallback order when aggregate is missing -> falls back to connector 1 + cp_srv._metrics[(0, meas)].extra_attr = None + attrs_fallback = cs.get_extra_attr(cpid, measurand=meas) + assert attrs_fallback == {"custom": "c1", "context": "Override"} + + # Clean up + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9014, "cp_id": "CP_1_fw_ok", "cms": "cms_fw_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_ok"]) +@pytest.mark.parametrize("port", [9014]) +async def test_update_firmware_supported_valid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + valid URL -> returns True and RPC is sent with correct payload.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Enable FW bit + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + url = "https://example.com/fw.bin" + caplog.set_level(logging.INFO) + + ok = await server_cp.update_firmware(url, wait_time=0) + assert ok is True + + # Assert the client actually received an UpdateFirmware call with expected data + # retrieveDate format: YYYY-mm-ddTHH:MM:SSZ + assert cp.last_update_firmware is not None + assert cp.last_update_firmware.get("location") == url + rd = cp.last_update_firmware.get("retrieve_date") + assert isinstance(rd, str) and re.match( + r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$", rd + ) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9015, "cp_id": "CP_1_fw_badurl", "cms": "cms_fw_badurl"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_badurl"]) +@pytest.mark.parametrize("port", [9015]) +async def test_update_firmware_supported_invalid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + invalid URL -> returns False and no RPC is sent.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + bad_url = "not-a-valid-url" + caplog.set_level(logging.WARNING) + + ok = await server_cp.update_firmware(bad_url, wait_time=1) + assert ok is False + # Should warn about invalid URL + assert any("Failed to parse url" in rec.message for rec in caplog.records) + # Client must not have received any UpdateFirmware + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9016, "cp_id": "CP_1_fw_nosupport", "cms": "cms_fw_nosupport"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_nosupport"]) +@pytest.mark.parametrize("port", [9016]) +async def test_update_firmware_not_supported_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW not supported -> returns False; no RPC.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Ensure FW bit is NOT set + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) & ~prof.FW + ) + + caplog.set_level(logging.WARNING) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any( + "does not support OCPP firmware updating" in rec.message + for rec in caplog.records + ) + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9017, "cp_id": "CP_1_fw_rpcfail", "cms": "cms_fw_rpcfail"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_rpcfail"]) +@pytest.mark.parametrize("port", [9017]) +async def test_update_firmware_rpc_failure_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog, monkeypatch +): + """FW supported but self.call raises -> returns False and logs error.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + # Make the server-side call() fail + async def boom(_req): + raise RuntimeError("boom") + + monkeypatch.setattr(server_cp, "call", boom, raising=True) + + caplog.set_level(logging.ERROR) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any("UpdateFirmware failed" in rec.message for rec in caplog.records) + # No successful RPC reached the client + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(40) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9020, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_unit_fallback"]) +@pytest.mark.parametrize("port", [9020]) +async def test_api_get_unit_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """get_unit() should fall back to connectors >=2 when (0) and (1) have no unit.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # IMPORTANT: advertise 3 connectors so the CS learns n_connectors >= 3 + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Power.Active.Import" + + # Ensure no flat-key unit short-circuits the fallback + if meas in srv._metrics: + srv._metrics[meas].unit = None + + # Seed (0) and (1) with metrics but no unit… + srv._metrics[(0, meas)] = srv._metrics.get((0, meas), M(0.0, None)) + srv._metrics[(0, meas)].unit = None + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(0.0, None)) + srv._metrics[(1, meas)].unit = None + + # …and (2) with a concrete unit the fallback should discover. + srv._metrics[(2, meas)] = M(0.0, "kW") + + unit = cs.get_unit(cpid, measurand=meas) + assert unit == "kW" + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9019, + "cp_id": "CP_1_extra_fallback", + "cms": "cms_extra_fallback", + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra_fallback"]) +@pytest.mark.parametrize("port", [9019]) +async def test_api_get_extra_attr_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure get_extra_attr() falls back. + + To connectors >=2 when (0), flat-key, (1) and (2) have no attrs (extra_attr=None), so connector 3 is returned. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + from custom_components.ocpp.chargepoint import Metric as M + + meas = "Energy.Active.Import.Register" + + # (1) Force early checks to return None (NOT {}): + # - Access the flat key via __getitem__ to create the exact object the API will read, + # then set its extra_attr to None. + srv._metrics[(0, meas)] = M(0.0, None) + srv._metrics[(0, meas)].extra_attr = None + + _flat = srv._metrics[meas] # <-- pre-touch the flat key + _flat.extra_attr = None # <-- ensure it returns None, not {} + + srv._metrics[(1, meas)] = M(0.0, None) + srv._metrics[(1, meas)].extra_attr = None + + srv._metrics[(2, meas)] = M(0.0, None) + srv._metrics[(2, meas)].extra_attr = None + + # (2) Seed connector 3 with the only non-empty attrs. + expected = {"source": "conn3", "context": "Sample.Clock"} + srv._metrics[(3, meas)] = M(0.0, None) + srv._metrics[(3, meas)].extra_attr = expected + + # (3) Now the API should fall through to connector 3. + got = cs.get_extra_attr(cpid, measurand=meas) + assert got == expected + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9008, "cp_id": "CP_1_diag_dt", "cms": "cms_diag_dt"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_diag_dt"]) +@pytest.mark.parametrize("port", [9008]) +async def test_get_diagnostics_and_data_transfer_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Ensure HA services trigger correct OCPP 1.6 calls with expected payload. + + including DataTransfer rejected path and get_diagnostics error/feature branches. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Bring charger to ready state (boot + post_connect) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Resolve HA device id (cpid) + cpid = cs.charge_points[cp_id].settings.cpid + + # --- get_diagnostics: happy path with valid URL --- + upload_url = "https://example.test/diag" + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": upload_url}, + blocking=True, + ) + + # --- data_transfer: Accepted path --- + vendor_id = "VendorX" + message_id = "Msg42" + payload = '{"hello":"world"}' + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": vendor_id, + "message_id": message_id, + "data": payload, + }, + blocking=True, + ) + + # Give event loop a tick to flush ws calls + await asyncio.sleep(0.05) + + # Assert CP handlers received expected fields (as captured by the fake CP) + assert cp.last_diag_location == upload_url + assert cp.last_data_transfer == (vendor_id, message_id, payload) + + # --- data_transfer: Rejected path (flip cp.accept -> False) --- + cp.accept = False + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": "VendorX", + "message_id": "MsgRejected", + "data": "nope", + }, + blocking=True, + ) + await asyncio.sleep(0.05) + + # --- get_diagnostics: invalid URL triggers vol.MultipleInvalid warning --- + caplog.clear() + caplog.set_level(logging.WARNING) + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "not-a-valid-url"}, + blocking=True, + ) + assert any( + "Failed to parse url" in rec.message for rec in caplog.records + ), "Expected warning for invalid diagnostics upload_url not found" + + # --- get_diagnostics: FW profile NOT supported branch --- + # Simulate that FirmwareManagement profile is not supported by the CP + cpobj = cs.charge_points[cp_id] + original_features = getattr(cpobj, "_attr_supported_features", None) + + # Try to blank out features regardless of type (set/list/tuple/int) + try: + tp = type(original_features) + if isinstance(original_features, set | list | tuple): + new_val = tp() # empty same container type + else: + new_val = 0 # fall back to "no features" + setattr(cpobj, "_attr_supported_features", new_val) + except Exception: + setattr(cpobj, "_attr_supported_features", 0) + + # Valid URL, but without FW support the handler should skip/return gracefully + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "https://example.com/diag2"}, + blocking=True, + ) + + # Restore original features to avoid impacting other tests + if original_features is not None: + setattr(cpobj, "_attr_supported_features", original_features) + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9024, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_monconn"]) +@pytest.mark.parametrize("port", [9024]) +async def test_monitor_connection_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise TimeoutError branch in chargepoint.monitor_connection and ensure it raises after exceeded tries.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + + from custom_components.ocpp import chargepoint as cp_mod + + async def noop_task(_coro): + return None + + monkeypatch.setattr(srv_cp.hass, "async_create_task", noop_task, raising=True) + + async def fast_sleep(_): + return None # skip the initial sleep(10) and interval sleeps + + monkeypatch.setattr(cp_mod.asyncio, "sleep", fast_sleep, raising=True) + + # First wait_for returns a never-finishing "pong waiter", + # second wait_for raises TimeoutError -> hits the except branch + calls = {"n": 0} + + async def fake_wait_for(awaitable, timeout): + calls["n"] += 1 + if inspect.iscoroutine(awaitable): + awaitable.close() + if calls["n"] == 1: + + class _NeverFinishes: + def __await__(self): + fut = asyncio.get_event_loop().create_future() + return fut.__await__() + + return _NeverFinishes() + raise TimeoutError + + monkeypatch.setattr(cp_mod.asyncio, "wait_for", fake_wait_for, raising=True) + + # Make the code raise on first timeout + srv_cp.cs_settings.websocket_ping_interval = 0.0 + srv_cp.cs_settings.websocket_ping_timeout = 0.01 + srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise + + srv_cp.post_connect_success = True + + async def noop(): + return None + + monkeypatch.setattr(srv_cp, "post_connect", noop, raising=True) + monkeypatch.setattr(srv_cp, "set_availability", noop, raising=True) + + with pytest.raises(TimeoutError): + await srv_cp.monitor_connection() + + assert calls["n"] >= 2 # both wait_for calls were exercised + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9025, "cp_id": "CP_1_authlist", "cms": "cms_authlist"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_authlist"]) +@pytest.mark.parametrize("port", [9025]) +async def test_get_authorization_status_with_auth_list( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise ChargePoint.get_authorization_status() when an auth_list is configured.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.const import ( + DOMAIN, + CONFIG, + CONF_DEFAULT_AUTH_STATUS, + CONF_AUTH_LIST, + CONF_ID_TAG, + CONF_AUTH_STATUS, + ) + + # Start a minimal client so the server-side CP is registered. + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # We only needed a boot to register the CP; close the socket cleanly. + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + srv_cp = cs.charge_points[cp_id] + + # Configure default + auth_list in HA config dict + hass.data[DOMAIN][CONFIG][CONF_DEFAULT_AUTH_STATUS] = ( + AuthorizationStatus.blocked.value + ) + hass.data[DOMAIN][CONFIG][CONF_AUTH_LIST] = [ + { + CONF_ID_TAG: "TAG_PRESENT", + CONF_AUTH_STATUS: AuthorizationStatus.expired.value, + }, + {CONF_ID_TAG: "TAG_NO_STATUS"}, # should fall back to default + ] + + # 1) Early return path: remote id tag + srv_cp._remote_id_tag = "REMOTE123" + assert ( + srv_cp.get_authorization_status("REMOTE123") + == AuthorizationStatus.accepted.value + ) + + # 2) Match in auth_list with explicit status + assert ( + srv_cp.get_authorization_status("TAG_PRESENT") + == AuthorizationStatus.expired.value + ) + + # 3) Match in auth_list without explicit status -> default + assert ( + srv_cp.get_authorization_status("TAG_NO_STATUS") + == AuthorizationStatus.blocked.value + ) + + # 4) Not found in auth_list -> default + assert ( + srv_cp.get_authorization_status("UNKNOWN") == AuthorizationStatus.blocked.value + ) + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9026, + "cp_id": "CP_1_sess_single", + "cms": "cms_sess_single", + "num_connectors": 1, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_single"]) +@pytest.mark.parametrize("port", [9026]) +async def test_session_metrics_single_connector_backward_compat( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Single-connector: connector_id=None should transparently read connector 1 session metrics.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=1) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Server-side handle + CPID + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Seed connector 1 session value directly + meas = "Energy.Session" + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(None, None)) + srv._metrics[(1, meas)].value = 3.2 + srv._metrics[(1, meas)].unit = "kWh" + + # Backward-compat read: connector_id=None must resolve to connector 1 for single-connector + val_none = cs.get_metric(cpid, measurand=meas, connector_id=None) + assert val_none == 3.2 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9027, + "cp_id": "CP_1_sess_multi", + "cms": "cms_sess_multi", + "num_connectors": 2, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_multi"]) +@pytest.mark.parametrize("port", [9027]) +async def test_session_metrics_multi_connector_isolated( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Multi-connector: values on connector 1 and 2 are distinct.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=2) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Energy.Session" + # Seed distinct values per connector + for conn, val in [(1, 1.0), (2, 2.0)]: + srv._metrics[(conn, meas)] = srv._metrics.get((conn, meas), M(None, None)) + srv._metrics[(conn, meas)].value = val + srv._metrics[(conn, meas)].unit = "kWh" + + # Verify isolation + assert cs.get_metric(cpid, measurand=meas, connector_id=1) == 1.0 + assert cs.get_metric(cpid, measurand=meas, connector_id=2) == 2.0 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9071, "cp_id": "CP_ST_SU", "cms": "cms_st_su"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_ST_SU"]) +@pytest.mark.parametrize("port", [9071]) +async def test_start_transaction_accept_and_reject( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """start_transaction returns True on accepted, False on reject and notifies HA.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] # server-side CP + + # 1) Accepted -> True + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.start_transaction(connector_id=2) + assert ok is True + + # 2) Rejected -> False and notify_ha called + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append((msg, title)) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + bad = await srv_cp.start_transaction(connector_id=1) + assert bad is False + assert notes and "Start transaction failed" in notes[0][0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9072, "cp_id": "CP_STOP", "cms": "cms_stop"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_STOP"]) +@pytest.mark.parametrize("port", [9072]) +async def test_stop_transaction_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """stop_transaction: early True when no active tx; accepted True; reject False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: no active tx anywhere -> returns True without calling cp + srv_cp.active_transaction_id = 0 + # ocppv16 uses _active_tx dict; ensure it's empty/falsey + setattr(srv_cp, "_active_tx", {}) # or defaultdict if lib uses that + called = {"n": 0} + + async def should_not_call(_req): + called["n"] += 1 + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", should_not_call, raising=True) + early = await srv_cp.stop_transaction() + assert early is True + assert called["n"] == 0 # verify we didn't call into charger + + # Case B: active tx id present -> accepted -> True + srv_cp.active_transaction_id = 42 + + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.stop_transaction() + assert ok is True + + # Case C: active tx but reject -> False and notify_ha + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + srv_cp.active_transaction_id = 99 + bad = await srv_cp.stop_transaction() + assert bad is False + assert notes and "Stop transaction failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9073, "cp_id": "CP_UNLOCK", "cms": "cms_unlock"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_UNLOCK"]) +@pytest.mark.parametrize("port", [9073]) +async def test_unlock_accept_and_fail( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """unlock: unlocked -> True; otherwise False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Success + async def call_ok(req): + return SimpleNamespace(status=UnlockStatus.unlocked) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.unlock(connector_id=2) + assert ok is True + + # Failure → notify + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_fail(req): + # pick a non-success status + return SimpleNamespace(status=UnlockStatus.unlock_failed) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_fail, raising=True) + bad = await srv_cp.unlock(connector_id=1) + assert bad is False + assert notes and "Unlock failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9074, "cp_id": "CP_NUM_CONN", "cms": "cms_num_conn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_NUM_CONN"]) +@pytest.mark.parametrize("port", [9074]) +async def test_get_number_of_connectors_variants( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise all branches of get_number_of_connectors().""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: valid configurationKey with correct value + async def call_good(req): + return SimpleNamespace( + configuration_key=[ + SimpleNamespace(key="NumberOfConnectors", value="3") + ] + ) + + monkeypatch.setattr(srv_cp, "call", call_good) + n = await srv_cp.get_number_of_connectors() + assert n == 3 + + # Case B: resp is list[tuple] with dict inside ("configurationKey") + async def call_tuple(req): + return [ + "ignored", + "ignored", + {"configurationKey": [{"key": "NumberOfConnectors", "value": "4"}]}, + ] + + monkeypatch.setattr(srv_cp, "call", call_tuple) + n = await srv_cp.get_number_of_connectors() + assert n == 4 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9076, "cp_id": "CP_diag", "cms": "cms_diag"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_diag"]) +@pytest.mark.parametrize("port", [9076]) +async def test_on_diagnostics_status_notification( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test on_diagnostics_status. + + - replies with DiagnosticsStatusNotification + - schedules notify_ha with expected message + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + captured = {"called": 0, "msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + # record the message; return True like the real notifier + captured["msg"] = msg + return True + + def fake_async_create_task(coro): + # actually schedule the coroutine so fake_notify runs + captured["called"] += 1 + return asyncio.create_task(coro) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr( + srv_cp.hass, "async_create_task", fake_async_create_task, raising=True + ) + + # trigger server handler + req = call.DiagnosticsStatusNotification(status="Uploaded") + resp = await cp.call(req) + assert resp is not None # server replied + + # ensure notify_ha ran and message content is correct + # give the task a tick to run + await asyncio.sleep(0) + assert captured["called"] == 1 + assert captured["msg"] == "Diagnostics upload status: Uploaded" + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(15) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9077, "cp_id": "CP_stop_hdl", "cms": "cms_stop_hdl"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_hdl"]) +@pytest.mark.parametrize("port", [9077]) +async def test_on_stop_transaction_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test ocppv16.on_stop_transaction. + + 1) Normal routed call (valid payload) with unknown tx -> falls back to conn=1 and + exception on meter_start only. + 2) Direct handler call to cover the exception path on meter_stop (string) + and the EAIR-derived branch’s conversion error. + Also verify currents/powers are zeroed and HA update is scheduled. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Minimal client to start the protocol task and register the CP + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + spawned_tasks: list[asyncio.Task] = [] + scheduled = {"n": 0} + + try: + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv: ServerCP = cs.charge_points[cp_id] + + # Keep HA quiet; count scheduled updates instead of running them + scheduled = {"n": 0} + + def fake_async_create_task(target, *args, **kwargs): + """Intercept HA task scheduling. + + - If the target is the cp.update(...) coroutine, close it so it never runs + - Otherwise, create a real asyncio task so nothing else in the loop breaks. + """ + scheduled["n"] += 1 + + if inspect.iscoroutine(target): + co = getattr(target, "cr_code", None) + name = getattr(co, "co_name", "") if co else "" + + if name == "update": + target.close() + t = asyncio.create_task(asyncio.sleep(0)) + spawned_tasks.append(t) + return t + + t = asyncio.create_task(target) + spawned_tasks.append(t) + return t + + t = asyncio.create_task(asyncio.sleep(0)) + spawned_tasks.append(t) + return t + + monkeypatch.setattr( + srv.hass, "async_create_task", fake_async_create_task, raising=True + ) + + # Ensure connector 1 metrics exist + _ = srv._metrics[(1, cstat.stop_reason.value)] + _ = srv._metrics[(1, csess.meter_start.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.session_energy.value)] + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + _ = srv._metrics[(1, m)] + + # ------------------------------------------------------------------ + # (A) Routed normal call: unknown tx -> conn is None path; make meter_start + # non-numeric to hit that exception (meter_stop remains valid int). + # ------------------------------------------------------------------ + unknown_tx = 999_001 + srv._active_tx = {} # ensures lookup fails -> fallback to conn=1 + srv.active_transaction_id = 0 + + # Force meter_start conversion failure + srv._metrics[(1, csess.meter_start.value)].value = "not-a-number" + + stop_req = call.StopTransaction( + transaction_id=unknown_tx, + meter_stop=12345, + timestamp="2024-01-01T00:00:00Z", + reason="Local", + ) + stop_resp = await cli.call(stop_req) + assert isinstance(stop_resp, call_result.StopTransaction) + + # Session energy is derived from meter_stop (12.345 kWh) minus + # meter_start (conversion failed -> 0.0) = 12.345 + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + # Zeroing of currents/powers + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + assert srv._metrics[(1, m)].value == 0 + + assert scheduled["n"] >= 1 # update(...) scheduled + + # ------------------------------------------------------------------ + # (B) Direct handler call to cover: + # - meter_stop conversion exception (string) + # - EAIR-based branch with conversion error + # ------------------------------------------------------------------ + # Prepare connector 2 + _ = srv._metrics[(2, DEFAULT_MEASURAND)] + _ = srv._metrics[(2, csess.session_energy.value)] + _ = srv._metrics[(2, csess.meter_start.value)] + + # Choose EAIR-based route + srv._charger_reports_session_energy = True + # No precomputed session value so handler tries to derive from last EAIR + srv._metrics[(2, csess.session_energy.value)].value = None + srv._metrics[(2, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + # Make EAIR non-convertible to float -> triggers exception inside EAIR branch + srv._metrics[(2, DEFAULT_MEASURAND)].value = "NaN-err" + + # Map tx to connector 2 (so conn is found and not None) + known_tx = 222_333 + srv._active_tx = {2: known_tx} + srv.active_transaction_id = known_tx + + # Call handler directly to bypass OCPP schema and send bad meter_stop + # NOTE: This is intentional to exercise the internal try/except on meter_stop. + direct_resp = srv.on_stop_transaction( + meter_stop="bad-int", # triggers exception -> 0.0 if fallback path used + timestamp="2024-01-01T00:00:01Z", + transaction_id=known_tx, + reason="Local", + ) + assert isinstance(direct_resp, call_result.StopTransaction) + + # EAIR conversion failed; code swallows the exception and leaves session possibly unset + assert srv._metrics[(2, csess.session_energy.value)].value in (None,) + + # Currents/powers should be zeroed on connector 2 as well + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + _ = srv._metrics[(2, m)] + assert srv._metrics[(2, m)].value == 0 + + finally: + for t in spawned_tasks: + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9082, "cp_id": "CP_stop_eair_wh", "cms": "cms_stop_eair_wh"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_eair_wh"]) +@pytest.mark.parametrize("port", [9082]) +async def test_on_stop_transaction_eair_unit_wh( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test on_stop_transaction EAIR branch with last_unit == Wh and last_eair set. + + Covers the branch where eair_kwh = float(last_eair) / 1000.0. + """ + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + + try: + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv: ServerCP = cs.charge_points[cp_id] + + # Prepare connector 1 metrics + _ = srv._metrics[(1, csess.session_energy.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.meter_start.value)] + + # Force EAIR branch + srv._charger_reports_session_energy = True + srv._metrics[(1, csess.session_energy.value)].value = None + srv._metrics[(1, DEFAULT_MEASURAND)].unit = DEFAULT_ENERGY_UNIT + # Here: set a Wh value to trigger the branch + srv._metrics[(1, DEFAULT_MEASURAND)].value = 12345 # Wh = 12.345 kWh + + # Map tx → connector 1 + tx_id = 222 + srv._active_tx = {1: tx_id} + srv.active_transaction_id = tx_id + + # Prevent lingering post_connect job during teardown + srv.post_connect_success = True + + async def _noop(): # don't start background work in tests + return None + + monkeypatch.setattr(srv, "post_connect", _noop, raising=True) + + def _schedule(target, *args, **kwargs): + # Always schedule the coroutine; ignore HA's optional args (name/eager_start) + return asyncio.create_task(target) + + # Patch both the server CP’s hass and the root hass to be safe + monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) + monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) + + # Call handler directly + resp = srv.on_stop_transaction( + meter_stop=99999, # ignored in EAIR branch + timestamp="2024-01-01T00:00:01Z", + transaction_id=tx_id, + reason="Local", + ) + assert isinstance(resp, call_result.StopTransaction) + + # Session energy should now be set to 12.345 kWh + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9083, "cp_id": "CP_stop_eair_kwh", "cms": "cms_stop_eair_kwh"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_eair_kwh"]) +@pytest.mark.parametrize("port", [9083]) +async def test_on_stop_transaction_eair_unit_kwh( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """EAIR branch where last_unit == kWh and last_eair has a value. + + Verifies that session energy is copied as-is (already in kWh), + and avoids warnings by scheduling the HA update and disabling post_connect. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + + try: + # Boot so the server registers this CP + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv: ServerCP = cs.charge_points[cp_id] + + # Prevent lingering post_connect job during teardown + srv.post_connect_success = True + + async def _noop(): # don't start background work in tests + return None + + monkeypatch.setattr(srv, "post_connect", _noop, raising=True) + + def _schedule(target, *args, **kwargs): + # Always schedule the coroutine; ignore HA's optional args (name/eager_start) + return asyncio.create_task(target) + + # Patch both the server CP’s hass and the root hass to be safe + monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) + monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) + + # Prepare connector 1 metrics for the EAIR branch + _ = srv._metrics[(1, csess.session_energy.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.meter_start.value)] + + srv._charger_reports_session_energy = True + srv._metrics[(1, csess.session_energy.value)].value = None + srv._metrics[(1, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + srv._metrics[(1, DEFAULT_MEASURAND)].value = 12.345 # already kWh + + # Map tx → connector 1 so the handler resolves conn=1 + tx_id = 333 + srv._active_tx = {1: tx_id} + srv.active_transaction_id = tx_id + + # Call handler directly to exercise the branch + resp = srv.on_stop_transaction( + meter_stop=99999, # ignored in EAIR branch + timestamp="2024-01-01T00:00:01Z", + transaction_id=tx_id, + reason="Local", + ) + assert isinstance(resp, call_result.StopTransaction) + + # Expect the EAIR value to be copied to session energy (kWh) + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + def __init__(self, id, connection, response_timeout=30, no_connectors=1): + """Init extra variables for testing.""" + super().__init__(id, connection) + self.no_connectors = int(no_connectors) + self.active_transactionId: int = 0 + self.accept: bool = True + self.task = None # reused for background triggers + self._tasks: set[asyncio.Task] = set() + self.last_diag_location: str | None = None + self.last_data_transfer: tuple[str | None, str | None, str | None] | None = None + self.last_clear_profile_kwargs: dict | None = None + self.last_update_firmware: dict | None = None + + @on(Action.get_configuration) + def on_get_configuration(self, key, **kwargs): + """Handle a get configuration requests.""" + if key[0] == ConfigurationKey.supported_feature_profiles.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Core,FirmwareManagement,LocalAuthListManagement,Reservation,SmartCharging,RemoteTrigger,Dummy", + } + ] + ) + else: + # use to test TypeError handling + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ConfigurationKey.heartbeat_interval.value: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] + ) + if key[0] == ConfigurationKey.number_of_connectors.value: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": f"{self.no_connectors}"} + ] + ) + if key[0] == ConfigurationKey.web_socket_ping_interval.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "60"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ConfigurationKey.meter_values_sampled_data.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Energy.Active.Import.Register", + } + ] + ) + else: + pass + if key[0] == ConfigurationKey.meter_value_sample_interval.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "60"} + ] + ) + else: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] + ) + if ( + key[0] + == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value + ): + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "Current"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "false"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] + ) + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": ""}] + ) + + @on(Action.change_configuration) + def on_change_configuration(self, key, **kwargs): + """Handle a get configuration request.""" + if self.accept is True: + if key == ConfigurationKey.meter_values_sampled_data.value: + return call_result.ChangeConfiguration( + ConfigurationStatus.reboot_required + ) + else: + return call_result.ChangeConfiguration(ConfigurationStatus.accepted) + else: + return call_result.ChangeConfiguration(ConfigurationStatus.rejected) + + @on(Action.change_availability) + def on_change_availability(self, **kwargs): + """Handle change availability request.""" + if self.accept is True: + return call_result.ChangeAvailability(AvailabilityStatus.accepted) + else: + return call_result.ChangeAvailability(AvailabilityStatus.rejected) + + @on(Action.unlock_connector) + def on_unlock_connector(self, **kwargs): + """Handle unlock request.""" + if self.accept is True: + return call_result.UnlockConnector(UnlockStatus.unlocked) + else: + return call_result.UnlockConnector(UnlockStatus.unlock_failed) + + @on(Action.reset) + def on_reset(self, **kwargs): + """Handle reset request.""" + if self.accept is True: + return call_result.Reset(ResetStatus.accepted) + else: + return call_result.Reset(ResetStatus.rejected) + + @on(Action.remote_start_transaction) + def on_remote_start_transaction(self, **kwargs): + """Handle remote start request.""" + if self.accept is True: + self.task = asyncio.create_task(self.send_start_transaction()) + return call_result.RemoteStartTransaction(RemoteStartStopStatus.accepted) + else: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) + + @on(Action.remote_stop_transaction) + def on_remote_stop_transaction(self, **kwargs): + """Handle remote stop request.""" + if self.accept is True: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.accepted) + else: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) + + @on(Action.set_charging_profile) + def on_set_charging_profile(self, **kwargs): + """Handle set charging profile request.""" + if self.accept is True: + return call_result.SetChargingProfile(ChargingProfileStatus.accepted) + else: + return call_result.SetChargingProfile(ChargingProfileStatus.rejected) + + @on(Action.clear_charging_profile) + def on_clear_charging_profile(self, **kwargs): + """Handle clear charging profile request.""" + # keep what was requested so the test can assert + self.last_clear_profile_kwargs = dict(kwargs) if kwargs else {} + if self.accept is True: + return call_result.ClearChargingProfile(ClearChargingProfileStatus.accepted) + else: + return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) + + @on(Action.trigger_message) + def on_trigger_message(self, **kwargs): + """Handle trigger message request.""" + if self.accept is True: + return call_result.TriggerMessage(TriggerMessageStatus.accepted) + else: + return call_result.TriggerMessage(TriggerMessageStatus.rejected) + + @on(Action.update_firmware) + def on_update_firmware(self, **kwargs): + """Handle update firmware request.""" + self.last_update_firmware = dict(kwargs) + return call_result.UpdateFirmware() + + @on(Action.get_diagnostics) + def on_get_diagnostics(self, **kwargs): + """Handle get diagnostics request.""" + # OCPP 1.6 GetDiagnostics request uses 'location' + self.last_diag_location = kwargs.get("location") + return call_result.GetDiagnostics() + + @on(Action.data_transfer) + def on_data_transfer(self, **kwargs): + """Handle get data transfer request.""" + # OCPP 1.6 DataTransfer request uses 'vendor_id', 'message_id', 'data' + self.last_data_transfer = ( + kwargs.get("vendor_id"), + kwargs.get("message_id"), + kwargs.get("data"), + ) + if self.accept is True: + return call_result.DataTransfer(DataTransferStatus.accepted) + else: + return call_result.DataTransfer(DataTransferStatus.rejected) + + async def send_boot_notification(self): + """Send a boot notification.""" + request = call.BootNotification( + charge_point_model="Optimus", charge_point_vendor="The Mobility House" + ) + resp = await self.call(request) + assert resp.status == RegistrationStatus.accepted + + async def send_heartbeat(self): """Send a heartbeat.""" request = call.Heartbeat() resp = await self.call(request) @@ -921,14 +3001,18 @@ async def send_status_notification(self): assert resp is not None - async def send_meter_periodic_data(self): - """Send periodic meter data notification.""" + async def send_status_for_all_connectors(self): + """Send StatusNotification for all connectors.""" + await self.send_status_notification() + + async def send_meter_periodic_data(self, connector_id: int = 1): + """Send periodic meter data notification for a given connector.""" n = 0 while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) n += 1 request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1067,12 +3151,12 @@ async def send_meter_periodic_data(self): resp = await self.call(request) assert resp is not None - async def send_meter_line_voltage(self): - """Send line voltages.""" + async def send_meter_line_voltage(self, connector_id: int = 1): + """Send line voltages for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1109,12 +3193,12 @@ async def send_meter_line_voltage(self): resp = await self.call(request) assert resp is not None - async def send_meter_err_phases(self): - """Send erroneous voltage phase.""" + async def send_meter_err_phases(self, connector_id: int = 1): + """Send erroneous voltage phase for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1143,12 +3227,12 @@ async def send_meter_err_phases(self): resp = await self.call(request) assert resp is not None - async def send_meter_energy_kwh(self): - """Send periodic energy meter value with kWh unit.""" + async def send_meter_energy_kwh(self, connector_id: int = 1): + """Send periodic energy meter value with kWh unit for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1168,12 +3252,12 @@ async def send_meter_energy_kwh(self): resp = await self.call(request) assert resp is not None - async def send_main_meter_clock_data(self): - """Send periodic main meter value. Main meter values dont have transaction_id.""" + async def send_main_meter_clock_data(self, connector_id: int = 1): + """Send periodic main meter value (no transaction_id) for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, meter_value=[ { "timestamp": "2021-06-21T16:15:09Z", @@ -1192,11 +3276,11 @@ async def send_main_meter_clock_data(self): resp = await self.call(request) assert resp is not None - async def send_meter_clock_data(self): - """Send periodic meter data notification.""" + async def send_meter_clock_data(self, connector_id: int = 1): + """Send periodic meter data (clock) for a given connector.""" self.active_transactionId = 0 request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index b00dae95..5b37cb69 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -1058,12 +1058,12 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): # Junk report to be ignored await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) - assert cs.get_metric(cpid, cdet.serial.value) == "SERIAL" - assert cs.get_metric(cpid, cdet.model.value) == "MODEL" - assert cs.get_metric(cpid, cdet.vendor.value) == "VENDOR" - assert cs.get_metric(cpid, cdet.firmware_version.value) == "VERSION" + assert cs.get_metric(cpid, cdet.serial.value, connector_id=0) == "SERIAL" + assert cs.get_metric(cpid, cdet.model.value, connector_id=0) == "MODEL" + assert cs.get_metric(cpid, cdet.vendor.value, connector_id=0) == "VENDOR" + assert cs.get_metric(cpid, cdet.firmware_version.value, connector_id=0) == "VERSION" assert ( - cs.get_metric(cpid, cdet.features.value) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.SMART | Profiles.RES | Profiles.AUTH ) assert ( @@ -1164,10 +1164,7 @@ async def _extra_features_test( await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric( - cpid, - cdet.features.value, - ) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.SMART | Profiles.RES @@ -1219,10 +1216,7 @@ async def _unsupported_base_report_test( ) await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric( - cpid, - cdet.features.value, - ) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.REM | Profiles.FW ) @@ -1241,7 +1235,7 @@ async def test_cms_responses_v201(hass, socket_enabled): config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" - config_data[CONF_PORT] = 9010 + config_data[CONF_PORT] = 9080 config_entry = MockConfigEntry( domain=OCPP_DOMAIN, diff --git a/tests/test_charge_point_v201_multi.py b/tests/test_charge_point_v201_multi.py new file mode 100644 index 00000000..f5902950 --- /dev/null +++ b/tests/test_charge_point_v201_multi.py @@ -0,0 +1,372 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, UTC + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ocpp.const import CONF_CPIDS, CONF_CPID +from custom_components.ocpp.const import ( + DOMAIN as OCPP_DOMAIN, +) +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, +) +import ocpp +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.enums import ( + Action, + BootReasonEnumType, + ChangeAvailabilityStatusEnumType, + ChargingStateEnumType, + ConnectorStatusEnumType, + DataEnumType, + GenericDeviceModelStatusEnumType, + MutabilityEnumType, + OperationalStatusEnumType, + RegistrationStatusEnumType, + ReportBaseEnumType, + SetVariableStatusEnumType, + TransactionEventEnumType, + TriggerMessageStatusEnumType, + TriggerReasonEnumType, + UpdateFirmwareStatusEnumType, +) +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, + SetVariableResultType, +) + +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_CP_APPEND + +from .charge_point_test import ( + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) + + +class MultiConnectorChargePoint(cpclass): + """Minimal OCPP 2.0.1 client that reports 2 EVSE (1: two connectors, 2: one).""" + + def __init__(self, cp_id, ws): + """Initialize.""" + super().__init__(cp_id, ws) + self.inventory_done = asyncio.Event() + self.last_start_evse_id = None + self._tasks: set[asyncio.Task] = set() + + @on(Action.get_base_report) + async def on_get_base_report(self, request_id: int, report_base: str, **kwargs): + """Get base report.""" + assert report_base in (ReportBaseEnumType.full_inventory, "FullInventory") + task = asyncio.create_task(self._send_full_inventory(request_id)) + self._tasks.add(task) + return call_result.GetBaseReport( + GenericDeviceModelStatusEnumType.accepted.value + ) + + @on(Action.trigger_message) + async def on_trigger_message(self, requested_message: str, **kwargs): + """Handle trigger message.""" + self.last_trigger = (requested_message, kwargs.get("evse")) + return call_result.TriggerMessage(TriggerMessageStatusEnumType.accepted.value) + + @on(Action.update_firmware) + async def on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + """Handle update firmware.""" + return call_result.UpdateFirmware(UpdateFirmwareStatusEnumType.rejected.value) + + @on(Action.set_variables) + async def on_set_variables(self, set_variable_data: list[dict], **kwargs): + """Handle SetVariables.""" + results: list[SetVariableResultType] = [] + for item in set_variable_data: + comp = item.get("component", {}) + var = item.get("variable", {}) + results.append( + SetVariableResultType( + SetVariableStatusEnumType.accepted, + ComponentType( + comp.get("name"), + instance=comp.get("instance"), + evse=comp.get("evse"), + ), + VariableType( + var.get("name"), + instance=var.get("instance"), + ), + ) + ) + return call_result.SetVariables(results) + + async def _send_full_inventory(self, request_id: int): + ts = datetime.now(UTC).isoformat() + report_data = [ + # EVSE 1 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # EVSE 2 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(2)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # Connector(1,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(1,2) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=2)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(2,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(2, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SmartChargingCtrlr.Available + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SampledDataCtrlr.TxUpdatedMeasurands + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataEnumType.member_list, + False, + values_list="Energy.Active.Import.Register,Current.Import,Voltage", + ), + ), + ] + + req = call.NotifyReport( + request_id=request_id, + generated_at=ts, + seq_no=0, + report_data=report_data, + tbc=False, + ) + await self.call(req) + self.inventory_done.set() + + @on(Action.change_availability) + async def on_change_availability(self, operational_status: str, **kwargs): + """Handle change availability.""" + self.operative = operational_status == OperationalStatusEnumType.operative.value + return call_result.ChangeAvailability( + ChangeAvailabilityStatusEnumType.accepted.value + ) + + @on(Action.request_start_transaction) + async def on_request_start_transaction( + self, evse_id: int, id_token: dict, **kwargs + ): + """Handle request for start transaction.""" + self.last_start_evse_id = evse_id + return call_result.RequestStartTransaction(status="Accepted") + + @on(Action.request_stop_transaction) + async def on_request_stop_transaction(self, transaction_id: str, **kwargs): + """Handle request for stop transaction.""" + return call_result.RequestStopTransaction(status="Accepted") + + async def send_status( + self, evse_id: int, connector_id: int, status: ConnectorStatusEnumType + ): + """Send status.""" + await self.call( + call.StatusNotification( + timestamp=datetime.now(UTC).isoformat(), + connector_status=status.value, + evse_id=evse_id, + connector_id=connector_id, + ) + ) + + async def send_tx_started_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction started.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.started, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.authorized, + seq_no=0, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + async def send_tx_updated_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction updated.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.updated, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.meter_value_periodic, + seq_no=1, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + +@pytest.mark.timeout(150) +async def test_v201_multi_connectors_per_evse(hass, socket_enabled): + """Test multi connector per EVSE functionality.""" + cp_id = "CP_v201_multi" + + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_multi", + title="test_v201_multi", + version=2, + minor_version=0, + ) + + cs = await create_configuration(hass, config_entry) + ocpp.messages.ASYNC_VALIDATION = False + + async def _scenario(hass, cs, cp: MultiConnectorChargePoint): + boot = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + assert boot.status == RegistrationStatusEnumType.accepted.value + + await wait_ready(cs.charge_points["CP_v201_multi"]) + + await asyncio.wait_for(cp.inventory_done.wait(), timeout=5) + + cp_srv = cs.charge_points["CP_v201_multi"] + for _ in range(50): + if getattr(cp_srv, "num_connectors", 0) == 3: + break + await asyncio.sleep(0.1) + assert cp_srv.num_connectors == 3 + + cpid = cp_srv.settings.cpid + + await cp.send_status(1, 1, ConnectorStatusEnumType.available) + await cp.send_status(1, 2, ConnectorStatusEnumType.occupied) + await cp.send_status(2, 1, ConnectorStatusEnumType.unavailable) + await asyncio.sleep(0.05) + + assert cs.get_metric(cpid, "Status.Connector", connector_id=1) == "Available" + assert cs.get_metric(cpid, "Status.Connector", connector_id=2) == "Occupied" + assert cs.get_metric(cpid, "Status.Connector", connector_id=3) == "Unavailable" + + await cp.send_tx_started_eair_wh(1, 2, "TX-1", 10_000) + await cp.send_tx_updated_eair_wh(1, 2, "TX-1", 10_500) + await asyncio.sleep(0.05) + + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=2 + ) == pytest.approx(10.5) + assert cs.get_metric(cpid, "Energy.Session", connector_id=2) == pytest.approx( + 0.5 + ) + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=2) == "kWh" + ) + assert cs.get_unit(cpid, "Energy.Session", connector_id=2) == "kWh" + + ok = await cs.set_charger_state( + cpid, csvcs.service_charge_start.name, True, connector_id=3 + ) + assert ok is True + await asyncio.sleep(0.05) + assert cp.last_start_evse_id == 2 + + await run_charge_point_test( + config_entry, + "CP_v201_multi", + ["ocpp2.0.1"], + lambda ws: MultiConnectorChargePoint("CP_v201_multi_client", ws), + [lambda cp: _scenario(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2f69e6cf..c27539b5 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -7,7 +7,11 @@ from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.const import ( + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, +) from .const import ( MOCK_CONFIG_CS, @@ -116,6 +120,10 @@ async def test_successful_discovery_flow(hass, bypass_get_data): flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( False ) + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = ( + DEFAULT_NUM_CONNECTORS + ) + assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] assert entry.data == flow_output diff --git a/tests/test_connector_aware_metrics.py b/tests/test_connector_aware_metrics.py new file mode 100644 index 00000000..e39cebfd --- /dev/null +++ b/tests/test_connector_aware_metrics.py @@ -0,0 +1,258 @@ +"""Test connector-aware metrics handling.""" + +import pytest + +from custom_components.ocpp.chargepoint import _ConnectorAwareMetrics, Metric + + +def M(v=None, unit=None): + """Help to create a Metric with a value and a None timestamp.""" + return Metric(v, unit) + + +def test_flat_set_get_contains_len_iter(): + """Flat access (connector 0) should work and be independent from per-connector.""" + m = _ConnectorAwareMetrics() + + # Flat = connector 0 + m["Power.Active.Import"] = M(1.5) + assert "Power.Active.Import" in m + assert len(m) == 1 + assert list(iter(m)) == ["Power.Active.Import"] + assert m["Power.Active.Import"].value == 1.5 + + # Per-connector should not affect flat view length/keys + m[(2, "Power.Active.Import")] = M(7.0) + assert len(m) == 1 # len() reports flat/conn-0 size only + assert "Power.Active.Import" in m + assert m[(2, "Power.Active.Import")].value == 7.0 + assert m["Power.Active.Import"].value == 1.5 + + +def test_get_whole_connector_mapping_and_assign_full_mapping(): + """Accessing m[conn_id] returns the dict for that connector; setting dict replaces it.""" + m = _ConnectorAwareMetrics() + + # When first accessed, connector dict exists (created by defaultdict) + conn1_map = m[1] + assert isinstance(conn1_map, dict) + assert conn1_map == {} + + # Replace entire mapping for connector 1 + m[1] = {"Voltage": M(229.9), "Current.Import": M(6.0)} + assert m[(1, "Voltage")].value == 229.9 + assert m[(1, "Current.Import")].value == 6.0 + + # Flat (connector 0) remains independent — do NOT access m["Voltage"] (would create) + assert "Voltage" not in m + assert "Current.Import" not in m + assert len(m) == 0 + + +def test_delete_per_connector_and_flat(): + """Deleting per-connector keys must not touch flat; deleting flat must not touch others.""" + m = _ConnectorAwareMetrics() + + m["A"] = M(10) # flat + m[(2, "A")] = M(20) # connector 2 + + # Delete per-connector key (check via the connector dict, not tuple get) + del m[(2, "A")] + assert "A" not in m[2] # still present flat + assert m["A"].value == 10 + + # Delete flat key (verify flat keys(), avoid m["A"] which would recreate) + del m["A"] + assert "A" not in m + + # Recreate per-connector and verify it stays independent + m[(2, "A")] = M(99) + assert m[(2, "A")].value == 99 + assert "A" not in m # flat untouched + + +def test_keys_values_items_are_flat_only(): + """keys()/values()/items() only reflect connector 0 (flat) mapping.""" + m = _ConnectorAwareMetrics() + m["k0"] = M(0) + m[(1, "k1")] = M(1) + m[(2, "k2")] = M(2) + + assert list(m.keys()) == ["k0"] + assert [v.value for v in m.values()] == [0] + items = list(m.items()) + assert items == [("k0", m["k0"])] + + +def test_type_checks_on_setitem(): + """__setitem__ must enforce types for flat, per-connector tuple, and connector dict.""" + m = _ConnectorAwareMetrics() + + # Flat must be Metric + with pytest.raises(TypeError): + m["foo"] = 123 # not a Metric + + # Per-connector must be Metric + with pytest.raises(TypeError): + m[(1, "foo")] = 123 # not a Metric + + # Connector mapping must be dict[str, Metric] + with pytest.raises(TypeError): + m[1] = 123 + + # Correct types should pass + m["ok_flat"] = M(1) + m[(1, "ok_pc")] = M(2) + m[2] = {"ok_map": M(3)} + + assert m["ok_flat"].value == 1 + assert m[(1, "ok_pc")].value == 2 + assert m[(2, "ok_map")].value == 3 + + +def test_clear_and_contains_tuple_semantics(): + """clear() empties everything; __contains__ tuple logic works without creating entries.""" + m = _ConnectorAwareMetrics() + m["flat"] = M(1) + m[(1, "pc")] = M(2) + + # Membership checks + assert "flat" in m + assert "pc" in m[1] # check in the connector dict + + m.clear() + + # After clear, no flat keys and no connector dicts + assert len(list(m.keys())) == 0 + + +def test_get_variants_and_contains_behavior(): + """Exercise get() for flat keys, per-connector keys, connector dicts, and defaults.""" + m = _ConnectorAwareMetrics() + + # Seed some values + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") # connector 2 + + # 1) get() existing flat key + got = m.get("Voltage") + assert isinstance(got, Metric) + assert got.value == 230.0 + assert "Voltage" in m + + # 2) get() missing flat key -> default is returned + default_metric = M(0.0, "V") + got_default = m.get("Nope", default_metric) + assert isinstance(got_default, Metric) + assert got_default is default_metric + assert got_default.value == 0.0 + assert "V" not in m + + # 3) get() existing tuple key + got_c2 = m.get((2, "Voltage")) + assert isinstance(got_c2, Metric) + assert got_c2.value == 231.0 + assert 2 in m + + # 4) get() missing tuple key -> also inserts default, not the missing key + missing_default = M(7.0) + got_missing = m.get((2, "Missing"), missing_default) + assert isinstance(got_missing, Metric) + assert got_missing is missing_default + assert got_missing.value == 7.0 + assert 2 in m and "Missing" not in m[2] + + +def test_delitem_all_paths_and_errors(): + """Cover deletion of flat keys, per-connector keys, and entire connector maps.""" + m = _ConnectorAwareMetrics() + + # Seed: + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") + m[(2, "Current.Import")] = M(6.0, "A") + + # A) Delete a flat key + del m["Voltage"] + assert "Voltage" not in m + # Accessing again returns a fresh Metric(None, None) due to defaultdict + after_del_flat = m["Voltage"] + assert isinstance(after_del_flat, Metric) + assert after_del_flat.value is None + + # B) Delete a (conn, meas) key + del m[(2, "Current.Import")] + assert "Current.Import" not in m[2] + # Accessing again creates a fresh Metric(None, None) + after_del_tuple = m[(2, "Current.Import")] + assert isinstance(after_del_tuple, Metric) + assert after_del_tuple.value is None + # Remaining key for connector 2 is still there + assert "Voltage" in m[2] + + # C) Delete an entire connector map + del m[2] + assert 2 not in m + # Accessing m[2] recreates an empty mapping via top-level defaultdict + recreated = m[2] + assert isinstance(recreated, dict) + assert 2 in m and recreated == {} + + # D) Deleting a missing flat key raises KeyError and does not create it + with pytest.raises(KeyError): + del m["DoesNotExist"] + assert "DoesNotExist" not in m + + # E) Deleting a missing (conn, meas) raises KeyError, but creates the connector id + with pytest.raises(KeyError): + del m[(3, "NoSuchMeas")] + assert 3 in m and m[3] == {} + + # F) Deleting a non-existent connector id raises KeyError and does not create it + with pytest.raises(KeyError): + del m[42] + assert 42 not in m + + +def test_get_returns_default_when_inner_is_plain_dict(): + """Ensure get() returns provided default if inner mapping is a plain dict that raises KeyError.""" + m = _ConnectorAwareMetrics() + + # Replace connector 1 map with a plain dict (no defaultdict semantics) + m[1] = {"Voltage": M(230.0, "V")} + + # Missing measurand under connector 1 -> __getitem__ would raise KeyError, + # so get() must return the supplied default (and not insert anything). + default_metric = M(99.0, "V") + got = m.get((1, "Missing"), default_metric) + assert got is default_metric + + # Confirm we didn't insert the missing key as a side effect + assert "Missing" not in m[1] + # And tuple __contains__ is still False + assert (1, "Missing") not in m + + +def test_contains_tuple_semantics_true_false_and_missing_connector(): + """Exercise __contains__ for (connector, measurand) tuples.""" + m = _ConnectorAwareMetrics() + + # Seed values on different connectors + m[(2, "Voltage")] = M(231.0, "V") + m[(3, "Current.Import")] = M(6.0, "A") + + # Present tuple -> True + assert (2, "Voltage") in m + assert (3, "Current.Import") in m + + # Wrong measurand on existing connector -> False + assert (2, "Current.Import") not in m + assert (3, "Voltage") not in m + + # Missing connector entirely -> False (uses .get(conn, {}) in __contains__) + assert 99 not in m # connector 99 doesn't exist yet + assert (99, "Voltage") not in m # tuple contains should be False as well + + # After creating empty map for 99 (via direct access), measurand still absent -> False + _ = m[99] # creates empty mapping for connector 99 + assert (99, "Voltage") not in m diff --git a/tests/test_init.py b/tests/test_init.py index 965b75f7..b5b63b7d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,7 +10,12 @@ from custom_components.ocpp import CentralSystem from custom_components.ocpp.const import DOMAIN -from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_1, MOCK_CONFIG_MIGRATION_FLOW +from .const import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_DATA_1, + MOCK_CONFIG_MIGRATION_FLOW, + MOCK_CONFIG_DATA_1_MC, +) # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -54,6 +59,42 @@ async def test_setup_unload_and_reload_entry( assert config_entry.entry_id not in hass.data[DOMAIN] +async def test_setup_unload_and_reload_entry_multiple_connectors( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_DATA_1_MC, + entry_id="test_cms1_mc", + title="test_cms1_mc", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data + # call, no code from custom_components/ocpp/api.py actually runs. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Reload the entry and assert that the data from above is still there + assert await hass.config_entries.async_reload(config_entry.entry_id) + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Unload the entry and verify that the data has been removed + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[DOMAIN] + + async def test_migration_entry( hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None ): @@ -81,7 +122,7 @@ async def test_migration_entry( assert config_entry.data.keys() == MOCK_CONFIG_DATA.keys() # check versions match assert config_entry.version == 2 - assert config_entry.minor_version == 0 + assert config_entry.minor_version == 1 # Unload the entry and verify that the data has been removed assert await hass.config_entries.async_remove(config_entry.entry_id) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 67d4ee56..928a0abd 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -4,14 +4,15 @@ import websockets from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN - from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.components.sensor.const import ( SensorDeviceClass, SensorStateClass, ATTR_STATE_CLASS, ) + +from custom_components.ocpp.const import CONF_NUM_CONNECTORS, DOMAIN as OCPP_DOMAIN + from .const import ( MOCK_CONFIG_DATA, CONF_CPIDS, @@ -48,7 +49,7 @@ async def test_sensor(hass, socket_enabled): async with websockets.connect( f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", subprotocols=["ocpp1.6"], - ): + ) as ws: # Wait for setup to complete await asyncio.sleep(1) # Test reactive power sensor @@ -62,4 +63,57 @@ async def test_sensor(hass, socket_enabled): assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) is None + await ws.close() + + await remove_configuration(hass, config_entry) + + +async def test_sensor_entities_per_connector_created(hass, socket_enabled): + """Create separate entities per connector when num_connectors=2.""" + + cp_id = "CP_1_sens_mc" + cpid = "test_cpid_sens_mc" + + data = MOCK_CONFIG_DATA.copy() + cp_data = MOCK_CONFIG_CP_APPEND.copy() + cp_data[CONF_CPID] = cpid + cp_data[CONF_NUM_CONNECTORS] = 2 # ensure two connectors up front + data[CONF_CPIDS].append({cp_id: cp_data}) + data[CONF_PORT] = 9050 + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=data, + entry_id="test_cms_sens_mc", + title="test_cms_sens_mc", + version=2, + minor_version=0, + ) + + await create_configuration(hass, config_entry) + + # Open a ws once to trigger platform setup; entities are created during setup_entry + async with websockets.connect( + f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Give HA a tick to register entities + await asyncio.sleep(0.5) + + # Per-connector entities should include in the entity_id + s1 = hass.states.get(f"sensor.{cpid}_connector_1_status_connector") + s2 = hass.states.get(f"sensor.{cpid}_connector_2_status_connector") + assert s1 is not None, "missing sensor for connector 1" + assert s2 is not None, "missing sensor for connector 2" + + # There must not be any entity for a non-existent connector 3 + s3 = hass.states.get(f"sensor.{cpid}_connector_3_status_connector") + assert s3 is None, "unexpected sensor for connector 3" + + # Root-level sensor still includes + root = hass.states.get(f"sensor.{cpid}_connectors") + assert root is not None, "missing root-level 'connectors' sensor" + + await ws.close() + await remove_configuration(hass, config_entry) From 868b37493b3727ea9d1a21411baca1ee9dc8776e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:04:58 +1200 Subject: [PATCH 304/370] build(deps): bump jsonschema from 4.24.0 to 4.25.1 (#1690) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.24.0 to 4.25.1. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.24.0...v4.25.1) --- updated-dependencies: - dependency-name: jsonschema dependency-version: 4.25.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lbbrhzn <8673442+lbbrhzn@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e381cd56..69174934 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ uv>=0.4 ruff==0.12.9 ocpp==2.1.0 websockets==15.0.1 -jsonschema==4.24.0 +jsonschema==4.25.1 pre-commit==4.3.0 pytest-homeassistant-custom-component==0.13.256 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From cdab0727b361ac91c8536f876e65b3a88946507d Mon Sep 17 00:00:00 2001 From: Mark Gibson Date: Fri, 5 Sep 2025 10:05:50 +0100 Subject: [PATCH 305/370] Added Rolec EVO to docs (#1695) * Added Rolec EVO to docs * Update docs/user-guide.md Fix unit of power Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- docs/supported-devices.md | 22 ++++++++++++++++------ docs/user-guide.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/supported-devices.md b/docs/supported-devices.md index ea3f5aee..6d9d890c 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -56,13 +56,13 @@ This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC ( ## [EN+ Caro Series Home Wallbox](https://www.en-plustech.com/product/caro-series-wallbox/) This charger is often white-labelled by other vendors, including [cord](https://www.cord-ev.com/cord-one.html) and [EV Switch](https://www.evswitchstore.com.au/pages/ev-charger-range). -Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant if the OCPP integration does not discover your charger, and also to request a firmware update for versions earlier than 1.0.25.130. +Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant if the OCPP integration does not discover your charger, and also to request a firmware update for versions earlier than 1.0.25.130. For firmware versions earlier than 1.0.25.130 the only way you can update firmware is by connecting to the evchargo OCPP server at `wss://ocpp16.evchargo.com:33033/` and emailing your serial number to `support@en-plus.com.cn` requesting that your firmware is updated. You will probably want to update your firmware if it is earlier than 1.0.25.130 before configuring your charger to connect to your own OCPP server. -Firmware 1.0.25.130 has a firmware update option on the configuration interface (on IP address 192.168.4.1) which you can access by power-cycling the charger and connecting to its access point (see below). +Firmware 1.0.25.130 has a firmware update option on the configuration interface (on IP address 192.168.4.1) which you can access by power-cycling the charger and connecting to its access point (see below). If you have already installed the OCPP integration and have the default `charger` charge point installed, then you will need to re-configure this with the correct charge point identity (by removing and re-adding the OCPP integration) to change from the default `charger` charge point identity before configuring the charger. @@ -80,7 +80,7 @@ Save, and the charger will reboot. Reconnect to the charger's SSID, and log in again to 192.168.4.1 to confirm that the Network Status is online. This confirms that the charger has an internet connection via Ethernet or WiFi and is connected to your OCPP server in Home Assistant. Once enabled, the charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. -Even though the device accepts all measurands, the key working ones are +Even though the device accepts all measurands, the key working ones are - `Current.Import` - `Current.Offered` - `Energy.Active.Import.Register` @@ -112,6 +112,16 @@ Successful connection requires firmware version **A0-MEV-V2.0.9** or newer. The "Charger idle sampling interval" is not supported. Set this to **0** to avoid a "ClockAlignedDataInterval is read-only" warning. +## [Rolec EVO](https://www.rolecserv.com/ev-products/evo) +Tested single phase 7kW model (ROLEC5011) with firmware 1.2.7, appears to be working fine. + +Need to configure the OCPP server using the **Rolec Connect** mobile app, and +set the current to the maximum (32A), otherwise the exposed `Maximum Current` +entity in HA will be capped. + +You can still connect with the EVO app via Bluetooth after setting the OCPP server, +but certain features (eg. scheduling) may not work. + ## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) All basic functions work properly @@ -131,12 +141,12 @@ A few plugin tweaks to get full functionality... - Optionally create an automation updating the hearbeat interval (you have to set a value different to the one in the chargepoint) when the chargepoint reboots. - I haven't tested using secure mode. - If you have problems with charging profiles, check your firmware version is 1.6.3 (the latest in Mar 2025) - - Firmware updates can be done through the app, by reconnecting the charger to the original OCPP backend (wss://cpc.uk.charge.ampeco.tech:443/syncev/) and if it says you're on the latest, call them (+44 1952 983 940) to get it updated. + - Firmware updates can be done through the app, by reconnecting the charger to the original OCPP backend (wss://cpc.uk.charge.ampeco.tech:443/syncev/) and if it says you're on the latest, call them (+44 1952 983 940) to get it updated. ## [Teison Smart MINI Wallbox](https://www.teison.com/ac_smart_mini_ev_wallbox.html) Use *My Teison* app to enable webSocket. In the socket URL field enter the address of your Home Assistant server including the port. In the socket port field enter *ocpp1.6* for insecure connection or *socpp1.6* for secure connection with certificates. Once enabled, charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. -Even though the device accepts all measurands, the working ones are +Even though the device accepts all measurands, the working ones are - `Current.Import` - `Energy.Active.Import.Register` - `Power.Active.Import` @@ -144,7 +154,7 @@ Even though the device accepts all measurands, the working ones are - `Voltage` If the devices loses connection to Home Assistant (due to Wi-Fi disconnection or update, for example) it doesn't seem to reconnect automatically. It is necessary to reboot the charger via Bluetooth for it to reconnect. - + ## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) Grizzl-E chargers with firmware 3.x.x work mostly without issue, such as the following: diff --git a/docs/user-guide.md b/docs/user-guide.md index d7ef4ba3..11138616 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -124,7 +124,35 @@ The Grizzl-E updates these metrics every 30s during charging sessions: * `Maximum Current` (sets maximum charging current available) * `Reset` -### OCPP Compatibility Issues +## Useful Entities for Rolec EVO + +### Metrics + +* `Current Import` +* `Current Offered` (may be limited by the settings on the charger itself, check the EVO app) +* `Energy Session` (charge for present/last session - kWh) +* `Power Active Import` (active charging power - kW) +* `Temperature` (internal temperature - degrees C) +* `Time Session` (duration of active/last charging session) +* `Voltage` (seems to report a little higher than expected) + +There are several other metrics too, I'm not sure what they mean, and also `Export` variants of some of the `Import` entities, but they seem to always be zero for me. + +### Diagnostics + +* `Status Connector` (Available, Preparing, Charging, etc) + +There are many other diagnostic entities about the features, ids, model, firmware etc, not sure if they'd be much practical use. + +### Controls + +* `Availability` (turning off switches the halo from flashing blue to constant red) +* `Charge Control` +* `Maximum Current` (if `Current Offered` doesn't reach this when charging, raise the current to the max in the EVO app itself, connect via Bluetooth) +* `Reset` (reboot the charger) +* `Unlock` (I think this will unlock the charging cable, if permanent lock is enabled from the app) + +## OCPP Compatibility Issues ### ABB Terra AC From ed683eb2788cbb931d698c64a01d9e8dc77c7903 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:06:20 +1200 Subject: [PATCH 306/370] build(deps): bump ruff from 0.12.9 to 0.12.11 (#1696) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.12.9 to 0.12.11. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.12.9...0.12.11) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.12.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 69174934..cdd47b16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.12.9 +ruff==0.12.11 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From be608745d03708a8053b58247125cc0316161691 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:06:44 +1200 Subject: [PATCH 307/370] build(deps): bump actions/stale from 9 to 10 (#1699) Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v9...v10) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '10' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c3424111..4fffeb56 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'Stale issue message' From 00e5fd138da2f38f4b678b80b34bc21711514ce0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:07:26 +1200 Subject: [PATCH 308/370] build(deps): bump actions/setup-python from 5.6.0 to 6.0.0 (#1700) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.6.0...v6.0.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 01cda609..126fd261 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v5 - name: 🛠️ Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f9f0b8a5..d1356111 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v5" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v5.6.0" + uses: "actions/setup-python@v6.0.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 9bf50dd8174b26bacd91d630cc1c0b055ec6f301 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 8 Sep 2025 13:07:08 +0200 Subject: [PATCH 309/370] fixes for initial release of multiple connector support Co-authored-by: Jan Thunqvist --- .vscode/settings.json | 9 +- custom_components/ocpp/api.py | 21 +- custom_components/ocpp/chargepoint.py | 52 ++- custom_components/ocpp/number.py | 36 +- custom_components/ocpp/ocppv16.py | 278 +++++++---- tests/test_charge_point_v16.py | 644 +++++++++++++++++++++++++- 6 files changed, 908 insertions(+), 132 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fbfb5bf6..8cbb6e2d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,12 @@ ], "files.associations": { "*.yaml": "home-assistant" - } + }, + "python.testing.pytestArgs": [ + "tests", + "--cov=custom_components", + "--cov-report=term-missing", + "--cov-report=xml:coverage.xml", + "--timeout=30" + ], } \ No newline at end of file diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index e4df8061..d97e1f49 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -367,7 +367,10 @@ def get_unit(self, id: str, measurand: str, connector_id: int | None = None): def _try_unit(key): with contextlib.suppress(Exception): - return m[key].unit + val = m[key].unit + if isinstance(val, str) and val.strip() == "": + return None + return val return None if connector_id is not None: @@ -380,6 +383,8 @@ def _try_unit(key): with contextlib.suppress(Exception): val = m[measurand].unit + if isinstance(val, str) and val.strip() == "": + val = None if val is not None: return val @@ -404,7 +409,10 @@ def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): def _try_ha_unit(key): with contextlib.suppress(Exception): - return m[key].ha_unit + val = m[key].ha_unit + if isinstance(val, str) and val.strip() == "": + return None + return val return None if connector_id is not None: @@ -417,6 +425,8 @@ def _try_ha_unit(key): with contextlib.suppress(Exception): val = m[measurand].ha_unit + if isinstance(val, str) and val.strip() == "": + val = None if val is not None: return val @@ -441,7 +451,10 @@ def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = Non def _try_extra(key): with contextlib.suppress(Exception): - return m[key].extra_attr + val = m[key].extra_attr + if isinstance(val, dict) and not val: + return None + return val return None if connector_id is not None: @@ -454,6 +467,8 @@ def _try_extra(key): with contextlib.suppress(Exception): val = m[measurand].extra_attr + if isinstance(val, dict) and not val: + val = None if val is not None: return val diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index df6ad74c..9d427795 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -327,8 +327,7 @@ async def post_connect(self): try: self.status = STATE_OK await self.fetch_supported_features() - num_connectors: int = await self.get_number_of_connectors() - self.num_connectors = num_connectors + self.num_connectors = await self.get_number_of_connectors() for conn in range(1, self.num_connectors + 1): self._init_connector_slots(conn) self._metrics[(0, cdet.connectors.value)].value = self.num_connectors @@ -355,13 +354,20 @@ async def post_connect(self): # nice to have, but not needed for integration to function # and can cause issues with some chargers - await self.set_availability() + try: + await self.set_availability() + except asyncio.CancelledError: + raise + except Exception as ex: + _LOGGER.debug("post_connect: set_availability ignored error: %s", ex) + if prof.REM in self._attr_supported_features: if self.received_boot_notification is False: await self.trigger_boot_notification() await self.trigger_status_notification() - except NotImplementedError as e: - _LOGGER.error("Configuration of the charger failed: %s", e) + + except Exception as e: + _LOGGER.debug("post_connect aborted non-fatally: %s", e) async def trigger_boot_notification(self): """Trigger a boot notification.""" @@ -640,8 +646,17 @@ def get_authorization_status(self, id_tag): ) return auth_status - def process_phases(self, data: list[MeasurandValue], connector_id: int = 0): + def process_phases(self, data: list[MeasurandValue], connector_id: int | None = 0): """Process phase data from meter values.""" + # For single-connector chargers, use connector 1. + n_connectors = getattr(self, CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) or 1 + if connector_id in (None, 0): + target_cid = 1 if n_connectors == 1 else 0 + else: + try: + target_cid = int(connector_id) + except Exception: + target_cid = 1 if n_connectors == 1 else 0 def average_of_nonzero(values): nonzero_values: list = [v for v in values if v != 0.0] @@ -662,14 +677,12 @@ def average_of_nonzero(values): measurand_data[measurand] = {} measurand_data[measurand][om.unit.value] = unit measurand_data[measurand][phase] = value - self._metrics[(connector_id, measurand)].unit = unit - self._metrics[(connector_id, measurand)].extra_attr[om.unit.value] = ( - unit + self._metrics[(target_cid, measurand)].unit = unit + self._metrics[(target_cid, measurand)].extra_attr[om.unit.value] = unit + self._metrics[(target_cid, measurand)].extra_attr[phase] = value + self._metrics[(target_cid, measurand)].extra_attr[om.context.value] = ( + context ) - self._metrics[(connector_id, measurand)].extra_attr[phase] = value - self._metrics[(connector_id, measurand)].extra_attr[ - om.context.value - ] = context line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] @@ -707,15 +720,16 @@ def average_of_nonzero(values): if metric_value is not None: metric_unit = phase_info.get(om.unit.value) + m = self._metrics[(target_cid, metric)] if metric_unit == DEFAULT_POWER_UNIT: - self._metrics[(connector_id, metric)].value = metric_value / 1000 - self._metrics[(connector_id, metric)].unit = HA_POWER_UNIT + m.value = metric_value / 1000 + m.unit = HA_POWER_UNIT elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[(connector_id, metric)].value = metric_value / 1000 - self._metrics[(connector_id, metric)].unit = HA_ENERGY_UNIT + m.value = metric_value / 1000 + m.unit = HA_ENERGY_UNIT else: - self._metrics[(connector_id, metric)].value = metric_value - self._metrics[(connector_id, metric)].unit = metric_unit + m.value = metric_value + m.unit = metric_unit @staticmethod def get_energy_kwh(measurand_value: MeasurandValue) -> float: diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 86d2bd94..2f3287e3 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Final from homeassistant.components.number import ( @@ -31,6 +32,9 @@ ) from .enums import Profiles +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + @dataclass class OcppNumberDescription(NumberEntityDescription): @@ -213,13 +217,25 @@ def available(self) -> bool: ) async def async_set_native_value(self, value): - """Set new value for max current (station-wide when _op_connector_id==0, otherwise per-connector).""" - num_value = float(value) - resp = await self.central_system.set_max_charge_rate_amps( - self.cpid, - num_value, - connector_id=self._op_connector_id, - ) - if resp is True: - self._attr_native_value = num_value - self.async_write_ha_state() + """Set new value for max current (station-wide when _op_connector_id==0, otherwise per-connector). + + - Optimistic UI: move the slider immediately; attempt backend; never raise. + """ + self._attr_native_value = float(value) + self.async_write_ha_state() + + try: + ok = await self.central_system.set_max_charge_rate_amps( + self.cpid, self._attr_native_value, connector_id=self._op_connector_id + ) + if not ok: + _LOGGER.warning( + "Set current limit rejected by CP (kept optimistic UI at %.1f A).", + value, + ) + except Exception as ex: + _LOGGER.warning( + "Set current limit failed: %s (kept optimistic UI at %.1f A).", + ex, + value, + ) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index f1076723..402984ab 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -105,10 +105,6 @@ def __init__( ) self._active_tx: dict[int, int] = {} # connector_id -> transaction_id - def _profile_ids_for_connector(self, conn_id: int) -> tuple[int, int]: - """Return (profile_id, stack_level) that is stable and unique per connector.""" - return 1000 + max(1, int(conn_id)), 1 - async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" resp = None @@ -315,22 +311,49 @@ async def clear_profile( purpose: ChargingProfilePurposeType | None = None, ) -> bool: """Clear charging profiles (per connector and/or purpose).""" - target_connector = int(conn_id) if conn_id is not None else None - req = call.ClearChargingProfile( - connector_id=target_connector, - charging_profile_purpose=purpose.value if purpose is not None else None, - ) - resp = await self.call(req) - if resp.status in ( - ClearChargingProfileStatus.accepted, - ClearChargingProfileStatus.unknown, - ): - return True - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Clear profile failed with response {resp.status}" - ) - return False + try: + req = call.ClearChargingProfile( + connector_id=(int(conn_id) if conn_id is not None else None), + charging_profile_purpose=(purpose.value if purpose else None), + ) + resp = await self.call(req) + return resp.status in ( + ClearChargingProfileStatus.accepted, + ClearChargingProfileStatus.unknown, + ) + except Exception as ex: + _LOGGER.debug("ClearChargingProfile raised %s (ignored)", ex) + return False + + def _profile_ids_for( + self, conn_id: int, purpose: str, tx_id: int | None = None + ) -> tuple[int, int]: + """Return (chargingProfileId, stackLevel) unique per (purpose, connector). + + - Keeps IDs small and stable across restarts. + - For TxProfile you may include tx_id to avoid clashes if multiple are alive. + """ + PURPOSE_CODE = { + "ChargePointMaxProfile": 1, + "TxDefaultProfile": 2, + "TxProfile": 3, + } + if purpose == "ChargePointMaxProfile": + conn_seg = 0 + else: + try: + conn_seg = max(1, int(conn_id or 1)) + except Exception: + conn_seg = 1 + + base = 1000 + pid = base + PURPOSE_CODE[purpose] + conn_seg * 10 + + if purpose == "TxProfile" and tx_id is not None: + pid = pid * 1000 + (int(tx_id) % 1000) + + stack_level = 1 + return pid, stack_level async def set_charge_rate( self, @@ -341,18 +364,17 @@ async def set_charge_rate( ) -> bool: """Set charge rate.""" if profile is not None: - req = call.SetChargingProfile( - connector_id=int(conn_id), - cs_charging_profiles=profile, - ) - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False + try: + resp = await self.call( + call.SetChargingProfile( + connector_id=int(conn_id), cs_charging_profiles=profile + ) + ) + if resp.status == ChargingProfileStatus.accepted: + return True + _LOGGER.warning("Custom SetChargingProfile rejected: %s", resp.status) + except Exception as ex: + _LOGGER.warning("Custom SetChargingProfile failed: %s", ex) resp_units = await self.get_configuration( ckey.charging_schedule_allowed_charging_rate_unit.value @@ -369,84 +391,116 @@ async def set_charge_rate( else ChargingRateUnitType.watts.value ) - conn_id = int(conn_id or 0) - is_station_level = conn_id == 0 - - if is_station_level: - purpose = ChargingProfilePurposeType.charge_point_max_profile - resp_stack = await self.get_configuration( - ckey.charge_profile_max_stack_level.value + # Build attempt order (CPMax -> TxDefault -> TxProfile if active) + attempts: list[tuple[int, str]] = [] + attempts.append((0, "ChargePointMaxProfile")) + if conn_id and conn_id > 0: + attempts.append((conn_id, "TxDefaultProfile")) + + has_active = bool(getattr(self, "active_transaction_id", 0)) + if has_active: + tx_conn = next( + (c for c, tx in getattr(self, "_active_tx", {}).items() if tx), + conn_id or 1, ) - try: - stack_level = int(resp_stack) - except Exception: - stack_level = 1 - profile_id = 8 - else: - purpose = ChargingProfilePurposeType.tx_default_profile - profile_id, stack_level = self._profile_ids_for_connector(conn_id) - - is_default = (limit_amps >= 32) and (limit_watts >= 22000) - if is_default: - return await self.clear_profile( - conn_id=None if is_station_level else conn_id, - purpose=purpose, + attempts.append((tx_conn, "TxProfile")) + + await self.clear_profile( + None, ChargingProfilePurposeType.charge_point_max_profile + ) + if conn_id and conn_id > 0: + await self.clear_profile( + conn_id, ChargingProfilePurposeType.tx_default_profile ) - cs_profile = { - om.charging_profile_id.value: profile_id, - om.stack_level.value: stack_level, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: purpose.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: unit_val, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: limit_val} - ], - }, - } + def _mk_profile(purpose: str, cid: int) -> dict: + tx_id = ( + self.active_transaction_id + if (purpose == "TxProfile" and has_active) + else None + ) + pid, stack = self._profile_ids_for(cid, purpose, tx_id=tx_id) + return { + om.charging_profile_id.value: pid, + om.stack_level.value: stack, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: purpose, + om.charging_schedule.value: { + om.charging_rate_unit.value: unit_val, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: limit_val} + ], + }, + } - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles=cs_profile, - ) - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True + # Try each purpose/connector in order; optionally clear-by-id before setting + last_status = None + for cid, purpose in attempts: + try: + try: + tx_id = ( + self.active_transaction_id + if (purpose == "TxProfile" and has_active) + else None + ) + pid, _ = self._profile_ids_for(cid, purpose, tx_id=tx_id) + await self.call(call.ClearChargingProfile(id=pid)) + except Exception: + pass - if is_station_level and resp.status != ChargingProfileStatus.accepted: - _LOGGER.debug("Station profile rejected, trying lower stack level …") - cs_profile[om.stack_level.value] = max(1, stack_level - 1) - resp = await self.call( - call.SetChargingProfile( - connector_id=0, - cs_charging_profiles=cs_profile, + req = call.SetChargingProfile( + connector_id=cid, cs_charging_profiles=_mk_profile(purpose, cid) + ) + resp = await self.call(req) + last_status = resp.status + if resp.status == ChargingProfileStatus.accepted: + _LOGGER.debug( + "SetChargingProfile accepted with purpose=%s connectorId=%s", + purpose, + cid, + ) + return True + _LOGGER.debug( + "SetChargingProfile %s on connector %s -> %s", + purpose, + cid, + resp.status, + ) + except Exception as ex: + _LOGGER.debug( + "SetChargingProfile %s on connector %s raised %s", purpose, cid, ex ) - ) - if resp.status == ChargingProfileStatus.accepted: - return True - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) + _LOGGER.warning("SetChargingProfile failed (last status=%s).", last_status) + await self.notify_ha(f"SetChargingProfile failed (last status={last_status}).") return False async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" - if state is True: - typ = AvailabilityType.operative.value - else: - typ = AvailabilityType.inoperative.value + try: + conn = 0 if connector_id in (None, 0) else int(connector_id) + except Exception: + conn = 0 - req = call.ChangeAvailability(connector_id=int(connector_id or 0), type=typ) - resp = await self.call(req) - if resp.status in [ - AvailabilityStatus.accepted, - AvailabilityStatus.scheduled, - ]: - return True - else: + typ = AvailabilityType.operative if state else AvailabilityType.inoperative + req = call.ChangeAvailability(connector_id=conn, type=typ) + + try: + resp = await self.call(req) + except TimeoutError as ex: + _LOGGER.debug("ChangeAvailability timed out (conn=%s): %s", conn, ex) + return False + except Exception as ex: + _LOGGER.debug("ChangeAvailability failed (conn=%s): %s", conn, ex) + return False + + try: + status = getattr(resp, "status", None) + return status in ( + AvailabilityStatus.accepted, + AvailabilityStatus.scheduled, + ) + except Exception: _LOGGER.warning("Failed with response: %s", resp.status) await self.notify_ha( f"Warning: Set availability failed with response {resp.status}" @@ -764,6 +818,27 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ om.context.value ] = item.context + else: + # Update EAIR on the specific connector during active transactions as well + for bucket in meter_values: + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand == DEFAULT_MEASURAND: + eair_kwh = cp.get_energy_kwh(item) + self._metrics[ + (connector_id, DEFAULT_MEASURAND) + ].value = eair_kwh + self._metrics[ + (connector_id, DEFAULT_MEASURAND) + ].unit = HA_ENERGY_UNIT + if item.location is not None: + self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[ + om.location.value + ] = item.location + if item.context is not None: + self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[ + om.context.value + ] = item.context self.process_measurands(meter_values, transaction_matches, connector_id) @@ -850,6 +925,10 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): if meas in self._metrics[connector_id]: self._metrics[(connector_id, meas)].value = 0 + if status == ChargePointStatus.available: + self._metrics[(connector_id or 1, cstat.id_tag.value)].value = "" + self._metrics[(connector_id or 1, csess.transaction_id.value)].value = 0 + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @@ -954,6 +1033,9 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): om.reason.name, None ) + self._metrics[(conn, cstat.id_tag.value)].value = "" + self._metrics[(conn, csess.transaction_id.value)].value = 0 + use_eair_from_tx = bool(self._charger_reports_session_energy) if use_eair_from_tx: diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index c2d8c945..9ad92715 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -43,6 +43,7 @@ Action, AuthorizationStatus, AvailabilityStatus, + AvailabilityType, ChargePointErrorCode, ChargePointStatus, ChargingProfileStatus, @@ -52,6 +53,7 @@ DiagnosticsStatus, FirmwareStatus, Measurand, + Phase, RegistrationStatus, RemoteStartStopStatus, ResetStatus, @@ -2617,7 +2619,7 @@ def _schedule(target, *args, **kwargs): await ws.close() -@pytest.mark.timeout(10) +@pytest.mark.timeout(30) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9083, "cp_id": "CP_stop_eair_kwh", "cms": "cms_stop_eair_kwh"}], @@ -2700,6 +2702,646 @@ def _schedule(target, *args, **kwargs): await ws.close() +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9077, "cp_id": "CP_phases", "cms": "cms_phases"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_phases"]) +@pytest.mark.parametrize("port", [9077]) +@pytest.mark.parametrize("num_connectors", [1, 2]) +async def test_current_import_phase_extra_attrs_single_and_multi_connector( + hass, socket_enabled, cp_id, port, setup_config_entry, num_connectors +): + """Verify that phase extra attributes (L1/L2/L3) for Current.Import are populated. + + - with 1 connector: reading without connector_id should resolve via fallback. + - with 2 connectors: each connector returns its own phase set. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + try: + # Boot and wait until server is ready to receive MeterValues + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Server-side CP instance + srv_cp: ServerCP = cs.charge_points[cp_id] + # Force connector count for this test parameterization + srv_cp.num_connectors = num_connectors + + # Helper to send a MeterValues frame with phase currents + async def send_current_import_phases( + connector_id: int, l1: float, l2: float, l3: float + ): + ts = datetime.now(UTC).isoformat() + req = call.MeterValues( + connector_id=connector_id, + meter_value=[ + { + "timestamp": ts, + "sampledValue": [ + { + "measurand": "Current.Import", + "phase": Phase.l1.value, + "unit": "A", + "value": str(l1), + }, + { + "measurand": "Current.Import", + "phase": Phase.l2.value, + "unit": "A", + "value": str(l2), + }, + { + "measurand": "Current.Import", + "phase": Phase.l3.value, + "unit": "A", + "value": str(l3), + }, + ], + } + ], + ) + # Send to server + await cp.call(req) + + # Send phases for connector 1 + await send_current_import_phases(1, 5.0, 7.0, 8.0) + + # If two connectors, send different phases for connector 2 + if num_connectors == 2: + await send_current_import_phases(2, 11.0, 13.0, 17.0) + + # Let server handlers run + await asyncio.sleep(0) + + # Assertions + if num_connectors == 1: + # Without connector_id -> should resolve (fallback) to connector 1 + attrs = cs.get_extra_attr(cp_id, "Current.Import", connector_id=None) + assert ( + attrs is not None + ), "Expected extra_attr dict for single-connector" + assert attrs.get("L1") == 5.0 + assert attrs.get("L2") == 7.0 + assert attrs.get("L3") == 8.0 + + # Explicit connector_id=1 also works + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + assert attrs1 is not None + assert attrs1.get("L1") == 5.0 + assert attrs1.get("L2") == 7.0 + assert attrs1.get("L3") == 8.0 + + else: + # Two connectors: verify separation + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + attrs2 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=2) + + assert ( + attrs1 is not None and attrs2 is not None + ), "Expected extra_attr dicts for both connectors" + + # Connector 1 values + assert attrs1.get("L1") == 5.0 + assert attrs1.get("L2") == 7.0 + assert attrs1.get("L3") == 8.0 + + # Connector 2 values + assert attrs2.get("L1") == 11.0 + assert attrs2.get("L2") == 13.0 + assert attrs2.get("L3") == 17.0 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +class _ExplosiveStatus: + """A status object that raises on equality checks, but can be stringified.""" + + def __str__(self) -> str: + return "ExplosiveStatus" + + def __repr__(self) -> str: + return "ExplosiveStatus" + + # Cause 'status in (Accepted, Scheduled)' to raise inside try: + def __eq__(self, other): + raise RuntimeError("eq() boom on status comparison") + + +class _RespWithExplosiveStatus: + def __init__(self): + self.status = _ExplosiveStatus() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9078, "cp_id": "CP_avail", "cms": "cms_avail"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail"]) +@pytest.mark.parametrize("port", [9078]) +async def test_set_availability_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test set_availability timeout branch.""" + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_timeout(req): + raise TimeoutError("simulated timeout") + + monkeypatch.setattr(srv_cp, "call", fake_call_timeout, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is False # timeout-grenen ska returnera False + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9079, "cp_id": "CP_avail2", "cms": "cms_avail2"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail2"]) +@pytest.mark.parametrize("port", [9079]) +async def test_set_availability_exception_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test set_availability exception branch.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_error(req): + raise RuntimeError("generic error") + + monkeypatch.setattr(srv_cp, "call", fake_call_error, raising=True) + + ok = await srv_cp.set_availability(state=False, connector_id=2) + assert ok is False + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9090, "cp_id": "CP_avail3", "cms": "cms_avail3"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail3"]) +@pytest.mark.parametrize("port", [9090]) +async def test_set_availability_final_try_exception_path_with_notify( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Trigger the last try/except-branch. + + - resp.status exists but the comparison 'status in (...)' throws (via __eq__). + - Expect: warning + notify_ha and return False. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_ok(req): + # Returnera ett objekt där status-jämförelsen spränger inne i try-blocket + return _RespWithExplosiveStatus() + + captured = {"msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + captured["msg"] = msg + return True + + monkeypatch.setattr(srv_cp, "call", fake_call_ok, raising=True) + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is False + # Kontrollera att notify_ha kördes med rätt innehåll + assert ( + captured["msg"] + == "Warning: Set availability failed with response ExplosiveStatus" + ) + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9091, "cp_id": "CP_avail4", "cms": "cms_avail4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail4"]) +@pytest.mark.parametrize("port", [9091]) +@pytest.mark.parametrize( + "status,expected", + [(AvailabilityStatus.accepted, True), (AvailabilityStatus.scheduled, True)], +) +async def test_set_availability_happy_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch, status, expected +): + """Test set_availability happy paths.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_ok(req): + assert isinstance(req, call.ChangeAvailability) + assert req.type in ( + AvailabilityType.operative, + AvailabilityType.inoperative, + ) + return SimpleNamespace(status=status) + + monkeypatch.setattr(srv_cp, "call", fake_call_ok, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is expected + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9092, "cp_id": "CP_avail5", "cms": "cms_avail5"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail5"]) +@pytest.mark.parametrize("port", [9092]) +async def test_set_availability_connector_id_parse_error_falls_back_to_zero( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Send non-int connector_id; should fallback to conn=0 and still work.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + captured = {"seen_conn": None} + + async def fake_call_capture(req): + assert isinstance(req, call.ChangeAvailability) + captured["seen_conn"] = req.connector_id + return SimpleNamespace(status=AvailabilityStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", fake_call_capture, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id="not-an-int") + assert ok is True + assert captured["seen_conn"] == 0 # fallback to 0 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9093, "cp_id": "CP_setrate_1", "cms": "cms_setrate_1"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_setrate_1"]) +@pytest.mark.parametrize("port", [9093]) +async def test_set_charge_rate_custom_profile_exception_then_fallback_all_fail( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Custom profile path raises -> should not crash; code continues with fallback attempts. + + Make all attempts fail -> returns False and notify_ha is called. + """ + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_get_conf(key): + return "Current" # use amps + + monkeypatch.setattr( + srv_cp, "get_configuration", fake_get_conf, raising=True + ) + + wanted_profile = {"foo": "bar"} # will be passed in + + calls = {"set": 0, "clear": 0} + + async def fake_call(req): + # First branch: custom profile call should raise + if ( + isinstance(req, call.SetChargingProfile) + and req.cs_charging_profiles == wanted_profile + ): + raise RuntimeError("custom profile failed") + # Fallback phase: + if isinstance(req, call.ClearChargingProfile): + calls["clear"] += 1 + return SimpleNamespace(status="Accepted") + if isinstance(req, call.SetChargingProfile): + calls["set"] += 1 + return SimpleNamespace(status=ChargingProfileStatus.rejected) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + captured = {"msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + captured["msg"] = msg + return True + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + + ok = await srv_cp.set_charge_rate( + limit_amps=16, conn_id=1, profile=wanted_profile + ) + assert ok is False + assert calls["set"] >= 1 + assert "SetChargingProfile failed" in (captured["msg"] or "") + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9094, "cp_id": "CP_setrate_2", "cms": "cms_setrate_2"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_setrate_2"]) +@pytest.mark.parametrize("port", [9094]) +async def test_set_charge_rate_pre_clear_by_id_raises_then_all_rejected( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """ClearChargingProfile(id=pid) raises (ignored) + all SetChargingProfile return Rejected. + + With active transaction present, attempts should include TxProfile. + Expect False and notify_ha(last_status=Rejected). + """ + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Active tx on connector 1 -> should attempt TxProfile + srv_cp.active_transaction_id = 123 + srv_cp._active_tx = {1: 123} + + async def fake_get_conf(key): + return "Current" # use amps + + monkeypatch.setattr( + srv_cp, "get_configuration", fake_get_conf, raising=True + ) + + attempts_seen = [] + + async def fake_call(req): + if isinstance(req, call.ClearChargingProfile): + # simulate firmware throwing here -> must be swallowed + raise TypeError("clear-by-id boom") + if isinstance(req, call.SetChargingProfile): + attempts_seen.append(req.connector_id) + return SimpleNamespace(status=ChargingProfileStatus.rejected) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + captured = {"msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + captured["msg"] = msg + return True + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + + ok = await srv_cp.set_charge_rate(limit_amps=10, conn_id=1) + assert ok is False + # Should have tried CPMax (0), TxDefault (1), and TxProfile (1 or detected tx connector) + assert 0 in attempts_seen + assert 1 in attempts_seen + assert "last status=Rejected" in (captured["msg"] or "") + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9095, "cp_id": "CP_setrate_3", "cms": "cms_setrate_3"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_setrate_3"]) +@pytest.mark.parametrize("port", [9095]) +async def test_set_charge_rate_set_call_raises_for_all_attempts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """SetChargingProfile raises for all attempts -> function should catch and continue. + + After all attempts fail, returns False and notify_ha(last_status=None). + """ + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Force active tx so TxProfile is attempted too + srv_cp.active_transaction_id = 456 + srv_cp._active_tx = {1: 456} + + async def fake_get_conf(key): + return "Current" + + monkeypatch.setattr( + srv_cp, "get_configuration", fake_get_conf, raising=True + ) + + async def fake_call(req): + if isinstance(req, call.ClearChargingProfile): + return SimpleNamespace(status="Accepted") + if isinstance(req, call.SetChargingProfile): + raise TypeError("set-profile boom") + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + captured = {"msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + captured["msg"] = msg + return True + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + + ok = await srv_cp.set_charge_rate(limit_amps=6, conn_id=1) + assert ok is False + # last_status stays None because we never got a resp to read .status from + assert "last status=None" in (captured["msg"] or "") + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9096, "cp_id": "CP_setrate_4", "cms": "cms_setrate_4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_setrate_4"]) +@pytest.mark.parametrize("port", [9096]) +async def test_set_charge_rate_units_none_fallback_to_amps_and_accept_first( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """get_configuration returns None -> fallback to Amps; first attempt returns Accepted -> True.""" + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Force fallback path + async def fake_get_conf(key): + return None + + monkeypatch.setattr( + srv_cp, "get_configuration", fake_get_conf, raising=True + ) + + async def fake_call(req): + if isinstance(req, call.ClearChargingProfile): + return SimpleNamespace(status="Accepted") + if isinstance(req, call.SetChargingProfile): + # Accept immediately (CPMax on connector 0) + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.set_charge_rate(limit_amps=20, conn_id=0) + assert ok is True + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" From 3fe4b64dee5ace8302d98cefd63d7a717f74e882 Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Mon, 8 Sep 2025 23:52:42 +0300 Subject: [PATCH 310/370] Fix config flow to persist cpids data in HA config entries (#1712) --- custom_components/ocpp/config_flow.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 4556c305..05764118 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -167,15 +167,19 @@ async def async_step_cp_user( **user_input, CONF_NUM_CONNECTORS: self._detected_num_connectors, } - self._data[CONF_CPIDS].append({self._cp_id: cp_data}) + cpids_list = self._data.get(CONF_CPIDS, []).copy() + cpids_list.append({self._cp_id: cp_data}) + self._data = {**self._data, CONF_CPIDS: cpids_list} + if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( DEFAULT_MONITORED_VARIABLES ) - return self.async_update_reload_and_abort( - self._entry, - data_updates=self._data, + self.hass.config_entries.async_update_entry( + self._entry, data=self._data ) + return self.async_abort(reason="Added/Updated charge point") + else: return await self.async_step_measurands() @@ -201,10 +205,11 @@ async def async_step_measurands(self, user_input=None): self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( self._measurands ) - return self.async_update_reload_and_abort( - self._entry, - data_updates=self._data, + + self.hass.config_entries.async_update_entry( + self._entry, data=self._data ) + return self.async_abort(reason="Added/Updated charge point") return self.async_show_form( step_id="measurands", From 7a2d711c2d7aa0b10870c29400da8e506c14e577 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 10 Sep 2025 11:12:59 +0200 Subject: [PATCH 311/370] Average Current Values. More robust handling of meter values. (#1710) * Average Current values. Handle cases where meter values bucket contains several EAIR with different contexts (Fixes #1709). Set energy_session initally to 0 to avoid spike. More tests. * More EAIR handling, also covering ABB chargers with 0 on session start. More efficient measurand handling on init. Improved log message/handling in trigger_status_notification. Remove excessive logging and notification of SetChargingProfile fails (Fixes #1713). Handle Power.Factor. More tests. --------- Co-authored-by: Jan Thunqvist --- custom_components/ocpp/api.py | 13 +- custom_components/ocpp/chargepoint.py | 194 ++- custom_components/ocpp/ocppv16.py | 410 ++++-- tests/conftest.py | 34 +- tests/test_additional_charge_point_v16.py | 1196 +++++++++++++++++ tests/test_api_paths.py | 2 +- tests/test_charge_point_v16.py | 1491 ++++++++++++++++++++- tests/test_charge_point_v201.py | 4 +- tests/test_more_coverage_chargepoint.py | 416 ++++++ 9 files changed, 3548 insertions(+), 212 deletions(-) create mode 100644 tests/test_additional_charge_point_v16.py create mode 100644 tests/test_more_coverage_chargepoint.py diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index d97e1f49..dc472c13 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -290,10 +290,19 @@ async def on_connect(self, websocket: ServerConnection): await charge_point.reconnect(websocket) def _get_metrics(self, id: str): - """Return metrics.""" + """Return (cp_id, metrics mapping, cp instance, safe int num_connectors).""" cp_id = self.cpids.get(id, id) cp = self.charge_points.get(cp_id) - n_connectors = getattr(cp, "num_connectors", 1) or 1 + + def _safe_int(value, default=1): + try: + iv = int(value) + return iv if iv > 0 else default + except Exception: + return default + + n_connectors = _safe_int(getattr(cp, "num_connectors", 1), default=1) + return ( (cp_id, cp._metrics, cp, n_connectors) if cp is not None diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 9d427795..36d717fa 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -363,8 +363,18 @@ async def post_connect(self): if prof.REM in self._attr_supported_features: if self.received_boot_notification is False: - await self.trigger_boot_notification() - await self.trigger_status_notification() + try: + await asyncio.wait_for( + self.trigger_boot_notification(), timeout=3 + ) + except Exception as ex: + _LOGGER.debug("trigger_boot_notification ignored: %s", ex) + try: + await asyncio.wait_for( + self.trigger_status_notification(), timeout=3 + ) + except Exception as ex: + _LOGGER.debug("trigger_status_notification ignored: %s", ex) except Exception as e: _LOGGER.debug("post_connect aborted non-fatally: %s", e) @@ -464,41 +474,48 @@ async def monitor_connection(self): self._metrics[(0, cstat.latency_pong.value)].unit = "ms" connection = self._connection timeout_counter = 0 + # Add backstop to start post connect for non-compliant chargers # after 10s to allow for when a boot notification has not been received await asyncio.sleep(10) if not self.post_connect_success: self.hass.async_create_task(self.post_connect()) + while connection.state is State.OPEN: try: await asyncio.sleep(self.cs_settings.websocket_ping_interval) time0 = time.perf_counter() latency_ping = self.cs_settings.websocket_ping_timeout * 1000 + latency_pong = self.cs_settings.websocket_ping_timeout * 1000 pong_waiter = await asyncio.wait_for( connection.ping(), timeout=self.cs_settings.websocket_ping_timeout ) time1 = time.perf_counter() latency_ping = round(time1 - time0, 3) * 1000 - latency_pong = self.cs_settings.websocket_ping_timeout * 1000 + await asyncio.wait_for( pong_waiter, timeout=self.cs_settings.websocket_ping_timeout ) timeout_counter = 0 time2 = time.perf_counter() latency_pong = round(time2 - time1, 3) * 1000 + _LOGGER.debug( - f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': " + f"ping={latency_ping} ms, pong={latency_pong} ms", ) self._metrics[(0, cstat.latency_ping.value)].value = latency_ping self._metrics[(0, cstat.latency_pong.value)].value = latency_pong except TimeoutError as timeout_exception: + timeout_counter += 1 _LOGGER.debug( - f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': " + f"ping={latency_ping} ms, pong={latency_pong} ms", ) self._metrics[(0, cstat.latency_ping.value)].value = latency_ping self._metrics[(0, cstat.latency_pong.value)].value = latency_pong - timeout_counter += 1 + if timeout_counter > self.cs_settings.websocket_ping_tries: _LOGGER.debug( f"Connection to '{self.id}' timed out after '{self.cs_settings.websocket_ping_tries}' ping tries", @@ -506,6 +523,9 @@ async def monitor_connection(self): raise timeout_exception else: continue + except Exception as ex: + _LOGGER.debug(f"monitor_connection stopping due to exception: {ex}") + break async def _handle_call(self, msg): try: @@ -646,8 +666,15 @@ def get_authorization_status(self, id_tag): ) return auth_status - def process_phases(self, data: list[MeasurandValue], connector_id: int | None = 0): - """Process phase data from meter values.""" + def process_phases(self, data: list[MeasurandValue], connector_id: int = 0): + """Process per-phase MeterValues and aggregate them into per-connector metrics. + + Rules: + - Voltage: average (L1-N/L2-N/L3-N or L-L divided by √3); fall back to averaging L1/L2/L3 if needed. + - Current.*: average of L1/L2/L3 (ignore N). + - Power.Factor: **average** of L1/L2/L3 (ignore N). *Do not sum; unit is dimensionless and may be missing.* + - Other (e.g. Power.Active.*): sum of L1/L2/L3 (ignore N). + """ # For single-connector chargers, use connector 1. n_connectors = getattr(self, CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) or 1 if connector_id in (None, 0): @@ -658,13 +685,13 @@ def process_phases(self, data: list[MeasurandValue], connector_id: int | None = except Exception: target_cid = 1 if n_connectors == 1 else 0 - def average_of_nonzero(values): - nonzero_values: list = [v for v in values if v != 0.0] - nof_values: int = len(nonzero_values) - average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 - return average + def average_of_nonzero(values: list[float]) -> float: + """Average only non-zero values; return 0.0 if all are zero or list is empty.""" + nonzero = [v for v in values if v != 0.0] + return (sum(nonzero) / len(nonzero)) if nonzero else 0.0 + + measurand_data: dict[str, dict[str, float]] = {} - measurand_data = {} for item in data: # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} measurand = item.measurand @@ -672,64 +699,115 @@ def average_of_nonzero(values): value = item.value unit = item.unit context = item.context - if measurand is not None and phase is not None and unit is not None: - if measurand not in measurand_data: - measurand_data[measurand] = {} + + if measurand is None or phase is None: + continue + + if measurand not in measurand_data: + measurand_data[measurand] = {} + + if unit is not None: measurand_data[measurand][om.unit.value] = unit - measurand_data[measurand][phase] = value self._metrics[(target_cid, measurand)].unit = unit self._metrics[(target_cid, measurand)].extra_attr[om.unit.value] = unit - self._metrics[(target_cid, measurand)].extra_attr[phase] = value + + measurand_data[measurand][phase] = value + self._metrics[(target_cid, measurand)].extra_attr[phase] = value + if context is not None: self._metrics[(target_cid, measurand)].extra_attr[om.context.value] = ( context ) - line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] + line_phases_all = [ + Phase.l1.value, + Phase.l2.value, + Phase.l3.value, + Phase.n.value, + ] + phases_l123 = [Phase.l1.value, Phase.l2.value, Phase.l3.value] line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] + def _avg_l123(phase_info: dict) -> float: + return average_of_nonzero( + [phase_info.get(phase, 0.0) for phase in phases_l123] + ) + + def _sum_l123(phase_info: dict) -> float: + return sum(phase_info.get(phase, 0.0) for phase in phases_l123) + for metric, phase_info in measurand_data.items(): - metric_value = None + metric_value: float | None = None + mname = str(metric) + if metric in [Measurand.voltage.value]: if not phase_info.keys().isdisjoint(line_to_neutral_phases): # Line to neutral voltages are averaged metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_neutral_phases] + [phase_info.get(phase, 0.0) for phase in line_to_neutral_phases] ) elif not phase_info.keys().isdisjoint(line_to_line_phases): # Line to line voltages are averaged and converted to line to neutral metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_line_phases] + [phase_info.get(phase, 0.0) for phase in line_to_line_phases] ) / sqrt(3) - elif not phase_info.keys().isdisjoint(line_phases): + elif not phase_info.keys().isdisjoint(line_phases_all): # Workaround for chargers that don't follow engineering convention # Assumes voltages are line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_phases] - ) + metric_value = _avg_l123(phase_info) + else: - if not phase_info.keys().isdisjoint(line_phases): - metric_value = sum( - phase_info.get(phase, 0) for phase in line_phases - ) - elif not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Workaround for some chargers that erroneously use line to neutral for current - metric_value = sum( - phase_info.get(phase, 0) for phase in line_to_neutral_phases - ) + is_current = mname.lower().startswith("current") + if is_current: + # Current.* shown per phase -> avg of L1/L2/L3, ignore N + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _avg_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Workaround for some chargers that erroneously use line to neutral for current + metric_value = average_of_nonzero( + [ + phase_info.get(phase, 0.0) + for phase in line_to_neutral_phases + ] + ) + + # Special-case: Power.Factor must be averaged, never summed + elif metric == Measurand.power_factor.value: + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _avg_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + metric_value = average_of_nonzero( + [phase_info.get(p, 0.0) for p in line_to_neutral_phases] + ) + # If only a single phase value exists, just pass it through + else: + metric_value = next( + (v for k, v in phase_info.items() if k != om.unit.value), + None, + ) + + else: + # Other (e.g. Power.*): total is sum over phases + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _sum_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + metric_value = sum( + phase_info.get(phase, 0.0) + for phase in line_to_neutral_phases + ) if metric_value is not None: metric_unit = phase_info.get(om.unit.value) - m = self._metrics[(target_cid, metric)] + if metric_unit == DEFAULT_POWER_UNIT: - m.value = metric_value / 1000 - m.unit = HA_POWER_UNIT + self._metrics[(target_cid, metric)].value = metric_value / 1000 + self._metrics[(target_cid, metric)].unit = HA_POWER_UNIT elif metric_unit == DEFAULT_ENERGY_UNIT: - m.value = metric_value / 1000 - m.unit = HA_ENERGY_UNIT + self._metrics[(target_cid, metric)].value = metric_value / 1000 + self._metrics[(target_cid, metric)].unit = HA_ENERGY_UNIT else: - m.value = metric_value - m.unit = metric_unit + self._metrics[(target_cid, metric)].value = metric_value + self._metrics[(target_cid, metric)].unit = metric_unit @staticmethod def get_energy_kwh(measurand_value: MeasurandValue) -> float: @@ -803,6 +881,36 @@ def process_measurands( and is_transaction and self._ocpp_version != "1.6" ): + # Ensure session metric is present and well-formed + sess_key = (connector_id, "Energy.Session") + if sess_key not in self._metrics: + self._metrics[sess_key] = Metric(0.0, HA_ENERGY_UNIT) + else: + if self._metrics[sess_key].unit is None: + self._metrics[sess_key].unit = HA_ENERGY_UNIT + if self._metrics[sess_key].value is None: + self._metrics[sess_key].value = 0.0 + + # Bootstrap baseline for 2.x if missing: + ms_key = ( + connector_id, + csess.meter_start.value, + ) # "Energy.Meter.Start" + if ms_key not in self._metrics: + # Create the slot with kWh unit to match normalized EAIR above + self._metrics[ms_key] = Metric(None, HA_ENERGY_UNIT) + + ms_metric = self._metrics[ms_key] + if ms_metric.value is None: + # First EAIR in this transaction: set baseline to current EAIR (kWh) + ms_metric.value = value + # Keep session at 0.0 for the baseline sample + else: + # Compute positive delta only (guard against counter resets) + delta = value - ms_metric.value + if delta >= 0: + self._metrics[sess_key].value = round(delta, 6) + if ( self._charger_reports_session_energy and context != ReadingContext.transaction_begin.value diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 402984ab..0dce7193 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -152,53 +152,98 @@ async def get_heartbeat_interval(self): async def get_supported_measurands(self) -> str: """Get comma-separated list of measurands supported by the charger.""" - all_measurands = self.settings.monitored_variables - autodetect_measurands = self.settings.monitored_variables_autoconfig - + all_measurands = self.settings.monitored_variables or "" + autodetect_measurands = bool(self.settings.monitored_variables_autoconfig) key = ckey.meter_values_sampled_data.value + desired_csv = all_measurands.strip().strip(",") + cfg_ok = {ConfigurationStatus.accepted, ConfigurationStatus.reboot_required} + + effective_csv: str = "" + if autodetect_measurands: - accepted_measurands = [] - cfg_ok = [ - ConfigurationStatus.accepted, - ConfigurationStatus.reboot_required, - ] + # One-shot CSV attempt + if desired_csv: + _LOGGER.debug( + "'%s' attempting CSV set for measurands: %s", self.id, desired_csv + ) + try: + resp = await self.call( + call.ChangeConfiguration(key=key, value=desired_csv) + ) + if getattr(resp, "status", None) in cfg_ok: + _LOGGER.debug( + "'%s' measurands CSV accepted with status=%s", + self.id, + resp.status, + ) + effective_csv = desired_csv + else: + _LOGGER.debug( + "'%s' measurands CSV rejected with status=%s; falling back to GetConfiguration", + self.id, + getattr(resp, "status", None), + ) + except Exception as ex: + _LOGGER.debug( + "get_supported_measurands CSV set raised for '%s': %s", + self.id, + ex, + ) - for measurand in all_measurands.split(","): - _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") - req = call.ChangeConfiguration(key=key, value=measurand) - resp = await self.call(req) - if resp.status in cfg_ok: - _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") - accepted_measurands.append(measurand) + # Always read back what the charger actually has + chgr_csv = await self.get_configuration(key) - accepted_measurands = ",".join(accepted_measurands) - else: - accepted_measurands = all_measurands - - # Quirk: - # Workaround for a bug on chargers that have invalid MeterValuesSampledData - # configuration and reboot while the server requests MeterValuesSampledData. - # By setting the configuration directly without checking current configuration - # as done when calling self.configure, the server avoids charger reboot. - # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 - if len(accepted_measurands) > 0: - req = call.ChangeConfiguration(key=key, value=accepted_measurands) - resp = await self.call(req) + if not effective_csv: _LOGGER.debug( - f"'{self.id}' measurands set manually to {accepted_measurands}" + "'%s' measurands not configurable by integration", self.id ) + _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, chgr_csv) + return chgr_csv or "" - chgr_measurands = await self.get_configuration(key) + _LOGGER.debug( + "Returning accepted measurands for '%s': '%s'", self.id, effective_csv + ) + await self.configure(key, effective_csv) + return effective_csv + + # Non-autodetect path: + if desired_csv: + try: + resp = await self.call( + call.ChangeConfiguration(key=key, value=desired_csv) + ) + _LOGGER.debug( + "'%s' measurands set manually to %s", self.id, desired_csv + ) + if getattr(resp, "status", None) in cfg_ok: + effective_csv = desired_csv + else: + _LOGGER.debug( + "'%s' manual measurands set not accepted (status=%s); using charger's value", + self.id, + getattr(resp, "status", None), + ) + effective_csv = await self.get_configuration(key) + except Exception as ex: + _LOGGER.debug( + "Manual measurands set failed for '%s': %s; using charger's value", + self.id, + ex, + ) + effective_csv = await self.get_configuration(key) + else: + effective_csv = await self.get_configuration(key) - if len(accepted_measurands) > 0: - _LOGGER.debug(f"'{self.id}' allowed measurands: '{accepted_measurands}'") - await self.configure(key, accepted_measurands) + if effective_csv: + _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, effective_csv) + # Only configure if we successfully set our desired CSV + if desired_csv and effective_csv == desired_csv: + await self.configure(key, effective_csv) else: - _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") - _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") + _LOGGER.debug("'%s' measurands not configurable by integration", self.id) - return accepted_measurands + return effective_csv or "" async def set_standard_configuration(self): """Send configuration values to the charger.""" @@ -264,29 +309,36 @@ async def trigger_boot_notification(self): async def trigger_status_notification(self): """Trigger status notifications for all connectors.""" - return_value = True try: - nof_connectors = int(self._metrics[0][cdet.connectors.value].value or 1) + n = int(self._metrics[0][cdet.connectors.value].value or 1) except Exception: - nof_connectors = 1 - for cid in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={cid}") - req = call.TriggerMessage( - requested_message=MessageTrigger.status_notification, - connector_id=int(cid), - ) - resp = await self.call(req) - if resp.status != TriggerMessageStatus.accepted: - _LOGGER.warning("Failed with response: %s", resp.status) - _LOGGER.warning( - "Forcing number of connectors to %d, charger returned %d", - cid - 1, - nof_connectors, + n = 1 + + # Single connector: only probe 1. Multi: probe 0 then 1..n. + attempts = [1] if n <= 1 else [0] + list(range(1, n + 1)) + + for cid in attempts: + _LOGGER.debug("trigger status notification for connector=%s", cid) + try: + req = call.TriggerMessage( + requested_message=MessageTrigger.status_notification, + connector_id=int(cid), ) - self._metrics[0][cdet.connectors.value].value = max(1, cid - 1) - return_value = cid > 1 - break - return return_value + resp = await self.call(req) + status = getattr(resp, "status", None) + except Exception as ex: + _LOGGER.debug("TriggerMessage failed for connector=%s: %s", cid, ex) + status = None + + if status != TriggerMessageStatus.accepted: + if cid > 0: + _LOGGER.warning("Failed with response: %s", status) + # Reduce to the last known-good connector index. + self._metrics[0][cdet.connectors.value].value = max(1, cid - 1) + return False + # If connector 0 is rejected, continue probing numbered connectors. + + return True async def trigger_custom_message( self, @@ -471,8 +523,11 @@ def _mk_profile(purpose: str, cid: int) -> dict: "SetChargingProfile %s on connector %s raised %s", purpose, cid, ex ) - _LOGGER.warning("SetChargingProfile failed (last status=%s).", last_status) - await self.notify_ha(f"SetChargingProfile failed (last status={last_status}).") + if last_status is not None: + _LOGGER.warning("SetChargingProfile failed (last status=%s).", last_status) + await self.notify_ha( + f"SetChargingProfile failed (last status={last_status})." + ) return False async def set_availability(self, state: bool = True, connector_id: int | None = 0): @@ -732,30 +787,38 @@ async def async_update_device_info_v16(self, boot_info: dict): def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): """Handle MeterValues (per connector). - - EAIR (Energy.Active.Import.Register) **without** transactionId is treated as main meter, - written to connector 0 (aggregate). - - EAIR **with** transactionId is written to the proper connector (connector_id) and used - to update Energy.Session (kWh). - - Other measurands handled via process_measurands(). + - EAIR **without** transactionId always writes to connector 0, + even if it decreases relative to a previously mirrored value. + - Tx-bound EAIR (with transactionId) writes to the specific connector using a non-decreasing rule, + **except** when a new transaction begins on that connector — then lower values are allowed. + - For single-connector chargers, mirror tx-bound EAIR to connector 0 **only** until a true + main-meter (no txId) value is observed. After that, do not mirror. + - Session energy is computed **only** from tx-bound EAIR, never from main-meter readings. """ transaction_id: int | None = kwargs.get(om.transaction_id.name, None) tx_has_id: bool = transaction_id not in (None, 0) - active_tx_for_conn: int = int(self._active_tx.get(connector_id, 0) or 0) + active_tx_for_conn: int | None = ( + int(self._active_tx.get(connector_id, 0) or 0) or None + ) # If missing meter_start or active_transaction_id try to restore from HA states. If HA # does not have values either, generate new ones. if self._metrics[(connector_id, csess.meter_start.value)].value is None: restored = self.get_ha_metric(csess.meter_start.value, connector_id) - if restored is None: - restored = self._metrics[(connector_id, DEFAULT_MEASURAND)].value - else: + restored_f: float | None + if restored is not None: try: - restored = float(restored) + restored_f = float(restored) except (ValueError, TypeError): - restored = None - if restored is not None: - self._metrics[(connector_id, csess.meter_start.value)].value = restored + restored_f = None + else: + # Fallback: if no txId and connector has a per-connector EAIR stored, use that + restored_f = None + if restored_f is not None: + self._metrics[ + (connector_id, csess.meter_start.value) + ].value = restored_f if self._metrics[(connector_id, csess.transaction_id.value)].value is None: restored_tx = self.get_ha_metric(csess.transaction_id.value, connector_id) @@ -766,7 +829,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): except (ValueError, TypeError): candidate = None else: - candidate = transaction_id if tx_has_id else None + candidate = int(transaction_id) if tx_has_id else None if candidate is not None and candidate != 0: self._metrics[ @@ -775,6 +838,23 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._active_tx[connector_id] = candidate active_tx_for_conn = candidate + # --- Detect a new transaction on this connector (for example ABB resets to 0 at Transaction.Begin) --- + new_tx_started = False + if tx_has_id and ( + active_tx_for_conn is None or int(transaction_id) != int(active_tx_for_conn) + ): + # Register the new transaction and clear per-connector EAIR so that a lower starting + # value (e.g., 0.0) is accepted. + self._metrics[(connector_id, csess.transaction_id.value)].value = int( + transaction_id + ) + self._active_tx[connector_id] = int(transaction_id) + active_tx_for_conn = int(transaction_id) + new_tx_started = True + # Reset tx-bound EAIR and session baseline; main meter (connector 0) remains untouched. + self._metrics[(connector_id, DEFAULT_MEASURAND)].value = None + self._metrics[(connector_id, csess.meter_start.value)].value = None + if tx_has_id: transaction_matches = transaction_id == active_tx_for_conn else: @@ -800,50 +880,105 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): ) meter_values.append(measurands) - # Write main meter value (EAIR) to connector 0 if this message is missing transactionId - if not tx_has_id: - for bucket in meter_values: - for item in bucket: - measurand = item.measurand or DEFAULT_MEASURAND - if measurand == DEFAULT_MEASURAND: - eair_kwh = cp.get_energy_kwh(item) # Wh→kWh if necessary - # Aggregate (connector 0) carries the latest main meter value - self._metrics[(0, DEFAULT_MEASURAND)].value = eair_kwh - self._metrics[(0, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT - if item.location is not None: - self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ - om.location.value - ] = item.location - if item.context is not None: - self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ - om.context.value - ] = item.context - else: - # Update EAIR on the specific connector during active transactions as well - for bucket in meter_values: - for item in bucket: - measurand = item.measurand or DEFAULT_MEASURAND - if measurand == DEFAULT_MEASURAND: - eair_kwh = cp.get_energy_kwh(item) - self._metrics[ - (connector_id, DEFAULT_MEASURAND) - ].value = eair_kwh - self._metrics[ - (connector_id, DEFAULT_MEASURAND) - ].unit = HA_ENERGY_UNIT - if item.location is not None: - self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[ - om.location.value - ] = item.location - if item.context is not None: - self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[ - om.context.value - ] = item.context - - self.process_measurands(meter_values, transaction_matches, connector_id) - - # Update session time if ongoing transaction - if active_tx_for_conn not in (None, 0): + # --- Helper to rank contexts when multiple EAIR candidates exist in a bucket --- + def _ctx_priority(ctx: str | None) -> int: + if ctx == "Transaction.End": + return 3 + if ctx == "Sample.Periodic": + return 2 + if ctx == "Sample.Clock": + return 1 + return 0 + + # --- Step 1: Apply EAIR --- + # target_cid = 0 (main meter) if no transactionId, else the connector itself + target_cid = 0 if not tx_has_id else connector_id + + for bucket in meter_values: + best_pr, best_val_kwh, best_item = -1, None, None + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand != DEFAULT_MEASURAND: + continue + # Ignore Transaction.Begin EAIR (often 0 right at start); ABB will be handled by new_tx_started + if item.context == "Transaction.Begin": + continue + try: + val_kwh = float(cp.get_energy_kwh(item)) + except Exception: + continue + if val_kwh < 0.0 or (val_kwh != val_kwh): + continue + pr = _ctx_priority(item.context) + if (pr > best_pr) or ( + pr == best_pr and (best_val_kwh is None or val_kwh > best_val_kwh) + ): + best_pr, best_val_kwh, best_item = pr, val_kwh, item + + if best_item is None: + continue + + if not tx_has_id and target_cid == 0: + # Authoritative main meter: always write (can decrease vs a mirrored value) + m = self._metrics[(0, DEFAULT_MEASURAND)] + m.value = best_val_kwh + m.unit = HA_ENERGY_UNIT + m.extra_attr["source"] = "main" + if best_item.context is not None: + m.extra_attr[om.context.value] = best_item.context + if best_item.location is not None: + m.extra_attr[om.location.value] = best_item.location + else: + # Tx-bound EAIR: write non-decreasing, unless a new transaction just began + m = self._metrics[(target_cid, DEFAULT_MEASURAND)] + prev = m.value + allow = new_tx_started or (prev is None or best_val_kwh >= float(prev)) + if allow: + m.value = best_val_kwh + m.unit = HA_ENERGY_UNIT + if best_item.context is not None: + m.extra_attr[om.context.value] = best_item.context + if best_item.location is not None: + m.extra_attr[om.location.value] = best_item.location + + # Mirror to connector 0 only for single-connector chargers, and only + # until we've observed an authoritative main meter. + try: + n_connectors = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n_connectors = 1 + if n_connectors == 1: + mm = self._metrics[(0, DEFAULT_MEASURAND)] + main_seen = mm.extra_attr.get("source") == "main" + if not main_seen: + prev_main = mm.value + allow_main = new_tx_started or ( + prev_main is None or best_val_kwh >= float(prev_main) + ) + if allow_main: + mm.value = best_val_kwh + mm.unit = HA_ENERGY_UNIT + mm.extra_attr["source"] = "mirrored_tx" + if best_item.context is not None: + mm.extra_attr[om.context.value] = best_item.context + if best_item.location is not None: + mm.extra_attr[om.location.value] = best_item.location + + # --- Step 2: Process non-EAIR measurands via existing pipeline --- + mv_wo_eair: list[list[MeasurandValue]] = [] + for bucket in meter_values: + filtered = [ + it + for it in bucket + if (it.measurand or DEFAULT_MEASURAND) != DEFAULT_MEASURAND + ] + if filtered: + mv_wo_eair.append(filtered) + self.process_measurands(mv_wo_eair, transaction_matches, connector_id) + + # --- Step 3: Update session metrics (time, energy) only for tx-bound EAIR --- + if tx_has_id and transaction_matches: + # Session time (minutes) — keep parity with previous behavior tx_start = float( self._metrics[(connector_id, csess.transaction_id.value)].value or time.time() @@ -855,23 +990,48 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): (connector_id, csess.session_time.value) ].unit = UnitOfTime.MINUTES - # Update Energy.Session ONLY from EAIR in this message if txId exists and matches - if tx_has_id and transaction_matches: + # Session energy from tx-bound EAIR only eair_kwh_in_msg: float | None = None + best_ctx_prio = -1 for bucket in meter_values: for item in bucket: measurand = item.measurand or DEFAULT_MEASURAND - if measurand == DEFAULT_MEASURAND: - eair_kwh_in_msg = cp.get_energy_kwh(item) + if measurand != DEFAULT_MEASURAND: + continue + if item.context == "Transaction.Begin": + continue + try: + val_kwh = float(cp.get_energy_kwh(item)) + except Exception: + continue + if val_kwh < 0.0 or (val_kwh != val_kwh): + continue + pr = _ctx_priority(item.context) + if (pr > best_ctx_prio) or ( + pr == best_ctx_prio + and (eair_kwh_in_msg is None or val_kwh > eair_kwh_in_msg) + ): + best_ctx_prio = pr + eair_kwh_in_msg = val_kwh + if eair_kwh_in_msg is not None: + raw_start = self._metrics[(connector_id, csess.meter_start.value)].value try: - meter_start_kwh = float( - self._metrics[(connector_id, csess.meter_start.value)].value - or 0.0 + meter_start_kwh = ( + float(raw_start) if raw_start is not None else None ) except Exception: - meter_start_kwh = 0.0 - session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh) + meter_start_kwh = None + + if meter_start_kwh is None: + # Initialize at first tx-bound EAIR; ABB starts at 0 which is desired here + self._metrics[ + (connector_id, csess.meter_start.value) + ].value = eair_kwh_in_msg + session_kwh = 0.0 + else: + session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh) + self._metrics[ (connector_id, csess.session_energy.value) ].value = session_kwh diff --git a/tests/conftest.py b/tests/conftest.py index f6843c20..f0c15dfb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,20 @@ """Global fixtures for ocpp integration.""" import asyncio +from collections.abc import AsyncGenerator from unittest.mock import patch - +from pytest_homeassistant_custom_component.common import MockConfigEntry import pytest import websockets +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.const import CONF_CPIDS, CONF_PORT, DOMAIN as OCPP_DOMAIN +from tests.const import MOCK_CONFIG_CP_APPEND, MOCK_CONFIG_DATA +from .charge_point_test import ( + create_configuration, + remove_configuration, +) + pytest_plugins = "pytest_homeassistant_custom_component" @@ -56,3 +65,26 @@ def error_get_data_fixture(): # side_effect=Exception, # ): yield + + +@pytest.fixture +async def setup_config_entry(hass, request) -> AsyncGenerator[CentralSystem, None]: + """Setup/teardown mock config entry and central system.""" + # Create a mock entry so we don't have to go through config flow + # Both version and minor need to match config flow so as not to trigger migration flow + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append( + {request.param["cp_id"]: MOCK_CONFIG_CP_APPEND.copy()} + ) + config_data[CONF_PORT] = request.param["port"] + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id=request.param["cms"], + title=request.param["cms"], + version=2, + minor_version=0, + ) + yield await create_configuration(hass, config_entry) + # tear down + await remove_configuration(hass, config_entry) diff --git a/tests/test_additional_charge_point_v16.py b/tests/test_additional_charge_point_v16.py new file mode 100644 index 00000000..92bb70f2 --- /dev/null +++ b/tests/test_additional_charge_point_v16.py @@ -0,0 +1,1196 @@ +"""Test additional v16 paths.""" + +import asyncio +import contextlib +from datetime import datetime, UTC +from types import SimpleNamespace + +import pytest +import websockets + +from ocpp.v16 import call +from ocpp.v16.enums import ( + TriggerMessageStatus, + ChargePointStatus, + ConfigurationStatus, +) + +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + ConfigurationKey as ckey, +) + +from .test_charge_point_v16 import wait_ready, ChargePoint + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9116, "cp_id": "CP_trig_timeout_nonzero_adjusts", "cms": "cms_trig_tnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9116]) +async def test_trigger_status_timeout_on_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger status timeout on nonzero adjusts and stops.""" + cs = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + # Should stop after the failing connector + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9310, "cp_id": "CP_cov_conn_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_conn_exc"]) +@pytest.mark.parametrize("port", [9310]) +async def test_get_number_of_connectors_exception_defaults( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get number of connectors when exception defaults to 1.""" + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + # Simulate failure when requesting NumberOfConnectors + if isinstance(req, call.GetConfiguration): + raise TypeError("boom") + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + n = await srv.get_number_of_connectors() + assert n == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9311, "cp_id": "CP_cov_conn_bad", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_conn_bad"]) +@pytest.mark.parametrize("port", [9311]) +async def test_get_number_of_connectors_invalid_value_defaults( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get number of connectors with invalid value defaults to 1.""" + cs = setup_config_entry + + class FakeResp: + def __init__(self): + self.configuration_key = [{"key": "NumberOfConnectors", "value": "n/a"}] + self.unknown_key = None + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + if isinstance(req, call.GetConfiguration): + return FakeResp() + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + n = await srv.get_number_of_connectors() + assert n == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9312, "cp_id": "CP_cov_auto_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_auto_exc"]) +@pytest.mark.parametrize("port", [9312]) +async def test_autodetect_measurands_change_configuration_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test autodetect measurands when ChangeConfiguration raises and fallback occurs.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Enable autodetect and set a desired CSV to trigger the path + srv.settings.monitored_variables_autoconfig = True + srv.settings.monitored_variables = "Power.Active.Import,Voltage" + + async def fake_call(req): + # Fail on ChangeConfiguration, so code reads back via GetConfiguration + if isinstance(req, call.ChangeConfiguration): + raise TypeError("set failed") + if isinstance(req, call.GetConfiguration): + return SimpleNamespace( + configuration_key=[{"value": "Voltage"}], unknown_key=None + ) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + result = await srv.get_supported_measurands() + assert result == "Voltage" + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9313, "cp_id": "CP_cov_manual_ok", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_manual_ok"]) +@pytest.mark.parametrize("port", [9313]) +async def test_measurands_manual_set_accepted_configures( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test manual measurands set accepted and configure is called.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Disable autodetect; set a desired CSV + srv.settings.monitored_variables_autoconfig = False + srv.settings.monitored_variables = "Energy.Active.Import.Register,Voltage" + + called_configure = [] + + async def fake_call(req): + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.accepted) + return SimpleNamespace() + + async def fake_configure(key, value): + called_configure.append((key, value)) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + monkeypatch.setattr(srv, "configure", fake_configure, raising=True) + + result = await srv.get_supported_measurands() + assert result == srv.settings.monitored_variables + # configure() should have been called with the accepted CSV + assert ( + called_configure + and called_configure[0][0] == ckey.meter_values_sampled_data.value + ) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9314, "cp_id": "CP_cov_manual_rej", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_manual_rej"]) +@pytest.mark.parametrize("port", [9314]) +async def test_measurands_manual_set_rejected_returns_empty( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test manual measurands rejected and fallback returns empty string.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + srv.settings.monitored_variables_autoconfig = False + srv.settings.monitored_variables = "Energy.Active.Import.Register" + + async def fake_call(req): + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.rejected) + if isinstance(req, call.GetConfiguration): + # Simulate charger returning no value for the requested key + # by providing an empty configuration_key list (attribute present). + return SimpleNamespace(configuration_key=[], unknown_key=None) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + result = await srv.get_supported_measurands() + assert result == "" # effective_csv was empty + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9315, "cp_id": "CP_cov_trig_bn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_bn"]) +@pytest.mark.parametrize("port", [9315]) +async def test_trigger_boot_notification_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger boot notification accepted path.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.trigger_boot_notification() + assert ok is True and srv.triggered_boot_notification is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9316, "cp_id": "CP_cov_trig_stat_n_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_stat_n_exc"]) +@pytest.mark.parametrize("port", [9316]) +async def test_trigger_status_notification_connector_count_parse_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger status notification when connector count parse exception causes n=1.""" + cs = setup_config_entry + attempts = [] + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Force parse error so n=1 + srv._metrics[0][cdet.connectors.value].value = "bad" + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.trigger_status_notification() + assert ok is True + assert attempts == [1] # n<=1 -> probe only connector 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9317, "cp_id": "CP_cov_trig_custom_bad", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_custom_bad"]) +@pytest.mark.parametrize("port", [9317]) +async def test_trigger_custom_message_unsupported_name( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test trigger custom message rejects unsupported trigger names.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + ok = await srv.trigger_custom_message("not_a_trigger") + assert ok is False + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9318, "cp_id": "CP_cov_profile_ids", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_profile_ids"]) +@pytest.mark.parametrize("port", [9318]) +async def test_profile_ids_for_bad_conn_id_cast( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test profile ids path when conn_id cast fails and conn_seg defaults to 1.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + pid, level = srv._profile_ids_for(conn_id="X", purpose="TxDefaultProfile") + # conn_seg should fall back to 1 -> pid = 1000 + 2 + (1*10) = 1012 + assert (pid, level) == (1012, 1) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9319, "cp_id": "CP_cov_stop_tx_early", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_stop_tx_early"]) +@pytest.mark.parametrize("port", [9319]) +async def test_stop_transaction_early_return( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test stop transaction early return when there is no active transaction.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + srv.active_transaction_id = 0 + srv._active_tx.clear() + ok = await srv.stop_transaction() + assert ok is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9320, "cp_id": "CP_cov_update_fw", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_fw"]) +@pytest.mark.parametrize("port", [9320]) +async def test_update_firmware_wait_time_invalid_falls_back( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test update firmware path when wait_time is invalid and falls back to immediate time.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + return SimpleNamespace() # success path + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.update_firmware( + "https://example.com/fw.bin", wait_time="not-int" + ) + assert ok is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9321, "cp_id": "CP_cov_getcfg_empty", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_getcfg_empty"]) +@pytest.mark.parametrize("port", [9321]) +async def test_get_configuration_empty_key_path( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get_configuration empty key path uses call without key property.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + # When key is empty, code constructs call.GetConfiguration() (no key list) + assert isinstance(req, call.GetConfiguration) and not getattr( + req, "key", None + ) + return SimpleNamespace( + configuration_key=[{"value": "42"}], unknown_key=None + ) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + val = await srv.get_configuration("") + assert val == "42" + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9322, "cp_id": "CP_cov_config_ro", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_config_ro"]) +@pytest.mark.parametrize("port", [9322]) +async def test_configure_readonly_warns_and_notifies( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test configure warns and notifies when key is read-only.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + notified = [] + + async def fake_call(req): + if isinstance(req, call.GetConfiguration): + return SimpleNamespace( + configuration_key=[ + {"key": "Foo", "value": "Bar", "readonly": True} + ], + unknown_key=None, + ) + # ChangeConfiguration may still be issued; return accepted for completeness + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.accepted) + return SimpleNamespace() + + async def fake_notify(msg): + notified.append(msg) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + monkeypatch.setattr(srv, "notify_ha", fake_notify, raising=True) + await srv.configure("Foo", "Baz") + # A warning/notification should be pushed for read-only + assert any("read-only" in m for m in notified) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9323, "cp_id": "CP_cov_restore_ms", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_restore_ms"]) +@pytest.mark.parametrize("port", [9323]) +async def test_restore_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test restore meter start from HA when cast raises and remains None.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Force metric slot to look missing + srv._metrics[(1, "Energy.Meter.Start")].value = None + + def fake_get_ha_metric(name, connector_id=None): + if name == "Energy.Meter.Start" and connector_id == 1: + return "not-a-float" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # Send a MeterValues WITHOUT transactionId to trigger restore branch + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + # Open a WS client to deliver this message + resp = await cp.call(mv_no_tx) + assert resp is not None + assert srv._metrics[(1, "Energy.Meter.Start")].value is None + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9324, "cp_id": "CP_cov_restore_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_restore_tx"]) +@pytest.mark.parametrize("port", [9324]) +async def test_restore_transaction_id_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test restore transaction id when cast fails leaving candidate as None.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + srv._metrics[(1, "Transaction.Id")].value = None + + def fake_get_ha_metric(name, connector_id=None): + if name == "Transaction.Id" and connector_id == 1: + return "not-an-int" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # Trigger the handler with a MeterValues (no strict need to carry tx) + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + _ = await cp.call(mv_no_tx) + # Value remains unset because candidate was None + assert srv._metrics[(1, "Transaction.Id")].value is None + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9325, "cp_id": "CP_cov_new_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_new_tx"]) +@pytest.mark.parametrize("port", [9325]) +async def test_new_transaction_resets_tx_bound_metrics( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test new transaction detection resets tx-bound EAIR and meter_start.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Preload per-connector values which should be cleared when new tx starts + srv._metrics[(1, "Energy.Active.Import.Register")].value = 999.0 + srv._metrics[(1, "Energy.Meter.Start")].value = 888.0 + + # Send MeterValues with a new transactionId + mv_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "0", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + "location": "Inlet", + } + ], + } + ], + transaction_id=12345, + ) + resp = await cp.call(mv_tx) + assert resp is not None + # New tx should be registered + assert srv._active_tx.get(1) == 12345 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9326, "cp_id": "CP_cov_eair_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_eair_cast_exc"]) +@pytest.mark.parametrize("port", [9326]) +async def test_eair_get_energy_kwh_exception_ignored( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test EAIR scan ignores entries when energy_kwh conversion raises.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Monkeypatch energy conversion to raise + from custom_components.ocpp.ocppv16 import cp as cp_mod + + monkeypatch.setattr( + cp_mod, + "get_energy_kwh", + lambda item: (_ for _ in ()).throw(RuntimeError("bad")), + ) + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=1, + ) + # Should not raise + _ = await cp.call(mv) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9327, "cp_id": "CP_cov_num_conn_int_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_num_conn_int_exc"]) +@pytest.mark.parametrize("port", [9327]) +async def test_mirror_single_connector_handles_int_exception( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test single connector mirroring handles int casting exception by falling back.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + class Bad: + def __int__(self): + raise ValueError("no int") + + srv.num_connectors = Bad() + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=2, + ) + _ = await cp.call(mv) + # If we reach here without exceptions, fallback worked (n_connectors=1) + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9328, "cp_id": "CP_cov_sess_energy_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_sess_energy_cast_exc"]) +@pytest.mark.parametrize("port", [9328]) +async def test_session_energy_get_energy_kwh_exception_ignored( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test session energy calculation ignores EAIR entries raising conversion errors.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + from custom_components.ocpp.ocppv16 import cp as cp_mod + + monkeypatch.setattr( + cp_mod, + "get_energy_kwh", + lambda item: (_ for _ in ()).throw(RuntimeError("bad")), + ) + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=3, + ) + _ = await cp.call(mv) + # No crash == lines 1005-1006 exercised + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9329, "cp_id": "CP_cov_sess_ms_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_sess_ms_cast_exc"]) +@pytest.mark.parametrize("port", [9329]) +async def test_session_energy_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test session energy path when meter_start cannot be cast to float.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Poison meter_start with a non-float so that float() raises + srv._metrics[(1, "Energy.Meter.Start")].value = object() + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=4, + ) + _ = await cp.call(mv) + # No crash is sufficient to cover lines 1023-1024 + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9330, "cp_id": "CP_cov_status_suspended", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_status_suspended"]) +@pytest.mark.parametrize("port", [9330]) +async def test_status_notification_suspended_resets_metrics( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test status notification suspended state resets power/current metrics to zero.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Pre-populate metrics (non-zero) so they can be zeroed on suspended status + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + srv._metrics[(1, meas)].value = 123 + + # Simulate status notification + resp = srv.on_status_notification( + connector_id=1, + error_code="NoError", + status=ChargePointStatus.suspended_ev.value, + ) + assert resp is not None + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert int(srv._metrics[(1, meas)].value) == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9331, "cp_id": "CP_cov_start_tx_ms_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_start_tx_ms_cast_exc"]) +@pytest.mark.parametrize("port", [9331]) +async def test_start_transaction_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test start transaction handler path when meter_start cast fails defaults to 0.0.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Ensure authorization passes so the handler proceeds normally + monkeypatch.setattr( + srv, "get_authorization_status", lambda id_tag: "Accepted", raising=True + ) + + # Call the handler directly with a non-numeric meter_start + result = srv.on_start_transaction( + connector_id=1, id_tag="test_cp", meter_start="not-a-number" + ) + assert result is not None + # The cast fails internally and baseline defaults to 0.0 (lines 1147-1148) + assert float(srv._metrics[(1, "Energy.Meter.Start")].value) == 0.0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9332, "cp_id": "CP_cov_start_tx_denied", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_start_tx_denied"]) +@pytest.mark.parametrize("port", [9332]) +async def test_start_transaction_auth_denied_returns_tx0( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test start transaction returns transaction id 0 when authorization is denied.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Force non-accepted authorization + monkeypatch.setattr( + srv, + "get_authorization_status", + lambda id_tag: "Invalid", + raising=True, + ) + # Call handler directly to inspect response + result = srv.on_start_transaction( + connector_id=1, id_tag="bad", meter_start=0 + ) + assert result.transaction_id == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9333, "cp_id": "CP_cov_stop_tx_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_stop_tx_paths"]) +@pytest.mark.parametrize("port", [9333]) +async def test_stop_transaction_misc_paths( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test stop transaction paths covering unknown unit and meter_stop cast exception.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Simulate a running transaction + srv._active_tx[1] = 777 + srv.active_transaction_id = 777 + + # Prepare main-meter EAIR with unknown unit to drive line 1213 + srv._metrics[(1, "Energy.Active.Import.Register")].value = 123.456 + srv._metrics[(1, "Energy.Active.Import.Register")].unit = "kWs" # unknown + + # Call stop_transaction with a non-numeric meter_stop so 1225-1226 -> 0.0 + result = srv.on_stop_transaction( + meter_stop="not-a-number", timestamp=None, transaction_id=777 + ) + assert result is not None + # Session energy should be computed; we mainly care lines executed without crash + assert srv._active_tx[1] == 0 and srv.active_transaction_id == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 3e32d4da..327d3426 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -19,7 +19,7 @@ from custom_components.ocpp.chargepoint import Metric as M from custom_components.ocpp.chargepoint import SetVariableResult -from .test_charge_point_v16 import MOCK_CONFIG_DATA +from tests.const import MOCK_CONFIG_DATA class DummyCP: diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 9ad92715..667e8a00 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -10,7 +10,6 @@ from types import SimpleNamespace import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.exceptions import HomeAssistantError import websockets @@ -22,13 +21,13 @@ CONF_CPIDS, CONF_CPID, CONF_NUM_CONNECTORS, - CONF_PORT, DEFAULT_ENERGY_UNIT, DEFAULT_MEASURAND, HA_ENERGY_UNIT, ) from custom_components.ocpp.enums import ( ConfigurationKey, + HAChargerDetails as cdet, HAChargerServices as csvcs, HAChargerStatuses as cstat, HAChargerSession as csess, @@ -62,15 +61,12 @@ ) from .const import ( - MOCK_CONFIG_DATA, MOCK_CONFIG_CP_APPEND, ) from .charge_point_test import ( set_switch, press_button, set_number, - create_configuration, - remove_configuration, wait_ready, ) @@ -233,29 +229,6 @@ async def test_services(hass, cpid, serv_list, socket_enabled): test_services.__test__ = False -@pytest.fixture -async def setup_config_entry(hass, request) -> CentralSystem: - """Setup/teardown mock config entry and central system.""" - # Create a mock entry so we don't have to go through config flow - # Both version and minor need to match config flow so as not to trigger migration flow - config_data = MOCK_CONFIG_DATA.copy() - config_data[CONF_CPIDS].append( - {request.param["cp_id"]: MOCK_CONFIG_CP_APPEND.copy()} - ) - config_data[CONF_PORT] = request.param["port"] - config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, - data=config_data, - entry_id=request.param["cms"], - title=request.param["cms"], - version=2, - minor_version=0, - ) - yield await create_configuration(hass, config_entry) - # tear down - await remove_configuration(hass, config_entry) - - # @pytest.mark.skip(reason="skip") @pytest.mark.timeout(20) # Set timeout for this test @pytest.mark.parametrize( @@ -2926,7 +2899,7 @@ async def fake_call_error(req): await ws.close() -@pytest.mark.timeout(10) +@pytest.mark.timeout(30) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9090, "cp_id": "CP_avail3", "cms": "cms_avail3"}], @@ -3152,7 +3125,7 @@ async def fake_notify(msg: str, title: str = "Ocpp integration"): await ws.close() -@pytest.mark.timeout(10) +@pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9094, "cp_id": "CP_setrate_2", "cms": "cms_setrate_2"}], @@ -3236,10 +3209,7 @@ async def fake_notify(msg: str, title: str = "Ocpp integration"): async def test_set_charge_rate_set_call_raises_for_all_attempts( hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch ): - """SetChargingProfile raises for all attempts -> function should catch and continue. - - After all attempts fail, returns False and notify_ha(last_status=None). - """ + """SetChargingProfile raises for all attempts -> function swallows errors, returns False, and does not notify HA.""" cs: CentralSystem = setup_config_entry async with websockets.connect( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] @@ -3271,18 +3241,18 @@ async def fake_call(req): monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) - captured = {"msg": None} + notify_calls = {"n": 0} async def fake_notify(msg: str, title: str = "Ocpp integration"): - captured["msg"] = msg + notify_calls["n"] += 1 return True monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) ok = await srv_cp.set_charge_rate(limit_amps=6, conn_id=1) assert ok is False - # last_status stays None because we never got a resp to read .status from - assert "last status=None" in (captured["msg"] or "") + # No user-facing notification on periodic/internal failure + assert notify_calls["n"] == 0 finally: cp_task.cancel() @@ -3342,6 +3312,1451 @@ async def fake_call(req): await ws.close() +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9097, "cp_id": "CP_eair_no_tx", "cms": "cms_eair_no_tx"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_no_tx"]) +@pytest.mark.parametrize("port", [9097]) +async def test_on_meter_values_no_tx_aggregate_ignores_begin_and_converts_wh( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that Transaction.Begin is ignored when Periodic also in the same message.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Both Periodic (4369 Wh) and Begin (0) in the same bucket + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "4369", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.Begin", + "unit": "Wh", + "value": "0", + }, + ], + } + ], + ) + await cp.call(req) + + val = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=0) + assert val == pytest.approx(4.369, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9098, "cp_id": "CP_eair_tx", "cms": "cms_eair_tx"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_tx"]) +@pytest.mark.parametrize("port", [9098]) +async def test_on_meter_values_tx_updates_connector_and_session_energy( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that values with txId writes to connector and updates Energy.Session from the best EAIR.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + req1 = call.MeterValues( + connector_id=1, + transaction_id=111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + } + ], + } + ], + ) + await cp.call(req1) + + v1 = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) + s1 = cs.get_metric(cp_id, "Energy.Session", connector_id=1) + assert v1 == pytest.approx(1.0, rel=1e-6) + assert s1 == pytest.approx(0.0, rel=1e-6) + + req2 = call.MeterValues( + connector_id=1, + transaction_id=111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "1.5", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.Begin", + "unit": "Wh", + "value": "0", + }, + ], + } + ], + ) + await cp.call(req2) + + v2 = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) + s2 = cs.get_metric(cp_id, "Energy.Session", connector_id=1) + assert v2 == pytest.approx(1.5, rel=1e-6) + assert s2 == pytest.approx(0.5, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9099, "cp_id": "CP_eair_prio", "cms": "cms_eair_prio"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_prio"]) +@pytest.mark.parametrize("port", [9099]) +async def test_on_meter_values_priority_end_over_periodic( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that Transaction.End wins over Sample.Periodic (no matter the order).""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + req = call.MeterValues( + connector_id=2, + transaction_id=222, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "10.0", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.End", + "unit": "kWh", + "value": "10.5", + }, + ], + } + ], + ) + await cp.call(req) + + v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=2) + assert v == pytest.approx(10.5, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9100, "cp_id": "CP_eair_sanitize", "cms": "cms_eair_sanitize"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_sanitize"]) +@pytest.mark.parametrize("port", [9100]) +async def test_on_meter_values_sanitizes_and_ignores_exceptions( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """NaN & negatives ignored; get_energy_kwh exception ignored; Periodic finally wins.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from custom_components.ocpp import ocppv16 as mod_v16 + + orig_get_e_kwh = mod_v16.cp.get_energy_kwh + + def flaky_get_energy_kwh(item): + try: + if ( + getattr(item, "context", None) == "Sample.Clock" + and getattr(item, "unit", None) in ("kWh", "Wh") + and float(getattr(item, "value", -1)) == 1234.0 + ): + raise ValueError("simulated conversion error") + except Exception: + pass + return orig_get_e_kwh(item) + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + monkeypatch.setattr( + mod_v16.cp, "get_energy_kwh", flaky_get_energy_kwh, raising=True + ) + + req = call.MeterValues( + connector_id=1, + transaction_id=333, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + # 1) NaN -> should be ignored + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Clock", + "unit": "kWh", + "value": "NaN", + }, + # 2) Negativt -> should be ignored + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Clock", + "unit": "kWh", + "value": "-1", + }, + # 3) Valid, but the patch will let get_energy_kwh throw -> ignored by except + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Clock", + "unit": "kWh", + "value": "1234", + }, + # 4) Finally a good Periodic that is chosen + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "3.2", + }, + ], + } + ], + ) + await cp.call(req) + + v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) + s = cs.get_metric(cp_id, "Energy.Session", connector_id=1) + assert v == pytest.approx(3.2, rel=1e-6) + assert s == pytest.approx(0.0, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + # Restore the patch + with contextlib.suppress(Exception): + monkeypatch.setattr( + mod_v16.cp, "get_energy_kwh", orig_get_e_kwh, raising=True + ) + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9101, "cp_id": "CP_eair_prio_vs_value", "cms": "cms_eair_prio_vs_value"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_prio_vs_value"]) +@pytest.mark.parametrize("port", [9101]) +async def test_on_meter_values_priority_beats_raw_value( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that prio beats raw value.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # "Other" context has prio 0; Periodic has prio 2 -> Periodic should be picked even if the value is lower. + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Other", + "unit": "kWh", + "value": "2.0", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "1.0", + }, + ], + } + ], + ) + await cp.call(req) + + v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=0) + assert v == pytest.approx(1.0, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9111, "cp_id": "CP_trig_single_ok", "cms": "cms_trig_single_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_single_ok"]) +@pytest.mark.parametrize("port", [9111]) +async def test_trigger_status_single_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=1: should NOT try connectorId=0; only cid=1; accepted -> True.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + # force single connector + srv_cp._metrics[0][cdet.connectors.value].value = 1 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [1] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9112, "cp_id": "CP_trig_multi_ok", "cms": "cms_trig_multi_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_multi_ok"]) +@pytest.mark.parametrize("port", [9112]) +async def test_trigger_status_multi_all_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: multi-connector happy path. Should return True and not reduce connector count.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + assert srv_cp._metrics[0][cdet.connectors.value].value == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9113, "cp_id": "CP_trig_reject_zero_continue", "cms": "cms_trig_r0"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_reject_zero_continue"]) +@pytest.mark.parametrize("port", [9113]) +async def test_trigger_status_reject_zero_but_accept_rest( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=0 -> Rejected (ignored), 1 & 2 accepted -> True; connector count unchanged.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 0: + return SimpleNamespace(status="Rejected") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + # should not downgrade connector count because only cid=0 rejected + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9114, "cp_id": "CP_trig_reject_nonzero_adjusts", "cms": "cms_trig_rnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_reject_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9114]) +async def test_trigger_status_reject_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=3: 0 & 1 accepted; 2 rejected -> set connectors to 1 (cid-1), return False, stop before 3.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 3 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + return SimpleNamespace(status="Rejected") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + assert attempts == [0, 1, 2] + # reduced to cid-1 => 1 + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9115, "cp_id": "CP_trig_timeout_zero_continue", "cms": "cms_trig_t0"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_zero_continue"]) +@pytest.mark.parametrize("port", [9115]) +async def test_trigger_status_timeout_on_zero_continues( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=0 raises TimeoutError (ignored), others accepted -> True; count unchanged.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 0: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9116, "cp_id": "CP_trig_timeout_nonzero_adjusts", "cms": "cms_trig_tnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9116]) +async def test_trigger_status_timeout_on_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=2 raises TimeoutError -> set connectors to 1, return False, stop.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + # Should stop after the failing connector + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9120, "cp_id": "CP_postconn_ex_1", "cms": "cms_postconn_ex_1"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_1"]) +@pytest.mark.parametrize("port", [9120]) +async def test_post_connect_fetch_supported_features_raises( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """fetch_supported_features raises inside post_connect -> swallowed; post_connect_success stays False.""" + cs: CentralSystem = setup_config_entry + + # Patch before connecting so our call to post_connect() hits the boom. + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def boom(self): + raise RuntimeError("fetch boom") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", boom, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # client test CP + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + # Vänta bara tills servern registrerat CP-objektet + # (ingen BootNotification -> ingen auto post_connect) + await asyncio.sleep(0.05) + srv_cp = cs.charge_points[cp_id] + + # Säkerställ definierat initialt värde + setattr(srv_cp, "post_connect_success", False) + + # Kör post_connect() – ska svälja exception och inte sätta success=True + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9121, "cp_id": "CP_postconn_ex_2", "cms": "cms_postconn_ex_2"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_2"]) +@pytest.mark.parametrize("port", [9121]) +async def test_post_connect_set_availability_error_swallowed_and_REM_triggers_called( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Inner try around set_availability: generic Exception swallowed, and REM triggers still called.""" + cs: CentralSystem = setup_config_entry + + # Patch server CP methods before connecting. + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def nope_avail(self): + raise ValueError("availability failed") + + called = {"boot": 0, "status": 0} + + async def fake_boot(self): + called["boot"] += 1 + + async def fake_status(self): + called["status"] += 1 + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", nope_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", fake_boot, raising=True) + monkeypatch.setattr( + ServerCP, "trigger_status_notification", fake_status, raising=True + ) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + # wait until server registered CP + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + # enable REM and force boot path + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is True + assert called["boot"] == 1 + assert called["status"] == 1 + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9122, "cp_id": "CP_postconn_ex_3", "cms": "cms_postconn_ex_3"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_3"]) +@pytest.mark.parametrize("port", [9122]) +async def test_post_connect_set_availability_cancelled_bubbles( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Inner try around set_availability: asyncio.CancelledError must be re-raised (not swallowed).""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def cancelled(self): + raise asyncio.CancelledError() + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", cancelled, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + + with pytest.raises(asyncio.CancelledError): + await srv_cp.post_connect() + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9123, "cp_id": "CP_postconn_ex_4", "cms": "cms_postconn_ex_4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_4"]) +@pytest.mark.parametrize("port", [9123]) +async def test_post_connect_trigger_boot_notification_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: trigger_boot_notification raises -> swallowed; post_connect_success already True.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def ok_avail(self): + return None + + async def boom_boot(self): + raise RuntimeError("boot fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", ok_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", boom_boot, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9124, "cp_id": "CP_postconn_ex_5", "cms": "cms_postconn_ex_5"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_5"]) +@pytest.mark.parametrize("port", [9124]) +async def test_post_connect_trigger_status_notification_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: trigger_status_notification raises -> swallowed; post_connect_success already True.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def ok_avail(self): + return None + + async def ok_boot(self): + return None + + async def boom_status(self): + raise RuntimeError("status fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", ok_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", ok_boot, raising=True) + monkeypatch.setattr( + ServerCP, "trigger_status_notification", boom_status, raising=True + ) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9125, "cp_id": "CP_postconn_ex_6", "cms": "cms_postconn_ex_6"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_6"]) +@pytest.mark.parametrize("port", [9125]) +async def test_post_connect_update_entry_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: async_update_entry raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + def boom_update_entry(entry, data=None): + raise RuntimeError("update failed") + + monkeypatch.setattr( + srv_cp.hass.config_entries, + "async_update_entry", + boom_update_entry, + raising=True, + ) + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9126, "cp_id": "CP_postconn_ex_7", "cms": "cms_postconn_ex_7"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_7"]) +@pytest.mark.parametrize("port", [9126]) +async def test_post_connect_set_standard_configuration_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: set_standard_configuration raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def boom_std(self): + raise RuntimeError("std cfg fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr(ServerCP, "set_standard_configuration", boom_std, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9127, "cp_id": "CP_postconn_ex_8", "cms": "cms_postconn_ex_8"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_8"]) +@pytest.mark.parametrize("port", [9127]) +async def test_post_connect_number_of_connectors_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: get_number_of_connectors raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def boom_n(self): + raise RuntimeError("n fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", boom_n, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9340, "cp_id": "CP_cov_abb_tx_reset", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_abb_tx_reset"]) +@pytest.mark.parametrize("port", [9340]) +async def test_abb_new_tx_resets_eair_and_meter_start( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """ABB: when a new transactionId appears, per-connector EAIR and meter_start are cleared so a lower EAIR (e.g. 0 Wh) is accepted.""" + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # --- Seed previous session on connector 1 with EAIR = 15000 Wh (15.0 kWh), txId=111 --- + mv_tx1 = call.MeterValues( + connector_id=1, + transaction_id=111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", # Wh + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp = await client.call(mv_tx1) + assert resp is not None + + # EAIR should be normalized to kWh on connector 1. + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert ( + pytest.approx( + cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ), + rel=1e-6, + ) + == 15.0 + ) + + # --- Simulate ABB behavior: new tx starts and EAIR restarts at 0 Wh with txId=222 --- + mv_tx2_begin = call.MeterValues( + connector_id=1, + transaction_id=222, # new transaction id triggers reset block + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "0", # Wh -> should be accepted after reset + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp2 = await client.call(mv_tx2_begin) + assert resp2 is not None + + # Verify: transaction id updated to 222, EAIR accepted as 0.0 kWh, and meter_start cleared. + assert int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) == 222 + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert ( + pytest.approx( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + or 0.0, + rel=1e-6, + ) + == 0.0 + ) + + # Meter.Start should be cleared (None) at new tx begin per integration logic. + assert cs.get_metric(cpid, "Energy.Meter.Start", connector_id=1) in ( + None, + 0, + 0.0, + ) + + # --- Follow-up periodic sample to ensure increasing values are tracked from the new baseline --- + mv_tx2_next = call.MeterValues( + connector_id=1, + transaction_id=222, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "100", # Wh + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp3 = await client.call(mv_tx2_next) + assert resp3 is not None + + # EAIR should now be 0.1 kWh on connector 1 from the new baseline. + assert ( + pytest.approx( + cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ), + rel=1e-6, + ) + == 0.1 + ) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9341, "cp_id": "CP_cov_ctx_priority", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_ctx_priority"]) +@pytest.mark.parametrize("port", [9341]) +async def test_eair_context_priority_in_bucket( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure EAIR context priority per bucket: Transaction.End > Sample.Periodic > Sample.Clock.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Bucket 1: include three EAIR candidates with different contexts. + # Expect: Transaction.End (13000 Wh) wins -> 13.0 kWh. + mv_bucket1 = call.MeterValues( + connector_id=1, + transaction_id=555, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "11000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "12000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + { + "value": "13000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Transaction.End", + }, + # Some unrelated measurand in the same bucket + { + "value": "230", + "measurand": "Voltage", + "unit": "V", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp1 = await client.call(mv_bucket1) + assert resp1 is not None + + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.0, rel=1e-6) + + # Bucket 2: No Transaction.End; Sample.Periodic should beat Sample.Clock. + # Expect: 13100 Wh -> 13.1 kWh. + mv_bucket2 = call.MeterValues( + connector_id=1, + transaction_id=555, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "13090", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "13100", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp2 = await client.call(mv_bucket2) + assert resp2 is not None + + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.1, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index 5b37cb69..34e90ab8 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -606,8 +606,8 @@ async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePo == ChargePointStatusv16.charging ) assert cs.get_metric(cpid, Measurand.current_export.value) == 0 - assert cs.get_metric(cpid, Measurand.current_import.value) == 6.6 - assert cs.get_metric(cpid, Measurand.current_offered.value) == 36.6 + assert cs.get_metric(cpid, Measurand.current_import.value) == pytest.approx(2.2) + assert cs.get_metric(cpid, Measurand.current_offered.value) == pytest.approx(12.2) assert cs.get_metric(cpid, Measurand.energy_active_export_register.value) == 0 assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.1 assert cs.get_metric(cpid, Measurand.energy_reactive_export_register.value) == 0 diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py new file mode 100644 index 00000000..e12ca832 --- /dev/null +++ b/tests/test_more_coverage_chargepoint.py @@ -0,0 +1,416 @@ +"""Test additional chargepoint paths.""" + +import asyncio +import contextlib +from types import SimpleNamespace + +import pytest +import websockets +from websockets.protocol import State + +from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue + + +# Reuse the client helpers & fixtures from your main v16 test module. +from .test_charge_point_v16 import wait_ready, ChargePoint + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9410, "cp_id": "CP_cov_base_defaults", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_base_defaults"]) +@pytest.mark.parametrize("port", [9410]) +async def test_base_default_methods_return_values( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 299, 307, 315, 413, 417, 425, 429, 433, 451–453, 455–457: base defaults & no-op behaviors.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Use the base-class implementations explicitly to cover the base lines, + # even though v16 overrides some of these. + assert ( + await BaseCP.get_number_of_connectors(srv) == srv.num_connectors + ) # L299 + assert await BaseCP.get_supported_measurands(srv) == "" # L307 + assert ( + await BaseCP.get_supported_features(srv) == 0 + ) # L315 (prof.NONE is 0) + + assert await BaseCP.set_availability(srv, True) is False # L413 + assert await BaseCP.start_transaction(srv, 1) is False # L417 + assert await BaseCP.stop_transaction(srv) is False # L425 + assert await BaseCP.reset(srv) is False # L429 + assert await BaseCP.unlock(srv, 1) is False # L433 + + assert await BaseCP.get_configuration(srv, "Foo") is None # L451–453 + assert await BaseCP.configure(srv, "Foo", "Bar") is None # L455–457 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9413, "cp_id": "CP_cov_handle_call", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_handle_call"]) +@pytest.mark.parametrize("port", [9413]) +async def test_handle_call_notimplemented_sends_call_error( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 520–526: wrapper catches ocpp.exceptions.NotImplementedError and sends CallError JSON.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Patch the exact base alias used by the subclass, and raise the *OCPP* NotImplementedError. + import custom_components.ocpp.chargepoint as cp_mod + from ocpp.exceptions import NotImplementedError as OcppNotImplementedError + + async def boom(self, msg): + # Raise the OCPP exception class that the wrapper actually catches. + raise OcppNotImplementedError(details={"cause": "nyi"}) + + captured = {"payload": None} + + async def fake_send(self, payload): + # _handle_call builds a JSON string via to_json(), then calls _send(...) + captured["payload"] = ( + payload.to_json() if hasattr(payload, "to_json") else payload + ) + + # Patch on the cp alias (the base class your subclass imports as `cp`). + monkeypatch.setattr(cp_mod.cp, "_handle_call", boom, raising=True) + monkeypatch.setattr(cp_mod.cp, "_send", fake_send, raising=True) + + class Msg: + """Minimal message stub compatible with msg.create_call_error(e).""" + + def create_call_error(self, *_, **__): + from types import SimpleNamespace + + # Return an object with to_json() so wrapper turns it into a JSON string. + return SimpleNamespace(to_json=lambda: '{"error":"NotImplemented"}') + + # Invoke: the wrapper should catch the OCPP NotImplementedError and call _send with JSON. + await srv._handle_call(Msg()) + + assert captured["payload"] == '{"error":"NotImplemented"}' + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9414, "cp_id": "CP_cov_run_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_run_paths"]) +@pytest.mark.parametrize("port", [9414]) +async def test_run_handles_timeout_and_other_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 537 and 540–541: run() swallows TimeoutError and logs other exceptions, then stops.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + stopped = {"count": 0} + + async def fake_stop(): + stopped["count"] += 1 + + monkeypatch.setattr(srv, "stop", fake_stop, raising=True) + + async def raises_timeout(): + await asyncio.sleep(0) + raise TimeoutError("simulated") + + async def raises_other(): + await asyncio.sleep(0) + raise ValueError("simulated") + + # TimeoutError path -> should be swallowed (L537) and then stop() called. + await srv.run([raises_timeout()]) + assert stopped["count"] >= 1 + + # Other exception path -> should be logged via L540–541 and then stop() called again. + await srv.run([raises_other()]) + assert stopped["count"] >= 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9415, "cp_id": "CP_cov_update_early", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_early"]) +@pytest.mark.parametrize("port", [9415]) +async def test_update_returns_early_when_root_device_missing( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 602: update() returns early if the root device cannot be found in the device registry.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Fake registries: no device returned. + import custom_components.ocpp.chargepoint as mod + + class FakeDR: + """Fake DR.""" + + def async_get_device(self, identifiers): + return None + + def async_clear_config_entry(self, config_entry_id): + return None + + @property + def devices(self): + """Fake devices.""" + return {} + + def async_update_device(self, *args, **kwargs): + return None + + def async_get_or_create(self, *args, **kwargs): + return SimpleNamespace(id="dummy") + + class FakeER: + """Fake ER.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def fake_entries_for_device(_er, _dev_id): + # No entities to update; the loop is exercised anyway. + return [] + + # Patch HA helpers & dispatcher. + monkeypatch.setattr( + mod.device_registry, "async_get", lambda _: FakeDR(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, "async_get", lambda _: FakeER(), raising=True + ) + + monkeypatch.setattr( + mod.entity_registry, + "async_entries_for_device", + fake_entries_for_device, + raising=True, + ) + monkeypatch.setattr( + mod, "async_dispatcher_send", lambda *args, **kw: None, raising=True + ) + + # Should exit early without error (L602). + await srv.update(srv.settings.cpid) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9416, "cp_id": "CP_cov_update_walk", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_walk"]) +@pytest.mark.parametrize("port", [9416]) +async def test_update_traverses_children_and_skips_visited( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 612 and 623–624: skips already visited IDs and appends children discovered via via_device_id.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Build a tiny fake device graph: + # root -> child (twice in the values() list to create a duplicate push) + import custom_components.ocpp.chargepoint as mod + + class Dev: + """Fake Dev.""" + + def __init__(self, id, via=None): + self.id = id + self.via_device_id = via + + root = Dev("root", via=None) + child = Dev("child", via="root") + + class FakeDR: + """Fake DR.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def async_update_device(self, *args, **kwargs): + return None + + def async_get_or_create(self, *args, **kwargs): + return SimpleNamespace(id="dummy") + + def async_get_device(self, identifiers): + return root + + @property + def devices(self): + # Duplicate the child to force the same ID to be appended twice -> will hit continue (L612) + class Container: + def values(self_inner): + return [root, child, child] + + return Container() + + class FakeER: + """Fake ER.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def fake_entries_for_device(_er, _dev_id): + # No entities to update; the loop is exercised anyway. + return [] + + # Patch HA helpers & dispatcher. + monkeypatch.setattr( + mod.device_registry, "async_get", lambda _: FakeDR(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, "async_get", lambda _: FakeER(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, + "async_entries_for_device", + fake_entries_for_device, + raising=True, + ) + monkeypatch.setattr( + mod, "async_dispatcher_send", lambda *args, **kw: None, raising=True + ) + + # No exceptions expected; internal traversal will append 'child' twice, + # so on second pop it will be in 'visited' and trigger L612 'continue'. + await srv.update(srv.settings.cpid) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +async def test_process_measurands_defaults_and_session_energy_v2x(hass, monkeypatch): + """Covers 830–832, 836, 881–887: default EAIR measurand/unit and Energy.Session handling for 2.x.""" + # Minimal CP instance not bound to a real socket. + version = SimpleNamespace(value="2.0.1") + fake_hass = SimpleNamespace( + async_create_task=lambda c: asyncio.create_task(c), + helpers=SimpleNamespace( + entity_component=SimpleNamespace(async_update_entity=lambda eid: None) + ), + ) + fake_entry = SimpleNamespace(entry_id="dummy") + fake_central = SimpleNamespace( + websocket_ping_interval=0, + websocket_ping_timeout=0, + websocket_ping_tries=0, + ) + fake_settings = SimpleNamespace(cpid="cpid_dummy") + fake_conn = SimpleNamespace(state=State.CLOSED) + + srv = BaseCP( + "cp_dummy", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv._ocpp_version = "2.0.1" # ensure 2.x path + + # 1) Missing measurand -> defaults to EAIR; missing unit -> defaults to Wh then normalized to kWh. + samples1 = [[MeasurandValue(None, 12345.0, None, None, None, None)]] + srv.process_measurands( + samples1, is_transaction=True, connector_id=1 + ) # <-- no await + + eair = srv._metrics[(1, "Energy.Active.Import.Register")] + assert eair.unit == "kWh" + assert pytest.approx(eair.value, rel=1e-6) == 12.345 + + esess = srv._metrics[(1, "Energy.Session")] + assert esess.unit == "kWh" + assert (esess.value or 0.0) == 0.0 + + # 2) Next periodic EAIR sample increases by 100 Wh -> session delta = 0.1 kWh. + samples2 = [[MeasurandValue(None, 12445.0, None, None, None, None)]] + srv.process_measurands( + samples2, is_transaction=True, connector_id=1 + ) # <-- no await + + eair2 = srv._metrics[(1, "Energy.Active.Import.Register")] + esess2 = srv._metrics[(1, "Energy.Session")] + assert pytest.approx(eair2.value, rel=1e-6) == 12.445 + assert pytest.approx(esess2.value or 0.0, rel=1e-6) == 0.1 From f43b1d2dc2e62f225580add06ac2248c4c6a4cf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 21:14:10 +1200 Subject: [PATCH 312/370] build(deps): bump ruff from 0.12.11 to 0.12.12 (#1701) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.12.11 to 0.12.12. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.12.11...0.12.12) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.12.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cdd47b16..6b274c14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.12.11 +ruff==0.12.12 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From 2462a215321bd8e8080734c2eb9863e8c5f00a39 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 12 Sep 2025 09:43:29 +0200 Subject: [PATCH 313/370] Remove clear profiles from set_charge_rate Co-authored-by: Jan Thunqvist --- custom_components/ocpp/ocppv16.py | 198 ++++++++----------- tests/test_additional_charge_point_v16.py | 32 ---- tests/test_charge_point_v16.py | 214 --------------------- tests/test_set_charge_rate_v16.py | 222 ++++++++++++++++++++++ 4 files changed, 301 insertions(+), 365 deletions(-) create mode 100644 tests/test_set_charge_rate_v16.py diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 0dce7193..f2c1f812 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -377,36 +377,6 @@ async def clear_profile( _LOGGER.debug("ClearChargingProfile raised %s (ignored)", ex) return False - def _profile_ids_for( - self, conn_id: int, purpose: str, tx_id: int | None = None - ) -> tuple[int, int]: - """Return (chargingProfileId, stackLevel) unique per (purpose, connector). - - - Keeps IDs small and stable across restarts. - - For TxProfile you may include tx_id to avoid clashes if multiple are alive. - """ - PURPOSE_CODE = { - "ChargePointMaxProfile": 1, - "TxDefaultProfile": 2, - "TxProfile": 3, - } - if purpose == "ChargePointMaxProfile": - conn_seg = 0 - else: - try: - conn_seg = max(1, int(conn_id or 1)) - except Exception: - conn_seg = 1 - - base = 1000 - pid = base + PURPOSE_CODE[purpose] + conn_seg * 10 - - if purpose == "TxProfile" and tx_id is not None: - pid = pid * 1000 + (int(tx_id) % 1000) - - stack_level = 1 - return pid, stack_level - async def set_charge_rate( self, limit_amps: int = 32, @@ -417,118 +387,108 @@ async def set_charge_rate( """Set charge rate.""" if profile is not None: try: - resp = await self.call( - call.SetChargingProfile( - connector_id=int(conn_id), cs_charging_profiles=profile - ) + req = call.SetChargingProfile( + connector_id=int(conn_id), cs_charging_profiles=profile ) + resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True _LOGGER.warning("Custom SetChargingProfile rejected: %s", resp.status) except Exception as ex: _LOGGER.warning("Custom SetChargingProfile failed: %s", ex) + await self.notify_ha( + "Warning: Set charging profile failed with response Exception" + ) + return False + + if prof.SMART not in self._attr_supported_features: + _LOGGER.info("Smart charging is not supported by this charger") + return False - resp_units = await self.get_configuration( + # Determine allowed unit (default to Amps if not reported) + units_resp = await self.get_configuration( ckey.charging_schedule_allowed_charging_rate_unit.value ) - if resp_units is None: - _LOGGER.warning("Failed to query charging rate unit, assuming Amps") - resp_units = om.current.value + if not units_resp: + _LOGGER.debug("Charging rate unit not reported; assuming Amps") + units_resp = om.current.value - use_amps = om.current.value in resp_units - limit_val = float(limit_amps if use_amps else limit_watts) - unit_val = ( + use_amps = om.current.value in units_resp + limit_value = float(limit_amps if use_amps else limit_watts) + units_value = ( ChargingRateUnitType.amps.value if use_amps else ChargingRateUnitType.watts.value ) - # Build attempt order (CPMax -> TxDefault -> TxProfile if active) - attempts: list[tuple[int, str]] = [] - attempts.append((0, "ChargePointMaxProfile")) - if conn_id and conn_id > 0: - attempts.append((conn_id, "TxDefaultProfile")) - - has_active = bool(getattr(self, "active_transaction_id", 0)) - if has_active: - tx_conn = next( - (c for c, tx in getattr(self, "_active_tx", {}).items() if tx), - conn_id or 1, - ) - attempts.append((tx_conn, "TxProfile")) - - await self.clear_profile( - None, ChargingProfilePurposeType.charge_point_max_profile - ) - if conn_id and conn_id > 0: - await self.clear_profile( - conn_id, ChargingProfilePurposeType.tx_default_profile + # Read max stack level (default to 1 on parse errors) + try: + stack_level_resp = await self.get_configuration( + ckey.charge_profile_max_stack_level.value ) + stack_level = int(stack_level_resp) + except Exception: + stack_level = 1 - def _mk_profile(purpose: str, cid: int) -> dict: - tx_id = ( - self.active_transaction_id - if (purpose == "TxProfile" and has_active) - else None - ) - pid, stack = self._profile_ids_for(cid, purpose, tx_id=tx_id) + # Helper to build a simple relative schedule with one period + def _mk_schedule(_units: str, _limit: float) -> dict: return { - om.charging_profile_id.value: pid, - om.stack_level.value: stack, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: purpose, - om.charging_schedule.value: { - om.charging_rate_unit.value: unit_val, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: limit_val} - ], - }, + om.charging_rate_unit.value: _units, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: _limit} + ], } - # Try each purpose/connector in order; optionally clear-by-id before setting - last_status = None - for cid, purpose in attempts: - try: - try: - tx_id = ( - self.active_transaction_id - if (purpose == "TxProfile" and has_active) - else None - ) - pid, _ = self._profile_ids_for(cid, purpose, tx_id=tx_id) - await self.call(call.ClearChargingProfile(id=pid)) - except Exception: - pass - - req = call.SetChargingProfile( - connector_id=cid, cs_charging_profiles=_mk_profile(purpose, cid) - ) - resp = await self.call(req) - last_status = resp.status - if resp.status == ChargingProfileStatus.accepted: - _LOGGER.debug( - "SetChargingProfile accepted with purpose=%s connectorId=%s", - purpose, - cid, - ) - return True - _LOGGER.debug( - "SetChargingProfile %s on connector %s -> %s", - purpose, - cid, - resp.status, - ) - except Exception as ex: - _LOGGER.debug( - "SetChargingProfile %s on connector %s raised %s", purpose, cid, ex - ) + # Try ChargePointMaxProfile (connectorId = 0) + try: + req = call.SetChargingProfile( + connector_id=0, + cs_charging_profiles={ + om.charging_profile_id.value: 8, + om.stack_level.value: stack_level, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, + om.charging_schedule.value: _mk_schedule(units_value, limit_value), + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + _LOGGER.debug( + "ChargePointMaxProfile not accepted (%s); will try TxDefaultProfile.", + resp.status, + ) + except Exception as ex: + _LOGGER.debug("ChargePointMaxProfile call raised: %s", ex) - if last_status is not None: - _LOGGER.warning("SetChargingProfile failed (last status=%s).", last_status) + # Fallback: TxDefaultProfile on target connector + # If no connector given, prefer 1 as a reasonable default. + target_cid = int(conn_id) if conn_id and int(conn_id) > 0 else 1 + try: + # Some chargers are picky: try a slightly lower stack level if possible. + tx_stack = max(1, stack_level - 1) + req = call.SetChargingProfile( + connector_id=target_cid, + cs_charging_profiles={ + om.charging_profile_id.value: 8, + om.stack_level.value: tx_stack, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, + om.charging_schedule.value: _mk_schedule(units_value, limit_value), + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + _LOGGER.warning("Set TxDefaultProfile rejected: %s", resp.status) await self.notify_ha( - f"SetChargingProfile failed (last status={last_status})." + f"Warning: Set charging profile failed with response {resp.status}" ) - return False + return False + except Exception as ex: + _LOGGER.warning("Set TxDefaultProfile failed: %s", ex) + await self.notify_ha(f"Warning: Set charging profile failed: {ex}") + return False async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" diff --git a/tests/test_additional_charge_point_v16.py b/tests/test_additional_charge_point_v16.py index 92bb70f2..81accd69 100644 --- a/tests/test_additional_charge_point_v16.py +++ b/tests/test_additional_charge_point_v16.py @@ -414,38 +414,6 @@ async def test_trigger_custom_message_unsupported_name( await ws.close() -@pytest.mark.timeout(5) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9318, "cp_id": "CP_cov_profile_ids", "cms": "cms_services"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_cov_profile_ids"]) -@pytest.mark.parametrize("port", [9318]) -async def test_profile_ids_for_bad_conn_id_cast( - hass, socket_enabled, cp_id, port, setup_config_entry -): - """Test profile ids path when conn_id cast fails and conn_seg defaults to 1.""" - cs = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - srv = cs.charge_points[cp_id] - pid, level = srv._profile_ids_for(conn_id="X", purpose="TxDefaultProfile") - # conn_seg should fall back to 1 -> pid = 1000 + 2 + (1*10) = 1012 - assert (pid, level) == (1012, 1) - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await ws.close() - - @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 667e8a00..affb785c 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -3047,220 +3047,6 @@ async def fake_call_capture(req): await ws.close() -@pytest.mark.timeout(10) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9093, "cp_id": "CP_setrate_1", "cms": "cms_setrate_1"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_setrate_1"]) -@pytest.mark.parametrize("port", [9093]) -async def test_set_charge_rate_custom_profile_exception_then_fallback_all_fail( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """Custom profile path raises -> should not crash; code continues with fallback attempts. - - Make all attempts fail -> returns False and notify_ha is called. - """ - cs: CentralSystem = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - srv_cp: ServerCP = cs.charge_points[cp_id] - - async def fake_get_conf(key): - return "Current" # use amps - - monkeypatch.setattr( - srv_cp, "get_configuration", fake_get_conf, raising=True - ) - - wanted_profile = {"foo": "bar"} # will be passed in - - calls = {"set": 0, "clear": 0} - - async def fake_call(req): - # First branch: custom profile call should raise - if ( - isinstance(req, call.SetChargingProfile) - and req.cs_charging_profiles == wanted_profile - ): - raise RuntimeError("custom profile failed") - # Fallback phase: - if isinstance(req, call.ClearChargingProfile): - calls["clear"] += 1 - return SimpleNamespace(status="Accepted") - if isinstance(req, call.SetChargingProfile): - calls["set"] += 1 - return SimpleNamespace(status=ChargingProfileStatus.rejected) - return SimpleNamespace() - - monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) - - captured = {"msg": None} - - async def fake_notify(msg: str, title: str = "Ocpp integration"): - captured["msg"] = msg - return True - - monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) - - ok = await srv_cp.set_charge_rate( - limit_amps=16, conn_id=1, profile=wanted_profile - ) - assert ok is False - assert calls["set"] >= 1 - assert "SetChargingProfile failed" in (captured["msg"] or "") - - finally: - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9094, "cp_id": "CP_setrate_2", "cms": "cms_setrate_2"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_setrate_2"]) -@pytest.mark.parametrize("port", [9094]) -async def test_set_charge_rate_pre_clear_by_id_raises_then_all_rejected( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """ClearChargingProfile(id=pid) raises (ignored) + all SetChargingProfile return Rejected. - - With active transaction present, attempts should include TxProfile. - Expect False and notify_ha(last_status=Rejected). - """ - cs: CentralSystem = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - srv_cp: ServerCP = cs.charge_points[cp_id] - - # Active tx on connector 1 -> should attempt TxProfile - srv_cp.active_transaction_id = 123 - srv_cp._active_tx = {1: 123} - - async def fake_get_conf(key): - return "Current" # use amps - - monkeypatch.setattr( - srv_cp, "get_configuration", fake_get_conf, raising=True - ) - - attempts_seen = [] - - async def fake_call(req): - if isinstance(req, call.ClearChargingProfile): - # simulate firmware throwing here -> must be swallowed - raise TypeError("clear-by-id boom") - if isinstance(req, call.SetChargingProfile): - attempts_seen.append(req.connector_id) - return SimpleNamespace(status=ChargingProfileStatus.rejected) - return SimpleNamespace() - - monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) - - captured = {"msg": None} - - async def fake_notify(msg: str, title: str = "Ocpp integration"): - captured["msg"] = msg - return True - - monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) - - ok = await srv_cp.set_charge_rate(limit_amps=10, conn_id=1) - assert ok is False - # Should have tried CPMax (0), TxDefault (1), and TxProfile (1 or detected tx connector) - assert 0 in attempts_seen - assert 1 in attempts_seen - assert "last status=Rejected" in (captured["msg"] or "") - - finally: - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - -@pytest.mark.timeout(10) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9095, "cp_id": "CP_setrate_3", "cms": "cms_setrate_3"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_setrate_3"]) -@pytest.mark.parametrize("port", [9095]) -async def test_set_charge_rate_set_call_raises_for_all_attempts( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """SetChargingProfile raises for all attempts -> function swallows errors, returns False, and does not notify HA.""" - cs: CentralSystem = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - srv_cp: ServerCP = cs.charge_points[cp_id] - - # Force active tx so TxProfile is attempted too - srv_cp.active_transaction_id = 456 - srv_cp._active_tx = {1: 456} - - async def fake_get_conf(key): - return "Current" - - monkeypatch.setattr( - srv_cp, "get_configuration", fake_get_conf, raising=True - ) - - async def fake_call(req): - if isinstance(req, call.ClearChargingProfile): - return SimpleNamespace(status="Accepted") - if isinstance(req, call.SetChargingProfile): - raise TypeError("set-profile boom") - return SimpleNamespace() - - monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) - - notify_calls = {"n": 0} - - async def fake_notify(msg: str, title: str = "Ocpp integration"): - notify_calls["n"] += 1 - return True - - monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) - - ok = await srv_cp.set_charge_rate(limit_amps=6, conn_id=1) - assert ok is False - # No user-facing notification on periodic/internal failure - assert notify_calls["n"] == 0 - - finally: - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", diff --git a/tests/test_set_charge_rate_v16.py b/tests/test_set_charge_rate_v16.py new file mode 100644 index 00000000..77eee443 --- /dev/null +++ b/tests/test_set_charge_rate_v16.py @@ -0,0 +1,222 @@ +"""Tests for the simplified OCPP 1.6 set_charge_rate implementation. + +These tests use the production ChargePoint class (v1.6) and monkeypatch only +the collaborators set_charge_rate depends on: +- get_configuration(...) +- call(...) +- notify_ha(...) + +They avoid any parallel/dummy implementation of ChargePoint. +""" + +from types import SimpleNamespace + +import pytest + +from custom_components.ocpp.ocppv16 import ChargePoint as ChargePointv16 +from custom_components.ocpp.enums import ( + Profiles as prof, + ConfigurationKey as ckey, +) +from ocpp.v16.enums import ( + ChargingProfileStatus, + ChargingProfilePurposeType, + ChargingProfileKindType, + ChargingRateUnitType, +) + + +@pytest.fixture +def cp_v16(): + """Provide a minimally-initialized v1.6 ChargePoint instance. + + We bypass __init__ and set only the attributes used by set_charge_rate. + """ + cp = object.__new__(ChargePointv16) # type: ignore[misc] + # What set_charge_rate reads: + cp._attr_supported_features = prof.SMART # can be overridden in tests + cp._ocpp_version = "1.6" + cp.active_transaction_id = 0 + cp._active_tx = {} + # set_charge_rate calls these (we’ll monkeypatch per-test): + # - cp.get_configuration(key) + # - cp.call(req) + # - cp.notify_ha(msg) + return cp + + +@pytest.mark.asyncio +async def test_custom_profile_path_exception_triggers_notify_and_returns_false( + cp_v16, monkeypatch +): + """1) When a custom profile is provided and the CP call raises, return False and notify HA.""" + # notify capture + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + async def fake_call(_req): + raise RuntimeError("boom") + + # get_configuration shouldn't be touched in this path + async def fake_get_conf(_key): + pytest.fail("get_configuration should not be called for custom profile") + + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + + profile = { + "chargingProfileId": 123, + "stackLevel": 1, + "chargingProfileKind": ChargingProfileKindType.relative.value, + "chargingProfilePurpose": ChargingProfilePurposeType.charge_point_max_profile.value, + "chargingSchedule": { + "chargingRateUnit": ChargingRateUnitType.amps.value, + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16}], + }, + } + + ok = await cp_v16.set_charge_rate(profile=profile, conn_id=2) + assert ok is False + assert len(notices) == 1 + assert "Set charging profile failed" in notices[0] + + +@pytest.mark.asyncio +async def test_smart_charging_not_supported_returns_false_no_notify( + cp_v16, monkeypatch +): + """2) If the charger doesn't advertise SMART profile, return False without notifications.""" + cp_v16._attr_supported_features = prof.NONE + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + # get_configuration and call should not be called + async def fake_get_conf(_key): + pytest.fail("get_configuration should not be called when SMART not supported") + + async def fake_call(_req): + pytest.fail("call should not be called when SMART not supported") + + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + + ok = await cp_v16.set_charge_rate(limit_amps=16, conn_id=2) + assert ok is False + assert notices == [] + + +@pytest.mark.asyncio +async def test_cpmax_exception_falls_back_to_txdefault_accepted_returns_true( + cp_v16, monkeypatch +): + """3) CPMax path raises -> fallback to TxDefault which is accepted -> return True.""" + + # Allow both A and stack level + async def fake_get_conf(key: str): + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "Current" # supports Amps + if key == ckey.charge_profile_max_stack_level.value: + return "2" + pytest.fail(f"Unexpected get_configuration key: {key}") + + # First SetChargingProfile (CPMax connectorId=0) raises, second (TxDefault connectorId=2) accepted + async def fake_call(req): + purpose = req.cs_charging_profiles["chargingProfilePurpose"] + if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: + raise RuntimeError("transport error") + if purpose == ChargingProfilePurposeType.tx_default_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace(status=ChargingProfileStatus.rejected) + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + + ok = await cp_v16.set_charge_rate(limit_amps=16, conn_id=2) + assert ok is True + # No user-facing warning necessary when fallback succeeds + assert notices == [] + + +@pytest.mark.asyncio +async def test_cpmax_rejected_txdefault_accepted_returns_true(cp_v16, monkeypatch): + """4) CPMax rejected -> TxDefault accepted -> return True.""" + + async def fake_get_conf(key: str): + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "Current" + if key == ckey.charge_profile_max_stack_level.value: + return "3" + pytest.fail(f"Unexpected get_configuration key: {key}") + + async def fake_call(req): + purpose = req.cs_charging_profiles["chargingProfilePurpose"] + if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.rejected) + if purpose == ChargingProfilePurposeType.tx_default_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace(status=ChargingProfileStatus.rejected) + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + + ok = await cp_v16.set_charge_rate(limit_amps=10, conn_id=2) + assert ok is True + assert notices == [] + + +@pytest.mark.asyncio +async def test_txdefault_raises_returns_false_and_notifies(cp_v16, monkeypatch): + """5) CPMax rejected, TxDefault raises -> return False and notify HA.""" + + async def fake_get_conf(key: str): + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "Current" + if key == ckey.charge_profile_max_stack_level.value: + return "1" + pytest.fail(f"Unexpected get_configuration key: {key}") + + async def fake_call(req): + purpose = req.cs_charging_profiles["chargingProfilePurpose"] + if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.rejected) + if purpose == ChargingProfilePurposeType.tx_default_profile.value: + raise RuntimeError("boom on txdefault") + return SimpleNamespace(status=ChargingProfileStatus.rejected) + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + + ok = await cp_v16.set_charge_rate(limit_amps=25, conn_id=2) + assert ok is False + assert len(notices) == 1 + assert "Set charging profile failed" in notices[0] From d94c1cb1324dc00e034e63c4e36c9a2ec9d93c08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:44:03 +1200 Subject: [PATCH 314/370] build(deps): bump ruff from 0.12.12 to 0.13.0 (#1716) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.12.12 to 0.13.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.12.12...0.13.0) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6b274c14..818f53b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.12.12 +ruff==0.13.0 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From 833284ea58739f6aa7de9c0a3a10e1467e930da1 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 16 Sep 2025 04:12:17 +0200 Subject: [PATCH 315/370] Improve EAIR and charging handling on single-connector chargers. (#1722) * Simplify on_meter_values (move out EAIR logic), process_measurands and on_stop_transaction. Add connector logic to set_charge_rate. Add TxProfile to set_charge_rate. Adapt tests. * stop_transaction per connector. * Keep HA entities available when connector is unavailable. Add per-connector Availability switch. Update test. * Update documentation for multi-connector chargers. Add supported device. --------- Co-authored-by: Jan Thunqvist --- custom_components/ocpp/api.py | 8 +- custom_components/ocpp/chargepoint.py | 204 ++-- custom_components/ocpp/ocppv16.py | 521 ++++------ custom_components/ocpp/ocppv201.py | 38 +- custom_components/ocpp/switch.py | 22 + docs/Charge_automation.md | 24 +- docs/installation.md | 3 +- docs/support.md | 16 +- docs/supported-devices.md | 3 + docs/user-guide.md | 41 +- tests/test_api_paths.py | 5 +- tests/test_charge_point_v16.py | 1347 ++++++++----------------- tests/test_set_charge_rate_v16.py | 35 - 13 files changed, 903 insertions(+), 1364 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index dc472c13..d0c17dbf 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -531,10 +531,12 @@ def get_available(self, id: str, connector_id: int | None = None): "finishing", "occupied", "reserved", + "unavailable", # do NOT make HA entities unavailable for this OCPP state } ret = _norm(status_val) in ok_statuses_norm - return ret + # If backend/WS is down, entity should be unavailable regardless. + return ret and (cp.status == STATE_OK) def get_supported_features(self, id: str): """Return what profiles the charger supports.""" @@ -580,7 +582,9 @@ async def set_charger_state( connector_id=connector_id ) if service_name == csvcs.service_charge_stop.name: - resp = await self.charge_points[cp_id].stop_transaction() + resp = await self.charge_points[cp_id].stop_transaction( + connector_id=connector_id + ) if service_name == csvcs.service_reset.name: resp = await self.charge_points[cp_id].reset() if service_name == csvcs.service_unlock.name: diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 36d717fa..f5fa1a3f 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -416,7 +416,7 @@ async def start_transaction(self, connector_id: int = 1) -> bool: """Remote start a transaction.""" return False - async def stop_transaction(self) -> bool: + async def stop_transaction(self, connector_id: int | None = None) -> bool: """Request remote stop of current transaction. Leaves charger in finishing state until unplugged. @@ -823,9 +823,54 @@ def process_measurands( connector_id: int = 0, ): """Process all values from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" + for bucket in meter_values: + # --- Preselect best EAIR in this bucket (ignore Transaction.Begin) --- + best_eair_idx = None + best_pr = -1 + best_val = None + for j, sv in enumerate(bucket): + meas = sv.measurand if sv.measurand is not None else DEFAULT_MEASURAND + if meas != DEFAULT_MEASURAND: + continue + ctx = sv.context + # Always ignore Transaction.Begin for EAIR (prevents resets to 0) + if ctx == ReadingContext.transaction_begin.value: + continue + try: + kwh = float( + ChargePoint.get_energy_kwh( + MeasurandValue( + meas, + sv.value, + sv.phase, + sv.unit, + sv.context, + sv.location, + ) + ) + ) + except Exception: + continue + if kwh < 0.0 or kwh != kwh: + continue + pr = 0 + if ctx == ReadingContext.transaction_end.value: + pr = 3 + elif ctx == ReadingContext.sample_periodic.value: + pr = 2 + elif ctx == ReadingContext.sample_clock.value: + pr = 1 + if (pr > best_pr) or ( + pr == best_pr and (best_val is None or kwh > best_val) + ): + best_pr = pr + best_val = kwh + best_eair_idx = j + unprocessed: list[MeasurandValue] = [] - for sampled_value in bucket: + + for idx, sampled_value in enumerate(bucket): measurand = sampled_value.measurand value = sampled_value.value unit = sampled_value.unit @@ -833,120 +878,109 @@ def process_measurands( location = sampled_value.location context = sampled_value.context - # If the measurand is missing: treat as EAIR but respect existing unit - if measurand is None: + # Backwards compatibility + if sampled_value.measurand is None: measurand = DEFAULT_MEASURAND - if unit is None: - unit = DEFAULT_ENERGY_UNIT + unit = unit or DEFAULT_ENERGY_UNIT - # If EAIR and unit missing, assume Wh (charger not sending unit) if measurand == DEFAULT_MEASURAND and unit is None: unit = DEFAULT_ENERGY_UNIT # Normalize units - if unit == DEFAULT_ENERGY_UNIT or ( - measurand == DEFAULT_MEASURAND and unit is None - ): - # Wh → kWh + if unit == DEFAULT_ENERGY_UNIT: value = ChargePoint.get_energy_kwh( MeasurandValue(measurand, value, phase, unit, context, location) ) unit = HA_ENERGY_UNIT - elif unit == DEFAULT_POWER_UNIT: - # W → kW + + if unit == DEFAULT_POWER_UNIT: value = value / 1000 unit = HA_POWER_UNIT - # Only flag if meter_start explicitly is 0 (not None) if self._metrics[(connector_id, csess.meter_start.value)].value == 0: + # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. self._charger_reports_session_energy = True if phase is None: - # Set main measurand - self._metrics[(connector_id, measurand)].value = value - self._metrics[(connector_id, measurand)].unit = unit + is_eair = measurand == DEFAULT_MEASURAND + + # Determine if this is a single-connector charger (only if explicitly known) + try: + n_connectors = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n_connectors = 1 + + single = n_connectors == 1 + + # Choose target connector id + if is_eair: + if connector_id and connector_id > 0: + # Always honor a positive connector_id for EAIR, even without txId + target_cid = connector_id + else: + # connector_id == 0 or missing → map based on topology + target_cid = 1 if single else 0 + else: + target_cid = connector_id + + # For EAIR: process only the best candidate in this bucket, skip others (incl. Transaction.Begin) + if is_eair and idx != best_eair_idx: + continue + + self._metrics[(target_cid, measurand)].value = value + self._metrics[(target_cid, measurand)].unit = unit if location is not None: - self._metrics[(connector_id, measurand)].extra_attr[ + self._metrics[(target_cid, measurand)].extra_attr[ om.location.value ] = location if context is not None: - self._metrics[(connector_id, measurand)].extra_attr[ + self._metrics[(target_cid, measurand)].extra_attr[ om.context.value ] = context - # Energy.Session is calculated here only for OCPP 2.x (not 1.6) - if ( - measurand == DEFAULT_MEASURAND - and is_transaction - and self._ocpp_version != "1.6" - ): - # Ensure session metric is present and well-formed - sess_key = (connector_id, "Energy.Session") - if sess_key not in self._metrics: - self._metrics[sess_key] = Metric(0.0, HA_ENERGY_UNIT) - else: - if self._metrics[sess_key].unit is None: - self._metrics[sess_key].unit = HA_ENERGY_UNIT - if self._metrics[sess_key].value is None: - self._metrics[sess_key].value = 0.0 - - # Bootstrap baseline for 2.x if missing: - ms_key = ( - connector_id, - csess.meter_start.value, - ) # "Energy.Meter.Start" - if ms_key not in self._metrics: - # Create the slot with kWh unit to match normalized EAIR above - self._metrics[ms_key] = Metric(None, HA_ENERGY_UNIT) - - ms_metric = self._metrics[ms_key] - if ms_metric.value is None: - # First EAIR in this transaction: set baseline to current EAIR (kWh) - ms_metric.value = value - # Keep session at 0.0 for the baseline sample - else: - # Compute positive delta only (guard against counter resets) - delta = value - ms_metric.value - if delta >= 0: - self._metrics[sess_key].value = round(delta, 6) - - if ( - self._charger_reports_session_energy - and context != ReadingContext.transaction_begin.value - ): - # The charger reports session energy directly (2.x case) - self._metrics[ - (connector_id, csess.session_energy.value) - ].value = value - self._metrics[ - (connector_id, csess.session_energy.value) - ].unit = HA_ENERGY_UNIT - self._metrics[ - (connector_id, csess.session_energy.value) - ].extra_attr[cstat.id_tag.name] = self._metrics[ - (connector_id, cstat.id_tag.value) - ].value + # Session handling, only for EAIR during a transaction (per-connector) + if is_transaction and is_eair: + if self._charger_reports_session_energy: + # Charger reports session energy directly; ignore Transaction.Begin. + if context != ReadingContext.transaction_begin.value: + self._metrics[ + (target_cid, csess.session_energy.value) + ].value = value + self._metrics[ + (target_cid, csess.session_energy.value) + ].unit = unit + self._metrics[ + (target_cid, csess.session_energy.value) + ].extra_attr[cstat.id_tag.name] = self._metrics[ + (target_cid, cstat.id_tag.value) + ].value else: - # Derive: EAIR_kWh - meter_start_kWh - ms_val = self._metrics[ - (connector_id, csess.meter_start.value) - ].value - if ms_val is not None: + # Initialize baseline on first tx-bound EAIR; then derive Session = EAIR - meter_start. + ms_metric = self._metrics[(target_cid, csess.meter_start)] + if ms_metric.value is None: + ms_metric.value = value + ms_metric.unit = unit self._metrics[ - (connector_id, csess.session_energy.value) - ].value = ( - round(1000 * (float(value) - float(ms_val))) / 1000 - ) + (target_cid, csess.session_energy.value) + ].value = 0.0 self._metrics[ - (connector_id, csess.session_energy.value) - ].unit = HA_ENERGY_UNIT + (target_cid, csess.session_energy.value) + ].unit = unit + elif ms_metric.unit == unit: + self._metrics[ + (target_cid, csess.session_energy.value) + ].value = round(1000 * (value - ms_metric.value)) / 1000 + self._metrics[ + (target_cid, csess.session_energy.value) + ].unit = unit else: - # Handle phase values separately unprocessed.append(sampled_value) - # Sum/calculate phase values - self.process_phases(unprocessed, connector_id) + try: + self.process_phases(unprocessed, connector_id) + except TypeError: + self.process_phases(unprocessed) @property def supported_features(self) -> int: diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index f2c1f812..8a534180 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -56,7 +56,6 @@ CentralSystemSettings, ChargerSystemSettings, DEFAULT_MEASURAND, - DEFAULT_ENERGY_UNIT, DOMAIN, HA_ENERGY_UNIT, ) @@ -421,7 +420,6 @@ async def set_charge_rate( else ChargingRateUnitType.watts.value ) - # Read max stack level (default to 1 on parse errors) try: stack_level_resp = await self.get_configuration( ckey.charge_profile_max_stack_level.value @@ -439,12 +437,27 @@ def _mk_schedule(_units: str, _limit: float) -> dict: ], } + # Helper to generate a unique, stable chargingProfileId per purpose+connector + def _profile_id(purpose: str, cid: int) -> int: + base = { + ChargingProfilePurposeType.charge_point_max_profile.value: 1000, + ChargingProfilePurposeType.tx_default_profile.value: 2000, + ChargingProfilePurposeType.tx_profile.value: 3000, + }.get(purpose, 9000) + try: + n = int(cid or 0) + except Exception: + n = 0 + return base + max(0, n) + # Try ChargePointMaxProfile (connectorId = 0) try: req = call.SetChargingProfile( connector_id=0, cs_charging_profiles={ - om.charging_profile_id.value: 8, + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.charge_point_max_profile.value, 0 + ), om.stack_level.value: stack_level, om.charging_profile_kind.value: ChargingProfileKindType.relative.value, om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, @@ -455,22 +468,63 @@ def _mk_schedule(_units: str, _limit: float) -> dict: if resp.status == ChargingProfileStatus.accepted: return True _LOGGER.debug( - "ChargePointMaxProfile not accepted (%s); will try TxDefaultProfile.", + "ChargePointMaxProfile not accepted (%s); will continue.", resp.status, ) except Exception as ex: _LOGGER.debug("ChargePointMaxProfile call raised: %s", ex) - # Fallback: TxDefaultProfile on target connector - # If no connector given, prefer 1 as a reasonable default. + # Target connector (default 1 if unspecified/0) target_cid = int(conn_id) if conn_id and int(conn_id) > 0 else 1 + + # Read active transaction on this connector try: - # Some chargers are picky: try a slightly lower stack level if possible. - tx_stack = max(1, stack_level - 1) + active_tx_id = int(self._active_tx.get(target_cid, 0) or 0) + except Exception: + active_tx_id = 0 + + txp_ok = False + txd_ok = False + + # If an active transaction exists on this connector, try TxProfile first (affects ongoing charging) + if active_tx_id > 0: + try: + txp_stack = max(1, stack_level) # keep same or higher than defaults + req = call.SetChargingProfile( + connector_id=target_cid, + cs_charging_profiles={ + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.tx_profile.value, target_cid + ), + om.stack_level.value: txp_stack, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_profile.value, + om.charging_schedule.value: _mk_schedule( + units_value, limit_value + ), + # Bind to the ongoing transaction + om.transaction_id.value: active_tx_id, + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + txp_ok = True + else: + _LOGGER.debug("TxProfile not accepted (%s).", resp.status) + except Exception as ex: + _LOGGER.debug("TxProfile call raised: %s.", ex) + + # Always attempt TxDefaultProfile as well (for future sessions) + try: + tx_stack = max( + 1, stack_level - 1 + ) # slightly lower to avoid overriding TxProfile req = call.SetChargingProfile( connector_id=target_cid, cs_charging_profiles={ - om.charging_profile_id.value: 8, + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.tx_default_profile.value, target_cid + ), om.stack_level.value: tx_stack, om.charging_profile_kind.value: ChargingProfileKindType.relative.value, om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, @@ -479,16 +533,21 @@ def _mk_schedule(_units: str, _limit: float) -> dict: ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: - return True - _LOGGER.warning("Set TxDefaultProfile rejected: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False + txd_ok = True + else: + _LOGGER.debug("Set TxDefaultProfile rejected: %s", resp.status) + if txp_ok: + _LOGGER.debug( + f"Note: Active TxProfile applied, but TxDefaultProfile was rejected ({resp.status})." + ) except Exception as ex: - _LOGGER.warning("Set TxDefaultProfile failed: %s", ex) - await self.notify_ha(f"Warning: Set charging profile failed: {ex}") - return False + _LOGGER.debug("Set TxDefaultProfile failed: %s", ex) + if txp_ok: + _LOGGER.debug( + f"Note: Active TxProfile applied, but TxDefaultProfile failed: {ex}" + ) + + return bool(txp_ok or txd_ok) async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" @@ -511,10 +570,34 @@ async def set_availability(self, state: bool = True, connector_id: int | None = try: status = getattr(resp, "status", None) - return status in ( - AvailabilityStatus.accepted, - AvailabilityStatus.scheduled, - ) + + pending_key = "availability_pending" + target_str = "Operative" if state else "Inoperative" + scope_str = "station" if conn == 0 else "connector" + + metric_key = (conn, cstat.status_connector.value) + metric = self._metrics.get(metric_key) + + if status == AvailabilityStatus.scheduled: + info = { + "target": target_str, + "scope": scope_str, + "since": datetime.now(tz=UTC).isoformat(), + } + if metric is not None: + metric.extra_attr[pending_key] = info + self.hass.async_create_task(self.update(self.settings.cpid)) + return True + + if status == AvailabilityStatus.accepted: + if metric is not None: + metric.extra_attr.pop(pending_key, None) + self.hass.async_create_task(self.update(self.settings.cpid)) + return True + + _LOGGER.warning("Failed with response: %s", resp.status) + return False + except Exception: _LOGGER.warning("Failed with response: %s", resp.status) await self.notify_ha( @@ -538,29 +621,48 @@ async def start_transaction(self, connector_id: int = 1): ) return False - async def stop_transaction(self): + async def stop_transaction(self, connector_id: int | None = None): """Request remote stop of current transaction. - Leaves charger in finishing state until unplugged. - Use reset() to make the charger available again for remote start + If connector_id is provided, only stop the transaction running on that connector. """ - if self.active_transaction_id == 0 and not any(self._active_tx.values()): - return True - tx_id = self.active_transaction_id or next( - (v for v in self._active_tx.values() if v), 0 - ) + # Resolve which transaction to stop + tx_id = 0 + if connector_id is not None: + # Per-connector stop: do NOT fall back to other connectors + try: + tx_id = int(self._active_tx.get(int(connector_id), 0) or 0) + except Exception: + tx_id = 0 + + # For single-connector chargers, maintain compatibility with legacy global field + if tx_id == 0: + try: + n = int(getattr(self, "num_connectors", 0) or 0) + except Exception: + n = 0 + if n == 1 and int(connector_id) in (0, 1): + tx_id = int(self.active_transaction_id or 0) + else: + # Global stop (legacy behavior): stop the known active tx, or any active tx + tx_id = int(self.active_transaction_id or 0) + if tx_id == 0: + tx_id = next((int(v) for v in self._active_tx.values() if v), 0) + + # Nothing to stop - succeed as no-op if tx_id == 0: return True + req = call.RemoteStopTransaction(transaction_id=tx_id) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Stop transaction failed with response {resp.status}" - ) - return False + + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Stop transaction failed with response {resp.status}" + ) + return False async def reset(self, typ: str = ResetType.hard): """Hard reset charger unless soft reset requested.""" @@ -745,259 +847,95 @@ async def async_update_device_info_v16(self, boot_info: dict): @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Handle MeterValues (per connector). - - - EAIR **without** transactionId always writes to connector 0, - even if it decreases relative to a previously mirrored value. - - Tx-bound EAIR (with transactionId) writes to the specific connector using a non-decreasing rule, - **except** when a new transaction begins on that connector — then lower values are allowed. - - For single-connector chargers, mirror tx-bound EAIR to connector 0 **only** until a true - main-meter (no txId) value is observed. After that, do not mirror. - - Session energy is computed **only** from tx-bound EAIR, never from main-meter readings. - """ - transaction_id: int | None = kwargs.get(om.transaction_id.name, None) - tx_has_id: bool = transaction_id not in (None, 0) + """Request handler for MeterValues Calls (multi-connector aware).""" - active_tx_for_conn: int | None = ( - int(self._active_tx.get(connector_id, 0) or 0) or None - ) + transaction_id: int = kwargs.get(om.transaction_id.name, 0) - # If missing meter_start or active_transaction_id try to restore from HA states. If HA - # does not have values either, generate new ones. - if self._metrics[(connector_id, csess.meter_start.value)].value is None: - restored = self.get_ha_metric(csess.meter_start.value, connector_id) - restored_f: float | None - if restored is not None: + # Restore missing per-connector meter_start / active_transaction_id from HA if possible. + ms_key = (connector_id, csess.meter_start.value) + tx_key = (connector_id, csess.transaction_id.value) + + if self._metrics[ms_key].value is None: + value = self.get_ha_metric(csess.meter_start.value, connector_id) + if value is None: + m = self._metrics.get((connector_id, DEFAULT_MEASURAND)) + value = m.value if m is not None else None + else: try: - restored_f = float(restored) + value = float(value) + _LOGGER.debug( + "%s[%s] was None, restored value=%s from HA.", + csess.meter_start.value, + connector_id, + value, + ) except (ValueError, TypeError): - restored_f = None + value = None + self._metrics[ms_key].value = value + + if self._metrics[tx_key].value is None: + value = self.get_ha_metric(csess.transaction_id.value, connector_id) + if value is None: + value = kwargs.get(om.transaction_id.name) else: - # Fallback: if no txId and connector has a per-connector EAIR stored, use that - restored_f = None - if restored_f is not None: - self._metrics[ - (connector_id, csess.meter_start.value) - ].value = restored_f - - if self._metrics[(connector_id, csess.transaction_id.value)].value is None: - restored_tx = self.get_ha_metric(csess.transaction_id.value, connector_id) - candidate: int | None - if restored_tx is not None: try: - candidate = int(restored_tx) + value = int(value) + _LOGGER.debug( + "%s[%s] was None, restored value=%s from HA.", + csess.transaction_id.value, + connector_id, + value, + ) except (ValueError, TypeError): - candidate = None - else: - candidate = int(transaction_id) if tx_has_id else None - - if candidate is not None and candidate != 0: - self._metrics[ - (connector_id, csess.transaction_id.value) - ].value = candidate - self._active_tx[connector_id] = candidate - active_tx_for_conn = candidate - - # --- Detect a new transaction on this connector (for example ABB resets to 0 at Transaction.Begin) --- - new_tx_started = False - if tx_has_id and ( - active_tx_for_conn is None or int(transaction_id) != int(active_tx_for_conn) - ): - # Register the new transaction and clear per-connector EAIR so that a lower starting - # value (e.g., 0.0) is accepted. - self._metrics[(connector_id, csess.transaction_id.value)].value = int( - transaction_id + value = None + self._metrics[tx_key].value = value + # Track active tx per connector + self._active_tx[connector_id] = value + + active_tx = self._active_tx.get(connector_id, 0) or 0 + + transaction_matches: bool = False + # Match is also false if no transaction is in progress, i.e. active_tx==transaction_id==0 + if transaction_id == active_tx and transaction_id != 0: + transaction_matches = True + elif transaction_id != 0 and transaction_id is not None: + _LOGGER.warning( + "Unknown transaction detected on conn %s with id=%i", + connector_id, + transaction_id, ) - self._active_tx[connector_id] = int(transaction_id) - active_tx_for_conn = int(transaction_id) - new_tx_started = True - # Reset tx-bound EAIR and session baseline; main meter (connector 0) remains untouched. - self._metrics[(connector_id, DEFAULT_MEASURAND)].value = None - self._metrics[(connector_id, csess.meter_start.value)].value = None - - if tx_has_id: - transaction_matches = transaction_id == active_tx_for_conn - else: - transaction_matches = active_tx_for_conn not in (None, 0) meter_values: list[list[MeasurandValue]] = [] for bucket in meter_value: measurands: list[MeasurandValue] = [] for sampled_value in bucket.get(om.sampled_value.name, []): measurand = sampled_value.get(om.measurand.value, None) - v = sampled_value.get(om.value.value, None) - # where an empty string is supplied convert to 0 + value = sampled_value.get(om.value.value, None) + # Where an empty string is supplied convert to 0 try: - v = float(v) + value = float(value) except (ValueError, TypeError): - v = 0.0 + value = 0.0 unit = sampled_value.get(om.unit.value, None) phase = sampled_value.get(om.phase.value, None) location = sampled_value.get(om.location.value, None) context = sampled_value.get(om.context.value, None) measurands.append( - MeasurandValue(measurand, v, phase, unit, context, location) + MeasurandValue(measurand, value, phase, unit, context, location) ) meter_values.append(measurands) - # --- Helper to rank contexts when multiple EAIR candidates exist in a bucket --- - def _ctx_priority(ctx: str | None) -> int: - if ctx == "Transaction.End": - return 3 - if ctx == "Sample.Periodic": - return 2 - if ctx == "Sample.Clock": - return 1 - return 0 - - # --- Step 1: Apply EAIR --- - # target_cid = 0 (main meter) if no transactionId, else the connector itself - target_cid = 0 if not tx_has_id else connector_id - - for bucket in meter_values: - best_pr, best_val_kwh, best_item = -1, None, None - for item in bucket: - measurand = item.measurand or DEFAULT_MEASURAND - if measurand != DEFAULT_MEASURAND: - continue - # Ignore Transaction.Begin EAIR (often 0 right at start); ABB will be handled by new_tx_started - if item.context == "Transaction.Begin": - continue - try: - val_kwh = float(cp.get_energy_kwh(item)) - except Exception: - continue - if val_kwh < 0.0 or (val_kwh != val_kwh): - continue - pr = _ctx_priority(item.context) - if (pr > best_pr) or ( - pr == best_pr and (best_val_kwh is None or val_kwh > best_val_kwh) - ): - best_pr, best_val_kwh, best_item = pr, val_kwh, item - - if best_item is None: - continue - - if not tx_has_id and target_cid == 0: - # Authoritative main meter: always write (can decrease vs a mirrored value) - m = self._metrics[(0, DEFAULT_MEASURAND)] - m.value = best_val_kwh - m.unit = HA_ENERGY_UNIT - m.extra_attr["source"] = "main" - if best_item.context is not None: - m.extra_attr[om.context.value] = best_item.context - if best_item.location is not None: - m.extra_attr[om.location.value] = best_item.location - else: - # Tx-bound EAIR: write non-decreasing, unless a new transaction just began - m = self._metrics[(target_cid, DEFAULT_MEASURAND)] - prev = m.value - allow = new_tx_started or (prev is None or best_val_kwh >= float(prev)) - if allow: - m.value = best_val_kwh - m.unit = HA_ENERGY_UNIT - if best_item.context is not None: - m.extra_attr[om.context.value] = best_item.context - if best_item.location is not None: - m.extra_attr[om.location.value] = best_item.location - - # Mirror to connector 0 only for single-connector chargers, and only - # until we've observed an authoritative main meter. - try: - n_connectors = int(getattr(self, "num_connectors", 1) or 1) - except Exception: - n_connectors = 1 - if n_connectors == 1: - mm = self._metrics[(0, DEFAULT_MEASURAND)] - main_seen = mm.extra_attr.get("source") == "main" - if not main_seen: - prev_main = mm.value - allow_main = new_tx_started or ( - prev_main is None or best_val_kwh >= float(prev_main) - ) - if allow_main: - mm.value = best_val_kwh - mm.unit = HA_ENERGY_UNIT - mm.extra_attr["source"] = "mirrored_tx" - if best_item.context is not None: - mm.extra_attr[om.context.value] = best_item.context - if best_item.location is not None: - mm.extra_attr[om.location.value] = best_item.location - - # --- Step 2: Process non-EAIR measurands via existing pipeline --- - mv_wo_eair: list[list[MeasurandValue]] = [] - for bucket in meter_values: - filtered = [ - it - for it in bucket - if (it.measurand or DEFAULT_MEASURAND) != DEFAULT_MEASURAND - ] - if filtered: - mv_wo_eair.append(filtered) - self.process_measurands(mv_wo_eair, transaction_matches, connector_id) - - # --- Step 3: Update session metrics (time, energy) only for tx-bound EAIR --- - if tx_has_id and transaction_matches: - # Session time (minutes) — keep parity with previous behavior - tx_start = float( - self._metrics[(connector_id, csess.transaction_id.value)].value - or time.time() - ) + self.process_measurands(meter_values, transaction_matches, connector_id) + + if transaction_matches: + try: + tx_start_epoch = float(self._metrics[tx_key].value) + except (TypeError, ValueError): + tx_start_epoch = time.time() self._metrics[(connector_id, csess.session_time.value)].value = round( - (int(time.time()) - tx_start) / 60 + (int(time.time()) - tx_start_epoch) / 60 ) - self._metrics[ - (connector_id, csess.session_time.value) - ].unit = UnitOfTime.MINUTES - - # Session energy from tx-bound EAIR only - eair_kwh_in_msg: float | None = None - best_ctx_prio = -1 - for bucket in meter_values: - for item in bucket: - measurand = item.measurand or DEFAULT_MEASURAND - if measurand != DEFAULT_MEASURAND: - continue - if item.context == "Transaction.Begin": - continue - try: - val_kwh = float(cp.get_energy_kwh(item)) - except Exception: - continue - if val_kwh < 0.0 or (val_kwh != val_kwh): - continue - pr = _ctx_priority(item.context) - if (pr > best_ctx_prio) or ( - pr == best_ctx_prio - and (eair_kwh_in_msg is None or val_kwh > eair_kwh_in_msg) - ): - best_ctx_prio = pr - eair_kwh_in_msg = val_kwh - - if eair_kwh_in_msg is not None: - raw_start = self._metrics[(connector_id, csess.meter_start.value)].value - try: - meter_start_kwh = ( - float(raw_start) if raw_start is not None else None - ) - except Exception: - meter_start_kwh = None - - if meter_start_kwh is None: - # Initialize at first tx-bound EAIR; ABB starts at 0 which is desired here - self._metrics[ - (connector_id, csess.meter_start.value) - ].value = eair_kwh_in_msg - session_kwh = 0.0 - else: - session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh) - - self._metrics[ - (connector_id, csess.session_energy.value) - ].value = session_kwh - self._metrics[ - (connector_id, csess.session_energy.value) - ].unit = HA_ENERGY_UNIT + self._metrics[(connector_id, csess.session_time.value)].unit = "min" self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -1135,7 +1073,9 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): @on(Action.stop_transaction) def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): - """Stop the current transaction.""" + """Stop the current transaction (multi-connector).""" + + # Resolve connector from active tx map conn = next( (c for c, tx in self._active_tx.items() if tx == transaction_id), None ) @@ -1144,8 +1084,9 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): "Stop transaction received for unknown transaction id=%i", transaction_id, ) - conn = 1 + conn = 1 # conservative fallback + # Reset active transaction (global + per-connector) self._active_tx[conn] = 0 self.active_transaction_id = 0 @@ -1153,47 +1094,18 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): om.reason.name, None ) - self._metrics[(conn, cstat.id_tag.value)].value = "" - self._metrics[(conn, csess.transaction_id.value)].value = 0 - - use_eair_from_tx = bool(self._charger_reports_session_energy) - - if use_eair_from_tx: - sess_val = self._metrics[(conn, csess.session_energy.value)].value - if sess_val is None: - last_eair = self._metrics[(conn, DEFAULT_MEASURAND)].value - last_unit = self._metrics[(conn, DEFAULT_MEASURAND)].unit - try: - if last_eair is not None: - if last_unit == DEFAULT_ENERGY_UNIT: - eair_kwh = float(last_eair) / 1000.0 - elif last_unit == HA_ENERGY_UNIT: - eair_kwh = float(last_eair) - else: - eair_kwh = float(last_eair) - self._metrics[ - (conn, csess.session_energy.value) - ].value = eair_kwh - self._metrics[ - (conn, csess.session_energy.value) - ].unit = HA_ENERGY_UNIT - except Exception: - pass - else: - try: - meter_stop_kwh = float(meter_stop) / 1000.0 - except Exception: - meter_stop_kwh = 0.0 + ms_key = (conn, csess.meter_start.value) + if ( + self._metrics[ms_key].value is not None + and not self._charger_reports_session_energy + ): try: - meter_start_kwh = float( - self._metrics[(conn, csess.meter_start.value)].value or 0.0 + session_kwh = int(meter_stop) / 1000.0 - float( + self._metrics[ms_key].value ) except Exception: - meter_start_kwh = 0.0 - - session_kwh = max(0.0, meter_stop_kwh - meter_start_kwh) + session_kwh = 0.0 self._metrics[(conn, csess.session_energy.value)].value = session_kwh - self._metrics[(conn, csess.session_energy.value)].unit = HA_ENERGY_UNIT for meas in [ Measurand.current_import.value, @@ -1203,7 +1115,10 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): Measurand.power_active_export.value, Measurand.power_reactive_export.value, ]: - self._metrics[(conn, meas)].value = 0 + key = (conn, meas) + if key in self._metrics: + self._metrics[key].value = 0 + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 65d28e05..dad9bcad 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -456,19 +456,39 @@ async def start_transaction(self, connector_id: int = 1) -> bool: resp: call_result.RequestStartTransaction = await self.call(req) return resp.status == RequestStartStopStatusEnumType.accepted.value - async def stop_transaction(self) -> bool: - """Request remote stop of current transaction (default EVSE 1).""" + async def stop_transaction(self, connector_id: int | None = None) -> bool: + """Request remote stop of current transaction. + + If connector_id is provided, only stop the transaction running on that EVSE. + If connector_id is None, stop the first active transaction found (legacy behavior). + """ await self._get_inventory() - tx_id = "" - total = self._total_connectors() or 1 - for g in range(1, total + 1): - val = self._metrics[(g, csess.transaction_id.value)].value - if val: - tx_id = val - break + + # Determine total EVSEs (connectors) if available + total = int(self._total_connectors() or 1) + + tx_id: str | None = None + + if connector_id is not None: + # Per-connector stop: do NOT fall back to other EVSEs + evse = int(connector_id) + if evse < 1 or evse > total: + _LOGGER.info("Requested EVSE %s is out of range (1..%s)", evse, total) + return False + val = self._metrics[(evse, csess.transaction_id.value)].value + tx_id = str(val) if val else None + else: + # Global stop: find the first active transaction across EVSEs + for evse in range(1, total + 1): + val = self._metrics[(evse, csess.transaction_id.value)].value + if val: + tx_id = str(val) + break + if not tx_id: _LOGGER.info("No active transaction found to stop") return False + req: call.RequestStopTransaction = call.RequestStopTransaction( transaction_id=tx_id ) diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index ba8054aa..b9ed1729 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -67,6 +67,25 @@ class OcppSwitchDescription(SwitchEntityDescription): default_state=True, per_connector=False, ), + OcppSwitchDescription( + key="connnector_availability", + name="Connector Availability", + icon=ICON, + on_action=HAChargerServices.service_availability.name, + off_action=HAChargerServices.service_availability.name, + metric_state=HAChargerStatuses.status_connector.value, # connector-level status + metric_condition=[ + ChargePointStatus.available.value, + ChargePointStatus.preparing.value, + ChargePointStatus.charging.value, + ChargePointStatus.suspended_evse.value, + ChargePointStatus.suspended_ev.value, + ChargePointStatus.finishing.value, + ChargePointStatus.reserved.value, + ], + default_state=True, + per_connector=True, + ), ] @@ -105,6 +124,9 @@ async def async_setup_entry(hass, entry, async_add_devices): for desc in SWITCHES: if desc.per_connector: + # Only create Connector Availability switches for multi-connector chargers + if desc.key == "connnector_availability" and num_connectors <= 1: + continue for conn_id in range(1, num_connectors + 1): entities.append( ChargePointSwitch( diff --git a/docs/Charge_automation.md b/docs/Charge_automation.md index 9f9fbeed..63534020 100644 --- a/docs/Charge_automation.md +++ b/docs/Charge_automation.md @@ -2,26 +2,26 @@ Dynamically adjusting the charge current of an electric vehicle (EV) within a home automation system offers significant advantages: -* **Preventing Overload:** +* **Preventing Overload:** * By monitoring real-time energy consumption, you can automatically reduce the EV's charging rate to prevent overloading the household's electrical circuits and potentially tripping the main fuse. -* **Optimizing Solar Power Usage:** +* **Optimizing Solar Power Usage:** * When the solar panel production is available in Home Assistant, you can prioritize charging the EV with excess solar energy. -* **Demand Response:** +* **Demand Response:** * When you have dynamic energy pricing, you can adjust charging rates based on time-of-use electricity pricing. This page provides several examples and hints to illustrate some of the many potential use cases." ## Adjusting the charge current -When the OCPP integration is added to your Home Assistant, you get a slider to control the maximum charge current named: +When the OCPP integration is added to your Home Assistant, you get a slider to control the maximum charge current named (or one per connector, if your charger has multiple connectors): number._maximum_current While using this entity in your automation might seem logical, it could potentially lead to permanent damage to your charger in the long run. This entity controls the OCPP ChargePointMaxProfile, which configures the maximum power or current available for the entire charging station. This setting is typically written to non-volatile storage (like EEPROM or flash memory) to persist across reboots. -Frequent writes to these types of memory can accelerate wear, potentially shortening the lifespan of your charger. Ten updates per day is no problem at all, 1 update per 10s could break your charger somewhere between 3 days and 3 years depending on the HW solution. +Frequent writes to these types of memory can accelerate wear, potentially shortening the lifespan of your charger. Ten updates per day is no problem at all, 1 update per 10s could break your charger somewhere between 3 days and 3 years depending on the HW solution. ⚠️ **Warning**: Using the maximum current slider in automations can lead to permanent hardware damage due to frequent writes to non-volatile memory. @@ -33,7 +33,7 @@ Essentially, the slider in your GUI maintains control over the absolute maximum To dynamically set the session-specific charge current within an automation, use the following action code snippet: - - action: ocpp.set_charge_rate + - action: ocpp.set_charge_rate data: custom_profile: | { @@ -45,7 +45,7 @@ To dynamically set the session-specific charge current within an automation, use "chargingSchedule": { "chargingRateUnit": "A", "chargingSchedulePeriod": [ - {"startPeriod": 0, "limit": {{ states('entity_charge_limit') | int }}} + {"startPeriod": 0, "limit": {{ states('entity_charge_limit') | int }}} ] } } @@ -54,6 +54,8 @@ To dynamically set the session-specific charge current within an automation, use Where entity_charge_limit refers to your chosen entity (e.g., a number or sensor) that holds the desired current value. +NB! The action above only changes your current session limits. If you want to impact future sessions, run the same action, but replace "chargingProfilePurpose": "TxProfile" with "chargingProfilePurpose": "TxDefaultProfile". + ## solar current The solar system usually reports its production in Watts or kW. To convert this to amps available for your EV charger, simply divide the watts by the mains voltage (e.g., 230V for the EU) @@ -71,7 +73,7 @@ This sensor contains the solar current in amps rounded down to the nearest whole ## Smart-meter The paragraph above suggests that nearly all the solar current is prioritized for the EV charger. However, this can lead to situations where other high-demand appliances, such as a washing machine or hot tub, still draw power from the grid even when solar energy is available. -To avoid this, you can use the data provided by your smart meter sensors. By integrating smart meter data into your home automation system, you can dynamically adjust the EV charging rate based on real-time energy consumption. This ensures that the EV primarily charges using excess solar power while minimizing reliance on grid electricity during periods of high household demand.you might have it as power. +To avoid this, you can use the data provided by your smart meter sensors. By integrating smart meter data into your home automation system, you can dynamically adjust the EV charging rate based on real-time energy consumption. This ensures that the EV primarily charges using excess solar power while minimizing reliance on grid electricity during periods of high household demand.you might have it as power. For this it is important to know the current you deliver or receive from the grid. Depending on you smart meter sensors you might have this current available in a sensor or you might have this as power. The example below uses power to and from the grid and converts it to a current which is negative when receiving from the grid. In this example the power is in kW and the current is calculated using at actual mains voltage. @@ -81,7 +83,7 @@ This template sensor gives the right value: sensors: grid_current_available: # Calculate the current still available to use, can be negative value_template: >- - {{((states('sensor.p1_meter_p1_returned') | float - states('sensor.p1_meter_p1_power') | float )*1000 + {{((states('sensor.p1_meter_p1_returned') | float - states('sensor.p1_meter_p1_power') | float )*1000 / (states('sensor.p1_meter_p1_voltage') | float )) }} unit_of_measurement: 'A' device_class: current @@ -99,8 +101,8 @@ This could lead to a negative charge current, to avoid this create a new variabl The `max` filter ensures the charge current never goes below 0 amps, which would be invalid for the charging station. ## maximum charge -A simular solution could be used to check how much power is still available from the grid substracting all power used by other appliances in your house. This way you can charge you EV as fast as possible without overloading your main fuse. +A simular solution could be used to check how much power is still available from the grid substracting all power used by other appliances in your house. This way you can charge you EV as fast as possible without overloading your main fuse. -:exclamation: By specificatiomn your main fuse can withand 1.2 its rate current for at least 1 hour +:exclamation: By specificatiomn your main fuse can withand 1.2 its rate current for at least 1 hour So a response time up to a minute is usually no problem if the overload is limited. diff --git a/docs/installation.md b/docs/installation.md index ee493166..598a1956 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -62,10 +62,11 @@ If you do not use HTTPS for your Home Assistant instance: - If you have configured _Secure connection_ in previous step, you should use 'wss://' - Some chargers require the url to be specified as an IP address, i.e. '192.168.178.1:9000' - You may need to reboot your charger before the changes become effective. +- If your charger has multiple connectors, wait until the charger device has populated its data (e.g., the Connectors sensor). Then reload the integration. A separate device will be created for each connector, and you’ll find all connector-specific entities there. ![image](https://user-images.githubusercontent.com/8673442/129495720-2ed9f0d6-b736-409a-8e14-fbd447dea078.png) -## Start Charging +## Start Charging - Use the charge control switch to start the charging process. ![image](https://user-images.githubusercontent.com/8673442/129495891-91f40bf9-f48e-4ced-b303-bf0fb77898f3.png) diff --git a/docs/support.md b/docs/support.md index e450d829..4c6ecd48 100644 --- a/docs/support.md +++ b/docs/support.md @@ -37,4 +37,18 @@ action: notification_id: "{{ trigger.notification.notification_id }}" mode: parallel max: 10 -``` \ No newline at end of file +``` + +### unstable behavior when setting the charger maximum current + +If your charger is acting strange when you're changing the maximum current, or sending an ocpp.set_charge_rate action, it might help to clear the charging profiles from the charger. + +Run the following action (from Developer Tools): + +``` + - action: ocpp.clear_profile + data: + devid: charger +``` + +Where charger refers to your selected charger device identity. diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 6d9d890c..9835e5da 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -50,6 +50,9 @@ This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC ( ## [Alfen - Eve Single S-line](https://alfen.com/en/ev-charge-points/alfen-product-range) +## [CTEK Chargestorm Connected 1, dual connectors](https://www.ctek.com/uk/ev-charging/chargestorm-connected-1) +See CTEK Chargestorm Connected 2 below for getting started instructions. + ## [CTEK Chargestorm Connected 2](https://www.ctek.com/uk/ev-charging/chargestorm%C2%AE-connected-2) [Jonas Karlsson](https://github.com/jonasbkarlsson) has written a [getting started guide](https://github.com/jonasbkarlsson/ocpp/wiki/CTEK-Chargestorm-Connected-2) for connecting CTEK Chargestorm Connected 2. diff --git a/docs/user-guide.md b/docs/user-guide.md index 11138616..cd185802 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -23,7 +23,46 @@ The `Charge point identity` shown above with a default of `charger` is a little Measurands (according to OCPP terminology) are actually metrics provided by the charger. Each charger supports a subset of the available metrics and for each one supported, a sensor entity is available in HA. Some of these sensor entities will give erroneous readings whilst others give no readings at all. Sensor entities not supported by the charger will show as `Unknown` if you try to create a sensor entity for them. Below is a table of the metrics I've found useful for the Wallbox Pulsar Plus. Tables for other chargers will follow as contributions come in from owners of each supported charger. -OCPP integration can automatically detect supported measurands. However, some chargers have faulty firmware that causes the detection mechanism to fail. For such chargers, it is possible to disable automatic measurand detection and manually set the measurands to those supported by the charger. When set manually, selected measurands are not checked for compatibility with the charger and are requested from it. See below for OCPP compliance notes and charger-specific instructions in [supported devices](supported-devices.md). +OCPP integration can automatically detect supported measurands. However, some chargers have faulty firmware that causes the detection mechanism to fail. For such chargers, it is possible to disable automatic measurand detection and manually set the measurands to those supported by the charger. When set manually, selected measurands are not checked for compatibility with the charger and are requested from it. See below for OCPP compliance notes and charger-specific instructions in [supported devices](supported-devices). + +For chargers with multiple connectors (outlets), the OCPP integration will create one device per connector, named `charger Connector 1`, `charger Connector 2` etc. All measurands and other entities (buttons, numbers, switches, diagnostics sensors) that are connector-specific per the OCPP standard will be found on these devices. + +## Understanding status + +Your charger exposes a connector status sensor: +* Single-connector: `sensor._status_connector` +* Multi-connector: `sensor._connector__status_connector` + +For OCPP 1.6, the sensor can show these values: + +* **Available** – No EV is connected; the connector is free. +* **Preparing** – EV is connected and/or authenticated but charging hasn’t started yet (handshake, cable lock, internal checks). +* **Charging** – Energy is being delivered. +* **SuspendedEV** – The EV has paused energy transfer (e.g., target SoC reached, schedule, thermal limit). +* **SuspendedEVSE** – The charger has paused energy transfer (e.g., power limit, smart charging profile, grid signal). +* **Finishing** – Charging has stopped, but the session isn’t fully closed yet (typically waiting for the cable to be unplugged). +* **Reserved** – The connector is reserved (via ReserveNow); only the intended user/ID may start. This is not supported by the OCPP integration yet. +* **Unavailable** – Intentionally set out of service (e.g., ChangeAvailability(Inoperative)) or temporarily not usable. (Entities remain available in Home Assistant.) +* **Faulted** – A fault prevents charging (e.g., ground fault, over-temp, lock error). Check the sensor errorCode for details. + +Note +In OCPP 1.6, `connectorId = 0` (station level) only uses Available, Unavailable, or Faulted.
+In OCPP 2.0.1, connector status is simplified to Available / Occupied / Reserved / Unavailable / Faulted; “Preparing/Finishing” are reflected in TransactionEvent rather than as connector statuses. + +If your integration shows extra attributes on the connector status sensor like availability_change or availability_pending, they indicate that a status change (e.g., after ChangeAvailability) has been accepted or scheduled and will take effect once current conditions allow (e.g., after an active session ends). + +## Changing availability + +* **Availability (charger-level) switch**
+ Sets the entire charger to `Unavailable` (station-level). All idle connectors switch to `Unavailable` immediately. Any connector with an ongoing session is marked as scheduled and will switch to `Unavailable` after the session ends. + +* **Availability (per-connector) switches**
+ Set a specific connector to `Unavailable`. If that connector currently has an ongoing session, the change is scheduled and will take effect once the session ends. + +* **Charge Control switch**
+ Turning off ends the ongoing charging session (remote stop). The connector typically transitions to `Finishing` and then back to its normal idle state once the cable is unplugged. Turning the switch on again resets the session metrics; the charger returns to its previous state (this does not force a new session to start). + + ## Useful Entities for Wallbox Pulsar Plus diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 327d3426..48a899e3 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -52,7 +52,7 @@ async def start_transaction(self, connector_id=None): self.calls.append(("start_transaction", {"connector_id": connector_id})) return True - async def stop_transaction(self): + async def stop_transaction(self, connector_id: int | None = None): """Stop transaction.""" self.calls.append(("stop_transaction", {})) return True @@ -232,7 +232,8 @@ async def test_get_available_paths(hass): cp = _install_dummy_cp(cs, status=STATE_UNAVAILABLE) assert cs.get_available("test_cpid", connector_id=0) is False - # specific connector via per-connector metric + # specific connector via per-connector metric, charger available + cp = _install_dummy_cp(cs, status=STATE_OK) meas = cstat.status_connector.value cp._metrics[(1, meas)] = M("Charging", None) assert cs.get_available("test_cpid", connector_id=1) is True diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index affb785c..af4af8ae 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -21,16 +21,11 @@ CONF_CPIDS, CONF_CPID, CONF_NUM_CONNECTORS, - DEFAULT_ENERGY_UNIT, - DEFAULT_MEASURAND, - HA_ENERGY_UNIT, ) from custom_components.ocpp.enums import ( - ConfigurationKey, + ConfigurationKey as ckey, HAChargerDetails as cdet, HAChargerServices as csvcs, - HAChargerStatuses as cstat, - HAChargerSession as csess, Profiles as prof, ) from custom_components.ocpp.number import NUMBERS @@ -51,7 +46,6 @@ DataTransferStatus, DiagnosticsStatus, FirmwareStatus, - Measurand, Phase, RegistrationStatus, RemoteStartStopStatus, @@ -567,21 +561,23 @@ async def test_cms_responses_actions_v16( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"], ) as ws: - # use a different id for debugging - cp = ChargePoint(f"{cp_id}_client", ws) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_start_transaction(0), - cp.send_meter_energy_kwh(), - cp.send_meter_clock_data(), - # add delay to allow meter data to be processed - cp.send_stop_transaction(2), - ), - timeout=5, - ) + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await cp.send_start_transaction(0) + await cp.send_meter_energy_kwh() + await cp.send_meter_clock_data() + # add delay to allow meter data to be processed + await asyncio.sleep(0.05) + await cp.send_stop_transaction(2) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == 1101 @@ -813,108 +809,6 @@ async def set_report_session_energyreport( set_report_session_energyreport.__test__ = False -# @pytest.mark.skip(reason="skip") -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9010, "cp_id": "CP_1_stop_paths", "cms": "cms_stop_paths"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths"]) -@pytest.mark.parametrize("port", [9010]) -async def test_stop_transaction_paths_v16_a( - hass, socket_enabled, cp_id, port, setup_config_entry -): - """Exercise all branches of ocppv16.on_stop_transaction.""" - cs: CentralSystem = setup_config_entry - - # - # SCENARIO A: _charger_reports_session_energy = True and SessionEnergy is None - # Use last Energy.Active.Import.Register to populate SessionEnergy. - # - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - cs.charge_points[cp_id]._charger_reports_session_energy = True - - # Ensure there is an active tx so stop is accepted - await cp.send_start_transaction(meter_start=0) - - # Force SessionEnergy to be None before stop - m = cs.charge_points[cp_id]._metrics - m[(1, "Energy.Session")].value = None # connector 1 - - # Case A1: last EAIR in Wh → should convert to kWh - m[(1, "Energy.Active.Import.Register")].value = 1300000 # Wh - m[(1, "Energy.Active.Import.Register")].unit = "Wh" - - await cp.send_stop_transaction(delay=0) - - cpid = cs.charge_points[cp_id].settings.cpid - sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) - assert round(sess, 3) == 1300000 / 1000.0 - assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" - - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - -# @pytest.mark.skip(reason="skip") -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9021, "cp_id": "CP_1_stop_paths_a1", "cms": "cms_stop_paths_a1"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_a1"]) -@pytest.mark.parametrize("port", [9021]) -async def test_stop_transaction_paths_v16_a1( - hass, socket_enabled, cp_id, port, setup_config_entry -): - """Exercise all branches of ocppv16.on_stop_transaction.""" - cs: CentralSystem = setup_config_entry - - # - # SCENARIO A (variant): charger reports session energy AND last EAIR already kWh. - # - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - cs.charge_points[cp_id]._charger_reports_session_energy = True - await cp.send_start_transaction(meter_start=0) - - m = cs.charge_points[cp_id]._metrics - m[(1, "Energy.Session")].value = None - m[(1, "Energy.Active.Import.Register")].value = 42.5 # already kWh - m[(1, "Energy.Active.Import.Register")].unit = "kWh" - - await cp.send_stop_transaction(delay=0) - - cpid = cs.charge_points[cp_id].settings.cpid - sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) - assert round(sess, 3) == 42.5 - assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" - - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - # @pytest.mark.skip(reason="skip") @pytest.mark.timeout(20) @pytest.mark.parametrize( @@ -1025,124 +919,6 @@ async def test_stop_transaction_paths_v16_c( await ws.close() -# @pytest.mark.skip(reason="skip") -@pytest.mark.timeout(30) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9061, "cp_id": "CP_1_meter_paths", "cms": "cms_meter_paths"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_1_meter_paths"]) -@pytest.mark.parametrize("port", [9061]) -async def test_on_meter_values_paths_v16( - hass, socket_enabled, cp_id, port, setup_config_entry -): - """Exercise important branches of ocppv16.on_meter_values, deterministically.""" - cs: CentralSystem = setup_config_entry - - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - - cp_task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - srv = cs.charge_points[cp_id] - cpid = srv.settings.cpid - - # 1) Start a transaction with meter_start = 10000 Wh (10.0 kWh) - await cp.send_start_transaction(meter_start=10000) - - async def _wait_until(cond, timeout=2.0, step=0.01): - import time - - end = time.monotonic() + timeout - while time.monotonic() < end: - if cond(): - return True - await asyncio.sleep(step) - return False - - assert await _wait_until( - lambda: ( - srv._metrics[(1, "Energy.Meter.Start")].value == 10.0 - and (srv.active_transaction_id or 0) != 0 - ), - timeout=2.0, - ), "Server never persisted meter_start=10.0 and active_transaction_id" - - active_tx = srv.active_transaction_id - assert active_tx != 0 - - # 2) MAIN METER without tx id -> updates connector 0 in kWh - await cp.send_main_meter_clock_data() - agg_eair = float( - cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=0) - ) - assert agg_eair == pytest.approx(67230012 / 1000.0, rel=1e-6) - assert ( - cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=0) - == "kWh" - ) - - # 3) Set meter_start to 12.5 kWh - m = srv._metrics - m[(1, "Energy.Meter.Start")].value = 12.5 - m[(1, "Energy.Meter.Start")].unit = "kWh" - m[(1, "Transaction.Id")].value = active_tx - - # 4) Send MV with tx id and EAIR=15000 Wh (15.0 kWh) + empty PAI -> 0.0 - mv = call.MeterValues( - connector_id=1, - transaction_id=active_tx, - meter_value=[ - { - "timestamp": datetime.now(tz=UTC).isoformat(), - "sampledValue": [ - { - "value": "15000", - "measurand": "Energy.Active.Import.Register", - "unit": "Wh", - "location": "Outlet", - "context": "Sample.Periodic", - }, - { - "value": "", - "measurand": "Power.Active.Import", - "unit": "W", - "context": "Sample.Periodic", - }, - ], - } - ], - ) - resp = await cp.call(mv) - assert resp is not None - - # meter_start reset from 12.5 kWh → session = 15.0 - 12.5 = 2.5 kWh - sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) - assert sess == pytest.approx(2.5, rel=1e-6) - assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" - - # Empty string → 0.0 - pai = float(cs.get_metric(cpid, "Power.Active.Import", connector_id=1)) - assert pai == 0.0 - - # Tx id reset - tx_restored = int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) - assert tx_restored == active_tx - - finally: - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() - - @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", @@ -1214,17 +990,17 @@ def fake_get_ha_metric(name: str, connector_id: int | None = None): assert srv._metrics[(1, "Transaction.Id")].value == 123456 assert srv._active_tx.get(1, 0) == 123456 - # Aggregate EAIR (connector 0) updated to 15.0 kWh with attrs. - assert srv._metrics[(0, "Energy.Active.Import.Register")].value == 15.0 - assert srv._metrics[(0, "Energy.Active.Import.Register")].unit == "kWh" + # EAIR (connector 1) updated to 15.0 kWh with attrs. + assert srv._metrics[(1, "Energy.Active.Import.Register")].value == 15.0 + assert srv._metrics[(1, "Energy.Active.Import.Register")].unit == "kWh" assert ( - srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get( + srv._metrics[(1, "Energy.Active.Import.Register")].extra_attr.get( "location" ) == "Inlet" ) assert ( - srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get("context") + srv._metrics[(1, "Energy.Active.Import.Register")].extra_attr.get("context") == "Sample.Clock" ) @@ -2325,459 +2101,109 @@ def fake_async_create_task(coro): await ws.close() -@pytest.mark.timeout(15) +@pytest.mark.timeout(30) @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9077, "cp_id": "CP_stop_hdl", "cms": "cms_stop_hdl"}], + [{"port": 9077, "cp_id": "CP_phases", "cms": "cms_phases"}], indirect=True, ) -@pytest.mark.parametrize("cp_id", ["CP_stop_hdl"]) +@pytest.mark.parametrize("cp_id", ["CP_phases"]) @pytest.mark.parametrize("port", [9077]) -async def test_on_stop_transaction_paths( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +@pytest.mark.parametrize("num_connectors", [1, 2]) +async def test_current_import_phase_extra_attrs_single_and_multi_connector( + hass, socket_enabled, cp_id, port, setup_config_entry, num_connectors ): - """Test ocppv16.on_stop_transaction. + """Verify that phase extra attributes (L1/L2/L3) for Current.Import are populated. - 1) Normal routed call (valid payload) with unknown tx -> falls back to conn=1 and - exception on meter_start only. - 2) Direct handler call to cover the exception path on meter_stop (string) - and the EAIR-derived branch’s conversion error. - Also verify currents/powers are zeroed and HA update is scheduled. + - with 1 connector: reading without connector_id should resolve via fallback. + - with 2 connectors: each connector returns its own phase set. """ cs: CentralSystem = setup_config_entry async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", - subprotocols=["ocpp1.6"], + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] ) as ws: - # Minimal client to start the protocol task and register the CP - cli = ChargePoint(f"{cp_id}_client", ws) - task = asyncio.create_task(cli.start()) - spawned_tasks: list[asyncio.Task] = [] - scheduled = {"n": 0} + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) try: - await cli.send_boot_notification() + # Boot and wait until server is ready to receive MeterValues + await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) - srv: ServerCP = cs.charge_points[cp_id] + # Server-side CP instance + srv_cp: ServerCP = cs.charge_points[cp_id] + # Force connector count for this test parameterization + srv_cp.num_connectors = num_connectors - # Keep HA quiet; count scheduled updates instead of running them - scheduled = {"n": 0} + # Helper to send a MeterValues frame with phase currents + async def send_current_import_phases( + connector_id: int, l1: float, l2: float, l3: float + ): + ts = datetime.now(UTC).isoformat() + req = call.MeterValues( + connector_id=connector_id, + meter_value=[ + { + "timestamp": ts, + "sampledValue": [ + { + "measurand": "Current.Import", + "phase": Phase.l1.value, + "unit": "A", + "value": str(l1), + }, + { + "measurand": "Current.Import", + "phase": Phase.l2.value, + "unit": "A", + "value": str(l2), + }, + { + "measurand": "Current.Import", + "phase": Phase.l3.value, + "unit": "A", + "value": str(l3), + }, + ], + } + ], + ) + # Send to server + await cp.call(req) - def fake_async_create_task(target, *args, **kwargs): - """Intercept HA task scheduling. + # Send phases for connector 1 + await send_current_import_phases(1, 5.0, 7.0, 8.0) - - If the target is the cp.update(...) coroutine, close it so it never runs - - Otherwise, create a real asyncio task so nothing else in the loop breaks. - """ - scheduled["n"] += 1 + # If two connectors, send different phases for connector 2 + if num_connectors == 2: + await send_current_import_phases(2, 11.0, 13.0, 17.0) - if inspect.iscoroutine(target): - co = getattr(target, "cr_code", None) - name = getattr(co, "co_name", "") if co else "" + # Let server handlers run + await asyncio.sleep(0) - if name == "update": - target.close() - t = asyncio.create_task(asyncio.sleep(0)) - spawned_tasks.append(t) - return t + # Assertions + if num_connectors == 1: + # Without connector_id -> should resolve (fallback) to connector 1 + attrs = cs.get_extra_attr(cp_id, "Current.Import", connector_id=None) + assert ( + attrs is not None + ), "Expected extra_attr dict for single-connector" + assert attrs.get("L1") == 5.0 + assert attrs.get("L2") == 7.0 + assert attrs.get("L3") == 8.0 - t = asyncio.create_task(target) - spawned_tasks.append(t) - return t + # Explicit connector_id=1 also works + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + assert attrs1 is not None + assert attrs1.get("L1") == 5.0 + assert attrs1.get("L2") == 7.0 + assert attrs1.get("L3") == 8.0 - t = asyncio.create_task(asyncio.sleep(0)) - spawned_tasks.append(t) - return t - - monkeypatch.setattr( - srv.hass, "async_create_task", fake_async_create_task, raising=True - ) - - # Ensure connector 1 metrics exist - _ = srv._metrics[(1, cstat.stop_reason.value)] - _ = srv._metrics[(1, csess.meter_start.value)] - _ = srv._metrics[(1, DEFAULT_MEASURAND)] - _ = srv._metrics[(1, csess.session_energy.value)] - for m in [ - Measurand.current_import.value, - Measurand.power_active_import.value, - Measurand.power_reactive_import.value, - Measurand.current_export.value, - Measurand.power_active_export.value, - Measurand.power_reactive_export.value, - ]: - _ = srv._metrics[(1, m)] - - # ------------------------------------------------------------------ - # (A) Routed normal call: unknown tx -> conn is None path; make meter_start - # non-numeric to hit that exception (meter_stop remains valid int). - # ------------------------------------------------------------------ - unknown_tx = 999_001 - srv._active_tx = {} # ensures lookup fails -> fallback to conn=1 - srv.active_transaction_id = 0 - - # Force meter_start conversion failure - srv._metrics[(1, csess.meter_start.value)].value = "not-a-number" - - stop_req = call.StopTransaction( - transaction_id=unknown_tx, - meter_stop=12345, - timestamp="2024-01-01T00:00:00Z", - reason="Local", - ) - stop_resp = await cli.call(stop_req) - assert isinstance(stop_resp, call_result.StopTransaction) - - # Session energy is derived from meter_stop (12.345 kWh) minus - # meter_start (conversion failed -> 0.0) = 12.345 - val = srv._metrics[(1, csess.session_energy.value)].value - unit = srv._metrics[(1, csess.session_energy.value)].unit - assert val == pytest.approx(12.345, rel=1e-6) - assert unit == HA_ENERGY_UNIT - - # Zeroing of currents/powers - for m in [ - Measurand.current_import.value, - Measurand.power_active_import.value, - Measurand.power_reactive_import.value, - Measurand.current_export.value, - Measurand.power_active_export.value, - Measurand.power_reactive_export.value, - ]: - assert srv._metrics[(1, m)].value == 0 - - assert scheduled["n"] >= 1 # update(...) scheduled - - # ------------------------------------------------------------------ - # (B) Direct handler call to cover: - # - meter_stop conversion exception (string) - # - EAIR-based branch with conversion error - # ------------------------------------------------------------------ - # Prepare connector 2 - _ = srv._metrics[(2, DEFAULT_MEASURAND)] - _ = srv._metrics[(2, csess.session_energy.value)] - _ = srv._metrics[(2, csess.meter_start.value)] - - # Choose EAIR-based route - srv._charger_reports_session_energy = True - # No precomputed session value so handler tries to derive from last EAIR - srv._metrics[(2, csess.session_energy.value)].value = None - srv._metrics[(2, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT - # Make EAIR non-convertible to float -> triggers exception inside EAIR branch - srv._metrics[(2, DEFAULT_MEASURAND)].value = "NaN-err" - - # Map tx to connector 2 (so conn is found and not None) - known_tx = 222_333 - srv._active_tx = {2: known_tx} - srv.active_transaction_id = known_tx - - # Call handler directly to bypass OCPP schema and send bad meter_stop - # NOTE: This is intentional to exercise the internal try/except on meter_stop. - direct_resp = srv.on_stop_transaction( - meter_stop="bad-int", # triggers exception -> 0.0 if fallback path used - timestamp="2024-01-01T00:00:01Z", - transaction_id=known_tx, - reason="Local", - ) - assert isinstance(direct_resp, call_result.StopTransaction) - - # EAIR conversion failed; code swallows the exception and leaves session possibly unset - assert srv._metrics[(2, csess.session_energy.value)].value in (None,) - - # Currents/powers should be zeroed on connector 2 as well - for m in [ - Measurand.current_import.value, - Measurand.power_active_import.value, - Measurand.power_reactive_import.value, - Measurand.current_export.value, - Measurand.power_active_export.value, - Measurand.power_reactive_export.value, - ]: - _ = srv._metrics[(2, m)] - assert srv._metrics[(2, m)].value == 0 - - finally: - for t in spawned_tasks: - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await ws.close() - - -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9082, "cp_id": "CP_stop_eair_wh", "cms": "cms_stop_eair_wh"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_stop_eair_wh"]) -@pytest.mark.parametrize("port", [9082]) -async def test_on_stop_transaction_eair_unit_wh( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """Test on_stop_transaction EAIR branch with last_unit == Wh and last_eair set. - - Covers the branch where eair_kwh = float(last_eair) / 1000.0. - """ - - cs: CentralSystem = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cli = ChargePoint(f"{cp_id}_client", ws) - task = asyncio.create_task(cli.start()) - - try: - await cli.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - srv: ServerCP = cs.charge_points[cp_id] - - # Prepare connector 1 metrics - _ = srv._metrics[(1, csess.session_energy.value)] - _ = srv._metrics[(1, DEFAULT_MEASURAND)] - _ = srv._metrics[(1, csess.meter_start.value)] - - # Force EAIR branch - srv._charger_reports_session_energy = True - srv._metrics[(1, csess.session_energy.value)].value = None - srv._metrics[(1, DEFAULT_MEASURAND)].unit = DEFAULT_ENERGY_UNIT - # Here: set a Wh value to trigger the branch - srv._metrics[(1, DEFAULT_MEASURAND)].value = 12345 # Wh = 12.345 kWh - - # Map tx → connector 1 - tx_id = 222 - srv._active_tx = {1: tx_id} - srv.active_transaction_id = tx_id - - # Prevent lingering post_connect job during teardown - srv.post_connect_success = True - - async def _noop(): # don't start background work in tests - return None - - monkeypatch.setattr(srv, "post_connect", _noop, raising=True) - - def _schedule(target, *args, **kwargs): - # Always schedule the coroutine; ignore HA's optional args (name/eager_start) - return asyncio.create_task(target) - - # Patch both the server CP’s hass and the root hass to be safe - monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) - monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) - - # Call handler directly - resp = srv.on_stop_transaction( - meter_stop=99999, # ignored in EAIR branch - timestamp="2024-01-01T00:00:01Z", - transaction_id=tx_id, - reason="Local", - ) - assert isinstance(resp, call_result.StopTransaction) - - # Session energy should now be set to 12.345 kWh - val = srv._metrics[(1, csess.session_energy.value)].value - unit = srv._metrics[(1, csess.session_energy.value)].unit - assert val == pytest.approx(12.345, rel=1e-6) - assert unit == HA_ENERGY_UNIT - - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await ws.close() - - -@pytest.mark.timeout(30) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9083, "cp_id": "CP_stop_eair_kwh", "cms": "cms_stop_eair_kwh"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_stop_eair_kwh"]) -@pytest.mark.parametrize("port", [9083]) -async def test_on_stop_transaction_eair_unit_kwh( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """EAIR branch where last_unit == kWh and last_eair has a value. - - Verifies that session energy is copied as-is (already in kWh), - and avoids warnings by scheduling the HA update and disabling post_connect. - """ - cs: CentralSystem = setup_config_entry - - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cli = ChargePoint(f"{cp_id}_client", ws) - task = asyncio.create_task(cli.start()) - - try: - # Boot so the server registers this CP - await cli.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - srv: ServerCP = cs.charge_points[cp_id] - - # Prevent lingering post_connect job during teardown - srv.post_connect_success = True - - async def _noop(): # don't start background work in tests - return None - - monkeypatch.setattr(srv, "post_connect", _noop, raising=True) - - def _schedule(target, *args, **kwargs): - # Always schedule the coroutine; ignore HA's optional args (name/eager_start) - return asyncio.create_task(target) - - # Patch both the server CP’s hass and the root hass to be safe - monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) - monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) - - # Prepare connector 1 metrics for the EAIR branch - _ = srv._metrics[(1, csess.session_energy.value)] - _ = srv._metrics[(1, DEFAULT_MEASURAND)] - _ = srv._metrics[(1, csess.meter_start.value)] - - srv._charger_reports_session_energy = True - srv._metrics[(1, csess.session_energy.value)].value = None - srv._metrics[(1, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT - srv._metrics[(1, DEFAULT_MEASURAND)].value = 12.345 # already kWh - - # Map tx → connector 1 so the handler resolves conn=1 - tx_id = 333 - srv._active_tx = {1: tx_id} - srv.active_transaction_id = tx_id - - # Call handler directly to exercise the branch - resp = srv.on_stop_transaction( - meter_stop=99999, # ignored in EAIR branch - timestamp="2024-01-01T00:00:01Z", - transaction_id=tx_id, - reason="Local", - ) - assert isinstance(resp, call_result.StopTransaction) - - # Expect the EAIR value to be copied to session energy (kWh) - val = srv._metrics[(1, csess.session_energy.value)].value - unit = srv._metrics[(1, csess.session_energy.value)].unit - assert val == pytest.approx(12.345, rel=1e-6) - assert unit == HA_ENERGY_UNIT - - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await ws.close() - - -@pytest.mark.timeout(30) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9077, "cp_id": "CP_phases", "cms": "cms_phases"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_phases"]) -@pytest.mark.parametrize("port", [9077]) -@pytest.mark.parametrize("num_connectors", [1, 2]) -async def test_current_import_phase_extra_attrs_single_and_multi_connector( - hass, socket_enabled, cp_id, port, setup_config_entry, num_connectors -): - """Verify that phase extra attributes (L1/L2/L3) for Current.Import are populated. - - - with 1 connector: reading without connector_id should resolve via fallback. - - with 2 connectors: each connector returns its own phase set. - """ - cs: CentralSystem = setup_config_entry - - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - - try: - # Boot and wait until server is ready to receive MeterValues - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - # Server-side CP instance - srv_cp: ServerCP = cs.charge_points[cp_id] - # Force connector count for this test parameterization - srv_cp.num_connectors = num_connectors - - # Helper to send a MeterValues frame with phase currents - async def send_current_import_phases( - connector_id: int, l1: float, l2: float, l3: float - ): - ts = datetime.now(UTC).isoformat() - req = call.MeterValues( - connector_id=connector_id, - meter_value=[ - { - "timestamp": ts, - "sampledValue": [ - { - "measurand": "Current.Import", - "phase": Phase.l1.value, - "unit": "A", - "value": str(l1), - }, - { - "measurand": "Current.Import", - "phase": Phase.l2.value, - "unit": "A", - "value": str(l2), - }, - { - "measurand": "Current.Import", - "phase": Phase.l3.value, - "unit": "A", - "value": str(l3), - }, - ], - } - ], - ) - # Send to server - await cp.call(req) - - # Send phases for connector 1 - await send_current_import_phases(1, 5.0, 7.0, 8.0) - - # If two connectors, send different phases for connector 2 - if num_connectors == 2: - await send_current_import_phases(2, 11.0, 13.0, 17.0) - - # Let server handlers run - await asyncio.sleep(0) - - # Assertions - if num_connectors == 1: - # Without connector_id -> should resolve (fallback) to connector 1 - attrs = cs.get_extra_attr(cp_id, "Current.Import", connector_id=None) - assert ( - attrs is not None - ), "Expected extra_attr dict for single-connector" - assert attrs.get("L1") == 5.0 - assert attrs.get("L2") == 7.0 - assert attrs.get("L3") == 8.0 - - # Explicit connector_id=1 also works - attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) - assert attrs1 is not None - assert attrs1.get("L1") == 5.0 - assert attrs1.get("L2") == 7.0 - assert attrs1.get("L3") == 8.0 - - else: - # Two connectors: verify separation - attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) - attrs2 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=2) + else: + # Two connectors: verify separation + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + attrs2 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=2) assert ( attrs1 is not None and attrs2 is not None @@ -3146,7 +2572,7 @@ async def test_on_meter_values_no_tx_aggregate_ignores_begin_and_converts_wh( ) await cp.call(req) - val = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=0) + val = cs.get_metric(cp_id, "Energy.Active.Import.Register") assert val == pytest.approx(4.369, rel=1e-6) finally: @@ -3298,107 +2724,6 @@ async def test_on_meter_values_priority_end_over_periodic( await ws.close() -@pytest.mark.timeout(10) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9100, "cp_id": "CP_eair_sanitize", "cms": "cms_eair_sanitize"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_eair_sanitize"]) -@pytest.mark.parametrize("port", [9100]) -async def test_on_meter_values_sanitizes_and_ignores_exceptions( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """NaN & negatives ignored; get_energy_kwh exception ignored; Periodic finally wins.""" - - cs: CentralSystem = setup_config_entry - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - from custom_components.ocpp import ocppv16 as mod_v16 - - orig_get_e_kwh = mod_v16.cp.get_energy_kwh - - def flaky_get_energy_kwh(item): - try: - if ( - getattr(item, "context", None) == "Sample.Clock" - and getattr(item, "unit", None) in ("kWh", "Wh") - and float(getattr(item, "value", -1)) == 1234.0 - ): - raise ValueError("simulated conversion error") - except Exception: - pass - return orig_get_e_kwh(item) - - cp = ChargePoint(f"{cp_id}_client", ws) - task = asyncio.create_task(cp.start()) - try: - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - monkeypatch.setattr( - mod_v16.cp, "get_energy_kwh", flaky_get_energy_kwh, raising=True - ) - - req = call.MeterValues( - connector_id=1, - transaction_id=333, - meter_value=[ - { - "timestamp": datetime.now(tz=UTC).isoformat(), - "sampledValue": [ - # 1) NaN -> should be ignored - { - "measurand": "Energy.Active.Import.Register", - "context": "Sample.Clock", - "unit": "kWh", - "value": "NaN", - }, - # 2) Negativt -> should be ignored - { - "measurand": "Energy.Active.Import.Register", - "context": "Sample.Clock", - "unit": "kWh", - "value": "-1", - }, - # 3) Valid, but the patch will let get_energy_kwh throw -> ignored by except - { - "measurand": "Energy.Active.Import.Register", - "context": "Sample.Clock", - "unit": "kWh", - "value": "1234", - }, - # 4) Finally a good Periodic that is chosen - { - "measurand": "Energy.Active.Import.Register", - "context": "Sample.Periodic", - "unit": "kWh", - "value": "3.2", - }, - ], - } - ], - ) - await cp.call(req) - - v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) - s = cs.get_metric(cp_id, "Energy.Session", connector_id=1) - assert v == pytest.approx(3.2, rel=1e-6) - assert s == pytest.approx(0.0, rel=1e-6) - - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await ws.close() - - # Restore the patch - with contextlib.suppress(Exception): - monkeypatch.setattr( - mod_v16.cp, "get_energy_kwh", orig_get_e_kwh, raising=True - ) - - @pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", @@ -3447,7 +2772,7 @@ async def test_on_meter_values_priority_beats_raw_value( ) await cp.call(req) - v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=0) + v = cs.get_metric(cp_id, "Energy.Active.Import.Register") assert v == pytest.approx(1.0, rel=1e-6) finally: @@ -4278,17 +3603,16 @@ async def boom_n(self): @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9340, "cp_id": "CP_cov_abb_tx_reset", "cms": "cms_services"}], + [{"port": 9341, "cp_id": "CP_cov_ctx_priority", "cms": "cms_services"}], indirect=True, ) -@pytest.mark.parametrize("cp_id", ["CP_cov_abb_tx_reset"]) -@pytest.mark.parametrize("port", [9340]) -async def test_abb_new_tx_resets_eair_and_meter_start( +@pytest.mark.parametrize("cp_id", ["CP_cov_ctx_priority"]) +@pytest.mark.parametrize("port", [9341]) +async def test_eair_context_priority_in_bucket( hass, socket_enabled, cp_id, port, setup_config_entry ): - """ABB: when a new transactionId appears, per-connector EAIR and meter_start are cleared so a lower EAIR (e.g. 0 Wh) is accepted.""" + """Ensure EAIR context priority per bucket: Transaction.End > Sample.Periodic > Sample.Clock.""" cs = setup_config_entry - async with websockets.connect( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] ) as ws: @@ -4300,119 +3624,96 @@ async def test_abb_new_tx_resets_eair_and_meter_start( srv = cs.charge_points[cp_id] cpid = srv.settings.cpid - # --- Seed previous session on connector 1 with EAIR = 15000 Wh (15.0 kWh), txId=111 --- - mv_tx1 = call.MeterValues( + # Bucket 1: include three EAIR candidates with different contexts. + # Expect: Transaction.End (13000 Wh) wins -> 13.0 kWh. + mv_bucket1 = call.MeterValues( connector_id=1, - transaction_id=111, + transaction_id=555, meter_value=[ { "timestamp": datetime.now(tz=UTC).isoformat(), "sampledValue": [ { - "value": "15000", # Wh + "value": "11000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "12000", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", "context": "Sample.Periodic", - } - ], - } - ], - ) - resp = await client.call(mv_tx1) - assert resp is not None - - # EAIR should be normalized to kWh on connector 1. - assert ( - cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) - == "kWh" - ) - assert ( - pytest.approx( - cs.get_metric( - cpid, "Energy.Active.Import.Register", connector_id=1 - ), - rel=1e-6, - ) - == 15.0 - ) - - # --- Simulate ABB behavior: new tx starts and EAIR restarts at 0 Wh with txId=222 --- - mv_tx2_begin = call.MeterValues( - connector_id=1, - transaction_id=222, # new transaction id triggers reset block - meter_value=[ - { - "timestamp": datetime.now(tz=UTC).isoformat(), - "sampledValue": [ + }, { - "value": "0", # Wh -> should be accepted after reset + "value": "13000", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", + "context": "Transaction.End", + }, + # Some unrelated measurand in the same bucket + { + "value": "230", + "measurand": "Voltage", + "unit": "V", + "location": "Outlet", "context": "Sample.Periodic", - } + }, ], } ], ) - resp2 = await client.call(mv_tx2_begin) - assert resp2 is not None + resp1 = await client.call(mv_bucket1) + assert resp1 is not None - # Verify: transaction id updated to 222, EAIR accepted as 0.0 kWh, and meter_start cleared. - assert int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) == 222 assert ( cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) == "kWh" ) - assert ( - pytest.approx( - cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) - or 0.0, - rel=1e-6, - ) - == 0.0 - ) - - # Meter.Start should be cleared (None) at new tx begin per integration logic. - assert cs.get_metric(cpid, "Energy.Meter.Start", connector_id=1) in ( - None, - 0, - 0.0, - ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.0, rel=1e-6) - # --- Follow-up periodic sample to ensure increasing values are tracked from the new baseline --- - mv_tx2_next = call.MeterValues( + # Bucket 2: No Transaction.End; Sample.Periodic should beat Sample.Clock. + # Expect: 13100 Wh -> 13.1 kWh. + mv_bucket2 = call.MeterValues( connector_id=1, - transaction_id=222, + transaction_id=555, meter_value=[ { "timestamp": datetime.now(tz=UTC).isoformat(), "sampledValue": [ { - "value": "100", # Wh + "value": "13090", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "13100", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", "context": "Sample.Periodic", - } + }, ], } ], ) - resp3 = await client.call(mv_tx2_next) - assert resp3 is not None + resp2 = await client.call(mv_bucket2) + assert resp2 is not None - # EAIR should now be 0.1 kWh on connector 1 from the new baseline. assert ( - pytest.approx( - cs.get_metric( - cpid, "Energy.Active.Import.Register", connector_id=1 - ), - rel=1e-6, - ) - == 0.1 + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.1, rel=1e-6) finally: task.cancel() @@ -4424,15 +3725,15 @@ async def test_abb_new_tx_resets_eair_and_meter_start( @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9341, "cp_id": "CP_cov_ctx_priority", "cms": "cms_services"}], + [{"port": 9342, "cp_id": "CP_eair_monotonic", "cms": "cms_services"}], indirect=True, ) -@pytest.mark.parametrize("cp_id", ["CP_cov_ctx_priority"]) -@pytest.mark.parametrize("port", [9341]) -async def test_eair_context_priority_in_bucket( +@pytest.mark.parametrize("cp_id", ["CP_eair_monotonic"]) +@pytest.mark.parametrize("port", [9342]) +async def test_eair_monotonic_increments_single_connector( hass, socket_enabled, cp_id, port, setup_config_entry ): - """Ensure EAIR context priority per bucket: Transaction.End > Sample.Periodic > Sample.Clock.""" + """Ensure EAIR monotonically increases during a normal charging session on a single-connector charger.""" cs = setup_config_entry async with websockets.connect( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] @@ -4445,41 +3746,80 @@ async def test_eair_context_priority_in_bucket( srv = cs.charge_points[cp_id] cpid = srv.settings.cpid - # Bucket 1: include three EAIR candidates with different contexts. - # Expect: Transaction.End (13000 Wh) wins -> 13.0 kWh. - mv_bucket1 = call.MeterValues( + # Start with a transaction-bound EAIR (Wh → kWh conversion should apply) + # Bucket 1: 1000 Wh → 1.0 kWh + mv1 = call.MeterValues( connector_id=1, - transaction_id=555, + transaction_id=777, meter_value=[ { "timestamp": datetime.now(tz=UTC).isoformat(), "sampledValue": [ { - "value": "11000", + "value": "1000", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", - "context": "Sample.Clock", - }, + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp1 = await client.call(mv1) + assert resp1 is not None + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + v1 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v1 == pytest.approx(1.0, rel=1e-6) + + # Bucket 2: 1500 Wh → 1.5 kWh (increase) + mv2 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ { - "value": "12000", + "value": "1500", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", "context": "Sample.Periodic", - }, + } + ], + } + ], + ) + resp2 = await client.call(mv2) + assert resp2 is not None + v2 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v2 == pytest.approx(1.5, rel=1e-6) + assert v2 >= v1 + + # Bucket 3: two EAIR candidates in the same bucket. + # Sample.Clock = 1.60 kWh, Sample.Periodic = 1.55 kWh → Periodic should win → 1.55 kWh + mv3 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ { - "value": "13000", + "value": "1600", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", - "context": "Transaction.End", + "context": "Sample.Clock", }, - # Some unrelated measurand in the same bucket { - "value": "230", - "measurand": "Voltage", - "unit": "V", + "value": "1550", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", "location": "Outlet", "context": "Sample.Periodic", }, @@ -4487,35 +3827,55 @@ async def test_eair_context_priority_in_bucket( } ], ) - resp1 = await client.call(mv_bucket1) - assert resp1 is not None + resp3 = await client.call(mv3) + assert resp3 is not None + v3 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v3 == pytest.approx(1.55, rel=1e-6) + assert v3 >= v2 - assert ( - cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) - == "kWh" + # Bucket 4: kWh sample directly (unit already kWh): 1.80 kWh → stays 1.80 kWh + mv4 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1.80", + "measurand": "Energy.Active.Import.Register", + "unit": "kWh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], ) - assert cs.get_metric( - cpid, "Energy.Active.Import.Register", connector_id=1 - ) == pytest.approx(13.0, rel=1e-6) - - # Bucket 2: No Transaction.End; Sample.Periodic should beat Sample.Clock. - # Expect: 13100 Wh -> 13.1 kWh. - mv_bucket2 = call.MeterValues( + resp4 = await client.call(mv4) + assert resp4 is not None + v4 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v4 == pytest.approx(1.80, rel=1e-6) + assert v4 >= v3 + + # Bucket 5: Include a Transaction.Begin(0) alongside a higher Periodic. + # Begin must be ignored; Periodic wins → 1.90 kWh + mv5 = call.MeterValues( connector_id=1, - transaction_id=555, + transaction_id=777, meter_value=[ { "timestamp": datetime.now(tz=UTC).isoformat(), "sampledValue": [ { - "value": "13090", + "value": "0", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", - "context": "Sample.Clock", + "context": "Transaction.Begin", }, { - "value": "13100", + "value": "1900", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "location": "Outlet", @@ -4525,16 +3885,178 @@ async def test_eair_context_priority_in_bucket( } ], ) - resp2 = await client.call(mv_bucket2) - assert resp2 is not None + resp5 = await client.call(mv5) + assert resp5 is not None + v5 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v5 == pytest.approx(1.90, rel=1e-6) + assert v5 >= v4 - assert ( - cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) - == "kWh" + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9351, "cp_id": "CP_set_rate_active_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_set_rate_active_tx"]) +@pytest.mark.parametrize("port", [9351]) +async def test_set_charge_rate_with_active_transaction( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Ensure set_charge_rate uses TxProfile for ongoing session and also attempts TxDefaultProfile.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Start a transaction on connector 1 so that _active_tx[1] is set. + await client.send_start_transaction(0) + + # Mock get_configuration so set_charge_rate doesn't hit srv.call for these + async def fake_get_configuration(key: str = "") -> str: + # units: pretend charger supports Amps + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "A" # same as om.current.value + # stack level + if key == ckey.charge_profile_max_stack_level.value: + return "2" + return "" + + calls = [] + + async def fake_call(req): + calls.append(req) + # Reject CP-max (connector_id == 0) so code proceeds to TxProfile + TxDefault + if getattr(req, "connector_id", None) == 0: + return SimpleNamespace(status=ChargingProfileStatus.rejected) + # Accept TxProfile and TxDefaultProfile + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + monkeypatch.setattr(srv, "get_configuration", fake_get_configuration) + + # Intercept outgoing SetChargingProfile calls + monkeypatch.setattr(srv, "call", fake_call) + + ok = await srv.set_charge_rate(limit_amps=16, conn_id=1) + assert ok is True + + # We expect 3 calls: CP-max (rejected), TxProfile (accepted), TxDefault (accepted) + assert len(calls) == 3 + assert getattr(calls[0], "connector_id", None) == 0 + # The rest should target connector 1 + assert getattr(calls[1], "connector_id", None) == 1 + assert getattr(calls[2], "connector_id", None) == 1 + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9352, "cp_id": "CP_set_rate_exceptions", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_set_rate_exceptions"]) +@pytest.mark.parametrize("port", [9352]) +async def test_set_charge_rate_exception_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Drive the exception handlers in set_charge_rate: CP-max, TxProfile, TxDefault, and custom-profile branch.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Make sure there is an active transaction on connector 1 + await client.send_start_transaction(0) + + # Case A: CP-max raises, TxProfile raises, TxDefault succeeds → overall True + call_count = 0 + + async def fake_call_case_a(req): + nonlocal call_count + call_count += 1 + # 1st call (CP-max) → raise + if call_count == 1: + raise RuntimeError("cp-max boom") + # 2nd call (TxProfile) → raise + if call_count == 2: + raise RuntimeError("tx-profile boom") + # 3rd call (TxDefault) → accept + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + # Ensure smart charging available + srv._attr_supported_features = {prof.SMART} + + async def fake_get_configuration(key: str = "") -> str: + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "A" + if key == ckey.charge_profile_max_stack_level.value: + return "2" + return "" + + monkeypatch.setattr(srv, "get_configuration", fake_get_configuration) + + monkeypatch.setattr(srv, "call", fake_call_case_a) + ok_a = await srv.set_charge_rate(limit_amps=10, conn_id=1) + assert ok_a is True + assert call_count == 3 # hit all branches + + # Case B: CP-max raises, TxProfile raises, TxDefault raises → overall False + call_count_b = 0 + + async def fake_call_case_b(req): + nonlocal call_count_b + call_count_b += 1 + raise RuntimeError(f"boom-{call_count_b}") + + monkeypatch.setattr(srv, "call", fake_call_case_b) + ok_b = await srv.set_charge_rate(limit_amps=12, conn_id=1) + assert ok_b is False + assert call_count_b >= 2 # at least CP-max + TxProfile tried + + # Case C: Custom profile branch raises → returns False + async def fake_call_custom(req): + raise RuntimeError("custom-profile boom") + + monkeypatch.setattr(srv, "call", fake_call_custom) + ok_c = await srv.set_charge_rate( + conn_id=1, + profile={ + # Minimal shape; actual content irrelevant since we stub .call + "chargingProfileId": 4242, + "stackLevel": 2, + "chargingProfileKind": "Relative", + "chargingProfilePurpose": "TxDefaultProfile", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 10}], + }, + }, ) - assert cs.get_metric( - cpid, "Energy.Active.Import.Register", connector_id=1 - ) == pytest.approx(13.1, rel=1e-6) + assert ok_c is False finally: task.cancel() @@ -4562,7 +4084,7 @@ def __init__(self, id, connection, response_timeout=30, no_connectors=1): @on(Action.get_configuration) def on_get_configuration(self, key, **kwargs): """Handle a get configuration requests.""" - if key[0] == ConfigurationKey.supported_feature_profiles.value: + if key[0] == ckey.supported_feature_profiles.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4576,17 +4098,17 @@ def on_get_configuration(self, key, **kwargs): else: # use to test TypeError handling return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.heartbeat_interval.value: + if key[0] == ckey.heartbeat_interval.value: return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] ) - if key[0] == ConfigurationKey.number_of_connectors.value: + if key[0] == ckey.number_of_connectors.value: return call_result.GetConfiguration( configuration_key=[ {"key": key[0], "readonly": False, "value": f"{self.no_connectors}"} ] ) - if key[0] == ConfigurationKey.web_socket_ping_interval.value: + if key[0] == ckey.web_socket_ping_interval.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4595,7 +4117,7 @@ def on_get_configuration(self, key, **kwargs): ) else: return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.meter_values_sampled_data.value: + if key[0] == ckey.meter_values_sampled_data.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4608,7 +4130,7 @@ def on_get_configuration(self, key, **kwargs): ) else: pass - if key[0] == ConfigurationKey.meter_value_sample_interval.value: + if key[0] == ckey.meter_value_sample_interval.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4619,10 +4141,7 @@ def on_get_configuration(self, key, **kwargs): return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] ) - if ( - key[0] - == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value - ): + if key[0] == ckey.charging_schedule_allowed_charging_rate_unit.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4631,7 +4150,7 @@ def on_get_configuration(self, key, **kwargs): ) else: return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: + if key[0] == ckey.authorize_remote_tx_requests.value: if self.accept is True: return call_result.GetConfiguration( configuration_key=[ @@ -4640,7 +4159,7 @@ def on_get_configuration(self, key, **kwargs): ) else: return call_result.GetConfiguration(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: + if key[0] == ckey.charge_profile_max_stack_level.value: return call_result.GetConfiguration( configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] ) @@ -4652,7 +4171,7 @@ def on_get_configuration(self, key, **kwargs): def on_change_configuration(self, key, **kwargs): """Handle a get configuration request.""" if self.accept is True: - if key == ConfigurationKey.meter_values_sampled_data.value: + if key == ckey.meter_values_sampled_data.value: return call_result.ChangeConfiguration( ConfigurationStatus.reboot_required ) diff --git a/tests/test_set_charge_rate_v16.py b/tests/test_set_charge_rate_v16.py index 77eee443..5050fdb4 100644 --- a/tests/test_set_charge_rate_v16.py +++ b/tests/test_set_charge_rate_v16.py @@ -185,38 +185,3 @@ async def fake_notify(msg, title="Ocpp integration"): ok = await cp_v16.set_charge_rate(limit_amps=10, conn_id=2) assert ok is True assert notices == [] - - -@pytest.mark.asyncio -async def test_txdefault_raises_returns_false_and_notifies(cp_v16, monkeypatch): - """5) CPMax rejected, TxDefault raises -> return False and notify HA.""" - - async def fake_get_conf(key: str): - if key == ckey.charging_schedule_allowed_charging_rate_unit.value: - return "Current" - if key == ckey.charge_profile_max_stack_level.value: - return "1" - pytest.fail(f"Unexpected get_configuration key: {key}") - - async def fake_call(req): - purpose = req.cs_charging_profiles["chargingProfilePurpose"] - if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: - return SimpleNamespace(status=ChargingProfileStatus.rejected) - if purpose == ChargingProfilePurposeType.tx_default_profile.value: - raise RuntimeError("boom on txdefault") - return SimpleNamespace(status=ChargingProfileStatus.rejected) - - notices = [] - - async def fake_notify(msg, title="Ocpp integration"): - notices.append(msg) - return True - - monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) - monkeypatch.setattr(cp_v16, "call", fake_call) - monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) - - ok = await cp_v16.set_charge_rate(limit_amps=25, conn_id=2) - assert ok is False - assert len(notices) == 1 - assert "Set charging profile failed" in notices[0] From 174b749d512c582c3d8456fdc11b701e966552fc Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 18 Sep 2025 07:49:56 +0200 Subject: [PATCH 316/370] Restore transaction after HA restart. (#1730) Co-authored-by: Jan Thunqvist --- custom_components/ocpp/ocppv16.py | 44 +++- tests/test_charge_point_v16.py | 356 ++++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+), 5 deletions(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 8a534180..1d10b854 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -849,7 +849,7 @@ async def async_update_device_info_v16(self, boot_info: dict): def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): """Request handler for MeterValues Calls (multi-connector aware).""" - transaction_id: int = kwargs.get(om.transaction_id.name, 0) + transaction_id: int = int(kwargs.get(om.transaction_id.name, 0) or 0) # Restore missing per-connector meter_start / active_transaction_id from HA if possible. ms_key = (connector_id, csess.meter_start.value) @@ -876,7 +876,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): if self._metrics[tx_key].value is None: value = self.get_ha_metric(csess.transaction_id.value, connector_id) if value is None: - value = kwargs.get(om.transaction_id.name) + value = transaction_id if transaction_id else None else: try: value = int(value) @@ -892,17 +892,51 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): # Track active tx per connector self._active_tx[connector_id] = value - active_tx = self._active_tx.get(connector_id, 0) or 0 + if connector_id not in self._active_tx: + try: + self._active_tx[connector_id] = int(self._metrics[tx_key].value or 0) + except Exception: + self._active_tx[connector_id] = 0 + + recorded_tx = int(self._metrics[tx_key].value or 0) + active_tx = int(self._active_tx.get(connector_id, 0) or 0) + + # Self-heal after restart: adopt incoming txId if we have none recorded yet + if transaction_id and (recorded_tx == 0 and active_tx == 0): + self._metrics[tx_key].value = transaction_id + self._active_tx[connector_id] = transaction_id + active_tx = transaction_id + recorded_tx = transaction_id + _LOGGER.debug( + "Restored transactionId=%s on conn %s from MeterValues.", + transaction_id, + connector_id, + ) + + # Keep legacy field synced for single-connector chargers, + # even if self-heal did not run (e.g., values were already restored). + try: + n_con = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n_con = 1 + if n_con == 1: + try: + legacy = int(getattr(self, "active_transaction_id", 0) or 0) + except Exception: + legacy = 0 + if legacy != int(active_tx or 0): + self.active_transaction_id = int(active_tx or 0) transaction_matches: bool = False # Match is also false if no transaction is in progress, i.e. active_tx==transaction_id==0 if transaction_id == active_tx and transaction_id != 0: transaction_matches = True - elif transaction_id != 0 and transaction_id is not None: + elif transaction_id != 0 and active_tx != 0 and transaction_id != active_tx: _LOGGER.warning( - "Unknown transaction detected on conn %s with id=%i", + "Unknown transaction detected on conn %s with id=%i (expected %s)", connector_id, transaction_id, + active_tx, ) meter_values: list[list[MeasurandValue]] = [] diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index af4af8ae..aa92363e 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -26,6 +26,7 @@ ConfigurationKey as ckey, HAChargerDetails as cdet, HAChargerServices as csvcs, + HAChargerSession as csess, Profiles as prof, ) from custom_components.ocpp.number import NUMBERS @@ -4065,6 +4066,361 @@ async def fake_call_custom(req): await ws.close() +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9361, "cp_id": "CP_tx_restore", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_tx_restore"]) +@pytest.mark.parametrize("port", [9361]) +async def test_meter_values_restores_transaction_on_restart( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """If a MeterValues arrives with a txId and we have no active tx recorded, adopt it (no warning).""" + cs = setup_config_entry + caplog.set_level(logging.WARNING) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Ensure single-connector semantics before first MeterValues arrives + srv.num_connectors = 1 + + # Simulate "after restart": no StartTransaction; we just receive MeterValues with txId. + req = call.MeterValues( + connector_id=1, + transaction_id=1111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + } + ], + } + ], + ) + await client.call(req) + + # The tx should be adopted (restored) without warning + tx_metric = cs.get_metric(cpid, "Transaction.Id", connector_id=1) + assert int(tx_metric) == 1111 + + # Active map should reflect it as well + assert int(srv._active_tx.get(1, 0) or 0) == 1111 + + # On single-connector chargers, legacy field may be mirrored; tolerate either default or mirrored + # (Do not fail test if multi-connector) + try: + n = int(getattr(srv, "num_connectors", 1) or 1) + except Exception: + n = 1 + if n == 1: + assert int(getattr(srv, "active_transaction_id", 0) or 0) == 1111 + + # No "Unknown transaction" warning should be present + assert not any( + "Unknown transaction detected on conn 1 with id=1111" in rec.message + for rec in caplog.records + ) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9362, "cp_id": "CP_tx_mismatch", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_tx_mismatch"]) +@pytest.mark.parametrize("port", [9362]) +async def test_meter_values_logs_warning_on_tx_mismatch( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """If a different tx is already active on the connector, log 'Unknown transaction' warning.""" + cs = setup_config_entry + caplog.set_level(logging.WARNING) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Establish an active tx on connector 1 (use helper that triggers StartTransaction) + await client.send_start_transaction(0) # typical helper targets connector 1 + + # Sanity: we should now have a recorded active tx id + recorded = int(srv._active_tx.get(1, 0) or 0) + assert recorded != 0 + + # Send MeterValues with a different transactionId to trigger mismatch + req = call.MeterValues( + connector_id=1, + transaction_id=recorded + 999, # different id + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "2000", + } + ], + } + ], + ) + await client.call(req) + + # Metric should NOT be overwritten by the mismatched txId + tx_metric = cs.get_metric(cpid, "Transaction.Id", connector_id=1) + assert int(tx_metric) == recorded + + # Warning should be logged + assert any( + f"Unknown transaction detected on conn 1 with id={recorded + 999}" + in rec.message + for rec in caplog.records + ) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +class BadInt: + """Object that raises when coerced to int; truthy so `value or 0` picks it.""" + + def __bool__(self): + """Bool function.""" + + return True + + def __int__(self): + """Bad int function.""" + + raise ValueError("bad int") + + +class FlakyTxMetric(M): + """Metric subclass whose .value raises int() the first time, then behaves like 0. + + Setter stores values normally. + """ + + def __init__(self): + """Init function.""" + + # unit can be None; extra_attr for safety + super().__init__(value=None, unit=None) + self._hits = 0 + self._forced = False + self._stored = None + self.extra_attr = {} + + @property + def value(self): + """Value prop method.""" + + if self._forced: + return self._stored + self._hits += 1 + return BadInt() if self._hits == 1 else 0 + + @value.setter + def value(self, v): + self._forced = True + self._stored = v + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9371, "cp_id": "CP_exc_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_exc_paths"]) +@pytest.mark.parametrize("port", [9371]) +async def test_on_meter_values_exception_branches_are_handled( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Exercise the three `except Exception:` branches in on_meter_values. + + 1) Warm-up: int(self._metrics[tx_key].value or 0) raises -> set 0 + 2) int(num_connectors) raises -> n_con = 1 + 3) int(active_transaction_id) raises -> legacy = 0 + """ + cs = setup_config_entry + caplog.set_level(logging.DEBUG) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + conn = 1 + + # 1) Install a Metric subclass that trips the first int(...) then returns 0 + tx_key = (conn, csess.transaction_id.value) + with contextlib.suppress(Exception): + del srv._active_tx[conn] + srv._metrics[tx_key] = FlakyTxMetric() + + # 2) Make int(num_connectors) raise -> n_con = 1 in except + srv.num_connectors = BadInt() + + # 3) Make int(active_transaction_id) raise -> legacy = 0 in except + srv.active_transaction_id = BadInt() + + # Send MeterValues with txId so paths are exercised + req = call.MeterValues( + connector_id=conn, + transaction_id=3333, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "500", + } + ], + } + ], + ) + await client.call(req) + + # Assertions: function returned, active_tx entry exists, + # Transaction.Id is int-coercible, and legacy field coercion doesn't raise. + assert conn in srv._active_tx + val = cs.get_metric(cpid, "Transaction.Id", connector_id=conn) + assert int(val or 0) in (0, 3333) + + ok = True + try: + int(getattr(srv, "active_transaction_id", 0) or 0) + except Exception: + ok = False + assert ok is True + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9372, "cp_id": "CP_exc_paths_2", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_exc_paths_2"]) +@pytest.mark.parametrize("port", [9372]) +async def test_on_meter_values_exception_branches_with_restore( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Same as above, but check we can end up with an adopted txId (3333) after the exception branches ran.""" + + cs = setup_config_entry + caplog.set_level(logging.DEBUG) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + conn = 1 + + with contextlib.suppress(Exception): + del srv._active_tx[conn] + srv._metrics[(conn, csess.transaction_id.value)] = FlakyTxMetric() + srv.num_connectors = BadInt() + srv.active_transaction_id = BadInt() + + req = call.MeterValues( + connector_id=conn, + transaction_id=3333, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "800", + } + ], + } + ], + ) + await client.call(req) + + # Either 0 (if not adopted) or 3333 (adopted); both prove branch ran without crash. + assert int(srv._active_tx.get(conn, 0) or 0) in (0, 3333) + val = cs.get_metric(cpid, "Transaction.Id", connector_id=conn) + assert int(val or 0) in (0, 3333) + + # Legacy coercion no longer raises (except branch set legacy=0 path) + ok = True + try: + int(getattr(srv, "active_transaction_id", 0) or 0) + except Exception: + ok = False + assert ok + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" From a3bc732b36df56d6e79aec416128ddecc54c6e66 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 19 Sep 2025 20:27:04 +0200 Subject: [PATCH 317/370] Fallback single connector chargers to connector 1 when rejected. (#1736) Co-authored-by: Jan Thunqvist --- custom_components/ocpp/ocppv16.py | 13 + tests/test_charge_point_v16.py | 465 ++++++++++++++++++++++++++++++ 2 files changed, 478 insertions(+) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 1d10b854..154ba71a 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -571,6 +571,18 @@ async def set_availability(self, state: bool = True, connector_id: int | None = try: status = getattr(resp, "status", None) + # Fallback: some single-connector chargers reject station-level (connectorId=0). + if status == AvailabilityStatus.rejected and conn == 0: + try: + n = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n = 1 + if n == 1: + _LOGGER.debug( + "Station-level ChangeAvailability rejected; retrying on connector 1." + ) + return await self.set_availability(state=state, connector_id=1) + pending_key = "availability_pending" target_str = "Operative" if state else "Inoperative" scope_str = "station" if conn == 0 else "connector" @@ -586,6 +598,7 @@ async def set_availability(self, state: bool = True, connector_id: int | None = } if metric is not None: metric.extra_attr[pending_key] = info + self.hass.async_create_task(self.update(self.settings.cpid)) return True diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index aa92363e..cdecc538 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -27,6 +27,7 @@ HAChargerDetails as cdet, HAChargerServices as csvcs, HAChargerSession as csess, + HAChargerStatuses as cstat, Profiles as prof, ) from custom_components.ocpp.number import NUMBERS @@ -4421,6 +4422,470 @@ async def test_on_meter_values_exception_branches_with_restore( await ws.close() +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9381, "cp_id": "CP_avail_reject_fallback", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail_reject_fallback"]) +@pytest.mark.parametrize("port", [9381]) +async def test_change_availability_conn0_rejected_falls_back_to_conn1( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test if ChangeAvailability(0, ...) is Rejected on a single-connector charger. + + The implementation should retry with connectorId=1 and return True on Accepted. + """ + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + # Ensure single-connector semantics so fallback path is eligible + srv.num_connectors = 1 + + calls = [] + + async def fake_call(req): + """Reject station-level request (conn=0), accept connector 1.""" + calls.append(req) + if isinstance(req, call.ChangeAvailability): + if getattr(req, "connector_id", None) == 0: + return SimpleNamespace(status=AvailabilityStatus.rejected) + if getattr(req, "connector_id", None) == 1: + return SimpleNamespace(status=AvailabilityStatus.accepted) + # Default: don't break other calls + return SimpleNamespace() + + # Intercept outgoing RPC calls + monkeypatch.setattr(srv, "call", fake_call) + + # Act: try to set station Unavailable via connectorId=0 + ok = await srv.set_availability(state=False, connector_id=0) + assert ok is True, "Fallback to connector 1 should make the call succeed" + + # Verify call sequence: first on 0 (Rejected), then on 1 (Accepted) + conn_ids = [ + getattr(c, "connector_id", None) + for c in calls + if isinstance(c, call.ChangeAvailability) + ] + assert conn_ids[:2] == [0, 1], f"Unexpected call order/targets: {conn_ids}" + + # Optionally assert that no pending marker was set for this Accepted outcome + m = srv._metrics.get((1, cstat.status_connector.value)) + if m is not None: + assert "availability_pending" not in getattr(m, "extra_attr", {}) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9391, "cp_id": "CP_pf_avg", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_avg"]) +@pytest.mark.parametrize("port", [9391]) +async def test_process_phases_power_factor_averages_l123( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with phases L1/L2/L3 is averaged (not summed).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # L1/L2/L3 values: 0.95, 0.97, 0.93 -> expected avg = 0.95 + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.95", + "phase": "L1", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.97", + "phase": "L2", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.93", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx((0.95 + 0.97 + 0.93) / 3, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9392, "cp_id": "CP_pf_single", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_single"]) +@pytest.mark.parametrize("port", [9392]) +async def test_process_phases_power_factor_single_phase_passthrough( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with only a single phase (e.g., L1-L2) is passed through.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Only one phase (line-to-line), so it should hit the single-pass branch. + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.88", + "phase": "L1-L2", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx(0.88, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9393, "cp_id": "CP_power_sum_kW", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_power_sum_kW"]) +@pytest.mark.parametrize("port", [9393]) +async def test_process_phases_power_active_import_sum_and_convert_to_kw( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Active.Import over phases is summed and unit W -> kW.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Three phase powers in W: 1200W + 800W + 0W -> 2000W -> 2.0 kW + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "1200", + "phase": "L1", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "800", + "phase": "L2", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "0", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Active.Import", connector_id=1) + u = cs.get_unit(cpid, "Power.Active.Import", connector_id=1) + assert v == pytest.approx(2.0, rel=1e-6) # 2000 W -> 2.0 kW + assert u == "kW" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9394, "cp_id": "CP_energy_sum_kwh", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_energy_sum_kwh"]) +@pytest.mark.parametrize("port", [9394]) +async def test_process_phases_energy_sum_and_convert_to_kwh( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Energy.Active.Import.Register over phases is summed and unit Wh -> kWh.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Three phase energies in Wh: 1000 + 500 + 0 -> 1500 Wh -> 1.5 kWh + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + "phase": "L1", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "500", + "phase": "L2", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "0", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + u = cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v == pytest.approx(1.5, rel=1e-6) # 1500 Wh -> 1.5 kWh + assert u == "kWh" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9395, "cp_id": "CP_pf_avg_ltn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_avg_ltn"]) +@pytest.mark.parametrize("port", [9395]) +async def test_process_phases_power_factor_avg_line_to_neutral( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with L1-N/L2-N/L3-N must use average-of-nonzero (hits line 779).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Include a zero to ensure "average of non-zero" behavior + # Non-zero values: 0.96 and 0.90 -> expected avg = (0.96 + 0.90) / 2 = 0.93 + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.96", + "phase": "L1-N", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.00", + "phase": "L2-N", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.90", + "phase": "L3-N", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx((0.96 + 0.90) / 2.0, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9396, "cp_id": "CP_power_sum_ltn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_power_sum_ltn"]) +@pytest.mark.parametrize("port", [9396]) +async def test_process_phases_power_active_import_sum_line_to_neutral_w_to_kw( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Active.Import with L1-N/L2-N/L3-N is summed and W→kW (hits lines 793–794 and 806).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # 900 W + 600 W + 500 W = 2000 W -> 2.0 kW expected + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "900", + "phase": "L1-N", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "600", + "phase": "L2-N", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "500", + "phase": "L3-N", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Active.Import", connector_id=1) + u = cs.get_unit(cpid, "Power.Active.Import", connector_id=1) + assert v == pytest.approx(2.0, rel=1e-6) # 2000 W -> 2.0 kW + assert u == "kW" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" From 3ce65ab8351c1228f941c9cfa413d4de891ee1c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 06:27:56 +1200 Subject: [PATCH 318/370] build(deps): bump ruff from 0.13.0 to 0.13.1 (#1738) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.13.0 to 0.13.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.13.0...0.13.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.13.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 818f53b9..744be57d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.13.0 +ruff==0.13.1 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From 97cd7705ade8e66021301795266d5fd95c6ced42 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Oct 2025 20:04:01 +0200 Subject: [PATCH 319/370] Handle disabled entities. (#1763) Co-authored-by: Jan Thunqvist --- custom_components/ocpp/button.py | 28 +++++++++++++++++++++++++++ custom_components/ocpp/chargepoint.py | 23 +++++++++++----------- custom_components/ocpp/number.py | 21 ++++++++++++++------ custom_components/ocpp/sensor.py | 21 ++++++++++++++------ 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index f3e53cbc..cc4a536d 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -11,7 +11,9 @@ ButtonEntity, ButtonEntityDescription, ) +from homeassistant.core import callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .api import CentralSystem @@ -19,6 +21,7 @@ CONF_CPID, CONF_CPIDS, CONF_NUM_CONNECTORS, + DATA_UPDATED, DEFAULT_NUM_CONNECTORS, DOMAIN, ) @@ -175,3 +178,28 @@ async def async_press(self) -> None: self.entity_description.press_action, connector_id=self._op_connector_id, ) + + async def async_added_to_hass(self) -> None: + """Handle entity added to hass.""" + await super().async_added_to_hass() + + @callback + def _maybe_update(*args): + """Handle dispatcher updates.""" + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + # Register dispatcher listener + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) + ) + + # Ensure button is shown as available after reload + self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index f5fa1a3f..ab9704b5 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import UnitOfTime -from homeassistant.helpers import device_registry, entity_component, entity_registry +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_send from websockets.asyncio.server import ServerConnection from websockets.exceptions import WebSocketException @@ -606,15 +606,15 @@ async def update(self, cpid: str): """Update sensors values in HA (charger + connector child devices).""" er = entity_registry.async_get(self.hass) dr = device_registry.async_get(self.hass) + identifiers = {(DOMAIN, cpid), (DOMAIN, self.id)} root_dev = dr.async_get_device(identifiers) if root_dev is None: return - to_visit = [root_dev.id] - visited = set() - updated_entities = 0 - found_children = 0 + to_visit: list[str] = [root_dev.id] + visited: set[str] = set() + active_entities: set[str] = set() while to_visit: dev_id = to_visit.pop(0) @@ -622,18 +622,19 @@ async def update(self, cpid: str): continue visited.add(dev_id) + # Collect enabled and currently loaded entities for this device for ent in entity_registry.async_entries_for_device(er, dev_id): - self.hass.async_create_task( - entity_component.async_update_entity(self.hass, ent.entity_id) - ) - updated_entities += 1 + if getattr(ent, "disabled", False) or getattr(ent, "disabled_by", None): + continue + if self.hass.states.get(ent.entity_id) is None: + continue + active_entities.add(ent.entity_id) for dev in dr.devices.values(): if dev.via_device_id == dev_id and dev.id not in visited: - found_children += 1 to_visit.append(dev.id) - async_dispatcher_send(self.hass, DATA_UPDATED) + async_dispatcher_send(self.hass, DATA_UPDATED, active_entities) def get_authorization_status(self, id_tag): """Get the authorization status for an id_tag.""" diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 2f3287e3..a23643b9 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -198,13 +198,22 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if restored := await self.async_get_last_number_data(): self._attr_native_value = restored.native_value - async_dispatcher_connect( - self._hass, DATA_UPDATED, self._schedule_immediate_update - ) - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) + @callback + def _maybe_update(*args): + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) + ) @property def available(self) -> bool: diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 186b2d8a..44269616 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -341,16 +341,25 @@ def native_unit_of_measurement(self): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() + if restored := await self.async_get_last_sensor_data(): self._attr_native_value = restored.native_value self._attr_native_unit_of_measurement = restored.native_unit_of_measurement - async_dispatcher_connect( - self._hass, DATA_UPDATED, self._schedule_immediate_update - ) + @callback + def _maybe_update(*args): + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None - self.async_schedule_update_ha_state(True) + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) + ) - @callback - def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) From 73ee4fa48dd9ca9e127f3944dafc2d8820ef6b58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 07:04:44 +1300 Subject: [PATCH 320/370] build(deps): bump ruff from 0.13.1 to 0.14.0 (#1758) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.13.1 to 0.14.0. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.13.1...0.14.0) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 744be57d..37413d8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.9.0 uv>=0.4 -ruff==0.13.1 +ruff==0.14.0 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From fab1a4cd865c6f42383c57144da9f5936629410d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 07:05:11 +1300 Subject: [PATCH 321/370] build(deps): bump pytest-homeassistant-custom-component (#1759) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.256 to 0.13.286. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.256...0.13.286) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.286 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 37413d8e..1ef8d9b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 pre-commit==4.3.0 -pytest-homeassistant-custom-component==0.13.256 +pytest-homeassistant-custom-component==0.13.286 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 4a023a2305cfdb664693ca3a05ac8c090a17c945 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Oct 2025 20:10:37 +0200 Subject: [PATCH 322/370] Default to Sample.Periodic for MeterValues without context. (#1764) Co-authored-by: Jan Thunqvist Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/chargepoint.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index ab9704b5..641bda64 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -834,7 +834,7 @@ def process_measurands( meas = sv.measurand if sv.measurand is not None else DEFAULT_MEASURAND if meas != DEFAULT_MEASURAND: continue - ctx = sv.context + ctx = sv.context or ReadingContext.sample_periodic.value # Always ignore Transaction.Begin for EAIR (prevents resets to 0) if ctx == ReadingContext.transaction_begin.value: continue @@ -846,7 +846,7 @@ def process_measurands( sv.value, sv.phase, sv.unit, - sv.context, + ctx, sv.location, ) ) @@ -877,7 +877,7 @@ def process_measurands( unit = sampled_value.unit phase = sampled_value.phase location = sampled_value.location - context = sampled_value.context + context = sampled_value.context or ReadingContext.sample_periodic.value # Backwards compatibility if sampled_value.measurand is None: @@ -935,10 +935,9 @@ def process_measurands( self._metrics[(target_cid, measurand)].extra_attr[ om.location.value ] = location - if context is not None: - self._metrics[(target_cid, measurand)].extra_attr[ - om.context.value - ] = context + self._metrics[(target_cid, measurand)].extra_attr[ + om.context.value + ] = context # Session handling, only for EAIR during a transaction (per-connector) if is_transaction and is_eair: From 380e3e2e0e64aff15122ea4e9fa19f393e065de9 Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:34:04 +0300 Subject: [PATCH 323/370] Fix EAIR metric handling for session vs lifetime energy (#1773) --- custom_components/ocpp/chargepoint.py | 32 ++++++-- tests/test_more_coverage_chargepoint.py | 99 +++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 641bda64..4ebfd818 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -928,16 +928,32 @@ def process_measurands( if is_eair and idx != best_eair_idx: continue - self._metrics[(target_cid, measurand)].value = value - self._metrics[(target_cid, measurand)].unit = unit + # Determine whether to skip writing EAIR to the main metric: + # - Skip only if this is an EAIR reading, + # - AND the charger reports session energy (meter_start == 0), + # - AND the reading belongs to an active transaction. + # + # Reason: in this situation, the EAIR value represents **session energy** for the current transaction, + # not the lifetime total meter. Writing it to the main metric would overwrite the true cumulative + # energy with a session-only value. For all other cases (non-EAIR readings or non-transaction readings), + # it is safe to write the metric normally. + skip_eair = ( + is_eair + and self._charger_reports_session_energy + and is_transaction + ) - if location is not None: + if not skip_eair: + # Normal write + self._metrics[(target_cid, measurand)].value = value + self._metrics[(target_cid, measurand)].unit = unit + if location is not None: + self._metrics[(target_cid, measurand)].extra_attr[ + om.location.value + ] = location self._metrics[(target_cid, measurand)].extra_attr[ - om.location.value - ] = location - self._metrics[(target_cid, measurand)].extra_attr[ - om.context.value - ] = context + om.context.value + ] = context # Session handling, only for EAIR during a transaction (per-connector) if is_transaction and is_eair: diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py index e12ca832..094db0cb 100644 --- a/tests/test_more_coverage_chargepoint.py +++ b/tests/test_more_coverage_chargepoint.py @@ -414,3 +414,102 @@ async def test_process_measurands_defaults_and_session_energy_v2x(hass, monkeypa esess2 = srv._metrics[(1, "Energy.Session")] assert pytest.approx(eair2.value, rel=1e-6) == 12.445 assert pytest.approx(esess2.value or 0.0, rel=1e-6) == 0.1 + + +@pytest.mark.timeout(5) +async def test_session_and_lifetime_eair_distinction(hass): + """Ensure correct EAIR handling for session vs lifetime readings. + + When the charger reports session energy directly in EAIR: + - an in-transaction EAIR reading (5000 Wh => 5.0 kWh) updates Energy.Session but NOT the lifetime EAIR metric, + - a separate non-transaction EAIR reading (123450 Wh => 123.45 kWh) updates the lifetime EAIR metric. + + When the charger does NOT report session energy in EAIR: + - both an in-transaction EAIR reading and a non-transaction EAIR reading write the lifetime EAIR metric. + """ + + # ------ First scenario: charger reports session energy ------ + # Minimal CP instance not bound to a real socket. + version = SimpleNamespace(value="1.6") + fake_hass = SimpleNamespace( + async_create_task=lambda c: asyncio.create_task(c), + helpers=SimpleNamespace( + entity_component=SimpleNamespace(async_update_entity=lambda eid: None) + ), + ) + fake_entry = SimpleNamespace(entry_id="dummy") + fake_central = SimpleNamespace( + websocket_ping_interval=0, + websocket_ping_timeout=0, + websocket_ping_tries=0, + ) + fake_settings = SimpleNamespace(cpid="cpid_dummy") + fake_conn = SimpleNamespace(state=State.CLOSED) + + srv = BaseCP( + "cp_dummy", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv._ocpp_version = "1.6" + + # Simulate charger that reports session energy directly (meter_start == 0). + srv._charger_reports_session_energy = True + + # 1) In-transaction EAIR reading (5000 Wh -> 5.0 kWh). + tx_samples = [[MeasurandValue(None, 5000.0, None, None, None, None)]] + srv.process_measurands(tx_samples, is_transaction=True, connector_id=1) + + # Session must be updated to 5.0 kWh, main EAIR must NOT be overwritten by this call. + sess = srv._metrics[(1, "Energy.Session")] + main_after_tx = srv._metrics[(1, "Energy.Active.Import.Register")].value + assert pytest.approx(sess.value, rel=1e-6) == 5.0 + # main might be None or unchanged; ensure it was NOT set to 5.0 by the tx call + assert pytest.approx(main_after_tx, rel=1e-6) != 5.0 + + # 2) Non-transaction EAIR reading representing lifetime meter (123450 Wh -> 123.45 kWh). + life_samples = [[MeasurandValue(None, 123450.0, None, None, None, None)]] + srv.process_measurands(life_samples, is_transaction=False, connector_id=1) + + # Now the lifetime EAIR metric must equal 123.45 kWh, and session must still be 5.0 kWh. + main = srv._metrics[(1, "Energy.Active.Import.Register")] + sess = srv._metrics[(1, "Energy.Session")] + + assert pytest.approx(main.value, rel=1e-6) == 123.45 + assert main.unit == "kWh" + assert pytest.approx(sess.value, rel=1e-6) == 5.0 + assert sess.unit == "kWh" + + # ------ Second scenario: charger does NOT report session energy ------ + # New CP instance (clean metrics) to test the other branch. + srv2 = BaseCP( + "cp_dummy2", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv2._ocpp_version = "1.6" + srv2._charger_reports_session_energy = False + + # 1) In-transaction EAIR reading (5000 Wh -> 5.0 kWh) should write lifetime EAIR metric. + tx_samples2 = [[MeasurandValue(None, 5000.0, None, None, None, None)]] + srv2.process_measurands(tx_samples2, is_transaction=True, connector_id=1) + + main_after_tx2 = srv2._metrics[(1, "Energy.Active.Import.Register")].value + # Because charger does NOT report session energy, main should be written with 5.0 kWh. + assert pytest.approx(main_after_tx2, rel=1e-6) == 5.0 + + # 2) Non-transaction EAIR reading representing lifetime meter (123450 Wh -> 123.45 kWh). + life_samples2 = [[MeasurandValue(None, 123450.0, None, None, None, None)]] + srv2.process_measurands(life_samples2, is_transaction=False, connector_id=1) + + main_after_life2 = srv2._metrics[(1, "Energy.Active.Import.Register")].value + # Lifetime EAIR should be updated to 123.45 kWh. + assert pytest.approx(main_after_life2, rel=1e-6) == 123.45 From ed7b0ff89c76a44aab48035fe4fb2f06bcae841b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:34:37 +1300 Subject: [PATCH 324/370] build(deps): bump pytest-homeassistant-custom-component (#1772) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.286 to 0.13.288. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.286...0.13.288) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.288 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1ef8d9b5..8328bf30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 pre-commit==4.3.0 -pytest-homeassistant-custom-component==0.13.286 +pytest-homeassistant-custom-component==0.13.288 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 681aae3dd1bf2a1d057070f7e6b4a41e10aa7050 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:35:09 +1300 Subject: [PATCH 325/370] build(deps): bump colorlog from 6.9.0 to 6.10.1 (#1770) Bumps [colorlog](https://github.com/borntyping/python-colorlog) from 6.9.0 to 6.10.1. - [Release notes](https://github.com/borntyping/python-colorlog/releases) - [Commits](https://github.com/borntyping/python-colorlog/compare/v6.9.0...v6.10.1) --- updated-dependencies: - dependency-name: colorlog dependency-version: 6.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8328bf30..8f70504b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -colorlog==6.9.0 +colorlog==6.10.1 uv>=0.4 ruff==0.14.0 ocpp==2.1.0 From 5804b7bd7c99cae0c6a618968c1ecd7e0c875a4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:35:41 +1300 Subject: [PATCH 326/370] build(deps): bump ruff from 0.14.0 to 0.14.1 (#1769) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.0 to 0.14.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.14.0...0.14.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8f70504b..6c386022 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.14.0 +ruff==0.14.1 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From 9db2129b752186f9a027f021b04b7ff8fc2b9d92 Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Wed, 22 Oct 2025 23:53:53 +0300 Subject: [PATCH 327/370] Fix session-time calculation when transaction_id is missing (#1774) --- custom_components/ocpp/ocppv16.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 154ba71a..ca3bbf0e 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -863,10 +863,12 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): """Request handler for MeterValues Calls (multi-connector aware).""" transaction_id: int = int(kwargs.get(om.transaction_id.name, 0) or 0) + tx_has_id: bool = transaction_id not in (None, 0) # Restore missing per-connector meter_start / active_transaction_id from HA if possible. ms_key = (connector_id, csess.meter_start.value) tx_key = (connector_id, csess.transaction_id.value) + session_key = (connector_id, csess.session_time.value) if self._metrics[ms_key].value is None: value = self.get_ha_metric(csess.meter_start.value, connector_id) @@ -974,15 +976,15 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self.process_measurands(meter_values, transaction_matches, connector_id) - if transaction_matches: + if tx_has_id and transaction_matches: try: tx_start_epoch = float(self._metrics[tx_key].value) except (TypeError, ValueError): tx_start_epoch = time.time() - self._metrics[(connector_id, csess.session_time.value)].value = round( - (int(time.time()) - tx_start_epoch) / 60 + self._metrics[session_key].value = round( + (time.time() - tx_start_epoch) / 60 ) - self._metrics[(connector_id, csess.session_time.value)].unit = "min" + self._metrics[session_key].unit = UnitOfTime.MINUTES self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() From 107f36f684cdbc27e124632ce6f563c1c9747500 Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Sat, 25 Oct 2025 22:34:13 +0300 Subject: [PATCH 328/370] Move transaction clearing from StatusNotification to StopTransaction (#1777) --- custom_components/ocpp/ocppv16.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index ca3bbf0e..81ec4052 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -981,11 +981,16 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): tx_start_epoch = float(self._metrics[tx_key].value) except (TypeError, ValueError): tx_start_epoch = time.time() - self._metrics[session_key].value = round( - (time.time() - tx_start_epoch) / 60 - ) - self._metrics[session_key].unit = UnitOfTime.MINUTES - + if tx_start_epoch > 0: + self._metrics[session_key].value = round( + (time.time() - tx_start_epoch) / 60 + ) + self._metrics[session_key].unit = UnitOfTime.MINUTES + else: + _LOGGER.debug( + "Skipping session time calc — invalid tx_start_epoch=%s", + tx_start_epoch, + ) self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -1032,10 +1037,6 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): if meas in self._metrics[connector_id]: self._metrics[(connector_id, meas)].value = 0 - if status == ChargePointStatus.available: - self._metrics[(connector_id or 1, cstat.id_tag.value)].value = "" - self._metrics[(connector_id or 1, csess.transaction_id.value)].value = 0 - self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @@ -1138,7 +1139,8 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): # Reset active transaction (global + per-connector) self._active_tx[conn] = 0 self.active_transaction_id = 0 - + self._metrics[(conn, cstat.id_tag.value)].value = "" + self._metrics[(conn, csess.transaction_id.value)].value = 0 self._metrics[(conn, cstat.stop_reason.value)].value = kwargs.get( om.reason.name, None ) From b10bb02a1a6b6d0b7c338772cbaec990cd24c016 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 08:34:45 +1300 Subject: [PATCH 329/370] build(deps): bump ruff from 0.14.1 to 0.14.2 (#1775) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.1 to 0.14.2. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.14.1...0.14.2) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6c386022..9649ab2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.14.1 +ruff==0.14.2 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From b90c219e720743d2ba15c3b460a0c584bd0d0918 Mon Sep 17 00:00:00 2001 From: nlindn <95648815+nlindn@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:55:17 +0200 Subject: [PATCH 330/370] fix: add fallback to generic HA sensor ID when connector-specific one not found (#1788) --- custom_components/ocpp/chargepoint.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 4ebfd818..fcf19317 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -1008,16 +1008,24 @@ def get_ha_metric(self, measurand: str, connector_id: int | None = None): base = self.settings.cpid.lower() meas_slug = measurand.lower().replace(".", "_") + # Build list of possible sensor entity IDs. + # Include connector-specific ID if applicable, then the generic one as fallback. candidates: list[str] = [] if connector_id and connector_id > 0: candidates.append(f"sensor.{base}_connector_{connector_id}_{meas_slug}") - else: - candidates.append(f"sensor.{base}_{meas_slug}") + candidates.append(f"sensor.{base}_{meas_slug}") + # Return the first valid state found among candidates. for entity_id in candidates: - st = self.hass.states.get(entity_id) + try: + st = self.hass.states.get(entity_id) + except Exception as e: + _LOGGER.debug("Error getting entity %s from HA: %s", entity_id, e) + st = None + if st and st.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): return st.state + return None async def notify_ha(self, msg: str, title: str = "Ocpp integration"): From 46e7d452696e8cb165a86c18f53188a2109678dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:55:48 +1300 Subject: [PATCH 331/370] build(deps): bump pytest-homeassistant-custom-component (#1785) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.288 to 0.13.289. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.288...0.13.289) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.289 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9649ab2b..0b5564bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 pre-commit==4.3.0 -pytest-homeassistant-custom-component==0.13.288 +pytest-homeassistant-custom-component==0.13.289 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 8e3c3c4e939ac0729e0927d467d411bf3e8fc022 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:56:23 +1300 Subject: [PATCH 332/370] build(deps): bump actions/upload-artifact from 4.6.2 to 5.0.0 (#1784) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...v5.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 126fd261..4591cf28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From fecf73ff7d697c03f88bc3e3857523f1ece7472f Mon Sep 17 00:00:00 2001 From: gabbuman <80305715+gabbuman@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:50:00 -0800 Subject: [PATCH 333/370] fix: validate CSID length to comply with OCPP idTag limit (#1819) * Added validation check for csid input to be atmost 20 characters long since it is enforced by OCPP for the idTag parameter * Updated documentation to state 20 character max length --- custom_components/ocpp/config_flow.py | 2 +- docs/user-guide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 05764118..dff3ea53 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -61,7 +61,7 @@ vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, vol.Required(CONF_SSL_CERTFILE_PATH, default=DEFAULT_SSL_CERTFILE_PATH): str, vol.Required(CONF_SSL_KEYFILE_PATH, default=DEFAULT_SSL_KEYFILE_PATH): str, - vol.Required(CONF_CSID, default=DEFAULT_CSID): str, + vol.Required(CONF_CSID, default=DEFAULT_CSID): vol.All(str, vol.Length(max=20)), vol.Required( CONF_WEBSOCKET_CLOSE_TIMEOUT, default=DEFAULT_WEBSOCKET_CLOSE_TIMEOUT ): int, diff --git a/docs/user-guide.md b/docs/user-guide.md index cd185802..c58c8820 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -15,7 +15,7 @@ The 'baggage' referred to above, is every single repository available through HA ![Central System Configuration](https://user-images.githubusercontent.com/8673442/129494762-08052152-f057-4563-93b5-5aae810dfbfc.png) -The `Central system identity` shown above with a default of `central` can be anything you like. Whatever is entered in that field will be used as a device identifier in Home Assistant (HA), so it's probably best to avoid spaces and punctuation symbols, but otherwise, enter anything you like. +The `Central system identity` shown above with a default of `central` can be anything you like up to a **maximum** of **20 characters**. Whatever is entered in that field will be used as a device identifier in Home Assistant (HA), so it's probably best to avoid spaces and punctuation symbols, but otherwise, enter anything you like. The `Charge point identity` shown above with a default of `charger` is a little different. Whatever you enter in that field will determine the prefix of all Charger entities added to Home Assistant (HA). My recommendation is that it's best left at the default of charger. If you put anything else in that field, it will be used as the prefix for all Charger entities added to HA during installation, however, new entities subsequently added in later version releases sometimes revert to the default prefix, regardless of what was entered during installation. So you end up with a mixture of different prefixes which can be avoided simply by leaving `Charge point identity` set to the default of `charger`. From e5686cde507d4bf5874bd0ffc4a695af0dd5011a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:12:22 +1300 Subject: [PATCH 334/370] build(deps): bump pytest-homeassistant-custom-component (#1817) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.289 to 0.13.297. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.289...0.13.297) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.297 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b5564bd..f5b88d74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 pre-commit==4.3.0 -pytest-homeassistant-custom-component==0.13.289 +pytest-homeassistant-custom-component==0.13.297 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 20d4c844cdb188b0059366efb8b86b26029b5f19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:13:20 +1300 Subject: [PATCH 335/370] build(deps): bump pre-commit from 4.3.0 to 4.5.0 (#1816) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.3.0 to 4.5.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.3.0...v4.5.0) --- updated-dependencies: - dependency-name: pre-commit dependency-version: 4.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f5b88d74..b57cf702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruff==0.14.2 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 -pre-commit==4.3.0 +pre-commit==4.5.0 pytest-homeassistant-custom-component==0.13.297 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 891d709504c50542a6ba7a6494fdb7b12d2b305e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:13:56 +1300 Subject: [PATCH 336/370] build(deps): bump ruff from 0.14.2 to 0.14.7 (#1820) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.2 to 0.14.7. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.14.2...0.14.7) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b57cf702..06ac0327 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.14.2 +ruff==0.14.7 ocpp==2.1.0 websockets==15.0.1 jsonschema==4.25.1 From 9fe9eaa0d0e6e674f96cb2b942216534e365a97b Mon Sep 17 00:00:00 2001 From: Daniel Reimer Date: Mon, 2 Feb 2026 01:17:39 +0100 Subject: [PATCH 337/370] Slugify fix for HA 2026.2.0 Beta (#1855) * Slugify fix by drc38 (2nd try) * Enhance user form with documentation URL Add description placeholders for user form. * Update configuration description link format * Update configuration link format in en.json * Add documentation URL to user form in config flow --------- Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/config_flow.py | 10 ++++++++-- custom_components/ocpp/sensor.py | 2 +- custom_components/ocpp/translations/en.json | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index dff3ea53..cde1ef20 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -132,7 +132,10 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: return self.async_create_entry(title=self._data[CONF_CSID], data=self._data) return self.async_show_form( - step_id="user", data_schema=STEP_USER_CS_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_CS_DATA_SCHEMA, + errors=errors, + description_placeholders={"docs_url": "https://github.com/lbbrhzn/ocpp"}, ) async def async_step_integration_discovery( @@ -184,7 +187,10 @@ async def async_step_cp_user( return await self.async_step_measurands() return self.async_show_form( - step_id="cp_user", data_schema=STEP_USER_CP_DATA_SCHEMA, errors=errors + step_id="cp_user", + data_schema=STEP_USER_CP_DATA_SCHEMA, + errors=errors, + description_placeholders={"docs_url": "https://github.com/lbbrhzn/ocpp"}, ) async def async_step_measurands(self, user_input=None): diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 44269616..f060870a 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -105,7 +105,7 @@ async def async_setup_entry(hass, entry, async_add_devices): def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: ms = str(metric).strip() return OcppSensorDescription( - key=ms.lower(), + key=ms.lower().replace(".", "_"), name=ms.replace(".", " "), metric=ms, entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 36a8562d..55ac39bd 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OCPP Central System Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "host": "Central system host address", "port": "Central system port number", @@ -19,7 +19,7 @@ }, "cp_user": { "title": "OCPP Charger Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", @@ -82,4 +82,4 @@ "message": "Failed to set variable: {message}" } } -} \ No newline at end of file +} From 3ebb5d5e3800d74d2989eafb663197fcdf5f97d1 Mon Sep 17 00:00:00 2001 From: dhi20 <38461182+dhi20@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:01:24 +1300 Subject: [PATCH 338/370] Update config_flow.py to permit capital letters in ocpp charger name (#1857) * Update config_flow.py Fix issue with the use of capital letters in the ocpp name of the charger * Update custom_components/ocpp/config_flow.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- custom_components/ocpp/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index cde1ef20..98fd6d6b 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -79,7 +79,11 @@ STEP_USER_CP_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_CPID, default=DEFAULT_CPID): str, + vol.Required(CONF_CPID, default=DEFAULT_CPID): vol.All( + str, + vol.Lower, + lambda v: v.replace(" ", "_"), + ), vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, vol.Required( CONF_MONITORED_VARIABLES_AUTOCONFIG, From 19a93a82c4769e80364ba6095a9bbd2c88f4303c Mon Sep 17 00:00:00 2001 From: Fil Mackay Date: Sat, 7 Feb 2026 16:15:14 +1100 Subject: [PATCH 339/370] Remove override of default logging level (#1876) * Remove logging level setting for DOMAIN logger Remove INFO log level setting for the domain logger - and use HA system default. * Remove logging level setting for API * Remove logging level for chargepoint Remove logging level. defer to system default. * Remove logging level in number Remove logging level setting for DOMAIN * Remove INFO logging level for domain Remove logging level setting for the domain. * Remove logging level setting for DOMAIN Remove logging level setting for the domain. * Update ocppv16.py --- custom_components/ocpp/__init__.py | 1 - custom_components/ocpp/api.py | 1 - custom_components/ocpp/chargepoint.py | 1 - custom_components/ocpp/number.py | 1 - custom_components/ocpp/ocppv16.py | 2 -- custom_components/ocpp/ocppv201.py | 1 - 6 files changed, 7 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index c07323c2..f65c61df 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -63,7 +63,6 @@ ) _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) AUTH_LIST_SCHEMA = vol.Schema( { diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index d0c17dbf..3c45e084 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -35,7 +35,6 @@ from .chargepoint import SetVariableResult _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) # Uncomment these when Debugging # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index fcf19317..7d7ba894 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -68,7 +68,6 @@ TIME_MINUTES = UnitOfTime.MINUTES _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) class Metric: diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index a23643b9..592b8a6a 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -33,7 +33,6 @@ from .enums import Profiles _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) @dataclass diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 81ec4052..d894c739 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -56,12 +56,10 @@ CentralSystemSettings, ChargerSystemSettings, DEFAULT_MEASURAND, - DOMAIN, HA_ENERGY_UNIT, ) _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) def _to_message_trigger(name: str) -> MessageTrigger | None: diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index dad9bcad..cd6c07ff 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -59,7 +59,6 @@ ) _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) @dataclass From 7b9c5c835c03648dee2bdfe224ac4cfeb265ccba Mon Sep 17 00:00:00 2001 From: Fil Mackay Date: Sun, 15 Feb 2026 10:08:52 +1100 Subject: [PATCH 340/370] Fix dev container, translations and charger name (#1882) * Update config_flow.py reversed prior change regarding capital letters, fix introduced a bug * Update dev container and translations * Downgrade pycares to version 4.9 Revert pycares to version 4.9 due to pytest error. * Downgrade pycares version in requirements.txt Revert pycares to a previous version due to pytest error. * fix dev environment * include convert to lower * Exclude whitespace * include coderabbit suggestions * add sensor state to migration test --------- Co-authored-by: dhi20 <38461182+dhi20@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> Co-authored-by: Derek Caudwell --- .devcontainer/devcontainer.json | 1 - Dockerfile | 55 +++++++++++++------ Dockerfile.dev | 15 +++-- custom_components/ocpp/config_flow.py | 3 +- custom_components/ocpp/translations/de.json | 4 +- custom_components/ocpp/translations/es.json | 4 +- .../ocpp/translations/i-default.json | 4 +- custom_components/ocpp/translations/nl.json | 4 +- requirements.txt | 1 + tests/test_init.py | 9 ++- 10 files changed, 62 insertions(+), 38 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8c3f3e9d..b1fcba37 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,6 @@ "PYTHONASYNCIODEBUG": "1" }, "features": { - "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, "appPort": [ diff --git a/Dockerfile b/Dockerfile index b63cd1c7..51f9e7a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,40 +4,59 @@ ARG BUILD_FROM FROM ${BUILD_FROM} +LABEL \ + io.hass.type="core" \ + org.opencontainers.image.authors="The Home Assistant Authors" \ + org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ + org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/home-assistant/core" \ + org.opencontainers.image.title="Home Assistant" \ + org.opencontainers.image.url="https://www.home-assistant.io/" + # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME=240000 \ UV_SYSTEM_PYTHON=true \ UV_NO_CACHE=true -ARG QEMU_CPU - # Home Assistant S6-Overlay COPY rootfs / -# Needs to be redefined inside the FROM statement to be set for RUN commands -ARG BUILD_ARCH -# Get go2rtc binary +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc + RUN \ - case "${BUILD_ARCH}" in \ - "aarch64") go2rtc_suffix='arm64' ;; \ - "armhf") go2rtc_suffix='armv6' ;; \ - "armv7") go2rtc_suffix='arm' ;; \ - *) go2rtc_suffix=${BUILD_ARCH} ;; \ - esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ - && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed - && go2rtc --version - -# Install uv -RUN pip3 install uv==0.7.1 + go2rtc --version \ + # Install uv + && pip3 install uv==0.9.26 WORKDIR /usr/src +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ uv pip install \ - -r requirements.txt + --no-build \ + -r homeassistant/requirements.txt +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \ + if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ + uv pip install homeassistant/home_assistant_*.whl; \ + fi \ + && uv pip install \ + --no-build \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \ + uv pip install \ + -e ./homeassistant \ + && python3 -m compileall \ + homeassistant/homeassistant WORKDIR /config \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index a37ca960..5acdaf49 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -3,8 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/base:debian SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN \ - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ - && apt-get update \ + apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery bluez \ @@ -14,7 +13,6 @@ RUN \ libavcodec-dev \ libavdevice-dev \ libavutil-dev \ - libgammu-dev \ libswscale-dev \ libswresample-dev \ libavfilter-dev \ @@ -35,15 +33,13 @@ WORKDIR /usr/src COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -RUN uv python install 3.13.2 - USER vscode + ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" -RUN uv venv $VIRTUAL_ENV +RUN uv python install \ + && uv venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -WORKDIR /tmp - # Setup hass-release RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ && uv pip install -e ~/hass-release/ @@ -52,6 +48,9 @@ RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-re COPY requirements.txt ./ RUN uv pip install --prerelease=allow -r requirements.txt +# Claude Code native install +RUN curl -fsSL https://claude.ai/install.sh | bash + WORKDIR /workspaces # Set the default shell to bash instead of sh diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 98fd6d6b..20b0b91e 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -81,8 +81,7 @@ { vol.Required(CONF_CPID, default=DEFAULT_CPID): vol.All( str, - vol.Lower, - lambda v: v.replace(" ", "_"), + vol.Match(r"^[a-z0-9_$]+$", msg="Use only lower case, digits and _"), ), vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, vol.Required( diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index 8d87f7dd..2a4ea7ac 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OCPP-Konfiguration", - "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier nach: https://github.com/lbbrhzn/ocpp", + "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier [nach]({docs_url})", "data": { "host": "Host-Adresse des Zentralsystems", "port": "Portnummer des Zentralsystems", @@ -19,7 +19,7 @@ }, "cp_user": { "title": "OCPP-Konfiguration", - "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier nach: https://github.com/lbbrhzn/ocpp", + "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier [nach]({docs_url})", "data": { "cpid": "Identität der Ladestation", "max_current": "Maximaler Ladestrom", diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index f40ebdc0..74525e99 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configuración OCPP", - "description": "Si necesitas ayuda con la configuración puedes hechar un vistazo en: https://github.com/lbbrhzn/ocpp", + "description": "Si necesitas ayuda con la configuración puedes hechar un [vistazo en]({docs_url})", "data": { "host": "Dirección de host del sistema central", "port": "Número de puerto del sistema central", @@ -16,7 +16,7 @@ }, "cp_user": { "title": "Configuración OCPP", - "description": "Si necesitas ayuda con la configuración puedes hechar un vistazo en: https://github.com/lbbrhzn/ocpp", + "description": "Si necesitas ayuda con la configuración puedes hechar un [vistazo en]({docs_url}", "data": { "cpid": "Nombre del punto de carga", "meter_interval": "Intervalo de mediciones (segundos)", diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index e01afa80..6b9f424f 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OCPP Central System Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "host": "Central system host address", "port": "Central system port number", @@ -19,7 +19,7 @@ }, "cp_user": { "title": "OCPP Charger Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index ce4cc92c..c0fab88b 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OCPP Configuratie", - "description": "Voor hulp bij configuratie zie: https://github.com/lbbrhzn/ocpp", + "description": "Voor hulp bij configuratie [zie]({docs_url})", "data": { "host": "Host", "port": "Poort", @@ -16,7 +16,7 @@ }, "cp_user": { "title": "OCPP Configuratie", - "description": "Voor hulp bij configuratie zie: https://github.com/lbbrhzn/ocpp", + "description": "Voor hulp bij configuratie [zie]({docs_url})", "data": { "cpid": "Charge point identifier", "max_current": "Maximale laadstroom", diff --git a/requirements.txt b/requirements.txt index 06ac0327..3fea8e18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pycares==4.9.0 # new version causes an error in pytest colorlog==6.10.1 uv>=0.4 ruff==0.14.7 diff --git a/tests/test_init.py b/tests/test_init.py index b5b63b7d..77143ed7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -8,7 +8,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.ocpp import CentralSystem -from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.const import DOMAIN, CONF_CPID from .const import ( MOCK_CONFIG_DATA, @@ -106,10 +106,17 @@ async def test_migration_entry( entry_id="test_migration", title="test_migration", version=1, + minor_version=1, ) config_entry.add_to_hass(hass) await hass.async_block_till_done() + # Ensure cp id is present in state machine to trigger migration flow. This simulates the condition where a user has a sensor entity in HA with the cp_id as the state value, which is used to identify the entry to migrate. If this value is not present, the migration flow will not be triggered and the test will fail. + hass.states.async_set( + f"sensor.{MOCK_CONFIG_MIGRATION_FLOW[CONF_CPID].lower()}_id", + MOCK_CONFIG_MIGRATION_FLOW[CONF_CPID], + ) + # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data # call, no code from custom_components/ocpp/api.py actually runs. From da66e2dd4ebbfa7338922838ae9f9d2e75fe9d67 Mon Sep 17 00:00:00 2001 From: Fil Mackay Date: Sun, 15 Feb 2026 11:18:08 +1100 Subject: [PATCH 341/370] Enable real-time charge control updates (#1881) * Update switch to push updates - not be polled * Update HA states after connection Ensure Home Assistant states are updated immediately after connection. * Change async_write_ha_state to async_schedule_update_ha_state Refactor state update method for switch entity - for consistency. * Formatting update * fix test errors * Improve supported feature coding * add TypeError guard for None --------- Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/chargepoint.py | 16 ++++++++++++++ custom_components/ocpp/ocppv16.py | 6 +++--- custom_components/ocpp/switch.py | 31 +++++++++++++++++++++++++++ tests/conftest.py | 8 ++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 7d7ba894..49a5a881 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -375,6 +375,9 @@ async def post_connect(self): except Exception as ex: _LOGGER.debug("trigger_status_notification ignored: %s", ex) + # Ensure HA states are correct immediately after connection + self.hass.async_create_task(self.update(self.settings.cpid)) + except Exception as e: _LOGGER.debug("post_connect aborted non-fatally: %s", e) @@ -1000,6 +1003,19 @@ def process_measurands( @property def supported_features(self) -> int: """Flag of Ocpp features that are supported.""" + # Tests (and some external callers) may set supported features as a + # `set` of `Profiles` members. Normalize to an IntFlag value so + # callers can consistently perform bitwise operations or membership + # checks. + if isinstance(self._attr_supported_features, set): + flags = prof.NONE + for p in self._attr_supported_features: + try: + flags |= p + except Exception: + # ignore non-Profiles items + continue + return flags return self._attr_supported_features def get_ha_metric(self, measurand: str, connector_id: int | None = None): diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index d894c739..0c7c2e99 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -398,7 +398,7 @@ async def set_charge_rate( ) return False - if prof.SMART not in self._attr_supported_features: + if not (int(self.supported_features or 0) & prof.SMART): _LOGGER.info("Smart charging is not supported by this charger") return False @@ -704,7 +704,7 @@ async def update_firmware(self, firmware_url: str, wait_time: int = 0): - firmware_url: http/https URL of the new firmware - wait_time: hours from now to wait before install """ - features = int(self._attr_supported_features or 0) + features = int(self.supported_features or 0) if not (features & prof.FW): _LOGGER.warning("Charger does not support OCPP firmware updating") return False @@ -734,7 +734,7 @@ async def update_firmware(self, firmware_url: str, wait_time: int = 0): async def get_diagnostics(self, upload_url: str): """Upload diagnostic data to server from charger.""" - features = int(self._attr_supported_features or 0) + features = int(self.supported_features or 0) if features & prof.FW: schema = vol.Schema(vol.Url()) try: diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index b9ed1729..6489bfaf 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -10,7 +10,9 @@ SwitchEntity, SwitchEntityDescription, ) +from homeassistant.core import callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from ocpp.v16.enums import ChargePointStatus @@ -20,6 +22,7 @@ CONF_CPIDS, CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS, + DATA_UPDATED, DOMAIN, ICON, ) @@ -203,6 +206,11 @@ def available(self) -> bool: ) return bool(self.central_system.get_available(self.cpid, target_conn)) + @property + def should_poll(self) -> bool: + """Don't poll - updates will be pushed.""" + return False + @property def is_on(self) -> bool: """Return true if the switch is on.""" @@ -250,3 +258,26 @@ async def async_turn_off(self, **kwargs): self.cpid, self.entity_description.off_action, connector_id=target_conn ) self._state = not resp + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + @callback + def update(*args): + """Pass through real-time updates to state.""" + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + # subscribe to updates + self.async_on_remove(async_dispatcher_connect(self.hass, DATA_UPDATED, update)) + + # Ensure switch publishes its current state immediately after being added + self.async_schedule_update_ha_state(True) diff --git a/tests/conftest.py b/tests/conftest.py index f0c15dfb..3a2dd6c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ create_configuration, remove_configuration, ) +from homeassistant.core import State pytest_plugins = "pytest_homeassistant_custom_component" @@ -46,11 +47,16 @@ def bypass_get_data_fixture(): """Skip calls to get data from API.""" future = asyncio.Future() future.set_result(websockets.asyncio.server.Server) + # Return a HomeAssistant State object instead of a plain string. Some HA + # helpers expect a State instance (with attributes) during restore/cleanup. with ( patch("websockets.asyncio.server.serve", return_value=future), patch("websockets.asyncio.server.Server.close"), patch("websockets.asyncio.server.Server.wait_closed"), - patch("homeassistant.core.StateMachine.get", return_value="test_cp_id"), + patch( + "homeassistant.core.StateMachine.get", + return_value=State("sensor.test_cp_id", "test_cp_id"), + ), ): yield From 73c4510532a780b53f44f35f511084f1ece72eba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:39:20 +1300 Subject: [PATCH 342/370] build(deps): bump websockets from 15.0.1 to 16.0 (#1862) Bumps [websockets](https://github.com/python-websockets/websockets) from 15.0.1 to 16.0. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/15.0.1...16.0) --- updated-dependencies: - dependency-name: websockets dependency-version: '16.0' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3fea8e18..a8909d16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ colorlog==6.10.1 uv>=0.4 ruff==0.14.7 ocpp==2.1.0 -websockets==15.0.1 +websockets==16.0 jsonschema==4.25.1 pre-commit==4.5.0 pytest-homeassistant-custom-component==0.13.297 From 78e3f5d3a3fecf823f32e6d4e2048c4f4f2b7066 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:45:21 +1300 Subject: [PATCH 343/370] build(deps): bump pre-commit from 4.5.0 to 4.5.1 (#1861) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.5.0 to 4.5.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.0...v4.5.1) --- updated-dependencies: - dependency-name: pre-commit dependency-version: 4.5.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a8909d16..6a20653c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ ruff==0.14.7 ocpp==2.1.0 websockets==16.0 jsonschema==4.25.1 -pre-commit==4.5.0 +pre-commit==4.5.1 pytest-homeassistant-custom-component==0.13.297 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 85be6c432927dcfbac09c2e21c015cdf05161898 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:19 +1300 Subject: [PATCH 344/370] build(deps): bump jsonschema from 4.25.1 to 4.26.0 (#1860) Bumps [jsonschema](https://github.com/python-jsonschema/jsonschema) from 4.25.1 to 4.26.0. - [Release notes](https://github.com/python-jsonschema/jsonschema/releases) - [Changelog](https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst) - [Commits](https://github.com/python-jsonschema/jsonschema/compare/v4.25.1...v4.26.0) --- updated-dependencies: - dependency-name: jsonschema dependency-version: 4.26.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6a20653c..54c06f3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ uv>=0.4 ruff==0.14.7 ocpp==2.1.0 websockets==16.0 -jsonschema==4.25.1 +jsonschema==4.26.0 pre-commit==4.5.1 pytest-homeassistant-custom-component==0.13.297 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From 0354d5f4ad55da910aff12dfd9ffa1b6c7ec17fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:53:34 +1300 Subject: [PATCH 345/370] build(deps): bump actions/setup-python from 6.0.0 to 6.2.0 (#1849) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.0.0 to 6.2.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6.0.0...v6.2.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4591cf28..e3008677 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v5 - name: 🛠️ Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.2.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d1356111..defe8ebe 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -71,7 +71,7 @@ jobs: - name: Check out code from GitHub uses: "actions/checkout@v5" - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v6.0.0" + uses: "actions/setup-python@v6.2.0" with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From e8ef1c2ac7a7089b0e01218d86d40595b68985fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:53:57 +1300 Subject: [PATCH 346/370] build(deps): bump release-drafter/release-drafter from 6.1.0 to 6.2.0 (#1848) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v6.1.0...v6.2.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index c391fea5..cc32d5a6 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v6.1.0 + uses: release-drafter/release-drafter@v6.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 2f762802912471d476b85fcb136395561dcb7053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:54:28 +1300 Subject: [PATCH 347/370] build(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 (#1828) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e3008677..acf87677 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v5.0.0 + uses: actions/upload-artifact@v6.0.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From 2b90d56abbd1516e4dcab676d588275980915044 Mon Sep 17 00:00:00 2001 From: Fil Mackay Date: Mon, 16 Feb 2026 17:19:21 +1100 Subject: [PATCH 348/370] Improve the validation and user feedback for the charge point identity (#1886) * Update switch to push updates - not be polled * Update HA states after connection Ensure Home Assistant states are updated immediately after connection. * Change async_write_ha_state to async_schedule_update_ha_state Refactor state update method for switch entity - for consistency. * Formatting update * fix test errors * Improve supported feature coding * add TypeError guard for None * Fix for config flow charger name --------- Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- custom_components/ocpp/config_flow.py | 52 +++++++++++-------- custom_components/ocpp/translations/en.json | 3 +- .../ocpp/translations/i-default.json | 3 +- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 20b0b91e..b3049b26 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -7,6 +7,7 @@ ConfigFlowResult, CONN_CLASS_LOCAL_PUSH, ) +from homeassistant.helpers import config_validation as cv import voluptuous as vol from .const import ( @@ -79,10 +80,7 @@ STEP_USER_CP_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_CPID, default=DEFAULT_CPID): vol.All( - str, - vol.Match(r"^[a-z0-9_$]+$", msg="Use only lower case, digits and _"), - ), + vol.Required(CONF_CPID, default=DEFAULT_CPID): str, vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, vol.Required( CONF_MONITORED_VARIABLES_AUTOCONFIG, @@ -168,26 +166,34 @@ async def async_step_cp_user( if user_input is not None: # Don't allow duplicate cpids to be used self._async_abort_entries_match({CONF_CPID: user_input[CONF_CPID]}) - - cp_data = { - **user_input, - CONF_NUM_CONNECTORS: self._detected_num_connectors, - } - cpids_list = self._data.get(CONF_CPIDS, []).copy() - cpids_list.append({self._cp_id: cp_data}) - self._data = {**self._data, CONF_CPIDS: cpids_list} - - if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: - self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( - DEFAULT_MONITORED_VARIABLES - ) - self.hass.config_entries.async_update_entry( - self._entry, data=self._data - ) - return self.async_abort(reason="Added/Updated charge point") - + # Validate cpid format against entity id requirements (lowercase letters, digits and _) + schema = vol.Schema( + {vol.Required(CONF_CPID): cv.matches_regex(r"^[\da-z_]+$")} + ) + try: + schema({CONF_CPID: user_input[CONF_CPID]}) + except vol.Invalid: + errors["base"] = "invalid_cpid" else: - return await self.async_step_measurands() + cp_data = { + **user_input, + CONF_NUM_CONNECTORS: self._detected_num_connectors, + } + cpids_list = self._data.get(CONF_CPIDS, []).copy() + cpids_list.append({self._cp_id: cp_data}) + self._data = {**self._data, CONF_CPIDS: cpids_list} + + if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: + self._data[CONF_CPIDS][-1][self._cp_id][ + CONF_MONITORED_VARIABLES + ] = DEFAULT_MONITORED_VARIABLES + self.hass.config_entries.async_update_entry( + self._entry, data=self._data + ) + return self.async_abort(reason="Added/Updated charge point") + + else: + return await self.async_step_measurands() return self.async_show_form( step_id="cp_user", diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 55ac39bd..7bb44931 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -61,7 +61,8 @@ }, "error": { "auth": "Username/Password is wrong.", - "no_measurands_selected": "No measurand selected: please select at least one" + "no_measurands_selected": "No measurand selected: please select at least one", + "invalid_cpid": "Invalid charge point identity name: use only lower case letters, digits and _" }, "abort": { "single_instance_allowed": "Only a single instance is allowed", diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 6b9f424f..2f0a6152 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -61,7 +61,8 @@ }, "error": { "auth": "Username/Password is wrong.", - "no_measurands_selected": "No measurand selected: please select at least one" + "no_measurands_selected": "No measurand selected: please select at least one", + "invalid_cpid": "Invalid charge point identity name: use only lower case letters, digits and _" }, "abort": { "single_instance_allowed": "Only a single instance is allowed", From ba55b1e80975062ca364ed9ea511942d86f8e34c Mon Sep 17 00:00:00 2001 From: altserg <79573763+altserg@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:34:39 +0100 Subject: [PATCH 349/370] Ensure entity IDs are properly slugified across OCPP components (#1891) Fixed #1878 and #1873 --- custom_components/ocpp/button.py | 3 ++- custom_components/ocpp/number.py | 3 ++- custom_components/ocpp/sensor.py | 3 ++- custom_components/ocpp/switch.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index cc4a536d..6722a3fc 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -15,6 +15,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.util import slugify from .api import CentralSystem from .const import ( @@ -164,7 +165,7 @@ def __init__( object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" else: object_id = f"{self.cpid}_{self.entity_description.key}" - self.entity_id = f"{BUTTON_DOMAIN}.{object_id}" + self.entity_id = f"{BUTTON_DOMAIN}.{slugify(object_id)}" @property def available(self) -> bool: diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 592b8a6a..8302cc66 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util import slugify from .api import CentralSystem from .const import ( @@ -188,7 +189,7 @@ def __init__( object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" else: object_id = f"{self.cpid}_{self.entity_description.key}" - self.entity_id = f"{NUMBER_DOMAIN}.{object_id}" + self.entity_id = f"{NUMBER_DOMAIN}.{slugify(object_id)}" self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index f060870a..cc62490f 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.util import slugify from .api import CentralSystem from .const import ( @@ -224,7 +225,7 @@ def __init__( object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" else: object_id = f"{self.cpid}_{self.entity_description.key}" - self.entity_id = f"{SENSOR_DOMAIN}.{object_id}" + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(object_id)}" self._attr_icon = ICON self._attr_native_unit_of_measurement = None diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 6489bfaf..3cf2bf96 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util import slugify from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem @@ -196,7 +197,7 @@ def __init__( object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" else: object_id = f"{self.cpid}_{self.entity_description.key}" - self.entity_id = f"{SWITCH_DOMAIN}.{object_id}" + self.entity_id = f"{SWITCH_DOMAIN}.{slugify(object_id)}" @property def available(self) -> bool: From ff30ac0c54067bb3f1731d14f62f898575a07906 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:31:06 +1300 Subject: [PATCH 350/370] build(deps): bump pytest-homeassistant-custom-component from 0.13.297 to 0.13.315 (#1889) * build(deps): bump pytest-homeassistant-custom-component Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.297 to 0.13.315. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.297...0.13.315) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.315 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Remove pycares version due to pytest error Remove problematic pycares version from requirements. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 54c06f3c..fe016036 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -pycares==4.9.0 # new version causes an error in pytest colorlog==6.10.1 uv>=0.4 ruff==0.14.7 @@ -6,6 +5,6 @@ ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 pre-commit==4.5.1 -pytest-homeassistant-custom-component==0.13.297 +pytest-homeassistant-custom-component==0.13.315 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From bd4a8eeb57f581eaed2e56e913d5e1460628c9c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:31:44 +1300 Subject: [PATCH 351/370] build(deps): bump ruff from 0.14.7 to 0.15.1 (#1890) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.7 to 0.15.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.14.7...0.15.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fe016036..0fb4ab76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.14.7 +ruff==0.15.1 ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 From 25d5ab74898018068d43f73024eec69e87547b0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:37:20 +1300 Subject: [PATCH 352/370] build(deps): bump release-drafter/release-drafter from 6.2.0 to 7.0.0 (#1914) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.2.0 to 7.0.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v6.2.0...v7.0.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index cc32d5a6..aec5995b 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v6.2.0 + uses: release-drafter/release-drafter@v7.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 5b581e0edebd7167d2c8ef1468f83f8af0003508 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:37:55 +1300 Subject: [PATCH 353/370] build(deps): bump crazy-max/ghaction-github-labeler from 5.3.0 to 6.0.0 (#1903) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.3.0 to 6.0.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/v5.3.0...v6.0.0) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2444aa1a..f443c2c9 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@v5 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v5.3.0 + uses: crazy-max/ghaction-github-labeler@v6.0.0 with: skip-delete: true From 4168e1d853faa3d2f01582a388dd1008b23e92e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:38:28 +1300 Subject: [PATCH 354/370] build(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#1901) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6.0.0...v7.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index acf87677..ad0dc232 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 if: ${{ github.event_name == 'push' }} with: name: ocpp From 4fb452f792c37b8b88f73119ba2091b3f2ff84b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:39:02 +1300 Subject: [PATCH 355/370] build(deps): bump ruff from 0.15.1 to 0.15.2 (#1895) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.1 to 0.15.2. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.1...0.15.2) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0fb4ab76..3118e13d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.15.1 +ruff==0.15.2 ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 From bd8e8f6f9d5b429714b08306a33fc4cc1e2270c3 Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:45:08 +0000 Subject: [PATCH 356/370] Split from PR #1900 - Neutral Voltage Override (#1917) * Split from PR #1900 - Neutral Voltage Fix. * fix imports in test_more_coverage_chargepoint.py --- custom_components/ocpp/chargepoint.py | 7 ++++ tests/test_more_coverage_chargepoint.py | 52 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 49a5a881..ec36cbf0 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -743,6 +743,13 @@ def _sum_l123(phase_info: dict) -> float: metric_value: float | None = None mname = str(metric) + # --- THE NEUTRAL SHIELD --- + # If the charger sends the "N" phase on its own, skip it to prevent overwriting the real voltage. + active_phases = set(phase_info.keys()) - {"unit"} + if active_phases == {"N"}: + continue + # -------------------------- + if metric in [Measurand.voltage.value]: if not phase_info.keys().isdisjoint(line_to_neutral_phases): # Line to neutral voltages are averaged diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py index 094db0cb..55a60cf7 100644 --- a/tests/test_more_coverage_chargepoint.py +++ b/tests/test_more_coverage_chargepoint.py @@ -9,7 +9,7 @@ from websockets.protocol import State from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue - +from ocpp.v16.enums import Measurand, Phase # Reuse the client helpers & fixtures from your main v16 test module. from .test_charge_point_v16 import wait_ready, ChargePoint @@ -513,3 +513,53 @@ async def test_session_and_lifetime_eair_distinction(hass): main_after_life2 = srv2._metrics[(1, "Energy.Active.Import.Register")].value # Lifetime EAIR should be updated to 123.45 kWh. assert pytest.approx(main_after_life2, rel=1e-6) == 123.45 + + +@pytest.mark.asyncio +async def test_process_phases_neutral_shield(): + """Test that isolated Neutral (N) phases do not overwrite main sensors with 0.0.""" + from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue + from unittest.mock import MagicMock + + # 1. Setup Mock ChargePoint + cp = MagicMock(spec=BaseCP) + + class MockMetric: + def __init__(self): + self.value = None + self.unit = None + self.extra_attr = {} + + # Pre-populate the main Voltage bucket + cp._metrics = {(1, Measurand.voltage.value): MockMetric()} + + # 2. PASS 1: The Valid L1-N Payload (Positional format: measurand, value, phase, unit, context, location) + payload_l1n = [ + MeasurandValue( + Measurand.voltage.value, + 241.5, + Phase.l1_n.value, + "V", + None, + None, + ) + ] + BaseCP.process_phases(cp, payload_l1n, connector_id=1) + + assert cp._metrics[(1, Measurand.voltage.value)].value == 241.5 + + # 3. PASS 2: The Isolated "N" Payload (The Saboteur) + payload_n = [ + MeasurandValue( + Measurand.voltage.value, + 2.1, + Phase.n.value, + "V", + None, + None, + ) + ] + BaseCP.process_phases(cp, payload_n, connector_id=1) + + # Shield should protect the 241.5 value! + assert cp._metrics[(1, Measurand.voltage.value)].value == 241.5 From bd0aa3d2faff91a85c0b885cb40eba4380434eb3 Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:10:42 +0000 Subject: [PATCH 357/370] Supported Measurands Fix (SyncEV) (#1900) * Supported Measurands and Neutral Voltage Fix (SyncEV) Fixes for Supported Measurands (strip phases as they shouldn't be here) Fixes for Neutral only phase (.N) overriding (L1-N) on the voltage meter setting the main sensor to 0.0 * Linting and testing fixes Linting and testing fixes * More Testing Tweaks * Refactor test for process_measurands in chargepoint Removed assertions for empty list in process_phases and related test scenario. * Add test for isolated Neutral phases in chargepoint Refactor test for processing phases with neutral shield logic. * Refactor import statements in test_process_phases_neutral_shield * Update test_more_coverage_chargepoint.py * Update test_more_coverage_chargepoint.py * Reworked Measurand Matching, Split Neutral fix into #1917 Reworked the reported Measurand matching to allowed values (thanks (@drc38) as a cleaner fix. Removed the Neutral Voltage Override fix and created PR #1917 * Tokenise Matching for Substrings Thanks CodeRabbit... :) Use token-aware measurand matching and handle Unknown sentine * Don't early-return in _filter_measurands Remove the early return and instead only attempt parsing when the string is present and not "Unknown". Pass back the default measurand at the end if nothing matched. * Reverted back to returning "" to blank lists (this is intentional) * Make 'Unknown' check case-insensitive Treat the sentinel value "Unknown" case-insensitively in _filter_measurands. --- custom_components/ocpp/ocppv16.py | 46 +++++++++++++++++---- tests/test_more_coverage_chargepoint.py | 53 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 0c7c2e99..90b09dbf 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -57,6 +57,7 @@ ChargerSystemSettings, DEFAULT_MEASURAND, HA_ENERGY_UNIT, + MEASURANDS, ) _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -149,6 +150,36 @@ async def get_heartbeat_interval(self): async def get_supported_measurands(self) -> str: """Get comma-separated list of measurands supported by the charger.""" + + def _filter_measurands(raw_csv: str) -> str: + """Keep only compliant measurands found as tokens in the charger's string.""" + # Protect against empty lists and the "Unknown" sentinel (checked by test_measurands_manual_set_rejected_returns_empty) + if not raw_csv or raw_csv.strip().lower() == "unknown": + return "" + + matched = [] + for token in raw_csv.split(","): + token = token.strip() + if not token: + continue + + for m in MEASURANDS: + # Token-aware match: Exact match OR prefix match with a dot (e.g. "Voltage.L1") + if token == m or token.startswith(f"{m}."): + if m not in matched: + matched.append(m) + break # Match found for this token, move to the next one + + if not matched: + _LOGGER.debug( + "Charger '%s' returned no valid measurands; falling back to %s.", + self.id, + DEFAULT_MEASURAND, + ) + return DEFAULT_MEASURAND + + return ",".join(matched) + all_measurands = self.settings.monitored_variables or "" autodetect_measurands = bool(self.settings.monitored_variables_autoconfig) key = ckey.meter_values_sampled_data.value @@ -159,7 +190,6 @@ async def get_supported_measurands(self) -> str: effective_csv: str = "" if autodetect_measurands: - # One-shot CSV attempt if desired_csv: _LOGGER.debug( "'%s' attempting CSV set for measurands: %s", self.id, desired_csv @@ -188,15 +218,16 @@ async def get_supported_measurands(self) -> str: ex, ) - # Always read back what the charger actually has + # Read from charger and filter it using lenient logic chgr_csv = await self.get_configuration(key) + chgr_csv = _filter_measurands(chgr_csv) if not effective_csv: _LOGGER.debug( "'%s' measurands not configurable by integration", self.id ) _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, chgr_csv) - return chgr_csv or "" + return chgr_csv _LOGGER.debug( "Returning accepted measurands for '%s': '%s'", self.id, effective_csv @@ -232,15 +263,16 @@ async def get_supported_measurands(self) -> str: else: effective_csv = await self.get_configuration(key) + # Filter whatever resulted from the manual path + effective_csv = _filter_measurands(effective_csv) + if effective_csv: _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, effective_csv) - # Only configure if we successfully set our desired CSV - if desired_csv and effective_csv == desired_csv: - await self.configure(key, effective_csv) + await self.configure(key, effective_csv) else: _LOGGER.debug("'%s' measurands not configurable by integration", self.id) - return effective_csv or "" + return effective_csv async def set_standard_configuration(self): """Send configuration values to the charger.""" diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py index 55a60cf7..a26b4cb6 100644 --- a/tests/test_more_coverage_chargepoint.py +++ b/tests/test_more_coverage_chargepoint.py @@ -8,7 +8,11 @@ import websockets from websockets.protocol import State + from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue +from custom_components.ocpp.ocppv16 import ChargePoint as CPv16 +from custom_components.ocpp.const import DEFAULT_MEASURAND +from unittest.mock import MagicMock, AsyncMock from ocpp.v16.enums import Measurand, Phase # Reuse the client helpers & fixtures from your main v16 test module. @@ -563,3 +567,52 @@ def __init__(self): # Shield should protect the 241.5 value! assert cp._metrics[(1, Measurand.voltage.value)].value == 241.5 + + +@pytest.mark.timeout(5) +async def test_ocppv16_clean_measurands_logic(): + """Test that get_supported_measurands strips illegal phases and drops garbage.""" + + # 1. Setup Mock v1.6 ChargePoint + cp = MagicMock(spec=CPv16) + cp.id = "test_charger" + cp.settings = MagicMock() + # Force the non-autodetect path to keep the test fast and isolated + cp.settings.monitored_variables = "" + cp.settings.monitored_variables_autoconfig = False + cp.configure = AsyncMock() + + # Scenario A: Real-world messy data from a broken firmware charger + dirty_string = ( + "Voltage.L1-N, Voltage.N, Temperature, Current.Offered.L1, " + "Current.Import.L1, Power.Active.Import.L1, , " + "Energy.Active.Import.Register.L1, GarbageText.L2, " + "Current.Export.L2, Current.Export.L3, " + ) + cp.get_configuration = AsyncMock(return_value=dirty_string) + + result_a = await CPv16.get_supported_measurands(cp) + + result_set = set(result_a.split(",")) + assert result_set == { + "Voltage", + "Temperature", + "Current.Offered", + "Current.Import", + "Power.Active.Import", + "Energy.Active.Import.Register", + "Current.Export", + } + assert "GarbageText" not in result_set + + # Scenario B: Complete garbage (Should fall back to DEFAULT_MEASURAND) + cp.get_configuration = AsyncMock(return_value="TotalGarbage.L1, Random.String.N") + result_b = await CPv16.get_supported_measurands(cp) + + assert result_b == DEFAULT_MEASURAND + + # Scenario C: Empty string / None + cp.get_configuration = AsyncMock(return_value="") + result_c = await CPv16.get_supported_measurands(cp) + + assert result_c == "" From 2b783c605756a487fa93ea9a0a52577a1872e2fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:39:47 +1300 Subject: [PATCH 358/370] build(deps): bump release-drafter/release-drafter from 7.0.0 to 7.1.0 (#1922) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.0.0 to 7.1.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v7.0.0...v7.1.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index aec5995b..2b264d3d 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v7.0.0 + uses: release-drafter/release-drafter@v7.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7ceb78f20d2c1a586233b069b71bf765730ae8d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:40:19 +1300 Subject: [PATCH 359/370] build(deps): bump ruff from 0.15.2 to 0.15.6 (#1919) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.2 to 0.15.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.2...0.15.6) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3118e13d..72f69031 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.15.2 +ruff==0.15.6 ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 From a3af550329ee1c8e7e780501a68cc19620418e1e Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:44:22 +0000 Subject: [PATCH 360/370] Use Python 3.14 in CI tests (#1926) Update GitHub Actions tests workflow to set DEFAULT_PYTHON to 3.14 (was 3.13). --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index defe8ebe..32b8532e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ on: pull_request: env: - DEFAULT_PYTHON: "3.13" + DEFAULT_PYTHON: "3.14" jobs: pre-commit: From fb6245c732c9bbaa23e1d6b781b449bc9af3b4e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:58:26 +1100 Subject: [PATCH 361/370] build(deps): bump pytest-homeassistant-custom-component (#1924) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.315 to 0.13.318. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.315...0.13.318) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.318 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: drc38 <20024196+drc38@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72f69031..16331946 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 pre-commit==4.5.1 -pytest-homeassistant-custom-component==0.13.315 +pytest-homeassistant-custom-component==0.13.318 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 3056c2eeb69905158262ccba08c700274ff69fe7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:22:46 +1300 Subject: [PATCH 362/370] build(deps): bump pytest-homeassistant-custom-component (#1929) Bumps [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) from 0.13.318 to 0.13.319. - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.318...0.13.319) --- updated-dependencies: - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.319 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 16331946..52cf6ec8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 pre-commit==4.5.1 -pytest-homeassistant-custom-component==0.13.318 +pytest-homeassistant-custom-component==0.13.319 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 8fbbaa6556194b0c12724371f2107a848d09ae39 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:23:20 +1300 Subject: [PATCH 363/370] build(deps): bump release-drafter/release-drafter from 7.1.0 to 7.1.1 (#1923) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.1.0 to 7.1.1. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v7.1.0...v7.1.1) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 2b264d3d..6c931ea6 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v7.1.0 + uses: release-drafter/release-drafter@v7.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 2d513a56540695dd972da29a6cc45520fadbaf34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:23:48 +1300 Subject: [PATCH 364/370] build(deps): bump codecov/codecov-action from 5 to 6 (#1931) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 32b8532e..cdb5f09a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -94,7 +94,7 @@ jobs: -rA \ tests - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} verbose: true From 840d141a00fd4cda3c57371b1a505a559889cf20 Mon Sep 17 00:00:00 2001 From: ric866 <94540869+ric866@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:01:22 +0100 Subject: [PATCH 365/370] Fix: Support Session Energy for single-phase chargers sending L1 EAIR tags (3-phase chargers left untouched) (#1943) * Fix EAIR session handling for non-standard chargers by updating phase check logic * Refactor phase handling logic in ChargePoint class to count phases of the eair received and strip only if it's a single phase charger. Maintaining 3phase functionality. * Add test for single-phase tagged EAIR reading handling in session energy calculation * Linting and AIRabbit Fix --- custom_components/ocpp/chargepoint.py | 17 ++++++++++ tests/test_more_coverage_chargepoint.py | 43 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index ec36cbf0..a959128b 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -880,6 +880,13 @@ def process_measurands( unprocessed: list[MeasurandValue] = [] + # Pre-scan: Count how many distinct phases are reported for the main energy register + eair_phases = set() + for v in bucket: + v_measurand = getattr(v, "measurand", None) or DEFAULT_MEASURAND + if v_measurand == DEFAULT_MEASURAND and getattr(v, "phase", None): + eair_phases.add(v.phase) + for idx, sampled_value in enumerate(bucket): measurand = sampled_value.measurand value = sampled_value.value @@ -888,6 +895,16 @@ def process_measurands( location = sampled_value.location context = sampled_value.context or ReadingContext.sample_periodic.value + # Strip the phase tag ONLY if a single-phase charger sends an isolated L1 energy reading. + # If multiple phases exist (e.g., L1, L2), leave them intact so process_phases() can sum them. + normalized_measurand = measurand or DEFAULT_MEASURAND + if ( + normalized_measurand == DEFAULT_MEASURAND + and phase == Phase.l1.value + and len(eair_phases) == 1 + ): + phase = None + # Backwards compatibility if sampled_value.measurand is None: measurand = DEFAULT_MEASURAND diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py index a26b4cb6..9e379e48 100644 --- a/tests/test_more_coverage_chargepoint.py +++ b/tests/test_more_coverage_chargepoint.py @@ -430,6 +430,7 @@ async def test_session_and_lifetime_eair_distinction(hass): When the charger does NOT report session energy in EAIR: - both an in-transaction EAIR reading and a non-transaction EAIR reading write the lifetime EAIR metric. + - a single-phase EAIR reading tagged with "L1" has its phase stripped to derive session energy correctly. """ # ------ First scenario: charger reports session energy ------ @@ -518,6 +519,48 @@ async def test_session_and_lifetime_eair_distinction(hass): # Lifetime EAIR should be updated to 123.45 kWh. assert pytest.approx(main_after_life2, rel=1e-6) == 123.45 + # ------ Third scenario: single-phase charger sending L1 tag on main EAIR ------ + # New CP instance to test the L1 stripping logic. + srv3 = BaseCP( + "cp_dummy3", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv3._ocpp_version = "1.6" + srv3._charger_reports_session_energy = False + + # 1) Set baseline meter_start (Car plugged in at 10.0 kWh) + # The correct string key is "Energy.Meter.Start" + srv3._metrics[(1, "Energy.Meter.Start")].value = 10.0 + srv3._metrics[(1, "Energy.Meter.Start")].unit = "kWh" + + # 2) Send in-transaction EAIR reading with an isolated L1 phase (10500 Wh -> 10.5 kWh) + l1_samples = [ + [ + MeasurandValue( + measurand="Energy.Active.Import.Register", + value=10500.0, + phase="L1", + unit="Wh", + context=None, + location=None, + ) + ] + ] + srv3.process_measurands(l1_samples, is_transaction=True, connector_id=1) + + # 3) Lifetime EAIR should update to 10.5 kWh + main_l1 = srv3._metrics[(1, "Energy.Active.Import.Register")] + assert pytest.approx(main_l1.value, rel=1e-6) == 10.5 + + # 4) Session Energy should derive to 0.5 kWh (10.5 - 10.0) + sess_l1 = srv3._metrics[(1, "Energy.Session")] + assert sess_l1.value == pytest.approx(0.5, rel=1e-6) + @pytest.mark.asyncio async def test_process_phases_neutral_shield(): From cb52ad48eeb1889788bace5c0fbf6863f817b990 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:02:01 +1200 Subject: [PATCH 366/370] build(deps): bump ruff from 0.15.6 to 0.15.8 (#1934) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.6 to 0.15.8. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.6...0.15.8) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 52cf6ec8..1880a623 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.10.1 uv>=0.4 -ruff==0.15.6 +ruff==0.15.8 ocpp==2.1.0 websockets==16.0 jsonschema==4.26.0 From f44c2b7955074de3148c6036c9b57e4a053833da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:02:38 +1200 Subject: [PATCH 367/370] build(deps): bump softprops/action-gh-release from 2 to 3 (#1941) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad0dc232..d87e7af2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: zip ocpp.zip -r ./ - name: 📤 Upload zip to release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 if: ${{ github.event_name == 'release' }} with: files: ${{ github.workspace }}/custom_components/ocpp/ocpp.zip \ No newline at end of file From 36ce2b8c0cd7b9a619c0f939a6c486f770020a35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:03:41 +1200 Subject: [PATCH 368/370] build(deps): bump release-drafter/release-drafter from 7.1.1 to 7.2.0 (#1940) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.1.1 to 7.2.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/v7.1.1...v7.2.0) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 6c931ea6..a7790dbe 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Run release-drafter - uses: release-drafter/release-drafter@v7.1.1 + uses: release-drafter/release-drafter@v7.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 54301937a8e713d058941a990df70303b8ef7528 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:04:09 +1200 Subject: [PATCH 369/370] build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#1942) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d87e7af2..3ea67c3e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - name: 📤 Upload zip to action - uses: actions/upload-artifact@v7.0.0 + uses: actions/upload-artifact@v7.0.1 if: ${{ github.event_name == 'push' }} with: name: ocpp From 6eb2cfc9e1a44305a944baf4cda5ab1990405493 Mon Sep 17 00:00:00 2001 From: Rishabh Vaish Date: Mon, 13 Apr 2026 23:17:32 -0700 Subject: [PATCH 370/370] Allow empty GetConfiguration to retrieve all keys (#1927) * Allow empty GetConfiguration to retrieve all keys (#1907) * style: fix ruff-format line length in test Break long configuration_key list onto multiple lines to satisfy ruff-format pre-commit check. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- custom_components/ocpp/api.py | 4 ++-- custom_components/ocpp/chargepoint.py | 2 +- custom_components/ocpp/ocppv16.py | 16 ++++++++++++++-- custom_components/ocpp/services.yaml | 4 ++-- tests/test_additional_charge_point_v16.py | 8 ++++++-- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 3c45e084..0dec30dc 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -56,7 +56,7 @@ GCONF_SERVICE_DATA_SCHEMA = vol.Schema( { vol.Optional("devid"): cv.string, - vol.Required("ocpp_key"): cv.string, + vol.Optional("ocpp_key"): cv.string, } ) GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( @@ -677,6 +677,6 @@ async def handle_configure(self, call, cp) -> ServiceResponse: @check_charger_available async def handle_get_configuration(self, call, cp) -> ServiceResponse: """Handle the get configuration service call.""" - key = call.data.get("ocpp_key") + key = call.data.get("ocpp_key", "") value = await cp.get_configuration(key) return {"value": value} diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index a959128b..ad67edba 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -450,7 +450,7 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = """Request vendor specific data transfer from charger.""" pass - async def get_configuration(self, key: str = "") -> str | None: + async def get_configuration(self, key: str = "") -> str | dict | None: """Get Configuration of charger for supported keys else return None.""" return None diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 90b09dbf..b80a8a76 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -809,14 +809,26 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ) return False - async def get_configuration(self, key: str = "") -> str: - """Get Configuration of charger for supported keys else return None.""" + async def get_configuration(self, key: str = "") -> str | dict | None: + """Get Configuration of charger for supported keys. + + When key is empty, returns a dict of all configuration key-value pairs. + When key is specified, returns the value as a string. + """ if key == "": req = call.GetConfiguration() else: req = call.GetConfiguration(key=[key]) resp = await self.call(req) if resp.configuration_key: + if key == "": + result = {} + for entry in resp.configuration_key: + entry_key = entry.get("key", "") + entry_value = entry.get(om.value.value, "") + result[entry_key] = entry_value + _LOGGER.debug("Get Configuration returned %d keys", len(result)) + return result value = resp.configuration_key[0][om.value.value] _LOGGER.debug("Get Configuration for %s: %s", key, value) self._metrics[0][cdet.config_response.value].value = datetime.now(tz=UTC) diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index 6d9b5b04..b70969fe 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -138,8 +138,8 @@ get_configuration: example: charger ocpp_key: name: Configuration key name - description: v1.6- Key name v2.0.1- Component name/Key name - required: true + description: v1.6- Key name v2.0.1- Component name/Key name. Leave empty to retrieve all configuration keys (v1.6 only). + required: false advanced: true example: "WebSocketPingInterval" diff --git a/tests/test_additional_charge_point_v16.py b/tests/test_additional_charge_point_v16.py index 81accd69..b6c9e971 100644 --- a/tests/test_additional_charge_point_v16.py +++ b/tests/test_additional_charge_point_v16.py @@ -514,12 +514,16 @@ async def fake_call(req): req, "key", None ) return SimpleNamespace( - configuration_key=[{"value": "42"}], unknown_key=None + configuration_key=[ + {"key": "HeartbeatInterval", "value": "300"}, + {"key": "MeterValueSampleInterval", "value": "60"}, + ], + unknown_key=None, ) monkeypatch.setattr(srv, "call", fake_call, raising=True) val = await srv.get_configuration("") - assert val == "42" + assert val == {"HeartbeatInterval": "300", "MeterValueSampleInterval": "60"} finally: task.cancel() with contextlib.suppress(asyncio.CancelledError):