# I reverse-engineered a webcam so Claude could point it at things

> Source: <https://dev.to/michael_jordan_87eaf96f24/i-reverse-engineered-a-webcam-so-claude-could-point-it-at-things-2h3>
> Published: 2026-08-04 20:39:26+00:00

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](https://modelcontextprotocol.io) 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:

``` php
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 moving`S_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](https://github.com/lxman/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.
