Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/claude-assistant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/coderabbit-nitpicks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
pull_request_review:
types: [submitted]

permissions:
contents: read

jobs:
log-nitpicks:
name: Log nitpicks as issues
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/dual-loop-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
28 changes: 0 additions & 28 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ on:
pull_request:
schedule:
- cron: "0 0 * * *"

permissions:
contents: read

jobs:
validate-hassfest:
runs-on: ubuntu-latest
Comment thread
coderabbitai[bot] marked this conversation as resolved.
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Hassfest validation
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/wiki-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand All @@ -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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 23 additions & 43 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- Ignore any `<codebase-intelligence>` 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
56 changes: 56 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 3 additions & 5 deletions custom_components/pricehawk/amber_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion custom_components/pricehawk/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions custom_components/pricehawk/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
11 changes: 5 additions & 6 deletions custom_components/pricehawk/tariff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading