feat(api): send a declared driver command, and hold it for a bounded time - #741
Conversation
There was a problem hiding this comment.
💡 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".
miravoss26
left a comment
There was a problem hiding this comment.
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.
fa3148c to
47f7e81
Compare
d8118ce to
1b99bef
Compare
f877ae1 to
ccd65a3
Compare
|
Follow-up for the independent safety review, on final head
Evidence:
The PR remains open and unmerged pending the separate Luna Max review of this exact head. #749 remains paused. |
ccd65a3 to
8e5e6fc
Compare
|
Follow-up for the independent safety review: the two new blockers are fixed on final head
Red/green evidence:
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. |
8e5e6fc to
054731d
Compare
|
Follow-up on final head 6d9d1e1, based on live master bc2ed86. This head adds the two latest safety fixes:
Evidence:
The PR remains open and unmerged. I am waiting for the two independent rereviews and the CODEOWNER review from @frahlg. #749 remains paused. |
58f3828 to
5917cb8
Compare
…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>
9ccb928 to
ecabb30
Compare
Stacked on #738 — merge that first. Stage C of the path in #520.
POST /api/drivers/{name}/controlwith{control, value, duration_s}sends one command the driver declared and holds it.DELETEon 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
RuntimePolicyfor unsigned drivers and reuseCommandV2— 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 requiresdriver_command_v2+driver_default_mode_v2entrypoints. No community driver has them.So this leaves the policy layer completely untouched. Signed packages keep
CommandV2with 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_moderather than a value Core invented — only the driver knows what neutral is (heishamon's is its configuredsafe_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.MaxDurationceiling I flagged earlier: the registry's lease machinery only arms forIsControlV2(), so with a nil policy there is no ceiling to fight.Tests
They drive a real
Registryand 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 verifyclean. UI in the Heating view is next.🤖 Generated with Claude Code