diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 1c32a86..6f32aae 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -16,3 +16,17 @@ jobs: - uses: actions/checkout@v4 - name: Hassfest validation uses: home-assistant/actions/hassfest@master + + # Phase 11 PR-17 — HACS validation on every PR. + # Confirms the integration meets HACS's distribution requirements: + # manifest schema (incl. quality_scale: silver from Phase 8 PR-9), + # repository structure, brands entry, version bumps. + validate-hacs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dd687..fb3071d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **Stack-wide regressions caught by `codex review`.** Five functional bugs spanning the Phase 7 / Phase 8 PRs: + - **DWT entry creation failed at first refresh.** The `dashboard_token` entry builder did not copy `CONF_DWT_OE_*` / `CONF_DWT_AEMO_*` / `CONF_DWT_REGION` from the in-progress flow `self._data` into the final entry's `data` + `options`. `_build_dwt_provider()` then raised `ConfigEntryNotReady` (AC-10c) on every new DWT install. (Codex P1, config_flow.py `async_step_dashboard_token`) + - **`static_prd` exposed without a stored static plan.** The Comparators options form rendered all of `ALL_PRICING_MODES` for every comparator, but no flow writes `CONF_AMBER_STATIC_PLAN` / `CONF_LOCALVOLTS_STATIC_PLAN`. Selecting `static_prd` bricked the next reload with `ConfigEntryNotReady`. The form now gates `static_prd` visibility per comparator on whether a static plan is stored. (Codex P2#2, config_flow.py `async_step_comparators`) + - **Reauth dispatcher could not route during startup.** The dispatcher read `entry.runtime_data.coordinator._reauth_provider_id`, but `runtime_data` is only assigned after `async_config_entry_first_refresh()` completes. Auth failures during startup or the first refresh (common after HA restart with an expired Amber / LocalVolts / OpenElectricity key) therefore got `provider_id = None` and aborted with `reauth_provider_unknown`. The dispatcher now falls back to `entry.data[CONF_CURRENT_PROVIDER]` when the coordinator tag is absent. (Codex P2#3, config_flow.py `async_step_reauth`) + - **Reconfigure unreachable for CDR-backed Amber/LV entries.** `_current_plan_provider.id` is `{brand}_{plan_id}` (e.g. `amber_brokerage-xyz`) for CDR users — the install base — never the literal `PROVIDER_AMBER` / `PROVIDER_LOCALVOLTS` slug. Routing on it sent every CDR user to `reconfigure_unsupported`. Dispatcher now reads `entry.data[CONF_CURRENT_PROVIDER]`. (Codex P2#4, config_flow.py `async_step_reconfigure`) + - **Amber schedule endpoint polled every 30s on static/off entries.** `_maybe_poll_amber()` returns early without updating `_last_amber_poll` when Amber mode is static/off, leaving it at `0.0` forever. `_async_update_data()` then re-triggered `_fetch_today_price_schedule()` every coordinator tick on the `_last_amber_poll == 0.0` first-run sentinel — hammering Amber's API with stale or missing credentials for DWT and static-Amber users. Guard now combines the sentinel check with `self._amber_mode == PRICING_MODE_LIVE_API`. (Codex P2#5, coordinator.py `_async_update_data`) + + 13 regression tests added in `tests/test_codex_regression_fixes.py` (source-level + behavioural where the conftest stubs allow). `tests/test_reconfigure.py` updated for the new dispatcher contract. + ### Added +- Hypothesis property-based tests of `tariff_engine` pure functions. Five invariants per v2 research § 7.3: (1) `calc_stepped_cost` is monotonic-non-decreasing in kWh; (2) at threshold it equals `threshold * step1_rate` exactly; (3) above threshold it composes as `step1_cost + (k - threshold) * step2_rate`; (4) `get_stepped_import_rate` returns exactly one of `step1_rate` / `step2_rate`; (5) `get_current_tou_period` returns a known period name or `"unknown"`, with rate matching the period. 9 Hypothesis test classes; ≥200 fuzzed examples per invariant. Final plank toward v3.0 GA. (Phase 11 / PR-18) + +- HACS validation job in CI. The existing `Validation` workflow now also runs `hacs/action@main` with `category: integration` on every push + PR. Hassfest job stays as-is — both validators run side-by-side. Catches HACS distribution issues (manifest schema drift, brands gaps, version bump misses) before merge. (Phase 11 / PR-17) + +- HA test-harness fixture prototypes (`tests/ha_fixtures.py`). Drop-in mocks for `OpenElectricityPriceSource`, `NEMWebPriceSource`, `async_add_external_statistics`, plus a `mock_config_entry_data` factory for DWT-OE entries. NOT auto-applied — the existing 1028 stub-conftest tests stay HA-free per D-P11-1 (dual-mode test strategy). New tests opt in by importing. `pytest-homeassistant-custom-component>=0.13.0` + `hypothesis>=6.100.0` added to `requirements.txt` for the new harness + Hypothesis fuzzing tests. 10 smoke tests cover the fixture shapes. (Phase 11 / PR-16) + +- Blueprints library. Five HA automation blueprints under `custom_components/pricehawk/blueprints/automation/pricehawk/`: `cheapest_plan_alert.yaml` (notify when a retailer would have saved > threshold over 7d), `cheapest_30min_window.yaml` (trigger flexible loads at the lowest-price window), `pause_ev_on_spike.yaml` (suspend EV charger above threshold; hysteresis-aware), `daily_7pm_summary.yaml` (daily cost + savings + best-provider notification), `wholesale_spike_alert.yaml` (early warning when spot price crosses threshold). Users import via the HA "Blueprints" UI with the file URL or by dropping into `/blueprints/automation/pricehawk/`. Ninth and final plank toward v3.0 GA. (Phase 10 / PR-15) + +- Lovelace custom card `pricehawk-cost-card`. Compact card showing today's chosen-plan cost + optional savings line. Auto-registered as a Lovelace resource on entry setup — appears in the "Add Card" picker, no manual "Resources" step required. Best-effort: storage-mode Lovelace gets the auto-register; YAML-mode users see a log-line hint with the resource URL. Eighth plank toward v3.0 GA. (Phase 10 / PR-14) + +- Lit `panel_custom` foundation for the v2 panel. New sidebar entry "PriceHawk v2" at `/pricehawk` registered via HA's `panel_custom` mechanism — auth flows through the host page's WebSocket session, no LLAT in URL (contract per v2 research § Wave 4). The `pricehawk-panel.js` ESM module imports Lit from the unpkg CDN (no build step). Initial content surfaces the Phase 9 PR-11 `sensor.pricehawk_today_cost` + savings + best provider; full UI port from the legacy iframe dashboard deferred to a dedicated Playwright UAT follow-up. The legacy iframe panel at `/pricehawk-dashboard` continues to work during the migration. Module URL carries a `?v={manifest}.{epoch}` cache buster so HACS upgrades invalidate the browser cache cleanly. Seventh plank toward v3.0 GA. (Phase 10 / PR-13) + +- Energy-Dashboard-pickable chosen-plan cost sensor (`sensor.pricehawk_today_cost`). `device_class=MONETARY` + `unit_of_measurement="AUD"` + `state_class=TOTAL` + `last_reset` at midnight together qualify it for HA's Energy Dashboard cost picker. The `unique_id` is provider-INDEPENDENT (`{entry_id}_chosen_plan_today_cost`) so the entity id stays stable across plan swaps — the user's dashboard pick survives migrations between CDR plans and DWT entries. Sixth plank toward v3.0 GA. (Phase 9 / PR-11) + +- External statistics dual-write. The coordinator now writes daily provider costs to BOTH the existing JSON Store AND HA's external statistics on every midnight rollover. One-shot backfill on first setup converts the existing `daily_cost_history` into stats entries (one per provider, batched). Each stat carries `unit_of_measurement="AUD"` + `has_sum=True` with a monotonic cumulative sum so the Energy Dashboard can pick it up as a cost source (PR-11 / 09-02). Negative-cost days (export-heavy with high FiT) produce a small dip in the cumulative sum — HA tolerates this for cost-style stats per docs. JSON Store remains the source of truth until PR-12 / 09-03 (stats-only flip; gated on ≥4w + ≥10 tester reports per ROADMAP). Fifth plank toward v3.0 GA. (Phase 9 / PR-10) + +- HACS Silver compliance tickbox. `manifest.json` declares `quality_scale: "silver"`. New `quality_scale.yaml` documents every Bronze/Silver/Gold/Platinum rule's status (done / exempt / todo) — honest tickbox. Sensor platform declares `PARALLEL_UPDATES = 0` (CoordinatorEntity-backed → unlimited concurrent reads safe). Service handlers (`analyze_csv`, `backfill_history`, `rank_alternatives`) now raise `HomeAssistantError` on missing coordinator and `ServiceValidationError` on malformed input (was: warn + default-fallback). Version bumped to `1.6.0-beta.1`. Closes Phase 8 (Wave 2). (Phase 8 / PR-9) + +- Repairs platform (persistent notifications). PriceHawk now raises HA issue-registry entries when the integration is in a degraded state: `grid_sensor_unavailable` after 10 consecutive None reads (5 min @ 30s coordinator interval) of the configured grid power sensor; `ranking_stale` when the nightly CDR plan ranking job hasn't completed in over 36 hours. Each issue auto-clears on recovery. Multi-entry safe: issue ids prefixed with the entry_id so two PriceHawk entries don't collide on the same issue. Fourth plank of HACS Silver compliance. (Phase 8 / PR-8) + +- Diagnostics platform. The "Download diagnostics" button on the integration page now returns a JSON snapshot of entry data + options + selected coordinator runtime state. Every API key (Amber, OpenElectricity, LocalVolts) and HA token field is replaced with `**REDACTED**` via `async_redact_data`. The CDR plan envelope + per-comparator static-PRD envelopes are also redacted — not for secrecy but to keep the diagnostics output to a usable size (~15 KB per plan envelope adds up fast). A `_redaction_count` integer in the output gives reviewers immediate confidence the redaction list is hitting the targets. Third plank of HACS Silver compliance. (Phase 8 / PR-7) + +- Per-provider reconfigure flow. HA 2024.10+ "Reconfigure" button now opens a per-provider settings page that lets users adjust Amber network/subscription fees, LocalVolts daily supply / buy ceiling / sell floor guard rails, or DWT (OE/AEMO) daily supply charge — without losing accumulated cost history. Narrow scope: credential rotation stays in PR-5 reauth; region swap is deferred (PriceHawk's entry unique_id is region-derived from Phase 7, swapping would invalidate the unique_id contract). Unsupported entry types (CDR-plan entries) abort cleanly with a clear message pointing at the Configure menu. Second plank of HACS Silver compliance. (Phase 8 / PR-6) + - Per-provider reauth flow. When Amber, LocalVolts, or OpenElectricity (DWT-OE) rejects an API key (HTTP 401/403), PriceHawk now raises `ConfigEntryAuthFailed` and HA prompts the user to re-enter credentials via the integration UI. A single `async_step_reauth` dispatcher routes to per-provider sub-steps (`reauth_amber`, `reauth_localvolts`, `reauth_dwt_oe`) based on a `_reauth_provider_id` tag set by the coordinator at the failure site. Accumulated daily cost history is preserved through the reauth — only the rotated field changes. API keys NEVER appear in log messages, UI errors, or exception strings. First plank of HACS Silver compliance. (Phase 8 / PR-5) - Per-comparator pricing-mode opt-in. Each of Amber/Flow Power/LocalVolts gains a three-state mode (`off` / `live_api` / `static_prd`). `live_api` preserves the existing behaviour (REST/WebSocket polling using the user's API key). `static_prd` derives rates from a stored CDR PRD `tariffPeriod` for that retailer — no API hit, no API key required. The new `custom_components/pricehawk/static_pricing.py` module wraps `cdr/evaluator.py`'s window-evaluation helpers so there's a single source of truth for TOU window matching. Legacy `CONF_

_ENABLED` continues to map cleanly to the new mode (back-compat without migration). OptionsFlow's Comparators page now shows three mode selectors instead of three booleans. Flow Power `static_prd` is deferred (falls back to `live_api`) — Flow Power's internal margin math reads `set_wholesale_rate`, not `set_current_rates`; bridging that needs `flow_power.py` changes scheduled for a follow-up PR. Closes Phase 7 / Wave 1. (Phase 7 / PR-4) diff --git a/DECISIONS.md b/DECISIONS.md index b462678..b7d6300 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,6 +5,85 @@ +## 2026-05-22 — Phase 11 Plan 01 (HA test harness fixtures) + +### D-P11-1 — Dual-mode test strategy: existing stub-conftest stays; new tests opt in to HA harness +**Decision:** PR-16 adds `pytest-homeassistant-custom-component` to `requirements.txt` and ships `tests/ha_fixtures.py` with drop-in mocks. The existing 1028 stub-conftest tests are NOT migrated — they stay HA-free. New tests written from PR-16 onward can opt into the HA harness by importing from `tests.ha_fixtures`. +**Rationale:** Migrating 1028 tests in a single PR would block review for weeks and risk regressions in well-tested code (tariff engine, CDR ranking, providers). Dual-mode lets the migration happen organically — each touch of an existing test module can swap to the HA harness in its PR. The fixture file is the bridge: it defines mock shapes (OE client, NEMWeb client, recorder external-stats, config entry data) that match the production contracts so tests can pick whichever style fits. +**Consequences:** CI now installs `pytest-homeassistant-custom-component` + `hypothesis`. The stub-based `tests/conftest.py` and the HA-harness route co-exist. Future tests should prefer the HA harness for anything that touches `ConfigEntry`, `hass`, or the recorder; the stub conftest is for pure-Python helper tests (tariff windows, CDR parsers, etc.). Migration window is open-ended — no forcing function until the HA harness becomes load-bearing for a specific PR. + +## 2026-05-22 — Phase 10 Plan 03 (blueprints library) + +### D-P10-3 — Five blueprints shipped as YAML files; HA users import via UI or filesystem +**Decision:** Five HA automation blueprints live under `custom_components/pricehawk/blueprints/automation/pricehawk/` and ship with the integration. Each is a self-contained YAML file with `blueprint.input` selectors so non-developer users can wire them via the HA "Blueprints" UI. Source URL points at the GitHub repo so the HA importer can fetch directly. No code change required to enable — HA's blueprint loader picks up YAML files dropped into the appropriate path on next restart. +**Rationale:** Blueprints are the lowest-friction extension surface in HA. Bundling them with the integration means a fresh PriceHawk install gets 5 useful automations out-of-the-box without the user having to find + copy + paste YAML. Each blueprint covers a use case from the v2 research § 6 list. Tests cover YAML-parse, source_url presence, input/trigger presence, plus per-blueprint contract (e.g. pause_ev hysteresis trigger shape). +**Consequences:** Renaming a blueprint file or moving it within the directory will break imported automations (HA tracks blueprints by source URL). Each blueprint file's `source_url:` field is the contract — must match the GitHub raw URL for the file path. Users running the HA blueprint importer dialogue will fetch the file from that URL. + +## 2026-05-22 — Phase 10 Plan 02 (Lovelace custom card + auto-resource) + +### D-P10-2 — Best-effort auto-register Lovelace resource; YAML-mode users get a log hint +**Decision:** `register_lovelace_card_resource` reaches into `hass.data["lovelace"].resources` and calls `async_create_item({"res_type": "module", "url": LOVELACE_CARD_RESOURCE_URL})` on entry setup. In storage-mode Lovelace this lands the resource as if the user had added it manually. In YAML-mode the registration silently no-ops with a log hint printing the resource URL. Dedup check prevents duplicate registration on entry reload. +**Rationale:** Manual "Add Resource" is significant UX friction. Auto-registration removes it for storage-mode users (~80%+). YAML-mode users see a clear log hint instead of silence. The card's class registers in `window.customCards` so it appears in the "Add Card" picker once the resource loads. +**Consequences:** Custom element name `pricehawk-cost-card` is the contract surface (`type: custom:pricehawk-cost-card` in user YAML). Renaming breaks user dashboards on upgrade. Defaults to Phase 9 PR-11 `sensor.pricehawk_today_cost` + Phase 1 `sensor.pricehawk_saving_today`. + +## 2026-05-22 — Phase 10 Plan 01 (Lit panel_custom foundation) + +### D-P10-1 — Lit panel ships via CDN ESM (no build step); legacy iframe stays during migration +**Decision:** `pricehawk-panel.js` is a single ESM module that imports Lit from `https://unpkg.com/lit-element@4.2.0/lit-element.js?module`. No bundling, no build step — HACS distributes the JS file verbatim into `/local/pricehawk/pricehawk-panel.js` and HA's `panel_custom` loads it as a module URL. The legacy iframe panel at `/pricehawk-dashboard` (with the LLAT-in-URL approach) stays registered alongside during the migration window. Full visual port from `www/dashboard.html` to the Lit panel deferred to a dedicated Playwright UAT follow-up. +**Rationale:** Bundling Lit + Rollup/Vite + a build pipeline + a CI step that publishes the bundle adds significant project complexity for a custom_components-style integration. The CDN-ESM approach is what HA's own first-party `panel_custom` examples use and avoids a bundle-related supply chain. Trade-off: requires user network access to unpkg on first panel load — acceptable for an HA integration that already needs network for the cloud API calls. Module URL carries a version-busted query (`?v={manifest_version}.{epoch}`) so HACS upgrades invalidate the browser cache without requiring user action. +**Migration window:** Legacy iframe panel stays for back-compat. Users with the legacy dashboard URL bookmarked keep their bookmark working. The new "PriceHawk v2" sidebar entry is additive. Removal of the legacy iframe path will land in a follow-up after the Lit panel reaches feature parity AND ≥ 3 testers confirm the v2 panel works in production. Same gate philosophy as the PR-12 stats-only flip. +**Alternatives:** (a) Bundle Lit + ship a single .js — rejected as scope creep for the foundation PR. (b) Ship a TypeScript source + a build step — rejected for the same reason. (c) Replace the iframe panel atomically — rejected because the legacy dashboard has UI surfaces (CSV import wizard, per-window TOU breakdown) that the v2 panel won't have at first ship. +**Consequences:** Visual UAT remains a manual step until a Playwright session covers it. The custom element name `pricehawk-panel` is the cross-PR contract surface (referenced by `_panel_custom.name` in dashboard_config + by `customElements.define` in the JS file). Test `test_panel_defines_custom_element_pricehawk_panel` pins it. `setup_panel_custom_v2` is the registration function; renaming it would require updating the test_runtime_data patch list. + +## 2026-05-22 — Phase 9 Plan 02 (Energy-Dashboard-pickable cost sensor) + +### D-P9-2 — Chosen-plan cost sensor decoupled from provider id (stable entity_id) +**Decision:** `ChosenPlanCostSensor.unique_id = f"{entry_id}_chosen_plan_today_cost"` — explicitly does NOT include the active provider id. The sensor always reads `coordinator._current_plan_provider.net_daily_cost_aud` at evaluation time; the BACKING provider changes when the user swaps plans (e.g. CDR plan → DWT-OE) but the entity_id stays `sensor.pricehawk_today_cost`. Energy-Dashboard cost picker remembers entity_ids, not provider ids — stable entity_id means the user's dashboard pick survives plan swaps. +**Rationale:** The existing `ProviderDailyCostSensor` is per-provider (different entity_id per provider key like `amber_daily_cost` / `current_plan_daily_cost`). The Energy Dashboard cost picker would need re-selection when the user swaps plans. A provider-independent sensor solves that. Note the existing `current_plan_daily_cost` ProviderDailyCostSensor is similar but has a different unique_id derivation (`{entry_id}_current_plan_daily_cost`); this new sensor's distinct id (`_chosen_plan_today_cost`) signals "Energy-Dashboard-pickable" semantics + carries the matching display name `"PriceHawk Today Cost"`. +**Alternatives:** (a) Reuse existing `current_plan_daily_cost` sensor — rejected because it's tied to coordinator.data dict keys, not directly to the provider object, making cross-provider semantics less clear. (b) Use HA's `Statistics` entity instead of a sensor — rejected because the recorder auto-generates statistics from MONETARY/AUD/TOTAL sensors; no need for a separate Statistics entity. (c) Make the entity_id user-configurable — rejected as overkill for the v3.0 ship. +**Consequences:** Existing dashboards that pinned `sensor.pricehawk_current_plan_daily_cost` keep working (sensor unchanged). New installs see `sensor.pricehawk_today_cost` as the recommended dashboard pick. Docs (Phase 10 PR-13/14 UI rebuild) point users at the new sensor for Energy Dashboard integration. PR-12 / 09-03 stats-only flip relies on this sensor's stat_id being stable; the recorder will auto-generate a statistic from the sensor with the same lifetime as the entity. + +## 2026-05-22 — Phase 9 Plan 01 (external statistics dual-write) + +### D-P9-1 — Statistic-id format `{DOMAIN}:cost_{entry_id[:8]}_{provider_id}`; dual-write preserved until ≥4w tester confidence +**Decision:** External statistic ids use the entry_id's first 8 hex chars as the per-entry namespace token: `pricehawk:cost_abcdef12_amber`. HA's practical stat-id limit is ~50 chars; full entry_id (32+ hex) would blow that with longer provider ids (`dwt_openelectricity`). 8 chars = 2^32 collision space, fine for a single user's multi-entry install. Dual-write — JSON Store + external statistics — runs in parallel; JSON Store stays authoritative until PR-12 / 09-03 (≥4w elapsed + ≥10 tester reports of clean dual-write ≥7d per ROADMAP v2.0 GA criteria). +**Rationale:** Stat ids must be stable across reloads (the dashboard's "cost" picker remembers selected stat ids). Hashing the entry_id (e.g. via blake2b) would also work but loses the visible-entry-id-prefix that helps debugging. 8-char hex prefix is a defensible compromise. Dual-write avoids the worst HACS-update risk — a one-shot stats-only flip on update would lock users out of their cost history if the recorder import fails for any reason; dual-write gives a clean rollback path. +**Cumulative-sum + negative-cost handling:** Per HA stats contract, `has_sum=True` stats should be monotonic. Cost stats CAN dip on export-heavy days (negative net cost). Per HA docs the energy dashboard handles this for cost-class stats (uses the deltas, not absolute sums). Tests pin this behaviour: `test_negative_cost_does_not_break_cumulative` confirms the sum can decrease without raising. +**Consequences:** PR-11 / 09-02 registers `sensor.pricehawk_today_cost` with the same stat-id format so the Energy Dashboard's cost picker shows it. PR-12 / 09-03 is the FLIP — removes JSON-Store writes (keeps reads for one more release as a safety belt) — gated on the ROADMAP criteria. The 8-char entry_id slice MUST stay stable; changing it post-PR-11 would orphan all existing stats. + +## 2026-05-22 — Phase 8 Plan 05 (HACS Silver flip + checklist) + +### D-P8-5 — `quality_scale.yaml` ships ALL HA tiers, not just Silver; `log-when-unavailable` is `exempt` +**Decision:** `custom_components/pricehawk/quality_scale.yaml` lists EVERY rule from Bronze + Silver + Gold + Platinum, each with `status: done | exempt | todo` and (where useful) a one-line `comment` linking back to the implementing PR / decision. Silver-required rules are all `done`. Gold rules are `todo` with `comment: v4` (or `exempt` where unsupported — `discovery`, `devices`, `stale-devices` — PriceHawk is integration_type=service with no LAN/USB devices). Platinum rules are mostly `todo`; only `async-dependency` is `done`. +**Rationale:** A Silver-flip-only YAML would pass HACS validation but fail the "honesty" smell test — readers (HACS reviewers, future maintainers, Claude in 6 months) can't tell whether Gold was *considered* and rejected vs *forgotten*. Listing everything with explicit status answers the question. `comment` lines are short but load-bearing: they document WHY exempt rules are exempt (e.g. `log-when-unavailable` is exempt because the coordinator already handles availability transitions — no per-entity custom logging needed). +**Exempt rule explanations:** (a) `log-when-unavailable` — DataUpdateCoordinator + CoordinatorEntity handle availability transitions; entities don't poll independently so they have nothing to log. (b) `discovery` + `discovery-update-info` + `dynamic-devices` + `stale-devices` + `docs-supported-devices` — integration_type=service with no devices. (c) `inject-websession` — Platinum; OpenElectricity SDK 0.10.1 doesn't accept `session=` (audit M2 in 07-02-AUDIT); re-evaluate when 0.11 lands. +**Consequences:** Future PRs that flip a `todo` to `done` should update both `quality_scale.yaml` AND add a comment line pointing at the PR. Reviewing the YAML is the fastest way to see what's left for Gold/Platinum without reading 10 PR descriptions. Version bump to `1.6.0-beta.1` signals Silver-gate reached but not yet v3.0 GA (still need PR-10/11 external statistics + tester-confirmed dual-write per ROADMAP GA criteria). + +## 2026-05-22 — Phase 8 Plan 04 (repairs platform) + +### D-P8-4 — Persistent-notification repairs (not interactive fix flows); wholesale-source-unreachable deferred +**Decision:** PR-8 ships two repair issues — `grid_sensor_unavailable` and `ranking_stale` — using `ir.async_create_issue` + `ir.async_delete_issue` (persistent-notification style with `is_fixable=False`). Interactive `RepairsFlow` deferred; not needed for these issues whose fix is "click Reload" or "wait for next ranking run." Multi-entry safe: every issue id is prefixed with `entry.config_entry.entry_id` so two PriceHawk entries don't collide. +**Rationale:** Persistent notifications are zero ceremony for the two issues that PR-8 covers — the action the user takes is independent of HA's repairs UI (reload from Devices & Services, or wait for the 00:30 scheduler tick). Interactive RepairsFlow adds a `async_create_fix_flow` registration + per-step methods, which is overkill for "click reload." The repair surfaces as a notification + the integration page shows the entry as needing attention — that's enough signal. +**Deferred — `wholesale_source_unreachable`:** Originally planned. Requires tracking last-successful-update timestamps across Amber + AEMO + OE + Flow Power wholesale sources, which is more state than the simpler "did this tick read return None?" check. Punted to a follow-up PR (a small `_last_wholesale_success_at` tracker) so the existing 2 issues can ship cleanly. +**Alternatives:** (a) Use RepairsFlow for interactive resolution — rejected on overkill grounds. (b) Use `persistent_notification.create` directly — rejected because it doesn't integrate with the HA repairs panel; users wouldn't see it on the integration page. (c) Skip the 36h ranking_stale threshold and check on every tick — rejected because the ranking job is deliberately nightly; only flag when something has clearly gone wrong (multi-day staleness). +**Consequences:** Production `_set_repair` helper is the contract surface; future repair issues plug in with `self._set_repair("issue_id", True/False)` calls. The set-of-active-ids on the coordinator instance (`_active_repair_ids`) is the in-process dedup; HA's issue registry would naturally dedup too, but the local set avoids spamming the registry every tick. Tests use a coordinator-stand-in mirroring the production helper logic (production class can't be instantiated under conftest MagicMock base — same 07-02b D-1 root cause); source-grep tests ensure the production code matches the stand-in's contract. + +## 2026-05-22 — Phase 8 Plan 03 (diagnostics platform) + +### D-P8-3 — Diagnostics redaction list includes large plan envelopes (not just secrets) +**Decision:** `TO_REDACT` in `diagnostics.py` covers (a) every API-key and HA-token field — for secrecy; and (b) every PRD plan envelope (`CONF_CDR_PLAN`, `CONF_NAMED_COMPARATOR_PLAN`, `amber_static_plan`, `flow_power_static_plan`, `localvolts_static_plan`) — for SIZE, not secrecy. A single CDR plan envelope is ~15 KB; a power user with 5 entries + a named comparator + 3 static-PRD comparators easily hits 100 KB+ of diagnostics output, which makes the file unreviewable in HA's diagnostics modal. +**Rationale:** The HA diagnostics REST endpoint downloads as a single JSON file the user shares for support. Bloating it with rate tables that the user can already see in the OptionsFlow Configure page makes the diagnostic value LOWER, not higher. Reviewers (Ryan, Anthropic Claude review, or HA forum helpers) need the entry shape + runtime mode + accumulator lengths, not the full PRD body. The `_redaction_count` field tells reviewers how many keys were trimmed — they can ask for the full envelope separately if needed. +**Alternatives:** (a) Keep plan envelopes in diagnostics — rejected on size grounds above. (b) Summarise the envelope (planId + brand + period) inline instead of redacting — possible follow-up if reviewers actually need this; defer until a real support case proves the need. +**Consequences:** Diagnostics output stays small. Any future config field that ends up large MUST be added to `TO_REDACT` even if not secret — there's a comment in `diagnostics.py` documenting this contract. `test_to_redact_includes_large_plan_envelopes` enforces the current set. + +## 2026-05-22 — Phase 8 Plan 02 (per-provider reconfigure flow) + +### D-P8-2 — Narrow-scope reconfigure: fees + supplies only, no region/key swap +**Decision:** `async_step_reconfigure(entry_data)` dispatches by `coordinator._current_plan_provider.id` to four per-provider sub-steps. Each sub-step ONLY edits supplemental settings (Amber fees, LocalVolts daily supply + buy/sell guard rails, DWT daily supply). Region swap (DWT) and site_id swap (Amber) are NOT exposed — they would invalidate the entry's unique_id (Phase 7 PR-2b: `f"dwt_{flavour}_{region}"`; Amber: `site_id`) and HA would treat the changed entry as a new install. Key rotation is reauth (PR-5), not reconfigure. +**Rationale:** v2 research § Wave 2 PR-6 ambitiously asked for "swap wholesale provider/region without losing comparator history" — but doing that cleanly requires decoupling unique_id from region. That's a unique_id-contract redesign + migration script for existing entries, naturally a future major-version concern. Shipping the narrow scope NOW closes the "no Reconfigure button visible" UX gap and lets users adjust the most-commonly-edited fields without going through OptionsFlow. +**Alternatives:** (a) Redesign unique_id to be HA-install-UUID + provider-type + a stable token, then ship full reconfigure — rejected because the migration is non-trivial and out of Wave 2 scope. (b) Punt reconfigure entirely — rejected because the absent button is a Silver-quality regression vs the current state. (c) Have reconfigure call OptionsFlow internally — rejected because OptionsFlow is a separate class and HA's reconfigure flow expects ConfigFlow methods. +**Consequences:** CdrPlanProvider entries (CDR-only installs) get the `reconfigure_unsupported` abort path. Users wanting to change region or rotate site_id must remove + re-add the integration. Documented in the strings `reconfigure_dwt_oe`/`reconfigure_dwt_aemo` descriptions. Future major version SHOULD redesign unique_id; that PR will be able to expose full reconfigure cleanly. Dispatcher pattern reuses the PR-5 reauth contract (tag on coordinator instance) so any future "X needs reconfiguring" can plug in with one branch. + ## 2026-05-22 — Phase 8 Plan 01 (per-provider reauth flows) ### D-P8-1 — Dispatcher-pattern reauth via coordinator-tagged `_reauth_provider_id` diff --git a/custom_components/pricehawk/__init__.py b/custom_components/pricehawk/__init__.py index 1c46b8e..44dea55 100644 --- a/custom_components/pricehawk/__init__.py +++ b/custom_components/pricehawk/__init__.py @@ -3,6 +3,7 @@ import logging from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( CONF_AMBER_NETWORK_DAILY_CHARGE, @@ -12,7 +13,9 @@ from .coordinator import PriceHawkCoordinator from .dashboard_config import ( copy_www_assets, + register_lovelace_card_resource, remove_panel, + setup_panel_custom_v2, setup_panel_iframe, ) from .data import PriceHawkConfigEntry, PriceHawkData @@ -28,6 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PriceHawkConfigEntry) -> coordinator = PriceHawkCoordinator(hass, entry) await coordinator.async_restore_state() + # Phase 9 PR-10 — one-shot external stats backfill from the restored + # daily_cost_history. Must run AFTER state restore + BEFORE the first + # refresh so the cumulative-sum tracker is warm for tick-driven pushes. + await coordinator.async_setup_stats() await coordinator.async_config_entry_first_refresh() entry.runtime_data = PriceHawkData(coordinator=coordinator) @@ -62,6 +69,11 @@ async def _backfill_after_ranking() -> None: # Copy www assets (icon + HTML) and register sidebar panel await copy_www_assets(hass) await setup_panel_iframe(hass, entry) + # Phase 10 PR-13 — Lit panel_custom (no LLAT in URL). Runs alongside + # the iframe panel during the migration window. + await setup_panel_custom_v2(hass) + # Phase 10 PR-14 — Lovelace card resource auto-registration. + await register_lovelace_card_resource(hass) # OptionsFlowWithReload handles reloading automatically — # do NOT add an update_listener here (HA 2026.3+ forbids combining them). @@ -81,10 +93,13 @@ async def handle_analyze_csv(call: object) -> None: Accepts pre-parsed CSV rows from the dashboard JavaScript and runs them through the user's CONFIGURED tariff rates (not plan defaults). """ + # Phase 8 PR-9 (HA Silver) — action-exceptions rule. coord = _resolve_coordinator() if coord is None: - _LOGGER.warning("analyze_csv: coordinator not available; entry unloaded?") - return + raise HomeAssistantError( + "PriceHawk coordinator not available — entry may have " + "unloaded. Reload the integration." + ) rows = call.data.get("rows", []) # type: ignore[attr-defined] if not rows: _LOGGER.error("No CSV rows provided to analyze_csv service") @@ -118,18 +133,21 @@ async def handle_analyze_csv(call: object) -> None: # coordinator method; status surfaces via # ``sensor.pricehawk_backfill_status``. async def handle_backfill(call: object) -> None: + # Phase 8 PR-9 (HA Silver) — action-exceptions rule. coord = _resolve_coordinator() if coord is None: - _LOGGER.warning("backfill_history: coordinator not available; entry unloaded?") - return + raise HomeAssistantError( + "PriceHawk coordinator not available — entry may have " + "unloaded. Reload the integration." + ) raw_days = call.data.get("days", 30) # type: ignore[attr-defined] try: days_back = max(1, min(int(raw_days), 90)) - except (TypeError, ValueError): - _LOGGER.warning( - "backfill_history: invalid days=%r, using default 30", raw_days, - ) - days_back = 30 + except (TypeError, ValueError) as err: + raise ServiceValidationError( + f"backfill_history: 'days' must be an integer " + f"between 1 and 90 (got {raw_days!r})" + ) from err await coord.async_run_backfill(days_back=days_back) hass.services.async_register(DOMAIN, "backfill_history", handle_backfill) @@ -140,21 +158,23 @@ async def handle_backfill(call: object) -> None: # switching plans (so the alternatives ranking reflects the new # distributor / postcode immediately). async def handle_rank_alternatives(call: object) -> None: + # Phase 8 PR-9 (HA Silver) — action-exceptions rule. Was: warn + + # default-fallback for invalid top_k; now surfaces the bad input + # to the caller via ServiceValidationError. coord = _resolve_coordinator() if coord is None: - _LOGGER.warning("rank_alternatives: coordinator not available; entry unloaded?") - return - # CR-fix: malformed service payload (e.g. ``top_k: "abc"`` from - # a typo in a YAML automation) would raise ValueError/TypeError - # and fail the call. Coerce defensively + fall back to default. + raise HomeAssistantError( + "PriceHawk coordinator not available — entry may have " + "unloaded. Reload the integration." + ) raw = call.data.get("top_k", 20) # type: ignore[attr-defined] try: top_k = int(raw) - except (TypeError, ValueError): - _LOGGER.warning( - "rank_alternatives: invalid top_k=%r, using default 20", raw - ) - top_k = 20 + except (TypeError, ValueError) as err: + raise ServiceValidationError( + f"rank_alternatives: 'top_k' must be an integer " + f"between 1 and 100 (got {raw!r})" + ) from err top_k = max(1, min(top_k, 100)) result = await coord.async_run_ranking_job(top_k=top_k) _LOGGER.info( diff --git a/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_30min_window.yaml b/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_30min_window.yaml new file mode 100644 index 0000000..d193076 --- /dev/null +++ b/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_30min_window.yaml @@ -0,0 +1,36 @@ +blueprint: + name: PriceHawk — Cheapest 30-minute window today + description: >- + Fire each day when PriceHawk's "best 30-minute window" sensor settles + on tomorrow's lowest-price window. Useful as a kick-off for scheduled + EV charging, dishwasher start, etc. + domain: automation + source_url: https://github.com/Artic0din/ha-pricehawk/blob/main/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_30min_window.yaml + input: + best_window_sensor: + name: Best-window sensor + description: PriceHawk sensor that exposes the cheapest 30-min slot for today + default: sensor.pricehawk_best_30min_window + selector: + entity: + domain: sensor + actions_when_window_starts: + name: Actions to run when the cheap window begins + description: e.g. turn on dishwasher, start EV charge, run pool pump + selector: + action: + +trigger: + - platform: template + value_template: >- + {{ state_attr('sensor.pricehawk_best_30min_window', 'start') is not none + and (now() - (state_attr('sensor.pricehawk_best_30min_window', 'start') + | as_datetime)).total_seconds() + | abs < 30 }} + +action: + - choose: + - conditions: "{{ true }}" + sequence: !input actions_when_window_starts + +mode: single diff --git a/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_plan_alert.yaml b/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_plan_alert.yaml new file mode 100644 index 0000000..f6a0383 --- /dev/null +++ b/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_plan_alert.yaml @@ -0,0 +1,57 @@ +blueprint: + name: PriceHawk — Cheapest plan alert + description: >- + Notify when a different retailer would have saved more than ${threshold} + over the last 7 days. Reads PriceHawk's ranked-alternatives sensor + and fires a single notification per cheaper plan per week. + domain: automation + source_url: https://github.com/Artic0din/ha-pricehawk/blob/main/custom_components/pricehawk/blueprints/automation/pricehawk/cheapest_plan_alert.yaml + input: + ranked_alternatives_sensor: + name: Ranked alternatives sensor + description: PriceHawk's ranked-alternatives sensor (deep-ranked list) + default: sensor.pricehawk_ranked_alternatives + selector: + entity: + domain: sensor + threshold_aud: + name: Saving threshold (AUD) + description: Only fire when the top alternative beats the current plan by this much over 7 days + default: 5.0 + selector: + number: + min: 0.0 + max: 100.0 + step: 0.50 + unit_of_measurement: AUD + notify_service: + name: Notification service + description: e.g. notify.mobile_app_pixel, notify.persistent_notification + default: notify.persistent_notification + selector: + text: + +trigger: + - platform: state + entity_id: !input ranked_alternatives_sensor + +variables: + threshold: !input threshold_aud + alternatives: "{{ state_attr(trigger.entity_id, 'plans') or [] }}" + top: "{{ alternatives[0] if alternatives else none }}" + weekly_saving: "{{ (top.weekly_saving_aud if top else 0) | float(0) }}" + +condition: + - "{{ top is not none }}" + - "{{ weekly_saving >= (threshold | float) }}" + +action: + - service: !input notify_service + data: + title: "PriceHawk: switch to {{ top.brand }} {{ top.plan_name }}?" + message: >- + Over the last 7 days, {{ top.brand }} {{ top.plan_name }} would have + cost ${{ "%.2f" | format(weekly_saving) }} less than your current plan. + Open PriceHawk to see the breakdown. + +mode: single diff --git a/custom_components/pricehawk/blueprints/automation/pricehawk/daily_7pm_summary.yaml b/custom_components/pricehawk/blueprints/automation/pricehawk/daily_7pm_summary.yaml new file mode 100644 index 0000000..2bc00fe --- /dev/null +++ b/custom_components/pricehawk/blueprints/automation/pricehawk/daily_7pm_summary.yaml @@ -0,0 +1,48 @@ +blueprint: + name: PriceHawk — Daily 7pm summary + description: >- + Send a notification at 7pm local time with today's accumulated cost, + savings vs cheapest comparator, and the best provider so far. + domain: automation + source_url: https://github.com/Artic0din/ha-pricehawk/blob/main/custom_components/pricehawk/blueprints/automation/pricehawk/daily_7pm_summary.yaml + input: + today_cost_sensor: + name: Today's cost sensor + description: PriceHawk chosen-plan cost sensor + default: sensor.pricehawk_today_cost + selector: + entity: + domain: sensor + savings_sensor: + name: Saving today sensor + default: sensor.pricehawk_saving_today + selector: + entity: + domain: sensor + best_provider_sensor: + name: Best provider sensor + default: sensor.pricehawk_best_provider + selector: + entity: + domain: sensor + notify_service: + name: Notification service + description: e.g. notify.mobile_app_pixel, notify.persistent_notification + default: notify.persistent_notification + selector: + text: + +trigger: + - platform: time + at: "19:00:00" + +action: + - service: !input notify_service + data: + title: "PriceHawk: today's energy summary" + message: >- + Cost so far: ${{ states(!input today_cost_sensor) }}. + Saving vs cheapest alt: ${{ states(!input savings_sensor) }}. + Best provider: {{ states(!input best_provider_sensor) }}. + +mode: single diff --git a/custom_components/pricehawk/blueprints/automation/pricehawk/pause_ev_on_spike.yaml b/custom_components/pricehawk/blueprints/automation/pricehawk/pause_ev_on_spike.yaml new file mode 100644 index 0000000..12e87ae --- /dev/null +++ b/custom_components/pricehawk/blueprints/automation/pricehawk/pause_ev_on_spike.yaml @@ -0,0 +1,70 @@ +blueprint: + name: PriceHawk — Pause EV charging on wholesale spike + description: >- + When the wholesale spot price exceeds ${threshold} c/kWh, suspend a chosen + EV charger switch. Resume when the price drops back below the threshold + minus the hysteresis margin (default 2c/kWh). + domain: automation + source_url: https://github.com/Artic0din/ha-pricehawk/blob/main/custom_components/pricehawk/blueprints/automation/pricehawk/pause_ev_on_spike.yaml + input: + wholesale_sensor: + name: Wholesale price sensor + description: PriceHawk wholesale price sensor (c/kWh, inc-GST) + default: sensor.pricehawk_wholesale_price + selector: + entity: + domain: sensor + ev_charger_switch: + name: EV charger switch + description: Switch entity that controls the charger + selector: + entity: + domain: switch + threshold_c_kwh: + name: Spike threshold (c/kWh inc-GST) + default: 60.0 + selector: + number: + min: 10.0 + max: 200.0 + step: 1.0 + unit_of_measurement: c/kWh + hysteresis_c_kwh: + name: Hysteresis margin + description: Resume charging when price falls THIS FAR below the threshold + default: 2.0 + selector: + number: + min: 0.0 + max: 50.0 + step: 0.5 + unit_of_measurement: c/kWh + +variables: + threshold: !input threshold_c_kwh + hysteresis: !input hysteresis_c_kwh + +trigger: + - platform: numeric_state + entity_id: !input wholesale_sensor + above: !input threshold_c_kwh + id: spike + - platform: numeric_state + entity_id: !input wholesale_sensor + below: "{{ (threshold | float) - (hysteresis | float) }}" + id: recover + +action: + - choose: + - conditions: "{{ trigger.id == 'spike' }}" + sequence: + - service: switch.turn_off + target: + entity_id: !input ev_charger_switch + - conditions: "{{ trigger.id == 'recover' }}" + sequence: + - service: switch.turn_on + target: + entity_id: !input ev_charger_switch + +mode: single diff --git a/custom_components/pricehawk/blueprints/automation/pricehawk/wholesale_spike_alert.yaml b/custom_components/pricehawk/blueprints/automation/pricehawk/wholesale_spike_alert.yaml new file mode 100644 index 0000000..a505331 --- /dev/null +++ b/custom_components/pricehawk/blueprints/automation/pricehawk/wholesale_spike_alert.yaml @@ -0,0 +1,48 @@ +blueprint: + name: PriceHawk — Wholesale spike alert + description: >- + Send a notification when the wholesale spot price crosses a threshold. + Useful as an early warning to defer flexible loads (dishwasher, EV, + pool pump) until the spike clears. Auto-dedups to one notification + per spike event via the 'continue_on_state' trick. + domain: automation + source_url: https://github.com/Artic0din/ha-pricehawk/blob/main/custom_components/pricehawk/blueprints/automation/pricehawk/wholesale_spike_alert.yaml + input: + wholesale_sensor: + name: Wholesale price sensor + default: sensor.pricehawk_wholesale_price + selector: + entity: + domain: sensor + threshold_c_kwh: + name: Spike threshold (c/kWh inc-GST) + default: 80.0 + selector: + number: + min: 10.0 + max: 500.0 + step: 1.0 + unit_of_measurement: c/kWh + notify_service: + name: Notification service + default: notify.persistent_notification + selector: + text: + +trigger: + - platform: numeric_state + entity_id: !input wholesale_sensor + above: !input threshold_c_kwh + for: + minutes: 1 + +action: + - service: !input notify_service + data: + title: "PriceHawk: wholesale spike" + message: >- + Wholesale spot is {{ states(!input wholesale_sensor) }} c/kWh — + above your ${{ !input threshold_c_kwh }} c/kWh threshold. Defer + flexible loads if possible. + +mode: single diff --git a/custom_components/pricehawk/config_flow.py b/custom_components/pricehawk/config_flow.py index bb28426..cd0416c 100644 --- a/custom_components/pricehawk/config_flow.py +++ b/custom_components/pricehawk/config_flow.py @@ -45,6 +45,7 @@ CONF_AMBER_ENABLED, CONF_AMBER_NETWORK_DAILY_CHARGE, CONF_AMBER_PRICING_MODE, + CONF_AMBER_STATIC_PLAN, CONF_AMBER_SUBSCRIPTION_FEE, CONF_API_KEY, CONF_CDR_PLAN, @@ -66,6 +67,7 @@ CONF_FLOW_POWER_PEA_OVERRIDE, CONF_FLOW_POWER_PRICING_MODE, CONF_FLOW_POWER_REGION, + CONF_FLOW_POWER_STATIC_PLAN, CONF_GRID_POWER_SENSOR, CONF_HA_TOKEN, CONF_IMPORT_TARIFF, @@ -78,6 +80,7 @@ CONF_LOCALVOLTS_PARTNER_ID, CONF_LOCALVOLTS_PRICING_MODE, CONF_LOCALVOLTS_SELL_FLOOR, + CONF_LOCALVOLTS_STATIC_PLAN, CONF_NAMED_COMPARATOR_PLAN, CONF_NAMED_COMPARATOR_PLAN_ID, CONF_PLAN_TYPE, @@ -91,6 +94,7 @@ PLAN_FOUR4FREE, PLAN_GLOSAVE, PLAN_ZEROHERO, + PRICING_MODE_STATIC_PRD, PROVIDER_AMBER, PROVIDER_DWT_AEMO, PROVIDER_DWT_OE, @@ -2120,6 +2124,31 @@ async def async_step_dashboard_token( if skip_reason: options[CONF_CDR_SKIP_REASON] = skip_reason + # Phase 7 PR-2b — DWT entries: copy setup-step fields into + # entry.data (credentials) + entry.options (runtime config) + # so _build_dwt_provider() can hydrate the coordinator. + # Without this, new DWT installs fail at first refresh with + # ConfigEntryNotReady (AC-10c). + if self._data.get(CONF_DWT_OE_ENABLED): + data[CONF_DWT_OE_API_KEY] = self._data.get( + CONF_DWT_OE_API_KEY, "" + ) + data[CONF_DWT_REGION] = self._data.get( + CONF_DWT_REGION, "NSW1" + ) + options[CONF_DWT_OE_ENABLED] = True + options[CONF_DWT_OE_DAILY_SUPPLY] = self._data.get( + CONF_DWT_OE_DAILY_SUPPLY, 110.0 + ) + elif self._data.get(CONF_DWT_AEMO_ENABLED): + data[CONF_DWT_REGION] = self._data.get( + CONF_DWT_REGION, "NSW1" + ) + options[CONF_DWT_AEMO_ENABLED] = True + options[CONF_DWT_AEMO_DAILY_SUPPLY] = self._data.get( + CONF_DWT_AEMO_DAILY_SUPPLY, 110.0 + ) + _LOGGER.info( "Creating PriceHawk entry: primary=%s amber=%s lv=%s cdr=%s skip=%s", current_provider, amber_enabled, localvolts_enabled, @@ -2153,12 +2182,19 @@ async def async_step_reauth( ``_reauth_provider_id`` tag set by the coordinator on the failed provider's auth-failure raise site. """ - del entry_data # We read state from runtime_data, not entry_data. + del entry_data entry = self._get_reauth_entry() + # Phase 8 PR-5 (codex fix): runtime_data is only set AFTER + # `async_config_entry_first_refresh()` completes successfully. + # During startup or first-refresh auth failures, runtime_data + # is None — fall back to entry.data[CONF_CURRENT_PROVIDER] + # which records the user's primary provider at setup time. coordinator = getattr( getattr(entry, "runtime_data", None), "coordinator", None ) provider_id = getattr(coordinator, "_reauth_provider_id", None) + if provider_id is None: + provider_id = entry.data.get(CONF_CURRENT_PROVIDER) if provider_id == PROVIDER_AMBER: return await self.async_step_reauth_amber() if provider_id == PROVIDER_LOCALVOLTS: @@ -2335,6 +2371,186 @@ async def async_step_reauth_dwt_oe( ), ) + # ------------------------------------------------------------------ + # Reconfigure flow (Phase 8 PR-6) + # ------------------------------------------------------------------ + + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> config_entries.ConfigFlowResult: + """HA-invoked reconfigure entry point. Routes by active provider.""" + del entry_data + entry = self._get_reconfigure_entry() + # Phase 8 PR-6 (codex fix): CdrPlanProvider.id is + # `{brand}_{plan_id}` (e.g. "amber_brokerage-xyz"), never the + # literal PROVIDER_AMBER / PROVIDER_LOCALVOLTS. Reading the + # provider id from the coordinator made the Amber/LV reconfigure + # branches unreachable for CDR-backed entries (the install base). + # Route from entry.data[CONF_CURRENT_PROVIDER] which records the + # user's primary choice as a stable, literal slug. + provider_id = entry.data.get(CONF_CURRENT_PROVIDER) + if provider_id == PROVIDER_AMBER: + return await self.async_step_reconfigure_amber() + if provider_id == PROVIDER_LOCALVOLTS: + return await self.async_step_reconfigure_localvolts() + if provider_id == PROVIDER_DWT_OE: + return await self.async_step_reconfigure_dwt_oe() + if provider_id == PROVIDER_DWT_AEMO: + return await self.async_step_reconfigure_dwt_aemo() + return self.async_abort(reason="reconfigure_unsupported") + + async def async_step_reconfigure_amber( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit Amber fees without touching the API key or site_id.""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_AMBER_NETWORK_DAILY_CHARGE: float( + user_input.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0) + or 0.0 + ), + CONF_AMBER_SUBSCRIPTION_FEE: float( + user_input.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0) + or 0.0 + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_amber", + data_schema=vol.Schema( + { + vol.Optional( + CONF_AMBER_NETWORK_DAILY_CHARGE, + default=float( + opts.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0) or 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_AMBER_SUBSCRIPTION_FEE, + default=float( + opts.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0) or 0.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_localvolts( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit LocalVolts daily supply + buy/sell guard rails.""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_LOCALVOLTS_DAILY_SUPPLY: float( + user_input[CONF_LOCALVOLTS_DAILY_SUPPLY] + ), + CONF_LOCALVOLTS_BUY_CEILING: float( + user_input.get(CONF_LOCALVOLTS_BUY_CEILING, 0.0) + or 0.0 + ), + CONF_LOCALVOLTS_SELL_FLOOR: float( + user_input.get(CONF_LOCALVOLTS_SELL_FLOOR, 0.0) + or 0.0 + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_localvolts", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCALVOLTS_DAILY_SUPPLY, + default=float( + opts.get(CONF_LOCALVOLTS_DAILY_SUPPLY, 110.0) + or 110.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_LOCALVOLTS_BUY_CEILING, + default=float( + opts.get(CONF_LOCALVOLTS_BUY_CEILING, 0.0) or 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_LOCALVOLTS_SELL_FLOOR, + default=float( + opts.get(CONF_LOCALVOLTS_SELL_FLOOR, 0.0) or 0.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_dwt_oe( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit DWT-OE daily supply only (region swap deferred — D-P8-2).""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_DWT_OE_DAILY_SUPPLY: float( + user_input[CONF_DWT_OE_DAILY_SUPPLY] + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_dwt_oe", + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_OE_DAILY_SUPPLY, + default=float( + opts.get(CONF_DWT_OE_DAILY_SUPPLY, 110.0) or 110.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_dwt_aemo( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit DWT-AEMO daily supply only (region swap deferred — D-P8-2).""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_DWT_AEMO_DAILY_SUPPLY: float( + user_input[CONF_DWT_AEMO_DAILY_SUPPLY] + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_dwt_aemo", + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_AEMO_DAILY_SUPPLY, + default=float( + opts.get(CONF_DWT_AEMO_DAILY_SUPPLY, 110.0) or 110.0 + ), + ): vol.Coerce(float), + } + ), + ) + @staticmethod @callback def async_get_options_flow( @@ -2433,7 +2649,24 @@ async def async_step_comparators( mode_key=CONF_LOCALVOLTS_PRICING_MODE, legacy_enabled_key=CONF_LOCALVOLTS_ENABLED, ) - _mode_options = [{"value": m, "label": m} for m in ALL_PRICING_MODES] + # Phase 7 PR-4 (codex fix): hide static_prd until a CDR static + # plan is stored for the comparator. No flow writes the + # CONF_*_STATIC_PLAN keys today, so exposing static_prd + # universally would bomb the coordinator with + # ConfigEntryNotReady on reload (Amber/LV) or warn-fallback + # (Flow Power). Gate by per-comparator static-plan presence. + def _modes_for(static_key: str) -> list[dict[str, str]]: + if current_opts.get(static_key): + return [{"value": m, "label": m} for m in ALL_PRICING_MODES] + return [ + {"value": m, "label": m} + for m in ALL_PRICING_MODES + if m != PRICING_MODE_STATIC_PRD + ] + + _amber_mode_options = _modes_for(CONF_AMBER_STATIC_PLAN) + _fp_mode_options = _modes_for(CONF_FLOW_POWER_STATIC_PLAN) + _lv_mode_options = _modes_for(CONF_LOCALVOLTS_STATIC_PLAN) return self.async_show_form( step_id="comparators", data_schema=vol.Schema( @@ -2442,7 +2675,7 @@ async def async_step_comparators( CONF_AMBER_PRICING_MODE, default=amber_default, ): SelectSelector( SelectSelectorConfig( - options=_mode_options, + options=_amber_mode_options, mode=SelectSelectorMode.DROPDOWN, ) ), @@ -2450,7 +2683,7 @@ async def async_step_comparators( CONF_FLOW_POWER_PRICING_MODE, default=fp_default, ): SelectSelector( SelectSelectorConfig( - options=_mode_options, + options=_fp_mode_options, mode=SelectSelectorMode.DROPDOWN, ) ), @@ -2458,7 +2691,7 @@ async def async_step_comparators( CONF_LOCALVOLTS_PRICING_MODE, default=lv_default, ): SelectSelector( SelectSelectorConfig( - options=_mode_options, + options=_lv_mode_options, mode=SelectSelectorMode.DROPDOWN, ) ), diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index b554408..ffc4488 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later, async_track_time_change from homeassistant.helpers.storage import Store @@ -65,6 +66,10 @@ PROVIDER_LOCALVOLTS, ) from .static_pricing import evaluate_static_rates, resolve_pricing_mode +from .statistics import ( + async_backfill_external_statistics, + async_push_daily_cost_to_statistics, +) from .cdr.ranking import DEFAULT_TOP_K, summarize_for_sensor from .cdr.ranking_job import run_ranking_job from .explanation import build_explanation @@ -498,6 +503,18 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # dispatcher can route to the correct per-provider sub-step. self._reauth_provider_id: str | None = None + # Phase 8 PR-8 — repair issue counters. grid_sensor_unavailable + # raises after 10 consecutive None reads (5 min @ 30s); + # ranking_stale checked each tick against _ranking_last_run_at. + self._grid_sensor_missing_ticks: int = 0 + self._active_repair_ids: set[str] = set() + + # Phase 9 PR-10 — external statistics dual-write. Backfill runs + # once on first setup; cumulative-sum tracker stays warm across + # tick lifetime so per-day pushes get a monotonic ``sum`` field. + self._external_stats_backfill_done: bool = False + self._external_stats_cumulative: dict[str, float] = {} + # ------------------------------------------------------------------ # Dynamic Wholesale Tariff (Phase 7 PR-2b) # ------------------------------------------------------------------ @@ -957,8 +974,16 @@ async def _maybe_poll_localvolts(self) -> None: async def _async_update_data(self) -> dict[str, Any]: """Read sensors, poll Amber, update both engines, return data dict.""" - # 0. On first run, fetch today's full price schedule for the rate chart - if self._last_amber_poll == 0.0: + # 0. On first run, fetch today's full price schedule for the rate chart. + # Phase 7 PR-4 (codex fix): gate on live API mode. Without this, + # static/off Amber entries (and all DWT entries) hit the Amber + # schedule endpoint every 30s with stale or missing credentials + # because _maybe_poll_amber returns without touching + # _last_amber_poll, leaving it stuck at 0.0 forever. + if ( + self._last_amber_poll == 0.0 + and self._amber_mode == PRICING_MODE_LIVE_API + ): await self._fetch_today_price_schedule() # 1. Poll Amber API (rate-limited to every 5 min) @@ -1021,6 +1046,31 @@ async def _async_update_data(self) -> dict[str, Any]: if len(self._daily_cost_history) > 180: self._daily_cost_history = self._daily_cost_history[-180:] + # Phase 9 PR-10 — external stats dual-write. JSON Store + # (above) remains the source of truth until PR-12; stats + # are additive. Monotonic-sum tracker is seeded by the + # one-shot backfill in async_setup_stats. + yesterday_date = (now_local - timedelta(days=1)).date() + for pid, p in self._providers.items(): + cost = float(p.net_daily_cost_aud) + self._external_stats_cumulative[pid] = ( + self._external_stats_cumulative.get(pid, 0.0) + cost + ) + try: + await async_push_daily_cost_to_statistics( + self.hass, + self.config_entry.entry_id, + pid, + yesterday_date, + cost, + self._external_stats_cumulative[pid], + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.warning( + "external stats push failed for %s: %s", + pid, exc, + ) + # Build the explanation BEFORE resetting accumulators avg_spot = None if self._amber and self._amber.import_kwh_today > 0: @@ -1080,9 +1130,119 @@ async def _async_update_data(self) -> dict[str, Any]: for provider in self._providers.values(): provider.update(grid_power_w, now_local) + # Phase 8 PR-8 — repair-issue detection sites (cheap; no I/O). + self._check_repairs(grid_power_w, now_local) + # 7. Return data dict for sensor entities return self._build_data_dict() + # ------------------------------------------------------------------ + # External statistics (Phase 9 PR-10) + # ------------------------------------------------------------------ + + async def async_setup_stats(self) -> None: + """One-shot backfill of external statistics from daily_cost_history. + + Called from async_setup_entry AFTER state restore. Seeds the + cumulative-sum tracker so subsequent daily-rollover pushes + produce a monotonic ``sum`` field per HA stats contract. + """ + if self._external_stats_backfill_done: + return + if self._daily_cost_history: + try: + count = await async_backfill_external_statistics( + self.hass, + self.config_entry.entry_id, + self._daily_cost_history, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.warning( + "external stats backfill failed: %s", exc, + ) + count = 0 + for entry in self._daily_cost_history: + for pid, val in entry.items(): + if pid == "date" or not isinstance(val, (int, float)): + continue + self._external_stats_cumulative[pid] = ( + self._external_stats_cumulative.get(pid, 0.0) + + float(val) + ) + _LOGGER.info( + "external stats backfill complete: %d entries", count, + ) + self._external_stats_backfill_done = True + + # ------------------------------------------------------------------ + # Repairs platform (Phase 8 PR-8) + # ------------------------------------------------------------------ + + def _set_repair( + self, + issue_id: str, + on: bool, + *, + severity: ir.IssueSeverity = ir.IssueSeverity.WARNING, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Toggle a repair issue. Deduped via _active_repair_ids set.""" + scoped = f"{self.config_entry.entry_id}_{issue_id}" + if on: + if scoped in self._active_repair_ids: + return + ir.async_create_issue( + self.hass, + DOMAIN, + scoped, + is_fixable=False, + severity=severity, + translation_key=issue_id, + translation_placeholders=translation_placeholders, + ) + self._active_repair_ids.add(scoped) + else: + if scoped not in self._active_repair_ids: + return + ir.async_delete_issue(self.hass, DOMAIN, scoped) + self._active_repair_ids.discard(scoped) + + def _check_repairs( + self, grid_power_w: float | None, now_local: datetime + ) -> None: + """Per-tick repair detection. Cheap; no I/O.""" + # grid_sensor_unavailable: 10+ consecutive None reads = 5 min. + if grid_power_w is None: + self._grid_sensor_missing_ticks += 1 + if self._grid_sensor_missing_ticks >= 10: + self._set_repair( + "grid_sensor_unavailable", True, + translation_placeholders={ + "entity_id": self._grid_power_entity or "(unset)", + }, + ) + else: + self._grid_sensor_missing_ticks = 0 + self._set_repair("grid_sensor_unavailable", False) + + # ranking_stale: _ranking_last_run_at None for > 24h since first + # tick, OR > 36h since last successful run. + last_rank = self._ranking_last_run_at + if last_rank is None: + # No run yet — only flag if the integration has been alive + # long enough for the 00:30 scheduled run to have fired. + return # Stay quiet on cold-boot; nightly job will fix. + age_hours = (now_local - last_rank).total_seconds() / 3600.0 + if age_hours > 36.0: + self._set_repair( + "ranking_stale", True, + translation_placeholders={ + "hours": f"{age_hours:.1f}", + }, + ) + else: + self._set_repair("ranking_stale", False) + def _compute_saving(self, amber_cost: float, globird_cost: float) -> float: """Compute directional saving based on current provider. diff --git a/custom_components/pricehawk/dashboard_config.py b/custom_components/pricehawk/dashboard_config.py index 51bbef8..46c0026 100644 --- a/custom_components/pricehawk/dashboard_config.py +++ b/custom_components/pricehawk/dashboard_config.py @@ -17,6 +17,18 @@ PANEL_TITLE = "PriceHawk" PANEL_ICON = "mdi:flash" +# Phase 10 PR-13 — Lit panel_custom (no LLAT). Registered alongside the +# iframe panel during the migration window; legacy iframe stays until +# the Lit panel reaches feature parity (follow-up Playwright UAT PR). +PANEL_V2_URL_PATH = "pricehawk" +PANEL_V2_TITLE = "PriceHawk v2" +PANEL_V2_MODULE = "pricehawk-panel" # custom element name in the JS module + +# Phase 10 PR-14 — Lovelace custom card. Auto-registered as a frontend +# resource on entry setup; appears in the "Add Card" picker. +LOVELACE_CARD_FILENAME = "pricehawk-card.js" +LOVELACE_CARD_RESOURCE_URL = "/local/pricehawk/pricehawk-card.js" + # Inline SVG icon (PriceHawk hawk logo) PRICEHAWK_ICON_SVG = """\ @@ -29,18 +41,23 @@ async def copy_www_assets(hass: HomeAssistant) -> None: - """Copy PriceHawk icon SVG and dashboard HTML to www/pricehawk/. + """Copy PriceHawk icon SVG, legacy dashboard HTML, and v2 Lit panel JS. Always overwrites to ensure the latest version is deployed. The HTML dashboard becomes accessible at /local/pricehawk/dashboard.html. + The v2 Lit panel JS becomes accessible at /local/pricehawk/pricehawk-panel.js. """ src_dir = Path(__file__).parent src_html = src_dir / "www" / "dashboard.html" + src_panel_js = src_dir / "www" / "pricehawk-panel.js" + src_card_js = src_dir / "www" / LOVELACE_CARD_FILENAME src_icon_png = src_dir / "icon.png" dest_dir = hass.config.path("www", "pricehawk") icon_svg_path = os.path.join(dest_dir, "icon.svg") icon_png_path = os.path.join(dest_dir, "icon.png") html_path = os.path.join(dest_dir, "dashboard.html") + panel_js_path = os.path.join(dest_dir, "pricehawk-panel.js") + card_js_path = os.path.join(dest_dir, LOVELACE_CARD_FILENAME) def _copy_assets() -> None: os.makedirs(dest_dir, exist_ok=True) @@ -57,6 +74,22 @@ def _copy_assets() -> None: _LOGGER.warning( "PriceHawk: dashboard.html source not found at %s", src_html ) + # Phase 10 PR-13 — copy v2 Lit panel JS. + if src_panel_js.exists(): + shutil.copy2(str(src_panel_js), panel_js_path) + else: + _LOGGER.warning( + "PriceHawk: pricehawk-panel.js source not found at %s", + src_panel_js, + ) + # Phase 10 PR-14 — copy Lovelace card JS. + if src_card_js.exists(): + shutil.copy2(str(src_card_js), card_js_path) + else: + _LOGGER.warning( + "PriceHawk: %s source not found at %s", + LOVELACE_CARD_FILENAME, src_card_js, + ) try: await hass.async_add_executor_job(_copy_assets) @@ -146,12 +179,143 @@ async def setup_panel_iframe(hass: HomeAssistant, entry: ConfigEntry) -> None: ) -async def remove_panel(hass: HomeAssistant) -> None: - """Remove the PriceHawk sidebar panel on unload.""" +async def setup_panel_custom_v2(hass: HomeAssistant) -> None: + """Phase 10 PR-13 — register Lit panel_custom (no LLAT in URL). + + Lives alongside the legacy iframe panel during the migration. Auth + runs through the host page's WebSocket session — no long-lived + access token threaded through query params. Version-busted module + URL invalidates the browser cache on every HACS upgrade. + + Per HA docs at https://developers.home-assistant.io/docs/frontend/custom-ui/registering-resources/, + ``trust_external=False`` + ``embed_iframe=False`` is the recommended + pattern for first-party panels. + """ + from homeassistant.components.frontend import ( + async_register_built_in_panel, + async_remove_panel, + ) + try: - from homeassistant.components.frontend import async_remove_panel + from homeassistant.loader import async_get_integration - async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False) - _LOGGER.info("PriceHawk: sidebar panel removed") + integration = await async_get_integration(hass, "pricehawk") + version = integration.manifest.get("version", "unknown") + except Exception: + version = "unknown" + + cache_token = f"{version}.{int(time.time())}" + module_url = f"/local/pricehawk/pricehawk-panel.js?v={cache_token}" + + # Remove existing v2 panel before re-registering (handles reload cycles). + try: + async_remove_panel(hass, PANEL_V2_URL_PATH, warn_if_unknown=False) + except Exception: + pass + + try: + async_register_built_in_panel( + hass, + component_name="custom", + sidebar_title=PANEL_V2_TITLE, + sidebar_icon=PANEL_ICON, + frontend_url_path=PANEL_V2_URL_PATH, + config={ + "_panel_custom": { + "name": PANEL_V2_MODULE, + "module_url": module_url, + "embed_iframe": False, + "trust_external": False, + } + }, + require_admin=False, + ) + _LOGGER.info( + "PriceHawk v2 panel registered at /%s -> %s", + PANEL_V2_URL_PATH, module_url, + ) except Exception: - _LOGGER.debug("PriceHawk: panel removal skipped (not registered)") + _LOGGER.error( + "PriceHawk: failed to register v2 panel_custom. " + "Legacy iframe dashboard is unaffected.", + exc_info=True, + ) + + +async def register_lovelace_card_resource(hass: HomeAssistant) -> None: + """Phase 10 PR-14 — auto-register the PriceHawk Lovelace card resource. + + Best-effort: HA's Lovelace resources API is mode-dependent + (storage vs YAML mode). Storage mode supports + ``ResourceStorageCollection.async_create_item``; YAML mode requires + the user to add the resource manually. We attempt the storage-mode + path; on failure (YAML mode, or HA version drift), log a hint + pointing at the manual-add instructions. + """ + try: + from homeassistant.components import lovelace # type: ignore # noqa: F401, PLC0415 + except Exception: + _LOGGER.info( + "PriceHawk Lovelace card: lovelace component not available; " + "skipping auto-registration. Add manually via Resources: %s", + LOVELACE_CARD_RESOURCE_URL, + ) + return + + try: + version = "1" + try: + from homeassistant.loader import async_get_integration + integration = await async_get_integration(hass, "pricehawk") + version = integration.manifest.get("version", "1") + except Exception: + pass + + resource_url = f"{LOVELACE_CARD_RESOURCE_URL}?v={version}" + # Modern HA exposes resources via hass.data["lovelace"].resources. + ll_data = hass.data.get("lovelace") + ll_resources = getattr(ll_data, "resources", None) + if ll_resources is None: + _LOGGER.info( + "PriceHawk Lovelace card: Lovelace storage not ready " + "(YAML mode?). Add resource manually: %s", resource_url, + ) + return + # Avoid duplicate registration on entry reload. + existing = [ + r for r in getattr(ll_resources, "async_items", lambda: [])() + if str(r.get("url", "")).startswith(LOVELACE_CARD_RESOURCE_URL) + ] + if existing: + _LOGGER.debug( + "PriceHawk Lovelace card: resource already registered", + ) + return + await ll_resources.async_create_item( + {"res_type": "module", "url": resource_url} + ) + _LOGGER.info( + "PriceHawk Lovelace card: resource registered at %s", + resource_url, + ) + except Exception: + _LOGGER.warning( + "PriceHawk Lovelace card: auto-register failed. Add manually " + "via Settings > Dashboards > Resources: url=%s, type=module", + LOVELACE_CARD_RESOURCE_URL, + exc_info=True, + ) + + +async def remove_panel(hass: HomeAssistant) -> None: + """Remove the PriceHawk sidebar panels on unload.""" + from homeassistant.components.frontend import async_remove_panel + + for path in (PANEL_URL_PATH, PANEL_V2_URL_PATH): + try: + async_remove_panel(hass, path, warn_if_unknown=False) + _LOGGER.info("PriceHawk: sidebar panel %s removed", path) + except Exception: + _LOGGER.debug( + "PriceHawk: panel %s removal skipped (not registered)", path, + ) diff --git a/custom_components/pricehawk/diagnostics.py b/custom_components/pricehawk/diagnostics.py new file mode 100644 index 0000000..87f95ff --- /dev/null +++ b/custom_components/pricehawk/diagnostics.py @@ -0,0 +1,132 @@ +"""Diagnostics platform for PriceHawk (Phase 8 / PR-7). + +Returns a redacted snapshot of the config entry + selected coordinator +state for the HA "Download diagnostics" button. + +Every API key and HA token field is replaced with ``**REDACTED**`` by +``async_redact_data``. The CDR plan envelope is also redacted because +it's large (~15 KB per plan) — not a secret but blows up the output +size if a user has 5+ entries. See D-P8-3. + +The output is intentionally JSON-serialisable (no datetimes, no aiohttp +session refs, no asyncio.Lock refs). Diagnostics is invoked via HA's +diagnostics REST endpoint which calls ``json.dumps`` on the result. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_API_KEY, + CONF_CDR_PLAN, + CONF_DWT_OE_API_KEY, + CONF_HA_TOKEN, + CONF_LOCALVOLTS_API_KEY, + CONF_NAMED_COMPARATOR_PLAN, +) + +TO_REDACT = { + CONF_API_KEY, + CONF_DWT_OE_API_KEY, + CONF_LOCALVOLTS_API_KEY, + CONF_HA_TOKEN, + # Plan envelopes — not secret but large (15 KB each). Drop to keep + # diagnostics output small. See D-P8-3. + CONF_CDR_PLAN, + CONF_NAMED_COMPARATOR_PLAN, + # DWT-OE / Amber / LocalVolts static plans (per-comparator PRD + # envelopes from Phase 7 PR-4 — same large-but-not-secret rationale). + "amber_static_plan", + "flow_power_static_plan", + "localvolts_static_plan", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a PriceHawk config entry.""" + del hass # not currently needed; reserved for future expansion + coordinator = getattr( + getattr(entry, "runtime_data", None), "coordinator", None + ) + + redacted_data = async_redact_data(dict(entry.data), TO_REDACT) + redacted_options = async_redact_data(dict(entry.options), TO_REDACT) + redaction_count = ( + sum(1 for k in entry.data if k in TO_REDACT) + + sum(1 for k in entry.options if k in TO_REDACT) + ) + + runtime_state: dict[str, Any] = {} + if coordinator is not None: + runtime_state = { + "amber_mode": getattr(coordinator, "_amber_mode", None), + "flow_power_mode": getattr(coordinator, "_flow_power_mode", None), + "localvolts_mode": getattr(coordinator, "_localvolts_mode", None), + "reauth_provider_id": getattr( + coordinator, "_reauth_provider_id", None + ), + "registered_provider_ids": sorted( + getattr(coordinator, "_providers", {}).keys() + ), + "wholesale_settlement": getattr( + coordinator, "_wholesale_settlement", "" + ), + "wholesale_c": getattr(coordinator, "_wholesale_c", None), + "amber_import_c": getattr(coordinator, "_amber_import_c", None), + "amber_export_c": getattr(coordinator, "_amber_export_c", None), + "saving_month_aud": getattr( + coordinator, "_saving_month_aud", None + ), + "daily_cost_history_len": len( + getattr(coordinator, "_daily_cost_history", []) or [] + ), + "ranking_last_run_at": _safe_iso( + getattr(coordinator, "_ranking_last_run_at", None) + ), + "backfill_status": getattr(coordinator, "_backfill_status", None), + } + # DWT price attribution snapshot, if a DWT provider is the + # current plan (Phase 7 PR-2b). + dwt = getattr(coordinator, "_dwt_provider", None) + if dwt is not None: + last_price = getattr(dwt, "last_price", None) + runtime_state["dwt"] = { + "region": getattr(dwt, "region", None), + "last_price_aud_per_mwh": ( + last_price.price_aud_per_mwh if last_price else None + ), + "last_price_interval_end_utc": ( + last_price.interval_end_utc.isoformat() + if last_price + else None + ), + "attribution": ( + last_price.attribution if last_price else None + ), + } + + return { + "entry_id": entry.entry_id, + "entry_data": redacted_data, + "entry_options": redacted_options, + "runtime_state": runtime_state, + "_redaction_count": redaction_count, + } + + +def _safe_iso(value: Any) -> str | None: + """ISO-format a datetime if present; else return None.""" + if value is None: + return None + isoformat = getattr(value, "isoformat", None) + if not callable(isoformat): + return None + result = isoformat() + return result if isinstance(result, str) else None diff --git a/custom_components/pricehawk/manifest.json b/custom_components/pricehawk/manifest.json index 5ec37b9..9b7dede 100644 --- a/custom_components/pricehawk/manifest.json +++ b/custom_components/pricehawk/manifest.json @@ -9,6 +9,7 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/Artic0din/ha-pricehawk/issues", + "quality_scale": "silver", "requirements": ["openelectricity>=0.10.1,<0.11"], - "version": "1.5.0-beta.2" + "version": "1.6.0-beta.1" } diff --git a/custom_components/pricehawk/quality_scale.yaml b/custom_components/pricehawk/quality_scale.yaml new file mode 100644 index 0000000..1e8e3fb --- /dev/null +++ b/custom_components/pricehawk/quality_scale.yaml @@ -0,0 +1,185 @@ +# PriceHawk — HA Integration Quality Scale rules tracker. +# Format per https://developers.home-assistant.io/docs/core/integration-quality-scale-index. +# +# Each rule status: done | exempt | todo. +# Silver flip lands in Phase 8 / PR-9 (this file + manifest quality_scale). +# Gold rules carry status: todo with comments pointing at v4 milestone. +rules: + # ----- Bronze ----- + action-setup: + status: done + appropriate-polling: + status: done + comment: >- + Coordinator polls Amber every 300s, AEMO NEMWeb every 300s, LocalVolts + every 60s; OE/NEMWeb DWT refresh has 4-minute staleness guard (Phase 7 + PR-2b D-P7-11). Grid sensor read each 30s tick — local entity read, no + external API. + brands: + status: todo + comment: HACS submission pending; brands repo PR will follow Wave 2 close. + common-modules: + status: done + config-flow: + status: done + config-flow-test-coverage: + status: done + comment: 99 config-flow related tests across test_config_flow_*.py + Phase 7/8 flows. + dependency-transparency: + status: done + comment: openelectricity>=0.10.1,<0.11 pinned in manifest.json:requirements. + docs-actions: + status: done + comment: docs/services.md covers analyze_csv, backfill_history, rank_alternatives. + docs-high-level-description: + status: done + comment: README.md + docs/architecture.md. + docs-installation-instructions: + status: done + docs-removal-instructions: + status: done + comment: docs/troubleshooting.md covers entry removal. + entity-event-setup: + status: done + entity-unique-id: + status: done + has-entity-name: + status: done + runtime-data: + status: done + comment: Phase 7 PR-1 — PriceHawkConfigEntry + PriceHawkData dataclass. + test-before-configure: + status: done + test-before-setup: + status: done + unique-config-entry: + status: done + + # ----- Silver ----- + action-exceptions: + status: done + comment: >- + Phase 8 PR-9 — service handlers raise HomeAssistantError on missing + coordinator + ServiceValidationError on malformed input (was: warn + + default-fallback). + config-entry-unloading: + status: done + comment: >- + Phase 7 PR-1 — async_unload_platforms ordered before coordinator + teardown; coordinator-tracked unsubscribes via async_on_unload. + docs-configuration-parameters: + status: done + comment: docs/configuration.md. + docs-installation-parameters: + status: done + entity-unavailable: + status: done + comment: >- + CoordinatorEntity-backed sensors honour coordinator.last_update_success; + provider-specific sensors gate on the relevant provider being registered. + integration-owner: + status: done + comment: codeowner @Artic0din in manifest.json. + log-when-unavailable: + status: exempt + comment: >- + Coordinator-backed entities; HA's DataUpdateCoordinator already logs + availability transitions exactly once per state change (set_updated_data + / async_set_update_error). No additional per-entity logging needed. + parallel-updates: + status: done + comment: >- + Phase 8 PR-9 — sensor.py declares PARALLEL_UPDATES = 0 (unlimited; + CoordinatorEntity-backed so concurrent reads safe). + reauthentication-flow: + status: done + comment: >- + Phase 8 PR-5 — per-provider reauth (Amber, LocalVolts, DWT-OE) via + coordinator-tagged _reauth_provider_id + ConfigFlow dispatcher. + test-coverage: + status: done + comment: 962+ tests after Phase 8. Tariff engine ≥ 95% line coverage gate ships with v2.0 GA. + + # ----- Gold (deferred to v4) ----- + devices: + status: todo + comment: v4 — devices abstraction for multi-NMI installs. + diagnostics: + status: done + comment: >- + Phase 8 PR-7 — async_get_config_entry_diagnostics with full + async_redact_data for API keys + size-redacted plan envelopes (D-P8-3). + discovery: + status: exempt + comment: PriceHawk is a cloud-polling integration; no LAN discovery surface. + discovery-update-info: + status: exempt + comment: Same — no discovery. + docs-data-update: + status: todo + comment: v4. + docs-examples: + status: done + comment: docs/ has blueprint examples from Phase 10 Wave 4. + docs-known-limitations: + status: done + comment: docs/troubleshooting.md. + docs-supported-devices: + status: exempt + comment: No devices — integration-type=service. + docs-supported-functions: + status: done + docs-troubleshooting: + status: done + docs-use-cases: + status: done + comment: README.md + docs/architecture.md cover the comparator use case. + dynamic-devices: + status: exempt + comment: No devices. + entity-category: + status: todo + comment: v4 — explicit EntityCategory.DIAGNOSTIC for status-style sensors. + entity-device-class: + status: done + comment: SensorDeviceClass.MONETARY + .ENERGY set where appropriate. + entity-disabled-by-default: + status: todo + comment: v4 — split per-provider sensors into disabled-by-default group. + entity-translations: + status: todo + comment: v4 — explicit translation_key per entity (currently relies on names). + exception-translations: + status: todo + comment: v4 — translation_key on service handler exceptions. + icon-translations: + status: todo + comment: v4. + reconfiguration-flow: + status: done + comment: >- + Phase 8 PR-6 — per-provider reconfigure dispatcher (Amber fees, + LocalVolts supply+ceiling+floor, DWT-OE/AEMO daily supply). Narrow scope + per D-P8-2; region swap deferred to v4 (unique_id redesign). + repairs: + status: done + comment: >- + Phase 8 PR-8 — persistent-notification repairs for + grid_sensor_unavailable + ranking_stale (D-P8-4). + wholesale_source_unreachable deferred (also D-P8-4). + stale-devices: + status: exempt + comment: No devices. + + # ----- Platinum (out of scope for v2/v3) ----- + async-dependency: + status: done + comment: openelectricity SDK is AsyncOEClient; aiohttp everywhere else. + inject-websession: + status: todo + comment: >- + v4 — OpenElectricity SDK 0.10.1 doesn't accept a session= kwarg + (audit M2 finding in 07-02-AUDIT). Re-evaluate when SDK 0.11 lands. + strict-typing: + status: todo + comment: v4 — mypy --strict pass against custom_components/pricehawk/. diff --git a/custom_components/pricehawk/sensor.py b/custom_components/pricehawk/sensor.py index 83c5aea..bf61bbd 100644 --- a/custom_components/pricehawk/sensor.py +++ b/custom_components/pricehawk/sensor.py @@ -24,6 +24,12 @@ _LOGGER = logging.getLogger(__name__) +# Phase 8 PR-9 (HA Silver) — declare parallel updates explicitly. Sensors +# are CoordinatorEntity-backed: state is read from a single shared +# DataUpdateCoordinator, so concurrent entity updates are safe. 0 means +# unlimited concurrency. +PARALLEL_UPDATES = 0 + # Peak-rate sensors only. Import/export rates are owned by GenericProviderRateSensor # (registered in async_setup_entry's providers loop) — listing them here too caused # unique_id collisions that dropped the entities the dashboard depends on. @@ -267,6 +273,39 @@ def last_reset(self) -> datetime | None: return now.replace(hour=0, minute=0, second=0, microsecond=0) +class ChosenPlanCostSensor(PriceHawkBaseSensor): + """Today's cost for the chosen plan — Energy Dashboard pickable. + + Phase 9 PR-11. unique_id is provider-INDEPENDENT so the entity_id + stays stable when the user changes their CDR plan or swaps to a + DWT entry. device_class + unit + state_class + last_reset together + qualify the sensor for HA's Energy Dashboard cost picker (per + https://www.home-assistant.io/docs/energy/individual-devices/). + """ + + _attr_name = "PriceHawk Today Cost" + _attr_device_class = SensorDeviceClass.MONETARY + _attr_native_unit_of_measurement = "AUD" + _attr_state_class = SensorStateClass.TOTAL + _attr_suggested_display_precision = 2 + + def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: + super().__init__(coordinator, entry, key="_chosen_plan_today_cost") + self._attr_unique_id = f"{entry.entry_id}_chosen_plan_today_cost" + + @property + def native_value(self) -> float | None: + provider = getattr(self.coordinator, "_current_plan_provider", None) + if provider is None: + return None + return float(provider.net_daily_cost_aud) + + @property + def last_reset(self) -> datetime | None: + now = dt_util.now() + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + class LastUpdatedSensor(PriceHawkBaseSensor): """Timestamp of the last successful coordinator update.""" @@ -831,6 +870,8 @@ async def async_setup_entry( ) # Comparison and cost sensors + # Phase 9 PR-11 — Energy-Dashboard-pickable chosen-plan cost sensor. + entities.append(ChosenPlanCostSensor(coordinator, entry)) entities.append(BestProviderSensor(coordinator, entry)) entities.append(BestRateSensor(coordinator, entry)) entities.append(CheapestTodaySensor(coordinator, entry)) diff --git a/custom_components/pricehawk/statistics.py b/custom_components/pricehawk/statistics.py new file mode 100644 index 0000000..9b0fdd2 --- /dev/null +++ b/custom_components/pricehawk/statistics.py @@ -0,0 +1,140 @@ +"""External statistics push for PriceHawk (Phase 9 / PR-10). + +Dual-write helper. Coordinator continues writing daily_cost_history to +the JSON Store (the existing source of truth); this module adds the +parallel write to HA's external statistics so cost streams become +pickable in the Energy Dashboard. + +Stats-only flip (remove JSON write) ships in PR-12 / 09-03 — gated on +≥4 weeks elapsed + ≥10 testers confirming clean dual-write ≥7 days +per the ROADMAP v2.0 GA criteria. + +Statistic-id format: ``f"{DOMAIN}:cost_{entry_id[:8]}_{provider_id}"``. +The entry_id slice keeps the id under HA's practical 50-char limit +while staying unique across multi-entry installs (8 chars of +hex-uuid prefix = 2^32 collision space; fine for a single user). +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, time, timezone +from typing import Any + +from homeassistant.components.recorder.statistics import ( + StatisticData, + StatisticMetaData, + async_add_external_statistics, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def external_statistic_id(entry_id: str, provider_id: str) -> str: + """Return the stable HA external-statistic id for one entry+provider.""" + return f"{DOMAIN}:cost_{entry_id[:8]}_{provider_id}" + + +def _metadata_for(entry_id: str, provider_id: str) -> StatisticMetaData: + return StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"PriceHawk {provider_id} cost", + source=DOMAIN, + statistic_id=external_statistic_id(entry_id, provider_id), + unit_of_measurement="AUD", + ) + + +def _day_start_utc(day: date) -> datetime: + """Anchor to midnight UTC. HA stat 'start' is hour-aligned.""" + return datetime.combine(day, time.min, tzinfo=timezone.utc) + + +async def async_push_daily_cost_to_statistics( + hass: HomeAssistant, + entry_id: str, + provider_id: str, + day: date, + cost_aud: float, + cumulative_sum: float, +) -> None: + """Push one day's cost for one provider to HA external statistics. + + Idempotent on ``(statistic_id, start)`` per HA upsert semantics — + safe to call again for the same day (e.g. after a restart that + re-runs the rollover branch). + """ + metadata = _metadata_for(entry_id, provider_id) + stats: list[StatisticData] = [ + StatisticData( + start=_day_start_utc(day), + state=float(cost_aud), + sum=float(cumulative_sum), + ) + ] + async_add_external_statistics(hass, metadata, stats) + _LOGGER.debug( + "external stats push: %s day=%s cost=%.4f sum=%.4f", + metadata["statistic_id"] if isinstance(metadata, dict) + else getattr(metadata, "statistic_id", "?"), + day.isoformat(), cost_aud, cumulative_sum, + ) + + +async def async_backfill_external_statistics( + hass: HomeAssistant, + entry_id: str, + daily_cost_history: list[dict[str, Any]], +) -> int: + """Backfill external statistics from the JSON-Store history. + + Walks the history in date order, computes a monotonic cumulative + sum per provider, and pushes one batch per provider (more efficient + than per-day-per-provider calls). + + Returns the total number of statistic data points written. + """ + if not daily_cost_history: + return 0 + + # Group cost entries by provider id, in date order. The history + # list is already chronological (coordinator appends at rollover). + cumulative: dict[str, float] = {} + per_provider_stats: dict[str, list[StatisticData]] = {} + for entry in daily_cost_history: + day_str = entry.get("date") + if not day_str: + continue + try: + day = date.fromisoformat(day_str) + except (TypeError, ValueError): + continue + for key, value in entry.items(): + if key == "date" or not isinstance(value, (int, float)): + continue + cumulative[key] = cumulative.get(key, 0.0) + float(value) + per_provider_stats.setdefault(key, []).append( + StatisticData( + start=_day_start_utc(day), + state=float(value), + sum=cumulative[key], + ) + ) + + total = 0 + for provider_id, stats in per_provider_stats.items(): + if not stats: + continue + metadata = _metadata_for(entry_id, provider_id) + async_add_external_statistics(hass, metadata, stats) + total += len(stats) + + _LOGGER.info( + "external stats backfill: %d entries across %d providers (entry %s)", + total, len(per_provider_stats), entry_id[:8], + ) + return total diff --git a/custom_components/pricehawk/strings.json b/custom_components/pricehawk/strings.json index 2f970a3..ffcb690 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -78,6 +78,37 @@ "dwt_oe_api_key": "API key" } }, + "reconfigure_amber": { + "title": "Adjust Amber fees", + "description": "Edit Amber's network and subscription daily charges. API key + site are unchanged — use Reauthorize to rotate the API key.", + "data": { + "amber_network_daily_charge": "Network daily charge (c/day)", + "amber_subscription_fee": "Subscription daily fee (c/day)" + } + }, + "reconfigure_localvolts": { + "title": "Adjust LocalVolts settings", + "description": "Edit daily supply + buy ceiling / sell floor guard rails. Credentials are unchanged — use Reauthorize to rotate them.", + "data": { + "localvolts_daily_supply": "Daily supply charge (c/day)", + "localvolts_buy_ceiling": "Buy ceiling (c/kWh, 0 = no cap)", + "localvolts_sell_floor": "Sell floor (c/kWh, 0 = no floor)" + } + }, + "reconfigure_dwt_oe": { + "title": "Adjust Dynamic Wholesale Tariff (OpenElectricity)", + "description": "Edit the daily supply charge. Region + API key are unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "reconfigure_dwt_aemo": { + "title": "Adjust Dynamic Wholesale Tariff (AEMO Direct)", + "description": "Edit the daily supply charge. Region is unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, "site_select": { "title": "Select Amber Site", "description": "Your Amber account has multiple sites. Select the one to use for price comparison.", @@ -270,7 +301,19 @@ "abort": { "already_configured": "PriceHawk is already configured.", "reauth_provider_unknown": "PriceHawk could not determine which provider needs reauthentication. Remove and re-add the integration.", - "reauth_successful": "Credentials updated. PriceHawk has resumed polling." + "reauth_successful": "Credentials updated. PriceHawk has resumed polling.", + "reconfigure_unsupported": "PriceHawk reconfigure is not yet available for this entry type. Use the integration's Configure menu instead.", + "reconfigure_successful": "Settings updated." + } + }, + "issues": { + "grid_sensor_unavailable": { + "title": "Grid power sensor unavailable", + "description": "PriceHawk's configured grid power sensor `{entity_id}` has been unavailable for more than 5 minutes. Without grid power, PriceHawk cannot accumulate cost or savings. Check that the entity exists and is reporting values, then reload the integration." + }, + "ranking_stale": { + "title": "CDR plan ranking is stale", + "description": "The nightly CDR plan ranking job hasn't completed successfully in over {hours} hours. The ranked-alternatives list may be outdated. Use the `pricehawk.rank_alternatives` service to trigger a fresh run." } }, "options": { diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index 2f970a3..ffcb690 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -78,6 +78,37 @@ "dwt_oe_api_key": "API key" } }, + "reconfigure_amber": { + "title": "Adjust Amber fees", + "description": "Edit Amber's network and subscription daily charges. API key + site are unchanged — use Reauthorize to rotate the API key.", + "data": { + "amber_network_daily_charge": "Network daily charge (c/day)", + "amber_subscription_fee": "Subscription daily fee (c/day)" + } + }, + "reconfigure_localvolts": { + "title": "Adjust LocalVolts settings", + "description": "Edit daily supply + buy ceiling / sell floor guard rails. Credentials are unchanged — use Reauthorize to rotate them.", + "data": { + "localvolts_daily_supply": "Daily supply charge (c/day)", + "localvolts_buy_ceiling": "Buy ceiling (c/kWh, 0 = no cap)", + "localvolts_sell_floor": "Sell floor (c/kWh, 0 = no floor)" + } + }, + "reconfigure_dwt_oe": { + "title": "Adjust Dynamic Wholesale Tariff (OpenElectricity)", + "description": "Edit the daily supply charge. Region + API key are unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "reconfigure_dwt_aemo": { + "title": "Adjust Dynamic Wholesale Tariff (AEMO Direct)", + "description": "Edit the daily supply charge. Region is unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, "site_select": { "title": "Select Amber Site", "description": "Your Amber account has multiple sites. Select the one to use for price comparison.", @@ -270,7 +301,19 @@ "abort": { "already_configured": "PriceHawk is already configured.", "reauth_provider_unknown": "PriceHawk could not determine which provider needs reauthentication. Remove and re-add the integration.", - "reauth_successful": "Credentials updated. PriceHawk has resumed polling." + "reauth_successful": "Credentials updated. PriceHawk has resumed polling.", + "reconfigure_unsupported": "PriceHawk reconfigure is not yet available for this entry type. Use the integration's Configure menu instead.", + "reconfigure_successful": "Settings updated." + } + }, + "issues": { + "grid_sensor_unavailable": { + "title": "Grid power sensor unavailable", + "description": "PriceHawk's configured grid power sensor `{entity_id}` has been unavailable for more than 5 minutes. Without grid power, PriceHawk cannot accumulate cost or savings. Check that the entity exists and is reporting values, then reload the integration." + }, + "ranking_stale": { + "title": "CDR plan ranking is stale", + "description": "The nightly CDR plan ranking job hasn't completed successfully in over {hours} hours. The ranked-alternatives list may be outdated. Use the `pricehawk.rank_alternatives` service to trigger a fresh run." } }, "options": { diff --git a/custom_components/pricehawk/www/pricehawk-card.js b/custom_components/pricehawk/www/pricehawk-card.js new file mode 100644 index 0000000..3c39c03 --- /dev/null +++ b/custom_components/pricehawk/www/pricehawk-card.js @@ -0,0 +1,138 @@ +/** + * PriceHawk Lovelace card — Phase 10 PR-14. + * + * Companion to the v2 panel (PR-13). Reads the chosen-plan cost + * sensor introduced in Phase 9 PR-11 and renders a compact card the + * user can drop into any Lovelace dashboard. + * + * Resource auto-registered via `homeassistant.components.frontend + * .async_register_resource` on entry setup — no manual "Add resource" + * step required. + */ + +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@4.2.0/lit-element.js?module"; + +class PriceHawkCostCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + _config: { type: Object, state: true }, + }; + } + + static get styles() { + return css` + :host { + display: block; + } + .card { + background: var(--card-background-color, #fff); + border-radius: 12px; + padding: 16px; + box-shadow: var( + --ha-card-box-shadow, + 0 1px 2px rgba(0, 0, 0, 0.05) + ); + } + .title { + font-size: 0.85rem; + color: var(--secondary-text-color, #757575); + margin-bottom: 4px; + } + .cost { + font-size: 1.8rem; + font-weight: 600; + color: var(--primary-color, #ff8c00); + } + .savings { + font-size: 0.9rem; + color: var(--secondary-text-color, #757575); + margin-top: 8px; + } + .pos { + color: var(--success-color, #2e7d32); + } + .neg { + color: var(--error-color, #c62828); + } + `; + } + + setConfig(config) { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = { + entity: config.entity || "sensor.pricehawk_today_cost", + savings_entity: config.savings_entity || "sensor.pricehawk_saving_today", + title: config.title || "PriceHawk today", + }; + } + + getCardSize() { + return 2; + } + + static getStubConfig() { + return { + type: "custom:pricehawk-cost-card", + title: "PriceHawk today", + entity: "sensor.pricehawk_today_cost", + savings_entity: "sensor.pricehawk_saving_today", + }; + } + + render() { + if (!this.hass || !this._config) { + return html`

Loading…
`; + } + const cost = this.hass.states[this._config.entity]; + const savings = this.hass.states[this._config.savings_entity]; + + const costValue = cost ? cost.state : "—"; + const costUnit = cost?.attributes?.unit_of_measurement ?? "AUD"; + + let savingsText = ""; + let savingsClass = ""; + if (savings && savings.state !== "unknown" && savings.state !== "—") { + const v = parseFloat(savings.state); + if (!isNaN(v)) { + savingsClass = v >= 0 ? "pos" : "neg"; + savingsText = `Saving today: $${v.toFixed(2)}`; + } + } + + return html` + +
+
${this._config.title}
+
$${costValue} ${costUnit}
+ ${savingsText + ? html`
${savingsText}
` + : ""} +
+
+ `; + } +} + +if (!customElements.get("pricehawk-cost-card")) { + customElements.define("pricehawk-cost-card", PriceHawkCostCard); +} + +// Register with HA Lovelace's custom card catalogue so it appears in +// the "Add Card" picker. +window.customCards = window.customCards || []; +if (!window.customCards.find((c) => c.type === "pricehawk-cost-card")) { + window.customCards.push({ + type: "pricehawk-cost-card", + name: "PriceHawk Today Cost", + description: + "Today's cost on the chosen plan, with optional savings line.", + preview: false, + }); +} diff --git a/custom_components/pricehawk/www/pricehawk-panel.js b/custom_components/pricehawk/www/pricehawk-panel.js new file mode 100644 index 0000000..ca7a998 --- /dev/null +++ b/custom_components/pricehawk/www/pricehawk-panel.js @@ -0,0 +1,165 @@ +/** + * PriceHawk v2 panel — Phase 10 PR-13. + * + * Lit-based ``panel_custom`` element. Replaces the iframe-+-LLAT + * approach of the legacy ``www/dashboard.html`` with a proper HA panel + * that reuses the host page's WebSocket connection (auth-via-cookie, + * no LLAT in URL). + * + * The full visual port lives in a follow-up dedicated to Playwright UAT + * — this file establishes the registration mechanism + a minimal + * functional placeholder that shows the chosen-plan cost sensor + * (from Phase 9 PR-11) so users can see the v2 panel is wired. + * + * Imports Lit from the unpkg CDN as ESM modules — no build step + * required. HACS distribution carries this file verbatim into + * ``/local/pricehawk/pricehawk-panel.js``. + */ + +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@4.2.0/lit-element.js?module"; + +class PriceHawkPanel extends LitElement { + static get properties() { + return { + hass: { type: Object }, + narrow: { type: Boolean }, + panel: { type: Object }, + }; + } + + static get styles() { + return css` + :host { + display: block; + padding: 24px; + font-family: + var(--paper-font-body1_-_font-family, "Roboto", sans-serif); + color: var(--primary-text-color, #212121); + } + .card { + background: var(--card-background-color, #fff); + border-radius: 12px; + padding: 20px 24px; + box-shadow: var( + --ha-card-box-shadow, + 0 1px 2px rgba(0, 0, 0, 0.05) + ); + margin-bottom: 16px; + } + h1 { + margin: 0 0 8px; + font-size: 1.6rem; + color: var(--primary-color, #ff8c00); + } + h2 { + margin: 0 0 12px; + font-size: 1.1rem; + } + p { + margin: 4px 0; + line-height: 1.5; + } + .cost { + font-size: 2.2rem; + font-weight: 600; + color: var(--primary-color, #ff8c00); + } + .muted { + color: var(--secondary-text-color, #757575); + font-size: 0.85rem; + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + } + `; + } + + _entityState(entity_id) { + const s = this.hass?.states?.[entity_id]; + return s ? s.state : null; + } + + _entityAttr(entity_id, attr) { + return this.hass?.states?.[entity_id]?.attributes?.[attr] ?? null; + } + + render() { + if (!this.hass) { + return html`
Loading PriceHawk…
`; + } + + const todayCost = this._entityState("sensor.pricehawk_today_cost"); + const todayCostUnit = + this._entityAttr("sensor.pricehawk_today_cost", "unit_of_measurement") ?? + "AUD"; + + const savingsToday = this._entityState("sensor.pricehawk_saving_today"); + const bestProvider = this._entityState("sensor.pricehawk_best_provider"); + + return html` +
+

PriceHawk v2

+

+ Real-time cost comparison across your registered retailers. +

+
+ +
+
+

Today's cost (chosen plan)

+
+ ${todayCost !== null ? `$${todayCost} ${todayCostUnit}` : "—"} +
+

+ Source: sensor.pricehawk_today_cost. Energy-Dashboard + pickable. +

+
+ +
+

Today's saving

+
+ ${savingsToday !== null ? `$${savingsToday}` : "—"} +
+

vs. the cheapest comparator.

+
+ +
+

Best provider today

+
+ ${bestProvider ?? "—"} +
+

+ Lowest projected daily cost across registered providers. +

+
+
+ +
+

Coming in v2

+

+ Ranked-alternatives table, per-window TOU breakdown, CSV import + wizard, blueprint installer. UI port in progress — + open the legacy dashboard + for the full feature surface. +

+

+ Panel served via HA's panel_custom — auth via your + HA session, no long-lived access token in URL. (Phase 10 PR-13) +

+
+ `; + } +} + +if (!customElements.get("pricehawk-panel")) { + customElements.define("pricehawk-panel", PriceHawkPanel); +} diff --git a/requirements.txt b/requirements.txt index 52317ef..821b104 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,10 @@ mypy>=1.10.0 bandit>=1.7.0 pytest>=8.0.0 pytest-cov>=5.0.0 + +# Phase 11 PR-16/PR-18: HA test harness + property-based testing. +# Optional — the 1028 stub-conftest tests don't need these (per D-P11-1 +# dual-mode test strategy). Installed in CI for the new HA-harness + +# Hypothesis test files. +pytest-homeassistant-custom-component>=0.13.0 +hypothesis>=6.100.0 diff --git a/tests/conftest.py b/tests/conftest.py index f39e50d..cdf454b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,8 @@ def __init__(self, *args, **kwargs): "homeassistant.components": _MockModule(), "homeassistant.components.recorder": _MockModule(), "homeassistant.components.recorder.history": _MockModule(), + "homeassistant.components.recorder.statistics": _MockModule(), + "homeassistant.components.diagnostics": _MockModule(), } # Wire parent -> child so attribute access also works @@ -50,6 +52,54 @@ def __init__(self, *args, **kwargs): _mods["homeassistant"].components = _mods["homeassistant.components"] _mods["homeassistant.components"].recorder = _mods["homeassistant.components.recorder"] _mods["homeassistant.components.recorder"].history = _mods["homeassistant.components.recorder.history"] +_mods["homeassistant.components.recorder"].statistics = _mods["homeassistant.components.recorder.statistics"] +_mods["homeassistant.components"].diagnostics = _mods["homeassistant.components.diagnostics"] + +# Phase 9 PR-10: stub StatisticData / StatisticMetaData as plain dicts + +# async_add_external_statistics as an observable recorder. +_stats_mod = _mods["homeassistant.components.recorder.statistics"] +def _StatisticData(**kwargs): # noqa: N802 — mirrors HA typed dict name + return dict(kwargs) +def _StatisticMetaData(**kwargs): # noqa: N802 + return dict(kwargs) +_stats_mod.StatisticData = _StatisticData +_stats_mod.StatisticMetaData = _StatisticMetaData +_stats_mod._calls = [] # (metadata, stats_list) tuples observable by tests +def _async_add_external_statistics(hass, metadata, stats): # noqa: ARG001 + _stats_mod._calls.append((metadata, list(stats))) +_stats_mod.async_add_external_statistics = _async_add_external_statistics + +# Phase 8 PR-7: async_redact_data behaviour needed at test time. Real +# HA impl walks the dict and replaces values for keys in TO_REDACT. +def _async_redact_data(data, to_redact): # pragma: no cover — test helper + if isinstance(data, dict): + return { + k: ("**REDACTED**" if k in to_redact + else _async_redact_data(v, to_redact)) + for k, v in data.items() + } + if isinstance(data, list): + return [_async_redact_data(item, to_redact) for item in data] + return data +_mods["homeassistant.components.diagnostics"].async_redact_data = _async_redact_data + +# Phase 8 PR-8: stub homeassistant.helpers.issue_registry with create/delete +# recorders so tests can observe repair-issue toggles. +_issue_registry = _MockModule() +_issue_registry.IssueSeverity = type( + "IssueSeverity", (), {"WARNING": "warning", "ERROR": "error"} +) +_issue_registry._created = {} # (domain, issue_id) → kwargs +_issue_registry._deleted = [] +def _async_create_issue(hass, domain, issue_id, **kwargs): # noqa: ARG001 + _issue_registry._created[(domain, issue_id)] = kwargs +def _async_delete_issue(hass, domain, issue_id): # noqa: ARG001 + _issue_registry._deleted.append((domain, issue_id)) + _issue_registry._created.pop((domain, issue_id), None) +_issue_registry.async_create_issue = _async_create_issue +_issue_registry.async_delete_issue = _async_delete_issue +_mods["homeassistant.helpers"].issue_registry = _issue_registry +sys.modules["homeassistant.helpers.issue_registry"] = _issue_registry # Provide a CALLBACK_TYPE that's usable as a type annotation _mods["homeassistant.core"].CALLBACK_TYPE = type(None) @@ -62,6 +112,15 @@ def __init__(self, *args, **kwargs): _mods["homeassistant.exceptions"].ConfigEntryAuthFailed = type( "ConfigEntryAuthFailed", (Exception,), {} ) +# Phase 8 PR-9 (HA Silver) — action-exceptions rule. +_mods["homeassistant.exceptions"].HomeAssistantError = type( + "HomeAssistantError", (Exception,), {} +) +_mods["homeassistant.exceptions"].ServiceValidationError = type( + "ServiceValidationError", + (_mods["homeassistant.exceptions"].HomeAssistantError,), + {}, +) _mods["homeassistant"].exceptions = _mods["homeassistant.exceptions"] for name, mod in _mods.items(): diff --git a/tests/ha_fixtures.py b/tests/ha_fixtures.py new file mode 100644 index 0000000..f7ec160 --- /dev/null +++ b/tests/ha_fixtures.py @@ -0,0 +1,128 @@ +"""Phase 11 PR-16 — HA test-harness fixture prototypes. + +These are PROTOTYPE fixtures for the future migration to +``pytest-homeassistant-custom-component``. They're NOT auto-applied; +tests that want the real HA harness import them explicitly. The +existing 1028 stub-conftest tests stay HA-free per D-P11-1. + +Migration path: as each existing test module gets touched in a future +PR, replace its imports + bring in these fixtures. Avoids a single +massive refactor PR that would block review. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock, MagicMock + + +def mock_openelectricity_client( + *, + price_aud_per_mwh: float = 85.42, + region: str = "NSW1", + interval_end_utc: datetime | None = None, +) -> MagicMock: + """Drop-in mock for ``OpenElectricityPriceSource``. + + Returns a MagicMock with ``fetch_current_price`` + ``last_good`` shaped + like the real class. Tests can override individual return values. + """ + from custom_components.pricehawk.providers.openelectricity import ( + WholesalePrice, + ) + + if interval_end_utc is None: + interval_end_utc = datetime.now(tz=timezone.utc) + + price = WholesalePrice( + price_aud_per_mwh=price_aud_per_mwh, + interval_end_utc=interval_end_utc, + region=region, + ) + client = MagicMock() + client.fetch_current_price = AsyncMock(return_value=price) + client.last_good = MagicMock(return_value=price) + return client + + +def mock_nemweb_client( + *, + price_c_kwh: float = 8.5, + region: str = "NSW1", +) -> MagicMock: + """Drop-in mock for ``NEMWebPriceSource``. + + Mirrors the OE mock shape; price is in c/kWh per the real NEMWeb + contract (gets multiplied by 10 internally to match WholesalePrice + ``price_aud_per_mwh`` field). + """ + from custom_components.pricehawk.providers.openelectricity import ( + WholesalePrice, + ) + + price = WholesalePrice( + price_aud_per_mwh=price_c_kwh * 10.0, + interval_end_utc=datetime.now(tz=timezone.utc), + region=region, + attribution="Wholesale price data: AEMO NEMWeb DISPATCH (public)", + ) + client = MagicMock() + client.fetch_current_price = AsyncMock(return_value=price) + client.last_good = MagicMock(return_value=price) + return client + + +def recorder_mock_external_statistics() -> tuple[MagicMock, list[Any]]: + """Mock ``async_add_external_statistics`` for tests that need to + observe stat-push calls without HA's recorder running. + + Returns a tuple of (mock, calls) where calls is appended every time + the mock is invoked: ``calls.append((metadata, stats))``. + """ + calls: list[Any] = [] + + def _record(_hass, metadata, stats): + calls.append((metadata, list(stats))) + + mock = MagicMock(side_effect=_record) + return mock, calls + + +def mock_config_entry_data( + *, + entry_id: str = "test-entry-xyz", + pricing_mode: str = "live_api", +) -> dict[str, Any]: + """Build the minimum entry.data + entry.options needed for a basic + DWT-OE coordinator construction. + + Useful for HA-harness tests that need a real ConfigEntry instance. + """ + from custom_components.pricehawk.const import ( + CONF_AMBER_PRICING_MODE, + CONF_API_KEY, + CONF_CURRENT_PROVIDER, + CONF_DWT_OE_API_KEY, + CONF_DWT_OE_DAILY_SUPPLY, + CONF_DWT_OE_ENABLED, + CONF_DWT_REGION, + CONF_GRID_POWER_SENSOR, + PROVIDER_DWT_OE, + ) + + return { + "entry_id": entry_id, + "data": { + CONF_API_KEY: "", + CONF_DWT_OE_API_KEY: "", + CONF_DWT_REGION: "NSW1", + CONF_CURRENT_PROVIDER: PROVIDER_DWT_OE, + }, + "options": { + CONF_DWT_OE_ENABLED: True, + CONF_DWT_OE_DAILY_SUPPLY: 110.0, + CONF_GRID_POWER_SENSOR: "sensor.test_grid_power", + CONF_AMBER_PRICING_MODE: pricing_mode, + }, + } diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..d75fd38 --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,123 @@ +"""Phase 10 PR-15 — blueprint library tests. + +Sanity-checks the 5 shipped blueprints: YAML parses, has the required +blueprint metadata block, declares domain=automation, names a +source_url, and exposes at least one `input` plus a `trigger`. +""" + +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +BP_DIR = ( + REPO / "custom_components" / "pricehawk" + / "blueprints" / "automation" / "pricehawk" +) + +EXPECTED = [ + "cheapest_plan_alert.yaml", + "cheapest_30min_window.yaml", + "pause_ev_on_spike.yaml", + "daily_7pm_summary.yaml", + "wholesale_spike_alert.yaml", +] + + +def _parse_yaml(path: Path) -> dict: + try: + import yaml # type: ignore[import-not-found] + + # HA's !input tag is custom; register a passthrough constructor + # so safe_load can parse blueprint files without choking. + class _SafeLoader(yaml.SafeLoader): + pass + + _SafeLoader.add_constructor( + "!input", lambda loader, node: f"!input {node.value}" + ) + return yaml.load(path.read_text(), Loader=_SafeLoader) + except ImportError: + # No yaml lib at runtime — fall back to a tiny scanner that + # only verifies the blueprint header block exists. + raw = path.read_text() + return { + "blueprint": { + "name": ( + [ + l.split("name:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("name:") + ] + or ["?"] + )[0], + "domain": ( + [ + l.split("domain:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("domain:") + ] + or [None] + )[0], + "source_url": ( + [ + l.split("source_url:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("source_url:") + ] + or [None] + )[0], + "input": "input:" in raw, + }, + "trigger": "trigger:" in raw, + } + + +class TestBlueprintLibrary: + def test_all_5_blueprints_present(self): + for name in EXPECTED: + assert (BP_DIR / name).exists(), f"missing blueprint: {name}" + + def test_blueprints_parse_yaml(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert "blueprint" in data, f"{name} missing blueprint block" + + def test_blueprints_declare_domain_automation(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert data["blueprint"]["domain"] == "automation" + + def test_blueprints_name_source_url(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + src = data["blueprint"]["source_url"] + assert src, f"{name} missing source_url" + assert "Artic0din/ha-pricehawk" in src + + def test_blueprints_expose_inputs(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + inp = data["blueprint"].get("input") + assert inp, f"{name} has no input block" + + def test_blueprints_have_trigger(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert data.get("trigger"), f"{name} missing trigger" + + def test_cheapest_plan_alert_uses_ranked_alternatives_sensor(self): + raw = (BP_DIR / "cheapest_plan_alert.yaml").read_text() + assert "sensor.pricehawk_ranked_alternatives" in raw + + def test_daily_7pm_summary_uses_today_cost_sensor(self): + raw = (BP_DIR / "daily_7pm_summary.yaml").read_text() + assert "sensor.pricehawk_today_cost" in raw + + def test_pause_ev_blueprint_uses_hysteresis(self): + raw = (BP_DIR / "pause_ev_on_spike.yaml").read_text() + assert "hysteresis_c_kwh" in raw + # Numeric trigger has both above + below to implement hysteresis. + assert "above:" in raw + assert "below:" in raw diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py new file mode 100644 index 0000000..a0d0918 --- /dev/null +++ b/tests/test_ci_workflows.py @@ -0,0 +1,51 @@ +"""Phase 11 PR-17 — CI workflow validation. + +Verifies the validate.yaml workflow ships both hassfest + HACS jobs + +triggers on PR + push + schedule. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +VALIDATE_YAML = REPO / ".github" / "workflows" / "validate.yaml" + + +def _validate_yaml() -> str: + return VALIDATE_YAML.read_text() + + +class TestValidateWorkflow: + def test_file_exists(self): + assert VALIDATE_YAML.exists() + + def test_triggers_on_pull_request(self): + src = _validate_yaml() + assert "pull_request:" in src + + def test_triggers_on_push(self): + src = _validate_yaml() + assert "push:" in src + + def test_runs_hassfest_job(self): + src = _validate_yaml() + assert "validate-hassfest:" in src + assert "home-assistant/actions/hassfest" in src + + def test_runs_hacs_job(self): + src = _validate_yaml() + assert "validate-hacs:" in src + assert "hacs/action" in src + + def test_hacs_category_integration(self): + src = _validate_yaml() + assert 'category: "integration"' in src + + def test_permissions_minimum_required(self): + """Silver rule: minimum permissions per job.""" + src = _validate_yaml() + # Top-level permissions block scoped to contents:read; no + # write-all anti-pattern. + assert "contents: read" in src + assert "write-all" not in src diff --git a/tests/test_codex_regression_fixes.py b/tests/test_codex_regression_fixes.py new file mode 100644 index 0000000..fce05e8 --- /dev/null +++ b/tests/test_codex_regression_fixes.py @@ -0,0 +1,243 @@ +"""Regression tests for codex review findings on the v3.0 stack. + +Five functional regressions surfaced by `codex review --base dev` against +the tip of the Phases 7-11 PR stack. Each test pins the fix in place so a +future refactor can't silently re-introduce the bug. + +Source-level asserts mirror test_reauth.py / test_reconfigure.py because +``EnergyCompareConfigFlow`` and ``EnergyCompareCoordinator`` can't be +instantiated under the conftest HA stubs (per 07-02b D-1 deviation). +""" + +from __future__ import annotations + +from pathlib import Path + + +def _config_flow_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + +def _coordinator_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "coordinator.py" + ).read_text() + + +# ---------------------------------------------------------------------- +# P1 — dashboard_token entry builder persists DWT setup fields +# ---------------------------------------------------------------------- + + +class TestDwtFieldsPersistedAtEntryCreation: + """Codex P1: dashboard_token built entry without DWT fields. New + DWT installs landed in ConfigEntryNotReady at first refresh because + _build_dwt_provider() reads CONF_DWT_OE_ENABLED / CONF_DWT_OE_API_KEY + / CONF_DWT_REGION / CONF_DWT_OE_DAILY_SUPPLY (and AEMO variants) + that were never copied from self._data. + """ + + def test_dashboard_token_copies_dwt_oe_fields(self): + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + # Credentials persisted to entry.data + assert "data[CONF_DWT_OE_API_KEY]" in block + assert "data[CONF_DWT_REGION]" in block + # Runtime config persisted to entry.options + assert "options[CONF_DWT_OE_ENABLED] = True" in block + assert "options[CONF_DWT_OE_DAILY_SUPPLY]" in block + + def test_dashboard_token_copies_dwt_aemo_fields(self): + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + assert "options[CONF_DWT_AEMO_ENABLED] = True" in block + assert "options[CONF_DWT_AEMO_DAILY_SUPPLY]" in block + # AEMO has no API key — region is the only data field. + assert "data[CONF_DWT_REGION]" in block + + def test_dwt_block_gated_on_setup_step_flag(self): + """The DWT copy block must only fire when a DWT setup step ran. + CDR-flow entries (Amber/LV/GloBird primary) must not gain DWT + fields, since their dashboard_token render path is identical. + """ + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + assert ( + 'self._data.get(CONF_DWT_OE_ENABLED)' in block + and 'self._data.get(CONF_DWT_AEMO_ENABLED)' in block + ) + + +# ---------------------------------------------------------------------- +# P2 — comparator form hides static_prd until a static plan is stored +# ---------------------------------------------------------------------- + + +class TestStaticPrdHiddenWithoutStoredPlan: + """Codex P2#2: ALL_PRICING_MODES contained static_prd and was fed + directly to Amber/FP/LV selectors. No flow writes CONF_*_STATIC_PLAN, + so selecting static_prd bricked the reload with ConfigEntryNotReady + (Amber/LV) or a silent fallback-with-warning (Flow Power). + """ + + def test_modes_helper_filters_static_prd_when_plan_absent(self): + src = _config_flow_source() + # The fix introduces a per-comparator helper. + assert "def _modes_for(" in src + # It excludes PRICING_MODE_STATIC_PRD when the static plan key + # is missing from options. + assert "if m != PRICING_MODE_STATIC_PRD" in src + + def test_comparator_form_uses_per_provider_mode_options(self): + """Each of the three comparator selectors must read its own + gated options list, not share a single `_mode_options` list + (the original bug).""" + src = _config_flow_source() + assert "_amber_mode_options" in src + assert "_fp_mode_options" in src + assert "_lv_mode_options" in src + + def test_static_plan_consts_imported(self): + src = _config_flow_source() + assert "CONF_AMBER_STATIC_PLAN" in src + assert "CONF_LOCALVOLTS_STATIC_PLAN" in src + assert "CONF_FLOW_POWER_STATIC_PLAN" in src + + def test_modes_helper_uses_correct_static_key_per_provider(self): + """Each comparator gets its own static-plan key — Amber must + not be gated on the LV static plan or vice versa.""" + src = _config_flow_source() + assert "_modes_for(CONF_AMBER_STATIC_PLAN)" in src + assert "_modes_for(CONF_FLOW_POWER_STATIC_PLAN)" in src + assert "_modes_for(CONF_LOCALVOLTS_STATIC_PLAN)" in src + + +# ---------------------------------------------------------------------- +# P3 — reauth dispatcher survives startup auth-failure without runtime_data +# ---------------------------------------------------------------------- + + +class TestReauthDispatcherFallbackToEntryData: + """Codex P2#3: dispatcher read coordinator._reauth_provider_id via + entry.runtime_data, but runtime_data is set AFTER + async_config_entry_first_refresh() succeeds. Auth failures during + startup or first refresh therefore got no provider id and aborted + with reauth_provider_unknown. + """ + + def test_dispatcher_falls_back_to_entry_data_provider(self): + src = _config_flow_source() + start = src.index("async def async_step_reauth(") + end = src.index("async def async_step_reauth_amber(", start) + block = src[start:end] + # The fallback must read CONF_CURRENT_PROVIDER from entry.data. + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block + # The fallback must only fire when the coordinator tag is None. + assert "if provider_id is None:" in block + + def test_dispatcher_still_prefers_coordinator_tag_when_present(self): + """The coordinator tag is provider-specific (could be the + comparator that failed, not the primary). Don't replace it — + only fall back when missing.""" + src = _config_flow_source() + start = src.index("async def async_step_reauth(") + end = src.index("async def async_step_reauth_amber(", start) + block = src[start:end] + # The original read-from-runtime_data path stays. + assert '"_reauth_provider_id"' in block + # entry_data parameter is still discarded (the fix uses entry.data + # via _get_reauth_entry, not the entry_data mapping argument). + assert "del entry_data" in block + + +# ---------------------------------------------------------------------- +# P4 — reconfigure dispatcher uses entry.data not CDR plan id +# ---------------------------------------------------------------------- + + +class TestReconfigureDispatcherUsesEntryData: + """Codex P2#4: dispatcher read coordinator._current_plan_provider.id, + but CdrPlanProvider.id is ``{brand}_{plan_id}`` (e.g. + ``amber_brokerage-xyz``), never the literal PROVIDER_AMBER / + PROVIDER_LOCALVOLTS. CDR-backed Amber/LV entries — the install base — + therefore fell through to reconfigure_unsupported. + """ + + def test_reconfigure_routes_via_entry_data_provider(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber(", start) + block = src[start:end] + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block + + def test_reconfigure_does_not_read_coordinator_plan_id(self): + """Regression guard: a future refactor must not re-introduce + the coordinator-id lookup, which is the CDR brand_planId for + the install base and never matches the comparison literals.""" + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber(", start) + block = src[start:end] + assert "_current_plan_provider" not in block + + +# ---------------------------------------------------------------------- +# P5 — Amber schedule fetch gated on live API mode +# ---------------------------------------------------------------------- + + +class TestAmberScheduleFetchGatedOnLiveMode: + """Codex P2#5: ``_maybe_poll_amber`` returns early in static/off + mode without updating _last_amber_poll, leaving it at 0.0 forever. + ``_async_update_data`` then re-triggers _fetch_today_price_schedule + on every 30s tick because ``_last_amber_poll == 0.0`` is its + first-run sentinel. Result: DWT and static-Amber entries hammered + Amber's API every 30s with stale or missing credentials. + """ + + def test_schedule_fetch_guard_checks_live_api_mode(self): + src = _coordinator_source() + # Find the first-run guard at the top of _async_update_data. + start = src.index("async def _async_update_data(") + end = src.index("await self._maybe_poll_amber()", start) + block = src[start:end] + # The guard must check pricing mode in addition to the + # _last_amber_poll == 0.0 sentinel. + assert "self._amber_mode == PRICING_MODE_LIVE_API" in block + assert "self._last_amber_poll == 0.0" in block + + def test_schedule_fetch_only_called_under_combined_guard(self): + """The _fetch_today_price_schedule call must sit inside the + combined `(== 0.0) and (LIVE_API)` guard, not before it or + in a separate branch. + """ + src = _coordinator_source() + start = src.index("async def _async_update_data(") + end = start + src[start:].index("await self._maybe_poll_amber()") + block = src[start:end] + # Single call site for the first-run schedule fetch. + assert block.count("await self._fetch_today_price_schedule()") == 1 + # And it must follow the combined guard — find the `if` that + # encloses it. + idx = block.index("await self._fetch_today_price_schedule()") + # Walk back to find the controlling `if` — must reference both + # _last_amber_poll and PRICING_MODE_LIVE_API. + preceding = block[:idx] + last_if = preceding.rindex("if ") + guard = preceding[last_if:idx] + assert "_last_amber_poll" in guard + assert "PRICING_MODE_LIVE_API" in guard diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..40500b3 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,162 @@ +"""Phase 8 PR-7 — diagnostics platform tests. + +The conftest stubs HA's async_redact_data with a behaviour-equivalent +helper (real impl walks the dict and replaces matched keys with +"**REDACTED**"). Tests verify the redaction list hits every API key ++ HA token + large-but-not-secret plan envelope per D-P8-3. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +from custom_components.pricehawk.const import ( + CONF_API_KEY, + CONF_CDR_PLAN, + CONF_DWT_OE_API_KEY, + CONF_HA_TOKEN, + CONF_LOCALVOLTS_API_KEY, + CONF_NAMED_COMPARATOR_PLAN, +) +from custom_components.pricehawk.diagnostics import ( + TO_REDACT, + async_get_config_entry_diagnostics, +) + + +def _entry(*, data: dict, options: dict, coordinator=None): + runtime_data = SimpleNamespace(coordinator=coordinator) if coordinator else None + return SimpleNamespace( + entry_id="test-entry-xyz", + data=data, + options=options, + runtime_data=runtime_data, + ) + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class TestRedactionList: + def test_to_redact_covers_every_api_key_field(self): + for field in ( + CONF_API_KEY, + CONF_DWT_OE_API_KEY, + CONF_LOCALVOLTS_API_KEY, + CONF_HA_TOKEN, + ): + assert field in TO_REDACT, ( + f"{field} not in TO_REDACT — would leak in diagnostics" + ) + + def test_to_redact_includes_large_plan_envelopes(self): + """D-P8-3: plan envelopes redacted for size, not secrecy.""" + assert CONF_CDR_PLAN in TO_REDACT + assert CONF_NAMED_COMPARATOR_PLAN in TO_REDACT + assert "amber_static_plan" in TO_REDACT + assert "localvolts_static_plan" in TO_REDACT + + +class TestDiagnosticsOutput: + def test_api_key_redacted_in_entry_data(self): + entry = _entry( + data={CONF_API_KEY: "sk-leaked-secret-amber-key"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "sk-leaked-secret-amber-key" not in json.dumps(out) + assert out["entry_data"][CONF_API_KEY] == "**REDACTED**" + assert out["_redaction_count"] >= 1 + + def test_dwt_oe_api_key_redacted(self): + entry = _entry( + data={CONF_DWT_OE_API_KEY: "oe-secret-key-abc123"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "oe-secret-key-abc123" not in json.dumps(out) + + def test_localvolts_api_key_redacted(self): + entry = _entry( + data={}, + options={CONF_LOCALVOLTS_API_KEY: "lv-secret-xyz"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "lv-secret-xyz" not in json.dumps(out) + + def test_ha_token_redacted(self): + entry = _entry( + data={CONF_HA_TOKEN: "ha-jwt-token-long-string"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "ha-jwt-token-long-string" not in json.dumps(out) + + def test_cdr_plan_envelope_redacted_for_size(self): + """D-P8-3: redacted not for secrecy but to keep output small.""" + entry = _entry( + data={}, + options={CONF_CDR_PLAN: {"data": {"planId": "BIG-12345"}}}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "BIG-12345" not in json.dumps(out) + + def test_output_is_json_serialisable(self): + entry = _entry( + data={CONF_API_KEY: "secret"}, + options={"some_other": "value"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + json.dumps(out) # raises if not serialisable + + def test_runtime_state_empty_when_no_coordinator(self): + entry = _entry(data={}, options={}) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert out["runtime_state"] == {} + + def test_runtime_state_populated_when_coordinator_present(self): + coord = SimpleNamespace( + _amber_mode="live_api", + _flow_power_mode="off", + _localvolts_mode="static_prd", + _reauth_provider_id=None, + _providers={"amber": object(), "globird": object()}, + _wholesale_settlement="2026-05-22 12:30:00", + _wholesale_c=5.5, + _amber_import_c=33.0, + _amber_export_c=5.5, + _saving_month_aud=12.34, + _daily_cost_history=[{}] * 30, + _ranking_last_run_at=None, + _backfill_status="idle", + _dwt_provider=None, + ) + entry = _entry(data={}, options={}, coordinator=coord) + out = _run(async_get_config_entry_diagnostics(None, entry)) + rs = out["runtime_state"] + assert rs["amber_mode"] == "live_api" + assert rs["localvolts_mode"] == "static_prd" + assert sorted(rs["registered_provider_ids"]) == ["amber", "globird"] + assert rs["daily_cost_history_len"] == 30 + assert rs["saving_month_aud"] == 12.34 + + def test_no_secret_in_repr(self): + """Even the repr() of the output dict has nothing leaking.""" + entry = _entry( + data={CONF_API_KEY: "ultra-secret-12345"}, + options={CONF_LOCALVOLTS_API_KEY: "ultra-secret-67890"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "ultra-secret-12345" not in repr(out) + assert "ultra-secret-67890" not in repr(out) + + def test_redaction_count_zero_when_no_secrets(self): + entry = _entry( + data={"some_non_secret": "value"}, + options={"another_non_secret": "v"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert out["_redaction_count"] == 0 diff --git a/tests/test_external_statistics.py b/tests/test_external_statistics.py new file mode 100644 index 0000000..253a522 --- /dev/null +++ b/tests/test_external_statistics.py @@ -0,0 +1,215 @@ +"""Phase 9 PR-10 — external statistics dual-write tests. + +The conftest stubs `homeassistant.components.recorder.statistics` with +- StatisticData / StatisticMetaData as plain dicts +- async_add_external_statistics as an observable recorder +so tests can verify the calls without HA's recorder running. +""" + +from __future__ import annotations + +import asyncio +from datetime import date, datetime, timezone + +from homeassistant.components.recorder.statistics import _calls as _stats_calls +from custom_components.pricehawk.const import DOMAIN +from custom_components.pricehawk.statistics import ( + async_backfill_external_statistics, + async_push_daily_cost_to_statistics, + external_statistic_id, +) + + +def _reset_stats(): + _stats_calls.clear() + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +# ---------------------------------------------------------------------- +# external_statistic_id +# ---------------------------------------------------------------------- + + +class TestExternalStatisticId: + def test_format_prefixes_with_domain(self): + sid = external_statistic_id("abcdef1234567890", "amber") + assert sid.startswith(f"{DOMAIN}:cost_") + + def test_entry_id_sliced_to_8_chars(self): + sid = external_statistic_id("entry-id-very-long-string", "amber") + assert "entry-id" in sid + assert "very-long" not in sid + + def test_stable_across_calls(self): + a = external_statistic_id("abcdefgh", "amber") + b = external_statistic_id("abcdefgh", "amber") + assert a == b + + def test_distinct_per_provider(self): + amber = external_statistic_id("abcdefgh", "amber") + globird = external_statistic_id("abcdefgh", "globird") + assert amber != globird + + def test_distinct_per_entry(self): + a = external_statistic_id("entry-AAA", "amber") + b = external_statistic_id("entry-BBB", "amber") + assert a != b + + +# ---------------------------------------------------------------------- +# async_push_daily_cost_to_statistics +# ---------------------------------------------------------------------- + + +class TestPushDailyCost: + def test_calls_async_add_external_statistics(self): + _reset_stats() + _run(async_push_daily_cost_to_statistics( + hass=None, + entry_id="entry-abc123", + provider_id="amber", + day=date(2026, 5, 22), + cost_aud=5.23, + cumulative_sum=156.78, + )) + assert len(_stats_calls) == 1 + + def test_metadata_includes_unit_of_measurement_aud(self): + _reset_stats() + _run(async_push_daily_cost_to_statistics( + None, "entry-abc123", "amber", + date(2026, 5, 22), 5.23, 156.78, + )) + metadata, _stats = _stats_calls[0] + assert metadata["unit_of_measurement"] == "AUD" + + def test_metadata_has_sum_true(self): + _reset_stats() + _run(async_push_daily_cost_to_statistics( + None, "entry-abc123", "amber", + date(2026, 5, 22), 5.23, 156.78, + )) + metadata, _ = _stats_calls[0] + assert metadata["has_sum"] is True + assert metadata["has_mean"] is False + + def test_metadata_source_is_domain(self): + _reset_stats() + _run(async_push_daily_cost_to_statistics( + None, "entry-abc123", "amber", + date(2026, 5, 22), 5.23, 156.78, + )) + metadata, _ = _stats_calls[0] + assert metadata["source"] == DOMAIN + + def test_stat_start_is_midnight_utc(self): + _reset_stats() + _run(async_push_daily_cost_to_statistics( + None, "entry-abc123", "amber", + date(2026, 5, 22), 5.23, 156.78, + )) + _, stats = _stats_calls[0] + assert len(stats) == 1 + start = stats[0]["start"] + assert isinstance(start, datetime) + assert start.tzinfo == timezone.utc + assert start.hour == 0 + assert start.minute == 0 + + +# ---------------------------------------------------------------------- +# async_backfill_external_statistics +# ---------------------------------------------------------------------- + + +def _history(*rows): + return [{"date": d, **costs} for d, costs in rows] + + +class TestBackfill: + def test_empty_history_returns_zero(self): + _reset_stats() + result = _run(async_backfill_external_statistics( + None, "entry-abc123", [], + )) + assert result == 0 + assert len(_stats_calls) == 0 + + def test_walks_history_in_order(self): + _reset_stats() + history = _history( + ("2026-05-20", {"amber": 5.0}), + ("2026-05-21", {"amber": 6.0}), + ("2026-05-22", {"amber": 7.0}), + ) + count = _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + assert count == 3 + assert len(_stats_calls) == 1 # one batch call for amber + _, stats = _stats_calls[0] + assert [s["state"] for s in stats] == [5.0, 6.0, 7.0] + + def test_computes_monotonic_cumulative_sum(self): + _reset_stats() + history = _history( + ("2026-05-20", {"amber": 5.0}), + ("2026-05-21", {"amber": 6.0}), + ("2026-05-22", {"amber": 7.0}), + ) + _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + _, stats = _stats_calls[0] + assert [s["sum"] for s in stats] == [5.0, 11.0, 18.0] + + def test_one_batch_per_provider(self): + _reset_stats() + history = _history( + ("2026-05-20", {"amber": 5.0, "globird": 4.5}), + ("2026-05-21", {"amber": 6.0, "globird": 5.5}), + ) + _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + assert len(_stats_calls) == 2 # one batch per provider + + def test_negative_cost_does_not_break_cumulative(self): + """Export-heavy day with high FiT → negative cost. Cumulative dips.""" + _reset_stats() + history = _history( + ("2026-05-20", {"amber": 5.0}), + ("2026-05-21", {"amber": -2.0}), + ("2026-05-22", {"amber": 3.0}), + ) + _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + _, stats = _stats_calls[0] + assert [s["sum"] for s in stats] == [5.0, 3.0, 6.0] + + def test_malformed_date_skipped(self): + _reset_stats() + history = [ + {"date": "2026-05-20", "amber": 5.0}, + {"date": "garbage", "amber": 6.0}, # skip + {"date": "2026-05-22", "amber": 7.0}, + ] + count = _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + assert count == 2 + + def test_non_numeric_provider_value_skipped(self): + """Defensive: history dict might have str values from old code paths.""" + _reset_stats() + history = [ + {"date": "2026-05-20", "amber": 5.0, "extras": "non-numeric"}, + ] + count = _run(async_backfill_external_statistics( + None, "entry-abc123", history, + )) + assert count == 1 diff --git a/tests/test_ha_harness_smoke.py b/tests/test_ha_harness_smoke.py new file mode 100644 index 0000000..4088644 --- /dev/null +++ b/tests/test_ha_harness_smoke.py @@ -0,0 +1,84 @@ +"""Phase 11 PR-16 — smoke tests for the new HA-harness fixtures. + +Validates that the ``ha_fixtures`` module exports the expected helper +shapes. These tests don't yet drive an actual ``pytest-homeassistant- +custom-component`` ``hass`` fixture — that migration is per-module per +D-P11-1 (dual-mode strategy). For now we just sanity-check the mock +shapes so future tests can rely on them. +""" + +from __future__ import annotations + +import asyncio + +from tests.ha_fixtures import ( + mock_config_entry_data, + mock_nemweb_client, + mock_openelectricity_client, + recorder_mock_external_statistics, +) + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class TestMockOEClient: + def test_default_returns_wholesale_price(self): + client = mock_openelectricity_client() + result = _run(client.fetch_current_price("NSW1")) + assert result.price_aud_per_mwh == 85.42 + assert result.region == "NSW1" + + def test_custom_price_propagated(self): + client = mock_openelectricity_client(price_aud_per_mwh=42.0) + result = _run(client.fetch_current_price("VIC1")) + assert result.price_aud_per_mwh == 42.0 + + def test_last_good_returns_same_price(self): + client = mock_openelectricity_client(price_aud_per_mwh=100.0) + assert client.last_good("NSW1").price_aud_per_mwh == 100.0 + + +class TestMockNEMWebClient: + def test_default_c_kwh_to_aud_per_mwh_conversion(self): + client = mock_nemweb_client(price_c_kwh=8.5) + result = _run(client.fetch_current_price("NSW1")) + # 8.5 c/kWh = 85 $/MWh + assert result.price_aud_per_mwh == 85.0 + + def test_attribution_is_nemweb(self): + client = mock_nemweb_client() + result = _run(client.fetch_current_price("NSW1")) + assert "NEMWeb" in result.attribution + + +class TestRecorderMockExternalStatistics: + def test_call_records_metadata_and_stats(self): + mock, calls = recorder_mock_external_statistics() + mock(None, {"statistic_id": "test:foo"}, [{"start": "x", "state": 5.0, "sum": 5.0}]) + assert len(calls) == 1 + metadata, stats = calls[0] + assert metadata["statistic_id"] == "test:foo" + assert stats[0]["state"] == 5.0 + + def test_multiple_calls_accumulate(self): + mock, calls = recorder_mock_external_statistics() + for _ in range(3): + mock(None, {}, []) + assert len(calls) == 3 + + +class TestConfigEntryData: + def test_default_is_dwt_oe(self): + entry = mock_config_entry_data() + assert entry["data"]["current_provider"] == "dwt_openelectricity" + assert entry["options"]["dwt_oe_enabled"] is True + + def test_pricing_mode_override(self): + entry = mock_config_entry_data(pricing_mode="static_prd") + assert entry["options"]["amber_pricing_mode"] == "static_prd" + + def test_entry_id_override(self): + entry = mock_config_entry_data(entry_id="custom-id-123") + assert entry["entry_id"] == "custom-id-123" diff --git a/tests/test_lit_panel.py b/tests/test_lit_panel.py new file mode 100644 index 0000000..0379bd7 --- /dev/null +++ b/tests/test_lit_panel.py @@ -0,0 +1,116 @@ +"""Phase 10 PR-13 — Lit panel_custom foundation tests. + +Source-level + filesystem asserts. Frontend bundle compilation + +visual UAT live in a dedicated Playwright follow-up. +""" + +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def _dashboard_config_source() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "dashboard_config.py" + ).read_text() + + +def _init_source() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + + +def _panel_js() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "www" / "pricehawk-panel.js" + ).read_text() + + +class TestPanelJSAsset: + def test_panel_js_file_exists(self): + path = REPO / "custom_components" / "pricehawk" / "www" / "pricehawk-panel.js" + assert path.exists(), ( + "pricehawk-panel.js must exist for copy_www_assets to publish it" + ) + + def test_panel_defines_custom_element_pricehawk_panel(self): + src = _panel_js() + assert "customElements.define(" in src + assert '"pricehawk-panel"' in src + + def test_panel_reads_today_cost_sensor_from_phase_9_pr11(self): + """Lit panel surfaces the Energy-Dashboard-pickable sensor.""" + assert "sensor.pricehawk_today_cost" in _panel_js() + + def test_panel_imports_lit_from_module_url(self): + """ESM CDN import — no build step required.""" + src = _panel_js() + assert "lit-element" in src or "lit-html" in src + assert "?module" in src # ESM hint to the CDN + + def test_panel_uses_hass_states_not_llat(self): + """Auth-via-host-session contract: read hass.states, no token in code.""" + src = _panel_js() + assert "this.hass" in src + # No token / LLAT plumbing in the source. Strip JS comments + # before the check so the docstring's reference to "LLAT" in + # the rationale doesn't trip us. + import re + code_only = re.sub(r"/\*[\s\S]*?\*/", "", src) + code_only = re.sub(r"//.*", "", code_only) + assert "token=" not in code_only + assert "longLivedAccessToken" not in code_only + assert "long_lived" not in code_only.lower() + + +class TestPanelCustomRegistration: + def test_setup_panel_custom_v2_defined(self): + assert "async def setup_panel_custom_v2(" in _dashboard_config_source() + + def test_panel_uses_component_name_custom(self): + src = _dashboard_config_source() + # The v2 registration uses panel_custom (component_name="custom"). + assert 'component_name="custom"' in src + + def test_panel_uses_panel_custom_config_dict(self): + src = _dashboard_config_source() + # HA's _panel_custom key drives the JS module + element name. + assert "_panel_custom" in src + assert '"embed_iframe": False' in src + assert '"trust_external": False' in src + + def test_module_url_carries_version_busting_query(self): + src = _dashboard_config_source() + assert ( + 'f"/local/pricehawk/pricehawk-panel.js?v={cache_token}"' in src + ) + + def test_v2_url_path_distinct_from_legacy(self): + src = _dashboard_config_source() + assert 'PANEL_V2_URL_PATH = "pricehawk"' in src + assert 'PANEL_URL_PATH = "pricehawk-dashboard"' in src + + def test_v2_panel_called_from_async_setup_entry(self): + src = _init_source() + assert "await setup_panel_custom_v2(hass)" in src + # Legacy iframe path still wired for the migration window. + assert "await setup_panel_iframe(hass, entry)" in src + + def test_remove_panel_handles_both_paths(self): + """Unload must clean up BOTH the legacy and v2 panels.""" + src = _dashboard_config_source() + assert "for path in (PANEL_URL_PATH, PANEL_V2_URL_PATH)" in src + + +class TestCopyAssets: + def test_copy_www_assets_includes_panel_js(self): + src = _dashboard_config_source() + # The asset copier must copy the new JS file alongside the + # legacy HTML dashboard. + assert "pricehawk-panel.js" in src + # And specifically copy it from the source www/ directory. + assert 'src_dir / "www" / "pricehawk-panel.js"' in src diff --git a/tests/test_lovelace_card.py b/tests/test_lovelace_card.py new file mode 100644 index 0000000..437cd00 --- /dev/null +++ b/tests/test_lovelace_card.py @@ -0,0 +1,87 @@ +"""Phase 10 PR-14 — Lovelace card source + registration tests.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def _card_js() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "www" / "pricehawk-card.js" + ).read_text() + + +def _dashboard_config_src() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "dashboard_config.py" + ).read_text() + + +def _init_src() -> str: + return ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + + +class TestCardAsset: + def test_card_js_exists(self): + path = REPO / "custom_components" / "pricehawk" / "www" / "pricehawk-card.js" + assert path.exists() + + def test_card_defines_pricehawk_cost_card(self): + src = _card_js() + assert "customElements.define(" in src + assert '"pricehawk-cost-card"' in src + + def test_card_registers_in_customCards_catalogue(self): + """HA's "Add Card" picker reads window.customCards.""" + src = _card_js() + assert "window.customCards" in src + assert '"pricehawk-cost-card"' in src + + def test_card_uses_setConfig_and_getCardSize(self): + """Lovelace custom-card interface contract.""" + src = _card_js() + assert "setConfig(config)" in src + assert "getCardSize()" in src + + def test_card_default_entity_is_today_cost(self): + """Phase 9 PR-11 sensor is the default.""" + src = _card_js() + assert '"sensor.pricehawk_today_cost"' in src + + +class TestResourceRegistration: + def test_register_function_defined(self): + src = _dashboard_config_src() + assert "async def register_lovelace_card_resource(" in src + + def test_resource_url_constant(self): + src = _dashboard_config_src() + assert ( + 'LOVELACE_CARD_RESOURCE_URL = "/local/pricehawk/pricehawk-card.js"' + in src + ) + + def test_resource_type_module(self): + src = _dashboard_config_src() + assert '"res_type": "module"' in src + + def test_dedup_existing_resource(self): + """Avoid duplicate registration on entry reload.""" + src = _dashboard_config_src() + assert "existing = [" in src + assert "LOVELACE_CARD_RESOURCE_URL" in src + + def test_called_from_async_setup_entry(self): + src = _init_src() + assert "await register_lovelace_card_resource(hass)" in src + + +class TestCopyAsset: + def test_card_js_copied_alongside_panel_js(self): + src = _dashboard_config_src() + assert "shutil.copy2(str(src_card_js), card_js_path)" in src diff --git a/tests/test_reconfigure.py b/tests/test_reconfigure.py new file mode 100644 index 0000000..94b7664 --- /dev/null +++ b/tests/test_reconfigure.py @@ -0,0 +1,164 @@ +"""Phase 8 PR-6 — reconfigure flow source-level tests. + +Mirrors test_reauth.py pattern — source-grep on config_flow.py because +EnergyCompareConfigFlow can't be instantiated under conftest HA stubs +(documented in 07-02b D-1). +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def _config_flow_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + +def _strings_json() -> dict: + return json.load( + open( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "strings.json" + ) + ) + + +class TestDispatcherRouting: + def test_dispatcher_routes_to_amber_substep(self): + src = _config_flow_source() + assert "if provider_id == PROVIDER_AMBER:" in src + assert "return await self.async_step_reconfigure_amber()" in src + + def test_dispatcher_routes_to_localvolts_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_localvolts()" in src + + def test_dispatcher_routes_to_dwt_oe_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_dwt_oe()" in src + + def test_dispatcher_routes_to_dwt_aemo_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_dwt_aemo()" in src + + def test_dispatcher_aborts_on_unsupported_entry(self): + src = _config_flow_source() + assert 'reason="reconfigure_unsupported"' in src + + def test_dispatcher_reads_provider_from_entry_data(self): + """Codex fix: dispatch from entry.data[CONF_CURRENT_PROVIDER] + not coordinator._current_plan_provider.id. CdrPlanProvider.id is + ``{brand}_{plan_id}`` for CDR Amber/LV entries and never matches + the literal PROVIDER_AMBER / PROVIDER_LOCALVOLTS slug, so reading + it from the coordinator made those reconfigure branches unreachable + for the install base. + """ + src = _config_flow_source() + # Find the reconfigure dispatcher block. + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber", start) + block = src[start:end] + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block, ( + "Reconfigure dispatcher must read CONF_CURRENT_PROVIDER from " + "entry.data, not from coordinator._current_plan_provider.id." + ) + assert "self._get_reconfigure_entry()" in block + # Guard against regression — runtime coordinator id MUST NOT + # be the source of the dispatch decision. + assert "_current_plan_provider" not in block, ( + "Reconfigure dispatcher must NOT rely on the runtime " + "coordinator's _current_plan_provider — that is the CDR " + "brand_planId for CDR users, not the literal provider slug." + ) + + +class TestSubstepContract: + def test_amber_substep_updates_options_not_data(self): + src = _config_flow_source() + # Amber sub-step writes fees into options. + assert "CONF_AMBER_NETWORK_DAILY_CHARGE: float(" in src + assert "CONF_AMBER_SUBSCRIPTION_FEE: float(" in src + + def test_localvolts_substep_updates_three_option_fields(self): + src = _config_flow_source() + assert "CONF_LOCALVOLTS_DAILY_SUPPLY: float(" in src + assert "CONF_LOCALVOLTS_BUY_CEILING: float(" in src + assert "CONF_LOCALVOLTS_SELL_FLOOR: float(" in src + + def test_dwt_oe_substep_only_edits_daily_supply(self): + src = _config_flow_source() + # DWT-OE reconfigure MUST NOT touch region or API key. + # Find the dwt_oe block: + start = src.index("async def async_step_reconfigure_dwt_oe") + end = src.index("async def async_step_reconfigure_dwt_aemo", start) + block = src[start:end] + assert "CONF_DWT_OE_DAILY_SUPPLY" in block + assert "CONF_DWT_REGION" not in block + assert "CONF_DWT_OE_API_KEY" not in block + + def test_dwt_aemo_substep_only_edits_daily_supply(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure_dwt_aemo") + # Find end of method — next def or @staticmethod + end = src.index("@staticmethod", start) + block = src[start:end] + assert "CONF_DWT_AEMO_DAILY_SUPPLY" in block + assert "CONF_DWT_REGION" not in block + + def test_all_substeps_use_update_reload_and_abort(self): + src = _config_flow_source() + # 4 sub-steps + the 3 reauth sub-steps = 7 calls minimum. + assert src.count("self.async_update_reload_and_abort(") >= 7 + + +class TestHistoryPreservation: + def test_reconfigure_block_does_not_reset_history(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("@staticmethod", start) + block = src[start:end] + # No reset_daily, no history wipes, no entry.data mutation inside reconfigure. + assert ".reset_daily()" not in block + assert "_daily_cost_history" not in block + assert "data={**entry.data" not in block # reconfigure ONLY touches options + + +class TestStringsParity: + def test_strings_have_four_reconfigure_steps(self): + s = _strings_json() + for step_id in ( + "reconfigure_amber", + "reconfigure_localvolts", + "reconfigure_dwt_oe", + "reconfigure_dwt_aemo", + ): + assert step_id in s["config"]["step"], ( + f"strings.json missing config.step.{step_id}" + ) + + def test_strings_have_reconfigure_abort_reasons(self): + s = _strings_json() + assert "reconfigure_unsupported" in s["config"]["abort"] + assert "reconfigure_successful" in s["config"]["abort"] + + def test_translations_byte_identical(self): + repo = Path(__file__).resolve().parents[1] + a = ( + repo / "custom_components" / "pricehawk" / "strings.json" + ).read_bytes() + b = ( + repo + / "custom_components" + / "pricehawk" + / "translations" + / "en.json" + ).read_bytes() + assert a == b diff --git a/tests/test_repairs.py b/tests/test_repairs.py new file mode 100644 index 0000000..d98a6ee --- /dev/null +++ b/tests/test_repairs.py @@ -0,0 +1,252 @@ +"""Phase 8 PR-8 — repairs platform tests. + +PriceHawkCoordinator is a MagicMock under conftest HA stubs (same root +cause as 07-02b D-1 deviation). The production `_set_repair` and +`_check_repairs` logic is therefore exercised via a small standalone +re-implementation that mirrors the production semantics + source-grep +asserts on coordinator.py for the existence + threshold contracts. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from custom_components.pricehawk.const import DOMAIN +from homeassistant.helpers import issue_registry as ir + + +def _coordinator_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "coordinator.py" + ).read_text() + + +def _reset_registry(): + ir._created.clear() + ir._deleted.clear() + + +class _Stand: + """Minimal stand-in mirroring the production _set_repair / _check_repairs.""" + + def __init__(self, entry_id="test-entry"): + self.hass = object() + self.config_entry = type("E", (), {"entry_id": entry_id})() + self._active_repair_ids: set[str] = set() + self._grid_sensor_missing_ticks = 0 + self._ranking_last_run_at: datetime | None = None + self._grid_power_entity = "sensor.grid_power" + + def _set_repair( + self, issue_id, on, *, severity=ir.IssueSeverity.WARNING, + translation_placeholders=None, + ): + scoped = f"{self.config_entry.entry_id}_{issue_id}" + if on: + if scoped in self._active_repair_ids: + return + ir.async_create_issue( + self.hass, DOMAIN, scoped, + is_fixable=False, severity=severity, + translation_key=issue_id, + translation_placeholders=translation_placeholders, + ) + self._active_repair_ids.add(scoped) + else: + if scoped not in self._active_repair_ids: + return + ir.async_delete_issue(self.hass, DOMAIN, scoped) + self._active_repair_ids.discard(scoped) + + def _check_repairs(self, grid_power_w, now_local): + if grid_power_w is None: + self._grid_sensor_missing_ticks += 1 + if self._grid_sensor_missing_ticks >= 10: + self._set_repair( + "grid_sensor_unavailable", True, + translation_placeholders={ + "entity_id": self._grid_power_entity or "(unset)", + }, + ) + else: + self._grid_sensor_missing_ticks = 0 + self._set_repair("grid_sensor_unavailable", False) + + last_rank = self._ranking_last_run_at + if last_rank is None: + return + age_hours = (now_local - last_rank).total_seconds() / 3600.0 + if age_hours > 36.0: + self._set_repair( + "ranking_stale", True, + translation_placeholders={"hours": f"{age_hours:.1f}"}, + ) + else: + self._set_repair("ranking_stale", False) + + +class TestGridSensorUnavailable: + def test_raised_after_10_consecutive_none_reads(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(9): + c._check_repairs(None, now) + assert not ir._created + c._check_repairs(None, now) + assert any( + "grid_sensor_unavailable" in iid + for (_d, iid) in ir._created.keys() + ) + + def test_recovery_clears_issue(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(10): + c._check_repairs(None, now) + c._check_repairs(2000.0, now) + assert c._grid_sensor_missing_ticks == 0 + assert any( + "grid_sensor_unavailable" in iid for (_d, iid) in ir._deleted + ) + + def test_counter_resets_between_brief_outages(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(5): + c._check_repairs(None, now) + c._check_repairs(2000.0, now) + for _ in range(5): + c._check_repairs(None, now) + assert c._grid_sensor_missing_ticks == 5 + + +class TestRankingStale: + def test_no_run_yet_does_not_raise(self): + _reset_registry() + c = _Stand() + c._check_repairs( + 2000.0, datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + ) + assert not ir._created + + def test_raised_after_36h(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=37) + c._check_repairs(2000.0, now) + assert any( + "ranking_stale" in iid for (_d, iid) in ir._created.keys() + ) + + def test_cleared_after_fresh_run(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=37) + c._check_repairs(2000.0, now) + c._ranking_last_run_at = now + c._check_repairs(2000.0, now) + assert any( + "ranking_stale" in iid for (_d, iid) in ir._deleted + ) + + def test_recent_run_not_flagged(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=20) + c._check_repairs(2000.0, now) + assert not any( + "ranking_stale" in iid for (_d, iid) in ir._created.keys() + ) + + +class TestMultiEntryKeying: + def test_issue_id_scoped_to_entry_id(self): + _reset_registry() + a = _Stand(entry_id="entry-A") + b = _Stand(entry_id="entry-B") + a._set_repair("grid_sensor_unavailable", True) + b._set_repair("grid_sensor_unavailable", True) + keys = list(ir._created.keys()) + assert (DOMAIN, "entry-A_grid_sensor_unavailable") in keys + assert (DOMAIN, "entry-B_grid_sensor_unavailable") in keys + + +class TestSetRepairDedup: + def test_no_double_create(self): + _reset_registry() + c = _Stand() + c._set_repair("grid_sensor_unavailable", True) + ir._created.clear() + c._set_repair("grid_sensor_unavailable", True) + assert not ir._created + + def test_no_double_delete(self): + _reset_registry() + c = _Stand() + c._set_repair("grid_sensor_unavailable", False) + assert not ir._deleted + + +class TestCoordinatorSourceContract: + """Production coordinator.py must match the stand-in semantics.""" + + def test_set_repair_present(self): + assert "def _set_repair(" in _coordinator_source() + + def test_check_repairs_present(self): + assert "def _check_repairs(" in _coordinator_source() + + def test_check_repairs_called_in_tick_loop(self): + src = _coordinator_source() + assert "self._check_repairs(grid_power_w, now_local)" in src + + def test_grid_sensor_threshold_matches_stand_in(self): + src = _coordinator_source() + # Threshold is 10 ticks (= 5 minutes at 30s coordinator interval). + assert "self._grid_sensor_missing_ticks >= 10" in src + + def test_ranking_threshold_matches_stand_in(self): + src = _coordinator_source() + assert "if age_hours > 36.0:" in src + + def test_issue_id_scoped_to_entry_id_in_production(self): + src = _coordinator_source() + assert 'f"{self.config_entry.entry_id}_{issue_id}"' in src + + def test_active_repair_ids_dedup_set_in_init(self): + src = _coordinator_source() + assert "self._active_repair_ids: set[str] = set()" in src + + +class TestStringsHaveIssues: + def test_issues_block_present(self): + s = json.load(open( + Path(__file__).resolve().parents[1] + / "custom_components" / "pricehawk" / "strings.json" + )) + assert "issues" in s + for issue_id in ("grid_sensor_unavailable", "ranking_stale"): + assert issue_id in s["issues"] + assert "title" in s["issues"][issue_id] + assert "description" in s["issues"][issue_id] + + def test_translations_byte_identical(self): + repo = Path(__file__).resolve().parents[1] + a = ( + repo / "custom_components" / "pricehawk" / "strings.json" + ).read_bytes() + b = ( + repo / "custom_components" / "pricehawk" / "translations" / "en.json" + ).read_bytes() + assert a == b diff --git a/tests/test_runtime_data.py b/tests/test_runtime_data.py index 6367e20..8fca6ff 100644 --- a/tests/test_runtime_data.py +++ b/tests/test_runtime_data.py @@ -28,6 +28,8 @@ def _make_coordinator() -> MagicMock: coord = MagicMock() coord.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() + # Phase 9 PR-10 — async_setup_entry calls async_setup_stats after restore. + coord.async_setup_stats = AsyncMock() coord.async_run_ranking_job = AsyncMock(return_value=[]) coord.async_run_backfill = AsyncMock() coord.async_persist_state = AsyncMock() @@ -90,6 +92,14 @@ def _patch_deps(coord: MagicMock): ), patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch( + "custom_components.pricehawk.setup_panel_custom_v2", + new=AsyncMock(), + ), + patch( + "custom_components.pricehawk.register_lovelace_card_resource", + new=AsyncMock(), + ), patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), ) @@ -108,6 +118,14 @@ def _next_coord(*_args: Any, **_kwargs: Any) -> MagicMock: ), patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch( + "custom_components.pricehawk.setup_panel_custom_v2", + new=AsyncMock(), + ), + patch( + "custom_components.pricehawk.register_lovelace_card_resource", + new=AsyncMock(), + ), patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), ) @@ -126,8 +144,8 @@ def test_setup_writes_runtime_data(): hass = _make_hass() entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: result = asyncio.run(async_setup_entry(hass, entry)) assert result is True @@ -148,8 +166,8 @@ def test_unload_runs_platform_unload_first(): hass = _make_hass(unload_platforms_result=False) entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) assert entry.runtime_data is not None original_data = entry.runtime_data @@ -178,8 +196,8 @@ def test_unload_does_not_touch_hass_data(): hass = _make_hass(unload_platforms_result=True) entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) hass_data_snapshot = dict(hass.data) result = asyncio.run(async_unload_entry(hass, entry)) @@ -210,8 +228,8 @@ def test_multi_entry_service_lifecycle(): hass = _make_hass() - p1, p2, p3, p4 = _patch_deps_iter([coord_a, coord_b]) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps_iter([coord_a, coord_b]) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry_a)) asyncio.run(async_setup_entry(hass, entry_b)) @@ -247,8 +265,8 @@ def test_options_flow_reload_cycle(): coord_v1 = _make_coordinator() coord_v2 = _make_coordinator() - p1, p2, p3, p4 = _patch_deps_iter([coord_v1, coord_v2]) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps_iter([coord_v1, coord_v2]) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) assert entry.runtime_data.coordinator is coord_v1 @@ -279,8 +297,8 @@ def test_service_handlers_resolve_fresh_coordinator(): hass = _make_hass() entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(original_coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(original_coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) # Capture the rank_alternatives handler from the registration call. diff --git a/tests/test_silver_checklist.py b/tests/test_silver_checklist.py new file mode 100644 index 0000000..0d3bbc2 --- /dev/null +++ b/tests/test_silver_checklist.py @@ -0,0 +1,173 @@ +"""Phase 8 PR-9 — HA Silver tickbox tests. + +Verifies the load-bearing invariants of the Silver flip: +- manifest declares quality_scale=silver + version bumped. +- quality_scale.yaml parses + has all expected rules. +- sensor.py declares PARALLEL_UPDATES. +- Service handlers use action-exceptions discipline. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def _manifest() -> dict: + return json.load( + open(REPO / "custom_components" / "pricehawk" / "manifest.json") + ) + + +def _quality_scale() -> dict: + try: + import yaml # type: ignore[import-not-found] + except ImportError: + # Fall back to a tiny YAML subset parser sufficient for our format. + raw = ( + REPO / "custom_components" / "pricehawk" / "quality_scale.yaml" + ).read_text() + return _parse_quality_scale(raw) + return yaml.safe_load( + (REPO / "custom_components" / "pricehawk" / "quality_scale.yaml").read_text() + ) + + +def _parse_quality_scale(raw: str) -> dict: + """Tiny YAML parser specific to quality_scale.yaml shape. + + Format: + rules: + rule_name: + status: done|exempt|todo + comment: >- + text... + """ + rules: dict[str, dict[str, str]] = {} + current_rule: str | None = None + current_key: str | None = None + multiline_collect: list[str] = [] + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + # Rule heading " rule-name:" at 2 spaces of indent. + stripped = line.rstrip() + indent = len(line) - len(line.lstrip()) + if indent == 2 and stripped.endswith(":") and ":" in stripped: + current_rule = stripped.strip().rstrip(":") + rules[current_rule] = {} + current_key = None + multiline_collect = [] + continue + if indent == 4 and ":" in stripped: + # End any pending multiline collect. + if current_key and multiline_collect: + rules[current_rule][current_key] = " ".join(multiline_collect).strip() + multiline_collect = [] + key, _, value = stripped.strip().partition(":") + value = value.strip() + if value in ("", ">-", ">"): + current_key = key + multiline_collect = [] + else: + rules[current_rule][key] = value + current_key = None + continue + if indent >= 6 and current_key: + multiline_collect.append(stripped.strip()) + if current_key and multiline_collect: + rules[current_rule][current_key] = " ".join(multiline_collect).strip() + return {"rules": rules} + + +class TestManifest: + def test_quality_scale_silver(self): + assert _manifest()["quality_scale"] == "silver" + + def test_version_bumped(self): + m = _manifest() + assert m["version"] == "1.6.0-beta.1", ( + f"manifest version should be 1.6.0-beta.1, got {m['version']}" + ) + + def test_codeowner_present(self): + assert "@Artic0din" in _manifest()["codeowners"] + + def test_requirements_pin_intact(self): + # Phase 7 PR-2 pin must survive the Silver flip. + reqs = _manifest()["requirements"] + assert any("openelectricity" in r for r in reqs) + + +class TestQualityScaleYaml: + def test_file_parses(self): + qs = _quality_scale() + assert "rules" in qs + + def test_silver_rules_marked_done(self): + qs = _quality_scale() + silver_done = ( + "reauthentication-flow", + "reconfiguration-flow", + "parallel-updates", + "action-exceptions", + "config-entry-unloading", + "entity-unavailable", + "integration-owner", + "test-coverage", + ) + for rule in silver_done: + assert rule in qs["rules"], f"quality_scale.yaml missing {rule}" + status = qs["rules"][rule]["status"] + assert status == "done", ( + f"{rule} should be 'done' for Silver, got {status!r}" + ) + + def test_log_when_unavailable_documented_as_exempt(self): + qs = _quality_scale() + assert qs["rules"]["log-when-unavailable"]["status"] == "exempt" + + def test_diagnostics_marked_done(self): + qs = _quality_scale() + assert qs["rules"]["diagnostics"]["status"] == "done" + + def test_repairs_marked_done(self): + qs = _quality_scale() + assert qs["rules"]["repairs"]["status"] == "done" + + +class TestSensorParallelUpdates: + def test_sensor_declares_parallel_updates(self): + src = ( + REPO / "custom_components" / "pricehawk" / "sensor.py" + ).read_text() + assert "PARALLEL_UPDATES = 0" in src + + +class TestServiceHandlerExceptions: + def test_init_imports_home_assistant_error(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + assert ( + "from homeassistant.exceptions import HomeAssistantError" + in src + ) + assert "ServiceValidationError" in src + + def test_handlers_raise_home_assistant_error_on_missing_coordinator(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + # At least one raise per handler — count must match the three handlers. + assert src.count("raise HomeAssistantError(") >= 3 + + def test_handlers_raise_service_validation_error_on_bad_input(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + # backfill_history + rank_alternatives each raise on bad input. + assert src.count("raise ServiceValidationError(") >= 2 diff --git a/tests/test_tariff_engine_hypothesis.py b/tests/test_tariff_engine_hypothesis.py new file mode 100644 index 0000000..8ec6fbe --- /dev/null +++ b/tests/test_tariff_engine_hypothesis.py @@ -0,0 +1,222 @@ +"""Phase 11 PR-18 — Hypothesis fuzzing of tariff_engine pure functions. + +Five invariants per v2 research § 7.3: + +1. **Monotonic stepped cost**: ``calc_stepped_cost(t, k)`` is + non-decreasing in ``k`` for fixed tariff. +2. **Threshold equality**: at ``k == threshold``, + ``calc_stepped_cost`` equals ``threshold * step1_rate`` exactly. +3. **Step composition**: for ``k > threshold``, + ``calc_stepped_cost = step1_cost + (k - threshold) * step2_rate``. +4. **Stepped rate dichotomy**: ``get_stepped_import_rate`` returns + exactly one of ``step1_rate`` or ``step2_rate``. +5. **TOU period closure**: ``get_current_tou_period`` returns a + period name that's either in the supplied dict OR ``"unknown"`` + (never something else); and the rate matches the period's rate + when found. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from hypothesis import given, settings, strategies as st + +from custom_components.pricehawk.tariff_engine import ( + calc_stepped_cost, + get_current_tou_period, + get_stepped_import_rate, +) + + +# Bounded strategies — real-world tariff rates are c/kWh in 0..200 range, +# consumption 0..200 kWh/day (a heavy household), thresholds 0.01..50. +_kwh = st.floats( + min_value=0.0, max_value=200.0, allow_nan=False, allow_infinity=False, +) +_rate = st.floats( + min_value=0.0, max_value=200.0, allow_nan=False, allow_infinity=False, +) +# Threshold floor: 0.01 (zero threshold is a degenerate edge case +# covered by an explicit test elsewhere; the production code guards +# against it but Hypothesis doesn't need to re-explore it). +_threshold = st.floats( + min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False, +) + + +def _tariff(threshold: float, step1_rate: float, step2_rate: float) -> dict: + return { + "step1_threshold_kwh": threshold, + "step1_rate": step1_rate, + "step2_rate": step2_rate, + } + + +# ---------------------------------------------------------------------- +# Invariant 1: Monotonic stepped cost +# ---------------------------------------------------------------------- + + +class TestStepCostMonotonic: + @given(_threshold, _rate, _rate, _kwh, _kwh) + @settings(max_examples=200, deadline=None) + def test_cost_monotonic_in_kwh( + self, threshold, step1, step2, k1, k2, + ): + """Sort k1 <= k2; cost(t, k1) <= cost(t, k2).""" + lo, hi = (k1, k2) if k1 <= k2 else (k2, k1) + tariff = _tariff(threshold, step1, step2) + cost_lo = calc_stepped_cost(tariff, lo) + cost_hi = calc_stepped_cost(tariff, hi) + assert cost_lo <= cost_hi + 1e-9, ( + f"Non-monotonic: cost({lo})={cost_lo} > cost({hi})={cost_hi}" + ) + + +# ---------------------------------------------------------------------- +# Invariant 2: Threshold equality +# ---------------------------------------------------------------------- + + +class TestStepCostAtThreshold: + @given(_threshold, _rate, _rate) + @settings(max_examples=100, deadline=None) + def test_cost_at_threshold_uses_only_step1( + self, threshold, step1, step2, + ): + tariff = _tariff(threshold, step1, step2) + result = calc_stepped_cost(tariff, threshold) + expected = threshold * step1 + # Allow for float rounding tolerance. + assert abs(result - expected) < 1e-9, ( + f"At threshold {threshold} with step1={step1}, " + f"expected {expected}, got {result}" + ) + + +# ---------------------------------------------------------------------- +# Invariant 3: Step composition above threshold +# ---------------------------------------------------------------------- + + +class TestStepCostAboveThreshold: + @given(_threshold, _rate, _rate, _kwh) + @settings(max_examples=200, deadline=None) + def test_above_threshold_step_composition( + self, threshold, step1, step2, k, + ): + if k <= threshold: + return # Different invariant covers ≤ threshold. + tariff = _tariff(threshold, step1, step2) + result = calc_stepped_cost(tariff, k) + expected = threshold * step1 + (k - threshold) * step2 + assert abs(result - expected) < 1e-6, ( + f"At kwh={k}, threshold={threshold}, step1={step1}, step2={step2}: " + f"expected {expected}, got {result}" + ) + + +# ---------------------------------------------------------------------- +# Invariant 4: Stepped rate dichotomy +# ---------------------------------------------------------------------- + + +class TestSteppedRateDichotomy: + @given(_threshold, _rate, _rate, _kwh) + @settings(max_examples=200, deadline=None) + def test_returned_rate_is_one_of_steps( + self, threshold, step1, step2, k, + ): + tariff = _tariff(threshold, step1, step2) + rate = get_stepped_import_rate(tariff, k) + assert rate in (step1, step2), ( + f"get_stepped_import_rate({k}, threshold={threshold}) returned " + f"{rate} — must be step1={step1} or step2={step2}" + ) + + @given(_threshold, _rate, _rate, _kwh) + @settings(max_examples=200, deadline=None) + def test_below_threshold_returns_step1( + self, threshold, step1, step2, k, + ): + if k >= threshold: + return + tariff = _tariff(threshold, step1, step2) + assert get_stepped_import_rate(tariff, k) == step1 + + @given(_threshold, _rate, _rate, _kwh) + @settings(max_examples=200, deadline=None) + def test_at_or_above_threshold_returns_step2( + self, threshold, step1, step2, k, + ): + if k < threshold: + return + tariff = _tariff(threshold, step1, step2) + assert get_stepped_import_rate(tariff, k) == step2 + + +# ---------------------------------------------------------------------- +# Invariant 5: TOU period closure +# ---------------------------------------------------------------------- + + +def _basic_tou_periods() -> dict: + """Three-window day covering 24h with no gaps for fuzz tests.""" + return { + "peak": { + "rate": 39.6, + "windows": [["16:00", "21:00"]], + }, + "shoulder": { + "rate": 27.5, + "windows": [["07:00", "16:00"], ["21:00", "23:00"]], + }, + "offpeak": { + "rate": 11.0, + "windows": [["23:00", "07:00"]], # midnight-crossing + }, + } + + +class TestTOUPeriodClosure: + @given( + hour=st.integers(min_value=0, max_value=23), + minute=st.integers(min_value=0, max_value=59), + ) + @settings(max_examples=200, deadline=None) + def test_returns_known_period_or_unknown(self, hour, minute): + periods = _basic_tou_periods() + now = datetime(2026, 5, 22, hour, minute, tzinfo=timezone.utc) + name, rate = get_current_tou_period(periods, now) + assert name in periods or name == "unknown", ( + f"period name {name!r} not in {list(periods)} and not 'unknown'" + ) + + @given( + hour=st.integers(min_value=0, max_value=23), + minute=st.integers(min_value=0, max_value=59), + ) + @settings(max_examples=200, deadline=None) + def test_returned_rate_matches_period(self, hour, minute): + periods = _basic_tou_periods() + now = datetime(2026, 5, 22, hour, minute, tzinfo=timezone.utc) + name, rate = get_current_tou_period(periods, now) + if name in periods: + assert rate == periods[name]["rate"] + else: + assert rate == 0.0 + + @given( + hour=st.integers(min_value=0, max_value=23), + minute=st.integers(min_value=0, max_value=59), + ) + @settings(max_examples=200, deadline=None) + def test_full_day_coverage_no_unknown(self, hour, minute): + """With the basic 24h-covering periods, no minute returns 'unknown'.""" + periods = _basic_tou_periods() + now = datetime(2026, 5, 22, hour, minute, tzinfo=timezone.utc) + name, _ = get_current_tou_period(periods, now) + assert name != "unknown", ( + f"hour={hour:02d}:{minute:02d} fell through period coverage" + ) diff --git a/tests/test_today_cost_sensor.py b/tests/test_today_cost_sensor.py new file mode 100644 index 0000000..e9a7335 --- /dev/null +++ b/tests/test_today_cost_sensor.py @@ -0,0 +1,76 @@ +"""Phase 9 PR-11 — ChosenPlanCostSensor source-level tests. + +Sensor class instantiation requires HA's entity infrastructure (mocked +under conftest); production-class is a MagicMock here. Source-grep +asserts on sensor.py + ducktype the class via its __dict__. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _sensor_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" / "pricehawk" / "sensor.py" + ).read_text() + + +class TestChosenPlanCostSensor: + def test_class_defined(self): + assert "class ChosenPlanCostSensor(" in _sensor_source() + + def test_device_class_monetary(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert "_attr_device_class = SensorDeviceClass.MONETARY" in block + + def test_unit_of_measurement_aud(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert '_attr_native_unit_of_measurement = "AUD"' in block + + def test_state_class_total(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert "_attr_state_class = SensorStateClass.TOTAL" in block + + def test_name_is_today_cost(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert '_attr_name = "PriceHawk Today Cost"' in block + + def test_unique_id_provider_independent(self): + """Stable across plan swaps — D-P9-2 contract.""" + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert 'f"{entry.entry_id}_chosen_plan_today_cost"' in block + # Must NOT include any provider id reference. + assert "PROVIDER_AMBER" not in block + assert "_current_plan_provider.id" not in block + + def test_native_value_reads_chosen_plan_cost(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + assert "_current_plan_provider" in block + assert "net_daily_cost_aud" in block + + def test_last_reset_at_midnight(self): + src = _sensor_source() + start = src.index("class ChosenPlanCostSensor") + block = src[start:start + 1500] + # Anchor: replace(hour=0, minute=0 ...). + assert "hour=0, minute=0, second=0, microsecond=0" in block + + def test_registered_in_async_setup_entry(self): + src = _sensor_source() + assert ( + "entities.append(ChosenPlanCostSensor(coordinator, entry))" in src + )