Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
8e853c3
fix: remove stale devices for deselected chargers after reconfigure (…
rhammen Jul 29, 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
68 changes: 59 additions & 9 deletions custom_components/zaptec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Container
import logging

from homeassistant.config_entries import ConfigEntry
Expand All @@ -15,6 +16,7 @@
CONF_CHARGERS,
CONF_MANUAL_SELECT,
CONF_PREFIX,
DOMAIN,
REDACT_DUMP_ON_STARTUP,
REDACT_LOGS,
ZAPTEC_POLL_INTERVAL_BUILD,
Expand Down Expand Up @@ -109,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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(
tracked_devices, all_selected_present = await ZaptecManager.first_time_setup(
zaptec=zaptec,
configured_chargers=configured_chargers,
)
Expand Down Expand Up @@ -218,7 +220,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Make a set of the circuit ids from zaptec to check for deprecated Circuit-devices
circuit_ids = {cid for c in manager.zaptec.chargers if (cid := c.get("CircuitId"))}

# Clean up unused device entries with no entities
# Removal needs both: track-all mode has no deselection to detect, and a partial
# API response is indistinguishable from one.
manual_select = configured_chargers is not None
check_untracked = manual_select and all_selected_present

if manual_select and not all_selected_present:
_LOGGER.warning(
"One or more selected chargers were not returned by the Zaptec API "
"this session; skipping removal of untracked devices to avoid deleting "
"a still-selected charger due to a transient/partial response"
)

_cleanup_devices(
hass,
entry,
manager.tracked_devices,
circuit_ids,
check_untracked=check_untracked,
)

return True


def _cleanup_devices(
hass: HomeAssistant,
entry: ZaptecConfigEntry,
tracked_devices: Container[str],
circuit_ids: Container[str],
check_untracked: bool,
) -> None:
"""Remove device entries that no longer belong to this config entry.

Handles devices with no entities, deprecated Circuit-devices, and - only when
`check_untracked` - devices whose zaptec id is no longer tracked. Passing
`check_untracked` while `tracked_devices` may be incomplete would permanently
delete a still-selected charger's device.
"""
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)

Expand All @@ -232,20 +270,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not dev_entities:
device_registry.async_remove_device(dev.id)
continue
# identifiers is a set with a (single) tuple ('zaptec', '<zaptec_id>')
for _, zap_dev_id in dev.identifiers:
for domain, zap_dev_id in dev.identifiers:
# A foreign id is never in tracked_devices, so without this the
# untracked check would delete the device.
if domain != DOMAIN:
continue
if zap_dev_id in circuit_ids:
_LOGGER.warning(
"Detected deprecated Circuit device %s, "
"removing device and associated entities",
zap_dev_id,
)
for ent in dev_entities:
_LOGGER.debug("Deleting entity %s", ent.entity_id)
entity_registry.async_remove(ent.entity_id)
device_registry.async_remove_device(dev.id)
elif check_untracked and zap_dev_id not in tracked_devices:
_LOGGER.warning(
"Detected stale device %s no longer selected, "
"removing device and associated entities",
zap_dev_id,
)
else:
continue

return True
for ent in dev_entities:
_LOGGER.debug("Deleting entity %s", ent.entity_id)
entity_registry.async_remove(ent.entity_id)
device_registry.async_remove_device(dev.id)
# Removing twice would raise KeyError, so stop at the first matching id.
break


def remove_deprecated_entities(hass: HomeAssistant, entry: ZaptecConfigEntry) -> None:
Expand Down
29 changes: 21 additions & 8 deletions custom_components/zaptec/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import asyncio
from collections.abc import Iterable
from collections.abc import Collection, Iterable
import contextlib
from copy import copy
from dataclasses import dataclass
Expand Down Expand Up @@ -174,7 +174,7 @@ def create_entities_from_zaptec(

elif isinstance(obj, Charger):
info = DeviceInfo()
if obj.installation:
if obj.installation is not None:
info["via_device"] = (DOMAIN, obj.installation.id)

entities.extend(
Expand Down Expand Up @@ -229,32 +229,45 @@ async def stream_callback(self, event: dict) -> None:
coordinator.async_update_listeners()

@staticmethod
async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None) -> set[str]:
"""Run the first time setup for the account."""
async def first_time_setup(
zaptec: Zaptec, configured_chargers: Collection[str] | None
) -> tuple[set[str], bool]:
"""Run the first time setup for the account.

Returns the tracked device ids, plus False if a selected charger was missing
from the Zaptec API this session. Callers must not treat a device as
user-deselected while that is False - a transient partial response would
look identical to a deselection.
"""
_LOGGER.debug("Running first time setup")

# Build the Zaptec hierarchy
await zaptec.build()

all_objects = set(zaptec)
tracked_devices = all_objects
all_selected_present = True

# Selected chargers to add
if configured_chargers is not None:
_LOGGER.debug("Configured chargers: %s", configured_chargers)
want = set(configured_chargers)

# Log if there are any objects listed not found in Zaptec
if not_present := want - all_objects:
# Chargers, not every zaptec object: only chargers reach `keep`, so a
# non-charger id would count as present but drop out of tracked_devices.
if not_present := want - {charger.id for charger in zaptec.chargers}:
_LOGGER.error("Charger objects %s not found", not_present)
all_selected_present = False

# Calculate the objects to keep. From the list of chargers we
# want to keep, we also want to keep the installation objects.
keep = set()
for charger in zaptec.chargers:
if charger.id in want:
keep.add(charger.id)
if charger.installation:
# `is not None`, not truthiness: ZaptecBase is a Mapping, so an
# attribute-less installation is falsy - and untracked means deleted.
if charger.installation is not None:
keep.add(charger.installation.id)

if not keep:
Expand All @@ -263,4 +276,4 @@ async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None)
# These objects will be updated by the coordinator
tracked_devices = keep

return tracked_devices
return tracked_devices, all_selected_present
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ ruff==0.15.22
# Copy from manifest.json to get this into the dev container
# without needing to start HA
azure-servicebus==7.14.3
pydantic==2.13.1
pydantic>=2.11.7,<2.14
aiolimiter==1.2.1
4 changes: 3 additions & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
pytest
pytest-asyncio
pytest-mock
pytest-cov
pytest-cov
pytest-homeassistant-custom-component==0.13.324; python_version >= "3.14"
pytest-homeassistant-custom-component==0.13.316; python_version < "3.14"
8 changes: 7 additions & 1 deletion scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ set -e
if [ "$1" == "--skip-api" ]; then
export SKIP_ZAPTEC_API_TEST="true"
fi

# HA-integration tests run under the pytest-hacc harness (autoloads on Linux).
# API-client tests (tests/zaptec/*) run as plain pytest with the harness
# disabled, so their live constants call is not socket-blocked. Coverage
# from both is combined via --cov-append.
# run tests with -s to display printouts and --log-cli-level to get logger output
pytest --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s
pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s
pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append --log-cli-level=INFO -s

# generate coverage report in html and xml
coverage html
Expand Down
Loading
Loading