Skip to content

✨ #72: Support for the OBSBOT Tiny 4K - #169

Open
hutzelmann wants to merge 7 commits into
OpenFoxes:mainfrom
hutzelmann:feature/#72-tiny-4k-support
Open

hutzelmann wants to merge 7 commits into
OpenFoxes:mainfrom
hutzelmann:feature/#72-tiny-4k-support

Conversation

@hutzelmann

Copy link
Copy Markdown

Summary

Adds support for the OBSBOT Tiny 4K (#72), so that every command the CLI and the GUI
already offer either works on this camera or says why it cannot. No new commands, no new
controls, no new dependencies.

The Tiny 4K is not a cut-down Tiny 2. It is architecturally the original OBSBOT Tiny and
speaks an older protocol, which is why so much of it looked like it worked: the camera
accepts the Tiny 2 frames on USB, answers without an error, and ignores them.

Seven independent problems, one commit each:

# Problem
1 The camera is never detected at all
2 Sleep and wake do nothing, and info reports the wrong state
3 AI tracking does nothing
4 Preset positions do nothing
5 A debug button crashes the whole GUI
6 The GUI offers controls this camera does not have
7 info and the GUI cannot read the tracking mode back

Changes

1 — detection. Camera::detect() tries "OBSBOT Tiny 2" and then "OBSBOT Tiny 4K".
A Tiny 2 is still preferred whenever one is connected, so nothing changes for Tiny 2
users; Camera::new(hint) is untouched.

2 — sleep and wake. A builder for the legacy frame next to the existing
command02.rs, plus CameraModel derived from the detection hint. The sleep state is
read from status byte 0x09 instead of 0x02, which is permanently zero on this model —
that is why info claimed a sleeping camera was awake.

3 — AI tracking. The 4K splits tracking across three frames: enable the engine, choose
the framing mode, select a target. Without the target selection it never follows anyone.
The frames also have to be paced: sent back to back they are dropped and nothing
happens, while the vendor app never sends two legacy frames closer than about 50 ms. The
modes it does not have, the tracking speed it lacks entirely and manual exposure are now
reported instead of silently accepted.

4 — preset positions. The 4K has no recall command. It stores the positions and hands
the table out in one read: a count byte, then one nine byte record per slot with slot
number, pitch and yaw in hundredths of a degree, and zoom. A recall is that read plus
release-the-target and one absolute move. Positions are still stored with OBSBOT Center.

5 — GUI crash. "Get & Dump 0x02" took the whole window down on a 4K, which stalls a
cold GET_CUR on selector 2. The other three debug actions had the same flaw for any USB
error, and invalid hex input panicked too.

6 — GUI honesty. Controls the camera does not have are disabled and faded, and the
status panel no longer states a tracking speed for a camera that has none.

7 — tracking readback. e3 3092 reports whether the AI engine is on and which framing
mode is set, so info and the GUI show the truth instead of "Unknown".

The Tiny 2 path is unchanged throughout, and each commit builds and passes its own
tests
(74 → 85 → 95 → 99 → 99 → 99 → 106).

The protocol, so this is reviewable without the hardware

aa 00 <len> <type> <seq u16 BE> <crc u16 BE> 00 <route> <command u16 BE> <payload>

len includes the 12-byte header, type is 0x10 to set and 0x12 to ask, route is
0xe1 camera / 0xe2 gimbal / 0xe3 AI. crc is CRC-16/USB over frame[0..len] with
the checksum bytes zeroed, stored big-endian. Padded to the 60-byte XU buffer.

Two frames captured from OBSBOT Center driving a Tiny 4K:

wake   aa 00 0e 10 00 35 2c f1 00 e1 13 c2 01 01
sleep  aa 00 0e 10 00 34 7d 7d 00 e1 13 c2 01 03

The unit tests pin the builder to both, byte for byte, and check the CRC against the
standard CRC-16/USB check value ("123456789" → 0xB4C8). If the captured frames are
right then the builder is, and a future change that breaks either fails the suite.

One trap worth knowing if you ever read this channel: the camera keeps the previous
answer available until the new one is ready.
Requests therefore carry a sequence number
that the reply echoes, and the read retries until they match. The sequence also has to
differ between runs, because the stale answer outlives the process that asked for it —
that one was not theoretical, it reported a stale tracking mode until fixed.

Verification on a real Tiny 4K (USB 3564:fef4, firmware 1.2.5.4, kernel 6.6)

Everything below was reverse engineered from three USB captures of the Windows vendor app
(~325 MB, every legacy frame CRC-valid) and then replayed against the camera.

Check Result
Detection and info camera found, status read
10 sleep/wake cycles through the CLI 10/10, status 0x09 3 ⇄ 1 every time
Byte 0x02 throughout stayed 0x00 — why the old decode could only ever say "Awake"
Sleep twice, wake twice idempotent both directions
Privacy state (0x09 = 4) reached and decoded as asleep
Tracking, controlled A/B tracking off: person moves, gimbal stays (range 3). Tracking on: gimbal follows 18°
Tracking through the CLI displaced gimbal returns to the person after t4l tracking normal
Tracking mode readback static / normal / upper-body set and read back correctly, 6/6
Preset recall gimbal moved from −9.91° to 21.05° against a stored 21.16°
Unsupported commands 10/10 correct exit codes: supported → 0, unsupported → 1
t4l preset 9 reports and exits 1, where it used to panic (exit 101)
GUI sleep toggle, disabled controls and tracking mode all correct on the real camera

Sleeping while a video stream runs yields 0x09 = 4 (privacy) rather than 3; it
cannot be set directly, so that is the only way to reach it. Both stop the stream, so both
decode as asleep. And a wake sent while the camera is still leaving privacy is silently
dropped — a second one works.

Scope

Three things still report themselves as unavailable, each a real property of the hardware:

  • Tracking speed does not exist on this model. The vendor SDK gates that function to
    other cameras, so there is nothing to implement.
  • The other tracking modes (close-up, desk, whiteboard …) do not exist on it either.
  • Manual exposure goes through the standard UVC camera terminal, which V4L2 already
    reaches; this project documents that division deliberately.

I mapped and hardware-verified more of the protocol than this PR uses — field of view,
mirror, noise cancellation, microphone-while-asleep, portrait mode, gesture control, the
gimbal home position. None of it is here, because it would mean new commands, new GUI
controls and new strings in seven locale files. It is all written up at the bottom of this
description so it is not lost.

Notes for review

  • The trait is still called Tiny2Camera even though it now dispatches Tiny 4K commands.
    Renaming it is a refactor, and Support for different cameras #108's known-device list is the better place for it.
  • CameraStatus::decode is no longer called from production code (transport calls
    decode_for). It stays as the Tiny 2 shim the existing status tests use, which keeps
    the public API stable — deliberate, not an oversight.
  • Capability checks live in the library, the wording in the callers. That was forced
    rather than stylistic: both binaries .unwrap() every camera call, so returning Err
    for an unsupported command would have turned an ignored command into a panic.
  • I do not own a Tiny 2, so nothing here was tested on one. The Tiny 2 path is
    unchanged by construction rather than by testing: every model-dependent call keeps the
    original command and offsets in its CameraModel::Tiny2 arm, CameraModel::from_hint
    falls back to Tiny2 for any unrecognised hint, and the existing tests still pass. The
    behaviour that does change for a Tiny 2 is that two error paths report instead of
    panicking: an unknown preset number, and any failure in the GUI debug area.
  • Seven new strings are in en and de. The other five locales fall back to English
    through the existing fallback = "en"; I did not want to invent translations in five
    languages I do not speak.
  • This is a one-off contribution: I do not plan to maintain the Tiny 4K path, so please do
    not hold anything open waiting on me. Everything I know is in this description.

cargo build, cargo test --all-features (106 passed, previously 74), cargo fmt --check
and npx prettier --check all pass. cargo clippy --all-features is unchanged from the
base branch: the same 6 / 7 / 8 warnings in lib, CLI and GUI, none of them in new code.

Reference: everything else I verified on the Tiny 4K (not implemented here)

Reverse engineered from three USB captures of OBSBOT Center driving a Tiny 4K, then
replayed against the camera. Cross-checked against the vendor SDK symbol tables and
published work on the 1st-generation Tiny.

Two traps that make this hard to reproduce

  • Wireshark's usbvideo dissector hijacks UVC class requests and renames the setup
    fields, so usb.setup.wValue / usb.setup.wIndex come back empty and every extension
    unit filter matches zero packets — the capture looks like it contains no vendor traffic
    at all. Pass --disable-protocol usbvideo to tshark.
  • v4l2-ctl --get-ctrl=pan_absolute is not a position sensor. uvcvideo echoes back the
    last value written, wherever the gimbal actually is. Use the camera's own attitude report
    (e2 2001) instead. This made a working command look broken.

Extension unit 2, selector 6 — TLV settings [tag, len, value]

Function Bytes Status byte
HDR 01 01 <0/1> 0x06
Face AE / global AE 03 01 <0/1> 0x07
Field of view (wide/mid/narrow) 04 01 <0/1/2> 0x11
Noise cancellation 0a 01 <0/1> 0x08
Auto-sleep timeout 0b 02 <int16 LE seconds> 0x0a-0x0b
Auto-sleep off 0b 02 <negated timeout> 0x0a-0x0b
Vertical / portrait mode 0c 01 <0/1> 0x0c
Microphone while asleep 13 01 <0/1> 0x10
Mirror image 14 01 <0/1> 0x13

Switching portrait mode re-enumerates the USB device; anything holding the video node
has to reopen it.

Extension unit 2, selector 2 — legacy frames

Function route+cmd Payload
Run status (this PR) e1 13c2 01 01 wake / 01 03 sleep
Face focus e1 13d2 <0/1>, reads back at status 0x0d
Gimbal attitude read e2 2001 GET, no payload
Gimbal absolute move e2 2006 6x int16 LE deg*100, first three are speeds
Gimbal continuous move e3 3023 [03][f32 LE roll=200.0][f32 pitch][f32 pan]
Move to boot position e3 303a none
AI master enable e3 3051 00 <0/1>
Target deselect e3 3066 none
Target select ("lock") e3 3067 00 — a constant, not a boolean
Gesture control enable e3 3090 [sub, value]: 00 target lock, 01 zoom
Tracking mode e3 3091 00 standard / 01 headroom / 02 motion
AI status read e3 3092 GET, no payload
Gesture zoom factor e3 3093 float32 LE
View + gimbal invert e3 30ee <0/1>
UUID / version / serial / info e8 8060 / 8010 / 8063 / 8065 GET, no payload

route is a (dst<<4) | src nibble pair with the host as 0xE, which is why replies come
back with it swapped (e3 → 3e). The camera does not validate the sequence number, and
it ignores everything past len, so trailing bytes of a captured 60-byte buffer are stale.

Plain UVC on this model — no vendor commands needed

Zoom (ZOOM_ABSOLUTE), exposure (AE_MODE + EXPOSURE_TIME_ABSOLUTE), focus
(FOCUS_AUTO, FOCUS_ABSOLUTE) and anti-flicker (POWER_LINE_FREQUENCY) are all standard
UVC controls, reachable through V4L2.

Status buffer (selector 6, 60 bytes)

0x00 ai target · 0x03 anti-flicker · 0x04 zoom/FOV position · 0x06 HDR ·
0x07 face AE · 0x08 noise cancellation · 0x09 run status (1 run, 3 sleep, 4 privacy) ·
0x0a-0x0b auto-sleep timeout int16 LE, negative = disabled · 0x0c portrait ·
0x0d face autofocus · 0x0e autofocus · 0x0f focus value, drifts constantly ·
0x10 mic while asleep · 0x11 FOV level (3 = manual zoom active) · 0x13 mirror.
0x14 upward stayed zero throughout.

Sleep resets zoom and FOV to wide; settings do not survive a sleep/wake cycle.

Do not replay these blind

  • e3 3023 is continuous — a nonzero frame runs the motor until an all-zero stop frame.
    Its floats are little-endian inside a big-endian header.
  • e2 2006's first three int16 are speeds, the last three are angles; getting the order
    wrong commands a full-range slew. The deg*100 scale comes from the SDK binary and was not
    confirmed on hardware.
  • AI must be disabled (e3 3051 → 00 00) before manual gimbal motion, or AI fights it.
  • Selector-7 writes overwrite stored presets.
  • e1 1fff appeared once and matches no known SDK opcode. Leave it alone.

🤖 Generated with Claude Code

hutzelmann and others added 7 commits September 4, 2026 00:42
The device hint was hardcoded to "OBSBOT Tiny 2" in the GUI and the CLI, and
open_camera matches that hint against the V4L2 card name. A Tiny 4K reports
"OBSBOT Tiny 4K: OBSBOT Tiny 4K", so NoCameraFound was returned and both
applications claimed that no camera is connected, although the camera works
everywhere else.

Camera::detect() now tries "OBSBOT Tiny 2" first and falls back to
"OBSBOT Tiny 4K". A connected Tiny 2 is still preferred, so nothing changes
for Tiny 2 users, and Camera::new(hint) is untouched.

Verified on an OBSBOT Tiny 4K (USB 3564:fef4): the camera is detected and
its status is read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tiny 4K accepts the Tiny 2 sleep frame on USB but ignores it. It is
architecturally the original OBSBOT Tiny and speaks an older frame format,
so sleep and wake now dispatch on the detected model. The Tiny 2 path is
unchanged.

Its sleep state is read from status byte 0x09, the device run status, rather
than byte 0x02, which is a reserved and always zero field on the 4K. That is
why info reported the Tiny 4K as awake while it was asleep.

The frames and the status offset were captured from the vendor app driving a
Tiny 4K and verified against the camera. The README gains the feature matrix
for this model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tiny 4K does not understand the Tiny 2 AI-mode setting. It splits the same
function over three legacy frames: enable the AI engine, select the framing mode
and select a target. Without the target selection the camera never starts
following anyone.

The frames also have to be paced. Sent back to back they are dropped and nothing
happens at all; in captures of the vendor app no two legacy frames are ever
closer than about 50 ms, so the sequence is sent with a 100 ms gap.

set_ai_mode dispatches on the detected model, so the Tiny 2 path is unchanged.
The 4K knows standard and headroom framing but not the other Tiny 2 modes, it has
no tracking speed setting at all, and it takes manual exposure only through the
standard UVC controls. Rather than accepting those commands and doing nothing,
the CLI and the GUI now report that the camera does not have them. For the same
reason info no longer prints an AI mode and a tracking speed that were decoded
from bytes the 4K leaves at zero.

Verified on a Tiny 4K: with tracking off the gimbal ignores a person moving
across the frame, with tracking on it follows them by 18 degrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tiny 4K has no command that recalls a preset. It stores the positions itself
and hands the whole table out in one read on extension unit 2, selector 7: a count
byte followed by one nine byte record per slot, holding the slot number, pitch and
yaw in hundredths of a degree and the zoom factor.

A recall is therefore a read followed by two frames: release the tracked target,
because the AI would otherwise steer the gimbal straight back, and move to the
stored angles. The positions themselves are still stored with OBSBOT Center; this
only recalls them.

goto_preset_position dispatches on the detected model, so the Tiny 2 path is
unchanged. Recalling a slot the camera does not have used to panic through unwrap
in both the CLI and the GUI, and now reports the problem instead.

Verified on a Tiny 4K: after moving the gimbal to -9.91 degrees, "t4l preset 1"
returns it to 21.05 degrees against a stored 21.16, and "t4l preset 2" to the
default position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Get & Dump 0x02" crashed the whole GUI on an OBSBOT Tiny 4K. That camera stalls
a cold GET_CUR on extension unit 2, selector 2, dump_02 returns the resulting USB
error and unwrap turned it into a panic. Selectors 6 and 7 answer normally, so
only this one button was affected.

The other three debug actions had the same problem for any USB error, and invalid
hex input in the two command fields panicked as well. All four now report what
went wrong and leave the window standing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On an OBSBOT Tiny 4K the tracking speed buttons, the tracking modes that camera
does not know and the manual exposure button all looked clickable, did nothing
and reported the problem on stderr, where a user of a graphical application never
sees it. The status panel additionally stated a tracking speed on a camera that
has no such setting, the same invented value that info used to print.

Those controls are now disabled and faded well below the contrast of an active
button, and the panel says the setting is not available. The fade deliberately
has no outline: an outlined control reads as interactive, and the active buttons
beside them are solid fills, so a border would have given the unavailable control
the stronger visual cue of the two.

The capability queries moved from Camera to CameraModel, because the view builds
its buttons from the application state and has no camera handle there. Camera
delegates to the model, so nothing else changes. While no camera is connected
every button stays enabled, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
info showed "Unknown" for the AI mode on a Tiny 4K, because that camera leaves
the Tiny 2 status bytes at zero. It answers a separate request instead: e3 3092
returns a status block whose second byte says whether the AI engine is enabled
and whose byte 33 holds the framing mode, with the same values the tracking mode
command takes.

Reading it needs care. The camera keeps the previous answer on the command
channel until the new one is ready, so a read can return the answer to an earlier
request. Every request therefore carries a sequence number that the reply echoes,
and the read is retried until the numbers match. That sequence also has to differ
between runs: the stale answer outlives the process that asked for it, so a
counter starting at the same value every time would accept the previous run's
answer. This was not theoretical, it reported a stale mode until fixed.

The GUI now also starts with the tracking mode the camera is really in, so the
correct button is selected from the first frame.

Verified on a Tiny 4K: setting static, normal and upper-body in turn is read back
correctly every time, and info still returns in well under a tenth of a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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