Skip to content

agent: Get/SetCameraControls — tune a local camera's exposure/gain (and make it stick) - #1818

Open
chrisdok43 wants to merge 10 commits into
mainfrom
feat/camera-exposure-controls
Open

agent: Get/SetCameraControls — tune a local camera's exposure/gain (and make it stick)#1818
chrisdok43 wants to merge 10 commits into
mainfrom
feat/camera-exposure-controls

Conversation

@chrisdok43

Copy link
Copy Markdown
Contributor

What

Adds GetCameraControls and SetCameraControls to the agent's WendyVideoService, with CLI verbs:

wendy device camera controls <id>                                              # list controls + ranges + current values
wendy device camera set-control <id> auto_exposure=1 exposure_time_absolute=20 # set (persists by default)

They let an operator read and set a local camera's tunable V4L2 controls — exposure, gain, white balance, backlight compensation, and friends.

Why

The agent owns each camera's /dev/videoN node — StreamVideo multiplexes one capture to every subscriber — so it is the only process that can set a V4L2 control to stick. A control set by anyone else is lost the moment the capture pipeline reopens the device, and on a device whose app reads through the agent (CAMERA_SOURCE=wendy-agent://…) there is no other writer at all.

Motivating case: a scene the camera's auto-exposure gets wrong — a flame or lamp that blows out to white. Measured on an Arducam UVC cam, dropping exposure_time_absolute took a candle flame from 47% clipped-to-white (a fire model scored 0) to a clean orange flame the same model scored 0.52 on.

What's in it

  • Proto: GetCameraControls (value + range) and SetCameraControls (set + optional persist) on WendyVideoService. Local USB/CSI cameras only; network cameras rejected.
  • Server (camera_controls.go): reuses the existing VIDIOC_S_EXT_CTRLS primitive (generalised to take the control which), adds G_EXT_CTRLS and QUERYCTRL. A JSON store at /var/lib/wendy/camera-controls.json re-applies desired controls on every capture (re)start (hook in runProducer) and across agent restarts — so a stream reconnect or reboot does not revert to the firmware default. Controls are ordered so a mode control (auto_exposure=Manual) lands before the control it gates (exposure_time_absolute, which the driver marks inactive until then).
  • CLI: controls (list) and set-control (set; --no-persist for a one-shot).
  • Tests: server (network-camera rejection, unknown-control handling, persist/merge round-trip, stored re-apply) and CLI (arg parsing, result reporting).

Testing

gofmt -l, go vet, go build ./..., and the new unit tests all pass. The device-touching ioctl path is behind seams so the RPC logic is unit-tested without a camera; it was validated end-to-end on an Arducam via v4l2-ctl beforehand.

Note

Deploying this to a device that relies on raw-thermal radiometry also needs #1817 (feat/raw-thermal-frames); that combining is out of scope for this PR, which targets main on its own.

🤖 Generated with Claude Code

…, ...)

The agent owns each camera's /dev/videoN node -- StreamVideo multiplexes one
capture to every subscriber -- so it is the only place a V4L2 control can be set
to stick. A setting made by anyone else is lost when the capture pipeline
reopens the device, and on a device the app reads through the agent
(CAMERA_SOURCE=wendy-agent://) there is no other writer at all.

Motivating case: a scene the camera's auto-exposure gets wrong -- a flame or
lamp that blows out to white -- where forcing manual exposure keeps the
highlight from clipping so a fire model can see it. Measured on an Arducam UVC
cam: dropping exposure_time_absolute took a candle flame from 47% clipped-white
(a fire model scored 0) to a clean orange flame it scored 0.52 on.

- Proto: GetCameraControls (value + range) and SetCameraControls (set, and
  optionally persist) on WendyVideoService; local USB/CSI cameras only, network
  cameras rejected.
- Server: reuse the existing VIDIOC_S_EXT_CTRLS primitive (generalised to take
  the control class/which), add G_EXT_CTRLS and QUERYCTRL; a JSON store
  re-applies desired controls on every capture (re)start and across agent
  restarts, so a stream reconnect or reboot does not silently revert.
- CLI: `wendy device camera controls <id>` and
  `wendy device camera set-control <id> name=value ...`.

Controls are reordered so a mode control (auto_exposure=Manual) lands before the
control it gates (exposure_time_absolute), which the driver marks inactive
until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

AI Security Review

Note

Automated security review from Claude. Apply, adapt, silence with // SECURITY: <reason>, or dismiss as needed.

Input coverage: 12/12 changed files; 97,001/97,001 bytes reviewed; diff SHA-256 beef9bbbc3bab92817bc1c1bc43fadb1a400536a2873e51db52e2982fae879ff; truncation: none.

Claude found security review findings for this PR.

⚠️ Concern — Open MEDIUM: New mutating device RPCs add no explicit authorization check

go/internal/agent/services/camera_controls.go:268-330: SetCameraControls durably reconfigures physical camera hardware with no visible per-RPC authorization or identity binding.

Details
**Status:** Open
**Severity:** MEDIUM
**Standards:** SOC2-CC6.1, ISO27001-A.8.3, NIST-800-53-AC-3
**Location:** `go/internal/agent/services/camera_controls.go:268-330`

`SetCameraControls` writes V4L2 controls to `/dev/videoN` and persists them so they are re-applied on every pipeline reopen and reboot — a durable, state-changing hardware operation. The diff adds these RPCs to `WendyVideoService` with no per-RPC authorization, device/organization identity binding, or caller-role check. If the agent's gRPC endpoint relies only on network reachability or a broad peer allowance, any client that can reach the agent can permanently alter camera behavior — a denial-of-detection vector (e.g. blowing out exposure so a safety/fire model scores 0).

**Remediation:**
- Confirm/add an explicit mTLS peer identity + role/scope check in `GetCameraControls`/`SetCameraControls`, consistent with how other mutating RPCs (`ForgetCamera`, `SetCameraCredentials`) are gated.
- If a server interceptor already enforces authorization uniformly for all `WendyVideoService` methods, this can be marked addressed with a reference to that interceptor.

💡 Info — Open LOW: Persisted control store re-applied automatically with no audit trail or value clamping

go/internal/agent/services/video_service.go:1178-1184: applyStoredCameraControls re-asserts persisted V4L2 values on every producer restart with no provenance, audit trail, or bounds check.

Details
**Status:** Open
**Severity:** LOW
**Standards:** SOC2-CC6, ISO27001-A.8.9, NIST-800-53-CM-6
**Location:** `go/internal/agent/services/video_service.go:1178-1184`

`runProducer` calls `s.applyStoredCameraControls(path)` on each pipeline reopen, and the store survives restarts and reboots. This amplifies the impact of any bad or unauthorized `SetCameraControls`: a later manual `v4l2-ctl` correction is silently reverted, with no record of who set the persisted values. Values are also written to the driver without clamping to the queried min/max from QUERYCTRL.

**Remediation:**
- Record provenance (caller identity, timestamp) and emit an audit-log entry when persisted controls are (re)applied.
- Clamp values to the queried min/max before persisting.
This becomes non-blocking once the authorization concern above is confirmed.

💡 Info — Open INFORMATIONAL: device_id has no upper bound before /dev/videoN path construction

go/internal/agent/services/camera_controls.go:268-276: localCameraPath rejects the network band but formats any uint32 into /dev/video%d without checking it names an enumerated camera.

Details
**Status:** Open
**Severity:** INFORMATIONAL
**Standards:** SOC2-CC6, ISO27001-A.8.28
**Location:** `go/internal/agent/services/camera_controls.go:268-276`

`localCameraPath` only rejects the reserved network-camera ID band and then formats `fmt.Sprintf("/dev/video%d", devID)`. Path traversal is not possible (uint32 with %d introduces no separators), but there is no check that the id corresponds to a camera the agent actually enumerated/owns, so a caller can drive open/ioctl attempts against arbitrary `/dev/videoN` nodes. Impact is limited by `O_NOFOLLOW`/`O_NONBLOCK` opens and per-control error handling.

**Remediation:** Resolve `device_id` against the enumerated video-device registry and return `NotFound`/`InvalidArgument` for unknown ids rather than probing an arbitrary node path.

✅ Addressed LOW: Persisted camera-control store written world-readable

go/internal/agent/services/camera_controls.go:150-172: The store now writes the file 0o600 and the directory 0o700, restricting access to the agent user.

Details
**Status:** ✅ Addressed
**Severity:** LOW
**Standards:** SOC2-CC7.2, ISO27001-A.8.15
**Location:** `go/internal/agent/services/camera_controls.go:150-172`

The prior finding flagged 0o644/0o755 permissions. This diff creates the directory with `os.MkdirAll(filepath.Dir(c.path), 0o700)` and writes the temp file with `os.WriteFile(tmp, b, 0o600)`, and `TestCameraControlStore_IsNotWorldReadable` asserts 0600/0700. The concern is addressed.

🚫 Cancelled INFORMATIONAL: Generated code protoc version downgrade in committed artifacts

go/proto/gen/agentpb/wendy_agent_v1_video_service.pb.go:1-7: Prior finding about a protoc version downgrade cannot be verified against the visible diff.

Details
**Status:** 🚫 Cancelled
**Severity:** INFORMATIONAL
**Standards:** SOC2-CC7.1
**Location:** `go/proto/gen/agentpb/wendy_agent_v1_video_service.pb.go:1-7`

The prior review flagged a `protoc` version downgrade based on the pb.go header, which is not part of this diff. Remaining cancelled as unverifiable. If generated-artifact reproducibility is a concern, pin protoc/protoc-gen-go versions in the codegen workflow and verify generated files in CI.
Compliance summary
The feature adds durable, self-reapplying control over physical camera hardware via new gRPC RPCs. The primary open gap is the absence of a visible, explicit authorization/identity check on state-changing device operations (SOC 2 CC6.1, ISO 27001 A.8.3, NIST AC-3) — confirm the shared interceptor or add per-RPC checks. The prior store-permission concern is now addressed (0600/0700). Remaining hardening: add audit logging/provenance plus value clamping for persisted-control application (SOC 2 CC7.2, NIST CM-6), and validate device_id against the enumerated camera registry before touching host device nodes. No PCI, HIPAA, or GDPR personal-data domains are touched.

@Joannis Joannis added risk: mid Medium estimated risk; test changes and adjacent workflows api-review Ask Joannis - Changes a public CLI or protobuf API surface labels Aug 28, 2026

@Joannis Joannis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Image

This is not an acceptable UX. I'm not going to guess arguments

`tunableControls` listed 14 CIDs, and a name outside it was refused as an
"unknown control". That makes the feature only as good as the cameras somebody
owned when they wrote the list. On a Logitech C920 it covered 12 of the 17
controls the camera actually has, silently omitting **zoom, pan, tilt, focus and
auto-focus** -- every geometry control, so the service could tune the picture
but not aim it.

That gap is not cosmetic. Measuring a candle against the fire model on a
wendy-console box: backing the camera off took detection from 98.4% of frames to
52.4%, and 3x zoom brought it back to 86.3% -- a bigger recovery than any
control on the list produces. Manual exposure, which IS on the list, moved mean
confidence +0.095 and the hit rate not at all. The most useful control was the
one that could not be set.

So enumerate instead. V4L2_CTRL_FLAG_NEXT_CTRL OR'd into the id makes QUERYCTRL
return the next control the DRIVER has; walking from 0 yields the camera's whole
set, vendor-private controls included. The driver's label is normalised to the
spelling v4l2-ctl prints -- "Zoom, Absolute" -> `zoom_absolute` -- because that
is what someone reading their camera's documentation will type. Names are then
resolved per camera, so any camera attached to any device is tunable with no
code change here.

Verified on the device, not just in tests: the C920 went 12 -> 17 controls,
`set-control zoom_absolute=250` applied and read back 250 where the same command
was previously refused, and a control the camera genuinely lacks
(`iso_sensitivity`) still reports cleanly instead of failing the request.

Details worth keeping:

* **Falls back to the static table** when the driver does not implement
  NEXT_CTRL. A first probe returning nothing is indistinguishable from "this
  camera has no controls", so a real camera would otherwise enumerate empty.
* **Two loop guards**: stop after 256 controls, and stop if the driver returns
  the id it was given. A driver that ignores the flag would otherwise spin
  inside the agent forever.
* Class headings (V4L2_CTRL_TYPE_CTRL_CLASS) and driver-disabled controls are
  skipped; the 32-byte name field is read without assuming a terminator.
* The refusal text changes from "unknown control" to "this camera has no control
  by that name", because with enumeration the first is simply wrong: the control
  may be perfectly well known and merely absent here.
* `controlIndexFor` joins `applyLocalControls`/`queryLocalControls` as a seam, so
  the tests still need no real /dev/videoN.

`tunableControls` survives only as that fallback probe list, and says so.

Tests: replaced TestControlCID -- it pinned a table that is no longer the source
of truth -- with the normalisation cases against real driver spellings and an
unterminated-name bounds check. The re-apply test now asserts CIDs are non-zero
and distinct (what "resolved against the camera" can promise) rather than
matching the table.
…iscover

agent: ask the camera which controls it has, instead of a table
@chrisdok43
chrisdok43 requested a review from thombles as a code owner August 30, 2026 18:25
…ents

Running either command bare printed a cobra arity error and nothing else:

    $ wendy device camera set-control
    ✗ requires at least 2 arg(s), only received 0
    $ wendy device camera controls
    ✗ accepts 1 arg(s), received 0

That says how many arguments are missing and never what they are, so the only
way forward is to guess -- which is the review comment on #1818, verbatim: "I'm
not going to guess arguments."

Both arguments are things the tool can look up. So it does:

* `controls` with no id, and `set-control` with no arguments, list the cameras
  this device actually has (id, name, path) -- the id IS the missing argument.
* `set-control <id>` with no pairs lists that camera's settable controls with
  their current value and range, and prints an example using one of them.

Neither is a usage dump: the answer to a missing argument is the set of values
it could take, read off the hardware in front of you. Since #1837 the control
list comes from the camera, so what is printed is what that camera supports --
zoom and focus on a C920, whatever a different sensor offers.

Verified against a C920 over the tunnel: bare `controls` prints the one camera;
`set-control 0` prints its 14 settable controls, ranges included, and the
example line.

Args validation is relaxed to MaximumNArgs(1) and ArbitraryArgs respectively,
because the bare invocation is now a legitimate way to ask what the command
wants. Wrong-shaped pairs are still rejected by parseControlAssignments, and a
non-numeric id by parseCameraID, so nothing that used to be an error stops being
one -- only the empty case changes, from a refusal into an answer.
There was no way to undo one. `--no-persist` only declines to store the write in
front of it; the store itself had Load/merge/save/get and no remove, so once a
control was persisted it was re-asserted on every pipeline reopen forever, and
the only way out was editing /var/lib/wendy/camera-controls.json by hand.

That is a trap rather than an inconvenience, because a wrong camera setting does
not fail loudly -- it quietly costs a vision model its detections. Forcing a long
exposure so a flame blows out to white is a durable, self-healing denial of
detection that nothing in the CLI could reverse.

`CameraControl.reset` (field 8, Set only) now means: put this control back to the
driver's own default and stop persisting it. The default comes from QUERYCTRL,
so it is the camera's answer -- no caller has to know what "default" means for a
control nobody documented. The CLI exposes it as:

    wendy device camera set-control 0 --reset auto_exposure,exposure_time_absolute
    wendy device camera set-control 0 --reset-all

Two things this got wrong first, both found by running it against a C920:

* **The persist block re-stored what reset had just removed**, because `applied`
  contains the reset controls too. Persisting now skips them -- pinning a control
  to the default is still pinning, which is what reset undoes.
* **Forgetting has to be unconditional.** It was scoped to controls whose write
  succeeded, so `--reset-all` left `exposure_time_absolute` in the store: the
  driver reports it inactive while auto_exposure is on, the write fails, and the
  stale value would be re-applied on every reopen with no way to clear it.
  Reset makes two promises -- stop persisting this, and put it back -- and the
  first has to hold even when the second cannot. For the same reason `--reset-all`
  covers every control the camera reports, not only the currently settable ones.

Verified end to end on a C920 over the tunnel: `brightness=200` persisted and
appeared in the store; `--reset brightness` returned it to 128 (the driver's
default) and removed that entry while leaving the others; `--reset-all` then
emptied the store, including the inactive control that the first attempt stripped.

Proto regeneration touched only this file: the generator bumps a protoc version
header across all 69 generated files, so the rest were reverted and the header
pinned, leaving a diff that is just the new field.
The MCP server registers tools by hand, one file per area, and there was no
camera file. The only mention of a camera anywhere in it was a description
string in hardware_capabilities -- which reports that a camera exists and
nothing else. So adding RPCs to the agent gave the CLI two commands and gave an
agent nothing: camera_list, camera_controls and camera_set_control did not exist
because nothing derives them from the proto.

Three tools, following the existing pattern:

* `camera_list` -- read-only. The ids the other two take.
* `camera_controls` -- read-only. Name, value, range, driver default and whether
  the control is settable right now. Since the list is enumerated from the
  camera, this is that hardware's real control set rather than a fixed one.
* `camera_set_control` -- mutating, idempotent, not destructive: it changes how
  the camera captures and that is durable, but nothing is removed and
  reset/reset_all undo it. Takes `controls` as name=value pairs, plus `reset`,
  `reset_all` and `persist`.

This is the loop the tools exist for: look at the picture, notice the exposure
is wrong for the scene, fix it, put it back. Every step needs the device, and
until now an agent could do none of them -- which matters because a camera whose
auto-exposure blows a flame out to white silently costs a vision model its
detections, and the fix is one control away.

`reset_all` asks the camera for its controls rather than keeping a list, and
resets every one it reports, not only the currently settable ones -- a control
gated inactive by a mode (exposure_time_absolute while auto_exposure is on) is
exactly the one that would otherwise stay pinned in the store.

Per-control `detail` is passed through on failure: "this camera has no control
by that name" is actionable, a bare false is not.

Verified by running the server and listing tools: 37 total, the three camera
ones present, `camera_set_control` correctly annotated readOnly=false
destructive=false, and device_id required on both that and camera_controls.
chrisdok43 and others added 2 commits August 30, 2026 22:36
cli+mcp: make camera controls discoverable, reversible, and usable by an agent
81 commits behind, and one conflict: the GENERATED
go/proto/gen/agentpb/wendy_agent_v1_video_service.pb.go. Both sides had edited
the video service -- main added ROS 2 cameras (`topic = 12`,
VIDEO_TRANSPORT_ROS2) and this branch added the camera-control RPCs and
CameraControl.reset.

The .proto itself merged cleanly; only its generated output collided, which is
not a disagreement about anything, just two sets of codegen. Resolved by
regenerating from the merged .proto rather than hand-editing a DO NOT EDIT file,
then restoring the other generated files the script rewrites so the diff stays
to the one that actually changed.

Verified the result carries BOTH sides: Topic and VIDEO_TRANSPORT_ROS2 from main,
GetReset_ and the control messages from here.

go build ./... clean; go vet clean on services, mcp and commands; the services
and mcp test packages pass; the agent still cross-builds for linux/arm64.
@github-actions github-actions Bot added risk: high High estimated risk; thoroughly test compatibility and affected workflows and removed risk: mid Medium estimated risk; test changes and adjacent workflows labels Aug 30, 2026
@github-actions
github-actions Bot requested a review from Joannis August 30, 2026 19:51
It was written 0o644 in a 0o755 directory, so any local user could read it and,
depending on the directory, replace it.

The contents are not secrets, but they are device configuration that decides how
a camera captures — and where that picture feeds a detector, someone who can
rewrite the exposure can blind it without going near the detector. The values
are also re-applied on every capture reopen, so a single write persists.

0o700/0o600, matching ipcam's credential store in the same package rather than
inventing a number. Pinned by a test, since a permission is the kind of thing
that regresses silently.

Raised as a LOW finding by the automated security review on this PR.
@chrisdok43

Copy link
Copy Markdown
Contributor Author

@Joannis — the UX point is addressed, in #1837 and #1838, both already merged into this branch.

Arguments are no longer guessed. Running either command bare now lists what it needs instead of printing an arity error:

$ wendy device camera set-control 0
Which control? Camera 0 accepts:
╭────────────────────────────┬─────┬────────┬───────╮
│ Control                    │ Now │ Min    │ Max   │
├────────────────────────────┼─────┼────────┼───────┤
│ auto_exposure              │ 3   │ 0      │ 3     │
│ zoom_absolute              │ 100 │ 100    │ 500   │
│ …                                                 │
╰────────────────────────────┴─────┴────────┴───────╯
Then: wendy device camera set-control 0 name=value [name=value ...]

No id lists the cameras; an id with no pairs lists that camera's controls. The answer to a missing argument is the set of values it can take, read off the hardware.

The control list now comes from the camera. It was a hardcoded table of 14 CIDs, which silently omitted anything not on it — zoom, pan, tilt and focus among them. It now enumerates from the driver, so any camera exposes what it actually supports (12 → 17 on a C920).

Settings are reversible. --reset <names> and --reset-all return controls to the driver's default and stop persisting them. Previously a persisted control could only be undone by editing state on the device.

Also exposed as MCP tools (camera_list, camera_controls, camera_set_control), since nothing derives those from the proto.


On the automated security review:

1 (MEDIUM, no authorization on the new RPCs) — addressed by the interceptor the finding asks about. The video service is registered on the mtls.NewServer instance (cmd/wendy-agent/main.go:575), alongside the agent, container, audio, provisioning and telemetry services. Its contract is explicit: "The mTLS interceptors are always applied — they cannot be omitted via extraOpts. This ensures no handler can accidentally receive an unauthenticated call regardless of how the caller configures the server", and they enforce organization-equality between the client cert and the device. These RPCs are reachable on exactly the same terms as ForgetCamera and SetCameraCredentials.

2 (LOW, world-readable store) — fixed in e62fc411a. Now 0o700/0o600, matching ipcam's credential store in the same package rather than inventing a number, with a test pinning it since a permission regresses silently.

3 (LOW, re-applied on every capture restart) — the re-apply is the feature: a setting that reverts on a stream reconnect is not a setting. The "no way to undo it" half is no longer true — --reset restores the driver default and removes the entry from the store, so it stops being re-asserted.

Mergeable, checks green. Ready for another look.

@github-actions

Copy link
Copy Markdown
Contributor

Swift E2E Review

No Swift E2E review issues were generated for this run.


Report artifact: swift-e2e-tests.gh33372600213.run.0001

Comment on lines +120 to +132
int32 minimum = 3; // inclusive; Get only
int32 maximum = 4; // inclusive; Get only
int32 step = 5; // Get only
int32 default_value = 6; // Get only
bool settable = 7; // Get only: false when the driver reports it read-only or disabled right now
// Set only: put this control back to the driver's own default and stop
// persisting it, ignoring `value`. Without this a persisted control cannot
// be taken back -- the store only ever grows, and a setting that degrades a
// scene (a wrong exposure quietly costs a vision model its detections) is
// re-applied on every reopen with no way to undo it short of editing the
// store by hand. The default comes from the driver (QUERYCTRL), so it is
// the camera's answer rather than a value the caller has to know.
bool reset = 8;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No get/set only properties. Let's make a separate message for reading and writing

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

Labels

api-review Ask Joannis - Changes a public CLI or protobuf API surface risk: high High estimated risk; thoroughly test compatibility and affected workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants