In-flight work — handover¶
Updated: 2026-10-09 (phase 2 complete) · Session: 01WCWytg6NQWQ5fPnCZiSAf5
Written so this work survives a crashed session. If you are picking this up cold, read §1 and §2, then §6 for the gotchas that cost time to learn. The design documents are the source of truth; this file is only the map.
1 · Where things stand¶
Documents — read in this order¶
| Doc | What it settles |
|---|---|
RUNTIME_DESIGN.md |
Accepted 2026-10-09. Component model, supervision, five decisions settled in §6. The foundation everything else assumed. |
BRIDGE_BASELINE.md |
38 numbered requirements (BR-n) both bridges target. Honest conformance table including what we fail. |
OBSERVABILITY_COVERAGE.md |
How a failure gets noticed — three layers, because most of these produce no event. |
The reason the design exists: fixes were being applied one discovery at a time on a runtime
that can die unobserved. The scheduler runs inside the gateway poll loop, run_gateway
has no except, and /api/live deliberately does no gateway I/O — so one exception stops
polling, metrics, MQTT and all scheduling for a gateway while the container reports healthy.
Repositories¶
| Repo | State |
|---|---|
franklinwh-direct-connect-bridge |
this repo. Renamed from franklinwh-local-bridge; old URL redirects. |
franklinwh-direct-connect-api |
library. 0.4.0 published to PyPI 2026-10-09 via trusted publishing on the v0.4.0 tag. |
franklinwh-modbus-bridge |
PR #30 proposes BRIDGE_BASELINE.md. Deliberately not refreshed while runtime work is in flight. |
2 · Next action¶
RUNTIME_DESIGN.md is FROZEN v1.3 — decisions settled (§7), questions closed (§10),
phase scope fixed (§8).
Phase 0a ✅ (PR #16) · Phase 0b ✅ (PR #17) — both merged. The supervisor recovers, the zombie window is closed, aborts are confirmed rather than assumed.
Phase 1 — built, in PR on feat/phase1-scheduler-worker: the scheduler stops being a
passenger on the gateway poll loop and becomes its own worker on its own cadence, reading a
per-gateway snapshot. Copy the shape from
franklinwh-modbus-bridge/src/franklinwh_bridge/gateway/scheduler.py — own task, own
_tick_s, inner guard so a bad tick cannot kill the loop.
Done: scheduler_worker.py runs the tick on a fixed 15 s cadence (scheduler_tick_s),
registered and supervised like any other worker; the poller no longer calls it; state_ts
stamps each snapshot so freshness is measurable.
Freshness guard matters here. Decoupling created a hazard the coupled version could not
have: a scheduler outliving its poller would evaluate conditions against a snapshot that
stopped updating hours ago. A gateway whose snapshot is older than max(90s, 3 × poll
interval) is skipped with a reason, never evaluated.
Cadence is clamped to [5, 60] s. Windows are minute-resolution, so the shortest expressible window is 60 s — the constraint belongs on the cadence, and a user cannot express an unschedulable window at all. No per-schedule validation needed.
Phase 1 is complete.
2b · Phase 2 — complete, on feat/phase2-occurrences¶
Scope per §8 is finished. 842 tests pass. Not yet merged; the branch also carries an unrelated batch of UI work (see §2c).
| Piece | Where | Note |
|---|---|---|
occurrences table (the execution queue) |
db.py |
UNIQUE(schedule_id, occurrence_key, gateway_id) is max_instances, enforced in the schema |
misfire_grace_time |
scheduler_worker.MISFIRE_GRACE_S |
120 s — the boundary between a late catch-up and missed |
coalesce |
db.coalesce_occurrences → _claim_run |
a backlog left by downtime collapses into ONE run; older open runs close as skipped naming the run that absorbed them |
| Per-resource exclusivity (§6.6) | scheduler.ACTION_RESOURCE, resource_for, resources_contend |
the starvation fix — see below |
| One-or-all targeting (BR-45) | gateway_scope |
an all-scope rule claims this tick's gateway, not the one it is bound to |
| Priority + deterministic tie-break | winner_key |
(-priority, created_at, id) — renaming a rule cannot change who wins |
| Symmetric entry/exit + restore (BR-50) | capture_prior, _restore_prior, occurrences.prior_state |
opt-in via restore_on_exit |
| Templated notifications | run_context |
%run.*% / %schedule.*% in the same namespace as the sensors |
schedule_log split |
LOG_SEVERITY, LOG_CAP |
event / error / exception, capped per class |
The three decisions worth not re-deriving¶
Exclusivity is per resource, and an unknown action claims itself. force, set_mode
and reserve_soc all claim <gw>/battery — they are one behaviour, and a mode change
during a force dispatch is a genuine fight. offgrid claims <gw>/grid (different
hardware), smart_circuit claims <gw>/circuit:<id> (so two circuits never contend), and
notify claims nothing. An unrecognised kind claims <gw>/other:<kind> rather than
defaulting to the battery: guessing wide is precisely what caused the Modbus Bridge's
starvation bug. A circuit write with no circuit named claims circuit:*, and those groups
are merged before a winner is picked — otherwise two rules that genuinely fight would each
win their own group and both dispatch.
The prior state lives on the run, not in memory. An override the bridge forgets across
a restart is an override that becomes permanent. It is captured on the first attempt
only: a retry would otherwise capture what the rule itself just set and "restore" the
override instead of undoing it. If the old value cannot be read, nothing is captured —
restoring to a guess looks deliberate and is worse than a visible override. Covers the
operating mode only; circuits and off-grid are not in the snapshot (FEAT-RESTORE-CIRCUIT-OFFGRID).
The log cap is per class because the measurement said so. Execution was 2.2% of rows
(9 of 405) and gated alone 76%. Under one shared 2000-row cap, routine chatter evicts
failures long before they age out. Budgets are now event 2000 / error 1000 / exception 500,
pruned only within the class that just grew.
2c · What is uncommitted¶
The working tree also carries UI work that is not phase 2 and should land separately: dashboard card overflow + equal heights, the in-app Guide panel, the Advanced-tools gate (Terminal + Reboot, default off), one shared nav registry driving both the sidebar and the bottom bar, and the cog split into Theme / Navbar / Top bar.
Next: phase 3 — wire resilience.call into scheduler actions and bridge writes.
Then phase 2 (occurrences) and phase 3 (wire resilience.call) — that is where retry,
resume and missed become expressible. Do not build those before phase 1 (§9).
3 · Branches and PRs¶
Merged to main 2026-10-09 — the stack is gone, nothing is waiting:
| PR | What landed |
|---|---|
| #10 | package rename franklinwh_local_bridge → franklinwh_direct_connect_bridge |
| #14 | never publish discovery under the agate fallback node; single-source __version__ (originally #11, auto-closed when its base branch was deleted — re-opened against main) |
| #12 | dry-run orphan scan + guarded purge |
| #15 | the four design documents + resilience.py |
In flight: feat/phase0-worker-supervision — see §2.
Lesson for the next stack: merging a PR with
--delete-branchcloses any PR based on that branch rather than retargeting it, and a closed PR cannot be reopened once its base is gone. Retarget dependent PRs tomainbefore merging their base.
4 · Known-failing requirements¶
From BRIDGE_BASELINE.md's conformance table. These are known, not surprises:
- BR-3 / BR-34 —
POST /api/dispatchtakes no gateway and resolves its host from global settings (modbus_host or fwh_host). In multi-gateway it dispatches the wrong battery, silently. - BR-16 / BR-18 / BR-19 —
_fire_entrymarks an occurrence fired even when the action failed, anddue()then refuses to re-enter. A transient Wi-Fi blip burns the whole occurrence. Stop leaveslast_fired_dayset, so a stopped schedule can never resume. - BR-23 / BR-24 / BR-27 —
DELETE /api/gateways/{id}never unpublishes, so deleting a gateway mints orphaned Home Assistant entities. - BR-35–43 — no supervision, no lifecycle, no joined health model. Phase 0 covers the first part only.
- BR-40 — a real zombie window.
supervisor.stop_pollersets the stop Event and returns immediately, but the poll loop only checks it at the end of a cycle and can sit inwait_forfor up to ~30 s.is_running()reads_pollers, already popped — so a quick disable→enable starts a second poller for the same gateway, both writing metrics and publishing MQTT for the same node. Phase 0'sunregisterhides the older one from/api/health/workerswhile it is still producing side effects. Fix is inRUNTIME_DESIGN.md§5.7: deregister only when the task has finished, and make the registry the authority on whether a name is in use.
5 · Outstanding operational items¶
- ~~The MQTT purge has never been run.~~ Done 2026-10-09: 21 stale
agateconfigs cleared, 0 orphans remaining, 63 ours-live kept and all 383 foreign configs untouched — the ownership test held in production. - Two mock gateways are registered in the production roster on :8101, publishing to the real broker. Probably unintended. Removing them from the roster is also what would make their 42 discovery configs purgeable.
franklinwh-modbus-bridgePR #30 awaits the runtime work before being refreshed.
6 · Gotchas — learned the hard way¶
- Never delete on PyPI. Irreversible, burns the version number permanently, breaks pins
and lockfiles. Yank is the mechanism. The library publishes on a
v*tag via trusted publishing (OIDC) — pushing the tag IS the release; do not also runtwine upload. franklinwh-localandfranklinwh-local-apion PyPI are not ours. A retirement plan was written and abandoned for this reason; seeLOCAL_API_COMPARISON.md. Do not retry it.- Do not rename the add-on
slug(franklinwh_local_bridge) or the MQTT identity strings ("Local Bridge v…","FranklinWH aGate (Local Bridge)"). They are identity, not labels: the slug is the add-on's identity to the Supervisor, andmqtt_scan.is_selfmatches thatsw_versionprefix to tell our entities from the Modbus bridge's and FWHAI's on a shared broker. Both carry comments saying so. - Do not free the old GitHub repo names. They redirect; the redirect dies the moment another repo claims the name.
- Tests must isolate
DATA_DIR—./datais mounted into the live container, and a non-isolated test once wiped real history.conftesthandles it; do not undo that. - The dev venv drifts from
pyproject.croniterwas missing and surfaced as a failing cron test rather than "your environment is incomplete". The planned drift panel (design §5.5) exists because of this. - The running container carries a library built from a branch —
tools/build_image.shbuilds the wheel from the sibling checkout, whatever branch it is on. Today that isfranklinwh-direct-connect-api0.4.0. except Exceptionis 203 sites in this repo against 23 mentions of retry. Apply the new pattern at boundaries only; the inner "never kill the poll loop" guards stay.
7 · Keeping this file honest¶
Update it when a phase lands, a PR merges, or a gotcha is learned. A handover that is quietly out of date is worse than none, because it is believed.