Skip to content

feat(api): send a declared driver command, and hold it for a bounded time - #741

Merged
frahlg merged 10 commits into
masterfrom
520-driver-control-path
Aug 4, 2026
Merged

feat(api): send a declared driver command, and hold it for a bounded time#741
frahlg merged 10 commits into
masterfrom
520-driver-control-path

Conversation

@frahlg

@frahlg frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member

Stacked on #738 — merge that first. Stage C of the path in #520.

POST /api/drivers/{name}/control with {control, value, duration_s} sends one command the driver declared and holds it. DELETE on the same path ends it early. /api/drivers/{name} shows the active hold, so a UI can render what is set and until when.

Why this sits outside control v2

The obvious approach — synthesise a RuntimePolicy for unsigned drivers and reuse CommandV2 — does not survive contact:

  • HostEnv.permissionAllowed (host.go:215) grants everything only while the policy is nil. A synthesised policy without permissions silently blocks heishamon's own MQTT. Adding a knob would break the driver.
  • LuaDriver.Command (lua.go:261) refuses a control v2 driver on the legacy path, and v2 requires driver_command_v2 + driver_default_mode_v2 entrypoints. No community driver has them.

So this leaves the policy layer completely untouched. Signed packages keep CommandV2 with its write scope, lease and evidence, unchanged. Unsigned drivers get a simpler path validated against the catalog declaration from #738.

The cost, stated plainly: no host-enforced write scope, no host-verified evidence. That belongs in the UI copy, not buried here.

What it keeps: Core clamps every value to the declared bounds rather than trusting the Lua — a driver that forgets to clamp is exactly the driver this protects. And the declaration is the whole allowlist, so an undeclared control is a 400 rather than a 200 for a command the Lua silently ignored, which the registry cannot otherwise distinguish.

The hold

Every hold ends by itself, and ending calls the driver's own driver_default_mode rather than a value Core invented — only the driver knows what neutral is (heishamon's is its configured safe_offset, which an operator may have moved). Default 4 h, max 24 h, nothing survives a restart.

This also disposes of the 300 s Lease.MaxDuration ceiling I flagged earlier: the registry's lease machinery only arms for IsControlV2(), so with a nil policy there is no ceiling to fight.

Tests

They drive a real Registry and a real Lua driver, so they tell "Core sent it" from "Core said it sent it" — the earlier version of these tests passed against a driver that had never polled. Covered: clamping reaches the device, undeclared control is refused, hold is visible and releasable, hold expires into default, and replacing a hold stops the old timer from defaulting the device out from under the new setting.

Verified to rebase cleanly onto #728 (mDNS) — no overlap; that branch lives in a fork so it cannot be the PR base.

make verify clean. UI in the Heating view is next.

🤖 Generated with Claude Code

@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: d8118ce57e

ℹ️ 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 go/internal/api/api_driver_control.go Outdated
Comment thread go/internal/api/api_driver_control.go Outdated
Comment thread go/internal/api/api_driver_control.go Outdated
Comment thread go/internal/api/api_drivers_debug.go

@miravoss26 miravoss26 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.

Adds an operator control path: POST /api/drivers/{name}/control sends one declared command and holds it for a bounded time, DELETE ends it early, and the active hold shows on /api/drivers/{name}. Deliberately outside control v2, with the tradeoff stated plainly in the changeset.

What I checked:

  • Core clamps to the declared bounds and treats the driver's declaration as the whole allowlist (undeclared control returns 400). Good defensive posture: it protects a driver that forgets to clamp.
  • Timer lifecycle is the risk area and it's handled. armControlHold stops any existing timer; expireControlHold does an identity check (current != fired is a no-op) so a timer that already began firing can't default a just-replaced hold; replace-cancels-old-timer has a test.
  • Holds auto-expire (default 4h, max 24h) and nothing survives a restart. That matches the "no offset outliving the browser tab" goal.
  • Registry.Send runs outside the mutex, so the hold lock isn't held across the driver round-trip.
  • No secrets, no new deps, no new network host. The new endpoints sit alongside the existing /restart, /disable, /enable, same auth posture.
  • Tests cover clamp+reach, reject-undeclared, hold visible+releasable, expiry into default, and replace-cancels-timer.

One thing to confirm before merge: GitHub shows no CI checks on this head, while the other open ftw PRs have the full matrix. Worth making sure the pipeline actually ran rather than assuming green.

Safe to merge from my read once CI is confirmed. Not in my auto-merge allowlist, so a human merges.

@frahlg
frahlg force-pushed the 520-declare-driver-controls branch from fa3148c to 47f7e81 Compare August 4, 2026 07:28
@frahlg
frahlg force-pushed the 520-driver-control-path branch from d8118ce to 1b99bef Compare August 4, 2026 07:52
@frahlg
frahlg changed the base branch from 520-declare-driver-controls to master August 4, 2026 07:52
@frahlg
frahlg force-pushed the 520-driver-control-path branch 2 times, most recently from f877ae1 to ccd65a3 Compare August 4, 2026 09:02
@frahlg

frahlg commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Follow-up for the independent safety review, on final head ccd65a370e0a1b09253c0f13da31ec7314acf218 (rebased onto ee416d1f2a1329b762da2e0cfeaa3f11e8368a65):

  • Legacy Lua driver_command now receives the request/lifecycle context. Restart and shutdown cancel an active command before stopping the run loop, then make the bounded default attempt.
  • Any command error triggers an autonomous-default attempt. A failed default sets controlBlocked, retries with a bounded 5-second call and exponential backoff capped at 30 seconds, rejects new control POSTs, and exposes default_recovery without claiming default confirmation.
  • observe_only is rejected in both the API and registry paths before a command reaches the driver.
  • Holds and timers carry the driver generation and are cleared on restart, reload, disable, and remove. An old timer cannot affect a replacement instance.
  • Driver detail reads control state without creating entries for unknown names.

Evidence:

  • Targeted go test -race passed for legacy cancellation, restart/shutdown, recovery blocking, observe-only, lifecycle, expiry/replacement, and unknown-name cleanup.
  • Final-head make verify-all passed, including 49 optimizer tests, the full Go suite, compose migration and container-boundary checks, vet/build, and Linux arm64/amd64 plus Windows amd64 cross-builds.
  • Fresh Actions run 30894545855 passed all applicable jobs; expected drivers, web, and module-boundary jobs were skipped.
  • The four earlier inline review threads remain resolved. No new unresolved review threads are present.

The PR remains open and unmerged pending the separate Luna Max review of this exact head. #749 remains paused.

@frahlg
frahlg force-pushed the 520-driver-control-path branch from ccd65a3 to 8e5e6fc Compare August 4, 2026 09:51
@frahlg

frahlg commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Follow-up for the independent safety review: the two new blockers are fixed on final head 8e5e6fc51725858403c8d9436db9ed73a821ae9a, rebased onto live master 8d39cd91c82095948663cbf4e3114da8f299e11b.

  • Registry.Send now tracks whether a command started and completed. Cancellation before dispatch returns ordinary context cancellation and does not invoke the driver. Cancellation after dispatch returns ErrCommandMayHaveRun while preserving the cancellation cause; a nil/error command result after start goes through the autonomous default path. Failed default keeps control blocked and retries with bounded backoff.
  • Restart/reload gates the name before the old generation is stopped. A failed shutdown default is carried into the replacement generation, which starts Blocked=true/RecoveryPending=true; no control is accepted until a retry confirms default. A concurrent recovery success cannot clear a later failed shutdown result.

Red/green evidence:

  • Both new deterministic tests were red on ccd65a370e0a1b09253c0f13da31ec7314acf218: cancellation after side effect left default uncalled; failed restart default left the new generation controllable.
  • On 8e5e6fc5: targeted cancel/restart/lifecycle tests pass; full go test -race ./internal/drivers and go test -race ./internal/api pass.
  • make verify and make verify-all pass with Python 3.12.13 in the gitignored optimizer venv, including 49 optimizer tests, the Go suite, compose/container gates, vet/build, and linux arm64/amd64 plus windows amd64 cross-builds.
  • Fresh Actions run 30898119005 is green for all selected required jobs; expected path-skipped jobs remain skipped.

The four earlier inline threads remain resolved and the thread-aware reader shows no new unresolved threads. The PR remains open and unmerged for a new independent safety review of this exact head. #749 remains paused.

@frahlg
frahlg force-pushed the 520-driver-control-path branch from 8e5e6fc to 054731d Compare August 4, 2026 10:24
@frahlg

frahlg commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Follow-up on final head 6d9d1e1, based on live master bc2ed86.

This head adds the two latest safety fixes:

  • A legacy driver that declares operator controls must expose a callable driver_default_mode; missing defaults fail validation. Reporting-only and observe-only legacy drivers remain loadable.
  • Control holds use the generation returned by concrete Registry.SendWithGeneration dispatch. A deterministic pause, restart, command, expiry test proves the replacement generation receives the default. Expiry default errors remain in the existing recovery gate.

Evidence:

  • go test -race ./internal/api ./internal/drivers -count=1 passed.
  • make verify-all passed locally, including Python 3.12.13 optimizer venv, full Go and optimizer suites, compose and boundary checks, vet/build, and all three cross-builds.
  • Fresh Actions run 30902955595 is green for all selected jobs. Web, drivers, module-boundary, and pin-only jobs are path-skipped.
  • The thread-aware reader confirms all four earlier inline threads remain resolved and no new unresolved threads exist.
  • A full local go test -race ./... also found the known unrelated race in go/internal/notifications/service_test.go:524/561/566; no notification files changed in this PR.

The PR remains open and unmerged. I am waiting for the two independent rereviews and the CODEOWNER review from @frahlg. #749 remains paused.

frahlg and others added 10 commits August 4, 2026 14:49
…time

POST /api/drivers/{name}/control sends one command the driver declared and
holds it; DELETE ends the hold early; /api/drivers/{name} shows what is set
and until when.

Deliberately outside control v2. Synthesising a RuntimePolicy for an unsigned
driver is worse than doing nothing: HostEnv.permissionAllowed grants
everything only while the policy is nil, so a policy without permissions
silently blocks the driver's own MQTT, and LuaDriver.Command refuses a control
v2 driver on the legacy path — v2 wants driver_command_v2 entrypoints no
community driver has. Signed packages keep CommandV2 unchanged.

What that costs: no host-enforced write scope, no host-verified evidence.
What it keeps is the part that protects hardware. Core clamps to the declared
bounds rather than trusting the Lua, and the declaration is the whole
allowlist — an undeclared control is a 400, not a 200 for a command the Lua
ignored, which is what the registry cannot otherwise tell apart.

Every hold ends by itself, into the driver's own driver_default_mode rather
than a value Core invented: only the driver knows what neutral is. Default
4 h, maximum 24 h, nothing survives a restart. Because the policy is nil the
registry's lease machinery never arms, so the 300 s Lease.MaxDuration ceiling
does not apply and the hold does not need to fight it.

Tests drive a real registry and a real Lua driver, so they distinguish "Core
sent it" from "Core said it sent it", and cover the case that bites: replacing
a hold must stop the old timer from defaulting the device out from under the
new setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the 520-driver-control-path branch from 9ccb928 to ecabb30 Compare August 4, 2026 12:54
@frahlg
frahlg merged commit 9e5fdb0 into master Aug 4, 2026
13 checks passed
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.

2 participants