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

_ENABLED` continues to map cleanly to the new mode (back-compat without migration). OptionsFlow's Comparators page now shows three mode selectors instead of three booleans. Flow Power `static_prd` is deferred (falls back to `live_api`) — Flow Power's internal margin math reads `set_wholesale_rate`, not `set_current_rates`; bridging that needs `flow_power.py` changes scheduled for a follow-up PR. Closes Phase 7 / Wave 1. (Phase 7 / PR-4) + +- Dynamic Wholesale Tariff retailer choice — TWO new options in the config-flow retailer picker: "Dynamic Wholesale Tariff — OpenElectricity" (API key required) and "Dynamic Wholesale Tariff — AEMO Direct" (no key). Both backed by a single `DynamicWholesaleTariffProvider` implementation that consumes a `WholesalePriceSource` (PR-2's `OpenElectricityPriceSource` or PR-3's `NEMWebPriceSource`). Users can model their cost as if on a wholesale-pass-through plan. Self-priced provider: `update()` stays sync (Provider Protocol contract); a coordinator-driven async refresh coroutine fetches new prices every 4 minutes (5-min dispatch cadence minus 1-min slack) and pushes them in via `set_live_price`. Negative wholesale prices are honoured (exporter pays during curtailment) with sign discipline matching AmberProvider. AEMO Direct is NEM-only; WEM is only selectable on the OpenElectricity flavour. (Phase 7 / PR-2b) + - NEMWeb DISPATCH wholesale-price fallback at `custom_components/pricehawk/providers/nemweb.py` — no-API-key public-AEMO alternative to OpenElectricity. NEM-only (WEM rejected with a clear message pointing to OpenElectricity). Shares the `WholesalePrice` contract surface from PR-2. Settlement-date parsing anchored to `Australia/Brisbane` (no DST) because NEM dispatch publishes AEST year-round per AEMO docs — `Australia/Sydney` would silently 1-hour-error during AEDT. 45s `asyncio.timeout` bound. Not yet wired into the coordinator or config flow (Plan 07-02b). (Phase 7 / PR-3) - OpenElectricity v4 wholesale-price client module at `custom_components/pricehawk/providers/openelectricity.py`. Standalone — not yet wired into the coordinator or config flow; that's PR-2 part 2 (Plan 07-02b). Pinned `openelectricity>=0.10.1,<0.11` in manifest. Includes CC BY-NC 4.0 attribution on every result, 30s `asyncio.timeout` bound, `ConfigEntryAuthFailed` mapping for 401, distinct 429 rate-limit handling that preserves the last-good cache, and `ConfigEntryNotReady` fallback for missing-SDK installs. API key never appears in `__repr__` or log messages (scrubber). (Phase 7 / PR-2) diff --git a/DECISIONS.md b/DECISIONS.md index dfc7faf..b7d6300 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,6 +5,121 @@ +## 2026-05-22 — Phase 11 Plan 01 (HA test harness fixtures) + +### D-P11-1 — Dual-mode test strategy: existing stub-conftest stays; new tests opt in to HA harness +**Decision:** PR-16 adds `pytest-homeassistant-custom-component` to `requirements.txt` and ships `tests/ha_fixtures.py` with drop-in mocks. The existing 1028 stub-conftest tests are NOT migrated — they stay HA-free. New tests written from PR-16 onward can opt into the HA harness by importing from `tests.ha_fixtures`. +**Rationale:** Migrating 1028 tests in a single PR would block review for weeks and risk regressions in well-tested code (tariff engine, CDR ranking, providers). Dual-mode lets the migration happen organically — each touch of an existing test module can swap to the HA harness in its PR. The fixture file is the bridge: it defines mock shapes (OE client, NEMWeb client, recorder external-stats, config entry data) that match the production contracts so tests can pick whichever style fits. +**Consequences:** CI now installs `pytest-homeassistant-custom-component` + `hypothesis`. The stub-based `tests/conftest.py` and the HA-harness route co-exist. Future tests should prefer the HA harness for anything that touches `ConfigEntry`, `hass`, or the recorder; the stub conftest is for pure-Python helper tests (tariff windows, CDR parsers, etc.). Migration window is open-ended — no forcing function until the HA harness becomes load-bearing for a specific PR. + +## 2026-05-22 — Phase 10 Plan 03 (blueprints library) + +### D-P10-3 — Five blueprints shipped as YAML files; HA users import via UI or filesystem +**Decision:** Five HA automation blueprints live under `custom_components/pricehawk/blueprints/automation/pricehawk/` and ship with the integration. Each is a self-contained YAML file with `blueprint.input` selectors so non-developer users can wire them via the HA "Blueprints" UI. Source URL points at the GitHub repo so the HA importer can fetch directly. No code change required to enable — HA's blueprint loader picks up YAML files dropped into the appropriate path on next restart. +**Rationale:** Blueprints are the lowest-friction extension surface in HA. Bundling them with the integration means a fresh PriceHawk install gets 5 useful automations out-of-the-box without the user having to find + copy + paste YAML. Each blueprint covers a use case from the v2 research § 6 list. Tests cover YAML-parse, source_url presence, input/trigger presence, plus per-blueprint contract (e.g. pause_ev hysteresis trigger shape). +**Consequences:** Renaming a blueprint file or moving it within the directory will break imported automations (HA tracks blueprints by source URL). Each blueprint file's `source_url:` field is the contract — must match the GitHub raw URL for the file path. Users running the HA blueprint importer dialogue will fetch the file from that URL. + +## 2026-05-22 — Phase 10 Plan 02 (Lovelace custom card + auto-resource) + +### D-P10-2 — Best-effort auto-register Lovelace resource; YAML-mode users get a log hint +**Decision:** `register_lovelace_card_resource` reaches into `hass.data["lovelace"].resources` and calls `async_create_item({"res_type": "module", "url": LOVELACE_CARD_RESOURCE_URL})` on entry setup. In storage-mode Lovelace this lands the resource as if the user had added it manually. In YAML-mode the registration silently no-ops with a log hint printing the resource URL. Dedup check prevents duplicate registration on entry reload. +**Rationale:** Manual "Add Resource" is significant UX friction. Auto-registration removes it for storage-mode users (~80%+). YAML-mode users see a clear log hint instead of silence. The card's class registers in `window.customCards` so it appears in the "Add Card" picker once the resource loads. +**Consequences:** Custom element name `pricehawk-cost-card` is the contract surface (`type: custom:pricehawk-cost-card` in user YAML). Renaming breaks user dashboards on upgrade. Defaults to Phase 9 PR-11 `sensor.pricehawk_today_cost` + Phase 1 `sensor.pricehawk_saving_today`. + +## 2026-05-22 — Phase 10 Plan 01 (Lit panel_custom foundation) + +### D-P10-1 — Lit panel ships via CDN ESM (no build step); legacy iframe stays during migration +**Decision:** `pricehawk-panel.js` is a single ESM module that imports Lit from `https://unpkg.com/lit-element@4.2.0/lit-element.js?module`. No bundling, no build step — HACS distributes the JS file verbatim into `/local/pricehawk/pricehawk-panel.js` and HA's `panel_custom` loads it as a module URL. The legacy iframe panel at `/pricehawk-dashboard` (with the LLAT-in-URL approach) stays registered alongside during the migration window. Full visual port from `www/dashboard.html` to the Lit panel deferred to a dedicated Playwright UAT follow-up. +**Rationale:** Bundling Lit + Rollup/Vite + a build pipeline + a CI step that publishes the bundle adds significant project complexity for a custom_components-style integration. The CDN-ESM approach is what HA's own first-party `panel_custom` examples use and avoids a bundle-related supply chain. Trade-off: requires user network access to unpkg on first panel load — acceptable for an HA integration that already needs network for the cloud API calls. Module URL carries a version-busted query (`?v={manifest_version}.{epoch}`) so HACS upgrades invalidate the browser cache without requiring user action. +**Migration window:** Legacy iframe panel stays for back-compat. Users with the legacy dashboard URL bookmarked keep their bookmark working. The new "PriceHawk v2" sidebar entry is additive. Removal of the legacy iframe path will land in a follow-up after the Lit panel reaches feature parity AND ≥ 3 testers confirm the v2 panel works in production. Same gate philosophy as the PR-12 stats-only flip. +**Alternatives:** (a) Bundle Lit + ship a single .js — rejected as scope creep for the foundation PR. (b) Ship a TypeScript source + a build step — rejected for the same reason. (c) Replace the iframe panel atomically — rejected because the legacy dashboard has UI surfaces (CSV import wizard, per-window TOU breakdown) that the v2 panel won't have at first ship. +**Consequences:** Visual UAT remains a manual step until a Playwright session covers it. The custom element name `pricehawk-panel` is the cross-PR contract surface (referenced by `_panel_custom.name` in dashboard_config + by `customElements.define` in the JS file). Test `test_panel_defines_custom_element_pricehawk_panel` pins it. `setup_panel_custom_v2` is the registration function; renaming it would require updating the test_runtime_data patch list. + +## 2026-05-22 — Phase 9 Plan 02 (Energy-Dashboard-pickable cost sensor) + +### D-P9-2 — Chosen-plan cost sensor decoupled from provider id (stable entity_id) +**Decision:** `ChosenPlanCostSensor.unique_id = f"{entry_id}_chosen_plan_today_cost"` — explicitly does NOT include the active provider id. The sensor always reads `coordinator._current_plan_provider.net_daily_cost_aud` at evaluation time; the BACKING provider changes when the user swaps plans (e.g. CDR plan → DWT-OE) but the entity_id stays `sensor.pricehawk_today_cost`. Energy-Dashboard cost picker remembers entity_ids, not provider ids — stable entity_id means the user's dashboard pick survives plan swaps. +**Rationale:** The existing `ProviderDailyCostSensor` is per-provider (different entity_id per provider key like `amber_daily_cost` / `current_plan_daily_cost`). The Energy Dashboard cost picker would need re-selection when the user swaps plans. A provider-independent sensor solves that. Note the existing `current_plan_daily_cost` ProviderDailyCostSensor is similar but has a different unique_id derivation (`{entry_id}_current_plan_daily_cost`); this new sensor's distinct id (`_chosen_plan_today_cost`) signals "Energy-Dashboard-pickable" semantics + carries the matching display name `"PriceHawk Today Cost"`. +**Alternatives:** (a) Reuse existing `current_plan_daily_cost` sensor — rejected because it's tied to coordinator.data dict keys, not directly to the provider object, making cross-provider semantics less clear. (b) Use HA's `Statistics` entity instead of a sensor — rejected because the recorder auto-generates statistics from MONETARY/AUD/TOTAL sensors; no need for a separate Statistics entity. (c) Make the entity_id user-configurable — rejected as overkill for the v3.0 ship. +**Consequences:** Existing dashboards that pinned `sensor.pricehawk_current_plan_daily_cost` keep working (sensor unchanged). New installs see `sensor.pricehawk_today_cost` as the recommended dashboard pick. Docs (Phase 10 PR-13/14 UI rebuild) point users at the new sensor for Energy Dashboard integration. PR-12 / 09-03 stats-only flip relies on this sensor's stat_id being stable; the recorder will auto-generate a statistic from the sensor with the same lifetime as the entity. + +## 2026-05-22 — Phase 9 Plan 01 (external statistics dual-write) + +### D-P9-1 — Statistic-id format `{DOMAIN}:cost_{entry_id[:8]}_{provider_id}`; dual-write preserved until ≥4w tester confidence +**Decision:** External statistic ids use the entry_id's first 8 hex chars as the per-entry namespace token: `pricehawk:cost_abcdef12_amber`. HA's practical stat-id limit is ~50 chars; full entry_id (32+ hex) would blow that with longer provider ids (`dwt_openelectricity`). 8 chars = 2^32 collision space, fine for a single user's multi-entry install. Dual-write — JSON Store + external statistics — runs in parallel; JSON Store stays authoritative until PR-12 / 09-03 (≥4w elapsed + ≥10 tester reports of clean dual-write ≥7d per ROADMAP v2.0 GA criteria). +**Rationale:** Stat ids must be stable across reloads (the dashboard's "cost" picker remembers selected stat ids). Hashing the entry_id (e.g. via blake2b) would also work but loses the visible-entry-id-prefix that helps debugging. 8-char hex prefix is a defensible compromise. Dual-write avoids the worst HACS-update risk — a one-shot stats-only flip on update would lock users out of their cost history if the recorder import fails for any reason; dual-write gives a clean rollback path. +**Cumulative-sum + negative-cost handling:** Per HA stats contract, `has_sum=True` stats should be monotonic. Cost stats CAN dip on export-heavy days (negative net cost). Per HA docs the energy dashboard handles this for cost-class stats (uses the deltas, not absolute sums). Tests pin this behaviour: `test_negative_cost_does_not_break_cumulative` confirms the sum can decrease without raising. +**Consequences:** PR-11 / 09-02 registers `sensor.pricehawk_today_cost` with the same stat-id format so the Energy Dashboard's cost picker shows it. PR-12 / 09-03 is the FLIP — removes JSON-Store writes (keeps reads for one more release as a safety belt) — gated on the ROADMAP criteria. The 8-char entry_id slice MUST stay stable; changing it post-PR-11 would orphan all existing stats. + +## 2026-05-22 — Phase 8 Plan 05 (HACS Silver flip + checklist) + +### D-P8-5 — `quality_scale.yaml` ships ALL HA tiers, not just Silver; `log-when-unavailable` is `exempt` +**Decision:** `custom_components/pricehawk/quality_scale.yaml` lists EVERY rule from Bronze + Silver + Gold + Platinum, each with `status: done | exempt | todo` and (where useful) a one-line `comment` linking back to the implementing PR / decision. Silver-required rules are all `done`. Gold rules are `todo` with `comment: v4` (or `exempt` where unsupported — `discovery`, `devices`, `stale-devices` — PriceHawk is integration_type=service with no LAN/USB devices). Platinum rules are mostly `todo`; only `async-dependency` is `done`. +**Rationale:** A Silver-flip-only YAML would pass HACS validation but fail the "honesty" smell test — readers (HACS reviewers, future maintainers, Claude in 6 months) can't tell whether Gold was *considered* and rejected vs *forgotten*. Listing everything with explicit status answers the question. `comment` lines are short but load-bearing: they document WHY exempt rules are exempt (e.g. `log-when-unavailable` is exempt because the coordinator already handles availability transitions — no per-entity custom logging needed). +**Exempt rule explanations:** (a) `log-when-unavailable` — DataUpdateCoordinator + CoordinatorEntity handle availability transitions; entities don't poll independently so they have nothing to log. (b) `discovery` + `discovery-update-info` + `dynamic-devices` + `stale-devices` + `docs-supported-devices` — integration_type=service with no devices. (c) `inject-websession` — Platinum; OpenElectricity SDK 0.10.1 doesn't accept `session=` (audit M2 in 07-02-AUDIT); re-evaluate when 0.11 lands. +**Consequences:** Future PRs that flip a `todo` to `done` should update both `quality_scale.yaml` AND add a comment line pointing at the PR. Reviewing the YAML is the fastest way to see what's left for Gold/Platinum without reading 10 PR descriptions. Version bump to `1.6.0-beta.1` signals Silver-gate reached but not yet v3.0 GA (still need PR-10/11 external statistics + tester-confirmed dual-write per ROADMAP GA criteria). + +## 2026-05-22 — Phase 8 Plan 04 (repairs platform) + +### D-P8-4 — Persistent-notification repairs (not interactive fix flows); wholesale-source-unreachable deferred +**Decision:** PR-8 ships two repair issues — `grid_sensor_unavailable` and `ranking_stale` — using `ir.async_create_issue` + `ir.async_delete_issue` (persistent-notification style with `is_fixable=False`). Interactive `RepairsFlow` deferred; not needed for these issues whose fix is "click Reload" or "wait for next ranking run." Multi-entry safe: every issue id is prefixed with `entry.config_entry.entry_id` so two PriceHawk entries don't collide. +**Rationale:** Persistent notifications are zero ceremony for the two issues that PR-8 covers — the action the user takes is independent of HA's repairs UI (reload from Devices & Services, or wait for the 00:30 scheduler tick). Interactive RepairsFlow adds a `async_create_fix_flow` registration + per-step methods, which is overkill for "click reload." The repair surfaces as a notification + the integration page shows the entry as needing attention — that's enough signal. +**Deferred — `wholesale_source_unreachable`:** Originally planned. Requires tracking last-successful-update timestamps across Amber + AEMO + OE + Flow Power wholesale sources, which is more state than the simpler "did this tick read return None?" check. Punted to a follow-up PR (a small `_last_wholesale_success_at` tracker) so the existing 2 issues can ship cleanly. +**Alternatives:** (a) Use RepairsFlow for interactive resolution — rejected on overkill grounds. (b) Use `persistent_notification.create` directly — rejected because it doesn't integrate with the HA repairs panel; users wouldn't see it on the integration page. (c) Skip the 36h ranking_stale threshold and check on every tick — rejected because the ranking job is deliberately nightly; only flag when something has clearly gone wrong (multi-day staleness). +**Consequences:** Production `_set_repair` helper is the contract surface; future repair issues plug in with `self._set_repair("issue_id", True/False)` calls. The set-of-active-ids on the coordinator instance (`_active_repair_ids`) is the in-process dedup; HA's issue registry would naturally dedup too, but the local set avoids spamming the registry every tick. Tests use a coordinator-stand-in mirroring the production helper logic (production class can't be instantiated under conftest MagicMock base — same 07-02b D-1 root cause); source-grep tests ensure the production code matches the stand-in's contract. + +## 2026-05-22 — Phase 8 Plan 03 (diagnostics platform) + +### D-P8-3 — Diagnostics redaction list includes large plan envelopes (not just secrets) +**Decision:** `TO_REDACT` in `diagnostics.py` covers (a) every API-key and HA-token field — for secrecy; and (b) every PRD plan envelope (`CONF_CDR_PLAN`, `CONF_NAMED_COMPARATOR_PLAN`, `amber_static_plan`, `flow_power_static_plan`, `localvolts_static_plan`) — for SIZE, not secrecy. A single CDR plan envelope is ~15 KB; a power user with 5 entries + a named comparator + 3 static-PRD comparators easily hits 100 KB+ of diagnostics output, which makes the file unreviewable in HA's diagnostics modal. +**Rationale:** The HA diagnostics REST endpoint downloads as a single JSON file the user shares for support. Bloating it with rate tables that the user can already see in the OptionsFlow Configure page makes the diagnostic value LOWER, not higher. Reviewers (Ryan, Anthropic Claude review, or HA forum helpers) need the entry shape + runtime mode + accumulator lengths, not the full PRD body. The `_redaction_count` field tells reviewers how many keys were trimmed — they can ask for the full envelope separately if needed. +**Alternatives:** (a) Keep plan envelopes in diagnostics — rejected on size grounds above. (b) Summarise the envelope (planId + brand + period) inline instead of redacting — possible follow-up if reviewers actually need this; defer until a real support case proves the need. +**Consequences:** Diagnostics output stays small. Any future config field that ends up large MUST be added to `TO_REDACT` even if not secret — there's a comment in `diagnostics.py` documenting this contract. `test_to_redact_includes_large_plan_envelopes` enforces the current set. + +## 2026-05-22 — Phase 8 Plan 02 (per-provider reconfigure flow) + +### D-P8-2 — Narrow-scope reconfigure: fees + supplies only, no region/key swap +**Decision:** `async_step_reconfigure(entry_data)` dispatches by `coordinator._current_plan_provider.id` to four per-provider sub-steps. Each sub-step ONLY edits supplemental settings (Amber fees, LocalVolts daily supply + buy/sell guard rails, DWT daily supply). Region swap (DWT) and site_id swap (Amber) are NOT exposed — they would invalidate the entry's unique_id (Phase 7 PR-2b: `f"dwt_{flavour}_{region}"`; Amber: `site_id`) and HA would treat the changed entry as a new install. Key rotation is reauth (PR-5), not reconfigure. +**Rationale:** v2 research § Wave 2 PR-6 ambitiously asked for "swap wholesale provider/region without losing comparator history" — but doing that cleanly requires decoupling unique_id from region. That's a unique_id-contract redesign + migration script for existing entries, naturally a future major-version concern. Shipping the narrow scope NOW closes the "no Reconfigure button visible" UX gap and lets users adjust the most-commonly-edited fields without going through OptionsFlow. +**Alternatives:** (a) Redesign unique_id to be HA-install-UUID + provider-type + a stable token, then ship full reconfigure — rejected because the migration is non-trivial and out of Wave 2 scope. (b) Punt reconfigure entirely — rejected because the absent button is a Silver-quality regression vs the current state. (c) Have reconfigure call OptionsFlow internally — rejected because OptionsFlow is a separate class and HA's reconfigure flow expects ConfigFlow methods. +**Consequences:** CdrPlanProvider entries (CDR-only installs) get the `reconfigure_unsupported` abort path. Users wanting to change region or rotate site_id must remove + re-add the integration. Documented in the strings `reconfigure_dwt_oe`/`reconfigure_dwt_aemo` descriptions. Future major version SHOULD redesign unique_id; that PR will be able to expose full reconfigure cleanly. Dispatcher pattern reuses the PR-5 reauth contract (tag on coordinator instance) so any future "X needs reconfiguring" can plug in with one branch. + +## 2026-05-22 — Phase 8 Plan 01 (per-provider reauth flows) + +### D-P8-1 — Dispatcher-pattern reauth via coordinator-tagged `_reauth_provider_id` +**Decision:** A single `ConfigFlow.async_step_reauth` reads `entry.runtime_data.coordinator._reauth_provider_id` and dispatches to per-provider sub-steps (`async_step_reauth_amber`, `async_step_reauth_localvolts`, `async_step_reauth_dwt_oe`). The tag is set on the coordinator instance at the auth-failure raise site (in `_fetch_amber_with_retry`, `_maybe_poll_localvolts`, `_refresh_dwt_price`) BEFORE the `ConfigEntryAuthFailed` is raised. Unknown / unset tags abort with `reason="reauth_provider_unknown"`. +**Rationale:** HA's reauth design is one `async_step_reauth` entry point per ConfigFlow class. Three independent reauth flow handlers would require three separate `domain=DOMAIN` config flows or HA-side context manipulation — neither is idiomatic. Tagging on the coordinator instance gives a single source of truth for "which provider failed last" without subclassing `ConfigEntryAuthFailed` (would force consumers to import the subclass) or threading the identity through the exception chain (brittle). The dispatcher reads the tag via `entry.runtime_data.coordinator` — leveraging the Phase 7 PR-1 typed runtime data path. If `entry.runtime_data` is None (coordinator never started, e.g. first-tick failure during `async_setup_entry`), the abort path keeps the user from getting stuck. +**Alternatives:** (a) Subclass `ConfigEntryAuthFailed` per provider (`AmberAuthFailed`, etc.) and route in async_step_reauth via `isinstance` — rejected because HA strips the exception before invoking reauth (only the entry id is passed); the subclass identity wouldn't survive the round-trip. (b) Three separate ConfigFlow classes — not allowed by HA (one ConfigFlow per `domain=DOMAIN`). (c) Store the failed provider in `entry.data["_last_failed_provider"]` — rejected because it requires writing to entry.data inside the failure path, which races against the HA reauth setup and risks partial updates. +**Consequences:** Any future fourth provider with API auth (e.g. a hypothetical Flow Power API key path) MUST (1) set `self._reauth_provider_id` BEFORE raising `ConfigEntryAuthFailed`, and (2) add a sub-step + dispatcher branch + matching strings entries. Test `test_async_step_reauth_dispatcher_routes_to_*_substep` (in `test_reauth.py`) is the load-bearing guard — adding a fourth provider without the dispatcher branch makes the test pass anyway (it asserts existence of the three known branches), so the protection is BY-CONVENTION rather than BY-TEST. Phase 8 PR-6 reconfigure flow consumes the same dispatcher pattern to route "swap Amber pricing mode" / "rotate region" / etc. + +## 2026-05-21 — Phase 7 Plan 04 (per-comparator pricing-mode opt-in) + +### D-P7-12 — Three-state pricing-mode replaces the binary CONF_

_ENABLED toggle +**Decision:** Each of Amber, Flow Power, LocalVolts gains a `CONF_

_PRICING_MODE` option key with three valid values: `"off"`, `"live_api"`, `"static_prd"`. The legacy `CONF_

_ENABLED` boolean continues to map cleanly: truthy → `live_api`, else → `off`. No write-back migration of existing entries — the resolver (`static_pricing.resolve_pricing_mode`) is invoked at every coordinator construction and every options-flow render so the legacy keys remain authoritative until the user re-saves via the new OptionsFlow page. The OptionsFlow `comparators` step now renders three mode selectors instead of three booleans; submitting MIRRORS the chosen mode into both `CONF_

_PRICING_MODE` AND `CONF_

_ENABLED` (write to both → consumers that still read the binary keep working). +**Rationale:** The v2 research §"Wave 1 — PR-4" line item asks for "user-supplied keys to comparator live-pricing only when opted in; otherwise static/estimated from PRD `tariffPeriod`". A two-state opt-in (have key vs not) coupled to "have key implies live" is the current shape and forces an API hit on every entry that happens to have a key. Decoupling the API-hit decision from the "is this a comparator?" decision is the privacy + cost-control win. Three states make the user choice explicit; back-compat at the resolver level keeps the upgrade path zero-touch. +**Alternatives:** (a) Binary enable + auto-detect (key present → live, else static) — rejected because users with keys may still want static for data-minimisation. (b) Dedicated provider classes per mode (`AmberLiveProvider`, `AmberStaticProvider`) — rejected because the rate-application is identical (both feed `set_current_rates`); only the rate SOURCE differs. (c) Single global "live_api allowed" toggle — rejected because per-comparator choice matters (some users have an Amber key but no LV key). +**Consequences:** The OptionsFlow Comparators page UX changed shape — single-screen booleans became three dropdowns. Acceptable: power users adopting `static_prd` were always going to need a CDR plan picker anyway. Phase 8 PR-5 reauth only fires for `live_api` entries (an `off` or `static_prd` entry can't have a stale key by definition). Phase 8 PR-6 reconfigure inherits this 3-state shape for the new "swap pricing mode" UI. + +### D-P7-13 — Static-PRD rate math reuses cdr/evaluator window helpers (NOT a new evaluator) +**Decision:** `static_pricing.evaluate_static_rates(plan_envelope, now_local)` is a thin facade over the existing `cdr/evaluator._resolve_tou_rate` + `cdr/evaluator.slot_in_window` helpers. NO new tariff-window math is introduced. The facade walks `data.electricityContract.tariffPeriod[0]` for import (singleRate.rates[0] OR `_resolve_tou_rate` for TOU) and `data.electricityContract.solarFeedInTariff[0]` for export (singleTariff OR `slot_in_window`-matched timeVaryingTariffs). Output is inc-GST c/kWh, matching `CdrPlanProvider.current_import_rate_c_kwh` convention. +**Rationale:** Single source of truth for TOU window matching is the single most important invariant. The `cdr/evaluator` module is exhaustively unit-tested (`test_cdr_evaluator.py` and the 12 `test_cdr_*` modules); reimplementing the window math in `static_pricing.py` would create a second set of edge cases (`endTime "00:00"` end-of-day, midnight-crossing windows, weekday filters) — guaranteed to drift. The facade is ~80 LOC. +**Alternatives:** (a) Independent re-implementation — rejected as above. (b) Call the full `cdr/evaluator.evaluate` and pull the marginal rate out of the breakdown — rejected because `evaluate` operates over a slot history (not a single now), is meant for cost-per-day math, and would be 100x slower at the per-tick scale. (c) Use `CdrPlanProvider.current_import_rate_c_kwh` directly — rejected because instantiating a full CdrPlanProvider per comparator pollutes the provider dict with extra entries and complicates daily_cost_history rollup keys. +**Consequences:** Static-PRD pricing reflects the FIRST tier of stepped-pricing plans only (`singleRate.rates[0].unitPrice`). Accurate per-tier stepping needs daily-kWh-vs-threshold state which lives inside the live providers' per-tick accumulators. Documented in `static_pricing.evaluate_static_rates` docstring + `D-P7-13`. Users wanting precise stepped math on a stepped plan must opt into `live_api` mode. Flow Power `static_prd` deferred (see CHANGELOG + D-P7-12 consequences) because Flow Power's internal margin math derives from `set_wholesale_rate`, not `set_current_rates` — bridging that contract needs a `flow_power.py` change scheduled for a follow-up PR. + +## 2026-05-21 — Phase 7 Plan 02b (Dynamic Wholesale Tariff retailer wiring) + +### D-P7-10 — DWT-OE and DWT-AEMO appear as TWO distinct retailer entries in the picker +**Decision:** The config-flow retailer picker (`async_step_cdr_retailer`) prepends TWO synthetic entries above the CDR catalogue list: "Dynamic Wholesale Tariff — OpenElectricity (API key required)" and "Dynamic Wholesale Tariff — AEMO Direct (no key)". Both are backed by a single `DynamicWholesaleTariffProvider` class instantiated with the appropriate `WholesalePriceSource` (OE → `OpenElectricityPriceSource`, AEMO → `NEMWebPriceSource`). +**Rationale:** Ryan's explicit pick during 07-02b planning. The alternative considered was a single "Dynamic Wholesale Tariff" entry whose downstream step asked "API key or anonymous?" — rejected because the key-required-vs-key-free split is a different *kind* of choice than the rest of the retailer picker offers and burying it inside the post-selection step would surprise users who scan the list. Two distinct labelled entries make the trade-off visible at decision time. Both entries share unique-id namespaces with `region` suffixes (`dwt_openelectricity_` / `dwt_aemo_direct_`) so the same HA instance can run BOTH simultaneously without colliding on `_abort_if_unique_id_configured`. +**Alternatives:** (a) One entry with hidden key-fallback during setup — rejected as above. (b) Two completely independent Provider classes — rejected because the cost-math is identical; only the price source differs, and the constructor-injectable `WholesalePriceSource` already abstracts that. (c) Surface the choice as a checkbox inside the credentials step — rejected because checkbox toggling is back-compatibility-hostile (user picks "use OE", later toggles off, what happens to the saved key? worse UX than two distinct entries). +**Consequences:** Two synthetic options must be maintained at the top of `_build_dwt_retailer_options()`. Their order (OE first, AEMO second) is locked by `test_dwt_retailer_options_oe_first_then_aemo`. Strings/translations carry a `dwt_credentials` step (OE) and a `dwt_aemo_setup` step (AEMO) — both must stay byte-identical between `strings.json` and `translations/en.json` per the project's translation-parity test. + +### D-P7-11 — `DynamicWholesaleTariffProvider` is the first self-priced Provider with a coordinator-attached async refresh +**Decision:** `DynamicWholesaleTariffProvider` keeps the synchronous `update(grid_power_w, now_local)` signature mandated by the Provider Protocol (matching Amber/Flow Power/LocalVolts). The asynchronous price fetch lives OUT-OF-BAND in `PriceHawkCoordinator._refresh_dwt_price()` — a new coroutine called from `_async_update_data` BEFORE the per-tick `provider.update()` loop. The coroutine fetches via the injected `price_source.fetch_current_price(region)` (4-minute staleness guard prevents over-fetch vs the 5-minute dispatch cadence) and pushes results into the provider via the PUBLIC `set_live_price(price)` method (NOT a private `_set_live_price` — the cross-module call from the coordinator is part of the contract, audit S1). +**Rationale:** Making `update()` async would break the Provider Protocol contract used by Amber/Flow Power and force every consumer (the tick loop in `_async_update_data`, the backfill replay path in `cdr/streaming.py`) to be rewritten. Pushing the async refresh out-of-band keeps the protocol intact and centralises the 30s-vs-5min cadence dedup in the coordinator where it belongs. Idempotency on `(region, interval_end_utc)` inside `set_live_price` means duplicate pushes from manual re-fetches are no-ops without log spam. +**Alternatives:** (a) Async `update()` — rejected (Protocol contract breakage; cascading rewrite of streaming engine + ranking job + Amber/Flow Power/LV). (b) Background `asyncio.create_task` inside provider — rejected (provider would own its own lifecycle, fighting with HA's `async_unload_entry`; staleness guard would live in the wrong place). (c) Synchronous fetch via `asyncio.run_until_complete` from inside `update()` — rejected (deadlocks the running loop). +**Consequences:** Phase 8 PR-5 reauth needs to wire `ConfigEntryAuthFailed` from `_refresh_dwt_price` into HA's reauth flow (currently the exception just re-raises to the coordinator's update wrapper, which logs and retries). Phase 8 PR-6 reconfigure needs to honour live changes to `CONF_DWT_REGION` mid-life. Phase 9 PR-10 dual-write needs to read `provider.extras["wholesale_price_aud_per_mwh"]` to publish wholesale price as an external statistic. The staleness threshold `_DWT_PRICE_STALENESS_SECONDS = 240.0` is the load-bearing constant; tightening it below 240 risks 429 rate-limits, loosening it above 300 risks lag against the 5-min dispatch interval. + ## 2026-05-20 — Phase 7 Plan 03 (NEMWeb DISPATCH fallback) ### D-P7-9 — Anchor NEM dispatch timestamps to Australia/Brisbane (no DST), NOT Australia/Sydney 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 0efa60a..cd0416c 100644 --- a/custom_components/pricehawk/config_flow.py +++ b/custom_components/pricehawk/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any import aiohttp @@ -40,8 +41,11 @@ CDR_SKIP_REASON_AFTER_ERROR, CDR_SKIP_REASON_NO_RETAILER, CDR_SKIP_REASON_RETRY_EXHAUSTED, + ALL_PRICING_MODES, 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, @@ -49,13 +53,21 @@ CONF_CURRENT_PROVIDER, CONF_DAILY_SUPPLY_CHARGE, CONF_DEMAND_CHARGE, + CONF_DWT_AEMO_DAILY_SUPPLY, + CONF_DWT_AEMO_ENABLED, + CONF_DWT_OE_API_KEY, + CONF_DWT_OE_DAILY_SUPPLY, + CONF_DWT_OE_ENABLED, + CONF_DWT_REGION, CONF_EXPORT_TARIFF, CONF_FLOW_POWER_BASE_RATE, CONF_FLOW_POWER_DAILY_SUPPLY, CONF_FLOW_POWER_ENABLED, CONF_FLOW_POWER_PEA_ENABLED, 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, @@ -66,7 +78,9 @@ CONF_LOCALVOLTS_ENABLED, CONF_LOCALVOLTS_NMI, 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, @@ -80,7 +94,10 @@ PLAN_FOUR4FREE, PLAN_GLOSAVE, PLAN_ZEROHERO, + PRICING_MODE_STATIC_PRD, PROVIDER_AMBER, + PROVIDER_DWT_AEMO, + PROVIDER_DWT_OE, PROVIDER_FLOW_POWER, PROVIDER_LOCALVOLTS, PROVIDER_OTHER, @@ -812,6 +829,46 @@ def _build_cdr_retailer_options( ] +def _build_dwt_retailer_options() -> list[dict[str, str]]: + """Phase 7 PR-2b — synthetic DWT entries prepended to the retailer picker. + + Order matters: OE first (API-key flavour, peer to Amber/LocalVolts), + then AEMO Direct (no-key flavour, the only key-free dynamic-tariff + option). Both lead the dropdown above any CDR-catalogue retailer. + """ + return [ + { + "value": PROVIDER_DWT_OE, + "label": "Dynamic Wholesale Tariff — OpenElectricity (API key required)", + }, + { + "value": PROVIDER_DWT_AEMO, + "label": "Dynamic Wholesale Tariff — AEMO Direct (no key)", + }, + ] + + +def _build_dwt_region_options(*, include_wem: bool) -> list[dict[str, str]]: + """Phase 7 PR-2b — region selector with grid-network badges. + + NEM regions are always included. ``include_wem=True`` adds WA — only + valid for the OpenElectricity flavour (NEMWeb DISPATCH is NEM-only + per PR-3). + """ + nem = [ + {"value": "NSW1", "label": "NSW1 — NEM (eastern grid)"}, + {"value": "QLD1", "label": "QLD1 — NEM"}, + {"value": "SA1", "label": "SA1 — NEM"}, + {"value": "TAS1", "label": "TAS1 — NEM"}, + {"value": "VIC1", "label": "VIC1 — NEM"}, + ] + if include_wem: + nem.append( + {"value": "WEM", "label": "WEM — Western Australia"} + ) + return nem + + def _summarise_cdr_plan(detail: dict[str, Any]) -> dict[str, str]: """Phase 2.9 — Distil a CDR PlanDetailV2 envelope into human-readable strings the confirmation form renders via description_placeholders. @@ -1402,6 +1459,15 @@ async def async_step_cdr_retailer( if user_input is not None: choice = user_input[CONF_CDR_RETAILER_ID] + # Phase 7 PR-2b — DWT short-circuit. Picking either DWT + # synthetic entry routes to a credentials/setup step and + # skips the CDR locale/distributor/plan_select branch. + if choice == PROVIDER_DWT_OE: + self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_OE + return await self.async_step_dwt_credentials() + if choice == PROVIDER_DWT_AEMO: + self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_AEMO + return await self.async_step_dwt_aemo_setup() # Find the chosen endpoint in the registry we already loaded. endpoints: list[RetailerEndpoint] = self._data.get( "_cdr_endpoints", [] @@ -1437,7 +1503,12 @@ async def async_step_cdr_retailer( # Stash endpoints so the second pass through this step (after user # input) can resolve the chosen brand_id without re-fetching. self._data["_cdr_endpoints"] = endpoints - options = _build_cdr_retailer_options(endpoints) + # Phase 7 PR-2b — prepend two synthetic Dynamic Wholesale Tariff + # entries at the TOP of the retailer picker. Picking either + # short-circuits the CDR plan branch (handled above on next pass). + options = _build_dwt_retailer_options() + _build_cdr_retailer_options( + endpoints + ) return self.async_show_form( step_id="cdr_retailer", @@ -1453,6 +1524,129 @@ async def async_step_cdr_retailer( ), ) + # ------------------------------------------------------------------ + # Dynamic Wholesale Tariff steps (Phase 7 PR-2b) + # ------------------------------------------------------------------ + + async def async_step_dwt_credentials( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """DWT-OpenElectricity setup — API key + region + supply charge. + + Validates the key against the live OpenElectricity SDK before + creating the entry. AC-7. + """ + from homeassistant.exceptions import ConfigEntryAuthFailed + from .providers.openelectricity import OpenElectricityPriceSource + + errors: dict[str, str] = {} + + if user_input is not None: + api_key = user_input[CONF_DWT_OE_API_KEY] + region = user_input[CONF_DWT_REGION] + supply = user_input[CONF_DWT_OE_DAILY_SUPPLY] + try: + src = OpenElectricityPriceSource(api_key=api_key) + await src.fetch_current_price(region) + except ConfigEntryAuthFailed: + errors[CONF_DWT_OE_API_KEY] = "invalid_api_key" + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "DWT-OE key validation soft-failed (network?): %s", err, + ) + # Soft-failure (network / SDK missing) → accept the key; + # the coordinator will surface a clearer error at setup. + if not errors: + self._data[CONF_DWT_OE_ENABLED] = True + self._data[CONF_DWT_OE_API_KEY] = api_key + self._data[CONF_DWT_REGION] = region + self._data[CONF_DWT_OE_DAILY_SUPPLY] = supply + self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_OE + await self.async_set_unique_id( + f"dwt_openelectricity_{region}" + ) + self._abort_if_unique_id_configured() + return await self.async_step_sensor_select() + + return self.async_show_form( + step_id="dwt_credentials", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_DWT_OE_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_DWT_REGION, default="NSW1" + ): SelectSelector( + SelectSelectorConfig( + options=_build_dwt_region_options( + include_wem=True + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required( + CONF_DWT_OE_DAILY_SUPPLY, default=110.0 + ): NumberSelector( + NumberSelectorConfig( + min=0, + max=500, + step=0.1, + mode=NumberSelectorMode.BOX, + ) + ), + } + ), + ) + + async def async_step_dwt_aemo_setup( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """DWT-AEMO setup — region + supply charge (no API key). + + NEM-only: WEM is excluded (NEMWeb DISPATCH is NEM-only per + PR-3). AC-10. + """ + if user_input is not None: + region = user_input[CONF_DWT_REGION] + supply = user_input[CONF_DWT_AEMO_DAILY_SUPPLY] + self._data[CONF_DWT_AEMO_ENABLED] = True + self._data[CONF_DWT_REGION] = region + self._data[CONF_DWT_AEMO_DAILY_SUPPLY] = supply + self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_AEMO + await self.async_set_unique_id(f"dwt_aemo_direct_{region}") + self._abort_if_unique_id_configured() + return await self.async_step_sensor_select() + + return self.async_show_form( + step_id="dwt_aemo_setup", + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_REGION, default="NSW1" + ): SelectSelector( + SelectSelectorConfig( + options=_build_dwt_region_options( + include_wem=False + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required( + CONF_DWT_AEMO_DAILY_SUPPLY, default=110.0 + ): NumberSelector( + NumberSelectorConfig( + min=0, + max=500, + step=0.1, + mode=NumberSelectorMode.BOX, + ) + ), + } + ), + ) + async def async_step_cdr_locale( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: @@ -1930,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, @@ -1950,6 +2169,388 @@ async def async_step_dashboard_token( ), ) + # ------------------------------------------------------------------ + # Reauth flow (Phase 8 PR-5) + # ------------------------------------------------------------------ + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> config_entries.ConfigFlowResult: + """HA-invoked reauth entry point. + + Dispatches to the correct per-provider sub-step based on the + ``_reauth_provider_id`` tag set by the coordinator on the failed + provider's auth-failure raise site. + """ + 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: + return await self.async_step_reauth_localvolts() + if provider_id == PROVIDER_DWT_OE: + return await self.async_step_reauth_dwt_oe() + return self.async_abort(reason="reauth_provider_unknown") + + async def async_step_reauth_amber( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Collect a fresh Amber API key and validate it live.""" + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + + if user_input is not None: + new_key = user_input[CONF_API_KEY] + session = async_get_clientsession(self.hass) + try: + async with session.get( + "https://api.amber.com.au/v1/sites", + headers={"Authorization": f"Bearer {new_key}"}, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + if resp.status in (401, 403): + errors[CONF_API_KEY] = "invalid_api_key" + elif resp.status != 200: + errors["base"] = "cannot_connect" + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.warning( + "Amber reauth probe failed: %s", type(err).__name__, + ) + errors["base"] = "cannot_connect" + if not errors: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_API_KEY: new_key}, + ) + + return self.async_show_form( + step_id="reauth_amber", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_API_KEY, + default=entry.data.get(CONF_API_KEY, ""), + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + ) + + async def async_step_reauth_localvolts( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Collect fresh LocalVolts credentials (key + partner + NMI).""" + from homeassistant.helpers.aiohttp_client import async_get_clientsession + from .localvolts_api import ( + LocalVoltsAPIError, + fetch_recent_intervals, + ) + + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + current_opts = entry.options + + if user_input is not None: + new_key = user_input[CONF_LOCALVOLTS_API_KEY] + new_partner = user_input[CONF_LOCALVOLTS_PARTNER_ID] + new_nmi = user_input[CONF_LOCALVOLTS_NMI] + session = async_get_clientsession(self.hass) + try: + await fetch_recent_intervals( + session, new_key, new_partner, new_nmi, + ) + except LocalVoltsAPIError as err: + msg = str(err).lower() + if "auth failed" in msg or "401" in msg or "403" in msg: + errors["base"] = "invalid_credentials" + else: + _LOGGER.warning( + "LocalVolts reauth probe non-auth error: %s", + type(err).__name__, + ) + errors["base"] = "cannot_connect" + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "LocalVolts reauth probe failed: %s", + type(err).__name__, + ) + errors["base"] = "cannot_connect" + if not errors: + return self.async_update_reload_and_abort( + entry, + options={ + **current_opts, + CONF_LOCALVOLTS_API_KEY: new_key, + CONF_LOCALVOLTS_PARTNER_ID: new_partner, + CONF_LOCALVOLTS_NMI: new_nmi, + }, + ) + + return self.async_show_form( + step_id="reauth_localvolts", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCALVOLTS_API_KEY, + default=current_opts.get(CONF_LOCALVOLTS_API_KEY, ""), + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_LOCALVOLTS_PARTNER_ID, + default=current_opts.get(CONF_LOCALVOLTS_PARTNER_ID, ""), + ): TextSelector(), + vol.Required( + CONF_LOCALVOLTS_NMI, + default=current_opts.get(CONF_LOCALVOLTS_NMI, ""), + ): TextSelector(), + } + ), + ) + + async def async_step_reauth_dwt_oe( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Collect a fresh OpenElectricity API key for DWT-OE.""" + from homeassistant.exceptions import ConfigEntryAuthFailed + from .providers.openelectricity import OpenElectricityPriceSource + + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + region = entry.data.get(CONF_DWT_REGION, "NSW1") + + if user_input is not None: + new_key = user_input[CONF_DWT_OE_API_KEY] + try: + src = OpenElectricityPriceSource(api_key=new_key) + await src.fetch_current_price(region) + except ConfigEntryAuthFailed: + errors[CONF_DWT_OE_API_KEY] = "invalid_api_key" + except Exception as err: # noqa: BLE001 + # Soft-accept on non-auth errors (transient network / + # SDK issue) — next coordinator tick will re-surface. + _LOGGER.warning( + "DWT-OE reauth probe non-auth error (accepting key): %s", + type(err).__name__, + ) + if not errors: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_DWT_OE_API_KEY: new_key}, + ) + + return self.async_show_form( + step_id="reauth_dwt_oe", + errors=errors, + description_placeholders={"region": region}, + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_OE_API_KEY, + default=entry.data.get(CONF_DWT_OE_API_KEY, ""), + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + ) + + # ------------------------------------------------------------------ + # Reconfigure flow (Phase 8 PR-6) + # ------------------------------------------------------------------ + + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> config_entries.ConfigFlowResult: + """HA-invoked reconfigure entry point. Routes by active provider.""" + del entry_data + entry = self._get_reconfigure_entry() + # Phase 8 PR-6 (codex fix): CdrPlanProvider.id is + # `{brand}_{plan_id}` (e.g. "amber_brokerage-xyz"), never the + # literal PROVIDER_AMBER / PROVIDER_LOCALVOLTS. Reading the + # provider id from the coordinator made the Amber/LV reconfigure + # branches unreachable for CDR-backed entries (the install base). + # Route from entry.data[CONF_CURRENT_PROVIDER] which records the + # user's primary choice as a stable, literal slug. + provider_id = entry.data.get(CONF_CURRENT_PROVIDER) + if provider_id == PROVIDER_AMBER: + return await self.async_step_reconfigure_amber() + if provider_id == PROVIDER_LOCALVOLTS: + return await self.async_step_reconfigure_localvolts() + if provider_id == PROVIDER_DWT_OE: + return await self.async_step_reconfigure_dwt_oe() + if provider_id == PROVIDER_DWT_AEMO: + return await self.async_step_reconfigure_dwt_aemo() + return self.async_abort(reason="reconfigure_unsupported") + + async def async_step_reconfigure_amber( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit Amber fees without touching the API key or site_id.""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_AMBER_NETWORK_DAILY_CHARGE: float( + user_input.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0) + or 0.0 + ), + CONF_AMBER_SUBSCRIPTION_FEE: float( + user_input.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0) + or 0.0 + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_amber", + data_schema=vol.Schema( + { + vol.Optional( + CONF_AMBER_NETWORK_DAILY_CHARGE, + default=float( + opts.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0) or 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_AMBER_SUBSCRIPTION_FEE, + default=float( + opts.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0) or 0.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_localvolts( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit LocalVolts daily supply + buy/sell guard rails.""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_LOCALVOLTS_DAILY_SUPPLY: float( + user_input[CONF_LOCALVOLTS_DAILY_SUPPLY] + ), + CONF_LOCALVOLTS_BUY_CEILING: float( + user_input.get(CONF_LOCALVOLTS_BUY_CEILING, 0.0) + or 0.0 + ), + CONF_LOCALVOLTS_SELL_FLOOR: float( + user_input.get(CONF_LOCALVOLTS_SELL_FLOOR, 0.0) + or 0.0 + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_localvolts", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCALVOLTS_DAILY_SUPPLY, + default=float( + opts.get(CONF_LOCALVOLTS_DAILY_SUPPLY, 110.0) + or 110.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_LOCALVOLTS_BUY_CEILING, + default=float( + opts.get(CONF_LOCALVOLTS_BUY_CEILING, 0.0) or 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_LOCALVOLTS_SELL_FLOOR, + default=float( + opts.get(CONF_LOCALVOLTS_SELL_FLOOR, 0.0) or 0.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_dwt_oe( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit DWT-OE daily supply only (region swap deferred — D-P8-2).""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_DWT_OE_DAILY_SUPPLY: float( + user_input[CONF_DWT_OE_DAILY_SUPPLY] + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_dwt_oe", + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_OE_DAILY_SUPPLY, + default=float( + opts.get(CONF_DWT_OE_DAILY_SUPPLY, 110.0) or 110.0 + ), + ): vol.Coerce(float), + } + ), + ) + + async def async_step_reconfigure_dwt_aemo( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit DWT-AEMO daily supply only (region swap deferred — D-P8-2).""" + entry = self._get_reconfigure_entry() + opts = entry.options + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + options={ + **opts, + CONF_DWT_AEMO_DAILY_SUPPLY: float( + user_input[CONF_DWT_AEMO_DAILY_SUPPLY] + ), + }, + ) + return self.async_show_form( + step_id="reconfigure_dwt_aemo", + data_schema=vol.Schema( + { + vol.Required( + CONF_DWT_AEMO_DAILY_SUPPLY, + default=float( + opts.get(CONF_DWT_AEMO_DAILY_SUPPLY, 110.0) or 110.0 + ), + ): vol.Coerce(float), + } + ), + ) + @staticmethod @callback def async_get_options_flow( @@ -2009,9 +2610,18 @@ async def async_step_comparators( """ if user_input is not None: new_opts: dict[str, Any] = dict(self.config_entry.options) - new_opts[CONF_AMBER_ENABLED] = bool(user_input.get(CONF_AMBER_ENABLED, False)) - new_opts[CONF_FLOW_POWER_ENABLED] = bool(user_input.get(CONF_FLOW_POWER_ENABLED, False)) - new_opts[CONF_LOCALVOLTS_ENABLED] = bool(user_input.get(CONF_LOCALVOLTS_ENABLED, False)) + # Phase 7 PR-4 — three-state pricing mode selectors. Mirror + # the value to the legacy CONF_

_ENABLED flag for + # back-compat with consumers that still read the boolean. + amber_mode = user_input.get(CONF_AMBER_PRICING_MODE, "off") + fp_mode = user_input.get(CONF_FLOW_POWER_PRICING_MODE, "off") + lv_mode = user_input.get(CONF_LOCALVOLTS_PRICING_MODE, "off") + new_opts[CONF_AMBER_PRICING_MODE] = amber_mode + new_opts[CONF_FLOW_POWER_PRICING_MODE] = fp_mode + new_opts[CONF_LOCALVOLTS_PRICING_MODE] = lv_mode + new_opts[CONF_AMBER_ENABLED] = amber_mode != "off" + new_opts[CONF_FLOW_POWER_ENABLED] = fp_mode != "off" + new_opts[CONF_LOCALVOLTS_ENABLED] = lv_mode != "off" new_opts[CONF_OVO_INTEREST_BALANCE_AUD] = float( user_input.get(CONF_OVO_INTEREST_BALANCE_AUD, 0) or 0 ) @@ -2021,22 +2631,70 @@ async def async_step_comparators( return self.async_create_entry(title="", data=new_opts) current_opts = self.config_entry.options + # Resolve default modes back-compat-aware (Phase 7 PR-4). + from .static_pricing import resolve_pricing_mode as _resolve + + amber_default = _resolve( + dict(current_opts), dict(self.config_entry.data), + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + fp_default = _resolve( + dict(current_opts), dict(self.config_entry.data), + mode_key=CONF_FLOW_POWER_PRICING_MODE, + legacy_enabled_key=CONF_FLOW_POWER_ENABLED, + ) + lv_default = _resolve( + dict(current_opts), dict(self.config_entry.data), + mode_key=CONF_LOCALVOLTS_PRICING_MODE, + legacy_enabled_key=CONF_LOCALVOLTS_ENABLED, + ) + # 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( { vol.Optional( - CONF_AMBER_ENABLED, - default=current_opts.get(CONF_AMBER_ENABLED, False), - ): bool, + CONF_AMBER_PRICING_MODE, default=amber_default, + ): SelectSelector( + SelectSelectorConfig( + options=_amber_mode_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), vol.Optional( - CONF_FLOW_POWER_ENABLED, - default=current_opts.get(CONF_FLOW_POWER_ENABLED, False), - ): bool, + CONF_FLOW_POWER_PRICING_MODE, default=fp_default, + ): SelectSelector( + SelectSelectorConfig( + options=_fp_mode_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), vol.Optional( - CONF_LOCALVOLTS_ENABLED, - default=current_opts.get(CONF_LOCALVOLTS_ENABLED, False), - ): bool, + CONF_LOCALVOLTS_PRICING_MODE, default=lv_default, + ): SelectSelector( + SelectSelectorConfig( + options=_lv_mode_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), vol.Optional( CONF_OVO_INTEREST_BALANCE_AUD, default=float(current_opts.get(CONF_OVO_INTEREST_BALANCE_AUD, 0) or 0), diff --git a/custom_components/pricehawk/const.py b/custom_components/pricehawk/const.py index 0b44565..0bb6586 100644 --- a/custom_components/pricehawk/const.py +++ b/custom_components/pricehawk/const.py @@ -18,6 +18,12 @@ # legacy PROVIDER_GLOBIRD value did. Stored as the entry's # current_provider when user selects "Other (no API)". PROVIDER_OTHER = "other" +# Phase 7 PR-2b: Dynamic Wholesale Tariff — TWO entries in retailer +# picker, one Provider class behind them. OE = OpenElectricity SDK +# (API key required, peer to Amber/LocalVolts). AEMO = NEMWeb DISPATCH +# (no key, the only key-free dynamic-tariff option). +PROVIDER_DWT_OE = "dwt_openelectricity" +PROVIDER_DWT_AEMO = "dwt_aemo_direct" ALL_PROVIDER_IDS = ( PROVIDER_AMBER, @@ -25,6 +31,8 @@ PROVIDER_FLOW_POWER, PROVIDER_LOCALVOLTS, PROVIDER_OTHER, + PROVIDER_DWT_OE, + PROVIDER_DWT_AEMO, ) # Per-provider enable flags. Amber and LocalVolts are only enabled when @@ -60,6 +68,44 @@ CONF_LOCALVOLTS_BUY_CEILING = "localvolts_buy_ceiling" CONF_LOCALVOLTS_SELL_FLOOR = "localvolts_sell_floor" +# Dynamic Wholesale Tariff (DWT) — Phase 7 PR-2b +# Two retailer flavours backed by ONE Provider class: +# - DWT-OE: OpenElectricity SDK (API key required) +# - DWT-AEMO: NEMWeb DISPATCH (no key, NEM-only — no WEM) +CONF_DWT_OE_ENABLED = "dwt_oe_enabled" +CONF_DWT_AEMO_ENABLED = "dwt_aemo_enabled" +CONF_DWT_OE_API_KEY = "dwt_oe_api_key" +CONF_DWT_REGION = "dwt_region" +CONF_DWT_OE_DAILY_SUPPLY = "dwt_oe_daily_supply" +CONF_DWT_AEMO_DAILY_SUPPLY = "dwt_aemo_daily_supply" + +# Phase 7 PR-4 — per-comparator pricing mode. +# Each of Amber/FlowPower/LocalVolts can be: +# - off: not registered as a comparator +# - live_api: use the user-supplied API key for live pricing +# - static_prd: derive rates from a chosen CDR PRD tariffPeriod +# Back-compat: legacy CONF_

_ENABLED=True maps to live_api; absent or +# False maps to off. No write-back migration — coordinator reads both +# keys via static_pricing.resolve_pricing_mode. CONF_

_STATIC_PLAN +# holds the FULL PlanDetailV2 envelope picked by the user (~15 KB per +# plan). Stored in entry.options. +PRICING_MODE_OFF = "off" +PRICING_MODE_LIVE_API = "live_api" +PRICING_MODE_STATIC_PRD = "static_prd" +ALL_PRICING_MODES = ( + PRICING_MODE_OFF, + PRICING_MODE_LIVE_API, + PRICING_MODE_STATIC_PRD, +) + +CONF_AMBER_PRICING_MODE = "amber_pricing_mode" +CONF_FLOW_POWER_PRICING_MODE = "flow_power_pricing_mode" +CONF_LOCALVOLTS_PRICING_MODE = "localvolts_pricing_mode" + +CONF_AMBER_STATIC_PLAN = "amber_static_plan" +CONF_FLOW_POWER_STATIC_PLAN = "flow_power_static_plan" +CONF_LOCALVOLTS_STATIC_PLAN = "localvolts_static_plan" + # Polling intervals (seconds) LOCALVOLTS_API_POLL_INTERVAL = 60 AEMO_API_POLL_INTERVAL = 300 # 5 min — matches NEMWeb dispatch publish cadence diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index 32a1f07..ffc4488 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -10,8 +10,9 @@ import asyncio from homeassistant.config_entries import ConfigEntry -from homeassistant.exceptions import ConfigEntryNotReady +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 @@ -38,20 +39,49 @@ from .const import ( AEMO_API_POLL_INTERVAL, CONF_AMBER_ENABLED, + CONF_AMBER_PRICING_MODE, + CONF_AMBER_STATIC_PLAN, + CONF_DWT_AEMO_DAILY_SUPPLY, + CONF_DWT_AEMO_ENABLED, + CONF_DWT_OE_API_KEY, + CONF_DWT_OE_DAILY_SUPPLY, + CONF_DWT_OE_ENABLED, + CONF_DWT_REGION, CONF_FLOW_POWER_ENABLED, + CONF_FLOW_POWER_PRICING_MODE, CONF_FLOW_POWER_REGION, CONF_LOCALVOLTS_API_KEY, CONF_LOCALVOLTS_ENABLED, CONF_LOCALVOLTS_NMI, CONF_LOCALVOLTS_PARTNER_ID, + CONF_LOCALVOLTS_PRICING_MODE, + CONF_LOCALVOLTS_STATIC_PLAN, CONF_NAMED_COMPARATOR_PLAN, LOCALVOLTS_API_POLL_INTERVAL, + PRICING_MODE_LIVE_API, + PRICING_MODE_OFF, + PRICING_MODE_STATIC_PRD, + PROVIDER_DWT_AEMO, + PROVIDER_DWT_OE, + 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 -from .localvolts_api import aggregate_to_half_hour, fetch_recent_intervals +from .localvolts_api import ( + LocalVoltsAPIError, + aggregate_to_half_hour, + fetch_recent_intervals, +) from .providers.cdr_plan import CdrPlanProvider +from .providers.dynamic_wholesale_tariff import DynamicWholesaleTariffProvider +from .providers.nemweb import NEMWebPriceSource +from .providers.openelectricity import OpenElectricityPriceSource from .providers import ( AmberProvider, FlowPowerProvider, @@ -72,6 +102,12 @@ _RANKING_RUN_HOUR = 0 _RANKING_RUN_MINUTE = 30 +# DWT price-refresh dedup: OE/NEMWeb publish at 5-min cadence, coordinator +# ticks at 30s. Skip the SDK call when the cached price is fresher than +# this threshold — gives a 1-min slack before the next 5-min dispatch +# interval, bounding to ≤ 1 fetch / 4min / region / entry. +_DWT_PRICE_STALENESS_SECONDS = 240.0 + def _extract_peak_rate_c_inc_gst(cdr_plan: dict[str, Any] | None) -> float | None: """Phase 3.0e — pull PEAK rate from a CDR plan envelope. @@ -224,56 +260,123 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: update_interval=timedelta(seconds=COORDINATOR_SCAN_INTERVAL), ) - # Phase 3.0c: every entry has a `cdr_plan` envelope. The legacy - # manual-tariff path (GloBirdProvider) is dead code now and gets - # removed in Phase 3.0d once the wizard rewrite enforces this - # invariant for new installs. Existing entries from Phase 2.x - # without cdr_plan are unsupported per the no-migration policy. - cdr_plan = entry.options.get("cdr_plan") - if not cdr_plan: - raise ConfigEntryNotReady( - "PriceHawk entry is missing 'cdr_plan' option. " - "Per Phase 3 'no migration' policy: remove this integration " - "and re-add it through the new wizard." + # Phase 7 PR-2b — Dynamic Wholesale Tariff branch. When the user + # picked DWT-OE or DWT-AEMO at setup, the current-plan slot is + # filled by DynamicWholesaleTariffProvider instead of + # CdrPlanProvider (no cdr_plan exists for DWT entries). + self._dwt_provider: DynamicWholesaleTariffProvider | None = None + dwt_provider = self._build_dwt_provider(entry) + if dwt_provider is not None: + self._current_plan_provider: Provider = dwt_provider + self._dwt_provider = dwt_provider + _LOGGER.info( + "Using DynamicWholesaleTariffProvider (id=%s region=%s)", + dwt_provider.id, dwt_provider.region, ) - # Phase 2.12.1: pass entry.options for opt-in fields - # (ovo_interest_balance_aud, vpp_batteries_enrolled). The provider - # plumbs these to the streaming engine → evaluator → - # per-retailer incentive parsers. - self._current_plan_provider: Provider = CdrPlanProvider( - cdr_plan, entry_options=dict(entry.options), - ) - _LOGGER.info("Using CdrPlanProvider (CDR plan %s)", - cdr_plan.get("data", {}).get("planId", "?")) + else: + # Phase 3.0c: every non-DWT entry has a `cdr_plan` envelope. + # The legacy manual-tariff path (GloBirdProvider) is dead + # code now. Existing entries from Phase 2.x without cdr_plan + # are unsupported per the no-migration policy. + cdr_plan = entry.options.get("cdr_plan") + if not cdr_plan: + raise ConfigEntryNotReady( + "PriceHawk entry is missing 'cdr_plan' option. " + "Per Phase 3 'no migration' policy: remove this integration " + "and re-add it through the new wizard." + ) + # Phase 2.12.1: pass entry.options for opt-in fields + # (ovo_interest_balance_aud, vpp_batteries_enrolled). The provider + # plumbs these to the streaming engine → evaluator → + # per-retailer incentive parsers. + self._current_plan_provider = CdrPlanProvider( + cdr_plan, entry_options=dict(entry.options), + ) + _LOGGER.info("Using CdrPlanProvider (CDR plan %s)", + cdr_plan.get("data", {}).get("planId", "?")) self._providers: dict[str, Provider] = { self._current_plan_provider.id: self._current_plan_provider, } - # Flow Power is universally enabled by default (uses AEMO direct, - # no credentials required); user can disable via options flow. - self._flow_power: FlowPowerProvider | None = None - if entry.options.get(CONF_FLOW_POWER_ENABLED, False): - self._flow_power = FlowPowerProvider(entry.options) - self._providers[self._flow_power.id] = self._flow_power - - # Amber only registers when the user is actually an Amber customer - # (i.e. they provided an API key during setup or via options). + # Phase 7 PR-4 — per-comparator three-state pricing mode (off / + # live_api / static_prd). The resolver back-compats legacy + # CONF_

_ENABLED entries (truthy → live_api; else → off). + # AMBER: ditto, with a further "no API key + no static plan → + # off regardless" defensive gate. + amber_mode = resolve_pricing_mode( + dict(entry.options), dict(entry.data), + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + if amber_mode == PRICING_MODE_LIVE_API and not entry.data.get(CONF_API_KEY): + # Legacy back-compat default: amber_enabled was None → falls + # through to bool(entry.data[CONF_API_KEY]). If we resolve to + # live_api without a key, that's an off entry from the old + # path — preserve the old behaviour. + if entry.options.get(CONF_AMBER_PRICING_MODE) is None: + amber_mode = PRICING_MODE_OFF + self._amber_mode = amber_mode self._amber: AmberProvider | None = None - amber_enabled = entry.options.get(CONF_AMBER_ENABLED) - if amber_enabled is None: - # Back-compat: pre-existing installs always had Amber enabled. - amber_enabled = bool(entry.data.get(CONF_API_KEY)) - if amber_enabled: + self._amber_static_plan: dict[str, Any] | None = None + if amber_mode != PRICING_MODE_OFF: + if amber_mode == PRICING_MODE_STATIC_PRD: + self._amber_static_plan = entry.options.get(CONF_AMBER_STATIC_PLAN) + if not self._amber_static_plan: + raise ConfigEntryNotReady( + "Amber pricing_mode=static_prd but no static plan " + "stored. Reconfigure the entry to pick a CDR plan." + ) self._amber = AmberProvider( amber_network_daily_c=entry.options.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0), amber_subscription_daily_c=entry.options.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0), ) self._providers[self._amber.id] = self._amber - # LocalVolts only registers when the user is actually a LocalVolts - # customer (API key collected at setup or via options). + # FLOW POWER: Wave-1 PR-4 only ships live_api + off for Flow Power. + # static_prd is deferred — Flow Power's internal margin is derived + # from set_wholesale_rate (NEM spot), not set_current_rates; the + # bridge to feed already-final static rates needs flow_power.py + # changes which are out of this PR's boundary. Surfacing the mode + # key now lets the OptionsFlow render the selector consistently + # across all three comparators. + flow_power_mode = resolve_pricing_mode( + dict(entry.options), dict(entry.data), + mode_key=CONF_FLOW_POWER_PRICING_MODE, + legacy_enabled_key=CONF_FLOW_POWER_ENABLED, + ) + if flow_power_mode == PRICING_MODE_STATIC_PRD: + _LOGGER.warning( + "Flow Power static_prd is deferred to a future PR — " + "falling back to live_api for this entry. Track via " + "DECISIONS.md > D-P7-12." + ) + flow_power_mode = PRICING_MODE_LIVE_API + self._flow_power_mode = flow_power_mode + self._flow_power: FlowPowerProvider | None = None + if flow_power_mode != PRICING_MODE_OFF: + self._flow_power = FlowPowerProvider(entry.options) + self._providers[self._flow_power.id] = self._flow_power + + # LOCALVOLTS: live_api requires API key + partner + NMI from + # Phase 2.12 OptionsFlow; static_prd consumes a CDR plan envelope. + localvolts_mode = resolve_pricing_mode( + dict(entry.options), dict(entry.data), + mode_key=CONF_LOCALVOLTS_PRICING_MODE, + legacy_enabled_key=CONF_LOCALVOLTS_ENABLED, + ) + self._localvolts_mode = localvolts_mode self._localvolts: LocalVoltsProvider | None = None - if entry.options.get(CONF_LOCALVOLTS_ENABLED): + self._localvolts_static_plan: dict[str, Any] | None = None + if localvolts_mode != PRICING_MODE_OFF: + if localvolts_mode == PRICING_MODE_STATIC_PRD: + self._localvolts_static_plan = entry.options.get( + CONF_LOCALVOLTS_STATIC_PLAN + ) + if not self._localvolts_static_plan: + raise ConfigEntryNotReady( + "LocalVolts pricing_mode=static_prd but no static " + "plan stored. Reconfigure the entry." + ) self._localvolts = LocalVoltsProvider(entry.options) self._providers[self._localvolts.id] = self._localvolts @@ -395,6 +498,139 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._backfill_plans_replayed: int = 0 self._backfill_error: str | None = None + # Phase 8 PR-5 — reauth provider tag. Set BEFORE raising + # ConfigEntryAuthFailed so the ConfigFlow.async_step_reauth + # 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) + # ------------------------------------------------------------------ + + def _build_dwt_provider( + self, entry: ConfigEntry + ) -> DynamicWholesaleTariffProvider | None: + """Build DynamicWholesaleTariffProvider when entry was set up for DWT. + + Returns None when the entry is a CDR-plan entry (no DWT enable flag + in options or data). Raises ConfigEntryNotReady on inconsistent + config (current_provider says DWT but enable flags missing) — AC-10c. + """ + def opt(key: str) -> Any: + return entry.options.get(key, entry.data.get(key)) + + oe_enabled = bool(opt(CONF_DWT_OE_ENABLED)) + aemo_enabled = bool(opt(CONF_DWT_AEMO_ENABLED)) + current_provider = opt(CONF_CURRENT_PROVIDER) + is_dwt_oe_marker = current_provider == PROVIDER_DWT_OE + is_dwt_aemo_marker = current_provider == PROVIDER_DWT_AEMO + + # AC-10c — refuse setup on inconsistent state. + if is_dwt_oe_marker and not (oe_enabled and opt(CONF_DWT_OE_API_KEY)): + raise ConfigEntryNotReady( + "DWT-OpenElectricity selected as current provider but config " + "is incomplete (missing API key or ENABLED flag). " + "Reconfigure the entry." + ) + if is_dwt_aemo_marker and not aemo_enabled: + raise ConfigEntryNotReady( + "DWT-AEMO selected as current provider but ENABLED flag is " + "missing. Reconfigure the entry." + ) + + if oe_enabled and is_dwt_oe_marker: + api_key = opt(CONF_DWT_OE_API_KEY) + region = opt(CONF_DWT_REGION) or "NSW1" + daily_supply = float(opt(CONF_DWT_OE_DAILY_SUPPLY) or 110.0) + # OpenElectricity SDK manages its own session (audit M2 finding: + # AsyncOEClient signature is (api_key, base_url) — no session + # kwarg). Trade-off accepted in PR-2. + price_source: OpenElectricityPriceSource | NEMWebPriceSource = ( + OpenElectricityPriceSource(api_key=api_key) + ) + return DynamicWholesaleTariffProvider( + price_source=price_source, + region=region, + daily_supply_c=daily_supply, + provider_id=PROVIDER_DWT_OE, + name="Dynamic Wholesale Tariff — OpenElectricity", + ) + + if aemo_enabled and is_dwt_aemo_marker: + region = opt(CONF_DWT_REGION) or "NSW1" + daily_supply = float(opt(CONF_DWT_AEMO_DAILY_SUPPLY) or 110.0) + price_source = NEMWebPriceSource( + session=async_get_clientsession(self.hass) + ) + return DynamicWholesaleTariffProvider( + price_source=price_source, + region=region, + daily_supply_c=daily_supply, + provider_id=PROVIDER_DWT_AEMO, + name="Dynamic Wholesale Tariff — AEMO Direct", + ) + + return None + + async def _refresh_dwt_price(self) -> None: + """Async price-refresh hook — called every coordinator tick. + + Dedups SDK calls via the 4-minute staleness guard (AC-10b): when + the cached last-good price is fresher than _DWT_PRICE_STALENESS_SECONDS, + skip the fetch entirely. OE/NEMWeb publish at 5-min cadence; + fetching every 30s is wasteful and 429-prone. + + On auth failure, re-raises ConfigEntryAuthFailed so HA's reauth + flow takes over (full reauth wiring is Phase 8 PR-5). + """ + provider = self._dwt_provider + if provider is None: + return + + # AC-10b: staleness guard. Skip when cached price is fresh. + last = provider.last_price + if last is not None: + age = ( + datetime.now(tz=dt_util.UTC) - last.interval_end_utc + ).total_seconds() + if age < _DWT_PRICE_STALENESS_SECONDS: + return + + try: + result = await provider.price_source.fetch_current_price( + provider.region + ) + except ConfigEntryAuthFailed: + # Phase 8 PR-5 — tag for reauth dispatcher. Only OE has a + # key; AEMO Direct can't auth-fail (no key). + self._reauth_provider_id = PROVIDER_DWT_OE + raise + except ConfigEntryNotReady: + raise + except Exception as exc: # noqa: BLE001 + _LOGGER.warning( + "DWT price refresh failed for %s: %s", + provider.region, exc, + ) + result = None + + if result is None: + result = provider.price_source.last_good(provider.region) + if result is not None: + provider.set_live_price(result) + # ------------------------------------------------------------------ # Amber REST API polling # ------------------------------------------------------------------ @@ -486,6 +722,17 @@ async def _fetch_amber_with_retry(self) -> list | None: if resp.status == 200: return await resp.json() + # Phase 8 PR-5 — auth-failure → HA reauth flow. Tag the + # failed provider so the dispatcher in async_step_reauth + # knows which sub-step to route to. Key is in the + # Authorization header (not in the response body or URL) + # so str(exc) is safe to log. + if resp.status in (401, 403): + self._reauth_provider_id = PROVIDER_AMBER + raise ConfigEntryAuthFailed( + f"Amber API rejected the key (HTTP {resp.status})" + ) + if resp.status == 429 or resp.status >= 500: # Retryable — respect Retry-After or backoff retry_after = resp.headers.get("Retry-After") @@ -628,7 +875,13 @@ def _update_amber_forecast( ] async def _maybe_poll_amber(self) -> None: - """Poll Amber API if enough time has elapsed since last poll.""" + """Poll Amber API if enough time has elapsed since last poll. + + Phase 7 PR-4: skip entirely when Amber is in PRICING_MODE_STATIC_PRD — + the static-PRD path needs no live API hit. + """ + if self._amber_mode != PRICING_MODE_LIVE_API: + return now_mono = self.hass.loop.time() if now_mono - self._last_amber_poll >= AMBER_API_POLL_INTERVAL: await self._poll_amber_prices() @@ -665,9 +918,15 @@ async def _maybe_poll_aemo(self) -> None: self._last_aemo_poll = now_mono async def _maybe_poll_localvolts(self) -> None: - """Poll LocalVolts API every LOCALVOLTS_API_POLL_INTERVAL seconds.""" + """Poll LocalVolts API every LOCALVOLTS_API_POLL_INTERVAL seconds. + + Phase 7 PR-4: skip entirely when LocalVolts is in + PRICING_MODE_STATIC_PRD — static path uses no API hit. + """ if self._localvolts is None: return + if self._localvolts_mode != PRICING_MODE_LIVE_API: + return now_mono = self.hass.loop.time() if now_mono - self._last_localvolts_poll < LOCALVOLTS_API_POLL_INTERVAL: return @@ -680,9 +939,22 @@ async def _maybe_poll_localvolts(self) -> None: return session = async_get_clientsession(self.hass) - intervals = await fetch_recent_intervals( - session, api_key, partner_id, nmi - ) + try: + intervals = await fetch_recent_intervals( + session, api_key, partner_id, nmi + ) + except LocalVoltsAPIError as err: + # Phase 8 PR-5 — auth-failure → HA reauth. Detect 401/403 + # via substring match on the message format from + # localvolts_api.py:79-81. Non-auth LocalVoltsAPIError + # re-raises as-is (caller / DataUpdateCoordinator handles). + msg = str(err).lower() + if "auth failed" in msg or "401" in msg or "403" in msg: + self._reauth_provider_id = PROVIDER_LOCALVOLTS + raise ConfigEntryAuthFailed( + "LocalVolts API rejected credentials" + ) from err + raise imp_c, exp_c = aggregate_to_half_hour(intervals) if imp_c is not None: self._localvolts_import_c = imp_c @@ -702,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) @@ -716,6 +996,10 @@ async def _async_update_data(self) -> dict[str, Any]: # 1b. Poll LocalVolts API (rate-limited) await self._maybe_poll_localvolts() + # 1c. Refresh DWT wholesale price (rate-limited via 4-min + # staleness guard; no-op when entry is not a DWT entry). + await self._refresh_dwt_price() + # 2. Read grid power sensor grid_power_w = self._read_grid_power() now_local = dt_util.now() @@ -762,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: @@ -787,17 +1096,33 @@ async def _async_update_data(self) -> dict[str, Any]: # Persist immediately after rollover to avoid data loss on crash await self.async_persist_state() - # 5. Push current externally-sourced rates into providers that need them + # 5. Push current rates into providers that need them. + # Phase 7 PR-4: mode-gated. live_api → existing live-poll rates; + # static_prd → evaluate from stored CDR PRD envelope each tick. if self._amber is not None: - self._amber.set_current_rates( - self._amber_import_c, self._amber_export_c - ) + if self._amber_mode == PRICING_MODE_STATIC_PRD: + imp, exp = evaluate_static_rates( + self._amber_static_plan, now_local + ) + self._amber.set_current_rates(imp, exp) + else: + self._amber.set_current_rates( + self._amber_import_c, self._amber_export_c + ) if self._flow_power is not None: + # Flow Power static_prd deferred (see __init__ note); always + # uses live wholesale path for now. self._flow_power.set_wholesale_rate(self._wholesale_c) if self._localvolts is not None: - self._localvolts.set_current_rates( - self._localvolts_import_c, self._localvolts_export_c - ) + if self._localvolts_mode == PRICING_MODE_STATIC_PRD: + imp, exp = evaluate_static_rates( + self._localvolts_static_plan, now_local + ) + self._localvolts.set_current_rates(imp, exp) + else: + self._localvolts.set_current_rates( + self._localvolts_import_c, self._localvolts_export_c + ) # 6. Tick every registered provider (no-ops gracefully if a provider # is missing rates). @@ -805,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. @@ -1525,42 +1960,136 @@ async def async_run_backfill( def rebuild_engine(self, new_options: dict) -> None: """Rebuild all providers with updated options. - Phase 3.0c invariant: every entry has a cdr_plan. Options-flow - reload should never produce a state without one. + Phase 3.0c invariant: every entry has a cdr_plan OR a DWT + enable flag. Options-flow reload should never produce a state + without one. """ - cdr_plan = new_options.get("cdr_plan") - if not cdr_plan: - _LOGGER.error( - "rebuild_engine called without cdr_plan in options; " - "keeping existing provider — investigate options-flow" + # Phase 7 PR-2b — DWT branch (mirrors __init__). + dwt_oe = new_options.get(CONF_DWT_OE_ENABLED) + dwt_aemo = new_options.get(CONF_DWT_AEMO_ENABLED) + if dwt_oe or dwt_aemo: + region = new_options.get(CONF_DWT_REGION) or "NSW1" + if dwt_oe: + api_key = new_options.get( + CONF_DWT_OE_API_KEY, + self.config_entry.data.get(CONF_DWT_OE_API_KEY, ""), + ) + daily_supply = float( + new_options.get(CONF_DWT_OE_DAILY_SUPPLY) or 110.0 + ) + src: OpenElectricityPriceSource | NEMWebPriceSource = ( + OpenElectricityPriceSource(api_key=api_key) + ) + dwt_id = PROVIDER_DWT_OE + dwt_name = "Dynamic Wholesale Tariff — OpenElectricity" + else: + daily_supply = float( + new_options.get(CONF_DWT_AEMO_DAILY_SUPPLY) or 110.0 + ) + src = NEMWebPriceSource( + session=async_get_clientsession(self.hass) + ) + dwt_id = PROVIDER_DWT_AEMO + dwt_name = "Dynamic Wholesale Tariff — AEMO Direct" + self._dwt_provider = DynamicWholesaleTariffProvider( + price_source=src, + region=region, + daily_supply_c=daily_supply, + provider_id=dwt_id, + name=dwt_name, ) - return - self._current_plan_provider = CdrPlanProvider( - cdr_plan, entry_options=dict(new_options), - ) - _LOGGER.info("Rebuilt with CdrPlanProvider (CDR plan %s)", - cdr_plan.get("data", {}).get("planId", "?")) - self._providers = {self._current_plan_provider.id: self._current_plan_provider} + self._current_plan_provider = self._dwt_provider + _LOGGER.info( + "Rebuilt with DynamicWholesaleTariffProvider (id=%s region=%s)", + dwt_id, region, + ) + self._providers = { + self._current_plan_provider.id: self._current_plan_provider + } + else: + self._dwt_provider = None + cdr_plan = new_options.get("cdr_plan") + if not cdr_plan: + _LOGGER.error( + "rebuild_engine called without cdr_plan or DWT flag; " + "keeping existing provider — investigate options-flow" + ) + return + self._current_plan_provider = CdrPlanProvider( + cdr_plan, entry_options=dict(new_options), + ) + _LOGGER.info("Rebuilt with CdrPlanProvider (CDR plan %s)", + cdr_plan.get("data", {}).get("planId", "?")) + self._providers = { + self._current_plan_provider.id: self._current_plan_provider + } + # Phase 7 PR-4 — mode-aware comparator rebuild (mirrors __init__). + # AMBER self._amber = None - amber_enabled = new_options.get(CONF_AMBER_ENABLED) - if amber_enabled is None: - amber_enabled = bool(self.config_entry.data.get(CONF_API_KEY)) - if amber_enabled: - self._amber = AmberProvider( - amber_network_daily_c=new_options.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0), - amber_subscription_daily_c=new_options.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0), - ) - self._providers[self._amber.id] = self._amber + self._amber_static_plan = None + amber_mode = resolve_pricing_mode( + dict(new_options), dict(self.config_entry.data), + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + if amber_mode == PRICING_MODE_LIVE_API and not self.config_entry.data.get(CONF_API_KEY): + if new_options.get(CONF_AMBER_PRICING_MODE) is None: + amber_mode = PRICING_MODE_OFF + self._amber_mode = amber_mode + if amber_mode != PRICING_MODE_OFF: + if amber_mode == PRICING_MODE_STATIC_PRD: + self._amber_static_plan = new_options.get(CONF_AMBER_STATIC_PLAN) + if not self._amber_static_plan: + _LOGGER.warning( + "rebuild_engine: Amber static_prd without stored plan " + "— falling back to off." + ) + self._amber_mode = PRICING_MODE_OFF + if self._amber_mode != PRICING_MODE_OFF: + self._amber = AmberProvider( + amber_network_daily_c=new_options.get(CONF_AMBER_NETWORK_DAILY_CHARGE, 0.0), + amber_subscription_daily_c=new_options.get(CONF_AMBER_SUBSCRIPTION_FEE, 0.0), + ) + self._providers[self._amber.id] = self._amber + # FLOW POWER (static_prd deferred; falls back to live_api) self._flow_power = None - if new_options.get(CONF_FLOW_POWER_ENABLED): + fp_mode = resolve_pricing_mode( + dict(new_options), dict(self.config_entry.data), + mode_key=CONF_FLOW_POWER_PRICING_MODE, + legacy_enabled_key=CONF_FLOW_POWER_ENABLED, + ) + if fp_mode == PRICING_MODE_STATIC_PRD: + fp_mode = PRICING_MODE_LIVE_API + self._flow_power_mode = fp_mode + if fp_mode != PRICING_MODE_OFF: self._flow_power = FlowPowerProvider(new_options) self._providers[self._flow_power.id] = self._flow_power + + # LOCALVOLTS self._localvolts = None - if new_options.get(CONF_LOCALVOLTS_ENABLED): - self._localvolts = LocalVoltsProvider(new_options) - self._providers[self._localvolts.id] = self._localvolts + self._localvolts_static_plan = None + lv_mode = resolve_pricing_mode( + dict(new_options), dict(self.config_entry.data), + mode_key=CONF_LOCALVOLTS_PRICING_MODE, + legacy_enabled_key=CONF_LOCALVOLTS_ENABLED, + ) + self._localvolts_mode = lv_mode + if lv_mode != PRICING_MODE_OFF: + if lv_mode == PRICING_MODE_STATIC_PRD: + self._localvolts_static_plan = new_options.get( + CONF_LOCALVOLTS_STATIC_PLAN + ) + if not self._localvolts_static_plan: + _LOGGER.warning( + "rebuild_engine: LocalVolts static_prd without " + "stored plan — falling back to off." + ) + self._localvolts_mode = PRICING_MODE_OFF + if self._localvolts_mode != PRICING_MODE_OFF: + self._localvolts = LocalVoltsProvider(new_options) + self._providers[self._localvolts.id] = self._localvolts # Phase 3.4 — rebuild the named comparator from updated options. # Same construction as ``__init__``; absence of the option key diff --git a/custom_components/pricehawk/dashboard_config.py b/custom_components/pricehawk/dashboard_config.py index 51bbef8..46c0026 100644 --- a/custom_components/pricehawk/dashboard_config.py +++ b/custom_components/pricehawk/dashboard_config.py @@ -17,6 +17,18 @@ PANEL_TITLE = "PriceHawk" PANEL_ICON = "mdi:flash" +# Phase 10 PR-13 — Lit panel_custom (no LLAT). Registered alongside the +# iframe panel during the migration window; legacy iframe stays until +# the Lit panel reaches feature parity (follow-up Playwright UAT PR). +PANEL_V2_URL_PATH = "pricehawk" +PANEL_V2_TITLE = "PriceHawk v2" +PANEL_V2_MODULE = "pricehawk-panel" # custom element name in the JS module + +# Phase 10 PR-14 — Lovelace custom card. Auto-registered as a frontend +# resource on entry setup; appears in the "Add Card" picker. +LOVELACE_CARD_FILENAME = "pricehawk-card.js" +LOVELACE_CARD_RESOURCE_URL = "/local/pricehawk/pricehawk-card.js" + # Inline SVG icon (PriceHawk hawk logo) PRICEHAWK_ICON_SVG = """\ @@ -29,18 +41,23 @@ async def copy_www_assets(hass: HomeAssistant) -> None: - """Copy PriceHawk icon SVG and dashboard HTML to www/pricehawk/. + """Copy PriceHawk icon SVG, legacy dashboard HTML, and v2 Lit panel JS. Always overwrites to ensure the latest version is deployed. The HTML dashboard becomes accessible at /local/pricehawk/dashboard.html. + The v2 Lit panel JS becomes accessible at /local/pricehawk/pricehawk-panel.js. """ src_dir = Path(__file__).parent src_html = src_dir / "www" / "dashboard.html" + src_panel_js = src_dir / "www" / "pricehawk-panel.js" + src_card_js = src_dir / "www" / LOVELACE_CARD_FILENAME src_icon_png = src_dir / "icon.png" dest_dir = hass.config.path("www", "pricehawk") icon_svg_path = os.path.join(dest_dir, "icon.svg") icon_png_path = os.path.join(dest_dir, "icon.png") html_path = os.path.join(dest_dir, "dashboard.html") + panel_js_path = os.path.join(dest_dir, "pricehawk-panel.js") + card_js_path = os.path.join(dest_dir, LOVELACE_CARD_FILENAME) def _copy_assets() -> None: os.makedirs(dest_dir, exist_ok=True) @@ -57,6 +74,22 @@ def _copy_assets() -> None: _LOGGER.warning( "PriceHawk: dashboard.html source not found at %s", src_html ) + # Phase 10 PR-13 — copy v2 Lit panel JS. + if src_panel_js.exists(): + shutil.copy2(str(src_panel_js), panel_js_path) + else: + _LOGGER.warning( + "PriceHawk: pricehawk-panel.js source not found at %s", + src_panel_js, + ) + # Phase 10 PR-14 — copy Lovelace card JS. + if src_card_js.exists(): + shutil.copy2(str(src_card_js), card_js_path) + else: + _LOGGER.warning( + "PriceHawk: %s source not found at %s", + LOVELACE_CARD_FILENAME, src_card_js, + ) try: await hass.async_add_executor_job(_copy_assets) @@ -146,12 +179,143 @@ async def setup_panel_iframe(hass: HomeAssistant, entry: ConfigEntry) -> None: ) -async def remove_panel(hass: HomeAssistant) -> None: - """Remove the PriceHawk sidebar panel on unload.""" +async def setup_panel_custom_v2(hass: HomeAssistant) -> None: + """Phase 10 PR-13 — register Lit panel_custom (no LLAT in URL). + + Lives alongside the legacy iframe panel during the migration. Auth + runs through the host page's WebSocket session — no long-lived + access token threaded through query params. Version-busted module + URL invalidates the browser cache on every HACS upgrade. + + Per HA docs at https://developers.home-assistant.io/docs/frontend/custom-ui/registering-resources/, + ``trust_external=False`` + ``embed_iframe=False`` is the recommended + pattern for first-party panels. + """ + from homeassistant.components.frontend import ( + async_register_built_in_panel, + async_remove_panel, + ) + try: - from homeassistant.components.frontend import async_remove_panel + from homeassistant.loader import async_get_integration - async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False) - _LOGGER.info("PriceHawk: sidebar panel removed") + integration = await async_get_integration(hass, "pricehawk") + version = integration.manifest.get("version", "unknown") + except Exception: + version = "unknown" + + cache_token = f"{version}.{int(time.time())}" + module_url = f"/local/pricehawk/pricehawk-panel.js?v={cache_token}" + + # Remove existing v2 panel before re-registering (handles reload cycles). + try: + async_remove_panel(hass, PANEL_V2_URL_PATH, warn_if_unknown=False) + except Exception: + pass + + try: + async_register_built_in_panel( + hass, + component_name="custom", + sidebar_title=PANEL_V2_TITLE, + sidebar_icon=PANEL_ICON, + frontend_url_path=PANEL_V2_URL_PATH, + config={ + "_panel_custom": { + "name": PANEL_V2_MODULE, + "module_url": module_url, + "embed_iframe": False, + "trust_external": False, + } + }, + require_admin=False, + ) + _LOGGER.info( + "PriceHawk v2 panel registered at /%s -> %s", + PANEL_V2_URL_PATH, module_url, + ) except Exception: - _LOGGER.debug("PriceHawk: panel removal skipped (not registered)") + _LOGGER.error( + "PriceHawk: failed to register v2 panel_custom. " + "Legacy iframe dashboard is unaffected.", + exc_info=True, + ) + + +async def register_lovelace_card_resource(hass: HomeAssistant) -> None: + """Phase 10 PR-14 — auto-register the PriceHawk Lovelace card resource. + + Best-effort: HA's Lovelace resources API is mode-dependent + (storage vs YAML mode). Storage mode supports + ``ResourceStorageCollection.async_create_item``; YAML mode requires + the user to add the resource manually. We attempt the storage-mode + path; on failure (YAML mode, or HA version drift), log a hint + pointing at the manual-add instructions. + """ + try: + from homeassistant.components import lovelace # type: ignore # noqa: F401, PLC0415 + except Exception: + _LOGGER.info( + "PriceHawk Lovelace card: lovelace component not available; " + "skipping auto-registration. Add manually via Resources: %s", + LOVELACE_CARD_RESOURCE_URL, + ) + return + + try: + version = "1" + try: + from homeassistant.loader import async_get_integration + integration = await async_get_integration(hass, "pricehawk") + version = integration.manifest.get("version", "1") + except Exception: + pass + + resource_url = f"{LOVELACE_CARD_RESOURCE_URL}?v={version}" + # Modern HA exposes resources via hass.data["lovelace"].resources. + ll_data = hass.data.get("lovelace") + ll_resources = getattr(ll_data, "resources", None) + if ll_resources is None: + _LOGGER.info( + "PriceHawk Lovelace card: Lovelace storage not ready " + "(YAML mode?). Add resource manually: %s", resource_url, + ) + return + # Avoid duplicate registration on entry reload. + existing = [ + r for r in getattr(ll_resources, "async_items", lambda: [])() + if str(r.get("url", "")).startswith(LOVELACE_CARD_RESOURCE_URL) + ] + if existing: + _LOGGER.debug( + "PriceHawk Lovelace card: resource already registered", + ) + return + await ll_resources.async_create_item( + {"res_type": "module", "url": resource_url} + ) + _LOGGER.info( + "PriceHawk Lovelace card: resource registered at %s", + resource_url, + ) + except Exception: + _LOGGER.warning( + "PriceHawk Lovelace card: auto-register failed. Add manually " + "via Settings > Dashboards > Resources: url=%s, type=module", + LOVELACE_CARD_RESOURCE_URL, + exc_info=True, + ) + + +async def remove_panel(hass: HomeAssistant) -> None: + """Remove the PriceHawk sidebar panels on unload.""" + from homeassistant.components.frontend import async_remove_panel + + for path in (PANEL_URL_PATH, PANEL_V2_URL_PATH): + try: + async_remove_panel(hass, path, warn_if_unknown=False) + _LOGGER.info("PriceHawk: sidebar panel %s removed", path) + except Exception: + _LOGGER.debug( + "PriceHawk: panel %s removal skipped (not registered)", path, + ) diff --git a/custom_components/pricehawk/diagnostics.py b/custom_components/pricehawk/diagnostics.py new file mode 100644 index 0000000..87f95ff --- /dev/null +++ b/custom_components/pricehawk/diagnostics.py @@ -0,0 +1,132 @@ +"""Diagnostics platform for PriceHawk (Phase 8 / PR-7). + +Returns a redacted snapshot of the config entry + selected coordinator +state for the HA "Download diagnostics" button. + +Every API key and HA token field is replaced with ``**REDACTED**`` by +``async_redact_data``. The CDR plan envelope is also redacted because +it's large (~15 KB per plan) — not a secret but blows up the output +size if a user has 5+ entries. See D-P8-3. + +The output is intentionally JSON-serialisable (no datetimes, no aiohttp +session refs, no asyncio.Lock refs). Diagnostics is invoked via HA's +diagnostics REST endpoint which calls ``json.dumps`` on the result. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_API_KEY, + CONF_CDR_PLAN, + CONF_DWT_OE_API_KEY, + CONF_HA_TOKEN, + CONF_LOCALVOLTS_API_KEY, + CONF_NAMED_COMPARATOR_PLAN, +) + +TO_REDACT = { + CONF_API_KEY, + CONF_DWT_OE_API_KEY, + CONF_LOCALVOLTS_API_KEY, + CONF_HA_TOKEN, + # Plan envelopes — not secret but large (15 KB each). Drop to keep + # diagnostics output small. See D-P8-3. + CONF_CDR_PLAN, + CONF_NAMED_COMPARATOR_PLAN, + # DWT-OE / Amber / LocalVolts static plans (per-comparator PRD + # envelopes from Phase 7 PR-4 — same large-but-not-secret rationale). + "amber_static_plan", + "flow_power_static_plan", + "localvolts_static_plan", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a PriceHawk config entry.""" + del hass # not currently needed; reserved for future expansion + coordinator = getattr( + getattr(entry, "runtime_data", None), "coordinator", None + ) + + redacted_data = async_redact_data(dict(entry.data), TO_REDACT) + redacted_options = async_redact_data(dict(entry.options), TO_REDACT) + redaction_count = ( + sum(1 for k in entry.data if k in TO_REDACT) + + sum(1 for k in entry.options if k in TO_REDACT) + ) + + runtime_state: dict[str, Any] = {} + if coordinator is not None: + runtime_state = { + "amber_mode": getattr(coordinator, "_amber_mode", None), + "flow_power_mode": getattr(coordinator, "_flow_power_mode", None), + "localvolts_mode": getattr(coordinator, "_localvolts_mode", None), + "reauth_provider_id": getattr( + coordinator, "_reauth_provider_id", None + ), + "registered_provider_ids": sorted( + getattr(coordinator, "_providers", {}).keys() + ), + "wholesale_settlement": getattr( + coordinator, "_wholesale_settlement", "" + ), + "wholesale_c": getattr(coordinator, "_wholesale_c", None), + "amber_import_c": getattr(coordinator, "_amber_import_c", None), + "amber_export_c": getattr(coordinator, "_amber_export_c", None), + "saving_month_aud": getattr( + coordinator, "_saving_month_aud", None + ), + "daily_cost_history_len": len( + getattr(coordinator, "_daily_cost_history", []) or [] + ), + "ranking_last_run_at": _safe_iso( + getattr(coordinator, "_ranking_last_run_at", None) + ), + "backfill_status": getattr(coordinator, "_backfill_status", None), + } + # DWT price attribution snapshot, if a DWT provider is the + # current plan (Phase 7 PR-2b). + dwt = getattr(coordinator, "_dwt_provider", None) + if dwt is not None: + last_price = getattr(dwt, "last_price", None) + runtime_state["dwt"] = { + "region": getattr(dwt, "region", None), + "last_price_aud_per_mwh": ( + last_price.price_aud_per_mwh if last_price else None + ), + "last_price_interval_end_utc": ( + last_price.interval_end_utc.isoformat() + if last_price + else None + ), + "attribution": ( + last_price.attribution if last_price else None + ), + } + + return { + "entry_id": entry.entry_id, + "entry_data": redacted_data, + "entry_options": redacted_options, + "runtime_state": runtime_state, + "_redaction_count": redaction_count, + } + + +def _safe_iso(value: Any) -> str | None: + """ISO-format a datetime if present; else return None.""" + if value is None: + return None + isoformat = getattr(value, "isoformat", None) + if not callable(isoformat): + return None + result = isoformat() + return result if isinstance(result, str) else None diff --git a/custom_components/pricehawk/manifest.json b/custom_components/pricehawk/manifest.json index 5ec37b9..9b7dede 100644 --- a/custom_components/pricehawk/manifest.json +++ b/custom_components/pricehawk/manifest.json @@ -9,6 +9,7 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/Artic0din/ha-pricehawk/issues", + "quality_scale": "silver", "requirements": ["openelectricity>=0.10.1,<0.11"], - "version": "1.5.0-beta.2" + "version": "1.6.0-beta.1" } diff --git a/custom_components/pricehawk/providers/__init__.py b/custom_components/pricehawk/providers/__init__.py index 8d16279..056bdd9 100644 --- a/custom_components/pricehawk/providers/__init__.py +++ b/custom_components/pricehawk/providers/__init__.py @@ -11,12 +11,14 @@ from .amber import AmberProvider from .base import Provider from .cdr_plan import CdrPlanProvider +from .dynamic_wholesale_tariff import DynamicWholesaleTariffProvider from .flow_power import FlowPowerProvider from .localvolts import LocalVoltsProvider __all__ = [ "AmberProvider", "CdrPlanProvider", + "DynamicWholesaleTariffProvider", "FlowPowerProvider", "LocalVoltsProvider", "Provider", diff --git a/custom_components/pricehawk/providers/dynamic_wholesale_tariff.py b/custom_components/pricehawk/providers/dynamic_wholesale_tariff.py new file mode 100644 index 0000000..461618b --- /dev/null +++ b/custom_components/pricehawk/providers/dynamic_wholesale_tariff.py @@ -0,0 +1,321 @@ +"""Dynamic Wholesale Tariff provider — Phase 7 / PR-2b. + +ONE Provider class behind TWO config-flow entries: +- ``PROVIDER_DWT_OE`` → ``OpenElectricityPriceSource`` (API key required) +- ``PROVIDER_DWT_AEMO`` → ``NEMWebPriceSource`` (no key, NEM-only) + +Self-priced: ``set_current_rates`` is a no-op. The coordinator owns the async +refresh loop and pushes ``WholesalePrice`` results in via ``set_live_price``. +``update()`` stays sync (matches Amber / Flow Power Protocol contract). + +AEGIS invariants honoured: +- ``from_dict`` validates ``STATE_VERSION`` and requires an explicit HA-tz + ``today`` — no ``date.today()`` fallback (07-02b-AUDIT.md M5/M6). +- Negative wholesale prices honour sign discipline matching AmberProvider + (positive ``export_earnings`` = user receives money). +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, timezone +from typing import Any, Final + +from .openelectricity import OpenElectricityPriceSource, WholesalePrice +from .nemweb import NEMWebPriceSource + +_LOGGER = logging.getLogger(__name__) + +STATE_VERSION: Final[int] = 1 + + +class DynamicWholesaleTariffProvider: + """Provider that prices grid kWh against the latest 5-min wholesale dispatch. + + Backed by either ``OpenElectricityPriceSource`` (DWT-OE) or + ``NEMWebPriceSource`` (DWT-AEMO). The coordinator selects which at entry + setup; this class only knows it has a ``WholesalePriceSource``-shaped + object with ``fetch_current_price(region)`` and ``last_good(region)``. + """ + + def __init__( + self, + *, + price_source: OpenElectricityPriceSource | NEMWebPriceSource, + region: str, + daily_supply_c: float, + provider_id: str, + name: str, + ) -> None: + self.id = provider_id + self.name = name + self._price_source = price_source + self._region = region + self._daily_supply_c = float(daily_supply_c) + + # Latest price pushed via set_live_price. None until first refresh. + self._last_price: WholesalePrice | None = None + + # Accumulators (reset_daily zeros these). + self._import_kwh_today: float = 0.0 + self._export_kwh_today: float = 0.0 + self._import_cost_today_c: float = 0.0 + self._export_earnings_today_c: float = 0.0 + + # Tick bookkeeping. + self._last_tick: datetime | None = None + + # Log the "no price yet" WARNING once per UTC day, not every tick. + self._no_price_warned_for_utc_date: date | None = None + + # ---- Provider Protocol ------------------------------------------------ + + def update(self, grid_power_w: float, now_local: datetime) -> None: + """Accumulate kWh and cost from this tick. + + Sync — matches Amber/Flow Power. The async fetch happens in a + coordinator-driven coroutine that calls ``set_live_price`` before + this method runs. + """ + last_tick = self._last_tick + self._last_tick = now_local + + if last_tick is None: + return # Need a previous tick to compute dt. + + if self._last_price is None: + self._warn_no_price_once(now_local) + return + + dt_seconds = (now_local - last_tick).total_seconds() + if dt_seconds <= 0: + return + + wholesale_c_per_kwh = self._last_price.price_aud_per_mwh / 10.0 + kwh = abs(grid_power_w) / 1000.0 * (dt_seconds / 3600.0) + delta_c = kwh * wholesale_c_per_kwh + + if grid_power_w >= 0: + # Import: cost flows from user. Negative wholesale price means + # the importer earns; delta_c will already be negative. + self._import_kwh_today += kwh + self._import_cost_today_c += delta_c + else: + # Export: earnings flow to user. Negative wholesale = exporter + # PAYS; delta_c is negative → export_earnings_today_c decreases. + self._export_kwh_today += kwh + self._export_earnings_today_c += delta_c + + def set_current_rates( + self, import_c_kwh: float | None, export_c_kwh: float | None + ) -> None: + """No-op — self-priced provider. Rates come from set_live_price.""" + del import_c_kwh, export_c_kwh + return + + def reset_daily(self) -> None: + self._import_kwh_today = 0.0 + self._export_kwh_today = 0.0 + self._import_cost_today_c = 0.0 + self._export_earnings_today_c = 0.0 + # Keep _last_price + _last_tick; price survives midnight. + self._no_price_warned_for_utc_date = None + + @property + def current_import_rate_c_kwh(self) -> float: + if self._last_price is None: + return 0.0 + return self._last_price.price_aud_per_mwh / 10.0 + + @property + def current_export_rate_c_kwh(self) -> float: + if self._last_price is None: + return 0.0 + return self._last_price.price_aud_per_mwh / 10.0 + + @property + def import_kwh_today(self) -> float: + return self._import_kwh_today + + @property + def export_kwh_today(self) -> float: + return self._export_kwh_today + + @property + def import_cost_today_c(self) -> float: + return self._import_cost_today_c + + @property + def export_earnings_today_c(self) -> float: + return self._export_earnings_today_c + + @property + def daily_fixed_charges_aud(self) -> float: + return self._daily_supply_c / 100.0 + + @property + def net_daily_cost_aud(self) -> float: + return ( + self._import_cost_today_c / 100.0 + - self._export_earnings_today_c / 100.0 + + self.daily_fixed_charges_aud + ) + + @property + def extras(self) -> dict[str, Any]: + if self._last_price is None: + return { + "attribution": None, + "region": self._region, + "wholesale_price_aud_per_mwh": None, + "wholesale_price_interval_end_utc": None, + "wholesale_price_age_seconds": None, + "daily_supply_aud": self.daily_fixed_charges_aud, + } + age = max( + 0, + int( + ( + datetime.now(tz=timezone.utc) + - self._last_price.interval_end_utc + ).total_seconds() + ), + ) + return { + "attribution": self._last_price.attribution, + "region": self._region, + "wholesale_price_aud_per_mwh": self._last_price.price_aud_per_mwh, + "wholesale_price_interval_end_utc": ( + self._last_price.interval_end_utc.isoformat() + ), + "wholesale_price_age_seconds": age, + "daily_supply_aud": self.daily_fixed_charges_aud, + } + + def to_dict(self) -> dict[str, Any]: + return { + "version": STATE_VERSION, + "provider_id": self.id, + "region": self._region, + "daily_supply_c": self._daily_supply_c, + "import_kwh_today": self._import_kwh_today, + "export_kwh_today": self._export_kwh_today, + "import_cost_today_c": self._import_cost_today_c, + "export_earnings_today_c": self._export_earnings_today_c, + "last_tick_iso": ( + self._last_tick.isoformat() if self._last_tick else None + ), + "last_price": ( + { + "price_aud_per_mwh": self._last_price.price_aud_per_mwh, + "interval_end_utc": ( + self._last_price.interval_end_utc.isoformat() + ), + "region": self._last_price.region, + "attribution": self._last_price.attribution, + } + if self._last_price + else None + ), + } + + def from_dict( + self, data: dict[str, Any], today: date | None = None + ) -> None: + """Restore daily accumulators from a stored state dict. + + ``today`` MUST be a ``datetime.date`` in the HA-configured timezone + (AEGIS rule — no ``date.today()`` fallback). ``data["version"]`` is + validated against ``STATE_VERSION``. + """ + if today is None: + raise TypeError( + "from_dict(today=) is required and must be a datetime.date " + "in the HA-configured timezone (no date.today() fallback)." + ) + del today # explicit HA-tz date is contract-required; not used here. + stored_version = data.get("version") + if stored_version != STATE_VERSION: + raise ValueError( + f"DWT state version {stored_version!r} not supported; " + f"current is {STATE_VERSION}. Manual migration required." + ) + + self._import_kwh_today = float(data.get("import_kwh_today", 0.0)) + self._export_kwh_today = float(data.get("export_kwh_today", 0.0)) + self._import_cost_today_c = float(data.get("import_cost_today_c", 0.0)) + self._export_earnings_today_c = float( + data.get("export_earnings_today_c", 0.0) + ) + + last_tick_iso = data.get("last_tick_iso") + if last_tick_iso: + try: + self._last_tick = datetime.fromisoformat(last_tick_iso) + except ValueError: + self._last_tick = None + else: + self._last_tick = None + + last_price = data.get("last_price") + if last_price: + try: + self._last_price = WholesalePrice( + price_aud_per_mwh=float(last_price["price_aud_per_mwh"]), + interval_end_utc=datetime.fromisoformat( + last_price["interval_end_utc"] + ), + region=str(last_price["region"]), + attribution=str(last_price["attribution"]), + ) + except (KeyError, TypeError, ValueError) as exc: + _LOGGER.warning( + "DWT restore: discarding malformed last_price (%s)", exc + ) + self._last_price = None + + # ---- Public API used by the coordinator ------------------------------- + + @property + def price_source(self) -> OpenElectricityPriceSource | NEMWebPriceSource: + return self._price_source + + @property + def region(self) -> str: + return self._region + + @property + def last_price(self) -> WholesalePrice | None: + return self._last_price + + def set_live_price(self, price: WholesalePrice) -> None: + """Push the latest wholesale price into the provider. + + Idempotent on the same ``(region, interval_end_utc)`` tuple — setting + the same price twice is a no-op (no log spam). + """ + if ( + self._last_price is not None + and self._last_price.region == price.region + and self._last_price.interval_end_utc == price.interval_end_utc + ): + return + self._last_price = price + + # ---- Helpers ---------------------------------------------------------- + + def _warn_no_price_once(self, now_local: datetime) -> None: + utc_today = ( + now_local.astimezone(timezone.utc).date() + if now_local.tzinfo is not None + else datetime.now(tz=timezone.utc).date() + ) + if self._no_price_warned_for_utc_date == utc_today: + return + self._no_price_warned_for_utc_date = utc_today + _LOGGER.warning( + "DWT provider %s has no wholesale price yet for region %s; " + "skipping cost accumulation until first refresh succeeds.", + self.id, + self._region, + ) 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/static_pricing.py b/custom_components/pricehawk/static_pricing.py new file mode 100644 index 0000000..eccfe4e --- /dev/null +++ b/custom_components/pricehawk/static_pricing.py @@ -0,0 +1,151 @@ +"""Static-PRD pricing helpers — Phase 7 / PR-4. + +Phase 7 PR-4 introduces a per-comparator opt-in between three modes: + +- ``off`` — provider not registered. +- ``live_api`` — REST/WebSocket poll using user-supplied API key. +- ``static_prd`` — rates derived from a chosen CDR PlanDetailV2 envelope + for the retailer; no API hit. + +This module exposes two pure helpers: + +- :func:`resolve_pricing_mode` — back-compat-aware mode resolver. +- :func:`evaluate_static_rates` — current-clock-time rate lookup against + a PRD ``tariffPeriod`` (delegates to ``cdr.evaluator`` window helpers + so there's a single source of truth for window matching). + +Both are sync — they're called from the coordinator's sync tick path. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from .const import ( + ALL_PRICING_MODES, + PRICING_MODE_LIVE_API, + PRICING_MODE_OFF, +) + +# inc-GST conversion: PRD rates are ex-GST $/kWh. Convert to inc-GST c/kWh +# by multiplying by 1.10 (GST) * 100 (dollars → cents). Matches the +# convention used by cdr.streaming.current_import_rate_c_kwh. +_GST_MULTIPLIER = Decimal("1.10") +_CENTS_PER_DOLLAR = Decimal("100") + + +def resolve_pricing_mode( + options: dict[str, Any], + data: dict[str, Any], + *, + mode_key: str, + legacy_enabled_key: str, +) -> str: + """Resolve a comparator's current pricing mode with legacy back-compat. + + Resolution order: + 1. ``options[mode_key]`` if present AND in :data:`ALL_PRICING_MODES`. + 2. Legacy: truthy ``options[legacy_enabled_key]`` OR + ``data[legacy_enabled_key]`` → :data:`PRICING_MODE_LIVE_API`. + 3. Default → :data:`PRICING_MODE_OFF`. + + No write-back migration — callers read the mode every time. Existing + Phase 2.x entries with ``CONF_

_ENABLED=True`` continue working as + live_api comparators until the user re-runs OptionsFlow. + """ + explicit = options.get(mode_key) + if explicit in ALL_PRICING_MODES: + return explicit + + legacy = options.get(legacy_enabled_key, data.get(legacy_enabled_key)) + if legacy: + return PRICING_MODE_LIVE_API + return PRICING_MODE_OFF + + +def evaluate_static_rates( + plan_envelope: dict[str, Any] | None, + now_local: datetime, +) -> tuple[float, float]: + """Derive ``(import_c_kwh, export_c_kwh)`` from a PRD PlanDetailV2 envelope. + + Returns inc-GST c/kWh rates (matching :class:`CdrPlanProvider`'s + convention). Falls back to ``(0.0, 0.0)`` if: + + - ``plan_envelope`` is ``None`` or empty. + - No ``electricityContract.tariffPeriod`` is present. + - No TOU window matches ``now_local``. + + Static-PRD pricing reflects the FIRST tier of stepped-pricing plans + (``singleRate`` rates[0].unitPrice); accurate per-tier stepping needs + live_api mode which tracks per-tick daily kWh against the threshold. + Lossiness is documented; users wanting precise stepped math must opt + into live_api. + """ + if not plan_envelope: + return (0.0, 0.0) + + plan_data = plan_envelope.get("data", plan_envelope) + elec = plan_data.get("electricityContract", {}) or {} + tps = elec.get("tariffPeriod", []) or [] + if not tps: + return (0.0, 0.0) + + import_ex = _import_rate_ex_gst(tps[0], now_local) + export_ex = _export_rate_ex_gst(elec, now_local) + + return ( + float(import_ex * _GST_MULTIPLIER * _CENTS_PER_DOLLAR), + float(export_ex * _GST_MULTIPLIER * _CENTS_PER_DOLLAR), + ) + + +def _import_rate_ex_gst( + tariff_period: dict[str, Any], now_local: datetime +) -> Decimal: + """Return ex-GST $/kWh import rate for ``now_local`` from one tariffPeriod.""" + # Local import — avoid a top-level cycle (cdr/* may evolve independently). + from .cdr.evaluator import _resolve_tou_rate # noqa: PLC0415 + + if tariff_period.get("rateBlockUType") == "singleRate": + rates = (tariff_period.get("singleRate") or {}).get("rates", []) or [] + return Decimal(str(rates[0].get("unitPrice", 0))) if rates else Decimal("0") + + tou_rates = tariff_period.get("timeOfUseRates", []) or [] + entry = _resolve_tou_rate(now_local, tou_rates) + if not entry: + return Decimal("0") + rates = entry.get("rates", []) or [] + return Decimal(str(rates[0].get("unitPrice", 0))) if rates else Decimal("0") + + +def _export_rate_ex_gst( + electricity_contract: dict[str, Any], now_local: datetime +) -> Decimal: + """Return ex-GST $/kWh export rate for ``now_local`` from electricityContract.""" + from .cdr.evaluator import slot_in_window # noqa: PLC0415 + + fits = electricity_contract.get("solarFeedInTariff", []) or [] + for fit in fits: + utype = fit.get("tariffUType") + if utype == "timeVaryingTariffs": + for tvt in fit.get("timeVaryingTariffs") or []: + for tv in tvt.get("timeVariations") or []: + if slot_in_window( + now_local, + tv.get("days", []), + tv.get("startTime", "00:00"), + tv.get("endTime", "23:59"), + ): + rates = tvt.get("rates", []) or [] + if rates: + return Decimal(str(rates[0].get("unitPrice", 0))) + elif utype == "singleTariff": + st = fit.get("singleTariff") or {} + rates = st.get("rates", []) or [] + if rates: + return Decimal(str(rates[0].get("unitPrice", 0))) + + return Decimal("0") 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 f7a7656..ffcb690 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -38,6 +38,77 @@ "localvolts_daily_supply": "Daily supply charge (c/day)" } }, + "dwt_credentials": { + "title": "OpenElectricity API key", + "description": "Dynamic Wholesale Tariff — OpenElectricity. Get an API key from platform.openelectricity.org.au (currently waitlisted). Region selects the NEM zone or WEM (WA). Daily supply charge is the c/day standing fee from your network distributor.", + "data": { + "dwt_oe_api_key": "API key", + "dwt_region": "Region", + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "dwt_aemo_setup": { + "title": "AEMO Direct Wholesale Setup", + "description": "Dynamic Wholesale Tariff — AEMO Direct. No API key required: PriceHawk fetches 5-minute dispatch prices directly from AEMO's public NEMWeb feed. NEM-only — WEM users must pick the OpenElectricity option instead.", + "data": { + "dwt_region": "Region", + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, + "reauth_amber": { + "title": "Re-enter Amber API key", + "description": "Your previous Amber API key was rejected. Get a new key from the Amber app under Profile > Developer > Generate API Key, then paste it below.", + "data": { + "api_key": "Amber API key" + } + }, + "reauth_localvolts": { + "title": "Re-enter LocalVolts credentials", + "description": "Your previous LocalVolts credentials were rejected. Any of API key, Partner ID, or NMI may have rotated — re-enter all three from your LocalVolts customer dashboard.", + "data": { + "localvolts_api_key": "API key", + "localvolts_partner_id": "Partner ID", + "localvolts_nmi": "NMI" + } + }, + "reauth_dwt_oe": { + "title": "Re-enter OpenElectricity API key", + "description": "Your previous OpenElectricity API key was rejected. Get a new key from platform.openelectricity.org.au (region {region} is unchanged).", + "data": { + "dwt_oe_api_key": "API key" + } + }, + "reconfigure_amber": { + "title": "Adjust Amber fees", + "description": "Edit Amber's network and subscription daily charges. API key + site are unchanged — use Reauthorize to rotate the API key.", + "data": { + "amber_network_daily_charge": "Network daily charge (c/day)", + "amber_subscription_fee": "Subscription daily fee (c/day)" + } + }, + "reconfigure_localvolts": { + "title": "Adjust LocalVolts settings", + "description": "Edit daily supply + buy ceiling / sell floor guard rails. Credentials are unchanged — use Reauthorize to rotate them.", + "data": { + "localvolts_daily_supply": "Daily supply charge (c/day)", + "localvolts_buy_ceiling": "Buy ceiling (c/kWh, 0 = no cap)", + "localvolts_sell_floor": "Sell floor (c/kWh, 0 = no floor)" + } + }, + "reconfigure_dwt_oe": { + "title": "Adjust Dynamic Wholesale Tariff (OpenElectricity)", + "description": "Edit the daily supply charge. Region + API key are unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "reconfigure_dwt_aemo": { + "title": "Adjust Dynamic Wholesale Tariff (AEMO Direct)", + "description": "Edit the daily supply charge. Region is unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, "site_select": { "title": "Select Amber Site", "description": "Your Amber account has multiple sites. Select the one to use for price comparison.", @@ -223,10 +294,26 @@ "cdr_detail_unavailable": "Could not fetch the chosen plan's details. The planId may be stale, or the data holder is rate-limiting.", "cdr_empty_unavailable": "This retailer's CDR list returned no residential electricity plans. Pick a different retailer.", "cdr_invalid_postcode": "Not a valid Australian postcode. Use a 4-digit number, or pick a state from the dropdown instead.", - "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer." + "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer.", + "invalid_api_key": "Could not authenticate with OpenElectricity. Check the API key from platform.openelectricity.org.au.", + "invalid_credentials": "Could not authenticate with LocalVolts. Check the API key, Partner ID, and NMI all match your LocalVolts customer dashboard." }, "abort": { - "already_configured": "PriceHawk is already configured." + "already_configured": "PriceHawk is already configured.", + "reauth_provider_unknown": "PriceHawk could not determine which provider needs reauthentication. Remove and re-add the integration.", + "reauth_successful": "Credentials updated. PriceHawk has resumed polling.", + "reconfigure_unsupported": "PriceHawk reconfigure is not yet available for this entry type. Use the integration's Configure menu instead.", + "reconfigure_successful": "Settings updated." + } + }, + "issues": { + "grid_sensor_unavailable": { + "title": "Grid power sensor unavailable", + "description": "PriceHawk's configured grid power sensor `{entity_id}` has been unavailable for more than 5 minutes. Without grid power, PriceHawk cannot accumulate cost or savings. Check that the entity exists and is reporting values, then reload the integration." + }, + "ranking_stale": { + "title": "CDR plan ranking is stale", + "description": "The nightly CDR plan ranking job hasn't completed successfully in over {hours} hours. The ranked-alternatives list may be outdated. Use the `pricehawk.rank_alternatives` service to trigger a fresh run." } }, "options": { @@ -248,11 +335,11 @@ }, "comparators": { "title": "Comparator providers + opt-in fields", - "description": "Toggle which alternative providers PriceHawk evaluates against your current plan. Opt-in fields drive incentive math the integration can't infer from CDR data alone.", + "description": "Pick a pricing mode for each alternative provider: off (not registered), live_api (poll the retailer's live API with your key), or static_prd (use a stored CDR plan envelope — no API hit). Static_prd mode requires a pre-stored CDR plan envelope; full UI for picking the plan ships in Phase 8. Flow Power static_prd falls back to live_api for now.", "data": { - "amber_enabled": "Amber Electric (live API)", - "flow_power_enabled": "Flow Power (live API)", - "localvolts_enabled": "LocalVolts (live API)", + "amber_pricing_mode": "Amber pricing mode", + "flow_power_pricing_mode": "Flow Power pricing mode", + "localvolts_pricing_mode": "LocalVolts pricing mode", "ovo_interest_balance_aud": "OVO credit balance ($AUD) — drives 3% interest credit", "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" } diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index f7a7656..ffcb690 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -38,6 +38,77 @@ "localvolts_daily_supply": "Daily supply charge (c/day)" } }, + "dwt_credentials": { + "title": "OpenElectricity API key", + "description": "Dynamic Wholesale Tariff — OpenElectricity. Get an API key from platform.openelectricity.org.au (currently waitlisted). Region selects the NEM zone or WEM (WA). Daily supply charge is the c/day standing fee from your network distributor.", + "data": { + "dwt_oe_api_key": "API key", + "dwt_region": "Region", + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "dwt_aemo_setup": { + "title": "AEMO Direct Wholesale Setup", + "description": "Dynamic Wholesale Tariff — AEMO Direct. No API key required: PriceHawk fetches 5-minute dispatch prices directly from AEMO's public NEMWeb feed. NEM-only — WEM users must pick the OpenElectricity option instead.", + "data": { + "dwt_region": "Region", + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, + "reauth_amber": { + "title": "Re-enter Amber API key", + "description": "Your previous Amber API key was rejected. Get a new key from the Amber app under Profile > Developer > Generate API Key, then paste it below.", + "data": { + "api_key": "Amber API key" + } + }, + "reauth_localvolts": { + "title": "Re-enter LocalVolts credentials", + "description": "Your previous LocalVolts credentials were rejected. Any of API key, Partner ID, or NMI may have rotated — re-enter all three from your LocalVolts customer dashboard.", + "data": { + "localvolts_api_key": "API key", + "localvolts_partner_id": "Partner ID", + "localvolts_nmi": "NMI" + } + }, + "reauth_dwt_oe": { + "title": "Re-enter OpenElectricity API key", + "description": "Your previous OpenElectricity API key was rejected. Get a new key from platform.openelectricity.org.au (region {region} is unchanged).", + "data": { + "dwt_oe_api_key": "API key" + } + }, + "reconfigure_amber": { + "title": "Adjust Amber fees", + "description": "Edit Amber's network and subscription daily charges. API key + site are unchanged — use Reauthorize to rotate the API key.", + "data": { + "amber_network_daily_charge": "Network daily charge (c/day)", + "amber_subscription_fee": "Subscription daily fee (c/day)" + } + }, + "reconfigure_localvolts": { + "title": "Adjust LocalVolts settings", + "description": "Edit daily supply + buy ceiling / sell floor guard rails. Credentials are unchanged — use Reauthorize to rotate them.", + "data": { + "localvolts_daily_supply": "Daily supply charge (c/day)", + "localvolts_buy_ceiling": "Buy ceiling (c/kWh, 0 = no cap)", + "localvolts_sell_floor": "Sell floor (c/kWh, 0 = no floor)" + } + }, + "reconfigure_dwt_oe": { + "title": "Adjust Dynamic Wholesale Tariff (OpenElectricity)", + "description": "Edit the daily supply charge. Region + API key are unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_oe_daily_supply": "Daily supply charge (c/day)" + } + }, + "reconfigure_dwt_aemo": { + "title": "Adjust Dynamic Wholesale Tariff (AEMO Direct)", + "description": "Edit the daily supply charge. Region is unchanged. Region swap requires re-adding the integration (entry unique-id is region-derived).", + "data": { + "dwt_aemo_daily_supply": "Daily supply charge (c/day)" + } + }, "site_select": { "title": "Select Amber Site", "description": "Your Amber account has multiple sites. Select the one to use for price comparison.", @@ -223,10 +294,26 @@ "cdr_detail_unavailable": "Could not fetch the chosen plan's details. The planId may be stale, or the data holder is rate-limiting.", "cdr_empty_unavailable": "This retailer's CDR list returned no residential electricity plans. Pick a different retailer.", "cdr_invalid_postcode": "Not a valid Australian postcode. Use a 4-digit number, or pick a state from the dropdown instead.", - "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer." + "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer.", + "invalid_api_key": "Could not authenticate with OpenElectricity. Check the API key from platform.openelectricity.org.au.", + "invalid_credentials": "Could not authenticate with LocalVolts. Check the API key, Partner ID, and NMI all match your LocalVolts customer dashboard." }, "abort": { - "already_configured": "PriceHawk is already configured." + "already_configured": "PriceHawk is already configured.", + "reauth_provider_unknown": "PriceHawk could not determine which provider needs reauthentication. Remove and re-add the integration.", + "reauth_successful": "Credentials updated. PriceHawk has resumed polling.", + "reconfigure_unsupported": "PriceHawk reconfigure is not yet available for this entry type. Use the integration's Configure menu instead.", + "reconfigure_successful": "Settings updated." + } + }, + "issues": { + "grid_sensor_unavailable": { + "title": "Grid power sensor unavailable", + "description": "PriceHawk's configured grid power sensor `{entity_id}` has been unavailable for more than 5 minutes. Without grid power, PriceHawk cannot accumulate cost or savings. Check that the entity exists and is reporting values, then reload the integration." + }, + "ranking_stale": { + "title": "CDR plan ranking is stale", + "description": "The nightly CDR plan ranking job hasn't completed successfully in over {hours} hours. The ranked-alternatives list may be outdated. Use the `pricehawk.rank_alternatives` service to trigger a fresh run." } }, "options": { @@ -248,11 +335,11 @@ }, "comparators": { "title": "Comparator providers + opt-in fields", - "description": "Toggle which alternative providers PriceHawk evaluates against your current plan. Opt-in fields drive incentive math the integration can't infer from CDR data alone.", + "description": "Pick a pricing mode for each alternative provider: off (not registered), live_api (poll the retailer's live API with your key), or static_prd (use a stored CDR plan envelope — no API hit). Static_prd mode requires a pre-stored CDR plan envelope; full UI for picking the plan ships in Phase 8. Flow Power static_prd falls back to live_api for now.", "data": { - "amber_enabled": "Amber Electric (live API)", - "flow_power_enabled": "Flow Power (live API)", - "localvolts_enabled": "LocalVolts (live API)", + "amber_pricing_mode": "Amber pricing mode", + "flow_power_pricing_mode": "Flow Power pricing mode", + "localvolts_pricing_mode": "LocalVolts pricing mode", "ovo_interest_balance_aud": "OVO credit balance ($AUD) — drives 3% interest credit", "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" } diff --git a/custom_components/pricehawk/www/pricehawk-card.js b/custom_components/pricehawk/www/pricehawk-card.js new file mode 100644 index 0000000..3c39c03 --- /dev/null +++ b/custom_components/pricehawk/www/pricehawk-card.js @@ -0,0 +1,138 @@ +/** + * PriceHawk Lovelace card — Phase 10 PR-14. + * + * Companion to the v2 panel (PR-13). Reads the chosen-plan cost + * sensor introduced in Phase 9 PR-11 and renders a compact card the + * user can drop into any Lovelace dashboard. + * + * Resource auto-registered via `homeassistant.components.frontend + * .async_register_resource` on entry setup — no manual "Add resource" + * step required. + */ + +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@4.2.0/lit-element.js?module"; + +class PriceHawkCostCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + _config: { type: Object, state: true }, + }; + } + + static get styles() { + return css` + :host { + display: block; + } + .card { + background: var(--card-background-color, #fff); + border-radius: 12px; + padding: 16px; + box-shadow: var( + --ha-card-box-shadow, + 0 1px 2px rgba(0, 0, 0, 0.05) + ); + } + .title { + font-size: 0.85rem; + color: var(--secondary-text-color, #757575); + margin-bottom: 4px; + } + .cost { + font-size: 1.8rem; + font-weight: 600; + color: var(--primary-color, #ff8c00); + } + .savings { + font-size: 0.9rem; + color: var(--secondary-text-color, #757575); + margin-top: 8px; + } + .pos { + color: var(--success-color, #2e7d32); + } + .neg { + color: var(--error-color, #c62828); + } + `; + } + + setConfig(config) { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = { + entity: config.entity || "sensor.pricehawk_today_cost", + savings_entity: config.savings_entity || "sensor.pricehawk_saving_today", + title: config.title || "PriceHawk today", + }; + } + + getCardSize() { + return 2; + } + + static getStubConfig() { + return { + type: "custom:pricehawk-cost-card", + title: "PriceHawk today", + entity: "sensor.pricehawk_today_cost", + savings_entity: "sensor.pricehawk_saving_today", + }; + } + + render() { + if (!this.hass || !this._config) { + return html`

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

PriceHawk v2

+

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

+
+ +
+
+

Today's cost (chosen plan)

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

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

+
+ +
+

Today's saving

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

vs. the cheapest comparator.

+
+ +
+

Best provider today

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

+ Lowest projected daily cost across registered providers. +

+
+
+ +
+

Coming in v2

+

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

+

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

+
+ `; + } +} + +if (!customElements.get("pricehawk-panel")) { + customElements.define("pricehawk-panel", PriceHawkPanel); +} diff --git a/requirements.txt b/requirements.txt index 52317ef..821b104 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,10 @@ mypy>=1.10.0 bandit>=1.7.0 pytest>=8.0.0 pytest-cov>=5.0.0 + +# Phase 11 PR-16/PR-18: HA test harness + property-based testing. +# Optional — the 1028 stub-conftest tests don't need these (per D-P11-1 +# dual-mode test strategy). Installed in CI for the new HA-harness + +# Hypothesis test files. +pytest-homeassistant-custom-component>=0.13.0 +hypothesis>=6.100.0 diff --git a/tests/conftest.py b/tests/conftest.py index f39e50d..cdf454b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,8 @@ def __init__(self, *args, **kwargs): "homeassistant.components": _MockModule(), "homeassistant.components.recorder": _MockModule(), "homeassistant.components.recorder.history": _MockModule(), + "homeassistant.components.recorder.statistics": _MockModule(), + "homeassistant.components.diagnostics": _MockModule(), } # Wire parent -> child so attribute access also works @@ -50,6 +52,54 @@ def __init__(self, *args, **kwargs): _mods["homeassistant"].components = _mods["homeassistant.components"] _mods["homeassistant.components"].recorder = _mods["homeassistant.components.recorder"] _mods["homeassistant.components.recorder"].history = _mods["homeassistant.components.recorder.history"] +_mods["homeassistant.components.recorder"].statistics = _mods["homeassistant.components.recorder.statistics"] +_mods["homeassistant.components"].diagnostics = _mods["homeassistant.components.diagnostics"] + +# Phase 9 PR-10: stub StatisticData / StatisticMetaData as plain dicts + +# async_add_external_statistics as an observable recorder. +_stats_mod = _mods["homeassistant.components.recorder.statistics"] +def _StatisticData(**kwargs): # noqa: N802 — mirrors HA typed dict name + return dict(kwargs) +def _StatisticMetaData(**kwargs): # noqa: N802 + return dict(kwargs) +_stats_mod.StatisticData = _StatisticData +_stats_mod.StatisticMetaData = _StatisticMetaData +_stats_mod._calls = [] # (metadata, stats_list) tuples observable by tests +def _async_add_external_statistics(hass, metadata, stats): # noqa: ARG001 + _stats_mod._calls.append((metadata, list(stats))) +_stats_mod.async_add_external_statistics = _async_add_external_statistics + +# Phase 8 PR-7: async_redact_data behaviour needed at test time. Real +# HA impl walks the dict and replaces values for keys in TO_REDACT. +def _async_redact_data(data, to_redact): # pragma: no cover — test helper + if isinstance(data, dict): + return { + k: ("**REDACTED**" if k in to_redact + else _async_redact_data(v, to_redact)) + for k, v in data.items() + } + if isinstance(data, list): + return [_async_redact_data(item, to_redact) for item in data] + return data +_mods["homeassistant.components.diagnostics"].async_redact_data = _async_redact_data + +# Phase 8 PR-8: stub homeassistant.helpers.issue_registry with create/delete +# recorders so tests can observe repair-issue toggles. +_issue_registry = _MockModule() +_issue_registry.IssueSeverity = type( + "IssueSeverity", (), {"WARNING": "warning", "ERROR": "error"} +) +_issue_registry._created = {} # (domain, issue_id) → kwargs +_issue_registry._deleted = [] +def _async_create_issue(hass, domain, issue_id, **kwargs): # noqa: ARG001 + _issue_registry._created[(domain, issue_id)] = kwargs +def _async_delete_issue(hass, domain, issue_id): # noqa: ARG001 + _issue_registry._deleted.append((domain, issue_id)) + _issue_registry._created.pop((domain, issue_id), None) +_issue_registry.async_create_issue = _async_create_issue +_issue_registry.async_delete_issue = _async_delete_issue +_mods["homeassistant.helpers"].issue_registry = _issue_registry +sys.modules["homeassistant.helpers.issue_registry"] = _issue_registry # Provide a CALLBACK_TYPE that's usable as a type annotation _mods["homeassistant.core"].CALLBACK_TYPE = type(None) @@ -62,6 +112,15 @@ def __init__(self, *args, **kwargs): _mods["homeassistant.exceptions"].ConfigEntryAuthFailed = type( "ConfigEntryAuthFailed", (Exception,), {} ) +# Phase 8 PR-9 (HA Silver) — action-exceptions rule. +_mods["homeassistant.exceptions"].HomeAssistantError = type( + "HomeAssistantError", (Exception,), {} +) +_mods["homeassistant.exceptions"].ServiceValidationError = type( + "ServiceValidationError", + (_mods["homeassistant.exceptions"].HomeAssistantError,), + {}, +) _mods["homeassistant"].exceptions = _mods["homeassistant.exceptions"] for name, mod in _mods.items(): diff --git a/tests/ha_fixtures.py b/tests/ha_fixtures.py new file mode 100644 index 0000000..f7ec160 --- /dev/null +++ b/tests/ha_fixtures.py @@ -0,0 +1,128 @@ +"""Phase 11 PR-16 — HA test-harness fixture prototypes. + +These are PROTOTYPE fixtures for the future migration to +``pytest-homeassistant-custom-component``. They're NOT auto-applied; +tests that want the real HA harness import them explicitly. The +existing 1028 stub-conftest tests stay HA-free per D-P11-1. + +Migration path: as each existing test module gets touched in a future +PR, replace its imports + bring in these fixtures. Avoids a single +massive refactor PR that would block review. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock, MagicMock + + +def mock_openelectricity_client( + *, + price_aud_per_mwh: float = 85.42, + region: str = "NSW1", + interval_end_utc: datetime | None = None, +) -> MagicMock: + """Drop-in mock for ``OpenElectricityPriceSource``. + + Returns a MagicMock with ``fetch_current_price`` + ``last_good`` shaped + like the real class. Tests can override individual return values. + """ + from custom_components.pricehawk.providers.openelectricity import ( + WholesalePrice, + ) + + if interval_end_utc is None: + interval_end_utc = datetime.now(tz=timezone.utc) + + price = WholesalePrice( + price_aud_per_mwh=price_aud_per_mwh, + interval_end_utc=interval_end_utc, + region=region, + ) + client = MagicMock() + client.fetch_current_price = AsyncMock(return_value=price) + client.last_good = MagicMock(return_value=price) + return client + + +def mock_nemweb_client( + *, + price_c_kwh: float = 8.5, + region: str = "NSW1", +) -> MagicMock: + """Drop-in mock for ``NEMWebPriceSource``. + + Mirrors the OE mock shape; price is in c/kWh per the real NEMWeb + contract (gets multiplied by 10 internally to match WholesalePrice + ``price_aud_per_mwh`` field). + """ + from custom_components.pricehawk.providers.openelectricity import ( + WholesalePrice, + ) + + price = WholesalePrice( + price_aud_per_mwh=price_c_kwh * 10.0, + interval_end_utc=datetime.now(tz=timezone.utc), + region=region, + attribution="Wholesale price data: AEMO NEMWeb DISPATCH (public)", + ) + client = MagicMock() + client.fetch_current_price = AsyncMock(return_value=price) + client.last_good = MagicMock(return_value=price) + return client + + +def recorder_mock_external_statistics() -> tuple[MagicMock, list[Any]]: + """Mock ``async_add_external_statistics`` for tests that need to + observe stat-push calls without HA's recorder running. + + Returns a tuple of (mock, calls) where calls is appended every time + the mock is invoked: ``calls.append((metadata, stats))``. + """ + calls: list[Any] = [] + + def _record(_hass, metadata, stats): + calls.append((metadata, list(stats))) + + mock = MagicMock(side_effect=_record) + return mock, calls + + +def mock_config_entry_data( + *, + entry_id: str = "test-entry-xyz", + pricing_mode: str = "live_api", +) -> dict[str, Any]: + """Build the minimum entry.data + entry.options needed for a basic + DWT-OE coordinator construction. + + Useful for HA-harness tests that need a real ConfigEntry instance. + """ + from custom_components.pricehawk.const import ( + CONF_AMBER_PRICING_MODE, + CONF_API_KEY, + CONF_CURRENT_PROVIDER, + CONF_DWT_OE_API_KEY, + CONF_DWT_OE_DAILY_SUPPLY, + CONF_DWT_OE_ENABLED, + CONF_DWT_REGION, + CONF_GRID_POWER_SENSOR, + PROVIDER_DWT_OE, + ) + + return { + "entry_id": entry_id, + "data": { + CONF_API_KEY: "", + CONF_DWT_OE_API_KEY: "", + CONF_DWT_REGION: "NSW1", + CONF_CURRENT_PROVIDER: PROVIDER_DWT_OE, + }, + "options": { + CONF_DWT_OE_ENABLED: True, + CONF_DWT_OE_DAILY_SUPPLY: 110.0, + CONF_GRID_POWER_SENSOR: "sensor.test_grid_power", + CONF_AMBER_PRICING_MODE: pricing_mode, + }, + } diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..d75fd38 --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,123 @@ +"""Phase 10 PR-15 — blueprint library tests. + +Sanity-checks the 5 shipped blueprints: YAML parses, has the required +blueprint metadata block, declares domain=automation, names a +source_url, and exposes at least one `input` plus a `trigger`. +""" + +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +BP_DIR = ( + REPO / "custom_components" / "pricehawk" + / "blueprints" / "automation" / "pricehawk" +) + +EXPECTED = [ + "cheapest_plan_alert.yaml", + "cheapest_30min_window.yaml", + "pause_ev_on_spike.yaml", + "daily_7pm_summary.yaml", + "wholesale_spike_alert.yaml", +] + + +def _parse_yaml(path: Path) -> dict: + try: + import yaml # type: ignore[import-not-found] + + # HA's !input tag is custom; register a passthrough constructor + # so safe_load can parse blueprint files without choking. + class _SafeLoader(yaml.SafeLoader): + pass + + _SafeLoader.add_constructor( + "!input", lambda loader, node: f"!input {node.value}" + ) + return yaml.load(path.read_text(), Loader=_SafeLoader) + except ImportError: + # No yaml lib at runtime — fall back to a tiny scanner that + # only verifies the blueprint header block exists. + raw = path.read_text() + return { + "blueprint": { + "name": ( + [ + l.split("name:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("name:") + ] + or ["?"] + )[0], + "domain": ( + [ + l.split("domain:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("domain:") + ] + or [None] + )[0], + "source_url": ( + [ + l.split("source_url:", 1)[1].strip() + for l in raw.splitlines() + if l.strip().startswith("source_url:") + ] + or [None] + )[0], + "input": "input:" in raw, + }, + "trigger": "trigger:" in raw, + } + + +class TestBlueprintLibrary: + def test_all_5_blueprints_present(self): + for name in EXPECTED: + assert (BP_DIR / name).exists(), f"missing blueprint: {name}" + + def test_blueprints_parse_yaml(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert "blueprint" in data, f"{name} missing blueprint block" + + def test_blueprints_declare_domain_automation(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert data["blueprint"]["domain"] == "automation" + + def test_blueprints_name_source_url(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + src = data["blueprint"]["source_url"] + assert src, f"{name} missing source_url" + assert "Artic0din/ha-pricehawk" in src + + def test_blueprints_expose_inputs(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + inp = data["blueprint"].get("input") + assert inp, f"{name} has no input block" + + def test_blueprints_have_trigger(self): + for name in EXPECTED: + data = _parse_yaml(BP_DIR / name) + assert data.get("trigger"), f"{name} missing trigger" + + def test_cheapest_plan_alert_uses_ranked_alternatives_sensor(self): + raw = (BP_DIR / "cheapest_plan_alert.yaml").read_text() + assert "sensor.pricehawk_ranked_alternatives" in raw + + def test_daily_7pm_summary_uses_today_cost_sensor(self): + raw = (BP_DIR / "daily_7pm_summary.yaml").read_text() + assert "sensor.pricehawk_today_cost" in raw + + def test_pause_ev_blueprint_uses_hysteresis(self): + raw = (BP_DIR / "pause_ev_on_spike.yaml").read_text() + assert "hysteresis_c_kwh" in raw + # Numeric trigger has both above + below to implement hysteresis. + assert "above:" in raw + assert "below:" in raw diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py new file mode 100644 index 0000000..a0d0918 --- /dev/null +++ b/tests/test_ci_workflows.py @@ -0,0 +1,51 @@ +"""Phase 11 PR-17 — CI workflow validation. + +Verifies the validate.yaml workflow ships both hassfest + HACS jobs + +triggers on PR + push + schedule. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +VALIDATE_YAML = REPO / ".github" / "workflows" / "validate.yaml" + + +def _validate_yaml() -> str: + return VALIDATE_YAML.read_text() + + +class TestValidateWorkflow: + def test_file_exists(self): + assert VALIDATE_YAML.exists() + + def test_triggers_on_pull_request(self): + src = _validate_yaml() + assert "pull_request:" in src + + def test_triggers_on_push(self): + src = _validate_yaml() + assert "push:" in src + + def test_runs_hassfest_job(self): + src = _validate_yaml() + assert "validate-hassfest:" in src + assert "home-assistant/actions/hassfest" in src + + def test_runs_hacs_job(self): + src = _validate_yaml() + assert "validate-hacs:" in src + assert "hacs/action" in src + + def test_hacs_category_integration(self): + src = _validate_yaml() + assert 'category: "integration"' in src + + def test_permissions_minimum_required(self): + """Silver rule: minimum permissions per job.""" + src = _validate_yaml() + # Top-level permissions block scoped to contents:read; no + # write-all anti-pattern. + assert "contents: read" in src + assert "write-all" not in src diff --git a/tests/test_codex_regression_fixes.py b/tests/test_codex_regression_fixes.py new file mode 100644 index 0000000..fce05e8 --- /dev/null +++ b/tests/test_codex_regression_fixes.py @@ -0,0 +1,243 @@ +"""Regression tests for codex review findings on the v3.0 stack. + +Five functional regressions surfaced by `codex review --base dev` against +the tip of the Phases 7-11 PR stack. Each test pins the fix in place so a +future refactor can't silently re-introduce the bug. + +Source-level asserts mirror test_reauth.py / test_reconfigure.py because +``EnergyCompareConfigFlow`` and ``EnergyCompareCoordinator`` can't be +instantiated under the conftest HA stubs (per 07-02b D-1 deviation). +""" + +from __future__ import annotations + +from pathlib import Path + + +def _config_flow_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + +def _coordinator_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "coordinator.py" + ).read_text() + + +# ---------------------------------------------------------------------- +# P1 — dashboard_token entry builder persists DWT setup fields +# ---------------------------------------------------------------------- + + +class TestDwtFieldsPersistedAtEntryCreation: + """Codex P1: dashboard_token built entry without DWT fields. New + DWT installs landed in ConfigEntryNotReady at first refresh because + _build_dwt_provider() reads CONF_DWT_OE_ENABLED / CONF_DWT_OE_API_KEY + / CONF_DWT_REGION / CONF_DWT_OE_DAILY_SUPPLY (and AEMO variants) + that were never copied from self._data. + """ + + def test_dashboard_token_copies_dwt_oe_fields(self): + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + # Credentials persisted to entry.data + assert "data[CONF_DWT_OE_API_KEY]" in block + assert "data[CONF_DWT_REGION]" in block + # Runtime config persisted to entry.options + assert "options[CONF_DWT_OE_ENABLED] = True" in block + assert "options[CONF_DWT_OE_DAILY_SUPPLY]" in block + + def test_dashboard_token_copies_dwt_aemo_fields(self): + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + assert "options[CONF_DWT_AEMO_ENABLED] = True" in block + assert "options[CONF_DWT_AEMO_DAILY_SUPPLY]" in block + # AEMO has no API key — region is the only data field. + assert "data[CONF_DWT_REGION]" in block + + def test_dwt_block_gated_on_setup_step_flag(self): + """The DWT copy block must only fire when a DWT setup step ran. + CDR-flow entries (Amber/LV/GloBird primary) must not gain DWT + fields, since their dashboard_token render path is identical. + """ + src = _config_flow_source() + start = src.index("async def async_step_dashboard_token(") + end = src.index("async def async_step_reauth(", start) + block = src[start:end] + assert ( + 'self._data.get(CONF_DWT_OE_ENABLED)' in block + and 'self._data.get(CONF_DWT_AEMO_ENABLED)' in block + ) + + +# ---------------------------------------------------------------------- +# P2 — comparator form hides static_prd until a static plan is stored +# ---------------------------------------------------------------------- + + +class TestStaticPrdHiddenWithoutStoredPlan: + """Codex P2#2: ALL_PRICING_MODES contained static_prd and was fed + directly to Amber/FP/LV selectors. No flow writes CONF_*_STATIC_PLAN, + so selecting static_prd bricked the reload with ConfigEntryNotReady + (Amber/LV) or a silent fallback-with-warning (Flow Power). + """ + + def test_modes_helper_filters_static_prd_when_plan_absent(self): + src = _config_flow_source() + # The fix introduces a per-comparator helper. + assert "def _modes_for(" in src + # It excludes PRICING_MODE_STATIC_PRD when the static plan key + # is missing from options. + assert "if m != PRICING_MODE_STATIC_PRD" in src + + def test_comparator_form_uses_per_provider_mode_options(self): + """Each of the three comparator selectors must read its own + gated options list, not share a single `_mode_options` list + (the original bug).""" + src = _config_flow_source() + assert "_amber_mode_options" in src + assert "_fp_mode_options" in src + assert "_lv_mode_options" in src + + def test_static_plan_consts_imported(self): + src = _config_flow_source() + assert "CONF_AMBER_STATIC_PLAN" in src + assert "CONF_LOCALVOLTS_STATIC_PLAN" in src + assert "CONF_FLOW_POWER_STATIC_PLAN" in src + + def test_modes_helper_uses_correct_static_key_per_provider(self): + """Each comparator gets its own static-plan key — Amber must + not be gated on the LV static plan or vice versa.""" + src = _config_flow_source() + assert "_modes_for(CONF_AMBER_STATIC_PLAN)" in src + assert "_modes_for(CONF_FLOW_POWER_STATIC_PLAN)" in src + assert "_modes_for(CONF_LOCALVOLTS_STATIC_PLAN)" in src + + +# ---------------------------------------------------------------------- +# P3 — reauth dispatcher survives startup auth-failure without runtime_data +# ---------------------------------------------------------------------- + + +class TestReauthDispatcherFallbackToEntryData: + """Codex P2#3: dispatcher read coordinator._reauth_provider_id via + entry.runtime_data, but runtime_data is set AFTER + async_config_entry_first_refresh() succeeds. Auth failures during + startup or first refresh therefore got no provider id and aborted + with reauth_provider_unknown. + """ + + def test_dispatcher_falls_back_to_entry_data_provider(self): + src = _config_flow_source() + start = src.index("async def async_step_reauth(") + end = src.index("async def async_step_reauth_amber(", start) + block = src[start:end] + # The fallback must read CONF_CURRENT_PROVIDER from entry.data. + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block + # The fallback must only fire when the coordinator tag is None. + assert "if provider_id is None:" in block + + def test_dispatcher_still_prefers_coordinator_tag_when_present(self): + """The coordinator tag is provider-specific (could be the + comparator that failed, not the primary). Don't replace it — + only fall back when missing.""" + src = _config_flow_source() + start = src.index("async def async_step_reauth(") + end = src.index("async def async_step_reauth_amber(", start) + block = src[start:end] + # The original read-from-runtime_data path stays. + assert '"_reauth_provider_id"' in block + # entry_data parameter is still discarded (the fix uses entry.data + # via _get_reauth_entry, not the entry_data mapping argument). + assert "del entry_data" in block + + +# ---------------------------------------------------------------------- +# P4 — reconfigure dispatcher uses entry.data not CDR plan id +# ---------------------------------------------------------------------- + + +class TestReconfigureDispatcherUsesEntryData: + """Codex P2#4: dispatcher read coordinator._current_plan_provider.id, + but CdrPlanProvider.id is ``{brand}_{plan_id}`` (e.g. + ``amber_brokerage-xyz``), never the literal PROVIDER_AMBER / + PROVIDER_LOCALVOLTS. CDR-backed Amber/LV entries — the install base — + therefore fell through to reconfigure_unsupported. + """ + + def test_reconfigure_routes_via_entry_data_provider(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber(", start) + block = src[start:end] + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block + + def test_reconfigure_does_not_read_coordinator_plan_id(self): + """Regression guard: a future refactor must not re-introduce + the coordinator-id lookup, which is the CDR brand_planId for + the install base and never matches the comparison literals.""" + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber(", start) + block = src[start:end] + assert "_current_plan_provider" not in block + + +# ---------------------------------------------------------------------- +# P5 — Amber schedule fetch gated on live API mode +# ---------------------------------------------------------------------- + + +class TestAmberScheduleFetchGatedOnLiveMode: + """Codex P2#5: ``_maybe_poll_amber`` returns early in static/off + mode without updating _last_amber_poll, leaving it at 0.0 forever. + ``_async_update_data`` then re-triggers _fetch_today_price_schedule + on every 30s tick because ``_last_amber_poll == 0.0`` is its + first-run sentinel. Result: DWT and static-Amber entries hammered + Amber's API every 30s with stale or missing credentials. + """ + + def test_schedule_fetch_guard_checks_live_api_mode(self): + src = _coordinator_source() + # Find the first-run guard at the top of _async_update_data. + start = src.index("async def _async_update_data(") + end = src.index("await self._maybe_poll_amber()", start) + block = src[start:end] + # The guard must check pricing mode in addition to the + # _last_amber_poll == 0.0 sentinel. + assert "self._amber_mode == PRICING_MODE_LIVE_API" in block + assert "self._last_amber_poll == 0.0" in block + + def test_schedule_fetch_only_called_under_combined_guard(self): + """The _fetch_today_price_schedule call must sit inside the + combined `(== 0.0) and (LIVE_API)` guard, not before it or + in a separate branch. + """ + src = _coordinator_source() + start = src.index("async def _async_update_data(") + end = start + src[start:].index("await self._maybe_poll_amber()") + block = src[start:end] + # Single call site for the first-run schedule fetch. + assert block.count("await self._fetch_today_price_schedule()") == 1 + # And it must follow the combined guard — find the `if` that + # encloses it. + idx = block.index("await self._fetch_today_price_schedule()") + # Walk back to find the controlling `if` — must reference both + # _last_amber_poll and PRICING_MODE_LIVE_API. + preceding = block[:idx] + last_if = preceding.rindex("if ") + guard = preceding[last_if:idx] + assert "_last_amber_poll" in guard + assert "PRICING_MODE_LIVE_API" in guard diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..40500b3 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,162 @@ +"""Phase 8 PR-7 — diagnostics platform tests. + +The conftest stubs HA's async_redact_data with a behaviour-equivalent +helper (real impl walks the dict and replaces matched keys with +"**REDACTED**"). Tests verify the redaction list hits every API key ++ HA token + large-but-not-secret plan envelope per D-P8-3. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +from custom_components.pricehawk.const import ( + CONF_API_KEY, + CONF_CDR_PLAN, + CONF_DWT_OE_API_KEY, + CONF_HA_TOKEN, + CONF_LOCALVOLTS_API_KEY, + CONF_NAMED_COMPARATOR_PLAN, +) +from custom_components.pricehawk.diagnostics import ( + TO_REDACT, + async_get_config_entry_diagnostics, +) + + +def _entry(*, data: dict, options: dict, coordinator=None): + runtime_data = SimpleNamespace(coordinator=coordinator) if coordinator else None + return SimpleNamespace( + entry_id="test-entry-xyz", + data=data, + options=options, + runtime_data=runtime_data, + ) + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class TestRedactionList: + def test_to_redact_covers_every_api_key_field(self): + for field in ( + CONF_API_KEY, + CONF_DWT_OE_API_KEY, + CONF_LOCALVOLTS_API_KEY, + CONF_HA_TOKEN, + ): + assert field in TO_REDACT, ( + f"{field} not in TO_REDACT — would leak in diagnostics" + ) + + def test_to_redact_includes_large_plan_envelopes(self): + """D-P8-3: plan envelopes redacted for size, not secrecy.""" + assert CONF_CDR_PLAN in TO_REDACT + assert CONF_NAMED_COMPARATOR_PLAN in TO_REDACT + assert "amber_static_plan" in TO_REDACT + assert "localvolts_static_plan" in TO_REDACT + + +class TestDiagnosticsOutput: + def test_api_key_redacted_in_entry_data(self): + entry = _entry( + data={CONF_API_KEY: "sk-leaked-secret-amber-key"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "sk-leaked-secret-amber-key" not in json.dumps(out) + assert out["entry_data"][CONF_API_KEY] == "**REDACTED**" + assert out["_redaction_count"] >= 1 + + def test_dwt_oe_api_key_redacted(self): + entry = _entry( + data={CONF_DWT_OE_API_KEY: "oe-secret-key-abc123"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "oe-secret-key-abc123" not in json.dumps(out) + + def test_localvolts_api_key_redacted(self): + entry = _entry( + data={}, + options={CONF_LOCALVOLTS_API_KEY: "lv-secret-xyz"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "lv-secret-xyz" not in json.dumps(out) + + def test_ha_token_redacted(self): + entry = _entry( + data={CONF_HA_TOKEN: "ha-jwt-token-long-string"}, + options={}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "ha-jwt-token-long-string" not in json.dumps(out) + + def test_cdr_plan_envelope_redacted_for_size(self): + """D-P8-3: redacted not for secrecy but to keep output small.""" + entry = _entry( + data={}, + options={CONF_CDR_PLAN: {"data": {"planId": "BIG-12345"}}}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "BIG-12345" not in json.dumps(out) + + def test_output_is_json_serialisable(self): + entry = _entry( + data={CONF_API_KEY: "secret"}, + options={"some_other": "value"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + json.dumps(out) # raises if not serialisable + + def test_runtime_state_empty_when_no_coordinator(self): + entry = _entry(data={}, options={}) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert out["runtime_state"] == {} + + def test_runtime_state_populated_when_coordinator_present(self): + coord = SimpleNamespace( + _amber_mode="live_api", + _flow_power_mode="off", + _localvolts_mode="static_prd", + _reauth_provider_id=None, + _providers={"amber": object(), "globird": object()}, + _wholesale_settlement="2026-05-22 12:30:00", + _wholesale_c=5.5, + _amber_import_c=33.0, + _amber_export_c=5.5, + _saving_month_aud=12.34, + _daily_cost_history=[{}] * 30, + _ranking_last_run_at=None, + _backfill_status="idle", + _dwt_provider=None, + ) + entry = _entry(data={}, options={}, coordinator=coord) + out = _run(async_get_config_entry_diagnostics(None, entry)) + rs = out["runtime_state"] + assert rs["amber_mode"] == "live_api" + assert rs["localvolts_mode"] == "static_prd" + assert sorted(rs["registered_provider_ids"]) == ["amber", "globird"] + assert rs["daily_cost_history_len"] == 30 + assert rs["saving_month_aud"] == 12.34 + + def test_no_secret_in_repr(self): + """Even the repr() of the output dict has nothing leaking.""" + entry = _entry( + data={CONF_API_KEY: "ultra-secret-12345"}, + options={CONF_LOCALVOLTS_API_KEY: "ultra-secret-67890"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert "ultra-secret-12345" not in repr(out) + assert "ultra-secret-67890" not in repr(out) + + def test_redaction_count_zero_when_no_secrets(self): + entry = _entry( + data={"some_non_secret": "value"}, + options={"another_non_secret": "v"}, + ) + out = _run(async_get_config_entry_diagnostics(None, entry)) + assert out["_redaction_count"] == 0 diff --git a/tests/test_dynamic_wholesale_tariff_provider.py b/tests/test_dynamic_wholesale_tariff_provider.py new file mode 100644 index 0000000..91375b0 --- /dev/null +++ b/tests/test_dynamic_wholesale_tariff_provider.py @@ -0,0 +1,435 @@ +"""Phase 7 PR-2b — DynamicWholesaleTariffProvider + config-flow wiring tests. + +Covers AC-1 through AC-10 of 07-02b-PLAN. Provider unit-tests use a +mock ``WholesalePriceSource``. Config-flow routing is tested via the +module-level option-builder helpers + source-string asserts; the +ConfigFlow class itself cannot be instantiated under the conftest HA +stubs (its base class is a MagicMock), so the step methods are exercised +manually via UAT per the plan's verification section. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from custom_components.pricehawk.providers import ( + DynamicWholesaleTariffProvider as PackageExport, +) +from custom_components.pricehawk.providers import __all__ as providers_all +from custom_components.pricehawk.providers.base import Provider +from custom_components.pricehawk.providers.dynamic_wholesale_tariff import ( + STATE_VERSION, + DynamicWholesaleTariffProvider, +) +from custom_components.pricehawk.providers.openelectricity import WholesalePrice + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + + +class _MockPriceSource: + """Minimal WholesalePriceSource — fetch returns whatever you queue.""" + + def __init__(self, queue: list[WholesalePrice | None] | None = None): + self.queue = list(queue or []) + self.fetch_calls = 0 + self.last_good_calls = 0 + self._cached: WholesalePrice | None = None + + async def fetch_current_price(self, region: str) -> WholesalePrice | None: + self.fetch_calls += 1 + if not self.queue: + return None + result = self.queue.pop(0) + if isinstance(result, Exception): + raise result + if result is not None: + self._cached = result + return result + + def last_good(self, region: str) -> WholesalePrice | None: + self.last_good_calls += 1 + return self._cached + + +def _make_provider( + *, + region: str = "NSW1", + daily_supply_c: float = 110.0, + provider_id: str = "dwt_openelectricity", + name: str = "Dynamic Wholesale Tariff — OpenElectricity", +) -> tuple[DynamicWholesaleTariffProvider, _MockPriceSource]: + src = _MockPriceSource() + p = DynamicWholesaleTariffProvider( + price_source=src, # type: ignore[arg-type] + region=region, + daily_supply_c=daily_supply_c, + provider_id=provider_id, + name=name, + ) + return p, src + + +def _price(value_mwh: float, *, region: str = "NSW1", attribution: str = "X"): + return WholesalePrice( + price_aud_per_mwh=value_mwh, + interval_end_utc=datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc), + region=region, + attribution=attribution, + ) + + +# ---------------------------------------------------------------------- +# Provider basics +# ---------------------------------------------------------------------- + + +class TestProviderBasics: + def test_provider_implements_protocol(self): + p, _ = _make_provider() + assert isinstance(p, Provider) + + def test_provider_id_and_name_passed_through_constructor(self): + p_oe, _ = _make_provider( + provider_id="dwt_openelectricity", + name="Dynamic Wholesale Tariff — OpenElectricity", + ) + p_aemo, _ = _make_provider( + provider_id="dwt_aemo_direct", + name="Dynamic Wholesale Tariff — AEMO Direct", + ) + assert p_oe.id == "dwt_openelectricity" + assert p_oe.name == "Dynamic Wholesale Tariff — OpenElectricity" + assert p_aemo.id == "dwt_aemo_direct" + assert p_aemo.name == "Dynamic Wholesale Tariff — AEMO Direct" + + def test_set_current_rates_is_noop(self): + p, _ = _make_provider() + p.set_current_rates(99.9, 12.3) + assert p.current_import_rate_c_kwh == 0.0 + assert p.current_export_rate_c_kwh == 0.0 + + +# ---------------------------------------------------------------------- +# Cost math (AC-3, AC-4) +# ---------------------------------------------------------------------- + + +class TestCostMath: + def test_update_with_no_price_accumulates_nothing(self): + p, _ = _make_provider() + t0 = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + p.update(grid_power_w=2000, now_local=t0) + p.update(grid_power_w=2000, now_local=t0 + timedelta(seconds=30)) + assert p.import_kwh_today == 0.0 + assert p.import_cost_today_c == 0.0 + + def test_update_with_positive_price_accumulates(self): + p, _ = _make_provider(daily_supply_c=110.0) + t0 = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + p.update(grid_power_w=2000, now_local=t0) # seed last_tick + p.set_live_price(_price(85.42)) + p.update( + grid_power_w=2000, now_local=t0 + timedelta(seconds=30) + ) + # 2kW * 30s = 60 Ws → 2000/1000 * 30/3600 = 0.01667 kWh + # 85.42 $/MWh = 8.542 c/kWh + # cost = 0.01667 * 8.542 ≈ 0.1424 c + assert p.import_kwh_today == pytest.approx(2.0 * 30 / 3600, rel=1e-6) + assert p.import_cost_today_c == pytest.approx( + (2.0 * 30 / 3600) * (85.42 / 10), rel=1e-6 + ) + assert p.daily_fixed_charges_aud == pytest.approx(1.10) + + def test_update_with_negative_price_handles_export(self): + """AC-4: negative wholesale → exporter PAYS (negative earnings).""" + p, _ = _make_provider() + t0 = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + p.update(grid_power_w=-3000, now_local=t0) + p.set_live_price(_price(-15.0)) + p.update( + grid_power_w=-3000, now_local=t0 + timedelta(seconds=30) + ) + # 3kW export for 30s = 0.025 kWh; -15 $/MWh = -1.5 c/kWh + # earnings = 0.025 * -1.5 = -0.0375 c (exporter PAYS). + assert p.export_kwh_today == pytest.approx(3.0 * 30 / 3600, rel=1e-6) + assert p.export_earnings_today_c < 0 + assert p.export_earnings_today_c == pytest.approx( + (3.0 * 30 / 3600) * (-15.0 / 10), rel=1e-6 + ) + + def test_daily_fixed_charges_constant(self): + p, _ = _make_provider(daily_supply_c=110.0) + assert p.daily_fixed_charges_aud == pytest.approx(1.10) + + def test_reset_daily_zeros_accumulators_keeps_price(self): + p, _ = _make_provider() + t0 = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + p.set_live_price(_price(85.42)) + p.update(grid_power_w=2000, now_local=t0) + p.update(grid_power_w=2000, now_local=t0 + timedelta(seconds=30)) + assert p.import_kwh_today > 0 + p.reset_daily() + assert p.import_kwh_today == 0 + assert p.import_cost_today_c == 0 + assert p.export_kwh_today == 0 + assert p.export_earnings_today_c == 0 + # Price survives midnight. + assert p.last_price is not None + + +# ---------------------------------------------------------------------- +# Persistence (AC-2, AC-2b) +# ---------------------------------------------------------------------- + + +class TestPersistence: + def test_to_dict_from_dict_roundtrip(self): + p1, _ = _make_provider() + t0 = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + p1.set_live_price(_price(85.42)) + p1.update(grid_power_w=2000, now_local=t0) + p1.update(grid_power_w=2000, now_local=t0 + timedelta(seconds=30)) + snapshot = p1.to_dict() + + p2, _ = _make_provider() + p2.from_dict(snapshot, today=date(2026, 5, 21)) + assert p2.import_kwh_today == p1.import_kwh_today + assert p2.import_cost_today_c == p1.import_cost_today_c + assert p2.last_price is not None + assert p2.last_price.price_aud_per_mwh == 85.42 + + def test_from_dict_requires_today(self): + p, _ = _make_provider() + snapshot = p.to_dict() + with pytest.raises(TypeError): + p.from_dict(snapshot, today=None) # type: ignore[arg-type] + + def test_from_dict_rejects_wrong_version(self): + p, _ = _make_provider() + snapshot = p.to_dict() + snapshot["version"] = STATE_VERSION + 1 + with pytest.raises(ValueError, match="not supported"): + p.from_dict(snapshot, today=date(2026, 5, 21)) + + def test_from_dict_rejects_missing_version(self): + p, _ = _make_provider() + with pytest.raises(ValueError, match="not supported"): + p.from_dict({}, today=date(2026, 5, 21)) + + +# ---------------------------------------------------------------------- +# Extras / attribution (AC-5) +# ---------------------------------------------------------------------- + + +class TestExtras: + def test_extras_surface_attribution_region_price_age(self): + p, _ = _make_provider(region="NSW1") + attrib = ( + "Wholesale price data: Open Electricity (Superpower Institute), " + "CC BY-NC 4.0" + ) + p.set_live_price(_price(85.42, region="NSW1", attribution=attrib)) + extras = p.extras + assert extras["attribution"] == attrib + assert extras["region"] == "NSW1" + assert extras["wholesale_price_aud_per_mwh"] == 85.42 + assert isinstance(extras["wholesale_price_age_seconds"], int) + assert extras["wholesale_price_age_seconds"] >= 0 + + def test_extras_handles_no_price(self): + p, _ = _make_provider(region="QLD1") + extras = p.extras + assert extras["region"] == "QLD1" + assert extras["wholesale_price_aud_per_mwh"] is None + assert extras["attribution"] is None + + +# ---------------------------------------------------------------------- +# set_live_price idempotency +# ---------------------------------------------------------------------- + + +class TestSetLivePriceIdempotency: + def test_same_region_and_interval_is_noop(self): + p, _ = _make_provider() + p_price = _price(85.42) + p.set_live_price(p_price) + p.set_live_price(p_price) # same instance — no-op + assert p.last_price is p_price + + +# ---------------------------------------------------------------------- +# Package export +# ---------------------------------------------------------------------- + + +class TestPackageExport: + def test_providers_init_exports_dynamic_wholesale_tariff_provider(self): + assert "DynamicWholesaleTariffProvider" in providers_all + assert PackageExport is DynamicWholesaleTariffProvider + + +# ---------------------------------------------------------------------- +# Config-flow option builders (AC-6, AC-10) +# ---------------------------------------------------------------------- + + +class TestConfigFlowOptionBuilders: + def test_dwt_retailer_options_oe_first_then_aemo(self): + """AC-6: DWT entries lead the retailer picker, OE before AEMO.""" + from custom_components.pricehawk.config_flow import ( + _build_dwt_retailer_options, + ) + + opts = _build_dwt_retailer_options() + assert len(opts) == 2 + assert opts[0]["value"] == "dwt_openelectricity" + assert opts[1]["value"] == "dwt_aemo_direct" + assert "OpenElectricity" in opts[0]["label"] + assert "API key required" in opts[0]["label"] + assert "AEMO Direct" in opts[1]["label"] + assert "no key" in opts[1]["label"] + + def test_dwt_region_options_aemo_excludes_wem(self): + """AC-10: AEMO Direct (include_wem=False) — NEM only, no WEM.""" + from custom_components.pricehawk.config_flow import ( + _build_dwt_region_options, + ) + + opts = _build_dwt_region_options(include_wem=False) + values = {o["value"] for o in opts} + assert "WEM" not in values + assert {"NSW1", "QLD1", "SA1", "TAS1", "VIC1"} == values + + def test_dwt_region_options_oe_includes_wem(self): + """AC-10: OE (include_wem=True) — NEM + WEM.""" + from custom_components.pricehawk.config_flow import ( + _build_dwt_region_options, + ) + + opts = _build_dwt_region_options(include_wem=True) + values = {o["value"] for o in opts} + assert "WEM" in values + assert {"NSW1", "QLD1", "SA1", "TAS1", "VIC1", "WEM"} == values + + def test_dwt_region_options_carry_grid_network_badge(self): + """AC-10 audit S5: region labels include grid-network badge.""" + from custom_components.pricehawk.config_flow import ( + _build_dwt_region_options, + ) + + opts = _build_dwt_region_options(include_wem=True) + labels = {o["value"]: o["label"] for o in opts} + assert "NEM" in labels["NSW1"] + assert "Western Australia" in labels["WEM"] + + +# ---------------------------------------------------------------------- +# Config-flow routing — source-level (AC-6, AC-7, AC-8) +# +# The ConfigFlow class itself cannot be instantiated under the conftest +# HA stubs (its base class is a MagicMock — class creation produces a +# MagicMock instance, not a real class). The step routing dispatch is +# therefore covered via source-string asserts on the production module; +# end-to-end is manual UAT per the plan's verification section. +# ---------------------------------------------------------------------- + + +class TestConfigFlowRoutingSource: + @staticmethod + def _source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + def test_cdr_retailer_step_prepends_dwt_options(self): + src = self._source() + # Selector options must call _build_dwt_retailer_options() FIRST + # then concatenate the CDR catalogue list. + assert ( + "_build_dwt_retailer_options() + _build_cdr_retailer_options" + ) in src + + def test_cdr_retailer_dispatch_to_dwt_credentials(self): + src = self._source() + assert "PROVIDER_DWT_OE" in src + assert "async_step_dwt_credentials" in src + # The dispatch arm exists in async_step_cdr_retailer. + assert ( + "if choice == PROVIDER_DWT_OE:" in src + and "return await self.async_step_dwt_credentials()" in src + ) + + def test_cdr_retailer_dispatch_to_dwt_aemo_setup(self): + src = self._source() + assert "PROVIDER_DWT_AEMO" in src + assert "async_step_dwt_aemo_setup" in src + assert ( + "if choice == PROVIDER_DWT_AEMO:" in src + and "return await self.async_step_dwt_aemo_setup()" in src + ) + + def test_dwt_credentials_step_sets_invalid_api_key_on_authfailed(self): + src = self._source() + # AC-7 path: ConfigEntryAuthFailed → errors[CONF_DWT_OE_API_KEY] = "invalid_api_key" + assert ( + 'errors[CONF_DWT_OE_API_KEY] = "invalid_api_key"' + ) in src + + def test_dwt_credentials_step_stores_oe_flags(self): + src = self._source() + # AC-7 success path stores the four DWT-OE keys + CONF_CURRENT_PROVIDER. + for key in ( + "self._data[CONF_DWT_OE_ENABLED] = True", + "self._data[CONF_DWT_OE_API_KEY]", + "self._data[CONF_DWT_REGION]", + "self._data[CONF_DWT_OE_DAILY_SUPPLY]", + "self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_OE", + ): + assert key in src, f"missing in config_flow.py: {key}" + + def test_dwt_credentials_unique_id_pattern(self): + src = self._source() + # AC-10d non-collision: unique_id baked from region. + assert 'f"dwt_openelectricity_{region}"' in src + + def test_dwt_aemo_setup_step_stores_aemo_flags_only(self): + src = self._source() + # AC-8 success path stores the three DWT-AEMO keys. + for key in ( + "self._data[CONF_DWT_AEMO_ENABLED] = True", + "self._data[CONF_DWT_REGION]", + "self._data[CONF_DWT_AEMO_DAILY_SUPPLY]", + "self._data[CONF_CURRENT_PROVIDER] = PROVIDER_DWT_AEMO", + ): + assert key in src, f"missing in config_flow.py: {key}" + + def test_dwt_aemo_setup_unique_id_pattern(self): + src = self._source() + assert 'f"dwt_aemo_direct_{region}"' in src + + +# ---------------------------------------------------------------------- +# strings.json ↔ translations/en.json byte-identical +# ---------------------------------------------------------------------- + + +class TestStringsTranslationsByteIdentical: + def test_strings_translations_byte_identical(self): + repo = Path(__file__).resolve().parents[1] + a = repo / "custom_components" / "pricehawk" / "strings.json" + b = repo / "custom_components" / "pricehawk" / "translations" / "en.json" + assert a.read_bytes() == b.read_bytes(), ( + "strings.json and translations/en.json drifted — " + "they must stay byte-identical." + ) 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_reauth.py b/tests/test_reauth.py new file mode 100644 index 0000000..6e5e466 --- /dev/null +++ b/tests/test_reauth.py @@ -0,0 +1,329 @@ +"""Phase 8 PR-5 — per-provider reauth flow tests. + +Covers AC-1 through AC-9 of 08-01-PLAN. Same pattern as PR-2b: +provider-side raise sites exercised via direct invocation with mocked +HA stubs; ConfigFlow routing covered via source-level asserts because +EnergyCompareConfigFlow can't be instantiated under conftest's HA +MagicMock base (documented in 07-02b D-1 deviation). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from homeassistant.exceptions import ConfigEntryAuthFailed +from custom_components.pricehawk.const import ( + PROVIDER_AMBER, + PROVIDER_DWT_OE, + PROVIDER_LOCALVOLTS, +) +from custom_components.pricehawk.localvolts_api import LocalVoltsAPIError + + +# ---------------------------------------------------------------------- +# Helpers — coordinator with just the bits we need +# ---------------------------------------------------------------------- + + +class _FakeCoordinator: + """Minimal coordinator surface for testing the raise sites. + + Mirrors the attributes touched by Tasks 1-3 (the auth-fail branches + in _fetch_amber_with_retry, _maybe_poll_localvolts, _refresh_dwt_price). + """ + + def __init__(self): + self._reauth_provider_id: str | None = None + + +def _amber_raise( + coord: _FakeCoordinator, + status: int, + api_key: str = "sk-secret-key-do-not-leak", +) -> None: + """Mirror the production raise branch (coordinator.py:692-705).""" + del api_key # Production code never embeds the key in the message. + if status in (401, 403): + coord._reauth_provider_id = PROVIDER_AMBER + raise ConfigEntryAuthFailed( + f"Amber API rejected the key (HTTP {status})" + ) + # 500 and others — production returns None, no raise. + + +def _localvolts_raise(coord: _FakeCoordinator, lv_error: LocalVoltsAPIError) -> None: + """Mirror the production translate branch (coordinator.py LV poll).""" + msg = str(lv_error).lower() + if "auth failed" in msg or "401" in msg or "403" in msg: + coord._reauth_provider_id = PROVIDER_LOCALVOLTS + raise ConfigEntryAuthFailed( + "LocalVolts API rejected credentials" + ) from lv_error + raise lv_error + + +def _dwt_oe_raise(coord: _FakeCoordinator, exc: ConfigEntryAuthFailed) -> None: + """Mirror the production DWT-OE tag branch.""" + coord._reauth_provider_id = PROVIDER_DWT_OE + raise exc + + +# ---------------------------------------------------------------------- +# Coordinator raise sites (AC-1, AC-2, AC-3) +# ---------------------------------------------------------------------- + + +class TestCoordinatorRaiseSites: + def test_amber_401_raises_config_entry_auth_failed(self): + coord = _FakeCoordinator() + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + _amber_raise(coord, 401) + assert coord._reauth_provider_id == PROVIDER_AMBER + assert "401" in str(exc_info.value) + + def test_amber_403_also_raises(self): + coord = _FakeCoordinator() + with pytest.raises(ConfigEntryAuthFailed): + _amber_raise(coord, 403) + assert coord._reauth_provider_id == PROVIDER_AMBER + + def test_amber_500_does_not_raise_auth_failed(self): + """Server error → existing retry path, no auth-fail tag.""" + coord = _FakeCoordinator() + _amber_raise(coord, 500) # No raise. + assert coord._reauth_provider_id is None + + def test_localvolts_auth_error_translated_to_auth_failed(self): + coord = _FakeCoordinator() + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + _localvolts_raise( + coord, LocalVoltsAPIError("LocalVolts auth failed (401)") + ) + assert coord._reauth_provider_id == PROVIDER_LOCALVOLTS + assert isinstance(exc_info.value.__cause__, LocalVoltsAPIError) + + def test_localvolts_403_also_translates(self): + coord = _FakeCoordinator() + with pytest.raises(ConfigEntryAuthFailed): + _localvolts_raise( + coord, LocalVoltsAPIError("auth failed (403)") + ) + assert coord._reauth_provider_id == PROVIDER_LOCALVOLTS + + def test_localvolts_non_auth_error_propagates(self): + coord = _FakeCoordinator() + with pytest.raises(LocalVoltsAPIError) as exc_info: + _localvolts_raise( + coord, LocalVoltsAPIError("connection refused") + ) + # Original error propagates; no auth-fail wrap. + assert "connection refused" in str(exc_info.value) + assert coord._reauth_provider_id is None + + def test_dwt_oe_auth_failed_tags_provider(self): + coord = _FakeCoordinator() + with pytest.raises(ConfigEntryAuthFailed): + _dwt_oe_raise(coord, ConfigEntryAuthFailed("HTTP 401")) + assert coord._reauth_provider_id == PROVIDER_DWT_OE + + +# ---------------------------------------------------------------------- +# API key redaction (AC-9) +# ---------------------------------------------------------------------- + + +class TestAPIKeyRedaction: + def test_amber_auth_failed_message_does_not_contain_key(self, caplog): + coord = _FakeCoordinator() + secret = "sk-abcdef123456-very-secret-amber-key" + caplog.set_level(logging.WARNING) + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + _amber_raise(coord, 401, api_key=secret) + assert secret not in str(exc_info.value) + # Captured log records (if any) must also be clean. + for record in caplog.records: + assert secret not in record.getMessage() + assert secret not in str(record.args or "") + + def test_localvolts_auth_failed_message_does_not_contain_key(self, caplog): + coord = _FakeCoordinator() + secret = "lv-xyz789-localvolts-secret-key" + caplog.set_level(logging.WARNING) + # The translated ConfigEntryAuthFailed message comes from the + # coordinator boundary — by contract, it does NOT include the + # key (the key only lives in the URL params during the fetch). + lv_err = LocalVoltsAPIError(f"LocalVolts auth failed (401)") # noqa: F541 — intentional + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + _localvolts_raise(coord, lv_err) + assert secret not in str(exc_info.value) + # Original LocalVoltsAPIError message also doesn't carry the key. + assert secret not in str(exc_info.value.__cause__) + for record in caplog.records: + assert secret not in record.getMessage() + + +# ---------------------------------------------------------------------- +# ConfigFlow dispatcher routing (AC-4) — source-level (see 07-02b D-1) +# ---------------------------------------------------------------------- + + +def _config_flow_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + +class TestConfigFlowDispatcherSource: + def test_dispatcher_routes_to_amber_substep(self): + src = _config_flow_source() + assert "if provider_id == PROVIDER_AMBER:" in src + assert "return await self.async_step_reauth_amber()" in src + + def test_dispatcher_routes_to_localvolts_substep(self): + src = _config_flow_source() + assert "if provider_id == PROVIDER_LOCALVOLTS:" in src + assert "return await self.async_step_reauth_localvolts()" in src + + def test_dispatcher_routes_to_dwt_oe_substep(self): + src = _config_flow_source() + assert "if provider_id == PROVIDER_DWT_OE:" in src + assert "return await self.async_step_reauth_dwt_oe()" in src + + def test_dispatcher_aborts_on_unknown_provider(self): + src = _config_flow_source() + assert 'reason="reauth_provider_unknown"' in src + + def test_dispatcher_reads_runtime_data_coordinator(self): + """Tag is read via entry.runtime_data.coordinator, not entry_data.""" + src = _config_flow_source() + assert 'getattr(entry, "runtime_data", None)' in src + assert '"_reauth_provider_id"' in src + + +# ---------------------------------------------------------------------- +# Per-provider sub-step source contract (AC-5, AC-6, AC-7) +# ---------------------------------------------------------------------- + + +class TestSubstepSource: + def test_amber_substep_sets_invalid_api_key_on_401_or_403(self): + src = _config_flow_source() + # The Amber probe checks 401 or 403 → invalid_api_key error. + assert "resp.status in (401, 403)" in src + assert 'errors[CONF_API_KEY] = "invalid_api_key"' in src + + def test_amber_substep_uses_update_reload_and_abort(self): + """Successful reauth must call update_reload_and_abort, not create_entry.""" + src = _config_flow_source() + assert "return self.async_update_reload_and_abort(" in src + # Specifically the Amber branch updates entry.data not options + # (Amber API key lives in data, not options). + assert "data={**entry.data, CONF_API_KEY: new_key}" in src + + def test_localvolts_substep_sets_invalid_credentials(self): + src = _config_flow_source() + # LocalVolts has 3 fields — single base error since the API + # doesn't tell us which one is wrong. + assert 'errors["base"] = "invalid_credentials"' in src + + def test_localvolts_substep_updates_options_not_data(self): + """LocalVolts credentials live in entry.options.""" + src = _config_flow_source() + # Search for the options update block — match the three keys. + assert "CONF_LOCALVOLTS_API_KEY: new_key" in src + assert "CONF_LOCALVOLTS_PARTNER_ID: new_partner" in src + assert "CONF_LOCALVOLTS_NMI: new_nmi" in src + + def test_dwt_oe_substep_sets_invalid_api_key_on_auth_failed(self): + src = _config_flow_source() + assert 'errors[CONF_DWT_OE_API_KEY] = "invalid_api_key"' in src + + def test_dwt_oe_substep_preserves_region(self): + """Region MUST NOT be re-collected during reauth — only the key.""" + src = _config_flow_source() + # The form schema for reauth_dwt_oe only has CONF_DWT_OE_API_KEY. + # We check the data update only touches the key. + assert "data={**entry.data, CONF_DWT_OE_API_KEY: new_key}" in src + + def test_all_substep_passwords_use_password_text_selector(self): + src = _config_flow_source() + # Three password fields total: Amber key, LV key, DWT-OE key. + assert src.count("TextSelectorType.PASSWORD") >= 3 + + +# ---------------------------------------------------------------------- +# strings/translations parity check (Phase 7 invariant continues) +# ---------------------------------------------------------------------- + + +class TestStringsHaveReauthEntries: + def test_strings_have_three_reauth_steps(self): + import json + repo = Path(__file__).resolve().parents[1] + s = json.load( + open(repo / "custom_components" / "pricehawk" / "strings.json") + ) + for step_id in ("reauth_amber", "reauth_localvolts", "reauth_dwt_oe"): + assert step_id in s["config"]["step"], ( + f"strings.json missing config.step.{step_id}" + ) + + def test_strings_have_invalid_credentials_error(self): + import json + repo = Path(__file__).resolve().parents[1] + s = json.load( + open(repo / "custom_components" / "pricehawk" / "strings.json") + ) + assert "invalid_credentials" in s["config"]["error"] + + def test_strings_have_reauth_abort_reasons(self): + import json + repo = Path(__file__).resolve().parents[1] + s = json.load( + open(repo / "custom_components" / "pricehawk" / "strings.json") + ) + assert "reauth_provider_unknown" in s["config"]["abort"] + assert "reauth_successful" in s["config"]["abort"] + + def test_translations_byte_identical_to_strings(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 + + +# ---------------------------------------------------------------------- +# History preservation contract (AC-8) +# ---------------------------------------------------------------------- + + +class TestHistoryPreservation: + def test_reauth_does_not_reset_daily_or_monthly_accumulators(self): + """The dispatcher + substeps MUST NOT call provider.reset_daily. + + Source-level guard: no occurrence of `.reset_daily()` inside the + reauth step methods. Reauth changes credentials only — the + coordinator continues from where it stopped. + """ + src = _config_flow_source() + # Find the reauth block (from "async def async_step_reauth" to + # the next "async def" or "@staticmethod"). + start = src.index("async def async_step_reauth(") + end = src.index("@staticmethod", start) + reauth_block = src[start:end] + assert ".reset_daily()" not in reauth_block, ( + "Reauth steps must not reset daily accumulators " + "(would wipe daily_cost_history)." + ) + assert "_daily_cost_history" not in reauth_block + assert "_saving_month_aud" not in reauth_block diff --git a/tests/test_reconfigure.py b/tests/test_reconfigure.py new file mode 100644 index 0000000..94b7664 --- /dev/null +++ b/tests/test_reconfigure.py @@ -0,0 +1,164 @@ +"""Phase 8 PR-6 — reconfigure flow source-level tests. + +Mirrors test_reauth.py pattern — source-grep on config_flow.py because +EnergyCompareConfigFlow can't be instantiated under conftest HA stubs +(documented in 07-02b D-1). +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def _config_flow_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "config_flow.py" + ).read_text() + + +def _strings_json() -> dict: + return json.load( + open( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "strings.json" + ) + ) + + +class TestDispatcherRouting: + def test_dispatcher_routes_to_amber_substep(self): + src = _config_flow_source() + assert "if provider_id == PROVIDER_AMBER:" in src + assert "return await self.async_step_reconfigure_amber()" in src + + def test_dispatcher_routes_to_localvolts_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_localvolts()" in src + + def test_dispatcher_routes_to_dwt_oe_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_dwt_oe()" in src + + def test_dispatcher_routes_to_dwt_aemo_substep(self): + src = _config_flow_source() + assert "return await self.async_step_reconfigure_dwt_aemo()" in src + + def test_dispatcher_aborts_on_unsupported_entry(self): + src = _config_flow_source() + assert 'reason="reconfigure_unsupported"' in src + + def test_dispatcher_reads_provider_from_entry_data(self): + """Codex fix: dispatch from entry.data[CONF_CURRENT_PROVIDER] + not coordinator._current_plan_provider.id. CdrPlanProvider.id is + ``{brand}_{plan_id}`` for CDR Amber/LV entries and never matches + the literal PROVIDER_AMBER / PROVIDER_LOCALVOLTS slug, so reading + it from the coordinator made those reconfigure branches unreachable + for the install base. + """ + src = _config_flow_source() + # Find the reconfigure dispatcher block. + start = src.index("async def async_step_reconfigure(") + end = src.index("async def async_step_reconfigure_amber", start) + block = src[start:end] + assert "entry.data.get(CONF_CURRENT_PROVIDER)" in block, ( + "Reconfigure dispatcher must read CONF_CURRENT_PROVIDER from " + "entry.data, not from coordinator._current_plan_provider.id." + ) + assert "self._get_reconfigure_entry()" in block + # Guard against regression — runtime coordinator id MUST NOT + # be the source of the dispatch decision. + assert "_current_plan_provider" not in block, ( + "Reconfigure dispatcher must NOT rely on the runtime " + "coordinator's _current_plan_provider — that is the CDR " + "brand_planId for CDR users, not the literal provider slug." + ) + + +class TestSubstepContract: + def test_amber_substep_updates_options_not_data(self): + src = _config_flow_source() + # Amber sub-step writes fees into options. + assert "CONF_AMBER_NETWORK_DAILY_CHARGE: float(" in src + assert "CONF_AMBER_SUBSCRIPTION_FEE: float(" in src + + def test_localvolts_substep_updates_three_option_fields(self): + src = _config_flow_source() + assert "CONF_LOCALVOLTS_DAILY_SUPPLY: float(" in src + assert "CONF_LOCALVOLTS_BUY_CEILING: float(" in src + assert "CONF_LOCALVOLTS_SELL_FLOOR: float(" in src + + def test_dwt_oe_substep_only_edits_daily_supply(self): + src = _config_flow_source() + # DWT-OE reconfigure MUST NOT touch region or API key. + # Find the dwt_oe block: + start = src.index("async def async_step_reconfigure_dwt_oe") + end = src.index("async def async_step_reconfigure_dwt_aemo", start) + block = src[start:end] + assert "CONF_DWT_OE_DAILY_SUPPLY" in block + assert "CONF_DWT_REGION" not in block + assert "CONF_DWT_OE_API_KEY" not in block + + def test_dwt_aemo_substep_only_edits_daily_supply(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure_dwt_aemo") + # Find end of method — next def or @staticmethod + end = src.index("@staticmethod", start) + block = src[start:end] + assert "CONF_DWT_AEMO_DAILY_SUPPLY" in block + assert "CONF_DWT_REGION" not in block + + def test_all_substeps_use_update_reload_and_abort(self): + src = _config_flow_source() + # 4 sub-steps + the 3 reauth sub-steps = 7 calls minimum. + assert src.count("self.async_update_reload_and_abort(") >= 7 + + +class TestHistoryPreservation: + def test_reconfigure_block_does_not_reset_history(self): + src = _config_flow_source() + start = src.index("async def async_step_reconfigure(") + end = src.index("@staticmethod", start) + block = src[start:end] + # No reset_daily, no history wipes, no entry.data mutation inside reconfigure. + assert ".reset_daily()" not in block + assert "_daily_cost_history" not in block + assert "data={**entry.data" not in block # reconfigure ONLY touches options + + +class TestStringsParity: + def test_strings_have_four_reconfigure_steps(self): + s = _strings_json() + for step_id in ( + "reconfigure_amber", + "reconfigure_localvolts", + "reconfigure_dwt_oe", + "reconfigure_dwt_aemo", + ): + assert step_id in s["config"]["step"], ( + f"strings.json missing config.step.{step_id}" + ) + + def test_strings_have_reconfigure_abort_reasons(self): + s = _strings_json() + assert "reconfigure_unsupported" in s["config"]["abort"] + assert "reconfigure_successful" in s["config"]["abort"] + + def test_translations_byte_identical(self): + repo = Path(__file__).resolve().parents[1] + a = ( + repo / "custom_components" / "pricehawk" / "strings.json" + ).read_bytes() + b = ( + repo + / "custom_components" + / "pricehawk" + / "translations" + / "en.json" + ).read_bytes() + assert a == b diff --git a/tests/test_repairs.py b/tests/test_repairs.py new file mode 100644 index 0000000..d98a6ee --- /dev/null +++ b/tests/test_repairs.py @@ -0,0 +1,252 @@ +"""Phase 8 PR-8 — repairs platform tests. + +PriceHawkCoordinator is a MagicMock under conftest HA stubs (same root +cause as 07-02b D-1 deviation). The production `_set_repair` and +`_check_repairs` logic is therefore exercised via a small standalone +re-implementation that mirrors the production semantics + source-grep +asserts on coordinator.py for the existence + threshold contracts. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from custom_components.pricehawk.const import DOMAIN +from homeassistant.helpers import issue_registry as ir + + +def _coordinator_source() -> str: + return ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "pricehawk" + / "coordinator.py" + ).read_text() + + +def _reset_registry(): + ir._created.clear() + ir._deleted.clear() + + +class _Stand: + """Minimal stand-in mirroring the production _set_repair / _check_repairs.""" + + def __init__(self, entry_id="test-entry"): + self.hass = object() + self.config_entry = type("E", (), {"entry_id": entry_id})() + self._active_repair_ids: set[str] = set() + self._grid_sensor_missing_ticks = 0 + self._ranking_last_run_at: datetime | None = None + self._grid_power_entity = "sensor.grid_power" + + def _set_repair( + self, issue_id, on, *, severity=ir.IssueSeverity.WARNING, + translation_placeholders=None, + ): + scoped = f"{self.config_entry.entry_id}_{issue_id}" + if on: + if scoped in self._active_repair_ids: + return + ir.async_create_issue( + self.hass, DOMAIN, scoped, + is_fixable=False, severity=severity, + translation_key=issue_id, + translation_placeholders=translation_placeholders, + ) + self._active_repair_ids.add(scoped) + else: + if scoped not in self._active_repair_ids: + return + ir.async_delete_issue(self.hass, DOMAIN, scoped) + self._active_repair_ids.discard(scoped) + + def _check_repairs(self, grid_power_w, now_local): + if grid_power_w is None: + self._grid_sensor_missing_ticks += 1 + if self._grid_sensor_missing_ticks >= 10: + self._set_repair( + "grid_sensor_unavailable", True, + translation_placeholders={ + "entity_id": self._grid_power_entity or "(unset)", + }, + ) + else: + self._grid_sensor_missing_ticks = 0 + self._set_repair("grid_sensor_unavailable", False) + + last_rank = self._ranking_last_run_at + if last_rank is None: + return + age_hours = (now_local - last_rank).total_seconds() / 3600.0 + if age_hours > 36.0: + self._set_repair( + "ranking_stale", True, + translation_placeholders={"hours": f"{age_hours:.1f}"}, + ) + else: + self._set_repair("ranking_stale", False) + + +class TestGridSensorUnavailable: + def test_raised_after_10_consecutive_none_reads(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(9): + c._check_repairs(None, now) + assert not ir._created + c._check_repairs(None, now) + assert any( + "grid_sensor_unavailable" in iid + for (_d, iid) in ir._created.keys() + ) + + def test_recovery_clears_issue(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(10): + c._check_repairs(None, now) + c._check_repairs(2000.0, now) + assert c._grid_sensor_missing_ticks == 0 + assert any( + "grid_sensor_unavailable" in iid for (_d, iid) in ir._deleted + ) + + def test_counter_resets_between_brief_outages(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + for _ in range(5): + c._check_repairs(None, now) + c._check_repairs(2000.0, now) + for _ in range(5): + c._check_repairs(None, now) + assert c._grid_sensor_missing_ticks == 5 + + +class TestRankingStale: + def test_no_run_yet_does_not_raise(self): + _reset_registry() + c = _Stand() + c._check_repairs( + 2000.0, datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + ) + assert not ir._created + + def test_raised_after_36h(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=37) + c._check_repairs(2000.0, now) + assert any( + "ranking_stale" in iid for (_d, iid) in ir._created.keys() + ) + + def test_cleared_after_fresh_run(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=37) + c._check_repairs(2000.0, now) + c._ranking_last_run_at = now + c._check_repairs(2000.0, now) + assert any( + "ranking_stale" in iid for (_d, iid) in ir._deleted + ) + + def test_recent_run_not_flagged(self): + _reset_registry() + c = _Stand() + now = datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc) + c._ranking_last_run_at = now - timedelta(hours=20) + c._check_repairs(2000.0, now) + assert not any( + "ranking_stale" in iid for (_d, iid) in ir._created.keys() + ) + + +class TestMultiEntryKeying: + def test_issue_id_scoped_to_entry_id(self): + _reset_registry() + a = _Stand(entry_id="entry-A") + b = _Stand(entry_id="entry-B") + a._set_repair("grid_sensor_unavailable", True) + b._set_repair("grid_sensor_unavailable", True) + keys = list(ir._created.keys()) + assert (DOMAIN, "entry-A_grid_sensor_unavailable") in keys + assert (DOMAIN, "entry-B_grid_sensor_unavailable") in keys + + +class TestSetRepairDedup: + def test_no_double_create(self): + _reset_registry() + c = _Stand() + c._set_repair("grid_sensor_unavailable", True) + ir._created.clear() + c._set_repair("grid_sensor_unavailable", True) + assert not ir._created + + def test_no_double_delete(self): + _reset_registry() + c = _Stand() + c._set_repair("grid_sensor_unavailable", False) + assert not ir._deleted + + +class TestCoordinatorSourceContract: + """Production coordinator.py must match the stand-in semantics.""" + + def test_set_repair_present(self): + assert "def _set_repair(" in _coordinator_source() + + def test_check_repairs_present(self): + assert "def _check_repairs(" in _coordinator_source() + + def test_check_repairs_called_in_tick_loop(self): + src = _coordinator_source() + assert "self._check_repairs(grid_power_w, now_local)" in src + + def test_grid_sensor_threshold_matches_stand_in(self): + src = _coordinator_source() + # Threshold is 10 ticks (= 5 minutes at 30s coordinator interval). + assert "self._grid_sensor_missing_ticks >= 10" in src + + def test_ranking_threshold_matches_stand_in(self): + src = _coordinator_source() + assert "if age_hours > 36.0:" in src + + def test_issue_id_scoped_to_entry_id_in_production(self): + src = _coordinator_source() + assert 'f"{self.config_entry.entry_id}_{issue_id}"' in src + + def test_active_repair_ids_dedup_set_in_init(self): + src = _coordinator_source() + assert "self._active_repair_ids: set[str] = set()" in src + + +class TestStringsHaveIssues: + def test_issues_block_present(self): + s = json.load(open( + Path(__file__).resolve().parents[1] + / "custom_components" / "pricehawk" / "strings.json" + )) + assert "issues" in s + for issue_id in ("grid_sensor_unavailable", "ranking_stale"): + assert issue_id in s["issues"] + assert "title" in s["issues"][issue_id] + assert "description" in s["issues"][issue_id] + + def test_translations_byte_identical(self): + repo = Path(__file__).resolve().parents[1] + a = ( + repo / "custom_components" / "pricehawk" / "strings.json" + ).read_bytes() + b = ( + repo / "custom_components" / "pricehawk" / "translations" / "en.json" + ).read_bytes() + assert a == b diff --git a/tests/test_runtime_data.py b/tests/test_runtime_data.py index 6367e20..8fca6ff 100644 --- a/tests/test_runtime_data.py +++ b/tests/test_runtime_data.py @@ -28,6 +28,8 @@ def _make_coordinator() -> MagicMock: coord = MagicMock() coord.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() + # Phase 9 PR-10 — async_setup_entry calls async_setup_stats after restore. + coord.async_setup_stats = AsyncMock() coord.async_run_ranking_job = AsyncMock(return_value=[]) coord.async_run_backfill = AsyncMock() coord.async_persist_state = AsyncMock() @@ -90,6 +92,14 @@ def _patch_deps(coord: MagicMock): ), patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch( + "custom_components.pricehawk.setup_panel_custom_v2", + new=AsyncMock(), + ), + patch( + "custom_components.pricehawk.register_lovelace_card_resource", + new=AsyncMock(), + ), patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), ) @@ -108,6 +118,14 @@ def _next_coord(*_args: Any, **_kwargs: Any) -> MagicMock: ), patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch( + "custom_components.pricehawk.setup_panel_custom_v2", + new=AsyncMock(), + ), + patch( + "custom_components.pricehawk.register_lovelace_card_resource", + new=AsyncMock(), + ), patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), ) @@ -126,8 +144,8 @@ def test_setup_writes_runtime_data(): hass = _make_hass() entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: result = asyncio.run(async_setup_entry(hass, entry)) assert result is True @@ -148,8 +166,8 @@ def test_unload_runs_platform_unload_first(): hass = _make_hass(unload_platforms_result=False) entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) assert entry.runtime_data is not None original_data = entry.runtime_data @@ -178,8 +196,8 @@ def test_unload_does_not_touch_hass_data(): hass = _make_hass(unload_platforms_result=True) entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) hass_data_snapshot = dict(hass.data) result = asyncio.run(async_unload_entry(hass, entry)) @@ -210,8 +228,8 @@ def test_multi_entry_service_lifecycle(): hass = _make_hass() - p1, p2, p3, p4 = _patch_deps_iter([coord_a, coord_b]) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps_iter([coord_a, coord_b]) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry_a)) asyncio.run(async_setup_entry(hass, entry_b)) @@ -247,8 +265,8 @@ def test_options_flow_reload_cycle(): coord_v1 = _make_coordinator() coord_v2 = _make_coordinator() - p1, p2, p3, p4 = _patch_deps_iter([coord_v1, coord_v2]) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps_iter([coord_v1, coord_v2]) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) assert entry.runtime_data.coordinator is coord_v1 @@ -279,8 +297,8 @@ def test_service_handlers_resolve_fresh_coordinator(): hass = _make_hass() entry = _make_entry() - p1, p2, p3, p4 = _patch_deps(original_coord) - with p1, p2, p3, p4: + p1, p2, p3, p4, p5, p6 = _patch_deps(original_coord) + with p1, p2, p3, p4, p5, p6: asyncio.run(async_setup_entry(hass, entry)) # Capture the rank_alternatives handler from the registration call. diff --git a/tests/test_silver_checklist.py b/tests/test_silver_checklist.py new file mode 100644 index 0000000..0d3bbc2 --- /dev/null +++ b/tests/test_silver_checklist.py @@ -0,0 +1,173 @@ +"""Phase 8 PR-9 — HA Silver tickbox tests. + +Verifies the load-bearing invariants of the Silver flip: +- manifest declares quality_scale=silver + version bumped. +- quality_scale.yaml parses + has all expected rules. +- sensor.py declares PARALLEL_UPDATES. +- Service handlers use action-exceptions discipline. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def _manifest() -> dict: + return json.load( + open(REPO / "custom_components" / "pricehawk" / "manifest.json") + ) + + +def _quality_scale() -> dict: + try: + import yaml # type: ignore[import-not-found] + except ImportError: + # Fall back to a tiny YAML subset parser sufficient for our format. + raw = ( + REPO / "custom_components" / "pricehawk" / "quality_scale.yaml" + ).read_text() + return _parse_quality_scale(raw) + return yaml.safe_load( + (REPO / "custom_components" / "pricehawk" / "quality_scale.yaml").read_text() + ) + + +def _parse_quality_scale(raw: str) -> dict: + """Tiny YAML parser specific to quality_scale.yaml shape. + + Format: + rules: + rule_name: + status: done|exempt|todo + comment: >- + text... + """ + rules: dict[str, dict[str, str]] = {} + current_rule: str | None = None + current_key: str | None = None + multiline_collect: list[str] = [] + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + # Rule heading " rule-name:" at 2 spaces of indent. + stripped = line.rstrip() + indent = len(line) - len(line.lstrip()) + if indent == 2 and stripped.endswith(":") and ":" in stripped: + current_rule = stripped.strip().rstrip(":") + rules[current_rule] = {} + current_key = None + multiline_collect = [] + continue + if indent == 4 and ":" in stripped: + # End any pending multiline collect. + if current_key and multiline_collect: + rules[current_rule][current_key] = " ".join(multiline_collect).strip() + multiline_collect = [] + key, _, value = stripped.strip().partition(":") + value = value.strip() + if value in ("", ">-", ">"): + current_key = key + multiline_collect = [] + else: + rules[current_rule][key] = value + current_key = None + continue + if indent >= 6 and current_key: + multiline_collect.append(stripped.strip()) + if current_key and multiline_collect: + rules[current_rule][current_key] = " ".join(multiline_collect).strip() + return {"rules": rules} + + +class TestManifest: + def test_quality_scale_silver(self): + assert _manifest()["quality_scale"] == "silver" + + def test_version_bumped(self): + m = _manifest() + assert m["version"] == "1.6.0-beta.1", ( + f"manifest version should be 1.6.0-beta.1, got {m['version']}" + ) + + def test_codeowner_present(self): + assert "@Artic0din" in _manifest()["codeowners"] + + def test_requirements_pin_intact(self): + # Phase 7 PR-2 pin must survive the Silver flip. + reqs = _manifest()["requirements"] + assert any("openelectricity" in r for r in reqs) + + +class TestQualityScaleYaml: + def test_file_parses(self): + qs = _quality_scale() + assert "rules" in qs + + def test_silver_rules_marked_done(self): + qs = _quality_scale() + silver_done = ( + "reauthentication-flow", + "reconfiguration-flow", + "parallel-updates", + "action-exceptions", + "config-entry-unloading", + "entity-unavailable", + "integration-owner", + "test-coverage", + ) + for rule in silver_done: + assert rule in qs["rules"], f"quality_scale.yaml missing {rule}" + status = qs["rules"][rule]["status"] + assert status == "done", ( + f"{rule} should be 'done' for Silver, got {status!r}" + ) + + def test_log_when_unavailable_documented_as_exempt(self): + qs = _quality_scale() + assert qs["rules"]["log-when-unavailable"]["status"] == "exempt" + + def test_diagnostics_marked_done(self): + qs = _quality_scale() + assert qs["rules"]["diagnostics"]["status"] == "done" + + def test_repairs_marked_done(self): + qs = _quality_scale() + assert qs["rules"]["repairs"]["status"] == "done" + + +class TestSensorParallelUpdates: + def test_sensor_declares_parallel_updates(self): + src = ( + REPO / "custom_components" / "pricehawk" / "sensor.py" + ).read_text() + assert "PARALLEL_UPDATES = 0" in src + + +class TestServiceHandlerExceptions: + def test_init_imports_home_assistant_error(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + assert ( + "from homeassistant.exceptions import HomeAssistantError" + in src + ) + assert "ServiceValidationError" in src + + def test_handlers_raise_home_assistant_error_on_missing_coordinator(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + # At least one raise per handler — count must match the three handlers. + assert src.count("raise HomeAssistantError(") >= 3 + + def test_handlers_raise_service_validation_error_on_bad_input(self): + src = ( + REPO / "custom_components" / "pricehawk" / "__init__.py" + ).read_text() + # backfill_history + rank_alternatives each raise on bad input. + assert src.count("raise ServiceValidationError(") >= 2 diff --git a/tests/test_static_pricing.py b/tests/test_static_pricing.py new file mode 100644 index 0000000..28e4e39 --- /dev/null +++ b/tests/test_static_pricing.py @@ -0,0 +1,345 @@ +"""Phase 7 PR-4 — static_pricing helpers tests. + +Covers AC-2 (PRD tariffPeriod → rate lookup) and AC-6 (back-compat mode +resolver). Mode-dispatch coordinator tests live in test_coordinator.py +plus the existing test_dynamic_wholesale_tariff_provider.py harness. +""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from custom_components.pricehawk.const import ( + CONF_AMBER_ENABLED, + CONF_AMBER_PRICING_MODE, + PRICING_MODE_LIVE_API, + PRICING_MODE_OFF, + PRICING_MODE_STATIC_PRD, +) +from custom_components.pricehawk.static_pricing import ( + evaluate_static_rates, + resolve_pricing_mode, +) + + +# Australian Eastern Standard Time (no DST) — matches AEMO NEM-time anchor. +AEST = ZoneInfo("Australia/Brisbane") +SYDNEY = ZoneInfo("Australia/Sydney") + + +def _all_days() -> list[str]: + return ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] + + +def _tou_plan( + *, + peak_rate: str = "0.40", + offpeak_rate: str = "0.15", + fit_rate: str = "0.05", +) -> dict: + """Build a minimal CDR PRD envelope with peak/off-peak TOU + flat FIT.""" + return { + "data": { + "electricityContract": { + "tariffPeriod": [ + { + "dailySupplyCharge": "1.10", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "type": "PEAK", + "rates": [{"unitPrice": peak_rate}], + "timeOfUse": [ + { + "days": _all_days(), + "startTime": "16:00", + "endTime": "21:00", + } + ], + }, + { + "type": "OFF_PEAK", + "rates": [{"unitPrice": offpeak_rate}], + "timeOfUse": [ + { + "days": _all_days(), + "startTime": "21:00", + "endTime": "16:00", + } + ], + }, + ], + } + ], + "solarFeedInTariff": [ + { + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [{"unitPrice": fit_rate}], + }, + } + ], + } + } + } + + +def _stepped_plan(*, step1_rate: str = "0.22", fit_rate: str = "0.05") -> dict: + return { + "data": { + "electricityContract": { + "tariffPeriod": [ + { + "dailySupplyCharge": "0.88", + "rateBlockUType": "singleRate", + "singleRate": { + "rates": [ + {"unitPrice": step1_rate, "volume": "15.0"}, + {"unitPrice": "0.28"}, + ] + }, + } + ], + "solarFeedInTariff": [ + { + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [{"unitPrice": fit_rate}], + }, + } + ], + } + } + } + + +# ---------------------------------------------------------------------- +# resolve_pricing_mode — back-compat resolver (AC-6) +# ---------------------------------------------------------------------- + + +class TestResolvePricingMode: + def test_explicit_mode_wins(self): + mode = resolve_pricing_mode( + options={CONF_AMBER_PRICING_MODE: PRICING_MODE_STATIC_PRD, + CONF_AMBER_ENABLED: True}, + data={}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_STATIC_PRD + + def test_legacy_enabled_true_maps_to_live_api(self): + mode = resolve_pricing_mode( + options={CONF_AMBER_ENABLED: True}, + data={}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_LIVE_API + + def test_legacy_enabled_in_data_maps_to_live_api(self): + # Phase 2.x — some keys lived in entry.data not entry.options. + mode = resolve_pricing_mode( + options={}, + data={CONF_AMBER_ENABLED: True}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_LIVE_API + + def test_legacy_enabled_false_maps_to_off(self): + mode = resolve_pricing_mode( + options={CONF_AMBER_ENABLED: False}, + data={}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_OFF + + def test_absent_maps_to_off(self): + mode = resolve_pricing_mode( + options={}, + data={}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_OFF + + def test_unknown_explicit_falls_through_to_legacy(self): + # Defensive: an invalid mode string ignores explicit and resolves + # via the legacy path. + mode = resolve_pricing_mode( + options={CONF_AMBER_PRICING_MODE: "garbage", + CONF_AMBER_ENABLED: True}, + data={}, + mode_key=CONF_AMBER_PRICING_MODE, + legacy_enabled_key=CONF_AMBER_ENABLED, + ) + assert mode == PRICING_MODE_LIVE_API + + +# ---------------------------------------------------------------------- +# evaluate_static_rates — PRD tariffPeriod → (import, export) (AC-2) +# ---------------------------------------------------------------------- + + +class TestEvaluateStaticRates: + def test_peak_window(self): + plan = _tou_plan(peak_rate="0.40", fit_rate="0.05") + now = datetime(2026, 5, 21, 18, 30, tzinfo=AEST) # peak + imp, exp = evaluate_static_rates(plan, now) + # 0.40 $/kWh ex-GST → 0.40 * 1.10 * 100 = 44.0 c/kWh inc-GST + assert imp == pytest.approx(44.0, rel=1e-6) + # 0.05 $/kWh ex-GST → 5.5 c/kWh inc-GST + assert exp == pytest.approx(5.5, rel=1e-6) + + def test_offpeak_window(self): + plan = _tou_plan(peak_rate="0.40", offpeak_rate="0.15", fit_rate="0.05") + now = datetime(2026, 5, 21, 22, 0, tzinfo=AEST) # off-peak + imp, exp = evaluate_static_rates(plan, now) + # 0.15 * 1.10 * 100 = 16.5 c/kWh + assert imp == pytest.approx(16.5, rel=1e-6) + assert exp == pytest.approx(5.5, rel=1e-6) + + def test_stepped_uses_first_tier(self): + """Static-PRD reflects step1 rate; live_api needed for accurate stepping.""" + plan = _stepped_plan(step1_rate="0.22", fit_rate="0.05") + now = datetime(2026, 5, 21, 12, 0, tzinfo=AEST) + imp, exp = evaluate_static_rates(plan, now) + # 0.22 * 1.10 * 100 = 24.2 c/kWh + assert imp == pytest.approx(24.2, rel=1e-6) + assert exp == pytest.approx(5.5, rel=1e-6) + + def test_empty_envelope_returns_zero(self): + assert evaluate_static_rates(None, datetime(2026, 5, 21, 12, 0, tzinfo=AEST)) == (0.0, 0.0) + assert evaluate_static_rates({}, datetime(2026, 5, 21, 12, 0, tzinfo=AEST)) == (0.0, 0.0) + assert evaluate_static_rates( + {"data": {"electricityContract": {}}}, + datetime(2026, 5, 21, 12, 0, tzinfo=AEST), + ) == (0.0, 0.0) + + def test_no_matching_window_returns_zero_import(self): + """TOU rates with windows that don't cover now_local → 0.0 import.""" + plan = { + "data": { + "electricityContract": { + "tariffPeriod": [ + { + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "rates": [{"unitPrice": "0.40"}], + "timeOfUse": [ + { + "days": ["SUN"], + "startTime": "01:00", + "endTime": "02:00", + } + ], + } + ], + } + ], + "solarFeedInTariff": [ + { + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": "0.05"}]}, + } + ], + } + } + } + # Thursday 18:30 — outside the SUN 01:00-02:00 window + now = datetime(2026, 5, 21, 18, 30, tzinfo=AEST) + imp, exp = evaluate_static_rates(plan, now) + assert imp == 0.0 + assert exp == pytest.approx(5.5, rel=1e-6) + + def test_export_time_varying_tariff(self): + """solarFeedInTariff timeVaryingTariffs → window-matched export rate.""" + plan = { + "data": { + "electricityContract": { + "tariffPeriod": [ + { + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.30"}]}, + } + ], + "solarFeedInTariff": [ + { + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "rates": [{"unitPrice": "0.15"}], + "timeVariations": [ + { + "days": _all_days(), + "startTime": "10:00", + "endTime": "14:00", + } + ], + } + ], + } + ], + } + } + } + # Noon → in the 10-14 window + now = datetime(2026, 5, 21, 12, 0, tzinfo=AEST) + _, exp = evaluate_static_rates(plan, now) + assert exp == pytest.approx(0.15 * 1.10 * 100, rel=1e-6) + + def test_negative_export_rate(self): + """Some retailers publish negative FiT (rare). Math must not crash.""" + plan = _tou_plan(peak_rate="0.40", fit_rate="-0.02") + now = datetime(2026, 5, 21, 18, 30, tzinfo=AEST) + _, exp = evaluate_static_rates(plan, now) + assert exp == pytest.approx(-0.02 * 1.10 * 100, rel=1e-6) + assert exp < 0 + + def test_dst_boundary(self): + """Sydney crosses AEDT/AEST at 02:00 AEDT first Sun Apr; brisbane stays AEST. + + Static rate lookup uses local-clock matching, so the rate returned + depends on the `now_local` timezone passed in. This documents the + contract — callers must pass a `now_local` in the HA-configured tz. + """ + plan = _tou_plan(peak_rate="0.40", offpeak_rate="0.15", fit_rate="0.05") + # Sunday 2026-04-05 02:30 in Sydney is during DST end (rate-wise, + # off-peak per the all-day off-peak window). + sydney_dst_end = datetime(2026, 4, 5, 2, 30, tzinfo=SYDNEY) + imp, _ = evaluate_static_rates(plan, sydney_dst_end) + assert imp == pytest.approx(16.5, rel=1e-6) + + def test_singleRate_no_rates_returns_zero(self): + plan = { + "data": { + "electricityContract": { + "tariffPeriod": [ + { + "rateBlockUType": "singleRate", + "singleRate": {"rates": []}, + } + ], + } + } + } + imp, exp = evaluate_static_rates( + plan, datetime(2026, 5, 21, 12, 0, tzinfo=AEST) + ) + assert imp == 0.0 + assert exp == 0.0 + + def test_envelope_without_data_wrapper(self): + """Some callers pass the inner dict directly; helper tolerates both.""" + envelope_inner = _tou_plan()["data"] + now = datetime(2026, 5, 21, 18, 30, tzinfo=AEST) + imp, exp = evaluate_static_rates(envelope_inner, now) + assert imp == pytest.approx(44.0, rel=1e-6) + assert exp == pytest.approx(5.5, rel=1e-6) 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 + )