Skip to content

feat(control): optional single-battery scope for manual hold - #608

Merged
frahlg merged 1 commit into
masterfrom
feat/single-battery-manual-hold
Aug 4, 2026
Merged

feat(control): optional single-battery scope for manual hold#608
frahlg merged 1 commit into
masterfrom
feat/single-battery-manual-hold

Conversation

@frahlg

@frahlg frahlg commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

  • Extend the existing battery manual_hold endpoint with an optional driver scope.
  • Bind a scoped hold to the driver's current FTW hardware ID in memory. Core cancels the hold if the name points to other hardware or the target becomes unsafe.
  • Send the requested power only to the chosen battery and stop online sibling batteries at 0 W.
  • Keep requests without driver on the existing pool path.
  • Add the battery scope and active scope to the current battery control UI.

Closes #607.

Relation to battery boost

This is not a battery-boost lease. Battery boost grants one loadpoint a short right to use the home battery pool and keeps the state needed for safe restart. This change adds a short service hold for one home battery and stores it only in memory. The two features share the core safety path. An active battery manual hold stops battery boost with the existing battery_hold reason.

API

POST /api/battery/manual_hold

{ "direction": "charge", "power_w": 5000, "hold_s": 900, "driver": "bat_a" }

Omit driver for the old pool behavior. The endpoint still limits hold_s to 1–1800 seconds. DELETE /api/battery/manual_hold still stops the hold.

Safety review

  • Hardware ID: Core resolves the same canonical device ID used by FTW state. It rejects a target without live identity and checks the driver name and device ID again before every dispatch.
  • Restart: Battery manual holds remain in memory only. A process restart starts with no hold, so old service state cannot return.
  • Expiry: The existing absolute expiry and 30-minute maximum stay in force.
  • Target health: The API rejects unknown, observe-only, offline, faulted, or unreadable battery targets. The control tick cancels an active scoped hold when health, battery telemetry, SoC, or hardware identity fails.
  • Site health: A stale site meter sends autonomous defaults and clears the hold before normal battery dispatch can run.
  • Driver default: This change does not alter driver default mode, Lua drivers, driver packages, or repository data.
  • Core limits: Per-driver power caps, SoC limits, slew, battery-boost reserve, and the site fuse guard remain after the hold request. The emergency fuse action remains last and can replace the requested hold.
  • Siblings: Online sibling batteries go to 0 W at once. This standdown may skip slew because it only removes charge or discharge; the fuse guard may still require discharge.
  • Planner: The hold remains a core operator action. It works without MPC and never sends planner output to hardware.

Automated tests

  • go test ./internal/control -run BatteryManualHold -count=1
  • go test ./internal/api -run BatteryHold -count=1
  • go test ./cmd/ftw -run 'Battery|DispatchBlock|SiteDispatchFreshness|StaleSiteDefault' -count=1
  • npm test — 67 passed
  • make verify after rebase
  • Pre-push make verify-all, including Linux ARM64, Linux AMD64, and Windows AMD64 builds

The Go tests cover target scope, immediate sibling standdown with slew enabled, missing and changed hardware identity, unavailable targets, core power and reserve limits, and stale-site cancellation.

Field checklist

  • On a two-battery site, start a scoped charge from battery A and confirm battery B moves to 0 W.
  • Repeat with scoped discharge and idle.
  • Start a pool hold and confirm the old pool split still works.
  • Let a short hold expire, then use Stop on another hold; confirm normal control returns in both cases.
  • Restart Core during a scoped hold; confirm the hold stays inactive after start.
  • Stop target telemetry long enough to trip the watchdog; confirm autonomous default and inactive hold.
  • Mark the target offline or faulted; confirm the hold cancels and does not return after recovery.
  • Stop or stale the site meter; confirm all controllable drivers get defaults and the hold clears.
  • Rebind the same driver name to other hardware; confirm the old hold cannot command it.
  • Reach local battery SoC min/max and configured power caps; confirm Core clamps the request.
  • Exercise a near-fuse-limit site; confirm the fuse guard can lower or replace the requested target.
  • Start battery boost, then start a battery hold; confirm boost stops with battery_hold.
  • Run without MPC enabled; confirm scoped hold, expiry, stop, and safety cancellation still work.
  • Confirm an observe-only battery has no hold action and the API rejects it.
  • Confirm a driver planet opens that battery scope and the merged battery planet opens pool scope.

Keep this PR in draft until the field checks pass.

@frahlg
frahlg force-pushed the feat/single-battery-manual-hold branch from 9e95e08 to a315acd Compare July 22, 2026 05:24
@frahlg
frahlg force-pushed the feat/single-battery-manual-hold branch from a315acd to 9480208 Compare July 22, 2026 05:28
@frahlg

frahlg commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current master and re-verified — this was CONFLICTING after today's merges (#700, #706, #707, #715, #716, #721, #722) and is MERGEABLE again.

One real conflict, in the control tick. master added per-tick telemetry persistence to the blocked-dispatch branch in go/cmd/ftw/main.go while this branch added the manual-hold release, and both landed on the same line inside if !freshness.Allowed(). Both belong there, so both are kept. The order is not arbitrary: the hold release runs first because persistTelemetryTick snapshots ctrl, so the persisted tick has to show the hold already cleared rather than recording one the blocked tick never executed. go/internal/api/api.go and web/app.js auto-merged.

make verify is clean on the merged head (55d0d2f0): Go tests, optimizer suite, Lua driver contract checks, compose migration, vet and build.

Still a draft, and I have deliberately left it that way. It changes the control path, it has no review, and the safety-relevant question is a judgement call rather than a mechanical one: a scoped hold binds to the driver's hardware ID and stops online sibling batteries at 0 W, so the review that matters is whether cancelling on hardware-identity mismatch covers every way the name can move. That is worth a proper read rather than a green tick.

@frahlg

frahlg commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current master (369d1202) and re-verified — this was CONFLICTING again and is MERGEABLE. This time it is a real rebase, not a merge, so the earlier merge master commit is gone and the branch is one commit; its reasoning moved into the commit message rather than being dropped.

The control-tick resolution is carried forward unchanged. master added per-tick telemetry persistence to the blocked-dispatch branch in go/cmd/ftw/main.go while this branch added the manual-hold release. Both belong there, and the release still runs first: persistTelemetryTick snapshots ctrl, so the stored tick has to show the hold already cleared rather than one the blocked tick never executed.

Two new conflicts, both the same shape. #761 added a ConfigApplier field exactly where this branch adds BatteryIdentity — once in the api.Deps type in go/internal/api/api.go, once in main.go's api.Deps literal, with #741 reflowing the surrounding block. Both fields are kept in both places; the rest of the diff there is gofmt realigning. Nothing was dropped from either side.

#791 needed no resolution. driverCmdTimeout and the two sendDriverCommand call sites are untouched by this branch, so the bounded sends survive as merged. Same for sendDriverDefault, which now takes *api.Server instead of the registry.

Golden corpus: no records moved. All 435 records across the seven files replay identically — go test ./internal/control/ -run TestGoldenCorpus passes without regenerating anything. That is the expected result: the corpus has no scoped-hold inputs, and every behaviour change here is gated on manualHold.Driver != "". Both the scoped distribution and the sibling standdown are behind that gate, so unscoped holds and every planner path take the same branch they did before. go/internal/control/dispatch.go auto-merged clean — master's only change since the merge base is the additive SlotEnergy snapshot.

Verified on 8f19c7a1: make verify clean (Go suite, optimizer suite, Lua driver contract checks, compose migration, container boundaries, vet, build), npm test 282 passed, and pre-push make verify-all including the linux/arm64, linux/amd64 and windows/amd64 cross-builds. The scoped control tests still pass by name — target scope with sibling standdown before slew, unsafe target, changed hardware identity, core clamps.

Still a draft, and still deliberately so. The rebase does not change the judgement call that needs a human read: a scoped hold binds to the driver's hardware ID and stops online sibling batteries at 0 W, and the question is whether cancelling on hardware-identity mismatch covers every way the name can move. The field checklist is also still unticked.

@frahlg
frahlg force-pushed the feat/single-battery-manual-hold branch from 8f19c7a to bc4ff32 Compare August 4, 2026 14:31
Rebased onto master. Three resolutions are carried by this commit.

The control tick in go/cmd/ftw/main.go: master added per-tick telemetry
persistence to the blocked-dispatch branch while this branch added the
manual-hold release. Both belong there. The release runs first because
persistTelemetryTick snapshots ctrl, so the stored tick has to show the hold
already cleared rather than one the blocked tick never executed.

Two struct conflicts, both the same shape: master's ConfigApplier landed where
this branch adds BatteryIdentity, in the api.Deps type and in main.go's
composite literal. Both fields are kept.

The golden-clock seam in go/internal/control/dispatch.go: #801 made state.now()
the only wall-clock read in the package, and it rewrote the same line this
branch extends. The manual-hold read keeps master's state.now(), and the scoped
target-validity gate is appended after it. dispatch.go holds no ambient clock
read outside now() itself.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the feat/single-battery-manual-hold branch from bc4ff32 to cfbe525 Compare August 4, 2026 15:47
@frahlg
frahlg marked this pull request as ready for review August 4, 2026 15:50
@frahlg
frahlg merged commit a55328d into master Aug 4, 2026
13 checks passed
@frahlg
frahlg deleted the feat/single-battery-manual-hold branch August 4, 2026 15:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cfbe525ef2

ℹ️ 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".

Comment thread web/app.js
if (clicked.observe_only) return;
var bc = document.getElementById("battery-control");
if (bc && typeof bc.open === "function") bc.open();
if (bc && typeof bc.open === "function") bc.open(d.name || d.id || "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Open aggregate battery planets at pool scope

When multiple batteries are aggregated, aggregateGroups gives the merged planet a display name such as and an ID such as agg-top-right. Passing d.name here therefore opens a driver-scoped form for the nonexistent driver ; installing the hold then receives unknown controllable battery driver instead of creating the advertised pool hold. Detect the aggregated planet and call open("") for it.

Useful? React with 👍 / 👎.

Comment thread go/cmd/ftw/main.go
Comment on lines +556 to +558
health := tel.DriverHealth(name)
reading := tel.Get(name, telemetry.DerBattery)
return health != nil && health.IsOnline() && reading != nil && reading.SoC != nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel holds when the target leaves controllable inventory

If a hot reload marks the target observe_only, classifies it as a loadpoint, or otherwise removes it from driverCapacitiesFrom(..., true), this validator can still return true because the same healthy driver and identity remain live. ComputeDispatch then omits the target from onlineBats while the scoped hold stays active, causing every remaining controllable sibling to be commanded to 0 W until expiry. Revalidate membership in the current controllable-capacity map and clear the hold when it disappears.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(control): optional single-battery scope for manual hold

1 participant