Skip to content

feat(drivers): let a driver declare the commands an operator may send - #738

Open
frahlg wants to merge 1 commit into
masterfrom
520-declare-driver-controls
Open

feat(drivers): let a driver declare the commands an operator may send#738
frahlg wants to merge 1 commit into
masterfrom
520-declare-driver-controls

Conversation

@frahlg

@frahlg frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member

Answers the "does the driver need to declare something extra?" half of #520. Today the answer is no — there is nothing to declare. This adds it.

The gap

Settings → Devices renders hand-written branches per driver family (devices.js:23 says so: the choices stay there "until the signed driver catalog grows a general config-schema field"). So a driver with a real control surface cannot describe it. The Heishamon heat pump has had a hardware-verified set_heat_curve_offset since June, and its author was told to drive it from a Home Assistant automation instead.

Signed packages already carry this shape in RuntimeCommand — typed inputs, lease policy, evidence — but only for drivers on the signed channel. rd.policy is nil for every bundled and local driver, so the DRIVER block is where their declaration has to live.

What this adds

  controls = {
    {
      id       = "set_heat_curve_offset",
      label    = "Heat curve offset",
      evidence = "readback",
      input    = { type = "number", min = -3, max = 3, step = 1, unit = "°C" },
    },
  },

Surfaced on /api/drivers/{name} and /api/drivers/catalog.

Nothing sends these commands. This is the description; the command path is separate work and is where the interesting problems are (lease expiry runs driver_default_mode, which for heishamon writes the offset back to safe — so a manual setting needs a Core-side hold with heartbeat renewal, since Lease.MaxDuration caps at 300 s).

Notes for review

  • The parser is the real change. pickKVBlock matches [^}]* and stops at the first closing brace; pickString anchors to the start of a line. Both are right for flat fields and both break on a nested list written inline. This reads controls with its own brace matching rather than changing how every other field is parsed. TestBundledCatalogStillParses covers the regression that would matter — a controls reader that quietly eats an unrelated field.
  • Unrenderable declarations are dropped, not surfaced. No id, a type outside number/boolean/string, or a number missing either bound. A slider with one end is a guess, not a control.
  • Bounds are pointers because zero is a real bound — a 0..100 percentage and an undeclared range must not read the same.
  • The JSON key stays absent for drivers that declare nothing, so a client can tell "reports only" from "declares an empty list".
  • Path matching follows IsEVOrVehicleDriver/IsReadOnlyDriver: portable path first, then filename. Operators write drivers/x.lua and /data/x.lua both.

No bundled driver declares controls yet — drivers/ is a generated snapshot and cannot be edited here. Adding the block to heishamon is a device-drivers change, after srcfl/device-drivers#60.

make verify clean.

🤖 Generated with Claude Code

Settings renders a hand-written branch per driver family, so a driver with a
real control surface has no way to describe it and no way to reach a person.
The Heishamon heat pump has had a verified curve-offset command since June and
its author was told to write a Home Assistant automation instead.

A `controls` list in the DRIVER block names the command, labels it, describes
its single input — type, bounds, step, unit — and states what counts as proof
the device took it. /api/drivers/{name} and /api/drivers/catalog surface it.

Nothing sends these commands. This is the description; the path is separate
work. Signed packages already carry the shape in RuntimeCommand, but only for
drivers on the signed channel — a bundled or local driver has no policy, so
the DRIVER block is where its declaration has to live.

The existing pick helpers stop at the first closing brace and anchor to the
start of a line, both right for flat fields and both wrong for a nested list
written inline. This reads controls with its own brace matching rather than
changing how every other field is parsed for the sake of one that did not
exist yet.

A declaration the UI could not render is dropped rather than surfaced half
formed: no id, a type outside number/boolean/string, or a number missing
either bound. Bounds are pointers because zero is a real bound — an
undeclared minimum and a minimum of zero must not read the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

ℹ️ 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 on lines +55 to +56
if strings.EqualFold(e.Path, wantPath) || strings.EqualFold(e.Filename, wantFilename) {
return e.Controls

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 Match controls to the configured Lua file

When luaPath is absolute and a different catalog source contains the same filename—for example, an explicitly selected bundled /app/drivers/foo.lua shadowed by local foo.lua—the portable-path comparison fails and this filename fallback returns the local entry's controls even though the registry loaded the bundled file. /api/drivers/{name} then attributes commands to the wrong driver; resolve against the configured file or its source-relative path before using a filename fallback.

AGENTS.md reference: AGENTS.md:L43-L47

Useful? React with 👍 / 👎.

// table does not end the outer one. Braces inside string literals are skipped;
// a topic or a label is allowed to contain one.
func nestedBlock(block, name string) string {
loc := regexp.MustCompile(regexp.QuoteMeta(name) + `\s*=\s*\{`).FindStringIndex(block)

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 Ignore commented-out control declarations

When a driver author disables a declaration with Lua comments such as -- controls = { ... }, this unanchored search still finds the text and the remaining parser publishes those controls through both APIs. That makes commented examples or intentionally disabled commands look actively declared; strip or skip Lua comments while locating and matching the table.

AGENTS.md reference: AGENTS.md:L43-L47

Useful? React with 👍 / 👎.

}

func fieldNumber(block, name string) *float64 {
re := regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)`)

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 Parse complete Lua numeric literals

When a valid Lua bound or step uses exponent notation, such as max = 1e6 or step = 1e-1, this regex matches only the prefix and ParseFloat successfully publishes 1. The API therefore silently changes the declared control range or increment instead of preserving or rejecting it; support Lua numeric forms or require a delimiter after the matched literal.

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.

1 participant