The OBSBOT Tiny 2 is a webcam bolted to a motorized gimbal. It pans, it tilts, it zooms, and it has an on-device AI that follows you around the room. It's a nice piece of hardware and OBSBOT ships a perfectly good GUI for it.
What I wanted was different. I wanted an MCP server, so a model could take a snapshot, look at the picture, find the thing it cared about, and then point the camera at it. Close the loop: see, aim, see again.
That needs a protocol. There isn't a published one.
The vendor app knows how to talk to the camera, so the protocol is in the vendor app. I ran Ghidra headless over the binaries and went looking for whatever assembles the USB control transfers.
It's a class called FrmPacketV3
, and once you find it the frame is almost tidy:
off 0 : 0xAA magic
off 1 : 0x25 flags (bit 0x60 => there's a nested segment)
off 2-3 : seq u16 LE
off 4-5 : len u16 LE bytes covered by the checksum (12)
off 6-7 : token u16 LE the checksum itself
off 8 : sender 0x0A
off 9 : receiver 0x04
off 10-11: cmd u16 LE the command id
off 12+ : payload float32/int32, little-endian
Sixty bytes, zero-padded, handed to a UVC Extension Unit as a SET_CUR
on selector 2. The token
is CRC-16/USB — poly 0xA001
, init 0xFFFF
, reflected, xorout 0xFFFF
— computed over the first twelve bytes with the token field itself zeroed:
def token(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc ^ 0xFFFF
Gimbal moves don't fit in twelve bytes, so they go in a nested segment starting at offset 12 with its own length and its own CRC over its own bytes. Roll, pitch, yaw as three float32s. A move-to-angle is cmd = 0x6444
, a speed move is 0x6484
, wake and sleep are both 0xA0C2
with an inverted flag byte (wake is 0, sleep is 1, which I got backwards the first time).
There turned out to be four separate ways into the device, and the vendor app uses all of them depending on what it's doing: framed V3 packets on XU selector 2, flat byte reads on XU selector 6, and then plain IAMCameraControl
and IAMVideoProcAmp
for the boring stuff. Knowing which channel a given feature lives on is most of the work.
Track speed didn't work. I'd send the command, the camera would acknowledge nothing in particular, and the tracking speed would stay exactly where it was. For weeks I assumed I had the payload encoding wrong, because that's the kind of thing that's usually wrong.
I had 0x0944
. The real opcode is 0x0cc4
, AI_SET_TRACK_MODE
. I only found it by capturing USB traffic while OBSBOT's own app changed the setting, and diffing that frame against mine.
This is the thing about an undocumented binary protocol that I hadn't internalized: a wrong opcode doesn't fail. There's no error, no stall, no NAK. The firmware looks at a command id it doesn't recognize and drops it on the floor, and from the host side that's indistinguishable from a command that worked and had no visible effect. Every silent no-op I chased after that, I went to the wire first instead of re-reading my decoder.
Static analysis tells you what the code could send. A capture tells you what it does send. Those are different documents.
Somewhere in the middle of this I checked whether the gimbal answers CT_PANTILT_ABSOLUTE
— control selector 0x0D
, a bog-standard UVC Camera Terminal control that every OS already knows how to talk to.
It does. And it's not echoing back what I told it. I polled it every 40 ms while a 0° to 90° pan was in flight:
t+0ms pan=0°
t+709ms pan=3°
t+832ms pan=10°
t+1913ms pan=51°
t+3259ms pan=90° <- physical arrival
t+3300ms pan=90° (steady)
That's an encoder tracking a physical slew. Tilt stayed at zero the whole time, having never been commanded. It even reports motion the host never commanded through that control at all — vendor speed moves, recenters, the AI panning itself around after a face.
That reframed the project. My end goal was cross-platform, and vendor XU access means writing and maintaining a native helper for every OS — DirectShow on Windows, IOKit on macOS, V4L2 and libusb on Linux — and then keeping three implementations of the same protocol honest against each other. Standard UVC controls cost none of that. The OS already has the code.
So the rule became: use standard UVC wherever it's sufficient, drop to the vendor extension unit only where it isn't. Zoom is UVC. Focus, exposure, white balance are UVC. Pan and tilt are UVC in both directions, read and write. What's genuinely vendor-only is the AI tracking, the presets, sleep/wake, and speed-mode gimbal moves. That's maybe a third of the surface, and everything else stopped being three implementations.
Same control, same camera, same USB transfers. On Linux, pan_absolute
reads back whatever value I last wrote, forever, no matter where the gimbal actually is.
That's uvcvideo
caching. __uvc_ctrl_load_cur()
short-circuits on a ctrl->loaded
flag, and that flag gets cleared in exactly two places: the driver's own commit path, and on receipt of a Control Change interrupt from the device. The Tiny 2 never sends one. I dumped GET_INFO
across five Camera Terminal controls and got a constant 0x03
for every single one — the firmware isn't computing the response per-control, it's returning a stub. Bits D3 (Autoupdate) and D4 (Asynchronous) are clear on a gimbal that takes seconds to complete a move and repositions itself autonomously as a headline feature. Both should be set. That's a real spec violation and I'll be reporting it.
But "the vendor is non-compliant, work around it" is a weak argument to bring to a kernel mailing list, and it's also not what's actually going on. Trace the cache on a fully compliant PTZ camera, one that does set D3 and does send its interrupts:
S_CTRL(pan=90)
— commit clears loaded
G_CTRL
— live GET_CUR
, sets loaded = 1
One live sample per write. And if userspace reads promptly after commanding the move, which is the natural thing to do, that one sample is taken before the actuator has appreciably moved. It's the least useful sample available. Polling is useless on any UVC PTZ camera, compliant or not.
The spec more or less concedes this. §4.2.2.1.15: a relative-control write "shall result in a Control Change interrupt for the Absolute Control at the end of the movement." The notification mechanism is event-shaped, and it's specified for the end of the move and nothing during it. That's fine for "auto-exposure changed mode." It's the wrong shape for a quantity that varies continuously for three seconds. V4L2_CTRL_FLAG_VOLATILE
already exists for exactly this concept and uvcvideo
just never applies it here.
So: patch.
Four lines. Add a UVC_CTRL_FLAG_VOLATILE
bit, set it on the pan/tilt entry, bypass the cache, surface the flag to userspace. Clean under checkpatch.pl --strict
. I never sent it, and I'm glad.
One UVC control holds both axes — PANTILT_ABSOLUTE
is 8 bytes, and PAN and TILT are 32-bit mappings at offsets 0 and 32. Because the mapping doesn't span the whole control, uvc_ctrl_set()
does a read-modify-write on every single pan or tilt write. Make that read live and:
S_CTRL(pan=90)
commits, gimbal starts movingS_CTRL(tilt=20)
a millisecond later re-reads live position. The gimbal hasn't physically moved yet, so it reads pan=0
SET_CUR(pan=0, tilt=20)
— the pan command is cancelledThe write path only works today because the cache holds the commanded setpoint rather than reality. My own server issues pan and tilt as two parallel single-axis writes, so I'd have shipped a patch that broke the tool I'd hardware-verified, the moment it merged.
The commit message had a second problem: I'd quoted the UVC spec asserting the control "indicates the pan/tilt actuator's current position." Correct section number. The quote does not exist. I'd reconstructed it from memory and presented it as verbatim, and the real text uses setpoint language throughout. The citation that actually supports the argument was two subsections away the whole time.
The version I sent inverts the fix. Instead of adding a setpoint shadow so the write path can keep working — which means modifying the write path, which is exactly where the regression lived — it adds a separate read buffer. uvc_ctrl_set()
comes out byte-for-byte identical to mainline. The regression isn't avoided, it's impossible by construction, and that's a much better thing to be able to tell a reviewer.
55 insertions, 4 deletions, two files. Hardware results on a self-built 7.2.0-rc4: the two-axis write passes with the hazard window genuinely exercised (pan still read 0 at the second write, and it still landed); live position steps 0→5→17→24→36→47→55→66→78→90 during a slew; 100 frames at 30.5 fps while polling position at 5 Hz with no frame loss; v4l2-compliance
identical patched versus pristine.
It went to linux-media on July 25th. It's still sitting there — no ack, no rejection, no maintainer feedback yet. Which is normal, and the sibling OBSBOT fix on that list stalled about twenty months before it was revived and merged as a general helper rather than the vendor quirk it was originally submitted as, so I'm not reading anything into the silence.
There's a nice accident buried in the retest, too. One run failed with the exact v1 signature — pan cancelled back to zero — and the cause wasn't my patch at all. The module reload had re-probed the camera while it was asleep, and asleep it doesn't answer GET_INFO
, so the driver couldn't override the static table and pan/tilt kept AUTO_UPDATE
. With that flag set, mainline clears loaded
after every commit and does the live read-modify-write by itself. It's a stock-kernel bug on this camera. It's also not going in this patch — it's orthogonal, and bundling a second bug story into a commit message whose only real virtue is narrowness is how you lose a reviewer.
obsbot-mcp is on npm and does the thing I wanted: snapshot, find something in the frame, aim at that pixel, snapshot again to confirm. The aiming math reads the camera's own magnification out of its status block, so it works at any zoom without being told what the zoom is, and it refuses rather than guessing when it can't — AI tracking is fighting it for the gimbal, the zoom is still ramping, the camera had to be woken (waking moves the gimbal, so the frame you measured is stale).
35 tools on Windows and macOS, 34 on Linux, where speed-mode gimbal moves are hidden — a speed times duration burst can't be bounded without live position feedback, and until that patch lands, Linux doesn't have any.