feat(caps): publish the post-quirk deliverable mode ladder - #25
Merged
Conversation
A UVC camera's advertised ladder is not a promise. The DJI Osmo Pocket 3
advertises 3840x2160 at 60, 50 and 48 fps and streams none of them; the
2ca3:0023 quirk row (max_pixel_rate = 248832000) makes negotiate() refuse
all three.
That exclusion was computed only inside negotiate() and never published, so
every consumer built its mode list from the raw descriptor walk. cerastream
and CeraUI therefore offered 2160p50 for a camera the element was guaranteed
to reject: on board 192.168.78.131 (2026-07-30) an operator picked it and got
twelve requests, twelve "Unable to negotiate common caps", and no stream.
The exclusion now lives in ONE function, uvc_quirks_filter_caps(), and gets
three consumers instead of one:
- negotiate() runs each advertised descriptor through it before selecting,
replacing the inline per-interval filtering it used to do itself
- "deliverable-caps" (read-only GstCaps) publishes the ladder negotiate()
selected from, for an open device
- "filter-deliverable-caps" (action signal) applies it to a caller-supplied
ladder with NO device involved, for consumers that enumerate cameras and
must not open one -- opening a UVC device through libuvc detaches
uvcvideo and destroys /dev/videoN for seconds
So the modes an operator is offered are by construction the modes
negotiation will accept.
Two asymmetries are deliberate: an unquirked vid:pid gets its ladder back
unchanged, and "deliverable-caps" reads NULL (unknown) rather than empty (no
modes) before negotiation, because under-reporting strands the operator with
no usable option while over-reporting fails one negotiation loudly.
Tests: 4 new quirks cases -- the Osmo ladder excludes 4K@60/50/48 and keeps
4K@30/25/24, an unquirked device keeps every rate, and the two surfaces are
asserted to agree across the whole ladder so they cannot drift apart again.
A compat case pins the property and signal names, types and arity, because
cerastream binds to them by name at runtime and a rename breaks device
enumeration in another repo with nothing failing in this one.
118/118 ctest pass; the 11 pre-existing quirks cases lock the negotiate()
rewire as behaviour-preserving.
Board-Evidence-SHA256: 8389becb3868a84136c7f8432d77715c405c374153e70139b6209cebcd6053fa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Publishes the mode ladder this element will actually accept, through two new read-only surfaces:
deliverable-caps(GstCaps property) — the post-quirk laddernegotiate()selected from, for an open device.NULLuntil a device is negotiated.filter-deliverable-caps(advertised, vendor-id, product-id)(action signal) — the same exclusion applied to a caller-supplied ladder, with no device involved.negotiate()no longer filters inline. All three paths now call one function,uvc_quirks_filter_caps()inquirks.c.Why
A UVC camera's advertised ladder is not a promise. The DJI Osmo Pocket 3 advertises
3840x2160at 60, 50 and 48 fps and streams none of them — the2ca3:0023quirk row (max_pixel_rate = 248832000) makesnegotiate()refuse all three.That exclusion was computed only inside
negotiate()and never published, so every consumer built its mode list from the raw descriptor walk instead. cerastream and CeraUI therefore offered2160p50for a camera the element was guaranteed to reject. On board192.168.78.131(2026-07-30) an operator selected it: twelve requests for3840x2160@50, twelveUnable to negotiate common caps, no stream — a perfect 1:1, and that error appeared for no other request shape all day.The fix is not more filtering. It is making the filtering visible, so the modes an operator is offered are by construction the modes negotiation will accept.
Why a signal and not just the property
The consumer that needs this (cerastream) enumerates devices with no camera open, and must stay that way — opening a UVC device through libuvc detaches
uvcvideoand destroys/dev/videoNfor 0.4–2.0 s, which is a separately tracked defect. It already holds the advertised ladder fromGstDevice::caps()and the USB ids, so the signal lets it get the same answer with zero device I/O. The signal is pure caps arithmetic.How to verify
118/118 pass. The 11 pre-existing
quirks_*cases are the regression lock on thenegotiate()rewire — they assert the same selected modes and the same probe counts as before.New cases:
quirks_deliverable_caps_excludes_phantomquirks_deliverable_caps_unquirked_intactquirks_filter_signal_agreesquirks_filter_signal_unquirked_noopmock_uvc_open_count() == 0compat_deliverable_caps_apiG_SIGNAL_ACTIONare pinnedcompat_deliverable_caps_apiearns its place: cerastream binds these by name at runtime, so a rename breaks device enumeration in another repo while nothing here fails to compile.Board proof
Built for aarch64 and deployed to
192.168.78.131(dcac0cb2…). RealGstDevice::caps()for the real camera, through the real deployed plugin:Exactly the three phantom rates removed; every other rung byte-identical. Control with an unquirked vid:pid on the same camera keeps all six 4K rates, so the exclusion is keyed on the quirk row and never on geometry.
Risks
negotiate()was restructured, which is the real risk here. It now builds each descriptor's full advertised caps and filters through the shared function, instead of skipping rates while building the framerate list. The-1"top rate" for continuous-interval descriptors is preserved deliberately — it makes a fraction range fixate to its lowest rate, which is the behaviour those devices have always had. The 11 untouched quirk cases plus the full 118-case suite are the evidence this is behaviour-preserving.deliverable-capsreturnsNULL, never empty, before negotiation. A consumer that reads empty as "no modes" would hide every option — the distinction is documented on the property, inAGENTS.md, and asserted incompat_deliverable_caps_api.