♻️ Introduce DataUpdateCoordinator and shared ComwattClient #14

Merged
mat merged 1 commit from refactor/coordinator into main 2026-04-24 15:26:57 +00:00
Owner

Summary

Replaces the per-entity sync-polling + cookie-dict-passing pattern with a single DataUpdateCoordinator that owns one long-lived ComwattClient per config entry. Addresses findings C2, C4, C6, H5, H7, H8, M3 and M6 structurally; C3 is fixed as a side-effect.

  • New coordinator.py: one ComwattClient per entry, every HTTP call runs in the executor, single _fetch_all cycle that walks sites → devices → partChilds and also reads switch state. Energy accumulator state lives on the coordinator. On fetch failure it re-authenticates once and retries; on re-auth 401 it raises ConfigEntryAuthFailed, otherwise UpdateFailed.
  • __init__.py: entry.runtime_data now holds the coordinator; async_config_entry_first_refresh maps setup-time failures to the correct HA exceptions; unload is trivial.
  • sensor.py / switch.py: sensors and switch are now CoordinatorEntity subclasses. native_value / is_on are memory-only properties (no I/O, per HA entity contract). available is gated on coordinator success and the entity's key existing in coordinator.data. Switch turn_on/off are async and delegate the blocking client call to the executor, then request a coordinator refresh. SCAN_INTERVAL removed from both platforms; the coordinator's UPDATE_INTERVAL is the single source of truth. Credentials no longer stored on entity instances.
  • Tests: conftest.py patches the new coordinator.ComwattClient import site (plus config_flow.ComwattClient unchanged). test_init.py asserts entry.runtime_data is a ComwattCoordinator. New test_coordinator.py covers three error paths: bad-credential 401 → not loaded, transient 5xx → SETUP_RETRY, one-off failure recovered by re-auth + retry.

Unique ids and device_info identifiers are unchanged ({device_id}_power, {device_id}_total_energy, {device_id}_switch, site_{site_id}_auto_production_rate; identifiers still (DOMAIN, device['name'])). Existing users' entity history and device registry entries keep working. H1/H2 will be addressed in dedicated PRs with a proper migration.

Test plan — please validate on your HA before merging

This is the first PR that changes runtime behavior, so per our agreed workflow it needs hands-on verification against the real Comwatt backend. Suggested checklist:

  1. Pull the branch, copy custom_components/comwatt/ over your HA config, restart.
  2. Confirm the integration finishes setup (no notification about "unavailable").
  3. For each sensor you currently have (power, energy, auto-production-rate): values update within ~2 minutes and match what the Comwatt app shows.
  4. If you have a switchable device: toggle it from HA and confirm the relay flips; state should reconcile within a poll cycle.
  5. Reload the integration (Settings → Devices & services → Comwatt → ⋮ → Reload). It should reload cleanly with no KeyError in the log.
  6. Check the log during an hour of uptime for unexpected errors. A couple of UpdateFailed during transient API blips is normal; a re-auth loop is not.

Followups

  • C7 reauth flow: next PR. The coordinator now raises ConfigEntryAuthFailed correctly, but HA needs an async_step_reauth to actually collect new credentials.
  • H1, H2, H3, H4, H6, L1, L7, M7: unchanged, each gets its own PR later.

Test status locally

  • pytest tests/ — 24 passed
  • ruff check . — clean
  • mypy — clean (existing custom_components.comwatt.* ignore untouched; this PR doesn't narrow it because there are still untyped findings elsewhere — later cleanup PR)
## Summary Replaces the per-entity sync-polling + cookie-dict-passing pattern with a single `DataUpdateCoordinator` that owns one long-lived `ComwattClient` per config entry. Addresses findings **C2, C4, C6, H5, H7, H8, M3 and M6** structurally; C3 is fixed as a side-effect. - **New `coordinator.py`**: one `ComwattClient` per entry, every HTTP call runs in the executor, single `_fetch_all` cycle that walks sites → devices → partChilds and also reads switch state. Energy accumulator state lives on the coordinator. On fetch failure it re-authenticates once and retries; on re-auth 401 it raises `ConfigEntryAuthFailed`, otherwise `UpdateFailed`. - **`__init__.py`**: `entry.runtime_data` now holds the coordinator; `async_config_entry_first_refresh` maps setup-time failures to the correct HA exceptions; unload is trivial. - **`sensor.py` / `switch.py`**: sensors and switch are now `CoordinatorEntity` subclasses. `native_value` / `is_on` are memory-only properties (no I/O, per HA entity contract). `available` is gated on coordinator success **and** the entity's key existing in `coordinator.data`. Switch turn_on/off are async and delegate the blocking client call to the executor, then request a coordinator refresh. `SCAN_INTERVAL` removed from both platforms; the coordinator's `UPDATE_INTERVAL` is the single source of truth. Credentials no longer stored on entity instances. - **Tests**: `conftest.py` patches the new `coordinator.ComwattClient` import site (plus `config_flow.ComwattClient` unchanged). `test_init.py` asserts `entry.runtime_data` is a `ComwattCoordinator`. New `test_coordinator.py` covers three error paths: bad-credential 401 → not loaded, transient 5xx → `SETUP_RETRY`, one-off failure recovered by re-auth + retry. **Unique ids and `device_info` identifiers are unchanged** (`{device_id}_power`, `{device_id}_total_energy`, `{device_id}_switch`, `site_{site_id}_auto_production_rate`; identifiers still `(DOMAIN, device['name'])`). Existing users' entity history and device registry entries keep working. H1/H2 will be addressed in dedicated PRs with a proper migration. ## Test plan — please validate on your HA before merging This is the first PR that changes runtime behavior, so per our agreed workflow it needs hands-on verification against the real Comwatt backend. Suggested checklist: 1. Pull the branch, copy `custom_components/comwatt/` over your HA config, restart. 2. Confirm the integration finishes setup (no notification about "unavailable"). 3. For each sensor you currently have (power, energy, auto-production-rate): values update within ~2 minutes and match what the Comwatt app shows. 4. If you have a switchable device: toggle it from HA and confirm the relay flips; state should reconcile within a poll cycle. 5. Reload the integration (Settings → Devices & services → Comwatt → ⋮ → Reload). It should reload cleanly with no `KeyError` in the log. 6. Check the log during an hour of uptime for unexpected errors. A couple of `UpdateFailed` during transient API blips is normal; a re-auth loop is not. ## Followups - **C7 reauth flow**: next PR. The coordinator now raises `ConfigEntryAuthFailed` correctly, but HA needs an `async_step_reauth` to actually collect new credentials. - H1, H2, H3, H4, H6, L1, L7, M7: unchanged, each gets its own PR later. ## Test status locally - [x] `pytest tests/` — 24 passed - [x] `ruff check .` — clean - [x] `mypy` — clean (existing `custom_components.comwatt.*` ignore untouched; this PR doesn't narrow it because there are still untyped findings elsewhere — later cleanup PR)
♻️ Introduce DataUpdateCoordinator and shared ComwattClient
All checks were successful
Validate / validate-hacs (push) Has been skipped
Validate / validate-hassfest (push) Has been skipped
Validate / lint-ruff (push) Successful in 6s
Validate / test-pytest (push) Successful in 1m45s
Validate / type-check-mypy (push) Successful in 1m49s
Validate / validate-hacs (pull_request) Has been skipped
Validate / validate-hassfest (pull_request) Has been skipped
Validate / lint-ruff (pull_request) Successful in 6s
Validate / test-pytest (pull_request) Successful in 1m45s
Validate / type-check-mypy (pull_request) Successful in 1m48s
915bf1ce4d
Replaces the per-entity sync-polling + cookie-dict passing pattern with
a single `DataUpdateCoordinator` that owns one long-lived
`ComwattClient` for the lifetime of the config entry. Addresses
findings C2, C4, C6, H5, H7, H8, M3 and M6 in one structural pass.

## Changes

- New `coordinator.py` with `ComwattCoordinator`:
  - One `ComwattClient` per entry, kept across polls so session cookies
    and connection pool survive.
  - `_async_update_data` runs every HTTP call inside
    `async_add_executor_job`, so nothing blocks the event loop.
  - A single `_fetch_all` cycle walks sites → devices → (optional)
    partChilds, collects power/energy/auto-production-rate metrics, and
    also refreshes switch state for switchable devices. Topology
    discovery no longer duplicated between `sensor.py` and `switch.py`.
  - Energy accumulator state (last_ts, total) lives on the coordinator
    keyed by device id instead of being sprinkled across entity
    instances.
  - On any fetch exception the coordinator re-authenticates once and
    retries; a 401/403 on re-auth raises `ConfigEntryAuthFailed`, other
    failures map to `UpdateFailed`.

- `__init__.py`:
  - `entry.runtime_data` now holds the coordinator directly.
  - `async_config_entry_first_refresh` turns setup-time failures into
    the right `ConfigEntryAuthFailed` / `ConfigEntryNotReady` for HA.
  - `hass.data[DOMAIN]` is no longer used; unload is a one-liner.

- `sensor.py` and `switch.py`:
  - All three sensors and the switch now subclass
    `CoordinatorEntity[ComwattCoordinator]` and read from
    `coordinator.data`. `native_value` and `is_on` are properties that
    only look at memory — no I/O per the HA entity contract.
  - `available` is gated on coordinator success AND the entity's key
    being present in `coordinator.data` (so a device that disappeared
    on the next poll goes unavailable instead of keeping a stale
    value).
  - Switch turn_on/turn_off are now async; they delegate the blocking
    `client.switch_capacity` to the executor via the coordinator and
    then request a refresh so the UI settles on the real server state.
  - Local `SCAN_INTERVAL` constants removed from both platforms; the
    coordinator's `UPDATE_INTERVAL` is the single source of truth.
  - Credentials are no longer stored on entity instances.

- Tests:
  - `conftest.py` patches `coordinator.ComwattClient` and the existing
    `config_flow.ComwattClient` — those are the only two import sites
    now.
  - `test_init.py` asserts `entry.runtime_data` is a `ComwattCoordinator`
    after setup.
  - `test_sensor.py`'s energy test updated for the new `float`
    representation (coordinator accumulator starts at `0.0`).
  - New `test_coordinator.py` covers the three error paths:
    bad-credential 401 → not loaded, transient upstream 5xx → SETUP_RETRY,
    one-off failure recovered by a re-auth + retry cycle.

## Findings addressed

- C2: blocking I/O on the event loop → coordinator uses executor.
- C4: `ComwattClient` rebuilt per call with a cookie-dict hack → one
  shared client per entry.
- C6: `async_setup_entry` had no error handling → proper
  `ConfigEntryAuthFailed` / `ConfigEntryNotReady` mapping.
- H5: bare `except Exception` that forced a silent re-auth on *every*
  error → narrow to a single re-auth + retry path in the coordinator,
  with distinct `ConfigEntryAuthFailed` vs `UpdateFailed` outcomes.
- H7: sync `turn_on` / `turn_off` → `async_turn_on` / `async_turn_off`
  using executor for the blocking API call.
- H8: `SCAN_INTERVAL` redefined in `sensor.py` and `switch.py` →
  coordinator owns the interval.
- M3: topology walk duplicated across platforms → single
  implementation in `ComwattCoordinator._fetch_all`.
- M6: no `available` fallback → coordinator-driven availability.

## Findings deliberately NOT addressed in this PR

- C3 (credentials duplicated on entity instances) → **done** as a
  side-effect of moving them onto the coordinator.
- C7 (reauth flow): still no `async_step_reauth`; the coordinator
  raises `ConfigEntryAuthFailed` but HA needs the flow to actually
  collect new credentials from the user. Tracked as a follow-up PR.
- H1 (device_info identifiers use device name) / H2 (unique_ids not
  domain-namespaced) / H3 (`has_entity_name`) / H4 (energy
  persistence) / H6 (duplicate entry) / L1 (explicit
  `runtime_data` type alias) / L7 (translations) / M7
  (manifest metadata): unchanged, each gets its own PR.
mat force-pushed refactor/coordinator from 915bf1ce4d
All checks were successful
Validate / validate-hacs (push) Has been skipped
Validate / validate-hassfest (push) Has been skipped
Validate / lint-ruff (push) Successful in 6s
Validate / test-pytest (push) Successful in 1m45s
Validate / type-check-mypy (push) Successful in 1m49s
Validate / validate-hacs (pull_request) Has been skipped
Validate / validate-hassfest (pull_request) Has been skipped
Validate / lint-ruff (pull_request) Successful in 6s
Validate / test-pytest (pull_request) Successful in 1m45s
Validate / type-check-mypy (pull_request) Successful in 1m48s
to 61a61b16e5
All checks were successful
Validate / validate-hacs (push) Has been skipped
Validate / validate-hassfest (push) Has been skipped
Validate / lint-ruff (push) Successful in 7s
Validate / test-pytest (push) Successful in 1m46s
Validate / type-check-mypy (push) Successful in 1m48s
Validate / validate-hacs (pull_request) Has been skipped
Validate / validate-hassfest (pull_request) Has been skipped
Validate / lint-ruff (pull_request) Successful in 7s
Validate / test-pytest (pull_request) Successful in 1m45s
Validate / type-check-mypy (pull_request) Successful in 1m48s
2026-04-24 14:46:07 +00:00
Compare
mat merged commit cde0346525 into main 2026-04-24 15:26:57 +00:00
mat deleted branch refactor/coordinator 2026-04-24 15:26:57 +00:00
mat referenced this pull request from a commit 2026-04-24 18:56:00 +00:00
mat referenced this pull request from a commit 2026-04-24 21:53:11 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
mat/homeassistant-comwatt!14
No description provided.