Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b618340
Add offline unit tests for the Zaptec API client
rhammen Jul 8, 2026
c8f18eb
Extend api.py offline tests to the command/poll/lifecycle wrappers
rhammen Jul 8, 2026
7423998
Retry transient server errors (429/502/503/504) with backoff
rhammen Jul 9, 2026
08e4f59
Map transient setup errors to ConfigEntryNotReady (#392)
rhammen Jul 9, 2026
2e1993b
Use named constants in retry tests and fix docstring (ruff check)
rhammen Jul 10, 2026
ebac029
test: add fake hass/config_entry fixtures for coordinator/entity tests
rhammen Jul 10, 2026
957abde
test: add ZaptecUpdateCoordinator init/validation tests
rhammen Jul 10, 2026
887fdcd
test: add ZaptecUpdateCoordinator.set_update_interval tests
rhammen Jul 10, 2026
2922bab
test: add ZaptecUpdateCoordinator._async_update_data tests
rhammen Jul 10, 2026
2219d93
test: add ZaptecUpdateCoordinator.trigger_poll tests
rhammen Jul 11, 2026
bea3338
test: add ZaptecBaseEntity init and _get_zaptec_value tests
rhammen Jul 11, 2026
6559f54
test: add ZaptecBaseEntity update-handling and logging tests
rhammen Jul 11, 2026
290af38
test: add ZaptecBaseEntity.trigger_poll delegation test
rhammen Jul 11, 2026
8ab7b49
test: address final-review polish findings (is_stopping pin, exceptio…
rhammen Jul 11, 2026
9797c59
test: add sensor.py entity tests
rhammen Jul 11, 2026
c6ad343
test: add switch.py entity tests
rhammen Jul 11, 2026
3469b9a
test: add number.py entity tests
rhammen Jul 11, 2026
d5efed8
test: add button.py entity tests
rhammen Jul 11, 2026
d66cd0f
test: add services.py tests, fix upgrade_firmware/update_firmware yam…
rhammen Jul 11, 2026
65d1b8f
test: add binary_sensor.py entity tests
rhammen Jul 11, 2026
b183167
test: add update.py entity tests
rhammen Jul 11, 2026
267a373
Merge pull request #2 from rhammen/api-offline-tests
rhammen Jul 11, 2026
2c2cb5c
Merge pull request #3 from rhammen/fix/issue-392-retry-transient
rhammen Jul 11, 2026
2f4e9cc
Merge pull request #4 from rhammen/test/coordinator-entity-coverage
rhammen Jul 11, 2026
c8b6a1e
Merge pull request #5 from rhammen/test/platform-entity-coverage
rhammen Jul 11, 2026
7feb36b
fix: relax Installation validation to fields api.py actually uses
rhammen Jul 12, 2026
e4a06e4
fix: add missing Circuit.MaxCurrent validation, split hierarchy charg…
rhammen Jul 12, 2026
3fcc101
docs: disclose Circuit.Name's Task-3 api.py dependency
rhammen Jul 12, 2026
1166ec3
fix: tolerate a null Chargers/Name on a hierarchy Circuit
rhammen Jul 12, 2026
b801a61
fix: relax ChargerFirmware validation to match its own defensive call…
rhammen Jul 12, 2026
efb4518
fix: require DeviceType on hierarchy-sourced chargers, fix stale comment
rhammen Jul 12, 2026
c8c6a6b
fix: block Owner/Service-only writes when the account lacks that role
rhammen Jul 12, 2026
d2c546a
feat: raise a Repair issue for installations with insufficient Zaptec…
rhammen Jul 12, 2026
6af351a
docs: document the User-only role limitations and new Repair issue
rhammen Jul 12, 2026
4560f10
docs: mention charger-level writes are also role-gated
rhammen Jul 12, 2026
2ffc3e4
fix: drop the raw URL from the insufficient_role Repair issue text
rhammen Jul 14, 2026
cf39faf
docs: add implementation plan for role write-gating
rhammen Jul 15, 2026
12aeefd
i18n: add issues.insufficient_role to nb/nl/nn/pl/sv
rhammen Jul 23, 2026
af278b8
docs: remove planning docs (archived to docs/ai-planning-archive)
rhammen Jul 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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ 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. 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 +73,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.


# Installation and setup
Expand Down
33 changes: 25 additions & 8 deletions custom_components/zaptec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,39 @@
from .manager import ZaptecConfigEntry, ZaptecManager
from .services import async_setup_services, async_unload_services
from .zaptec import (
RETRYABLE_HTTP_STATUSES,
AuthenticationError,
Installation,
RequestConnectionError,
RequestError,
RequestTimeoutError,
Zaptec,
ZaptecApiError,
)

_LOGGER = logging.getLogger(__name__)


def _config_entry_error(
err: ZaptecApiError,
) -> ConfigEntryAuthFailed | ConfigEntryNotReady | ConfigEntryError:
"""Map a Zaptec API error from setup login to a HA config-entry error.

Authentication failures are non-recoverable (trigger re-auth). Connection
and timeout errors, and transient server statuses (429/502/503/504), are
recoverable, so we raise ConfigEntryNotReady to let Home Assistant retry
setup automatically instead of failing permanently (issue #392). All other
API errors remain permanent ConfigEntryError failures.
"""
if isinstance(err, AuthenticationError):
return ConfigEntryAuthFailed(str(err))
if isinstance(err, (RequestTimeoutError, RequestConnectionError)):
return ConfigEntryNotReady(str(err))
if isinstance(err, RequestError) and err.error_code in RETRYABLE_HTTP_STATUSES:
return ConfigEntryNotReady(str(err))
return ConfigEntryError(str(err))


PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Expand Down Expand Up @@ -80,15 +103,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Login to the Zaptec account
try:
await zaptec.login()
except AuthenticationError as err:
_LOGGER.error("Authentication failed: %s", err)
raise ConfigEntryAuthFailed from err
except (RequestTimeoutError, RequestConnectionError) as err:
_LOGGER.error("Connection error: %s", err)
raise ConfigEntryNotReady from err
except ZaptecApiError as err:
_LOGGER.error("Zaptec API error: %s", err)
raise ConfigEntryError from err
_LOGGER.error("Zaptec login failed: %s", err)
raise _config_entry_error(err) from err

# Get the structure of devices from Zaptec and determine the zaptec objects to track
tracked_devices = await ZaptecManager.first_time_setup(
Expand Down
45 changes: 45 additions & 0 deletions 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 Down Expand Up @@ -121,6 +122,50 @@ 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 a Repair issue for insufficient write access.

`installation/update` requires the Owner or Service role
(https://docs.zaptec.com/reference/api_installation_id_update_post).
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

issue_id = f"insufficient_role_{installation.id}"
if "Owner" in roles or "Maintainer" in roles:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
return

ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="insufficient_role",
translation_placeholders={
"installation_name": str(installation.get("name", installation.qual_id)),
"role": roles or "None",
},
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
2 changes: 1 addition & 1 deletion custom_components/zaptec/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ restart_charger:
description: Charger identifier
example: 00000000-1111-2222-3333-444444444444

update_firmware:
upgrade_firmware:
name: Update firmware
description: >-
Send update firmware request to the charger. Select charger
Expand Down
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Firmware update"
}
}
},
"issues": {
"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."
}
}
}
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Fastvareoppdatering"
}
}
},
"issues": {
"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."
}
}
}
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Firmware"
}
}
},
"issues": {
"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."
}
}
}
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/nn.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Fastvareoppdatering"
}
}
},
"issues": {
"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."
}
}
}
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Aktualizacja oprogramowania"
}
}
},
"issues": {
"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."
}
}
}
6 changes: 6 additions & 0 deletions custom_components/zaptec/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,11 @@
"name": "Uppdatera mjukvara"
}
}
},
"issues": {
"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."
}
}
}
7 changes: 6 additions & 1 deletion custom_components/zaptec/zaptec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from __future__ import annotations

from .api import Charger, Installation, Zaptec, ZaptecBase
from .const import MISSING, Missing
from .const import MISSING, RETRYABLE_HTTP_STATUSES, Missing
from .exceptions import (
AuthenticationError,
InsufficientRoleError,
RequestConnectionError,
RequestDataError,
RequestError,
RequestRetryError,
RequestTimeoutError,
ZaptecApiError,
Expand All @@ -18,14 +20,17 @@

__all__ = [
"MISSING",
"RETRYABLE_HTTP_STATUSES",
"ZCONST",
"AuthenticationError",
"Charger",
"Installation",
"InsufficientRoleError",
"Missing",
"Redactor",
"RequestConnectionError",
"RequestDataError",
"RequestError",
"RequestRetryError",
"RequestTimeoutError",
"Zaptec",
Expand Down
Loading