docs: operations + deployment guide - #5
Conversation
New docs/architecture.md ties the system map together: summary, site-convention pointer, per-component paragraphs, data-flow ASCII diagram, persistence table, config source-of-truth, and a reading order for new developers. Line references into main.go, state.go, dispatch.go, registry.go, and the twin services are verified against the current code. Sibling docs (ml-models.md, writing-a-driver.md, safety.md, operations.md, api.md, testing.md, tsdb.md, device-identity.md) are being authored in parallel branches; existing links in this doc all resolve today, and the two forward-references are labelled planned.
Operator-focused guide covering build + cross-compile, systemd install, service lifecycle, log locations, hot-reload semantics, backup/restore, a troubleshooting runbook (driver hung, MQTT drop, dispatch stuck, PV model runaway, stale site meter, port conflict, config reload rejected), resource expectations on a Pi 4, and a state-file inventory. Cross-references: site-convention, configuration, architecture. Quotes deploy/forty-two-watts.service verbatim.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 243496ca28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| The operator's guide. From "I have a fresh Raspberry Pi" to "I can debug a hung driver at 3am". | ||
|
|
||
| Sign convention reminder: throughout this doc, `grid_w > 0` means **import** (buying from grid), `grid_w < 0` means **export** (selling). Battery `bat_w > 0` means **discharging** (battery → AC), `bat_w < 0` means **charging** (AC → battery). PV `pv_w` is always ≥ 0. See [site-convention.md](site-convention.md) for the full convention. |
There was a problem hiding this comment.
Correct site sign directions in operations guide
This sign-convention summary is inverted versus the project’s canonical convention (docs/site-convention.md): battery power is documented as bat_w > 0 meaning discharge and PV as pv_w ≥ 0, but the code/docs define battery charging as positive and PV generation as negative. Operators using this section to interpret /api/status or troubleshoot dispatch can take the opposite corrective action during live incidents.
Useful? React with 👍 / 👎.
|
|
||
| This is a **safety feature**, not a bug: stale grid readings would otherwise cause one battery to charge another. The control loop refuses to dispatch until the site meter recovers. | ||
|
|
||
| **Fix:** Investigate the site meter driver (Ferroamp by default — check `site.meter_driver` in `config.yaml`). Common causes: MQTT broker lost, network partition, Ferroamp rebooted. Restart the service if the driver process itself is wedged. |
There was a problem hiding this comment.
Point stale-meter troubleshooting to real config field
The runbook tells operators to check site.meter_driver, but that field does not exist in the config schema; the site meter is selected by setting is_site_meter: true on a driver. In a stale-meter outage, this sends users to a nonexistent knob and can delay recovery.
Useful? React with 👍 / 👎.
|
🎉 This PR is included in version 2.0.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Three items in the 11-comment review are still outstanding after the earlier rebase — the rest were already addressed in b6a5c78 / 4f3ad01: - #3 EV card keyboard accessibility (web/index.html + web/app.js): add role="button", tabindex="0", aria-label, and Enter/Space key handler so the summary card can be activated without a mouse and is announced correctly by assistive tech. - #9 Hardcoded "easee" provider (web/settings.js): Connect button now derives the provider name from the driver's lua path (strip dirs, trailing "_cloud", ".lua"), falling back to "easee" when the driver config is missing or oddly named. Lets a second cloud driver slot in without touching the button. - #5 Missing tests for path normalization (config_test.go): cover stripLeadingDotDot, ResolveDriverPaths (relative / absolute-in-tree / absolute-out-of-tree / leading-"../"), and the full Resolve → Unresolve → Resolve round-trip. The round-trip test is the one that pins down Copilot #11 — absolute paths outside baseDir must stay absolute so the next Resolve doesn't strip a "../" from filepath.Rel and silently re-anchor the driver under baseDir. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-agent vote per comment accepted 6 of 7: - Offload HealthTick observeAt to a goroutine so a slow ntfy Publish (10 s timeout) can't stall the control loop. The event bus runs handlers inline on the publisher, and HealthTick is emitted every control tick. (Copilot #1) - Nil-publisher guard in dispatch(): cold-start or reload with an unknown/unconfigured provider used to panic at s.pub.Publish; now it's treated as a failed dispatch with a history entry. (Copilot #3) - Emit NotificationDispatched on template render failures too, so the history UI surfaces template errors instead of only publish errors. (Copilot #4) - activeAlert leak fix: when no driver_recovered rule is enabled, a post-pass cleanup clears activeAlert for any driver whose telemetry is fresh (<30 s). Preserves the original lifecycle: when recovered IS enabled, the recovered rule still owns the clear. (Copilot #5) - Stop seeding backend default templates into currentConfig. Render title/body template fields as raw inputs whose placeholder is the server default. Blank stays blank, backend fallback keeps working, and future server-side default changes apply without operator action. (Copilot #6) - driver_recovered ignores threshold_s on the backend (hardcoded 30 s staleness window). Don't render the threshold input for that event type — show a clarifying note instead. (Copilot #7) Tests: new TestActiveAlertClearsWhenRecoveredDisabled and TestNilPublisherNoPanic. Full suite still green (35 packages). Skipped Copilot #2 (SQLite write in NotificationDispatched subscriber blocks the publisher): the write runs after HTTP publish on a path that already blocks for up to 10 s, a sub-millisecond SQLite insert adds nothing measurable. 3-of-5 agent votes INVALID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five comments, four logical fixes: #1, #2, #3 — config.SafetyMarginA: float64 → *float64 so we can distinguish "unset → use 0.5 default" from "explicit 0 → operator chose to disable margin". Single source of truth via new DefaultFuseSafetyMarginA constant + Fuse.EffectiveSafetyMarginA() helper. main.go startup + hot-reload both call the helper instead of duplicating `<= 0 → 0.5` shortcuts that clobbered the disable case. Validate() updated to dereference; explicit 0 is accepted (and verified by a new round-trip test). #4, #5 — direction attribution in applyFuseGuard + forceFuseDischarge now gates on `currentGrid` (live aggregate at the meter) instead of `predicted` (post-target hypothetical). Per-phase amps and currentGrid come from the same DerMeter sample; predicted mixes in sumTarget which can swing across 0 on a large planner request and silently flip the gate, attributing a current-export overage to the import path (or vice versa). New tests: - TestFuseSafetyMarginNilUsesDefault — back-compat for unset YAML - TestFuseSafetyMarginExplicitZeroDisables — locks the new contract - TestFuseSafetyMarginExplicitValuePassesThrough - TestValidateRejectsNegativeSafetyMargin (parses YAML) - TestValidateRejectsSafetyMarginAtOrAboveMaxAmps - TestValidateAcceptsExplicitZeroSafetyMargin (round-trips through Parse + EffectiveSafetyMarginA to verify 0 stays 0)
Codex returned NO-GO on the parallel build; these close the findings relevant to the shippable relay-hardening (slices 1+2): - #1 CRITICAL: require -home-web when a home host is set (else the relay fell back to forwarding anonymous GETs to the Pi — the exact surface we forbid). requireHomePin now enforces it. - #2 HIGH: device_pubkeys are now covered by the registration signature (MeRegisterSigningStringV2); a captured /me/register can no longer be replayed with a swapped/added device key. + regression test. - #4 MED: symlink-escape in the -home-web static server closed (EvalSymlinks + re-check the resolved path stays under the web root). Deferred to the C2-enforcement phase (inert until the device-key gate is ON), with TODOs: #3 (bind device_pubkey to the enroll ceremony) and #5 (separate per-IP limiter on challenge issuance). All Go unit + 97 web tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(home-route): inline passkey sign-in on the dashboard The dashboard now reveals a discreet sign-in banner + header key when whoami reports the viewer isn't signed in on a remote origin, and runs the passkey login ceremony in place over the same strict P2P channel (ownerFetch) — no redirect to /owner-access/login.html, which would spawn a fresh channel with no session. Re-checks on P2P (re)connect. LAN bypass + signed-in views never show it. Closes the discoverability gap (part of #438 seamless UX). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(home-route): full sign-in gate + informational transport indicator Evolve the inline-signin banner into a real gate: a logged-out remote visitor now sees ONLY a clean sign-in card, never the empty dashboard chrome (which rendered 'No devices configured' and read as an unconfigured instance). The gate covers the dashboard until whoami confirms a signed-in owner; the passkey ceremony runs in place over the strict P2P channel. Suppresses the no-devices prompt while logged out. The transport indicator is now informational (direct/ relayed/connecting), not a confusing toggle that broke the P2P-only channel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(home-route): no dashboard flash on reload behind the sign-in gate A reload briefly painted the dashboard before next-app.js could cover it with the gate. Add a pre-paint inline <head> script (mirroring the theme-flash guard) that marks <html class=ftw-gated> on a remote origin from the first frame; CSS shows the opaque full-screen gate immediately, and setupAuth() clears ftw-gated once whoami confirms a signed-in owner. The gate is now a class toggle (not the hidden attribute) so the pre-paint CSS can win. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: hardened-relay + device-key design spec (no anonymous path to a Pi) Spec for the relay-hardening Fredrik approved: relay serves the sign-in shell + /api/identity from its own embedded copy (Pi not touched), and forwards signaling to the Pi ONLY when it carries a device-key proof the relay can verify. The device-key (LAN-born, wallet-bound) becomes the sole credential that can reach a Pi from the internet, and also mints the session silently. Surface-by-surface threat model + build slices + one open trade-off for review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(home-route): hardened relay + device-key remote access (no anonymous path to a Pi) Relay serves the sign-in shell (-home-web) + /api/identity (from -home-pubkey) itself, so anonymous visitors never reach the Pi. Signaling is gated by a device-key proof (relay-issued nonce, signed by a non-extractable WebCrypto ECDSA P-256 key minted at LAN enrollment, verified against the device-pubkeys the Pi publishes on /me/register) — else 403, Pi never woken. The device-key also mints the session silently over the channel (device-PoP); step-up keeps a passkey; revocation drops the key on Pi + relay. Built in parallel across relay/ Pi/browser against locked wire contracts (C1-C4), then integrated. All Go unit tests + 97 web tests pass. Pending Codex security GO before live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(home-route): address Codex security review of the device-key build Codex returned NO-GO on the parallel build; these close the findings relevant to the shippable relay-hardening (slices 1+2): - #1 CRITICAL: require -home-web when a home host is set (else the relay fell back to forwarding anonymous GETs to the Pi — the exact surface we forbid). requireHomePin now enforces it. - #2 HIGH: device_pubkeys are now covered by the registration signature (MeRegisterSigningStringV2); a captured /me/register can no longer be replayed with a swapped/added device key. + regression test. - #4 MED: symlink-escape in the -home-web static server closed (EvalSymlinks + re-check the resolved path stays under the web root). Deferred to the C2-enforcement phase (inert until the device-key gate is ON), with TODOs: #3 (bind device_pubkey to the enroll ceremony) and #5 (separate per-IP limiter on challenge issuance). All Go unit + 97 web tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(relay): -require-device-key rollout flag (C2 gate dormant by default) Slices 1+2 (relay serves the shell + /api/identity itself, closing the anonymous-FETCH surface) ship independently of the device-key signaling gate: -require-device-key defaults OFF, so signalBrowserOffer parks the raw offer with no proof (pre-C2 behaviour) and a home Pi that doesn't yet publish device-keys keeps working. Flip it on once device-keys are enrolled to close the anonymous-SIGNALING surface. C2 tests now assert the enforced path; a new test asserts the dormant default. All Go unit + web tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds
docs/operations.md— the operator-focused guide for running forty-two-watts on a Raspberry Pi.Covers:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64, Makefile targets)deploy/forty-two-watts.serviceunit (quoted verbatim)fsnotifyapplies live vs what needs a restartAll command-line flags, log strings, and service-unit content verified against source (
go/cmd/forty-two-watts/main.go,go/internal/configreload/watcher.go,deploy/forty-two-watts.service,Makefile).Test plan
cd go && go build ./...cleancd go && go test -timeout 120s -count=1 ./...all green (including e2e)bash -nsyntax checkdeploy/forty-two-watts.servicebyte-for-byte