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 d9ee240..fb3071d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,39 @@ 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) diff --git a/DECISIONS.md b/DECISIONS.md index 36ea7ed..b7d6300 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,6 +5,69 @@ +## 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) 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 36a2e85..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: @@ -2345,12 +2381,14 @@ async def async_step_reconfigure( """HA-invoked reconfigure entry point. Routes by active provider.""" del entry_data entry = self._get_reconfigure_entry() - coordinator = getattr( - getattr(entry, "runtime_data", None), "coordinator", None - ) - provider_id = getattr( - getattr(coordinator, "_current_plan_provider", None), "id", None - ) + # 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: @@ -2611,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( @@ -2620,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, ) ), @@ -2628,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, ) ), @@ -2636,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/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 09b2863..ffcb690 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -306,6 +306,16 @@ "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": { "step": { "init": { diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index 09b2863..ffcb690 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -306,6 +306,16 @@ "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": { "step": { "init": { 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 5fef63b..cdf454b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ 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(), } @@ -51,8 +52,23 @@ 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 @@ -67,6 +83,24 @@ def _async_redact_data(data, to_redact): # pragma: no cover — test helper 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) @@ -78,6 +112,15 @@ def _async_redact_data(data, to_redact): # pragma: no cover — test helper _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_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 index 5af380f..94b7664 100644 --- a/tests/test_reconfigure.py +++ b/tests/test_reconfigure.py @@ -53,12 +53,31 @@ def test_dispatcher_aborts_on_unsupported_entry(self): src = _config_flow_source() assert 'reason="reconfigure_unsupported"' in src - def test_dispatcher_reads_active_provider_id_from_coordinator(self): + 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() - # Reads coordinator._current_plan_provider.id (NOT _reauth_provider_id - # — that's reauth territory). - assert '"_current_plan_provider"' in src - assert "self._get_reconfigure_entry()" in src + # 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: 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 + )