Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
44fcd53
docs: add design spec for HA test-harness migration
rhammen Jul 25, 2026
1143bf1
test: adopt pytest-homeassistant-custom-component harness with Window…
rhammen Jul 25, 2026
8cd0c0c
test: prevent zaptec_constants fixture from clobbering HA event-loop …
rhammen Jul 25, 2026
6eb68ac
test: add real-harness setup fixtures (mock Zaptec client + MockConfi…
rhammen Jul 25, 2026
bcdb186
test: behavior coverage for ZaptecUpdateCoordinator via real harness
rhammen Jul 25, 2026
cedc13b
test: behavior coverage for ZaptecBaseEntity; xfail documents #410
rhammen Jul 25, 2026
b74943c
test: remove temporary harness smoke test, cover coordinator/entity gaps
rhammen Jul 25, 2026
911d59b
test: document _backed_get MISSING-default/no-normalization divergence
rhammen Jul 25, 2026
0ecebab
test: assert correct entity-availability behavior (#410 is not a bug)
rhammen Jul 25, 2026
650e888
test: unpin pytest-hacc so CI 3.13 matrix leg can install
rhammen Jul 25, 2026
ffa2f78
test: pin pytest-hacc per-Python via markers to match reverted HA on …
rhammen Jul 25, 2026
5475a3b
test: decouple test-job install from requirements.txt (pytest-hacc ow…
rhammen Jul 25, 2026
221406a
test: make requirements.txt + pytest-hacc coexist (relax pydantic pin)
rhammen Jul 25, 2026
06c3eeb
docs: rework HA-harness migration to Linux-native + Option C (#257)
rhammen Jul 26, 2026
95b0800
test: drop committed Windows shim; rely on Linux pytest-hacc autoload
rhammen Jul 26, 2026
70ed43f
ci: run harness + API-client tests as two scoped pytest invocations
rhammen Jul 26, 2026
a77a9b7
test: scripts/test runs harness + API-client invocations, combined co…
rhammen Jul 26, 2026
3ba66e8
docs: explain two-invocation test split (harness vs API-client, #257)
rhammen Jul 26, 2026
6550f73
test: normalize keys in _backed_get via to_under, matching ZaptecBase…
rhammen Jul 26, 2026
b8f212a
test: pin charging-interval test to named constants, keep the relatio…
rhammen Jul 26, 2026
057af4b
test: explain non-obvious white-box test rationale, cover 4th log-for…
rhammen Jul 26, 2026
0b79410
test: explain the mock-name coupling behind test_init.py's entity filter
rhammen Jul 26, 2026
7611e67
test: explain three non-obvious setup choices in trigger_poll/constru…
rhammen Jul 26, 2026
6858e82
test: explain four non-obvious fixture/mock choices in conftest.py
rhammen Jul 26, 2026
4361b0a
docs: remove planning docs (archived to docs/ai-planning-archive)
rhammen Jul 26, 2026
5d9cdaf
merge: pull in upstream #413 (redaction) and #408 (AI policy)
rhammen Jul 26, 2026
9464908
test: drop in-code issue references flagged in PR #414 review
rhammen Jul 26, 2026
e3a7c68
test: split zaptec_constants into tests/zaptec/conftest.py
rhammen Jul 27, 2026
01d997d
test: trim redundant comment block in requirements_test.txt
rhammen Jul 27, 2026
55a5ed1
test: trim wordy docstrings/comments flagged in PR #414 review
rhammen Jul 27, 2026
14c44ad
Block Owner/Service-only writes and warn on insufficient role (#311)
rhammen Aug 26, 2026
0d2893c
Warn about individual chargers the account cannot write to
rhammen Aug 26, 2026
c82bc77
Render CurrentUserRoles in a stable order
rhammen Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ jobs:
-r requirements.txt \
-r requirements_test.txt

- name: Tests suite
- name: Tests suite (HA integration — harness)
run: |
pytest --cov=./custom_components/zaptec --cov-branch
pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch

- name: Tests suite (API client — plain pytest, no harness)
run: |
pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append

18 changes: 18 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,24 @@ To run tests and check test coverage:
report, or enable the "Coverage Gutters" extension to view the coverage
directly in VSCode.

The suite runs as **two pytest invocations**, and `./scripts/test` runs both:

- **HA-integration tests** (`tests/test_*.py`) run under the
`pytest-homeassistant-custom-component` harness, which autoloads on Linux.
Run directly with:
`pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch`
- **API-client tests** (`tests/zaptec/*`) test the vendored `zaptec/` client,
which is destined to become a standalone PyPI library (issue #257) and has no
Home Assistant dependency. They run as plain pytest with the harness disabled
(the harness blocks non-localhost sockets, which would break their live
`api.zaptec.com/api/constants` call):
`pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append`

Because the harness (and its socket block) is process-wide, a bare `pytest`
is not the entry point — use `./scripts/test` or the two commands above. The
HA-integration tests require Linux; run them in the Dev Container (native
Windows is not supported for that half). `tests/zaptec/*` run anywhere.

HA requires [95% coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage/)
for all core integration modules, and while HACS doesn't have the same
requirements, reaching this level is still a goal for this integration.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ Confirmed to work with Zaptec products
* Disable [Zaptec Sense](https://help.zaptec.com/hc/en-GB/article/how-to-manage-zaptec-sense-in-the-zaptec-portal) (aka APM/Automatic Power Management).
* Disable [stand-alone mode](https://help.zaptec.com/hc/en-GB/article/use-stand-alone-mode-for-troubleshooting-and-unstable-internet).

> [!NOTE]
> If the configured account only has the _User_ role on an installation, the
> integration still sets up and works normally for everything that doesn't
> need Owner/Service access (see [Known issues](#known-issues)). Trying to
> change the available current, the 3-to-1 phase switch current, a charger's
> settings, or send a charger command (e.g. restart) will fail with a clear
> error instead of a raw HTTP 403, and Home Assistant will show
> a persistent notice under *Settings → Repairs* naming the affected
> installation and the role it needs. Roles are granted per object, so an
> account can be _Owner_ on an installation and _User_ on one of its chargers:
> in that case the notice names the restricted chargers instead, one notice per
> installation. If this is expected for your setup, you can dismiss it with
> "Ignore" in the Repairs list — it won't come back unless the account's role
> actually changes.

# Known issues

* Sending a _"deauthorize_and_stop"_ command will give an error. This is due to
Expand All @@ -61,6 +76,14 @@ Confirmed to work with Zaptec products
a workaround is to use the more frequently updated _Session total charge_ entity instead. This reduces the delay-issue,
but has a separate drawback where a restart of Home Assistant during a charging session can give a fake spike in the logged
consumption that needs to be manually edited using "Adjust sum" in the Statistics tab of the Developer tools dashboard.
* A Zaptec Portal user with only the _User_ role (no _Owner_ or _Service_) has
significantly reduced access: the installation hierarchy, firmware info,
individual charger detail/state, and the live update stream are all blocked
by the Zaptec API itself, and this integration additionally blocks changing
installation-level current limits, charger settings, and charger commands
(see [Requirements](#requirements)). Online/offline status and operating
mode keep working, since those are
included in the basic charger list the API returns regardless of role.

## Features missing from the API

Expand Down
82 changes: 81 additions & 1 deletion custom_components/zaptec/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import TYPE_CHECKING

from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

Expand All @@ -18,7 +19,7 @@
ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS,
ZAPTEC_POLL_INSTALLATION_TRIGGER_DELAYS,
)
from .zaptec import Charger, Installation, Zaptec, ZaptecApiError, ZaptecBase
from .zaptec import Charger, Installation, Zaptec, ZaptecApiError, ZaptecBase, has_write_role

if TYPE_CHECKING:
from .manager import ZaptecConfigEntry, ZaptecManager
Expand Down Expand Up @@ -121,6 +122,85 @@ async def _async_update_data(self) -> None:
_LOGGER.exception("Fetching data failed")
raise UpdateFailed(err) from err

if isinstance(self.options.zaptec_object, Installation):
self._check_installation_role(self.options.zaptec_object)

def _check_installation_role(self, installation: Installation) -> None:
"""Create or clear the Repair issues for insufficient write access.

`installation/update` requires the Owner or Service role
(https://docs.zaptec.com/reference/api_installation_id_update_post),
as do `chargers/{id}/update` and `chargers/{id}/SendCommand/{id}`.
Roles are per object, so an account can be Owner on the installation
and User on one of its chargers; the two issues are mutually
exclusive, at most one per installation. If CurrentUserRoles hasn't
been observed yet, leave any existing issue alone rather than guessing.

Deliberately calling async_create_issue() again every poll (rather
than only on the first observation) is safe and intentional: HA's
issue registry replaces the existing IssueEntry in place and does not
touch dismissed_version, so a user who has clicked "Ignore" on this
issue in Settings > Repairs stays ignored across every subsequent
poll as long as the role doesn't change. Only deleting the issue
(role becomes sufficient) and later recreating it (role becomes
insufficient again) resets that dismissal -- which is intentional,
since a real role change deserves fresh attention.
"""
roles = installation.get("current_user_roles")
if roles is None:
return

name = str(installation.get("name", installation.qual_id))
issue_id = f"insufficient_role_{installation.id}"
charger_issue_id = f"insufficient_charger_role_{installation.id}"

if has_write_role(roles) is False:
# The installation-level warning covers the account's access to this
# installation; naming individual chargers on top of it would only
# repeat the same remedy.
ir.async_delete_issue(self.hass, DOMAIN, charger_issue_id)
self._create_role_issue(
issue_id,
"insufficient_role",
{"installation_name": name, "role": roles or "None"},
)
return

ir.async_delete_issue(self.hass, DOMAIN, issue_id)

# Chargers carry their own roles, populated by Charger.poll_info(); one
# that hasn't been polled yet reports None and is left out rather than
# assumed restricted.
restricted = sorted(
str(charger.get("name", charger.qual_id))
for charger in installation.chargers
if has_write_role(charger.get("current_user_roles")) is False
)
if not restricted:
ir.async_delete_issue(self.hass, DOMAIN, charger_issue_id)
return

self._create_role_issue(
charger_issue_id,
"insufficient_charger_role",
{"installation_name": name, "chargers": ", ".join(restricted)},
)

def _create_role_issue(
self, issue_id: str, translation_key: str, placeholders: dict[str, str]
) -> None:
"""Raise a non-fixable warning pointing at the Zaptec Portal."""
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
learn_more_url="https://portal.zaptec.com/",
)

async def _trigger_poll(self, zaptec_obj: ZaptecBase) -> None:
"""Trigger a poll update sequence for the given object.

Expand Down
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Firmware update"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Limited access to chargers in {installation_name}",
"description": "The Zaptec account used by this integration does not have the Owner or Service role on the following charger(s) in installation \"{installation_name}\": {chargers}. Changing their settings or sending them commands, such as restart, requires the Owner or Service role.\n\nTo enable these controls, grant Owner or Service access for those chargers to this account in the Zaptec Portal."
},
"insufficient_role": {
"title": "Limited access to {installation_name}",
"description": "The Zaptec account used by this integration only has the following role(s) on installation \"{installation_name}\": {role}. Changing the available current or the 3-to-1 phase switch current requires the Owner or Service role.\n\nTo enable these controls, grant Owner or Service access for this installation to this account in the Zaptec Portal."
}
}
}
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Fastvareoppdatering"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Begrenset tilgang til ladere i {installation_name}",
"description": "Zaptec-kontoen som brukes av denne integrasjonen har ikke Owner- eller Service-rollen på følgende lader(e) i installasjonen «{installation_name}»: {chargers}. Å endre innstillingene deres eller sende dem kommandoer, som omstart, krever Owner- eller Service-rollen.\n\nFor å aktivere disse kontrollene, gi Owner- eller Service-tilgang for disse laderne til denne kontoen i Zaptec Portal."
},
"insufficient_role": {
"title": "Begrenset tilgang til {installation_name}",
"description": "Zaptec-kontoen som brukes av denne integrasjonen har kun følgende rolle(r) på installasjonen «{installation_name}»: {role}. Å endre tilgjengelig strøm eller 3-til-1-fase bytteterskel krever Owner- eller Service-rollen.\n\nFor å aktivere disse kontrollene, gi Owner- eller Service-tilgang for denne installasjonen til denne kontoen i Zaptec Portal."
}
}
}
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Firmware"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Beperkte toegang tot laders in {installation_name}",
"description": "Het Zaptec-account dat door deze integratie wordt gebruikt heeft niet de rol Owner of Service op de volgende lader(s) in installatie \"{installation_name}\": {chargers}. Het wijzigen van hun instellingen of het versturen van opdrachten, zoals herstarten, vereist de rol Owner of Service.\n\nOm deze bedieningselementen in te schakelen, geef Owner- of Service-toegang voor die laders aan dit account in het Zaptec Portal."
},
"insufficient_role": {
"title": "Beperkte toegang tot {installation_name}",
"description": "Het Zaptec-account dat door deze integratie wordt gebruikt heeft alleen de volgende rol(len) op installatie \"{installation_name}\": {role}. Het wijzigen van de beschikbare stroom of de 3-naar-1-fase omschakeldrempel vereist de rol Owner of Service.\n\nOm deze bedieningselementen in te schakelen, geef Owner- of Service-toegang voor deze installatie aan dit account in het Zaptec Portal."
}
}
}
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/nn.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Fastvareoppdatering"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Avgrensa tilgang til ladarar i {installation_name}",
"description": "Zaptec-kontoen som blir brukt av denne integrasjonen har ikkje Owner- eller Service-rolla på følgjande ladar(ar) i installasjonen «{installation_name}»: {chargers}. Å endre innstillingane deira eller sende dei kommandoar, som omstart, krev Owner- eller Service-rolla.\n\nFor å aktivere desse kontrollane, gi Owner- eller Service-tilgang for desse ladarane til denne kontoen i Zaptec Portal."
},
"insufficient_role": {
"title": "Avgrensa tilgang til {installation_name}",
"description": "Zaptec-kontoen som blir brukt av denne integrasjonen har berre følgjande rolle(r) på installasjonen «{installation_name}»: {role}. Å endre tilgjengeleg straum eller 3-til-1-fase bytteterskel krev Owner- eller Service-rolla.\n\nFor å aktivere desse kontrollane, gi Owner- eller Service-tilgang for denne installasjonen til denne kontoen i Zaptec Portal."
}
}
}
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Aktualizacja oprogramowania"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Ograniczony dostęp do ładowarek w {installation_name}",
"description": "Konto Zaptec używane przez tę integrację nie ma roli Owner ani Service dla następujących ładowarek w instalacji „{installation_name}”: {chargers}. Zmiana ich ustawień lub wysyłanie do nich poleceń, takich jak ponowne uruchomienie, wymaga roli Owner lub Service.\n\nAby włączyć te funkcje, nadaj temu kontu dostęp Owner lub Service dla tych ładowarek w portalu Zaptec."
},
"insufficient_role": {
"title": "Ograniczony dostęp do {installation_name}",
"description": "Konto Zaptec używane przez tę integrację ma tylko następującą rolę (role) w instalacji „{installation_name}”: {role}. Zmiana dostępnego prądu lub progu przełączania 3-fazowego na 1-fazowe wymaga roli Owner lub Service.\n\nAby włączyć te funkcje, nadaj temu kontu dostęp Owner lub Service dla tej instalacji w portalu Zaptec."
}
}
}
10 changes: 10 additions & 0 deletions custom_components/zaptec/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,15 @@
"name": "Uppdatera mjukvara"
}
}
},
"issues": {
"insufficient_charger_role": {
"title": "Begränsad åtkomst till laddare i {installation_name}",
"description": "Zaptec-kontot som används av den här integrationen har inte rollen Owner eller Service på följande laddare i installationen \"{installation_name}\": {chargers}. Att ändra deras inställningar eller skicka kommandon till dem, som omstart, kräver rollen Owner eller Service.\n\nFör att aktivera dessa kontroller, ge Owner- eller Service-åtkomst för dessa laddare till det här kontot i Zaptec Portal."
},
"insufficient_role": {
"title": "Begränsad åtkomst till {installation_name}",
"description": "Zaptec-kontot som används av den här integrationen har endast följande roll(er) på installationen \"{installation_name}\": {role}. Att ändra tillgänglig ström eller 3-till-1-fas växlingströskeln kräver rollen Owner eller Service.\n\nFör att aktivera dessa kontroller, ge Owner- eller Service-åtkomst för den här installationen till det här kontot i Zaptec Portal."
}
}
}
5 changes: 4 additions & 1 deletion custom_components/zaptec/zaptec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

from __future__ import annotations

from .api import Charger, Installation, Zaptec, ZaptecBase
from .api import Charger, Installation, Zaptec, ZaptecBase, has_write_role
from .const import MISSING, RETRYABLE_HTTP_STATUSES, Missing
from .exceptions import (
AuthenticationError,
InsufficientRoleError,
RequestConnectionError,
RequestDataError,
RequestError,
Expand All @@ -24,6 +25,7 @@
"AuthenticationError",
"Charger",
"Installation",
"InsufficientRoleError",
"Missing",
"Redactor",
"RequestConnectionError",
Expand All @@ -35,4 +37,5 @@
"ZaptecApiError",
"ZaptecBase",
"get_ocmf_max_reader_value",
"has_write_role",
]
Loading
Loading