Skip to content

feat(caps): publish the post-quirk deliverable mode ladder - #25

Merged
andrescera merged 1 commit into
mainfrom
feat/deliverable-caps-property
Jul 31, 2026
Merged

feat(caps): publish the post-quirk deliverable mode ladder#25
andrescera merged 1 commit into
mainfrom
feat/deliverable-caps-property

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

Publishes the mode ladder this element will actually accept, through two new read-only surfaces:

  • deliverable-caps (GstCaps property) — the post-quirk ladder negotiate() selected from, for an open device. NULL until 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() in quirks.c.

Why

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 instead. 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 selected it: twelve requests for 3840x2160@50, twelve Unable 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 uvcvideo and destroys /dev/videoN for 0.4–2.0 s, which is a separately tracked defect. It already holds the advertised ladder from GstDevice::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

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
ninja -C build && (cd build && ctest -j4)

118/118 pass. The 11 pre-existing quirks_* cases are the regression lock on the negotiate() rewire — they assert the same selected modes and the same probe counts as before.

New cases:

Case Asserts
quirks_deliverable_caps_excludes_phantom the Osmo ladder publishes 4K@30/25/24 and not 60/50/48
quirks_deliverable_caps_unquirked_intact an unquirked device keeps every advertised rate
quirks_filter_signal_agrees property and signal answer identically across the whole ladder — the anti-drift lock
quirks_filter_signal_unquirked_noop unquirked vid:pid returns the input unchanged, with mock_uvc_open_count() == 0
compat_deliverable_caps_api names, types, arity and G_SIGNAL_ACTION are pinned

compat_deliverable_caps_api earns 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…). Real GstDevice::caps() for the real camera, through the real deployed plugin:

DEVICE: DJIPocket3: OsmoPocket3
ADVERTISED  3840x2160: 60/1 50/1 48/1 30/1 25/1 24/1
DELIVERABLE 3840x2160:           30/1 25/1 24/1
ADVERTISED  1920x1080: 30/1 25/1 24/1
DELIVERABLE 1920x1080: 30/1 25/1 24/1

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.
  • Additive API only. No existing property or signal changed; no caps contract changed.
  • deliverable-caps returns NULL, never empty, before negotiation. A consumer that reads empty as "no modes" would hide every option — the distinction is documented on the property, in AGENTS.md, and asserted in compat_deliverable_caps_api.
  • Land this before the cerastream side; that PR degrades to the advertised ladder when the signal is absent, so the ordering is for coherence rather than correctness.

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
@andrescera
andrescera merged commit 30d6c5d into main Jul 31, 2026
5 checks passed
@andrescera
andrescera deleted the feat/deliverable-caps-property branch July 31, 2026 01:00
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