Skip to content

agent: address a USB camera by its identity, not its boot order - #1853

Open
chrisdok43 wants to merge 3 commits into
mainfrom
feat/usb-camera-stable-id
Open

agent: address a USB camera by its identity, not its boot order#1853
chrisdok43 wants to merge 3 commits into
mainfrom
feat/usb-camera-stable-id

Conversation

@chrisdok43

Copy link
Copy Markdown
Contributor

Closes WDY-2774.

The problem

A camera's /dev/videoN number is assigned in USB enumeration order, so it belongs to the boot, not to the camera. A reboot re-ordered the two cameras on one of our devices, and every caller that had pinned a number was then addressing the other one.

The failure mode is what makes this worth fixing rather than documenting: opening the wrong camera succeeds. It streamed a valid picture from the wrong sensor, reported no error anywhere, and went unnoticed for hours precisely because nothing looked broken.

ListVideoDevices offered no way out:

field why it can't identify a camera
id, path the enumeration order — the thing that changed
name, driver the model — identical for two cameras of the same type

Two matching cameras on one box is a normal install, not a corner case.

The change

VideoDevice already solves this for network cameras:

string mac = 9;  // stable identity for network cameras; empty for local cameras

This gives local cameras the equivalent, from the two names udev already publishes:

by_id     vendor + product + serial   survives a reboot AND a move to another port;
                                      collides only for two same-model cameras
                                      sharing a factory serial
by_path   the USB port topology       survives a reboot but NOT a move; the only
                                      thing that separates identical cameras

Both are reported so a caller can pick the property it needs. Only by_id is resolvable in StreamVideo — pinning a stream to a physical port is a decision a caller should make deliberately rather than inherit.

StreamVideoRequest.device_by_id wins over device_id and is resolved at request time. That's the point: the number is looked up when the stream opens, instead of being baked into a config that outlives the boot it was written on.

Three deliberate choices, each with a test

  • No match raises, rather than falling back to device_id. Falling back would stream whatever the kernel put at that number and tell nobody — the exact bug this removes. The error lists the by-ids the agent can actually see, because the caller may not be able to log into the device to find out.
  • Matching is exact, not a prefix. ..._SN1-video-index0 must not match ..._SN10-video-index0 and silently hand back one of them.
  • A missing /dev/v4l is not an error. CSI and network cameras have no entry, and losing the enrichment beats failing enumeration — the numbers still work, they're just not stable.

Scope

Absent device_by_id, behaviour is unchanged. A device with no /dev/v4l enumerates exactly as before, pinned by its own test.

Tests

They reproduce the incident, not just the helper — the same two real camera names from the affected device resolve correctly both before and after the enumeration swap:

before   Arducam video0,1,2,3   TC001 video4,5
after    Arducam video2,3,4,5   TC001 video0,1

Plus: by-path reported separately, missing directories, a dangling symlink not costing healthy cameras their identity, exact-vs-prefix matching, and the unchanged path.

Reintroducing the silent fallback fails TestStreamDeviceIDRefusesAnUnknownName; I checked.

Verification

  • gofmt clean, go vet clean, go build ./go/... clean
  • go test ./go/internal/agent/services/ — passes, 12 new tests
  • Full go test ./go/... — 82 packages ok. Two failures, both pre-existing and environmental, verified by running them on clean main:
    • TestBuildCmd_MultiService_BuildsFromManifestOnly — fails on main too (macOS /private/var vs /var symlink)
    • TestSampleGPUFallsBackToTegrastatsForOrin — passes 4/4 in isolation; only flakes under full-suite load, and this change doesn't touch hoststats
  • Generated protobuf: only the video service files are in the diff. Regenerating touches every file's version comment (my protoc is v7.35.1, the repo's committed output is v7.34.0), so I reverted the unrelated ones. Worth noting the repo's generated headers are already mixed — v5.28.3, v7.34.0, v1.36.11-devel all appear.

Not in this PR

A by-path-based resolve for the same-model/shared-serial case. Reporting it is free and informative; resolving by it needs a policy call about pinning streams to ports.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KmLkEMDTdpMPuCPmRZs6px

A camera's /dev/videoN number is assigned in USB enumeration order, so it is a
property of the boot rather than of the camera. A reboot re-ordered the two
cameras on one of our devices and every caller that had pinned a number was
then addressing the other one.

The failure mode is why this is worth fixing rather than documenting: opening
the wrong camera SUCCEEDS. It streamed a valid picture from the wrong sensor,
reported no error anywhere, and went unnoticed for hours precisely because
nothing looked broken.

ListVideoDevices offered no way out. `id` and `path` are the enumeration order;
`name` and `driver` identify the MODEL, so they cannot separate two cameras of
the same type -- which is a normal install, not a corner case.

VideoDevice already solves this for network cameras (`mac`, "stable identity").
This gives local cameras the equivalent, from the two names udev already
publishes:

  by_id    vendor + product + serial   survives a reboot AND a move to another
                                       port; collides only for two same-model
                                       cameras sharing a factory serial
  by_path  the USB port topology       survives a reboot but NOT a move; the
                                       only thing that separates identical
                                       cameras

Both are reported so a caller can pick the property it needs. Only by_id is
resolvable in StreamVideo: pinning a stream to a physical port is a decision a
caller should make deliberately rather than inherit.

StreamVideoRequest.device_by_id wins over device_id and is resolved at REQUEST
time, which is the point -- the number is looked up when the stream opens
instead of being baked into a config that outlives the boot it was written on.

Three deliberate choices, each with a test:

* a name that matches nothing RAISES rather than falling back to device_id.
  Falling back would stream whatever the kernel put at that number and tell
  nobody, which is the exact bug this removes. The error lists the by-ids the
  agent can actually see, because the caller may not be able to log into the
  device to find out.
* matching is EXACT, not a prefix. "..._SN1-video-index0" must not match
  "..._SN10-video-index0" and hand back one of them silently.
* a missing /dev/v4l is not an error. CSI and network cameras have no entry,
  and losing the enrichment beats failing enumeration -- the numbers still
  work, they are simply not stable.

Absent device_by_id, behaviour is unchanged; a device with no /dev/v4l
enumerates exactly as before, which is pinned by its own test.

The tests reproduce the incident rather than the helper: the same two real
camera names resolve correctly both before and after the enumeration swap.
Reintroducing the silent fallback fails TestStreamDeviceIDRefusesAnUnknownName.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmLkEMDTdpMPuCPmRZs6px
@github-actions

github-actions Bot commented Sep 1, 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: 7/7 changed files; 32,878/32,878 bytes reviewed; diff SHA-256 b0b4093f9466cb08a93c00cb4ed47342cea7d0d84a086a11e36312bb21069331; truncation: none.

No security findings.

@github-actions github-actions Bot added risk: high High estimated risk; thoroughly test compatibility and affected workflows api-review Ask Joannis - Changes a public CLI or protobuf API surface labels Sep 1, 2026
`--by-id` takes the name `camera list` now reports and sends it as
StreamVideoRequest.device_by_id, which the agent resolves at request time.
Without it the new field is unreachable from the CLI, so the identity is
reported and cannot be used.

`--id` keeps working and its help now says what it actually is -- the boot
order. Passing both is an error rather than a silent precedence rule: they name
the camera two different ways and picking one for the caller is how you end up
streaming something nobody asked for.

Verified on hardware (jetson-orin-nano, Logitech C920):

  --by-id <the camera>   5.76 MB of H.264
  --by-id <absent cam>   0 bytes, and the error names the two by-ids the agent
                         can see rather than falling back to device 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmLkEMDTdpMPuCPmRZs6px
@chrisdok43

Copy link
Copy Markdown
Contributor Author

Verified end to end on hardware

Tested on a jetson-orin-nano (wendyos-testest, WendyOS-0.19.0) with a Logitech C920, before and after side-loading this build.

Before — the premise, reproduced

{ "name": "HD Pro Webcam C920",
  "path": "/dev/video0",
  "transport": 1,
  "driver": "uvcvideo" }

Nothing there identifies the unit: name/driver are the model, path is boot order. And id is missing entirely — it is 0, and proto3 omits zero-valued scalars, so the camera reads as unaddressable. That is the second bug noted in WDY-2774, live.

What udev had all along:

/dev/v4l/by-id/    usb-046d_HD_Pro_Webcam_C920_762B1F5F-video-index0  -> video0
/dev/v4l/by-path/  platform-3610000.usb-usb-0:2.1:1.0-video-index0    -> video0
                   platform-3610000.usb-usbv2-0:2.1:1.0-video-index0  -> video0

After

{ "name": "HD Pro Webcam C920",
  "path": "/dev/video0",
  "transport": 1,
  "driver": "uvcvideo",
  "by_id":   "usb-046d_HD_Pro_Webcam_C920_762B1F5F-video-index0",
  "by_path": "platform-3610000.usb-usb-0:2.1:1.0-video-index0" }

Note by_path resolved to usb- rather than usbv2-: this device publishes two by-path symlinks for video0, and the sorted first-wins rule picked deterministically. That collision case was hypothetical when I wrote it and turned out to be real on the first device I tried.

Streaming by name

--by-id usb-046d_HD_Pro_Webcam_C920_762B1F5F-video-index0   → 5.76 MB of H.264

And the case the whole change exists for

Asking for a camera that is not plugged in:

--by-id usb-Arducam_..._SN0001-video-index0   → 0 bytes

✗ no camera with by-id "usb-Arducam_..._SN0001-video-index0";
  ListVideoDevices reports [usb-046d_HD_Pro_Webcam_C920_762B1F5F-video-index0
                            usb-046d_HD_Pro_Webcam_C920_762B1F5F-video-index1]

It refuses and says what is actually there, instead of streaming /dev/video0 and reporting success — which is precisely the failure that cost us a day.

One addition since the first push

654b3100a adds --by-id to wendy device camera view. Without it the new field is reachable by no caller, so the identity would be reported and unusable. --id is unchanged, its help now says it is the boot order, and passing both is an error rather than a silent precedence rule.

Note for reviewers

An old CLI against a patched agent shows no new fields — the CLI's generated proto has no by_id, so its decoder drops them as unknown. That is correct proto3 behaviour and not a bug, but it cost me a confused minute, so: test with a CLI built from this branch.

@chrisdok43

Copy link
Copy Markdown
Contributor Author

Physical validation: port move + reboot

Same device (jetson-orin-nano, Logitech C920). The camera was physically moved to a different USB port, then the box was rebooted (uptime confirmed 77 s, not a network blip).

field     baseline          after port move   after reboot
path      /dev/video0       /dev/video0       /dev/video0
by_id     …C920_762B1F5F…   …C920_762B1F5F…   …C920_762B1F5F…
by_path   usb-0:2.1:1.0     usb-0:2.3:1.0     usb-0:2.3:1.0
  • by_idunchanged through both
  • by_pathchanged on the move, stable across the reboot
  • path — unchanged, as expected: one camera has nothing to race with

The practical check: the by_id string captured before either change still streams.

--by-id <captured before move+reboot>   → 4.97 MB of H.264
--by-id <the pre-move by_path>          → 0 bytes, refused

That second line is the point. The identifier this PR resolves on survived; the one it deliberately does not resolve on broke exactly as the proto comment claims. That was an assertion when I wrote it and is now measured.

What this does not show

It does not reproduce the original outage. With a single camera the numbering never shuffles, so --id 0 kept working throughout. What is demonstrated is the narrower, falsifiable claim: by_id is invariant under the two physical events that move the number.

Reproducing the swap needs a second camera on the same box so a reboot has something to race with. Worth doing if someone has a spare, but the design property it would exercise is the one already shown above.

#1817 landed and touches the same files. Three conflicts, all additive on both
sides -- the two changes are independent and simply arrived at the same lines:

* StreamVideoRequest: #1817 takes field 5 (`VideoCodec codec`), this branch
  takes field 6 (`device_by_id`). No collision; field 6 was chosen when this
  branch opened precisely because #1817 had already claimed 5.
* camera.go: #1817 adds `raw` / `Codec`, this branch adds `byID` /
  `DeviceById`. Both kept.
* The generated .pb.go was regenerated from the merged proto rather than
  hand-resolved, and the unrelated version-comment churn `make proto` produces
  was reverted so the diff stays to the video service.

Verified: gofmt, go vet, go build ./go/... clean; agent/services and
cli/commands both pass, including #1817's raw-frame tests; the 12 tests added
by this branch still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmLkEMDTdpMPuCPmRZs6px
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Swift E2E Review

No Swift E2E review issues were generated for this run.


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

@chrisdok43

Copy link
Copy Markdown
Contributor Author

The failure reproduced on hardware, with two identical cameras

Earlier testing used one camera, which can never renumber — so the original outage stayed unreproduced. A second camera has since been added to the bench box, and it is the hard case: two Logitech C920s, same model, different serials.

Everything a caller could match on is identical:

name       "HD Pro Webcam C920"   ==   "HD Pro Webcam C920"
driver     uvcvideo               ==   uvcvideo
transport  1                      ==   1
by_id      ..._762B1F5F...        !=   ..._577E3B1F...

One of them also reports no id at all, because its id is 0 and proto3 omits zero-valued scalars.

Then the cameras were replugged in a different order

Nothing moved physically — both stayed in the same USB sockets (0:2.2, 0:2.4). Only the plug order changed:

serial      id was   id now    port
577E3B1F      2   →    0       0:2.4  (unchanged)
762B1F5F      0   →    2       0:2.2  (unchanged)

The numbers traded places. And the consequence, measured:

                      before             after
--id 0            →   762B1F5F      →    577E3B1F    ← a different physical camera
--by-id 762B1F5F  →   762B1F5F      →    762B1F5F
--by-id 577E3B1F  →   577E3B1F      →    577E3B1F

A config pinned to --id 0 now addresses a different camera, streams perfectly, and reports nothing. That is the ccr2 outage exactly, on demand.

Every scheme has now failed except by_id

event by_id by_path device number
reboot held
moved to another USB port broke (0:2.10:2.2, verified 0 bytes) held
replugged in a different order swapped

by_id is the only column with no failure — which is why it is what device_by_id resolves against, and why by_path is reported but deliberately not resolvable.

And it settles the same-model question

This box cannot be served by matching on the sysfs name: both cameras return the same string. The serial is the only discriminator that exists. That was stated as a limitation in WDY-2774 and is now demonstrated.

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.

1 participant