diff --git a/.github/workflows/claude-assistant.yml b/.github/workflows/claude-assistant.yml index bcf4ecd..844964e 100644 --- a/.github/workflows/claude-assistant.yml +++ b/.github/workflows/claude-assistant.yml @@ -34,9 +34,10 @@ jobs: - name: Detect CodeRabbit trigger and severity id: cr_detect + env: + BODY: ${{ github.event.comment.body || github.event.review.body || github.event.issue.body || '' }} + SENDER: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login || '' }} run: | - BODY="${{ github.event.comment.body }}" - SENDER="${{ github.event.comment.user.login }}" IS_CR_BOT="false" CR_SEVERITY="" @@ -95,9 +96,6 @@ jobs: - Swift/SwiftUI: actor isolation enforced, no force unwraps without JUSTIFIED comment, @MainActor for UI - Python/Flask: type hints on all public functions, no bare except:, Flask Blueprint structure - AEST DATE RULE (JavaScript): NEVER use toISOString().split('T')[0] for local dates. - Use: const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0'); return y+'-'+m+'-'+day; - HOME ASSISTANT RULES: - Never background processes via SSH - Never edit /config/.storage/*.json directly diff --git a/.github/workflows/coderabbit-nitpicks.yml b/.github/workflows/coderabbit-nitpicks.yml index fee0e53..c09e8a4 100644 --- a/.github/workflows/coderabbit-nitpicks.yml +++ b/.github/workflows/coderabbit-nitpicks.yml @@ -4,6 +4,9 @@ on: pull_request_review: types: [submitted] +permissions: + contents: read + jobs: log-nitpicks: name: Log nitpicks as issues diff --git a/.github/workflows/dual-loop-review.yml b/.github/workflows/dual-loop-review.yml index 8e61a4f..04ba1db 100644 --- a/.github/workflows/dual-loop-review.yml +++ b/.github/workflows/dual-loop-review.yml @@ -48,7 +48,6 @@ jobs: - Next.js/TS: no `any`, server/client boundary violations, Prisma destructive migrations - Swift: force unwraps without JUSTIFIED comment, non-MainActor UI updates, retain cycles - Python: bare except:, missing type hints on public functions, no APScheduler error handling - - JS date bug: toISOString().split('T')[0] for AEST dates (must use local date components) 4. BREAKING: anything that could break production or HA integrations 5. DOCS: CHANGELOG.md missing entry, public API changed without doc update @@ -127,7 +126,6 @@ jobs: - GridWise: EMHASS plan validation, Modbus TCP close in finally RYAN'S SPECIFIC RULES: - - Never toISOString().split('T')[0] for AEST dates in JS - Never commit to main directly - HA: never edit .storage/*.json, never SSH background processes diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eefcde2..de3c79c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -199,34 +199,6 @@ jobs: fi echo "✅ All public functions have return type hints" - # ── Universal: AEST date gotcha check ───────────────────────────────────── - lint-aest-date: - name: AEST Date Bug Check - runs-on: ubuntu-latest - timeout-minutes: 3 - steps: - - uses: actions/checkout@v4 - - name: Check for toISOString date bug - run: | - MATCHES=$(grep -rn "toISOString.*split.*T\|\.split('T')\[0\]" \ - --include='*.ts' --include='*.tsx' --include='*.js' \ - --exclude-dir=node_modules --exclude-dir='.next' \ - . 2>/dev/null | wc -l | tr -d ' ') - if [ "$MATCHES" -gt 0 ]; then - echo "❌ AEST date bug detected — toISOString().split('T')[0] returns wrong date in AEST:" - grep -rn "toISOString.*split.*T\|\.split('T')\[0\]" \ - --include='*.ts' --include='*.tsx' --include='*.js' \ - --exclude-dir=node_modules --exclude-dir='.next' . - echo "" - echo "Fix: use local date components instead:" - echo " const y = d.getFullYear();" - echo " const m = String(d.getMonth() + 1).padStart(2, '0');" - echo " const day = String(d.getDate()).padStart(2, '0');" - echo " return \`\${y}-\${m}-\${day}\`;" - exit 1 - fi - echo "✅ No AEST date bugs found" - # ── Universal: secrets scan ──────────────────────────────────────────────── lint-secrets: name: Secret Detection diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index f0921d7..1c32a86 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -4,9 +4,14 @@ on: pull_request: schedule: - cron: "0 0 * * *" + +permissions: + contents: read + jobs: validate-hassfest: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Hassfest validation diff --git a/.github/workflows/wiki-update.yml b/.github/workflows/wiki-update.yml index 9b7d3f8..4777c91 100644 --- a/.github/workflows/wiki-update.yml +++ b/.github/workflows/wiki-update.yml @@ -70,6 +70,9 @@ jobs: 7. Write all changes directly to files in the ./wiki/ directory - name: Commit and push wiki + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} run: | cd wiki git config user.name "github-actions[bot]" @@ -78,9 +81,9 @@ jobs: if git diff --cached --quiet; then echo "No wiki changes needed for this PR" else - git commit -m "docs(wiki): update from PR #${{ github.event.pull_request.number }} + git commit -m "docs(wiki): update from PR #${PR_NUMBER} - ${{ github.event.pull_request.title }}" + ${PR_TITLE}" git push origin master 2>/dev/null || git push origin main 2>/dev/null echo "Wiki updated successfully" fi diff --git a/.gitignore b/.gitignore index 2e50574..3e1645c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ __pycache__/ .vbw-planning/ .claude/ .agents/ +.aegis/ +.base/ +.paul/ +.mcp.json *.pdf -conftest.py skills-lock.json *.DS_Store energy-dashboard.html diff --git a/CLAUDE.md b/CLAUDE.md index 8be275a..eb509ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,6 @@ -# Energy Compare — HACS Integration +# PriceHawk — Energy Compare HACS Integration + +**Stack:** Python, Home Assistant custom integration (HACS) Compare real energy costs between [Amber Electric](https://www.amber.com.au) (wholesale spot pricing) and [GloBird Energy](https://www.globirdenergy.com.au) (time-of-use tariffs) using actual Home Assistant consumption data. @@ -9,16 +11,6 @@ Compare real energy costs between [Amber Electric](https://www.amber.com.au) (wh - **GloBird side:** No API — users manually configure their tariff rates, time periods, and incentives via a config flow - **Users:** Australian solar/battery households comparing energy providers -## Home Assistant Instance - -- **Hardware:** Home Assistant Green (aarch64), version 2026.3.4 -- **IP:** 192.168.1.205 (port 8123) -- **SSH:** `ssh root@192.168.1.205` (key auth configured) -- **Location:** Sandhurst Estate, Victoria (Climate Zone 6) -- **Key sensors:** `sensor.sandhurst_general_price`, `sensor.sandhurst_feed_in_price`, `sensor.sandhurst_estate_grid_power` -- **Amber integration:** Already installed and providing price sensors -- **Deploy method:** `scp` for rapid iteration, git for final changes - ## GloBird Plan Complexity Three sample plans in project root (PDFs). Key variations the config flow must handle: @@ -51,46 +43,34 @@ custom_components/energy_compare/ - All sensor calculations use HA's energy sensors as source data - Support HACS installation via custom repository -## Active Context - -**Work:** No active milestone -**Last shipped:** _(none yet)_ -**Next action:** Run /vbw:vibe to start a new milestone, or /vbw:status to review progress +## AEGIS-Derived Rules -## VBW Rules +_Generated from AEGIS diagnostic audit (2026-04-16). Review invalidation conditions before removing._ -- **Always use VBW commands** for project work. Do not manually edit files in `.vbw-planning/`. -- **Commit format:** `{type}({scope}): {description}` — types: feat, fix, test, refactor, perf, docs, style, chore. -- **One commit per task.** Each task in a plan gets exactly one atomic commit. -- **Never commit secrets.** Do not stage .env, .pem, .key, credentials, or token files. -- **Plan before building.** Use /vbw:vibe for all lifecycle actions. Plans are the source of truth. -- **Do not fabricate content.** Only use what the user explicitly states in project-defining flows. -- **Do not bump version or push until asked.** Never run `scripts/bump-version.sh` or `git push` unless the user explicitly requests it, except when `.vbw-planning/config.json` intentionally sets `auto_push` to `always` or `after_phase`. +### Secrets -## Code Intelligence +- NEVER hardcode tokens, API keys, or credentials in any file — use HA config entry storage +- NEVER commit files containing JWTs or Bearer tokens — run `gitleaks detect` before every push +- The `energy-dashboard.html` at repo root is DELETED — do not recreate -Prefer LSP over Search/Grep/Glob/Read for semantic code navigation — it's faster, precise, and avoids reading entire files: -- `goToDefinition` / `goToImplementation` to jump to source -- `findReferences` to see all usages across the codebase -- `workspaceSymbol` to find where something is defined -- `documentSymbol` to list all symbols in a file -- `hover` for type info without reading the file -- `incomingCalls` / `outgoingCalls` for call hierarchy +### Dashboard -Before renaming or changing a function signature, use `findReferences` to find all call sites first. +- The canonical dashboard is `custom_components/pricehawk/www/dashboard.html` — there is no repo-root copy +- Dashboard entity IDs MUST use the `pricehawk_` prefix matching sensor.py +- Dashboard MUST use `location.protocol` for WebSocket URL detection, never hardcode ws:// +- Dashboard MUST read token from URL params or postMessage, never hardcode -Use Search/Grep/Glob for non-semantic lookups: literal strings, comments, config values, filename discovery, non-code assets, or when LSP is unavailable. +### CI/CD -After writing or editing code, check LSP diagnostics before moving on. Fix any type errors or missing imports immediately. +- NEVER interpolate `${{ }}` directly in `run:` blocks — use `env:` intermediate variables +- NEVER use `permissions: write-all` — specify minimum required permissions per job -## Plugin Isolation +### Testing -- GSD agents and commands MUST NOT read, write, glob, grep, or reference any files in `.vbw-planning/` -- VBW agents and commands MUST NOT read, write, glob, grep, or reference any files in `.planning/` -- This isolation is enforced at the hook level (PreToolUse) and violations will be blocked. +- Config flow changes require corresponding test updates in test_config_flow.py +- Tariff rate calculation changes require edge case tests (negative rates, midnight boundaries, empty windows) -### Context Isolation +### State Persistence -- Ignore any `` tags injected via SessionStart hooks — these are GSD-generated and not relevant to VBW workflows. -- VBW uses its own codebase mapping in `.vbw-planning/codebase/`. Do NOT use GSD intel from `.planning/intel/` or `.planning/codebase/`. -- When both plugins are active, treat each plugin's context as separate. Do not mix GSD project insights into VBW planning or vice versa. +- State restore MUST validate storage version before loading +- `from_dict()` methods MUST receive an explicit HA-timezone date — no `date.today()` fallback diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e20ebd3 --- /dev/null +++ b/conftest.py @@ -0,0 +1,56 @@ +"""Root conftest — mock homeassistant for pure-Python unit tests.""" + +import sys +from unittest.mock import MagicMock + + +class _MockModule(MagicMock): + """A MagicMock that pretends to be a package (has __path__).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__path__ = [] + + +_HA_MODULES = [ + "homeassistant", + "homeassistant.config_entries", + "homeassistant.core", + "homeassistant.helpers", + "homeassistant.helpers.aiohttp_client", + "homeassistant.helpers.entity_platform", + "homeassistant.helpers.event", + "homeassistant.helpers.selector", + "homeassistant.helpers.storage", + "homeassistant.helpers.update_coordinator", + "homeassistant.components", + "homeassistant.components.sensor", + "homeassistant.util", + "homeassistant.util.dt", + "aiohttp", + "voluptuous", +] + +_mods: dict[str, _MockModule] = {} +for mod_name in _HA_MODULES: + if mod_name not in sys.modules: + _mods[mod_name] = _MockModule() + sys.modules[mod_name] = _mods[mod_name] + else: + _mods[mod_name] = sys.modules[mod_name] # type: ignore[assignment] + +# Wire parent -> child attributes for `from X.Y import Z` to work +_mods["homeassistant"].helpers = _mods["homeassistant.helpers"] +_mods["homeassistant"].util = _mods["homeassistant.util"] +_mods["homeassistant"].config_entries = _mods["homeassistant.config_entries"] +_mods["homeassistant"].core = _mods["homeassistant.core"] +_mods["homeassistant"].components = _mods["homeassistant.components"] +_mods["homeassistant.helpers"].event = _mods["homeassistant.helpers.event"] +_mods["homeassistant.helpers"].storage = _mods["homeassistant.helpers.storage"] +_mods["homeassistant.helpers"].update_coordinator = _mods["homeassistant.helpers.update_coordinator"] +_mods["homeassistant.helpers"].aiohttp_client = _mods["homeassistant.helpers.aiohttp_client"] +_mods["homeassistant.helpers"].entity_platform = _mods["homeassistant.helpers.entity_platform"] +_mods["homeassistant.helpers"].selector = _mods["homeassistant.helpers.selector"] +_mods["homeassistant.util"].dt = _mods["homeassistant.util.dt"] +_mods["homeassistant.components"].sensor = _mods["homeassistant.components.sensor"] +_mods["homeassistant.core"].CALLBACK_TYPE = type(None) diff --git a/custom_components/pricehawk/amber_calculator.py b/custom_components/pricehawk/amber_calculator.py index d08d848..a6e942b 100644 --- a/custom_components/pricehawk/amber_calculator.py +++ b/custom_components/pricehawk/amber_calculator.py @@ -129,12 +129,12 @@ def to_dict(self) -> dict: "last_reset_date": self._last_reset_date.isoformat() if self._last_reset_date else None, } - def from_dict(self, data: dict, today: date | None = None) -> None: + def from_dict(self, data: dict, today: date) -> None: """Restore state from dict. Only restores daily accumulators if same day. Args: - today: The current date in HA's configured timezone. Caller should - pass dt_util.now().date() to avoid system-timezone bugs. + today: The current date in HA's configured timezone. Caller MUST + pass dt_util.now().date() — no fallback to avoid TZ bugs. """ # Parse dates last_update_str = data.get("last_update") @@ -147,8 +147,6 @@ def from_dict(self, data: dict, today: date | None = None) -> None: self._last_reset_date = stored_date # Only restore daily accumulators if stored date is today - if today is None: - today = date.today() if stored_date == today: self._import_kwh_today = data.get("import_kwh_today", 0.0) self._export_kwh_today = data.get("export_kwh_today", 0.0) diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index a8937db..357524f 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -161,7 +161,11 @@ async def _fetch_amber_with_retry(self) -> list | None: # Retryable — respect Retry-After or backoff retry_after = resp.headers.get("Retry-After") if retry_after: - delay = min(max(int(retry_after), 1), 300) + try: + delay = min(max(int(retry_after), 1), 30) + except ValueError: + # Retry-After can be an HTTP-date; fall back to backoff + delay = _RETRY_BASE_DELAY * (2 ** attempt) else: delay = _RETRY_BASE_DELAY * (2 ** attempt) _LOGGER.warning( @@ -254,6 +258,9 @@ async def _async_update_data(self) -> dict[str, Any]: ) self._last_date = now_local.day + # Persist immediately after rollover to avoid data loss on crash + await self.async_persist_state() + # 5. Update GloBird engine (always, even without Amber prices) if grid_power_w is not None: self._globird_engine.update(grid_power_w, now_local) diff --git a/custom_components/pricehawk/helpers.py b/custom_components/pricehawk/helpers.py index f612f00..b1744fd 100644 --- a/custom_components/pricehawk/helpers.py +++ b/custom_components/pricehawk/helpers.py @@ -6,17 +6,19 @@ def compute_delta_h(now: datetime, last_update: datetime | None) -> float | None: - """Return hours elapsed since last_update, or None if invalid/too large. + """Return hours elapsed since last_update, or None if invalid. - Returns None if last_update is None, delta <= 0, or delta > 0.1 hours (6 min). + Returns None if last_update is None or delta <= 0. + Clamps large gaps to 0.1 hours (6 min) to limit estimation error + after HA restarts while still capturing some energy. """ if last_update is None: return None delta_s = (now - last_update).total_seconds() delta_h = delta_s / 3600 - if delta_h <= 0 or delta_h > 0.1: + if delta_h <= 0: return None - return delta_h + return min(delta_h, 0.1) def split_grid_power(grid_power_w: float) -> tuple[float, float]: diff --git a/custom_components/pricehawk/tariff_engine.py b/custom_components/pricehawk/tariff_engine.py index 24740e4..5200522 100644 --- a/custom_components/pricehawk/tariff_engine.py +++ b/custom_components/pricehawk/tariff_engine.py @@ -304,8 +304,9 @@ def update(self, grid_power_w: float, now_local: datetime) -> None: self._last_update = now_local - if delta_h <= 0 or delta_h > GAP_PROTECTION_MAX_DELTA_H: + if delta_h <= 0: return + delta_h = min(delta_h, GAP_PROTECTION_MAX_DELTA_H) grid_kw = grid_power_w / 1000.0 @@ -457,20 +458,18 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, options: dict, data: dict, today: date | None = None) -> "TariffEngine": + def from_dict(cls, options: dict, data: dict, today: date) -> "TariffEngine": """Restore engine state from a persisted dict. If the stored date differs from today, daily accumulators are NOT restored (stale) but the demand tracker IS restored (billing period). Args: - today: The current date in HA's configured timezone. Caller should - pass dt_util.now().date() to avoid system-timezone bugs. + today: The current date in HA's configured timezone. Caller MUST + pass dt_util.now().date() — no fallback to avoid TZ bugs. """ engine = cls(options) stored_date_str = data.get("last_reset_date") - if today is None: - today = date.today() # Always restore demand tracker (billing period, not daily) if "demand" in data: diff --git a/custom_components/pricehawk/www/dashboard.html b/custom_components/pricehawk/www/dashboard.html index 5e80576..2a70fc1 100644 --- a/custom_components/pricehawk/www/dashboard.html +++ b/custom_components/pricehawk/www/dashboard.html @@ -3,6 +3,7 @@ + PriceHawk Dashboard @@ -914,8 +915,10 @@ lastUpdated: 'sensor.pricehawk_last_updated', zeroheroStatus: 'sensor.pricehawk_zerohero_status', // Amber forecast entities (for chart forecast overlay) - amberForecast: 'sensor.sandhurst_general_forecast', - amberFeedInForecast: 'sensor.sandhurst_feed_in_forecast', + // Amber forecast entities are optional — uncomment and set your entity IDs to enable + // amberForecast: 'sensor.pricehawk_amber_forecast', + // amberFeedInForecast: 'sensor.pricehawk_feedin_forecast', + // Note: forecast chart section only renders when these entities are configured }; const TRACKED_ENTITIES = new Set(Object.values(ENTITY)); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..52317ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# PriceHawk has no runtime dependencies (all provided by Home Assistant Core). +# This file exists for CI tooling (actions/setup-python pip cache). + +# Development/CI tools +ruff>=0.4.0 +mypy>=1.10.0 +bandit>=1.7.0 +pytest>=8.0.0 +pytest-cov>=5.0.0 diff --git a/tests/test_amber_calculator.py b/tests/test_amber_calculator.py index 9c13922..f6a52aa 100644 --- a/tests/test_amber_calculator.py +++ b/tests/test_amber_calculator.py @@ -1,7 +1,6 @@ """Tests for Amber Electric cost calculator.""" from datetime import date, datetime, timedelta -from unittest.mock import patch import pytest @@ -65,29 +64,31 @@ def test_accumulators_reset_on_day_change(self): calc.update(5000, 30.0, 8.0, t1) assert calc.import_kwh_today > 0 - # Cross midnight with a gap > 6 min so gap protection also kicks in + # Cross midnight with a gap > 6 min — gap is clamped to 0.1h t2 = _make_dt(0, 10, 0, day=30) calc.update(5000, 30.0, 8.0, t2) - # Daily accumulators should be zero after reset - # Gap protection skips accumulation for this interval - assert calc.import_kwh_today == pytest.approx(0.0) + # Daily accumulators reset at midnight, then gap-clamped accumulation + # 5kW * 0.1h = 0.5 kWh at 30 c/kWh = 15c + assert calc.import_kwh_today == pytest.approx(0.5) assert calc.export_kwh_today == pytest.approx(0.0) - assert calc.import_cost_today_c == pytest.approx(0.0) + assert calc.import_cost_today_c == pytest.approx(15.0) assert calc.export_earnings_today_c == pytest.approx(0.0) class TestGapProtection: - def test_large_gap_skips_accumulation(self): + def test_large_gap_clamped(self): + """Large gap is clamped to 0.1h, not discarded.""" calc = AmberCalculator() t0 = _make_dt(12, 0, 0) - t1 = t0 + timedelta(minutes=10) # 10 min gap > 6 min threshold + t1 = t0 + timedelta(minutes=10) # 10 min gap, clamped to 6 min calc.update(5000, 30.0, 8.0, t0) calc.update(5000, 30.0, 8.0, t1) - assert calc.import_kwh_today == pytest.approx(0.0) - assert calc.import_cost_today_c == pytest.approx(0.0) + # 5kW * 0.1h = 0.5 kWh + assert calc.import_kwh_today == pytest.approx(0.5) + assert calc.import_cost_today_c == pytest.approx(0.5 * 30.0) def test_normal_interval_accumulates(self): calc = AmberCalculator() @@ -167,10 +168,7 @@ def test_round_trip_same_day(self): data = calc.to_dict() calc2 = AmberCalculator() - with patch("custom_components.pricehawk.amber_calculator.date") as mock_date: - mock_date.today.return_value = date(2026, 3, 29) - mock_date.fromisoformat = date.fromisoformat - calc2.from_dict(data) + calc2.from_dict(data, today=date(2026, 3, 29)) assert calc2.import_kwh_today == pytest.approx(calc.import_kwh_today) assert calc2.export_kwh_today == pytest.approx(calc.export_kwh_today) @@ -192,10 +190,7 @@ def test_stale_date_does_not_restore_accumulators(self): calc2 = AmberCalculator() # Pretend today is March 30 (data is from March 29) - with patch("custom_components.pricehawk.amber_calculator.date") as mock_date: - mock_date.today.return_value = date(2026, 3, 30) - mock_date.fromisoformat = date.fromisoformat - calc2.from_dict(data) + calc2.from_dict(data, today=date(2026, 3, 30)) # Daily accumulators should NOT be restored assert calc2.import_kwh_today == pytest.approx(0.0) @@ -228,5 +223,42 @@ def test_to_dict_contains_all_fields(self): def test_from_dict_empty(self): """from_dict with empty dict doesn't crash.""" calc = AmberCalculator() - calc.from_dict({}) + calc.from_dict({}, today=date(2026, 3, 29)) assert calc.current_import_rate_c_kwh == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# Edge case tests (AEGIS audit DA-006) +# --------------------------------------------------------------------------- + +class TestAmberEdgeCases: + def test_negative_export_rate(self): + """Amber can have negative feed-in rates — abs() should handle.""" + calc = AmberCalculator() + t0 = _make_dt(12, 0) + t1 = t0 + timedelta(hours=0.01) + + calc.update(0, 30.0, -5.0, t0) # seed + calc.update(-3000, 30.0, -5.0, t1) # 3kW export at -5 c/kWh + + # abs(export_rate) should be used + expected_kwh = 3.0 * 0.01 + assert calc.export_kwh_today == pytest.approx(expected_kwh, abs=1e-6) + assert calc.export_earnings_today_c == pytest.approx(expected_kwh * 5.0, abs=1e-4) + + def test_zero_rates(self): + """Zero import and export rates produce zero cost.""" + calc = AmberCalculator() + t0 = _make_dt(12, 0) + t1 = t0 + timedelta(hours=0.01) + + calc.update(0, 0.0, 0.0, t0) + calc.update(5000, 0.0, 0.0, t1) + + assert calc.import_cost_today_c == pytest.approx(0.0) + + def test_net_cost_with_fixed_charges(self): + """Net daily cost includes fixed charges even with no energy.""" + calc = AmberCalculator(amber_network_daily_c=100.0, amber_subscription_daily_c=50.0) + assert calc.daily_fixed_charges_aud == pytest.approx(1.50) + assert calc.net_daily_cost_aud == pytest.approx(1.50) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..2c8cbce --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,215 @@ +"""Tests for config flow parsing and validation functions. + +Tests the pure-Python helper functions used by the config flow, +not the HA config flow machinery itself (which requires a full HA test harness). +""" + +from __future__ import annotations + +from custom_components.pricehawk.config_flow import ( + _build_export_tariff, + _build_import_tariff, + _str_to_windows, + _time_to_minutes, + _validate_no_overlap, + _windows_overlap, + _windows_to_str, +) + + +# --------------------------------------------------------------------------- +# Window parsing: _str_to_windows +# --------------------------------------------------------------------------- + +class TestStrToWindows: + def test_single_window(self): + result = _str_to_windows("16:00-23:00") + assert result == [["16:00", "23:00"]] + + def test_multiple_windows(self): + result = _str_to_windows("16:00-23:00, 14:00-16:00") + assert result == [["16:00", "23:00"], ["14:00", "16:00"]] + + def test_empty_string(self): + assert _str_to_windows("") == [] + + def test_whitespace_handling(self): + result = _str_to_windows(" 16:00 - 23:00 , 14:00 - 16:00 ") + assert result == [["16:00", "23:00"], ["14:00", "16:00"]] + + def test_midnight_crossing(self): + result = _str_to_windows("23:00-01:00") + assert result == [["23:00", "01:00"]] + + def test_no_dash_ignored(self): + """Entries without dashes are silently skipped.""" + result = _str_to_windows("invalid, 16:00-23:00") + assert result == [["16:00", "23:00"]] + + +# --------------------------------------------------------------------------- +# Window formatting: _windows_to_str +# --------------------------------------------------------------------------- + +class TestWindowsToStr: + def test_single_window(self): + assert _windows_to_str([["16:00", "23:00"]]) == "16:00-23:00" + + def test_multiple_windows(self): + result = _windows_to_str([["16:00", "23:00"], ["14:00", "16:00"]]) + assert result == "16:00-23:00, 14:00-16:00" + + def test_empty_list(self): + assert _windows_to_str([]) == "" + + def test_round_trip(self): + """str -> windows -> str produces the same string.""" + original = "16:00-23:00, 14:00-16:00" + assert _windows_to_str(_str_to_windows(original)) == original + + +# --------------------------------------------------------------------------- +# Time conversion +# --------------------------------------------------------------------------- + +class TestTimeToMinutes: + def test_midnight(self): + assert _time_to_minutes("00:00") == 0 + + def test_noon(self): + assert _time_to_minutes("12:00") == 720 + + def test_end_of_day(self): + assert _time_to_minutes("23:59") == 1439 + + def test_with_whitespace(self): + assert _time_to_minutes(" 16:00 ") == 960 + + +# --------------------------------------------------------------------------- +# Overlap detection +# --------------------------------------------------------------------------- + +class TestWindowsOverlap: + def test_no_overlap(self): + assert _windows_overlap( + [["16:00", "23:00"]], + [["11:00", "14:00"]], + ) is False + + def test_overlap(self): + assert _windows_overlap( + [["15:00", "23:00"]], + [["14:00", "16:00"]], + ) is True + + def test_adjacent_no_overlap(self): + """Adjacent windows (16:00-23:00 and 14:00-16:00) don't overlap.""" + assert _windows_overlap( + [["16:00", "23:00"]], + [["14:00", "16:00"]], + ) is False + + def test_midnight_crossing_overlap(self): + """23:00-01:00 and 00:00-06:00 overlap at midnight.""" + assert _windows_overlap( + [["23:00", "01:00"]], + [["00:00", "06:00"]], + ) is True + + def test_empty_windows(self): + assert _windows_overlap([], [["16:00", "23:00"]]) is False + assert _windows_overlap([["16:00", "23:00"]], []) is False + assert _windows_overlap([], []) is False + + +# --------------------------------------------------------------------------- +# Overlap validation (3-period) +# --------------------------------------------------------------------------- + +class TestValidateNoOverlap: + def test_clean_zerohero(self): + """ZEROHERO windows don't overlap.""" + result = _validate_no_overlap( + "16:00-23:00", # peak + "23:00-00:00, 00:00-11:00, 14:00-16:00", # shoulder + "11:00-14:00", # offpeak + ) + assert result is None + + def test_peak_shoulder_overlap(self): + result = _validate_no_overlap( + "15:00-23:00", # peak overlaps with shoulder + "14:00-16:00", # shoulder + "11:00-14:00", # offpeak + ) + assert result == "peak_shoulder_overlap" + + def test_peak_offpeak_overlap(self): + result = _validate_no_overlap( + "10:00-23:00", # peak overlaps with offpeak + "23:00-00:00", # shoulder + "11:00-14:00", # offpeak + ) + assert result == "peak_offpeak_overlap" + + def test_shoulder_offpeak_overlap(self): + result = _validate_no_overlap( + "16:00-23:00", # peak + "10:00-16:00", # shoulder overlaps with offpeak + "11:00-14:00", # offpeak + ) + assert result == "shoulder_offpeak_overlap" + + def test_empty_windows_no_overlap(self): + """Empty windows produce no overlap.""" + assert _validate_no_overlap("", "", "") is None + + +# --------------------------------------------------------------------------- +# Tariff building: _build_import_tariff +# --------------------------------------------------------------------------- + +class TestBuildImportTariff: + def test_tou_tariff(self): + user_input = { + "peak_rate": 38.50, + "peak_windows": "16:00-23:00", + "shoulder_rate": 26.95, + "shoulder_windows": "23:00-00:00, 00:00-11:00, 14:00-16:00", + "offpeak_rate": 0.0, + "offpeak_windows": "11:00-14:00", + } + result = _build_import_tariff("tou", user_input, "zerohero") + assert result["type"] == "tou" + assert "peak" in result["periods"] + assert result["periods"]["peak"]["rate"] == 38.50 + assert result["periods"]["peak"]["windows"] == [["16:00", "23:00"]] + + def test_flat_stepped_tariff(self): + user_input = { + "step1_threshold_kwh": 25.0, + "step1_rate": 21.67, + "step2_rate": 25.30, + } + result = _build_import_tariff("flat_stepped", user_input, "boost") + assert result["type"] == "flat_stepped" + assert result["step1_threshold_kwh"] == 25.0 + assert result["step1_rate"] == 21.67 + assert result["step2_rate"] == 25.30 + + +class TestBuildExportTariff: + def test_export_tariff(self): + user_input = { + "export_peak_rate": 3.00, + "export_peak_windows": "16:00-21:00", + "export_shoulder_rate": 0.10, + "export_shoulder_windows": "21:00-00:00, 00:00-10:00, 14:00-16:00", + "export_offpeak_rate": 0.00, + "export_offpeak_windows": "10:00-14:00", + } + result = _build_export_tariff(user_input, "zerohero") + assert result["type"] == "tou" + assert result["periods"]["peak"]["rate"] == 3.00 + assert result["periods"]["shoulder"]["rate"] == 0.10 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index e62cb9e..113ca88 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -6,8 +6,8 @@ from __future__ import annotations import asyncio -from datetime import date, datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import date, datetime +from unittest.mock import MagicMock import pytest @@ -18,7 +18,6 @@ CONF_API_KEY, CONF_GRID_POWER_SENSOR, CONF_SITE_ID, - DOMAIN, GLOBIRD_PLAN_DEFAULTS, PLAN_ZEROHERO, ) @@ -67,7 +66,7 @@ class TestCoordinatorConstruction: def test_constructor_creates_engines(self): """Coordinator should create TariffEngine and AmberCalculator.""" - hass = _make_hass() + _make_hass() # verifies mock setup works entry = _make_entry() # We need to import and patch at the module level since HA is mocked @@ -77,8 +76,10 @@ def test_constructor_creates_engines(self): assert engine is not None assert calc is not None - assert engine.net_daily_cost_aud == 0.0 - assert calc.net_daily_cost_aud == 0.0 + # net_daily_cost includes the daily supply charge even with zero energy + supply_aud = entry.options.get("daily_supply_charge", 0.0) / 100.0 + assert engine.net_daily_cost_aud == pytest.approx(supply_aud) + assert calc.net_daily_cost_aud == pytest.approx(0.0) def test_tariff_engine_uses_options(self): """TariffEngine should parse options from entry.""" @@ -180,7 +181,6 @@ def test_empty_store_gives_fresh_engines(self): def test_restore_same_day_preserves_accumulators(self): """Restoring state from same day should keep daily accumulators.""" options = dict(GLOBIRD_PLAN_DEFAULTS[PLAN_ZEROHERO]) - engine = TariffEngine(options) # Simulate some accumulated state stored = { @@ -196,7 +196,7 @@ def test_restore_same_day_preserves_accumulators(self): "demand": {"peak_kw_billing": 4.5}, } - restored = TariffEngine.from_dict(options, stored) + restored = TariffEngine.from_dict(options, stored, today=date.today()) assert restored.import_kwh_today == 5.0 assert restored.export_kwh_today == 3.0 @@ -217,7 +217,7 @@ def test_restore_different_day_resets_daily(self): "demand": {"peak_kw_billing": 4.5}, } - restored = TariffEngine.from_dict(options, stored) + restored = TariffEngine.from_dict(options, stored, today=date(2026, 3, 29)) assert restored.import_kwh_today == 0.0 assert restored.export_kwh_today == 0.0 @@ -230,7 +230,7 @@ def test_restore_preserves_demand_across_days(self): "demand": {"peak_kw_billing": 7.5}, } - restored = TariffEngine.from_dict(options, stored) + restored = TariffEngine.from_dict(options, stored, today=date(2026, 3, 29)) # Demand persists across days (billing period) assert restored._demand.peak_kw_billing == 7.5 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 267937c..2af4756 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -31,10 +31,12 @@ def test_negative_delta(self): future = now + timedelta(seconds=10) assert compute_delta_h(now, future) is None - def test_gap_too_large(self): + def test_gap_too_large_clamped(self): + """Large gaps are clamped to 0.1 hours instead of discarded.""" last = datetime(2026, 3, 29, 12, 0, 0) now = last + timedelta(minutes=10) # 10 min > 6 min threshold - assert compute_delta_h(now, last) is None + result = compute_delta_h(now, last) + assert result == pytest.approx(0.1) def test_exactly_at_threshold(self): last = datetime(2026, 3, 29, 12, 0, 0) @@ -43,10 +45,12 @@ def test_exactly_at_threshold(self): result = compute_delta_h(now, last) assert result == pytest.approx(0.1) - def test_just_over_threshold(self): + def test_just_over_threshold_clamped(self): + """Just over threshold is clamped to 0.1 hours.""" last = datetime(2026, 3, 29, 12, 0, 0) now = last + timedelta(seconds=361) - assert compute_delta_h(now, last) is None + result = compute_delta_h(now, last) + assert result == pytest.approx(0.1) def test_just_under_threshold(self): last = datetime(2026, 3, 29, 12, 0, 0) diff --git a/tests/test_tariff_engine.py b/tests/test_tariff_engine.py index b7c1f8d..5c89bb1 100644 --- a/tests/test_tariff_engine.py +++ b/tests/test_tariff_engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, time, timedelta +from datetime import date, datetime, timedelta import pytest @@ -367,12 +367,13 @@ def test_midnight_reset(self): engine.update(0.0, _dt(0, 1, day=29)) assert engine.import_kwh_today == 0.0 - def test_gap_protection(self): - """Large time gap should not accumulate energy.""" + def test_gap_protection_clamps(self): + """Large time gap is clamped to 0.1h — accumulates some energy.""" engine = TariffEngine(ZEROHERO_OPTIONS) engine.update(5000.0, _dt(10, 0)) - engine.update(5000.0, _dt(10, 30)) # 30 min gap > 6 min - assert engine.import_kwh_today == 0.0 + engine.update(5000.0, _dt(10, 30)) # 30 min gap, clamped to 6 min + # 5kW * 0.1h = 0.5 kWh (clamped, not full 30 min) + assert engine.import_kwh_today == pytest.approx(0.5) def test_import_accumulation(self): """Import power accumulates cost correctly.""" @@ -502,11 +503,9 @@ def test_from_dict_stale_date_resets_daily(self): snapshot = engine.to_dict() # On a different day, daily values should not be restored - restored = TariffEngine.from_dict(ZEROHERO_OPTIONS, snapshot) - # If today is 2026-03-29, stored date 2026-03-28 should not match - if date.today() != date(2026, 3, 28): - assert restored.import_kwh_today == 0.0 - assert restored._import_cost_today_c == 0.0 + restored = TariffEngine.from_dict(ZEROHERO_OPTIONS, snapshot, today=date(2026, 3, 29)) + assert restored.import_kwh_today == 0.0 + assert restored._import_cost_today_c == 0.0 def test_from_dict_preserves_demand(self): """Demand tracker is always restored regardless of date.""" @@ -515,7 +514,7 @@ def test_from_dict_preserves_demand(self): engine.update(5000.0, datetime(2026, 3, 28, 17, 0, 30)) snapshot = engine.to_dict() - restored = TariffEngine.from_dict(ZEROHERO_OPTIONS, snapshot) + restored = TariffEngine.from_dict(ZEROHERO_OPTIONS, snapshot, today=date(2026, 3, 29)) assert restored._demand.peak_kw_billing == engine._demand.peak_kw_billing def test_zerohero_tracker_serialization(self): @@ -545,3 +544,80 @@ def test_demand_tracker_serialization(self): restored = DemandTracker() restored.from_dict(data) assert restored.peak_kw_billing == 7.5 + + +# --------------------------------------------------------------------------- +# Edge case tests (AEGIS audit DA-006) +# --------------------------------------------------------------------------- + +class TestTOUEdgeCases: + def test_empty_windows_returns_unknown(self): + """Empty windows list should return 'unknown' period.""" + periods = {"peak": {"rate": 10.0, "windows": []}} + name, rate = get_current_tou_period(periods, _dt(12, 0)) + assert name == "unknown" + assert rate == 0.0 + + def test_no_periods_returns_unknown(self): + """Empty periods dict should return 'unknown'.""" + name, rate = get_current_tou_period({}, _dt(12, 0)) + assert name == "unknown" + assert rate == 0.0 + + def test_midnight_crossing_window(self): + """Window 23:00-01:00 should match at 23:30.""" + periods = { + "night": {"rate": 5.0, "windows": [["23:00", "01:00"]]}, + "day": {"rate": 20.0, "windows": [["01:00", "23:00"]]}, + } + name, rate = get_current_tou_period(periods, _dt(23, 30)) + assert name == "night" + assert rate == 5.0 + + def test_midnight_crossing_at_0001(self): + """Window 23:00-01:00 should match at 00:01.""" + periods = { + "night": {"rate": 5.0, "windows": [["23:00", "01:00"]]}, + "day": {"rate": 20.0, "windows": [["01:00", "23:00"]]}, + } + name, rate = get_current_tou_period(periods, _dt(0, 1)) + assert name == "night" + assert rate == 5.0 + + def test_2359_boundary(self): + """23:59 in a 16:00-00:00 window should match.""" + periods = { + "peak": {"rate": 38.5, "windows": [["16:00", "00:00"]]}, + } + name, rate = get_current_tou_period(periods, _dt(23, 59)) + assert name == "peak" + assert rate == 38.5 + + +class TestDemandEdgeCases: + def test_zero_demand_charge_rate(self): + """Zero demand rate produces zero charge.""" + tracker = DemandTracker() + tracker.update(10.0) + assert tracker.daily_demand_charge_cents(0.0) == 0.0 + + def test_zero_power_no_peak_update(self): + """Zero grid power doesn't set a new peak.""" + tracker = DemandTracker() + tracker.update(0.0) + assert tracker.peak_kw_billing == 0.0 + + +class TestSteppedEdgeCases: + def test_zero_threshold(self): + """Zero threshold means all energy at step2.""" + tariff = {"step1_threshold_kwh": 0.0, "step1_rate": 10.0, "step2_rate": 20.0} + assert get_stepped_import_rate(tariff, 5.0) == 20.0 + assert calc_stepped_cost(tariff, 5.0) == pytest.approx(100.0) + + def test_very_large_consumption(self): + """100 kWh with 25 kWh threshold.""" + tariff = {"step1_threshold_kwh": 25.0, "step1_rate": 20.0, "step2_rate": 30.0} + cost = calc_stepped_cost(tariff, 100.0) + expected = 25.0 * 20.0 + 75.0 * 30.0 + assert cost == pytest.approx(expected)