The first OBSBOT camera I reverse-engineered was a Tiny 2: a webcam on a motorized gimbal. I built obsbot-mcp so an AI assistant could take a picture, inspect it, and control the camera through the Model Context Protocol.
Then I started on the Tail 2.
Same manufacturer. Similar controls. Even the same USB Extension Unit GUID. Plenty of reasons to expect that supporting it would mean adding another device ID and adjusting some ranges.
The Tail 2 has a different network protocol, a different vendor USB protocol, and the same firmware capability-reporting problem that had sent me into the Linux camera driver. The useful part of the investigation was working out which similarities were real.
The measurements below were made on a physical Tail 2 in September 2026, with firmware 7.2.13.1. The protocol notebook distinguishes hardware measurements from things inferred by reading the camera's software.
The camera ships its own API client
The Tail 2 is a network PTZ camera with Ethernet, Wi-Fi, streaming outputs, and a USB-C webcam mode. Point a browser at its network address and it serves a web application.
That application was the best starting point. Its minified Vue/Vite bundle contained the endpoint names, request methods, and payload construction for an HTTP API under /camera/sdk/.
For example, these are useful read-only probes. Set CAMERA to your camera's address:
CAMERA=192.168.1.50
curl "http://$CAMERA/camera/sdk/device_info"
curl "http://$CAMERA/camera/sdk/range"
curl "http://$CAMERA/camera/sdk/ptz/zoom"
The range endpoint reports the camera's own zoom-ratio scale, 1.0–12.0. Setting zoom requires both a ratio and a speed:
PUT /camera/sdk/ptz/zoom
Content-Type: application/json
{"ratio": 2.0, "speed": 5}
Leave either field out and the camera returns 400. That is a small detail, but exactly the kind that makes an undocumented API frustrating when all you have is an endpoint name.
The bundle gave me a census of 47 endpoints. That does not mean 47 fully decoded, hardware-verified operations: some payloads remain unprobed. It gave me a map of where to look.
On the tested configuration, HTTP reads and writes worked without authentication. That is an observed property of this unit and firmware, not something I would generalize to every configuration or future release.
A successful request can change nothing
The camera also pushes a JSON status block over:
ws://<camera-address>/ws/
Connect, and a full status message arrives without a subscription request. Further updates arrive at roughly one per second. The block includes zoom, tracking mode, orientation, presets, streaming flags, battery state, and subsystem health.
That feedback matters because the write response is weaker than it looks:
{"code": 200, "err_idx": 0}
During testing, a roll-trim write received that response while the portrait-rotation motor was moving. The requested value never landed. Reading it back still showed the old value.
So the client separates acknowledgement from observed state. Where feedback exists, it sends the command, polls a bounded number of times, and returns the value it actually read plus a settled flag.
The zoom implementation, for example, compares the reported ratio with the target within a small tolerance. Portrait rotation checks the orientation field in the WebSocket status. If the polling budget runs out, settled:false says exactly what happened: the command was sent, but the expected state was not observed in time.
There are limits to this verification. The network status block has no live yaw or pitch. Recenter therefore returns on acknowledgement; it cannot prove physical arrival. Preset recall can verify zoom, but that does not prove that both gimbal axes have arrived.
That distinction matters when the caller is an AI assistant. A tool result saying “done” becomes the premise for its next action.
Presets: the request schema was in the component
My first attempts at the preset endpoint failed because I guessed the discriminator wrong. action, type, and operate were plausible. The firmware wanted operation.
Reading the web application's preset component gave me this grammar:
PUT /camera/sdk/ptz/preset
{"operation":"set", "id":0, "name":"RGVzaw=="}
{"operation":"call", "id":0}
{"operation":"rename", "id":0, "name":"V2lkZQ=="}
{"operation":"delete", "id":0}
Names are base64-encoded UTF-8. IDs are zero-based, with three slots. All four operations were exercised on hardware.
The semantic detail is more important than the spelling: set saves the camera's current pose. It also overwrites an occupied slot. There is no decoded request here that accepts an arbitrary yaw/pitch pose to save.
Reading a preset returns angles and a zoom ratio, but a rich response schema does not imply a symmetric write schema. You cannot take the returned object, edit its angles, and assume that writing it back will position the camera.
The server exposes slots as 1–3 and handles the wire encoding internally. Its save verification checks that the slot exists afterward; it cannot independently verify the captured pose, and an existing slot alone does not prove an overwrite succeeded.
Getting a picture took a different protocol
Controlling a camera and getting video from it turned out to be separate jobs.
The device advertised RTSP, and its status could report RTSP enabled. Nevertheless, the paths I tested returned 400 Bad Request, including after a reboot with the enable flag set. I did not establish a working RTSP stream on this firmware.
SRT listener mode worked. With that output enabled in OBSBOT Center, an SRT-enabled ffmpeg build could pull a frame:
ffmpeg \
-i "srt://$CAMERA:5000?mode=caller&latency=120&streamid=mainstream" \
-frames:v 1 tail2.jpg
That uses the measured port and default stream ID. The tested stream was H.264 at 1920×1080, 30 fps, with AAC audio.
Two practical constraints remain: enabling SRT disables NDI on this configuration, and the SRT enable state does not survive a camera reboot. Center also requires the output to be off while changing its streaming settings. An early apparent startup delay was actually a probe landing during that reconfiguration window; once enabled, the listener served immediately.
The snapshot tool uses this SRT path. The camera-control module is pure TypeScript, but image capture still needs ffmpeg.
The same USB GUID did not mean the same protocol
The USB side supplied the most misleading resemblance.
In UVC webcam mode, the Tail 2 exposes the same vendor Extension Unit GUID as the Tiny 2. Its selector 6 also returns a 60-byte status block. Running the Tiny 2 decoder over those bytes produces plausible-looking values.
They are wrong. Some byte positions happen to line up; the layouts are different.
The useful method was to change one setting at the camera, read the selectors again, and compare. Then write values the camera had actually been observed to report and check the result.
The Tail 2 controls I decoded use flat, zero-padded 60-byte payloads. They do not need the Tiny 2's framed V3 command, sequence number, or CRC.
Selector 9 controls human tracking:
| First byte | Observed meaning |
|---|---|
00 |
Tracking off |
01 |
Human tracking, single |
02 |
Human tracking, group |
Those values were written and read back on Linux. Activation also depended on a matching subject being in frame: an accepted write did not necessarily make tracking engage.
Selector 10 contains another easy trap. Its read layout is a settings block, but its write layout is an index/value pair:
Read:
byte 0 = tracking speed
byte 2 = Auto Zoom level
Write, with the remaining bytes zero-padded:
00 04 = set tracking-speed index 0 to value 4
02 07 = set Auto Zoom index 2 to value 7
Writing a read block back verbatim changes the wrong thing. A payload beginning 02 01 07 means “set index 2 to value 1,” regardless of what its third byte meant in the read response.
The camera even keeps these settings separately for landscape and portrait orientation. Switching orientation and seeing different values was not evidence that the preceding write had disappeared.
These USB findings are documented, but the current Tail 2 MCP tools use the network transport. A combined USB/network implementation is still future work.
The Linux problem really was shared
Standard UVC pan, tilt, and zoom controls also work on the Tail 2. On Linux, however, the affected controls had the same capability-reporting defect as the Tiny 2:
GET_INFO -> 0x03
GET and SET supported
AUTOUPDATE not advertised
The camera can return changing PTZ values through GET_CUR, including motion driven by its own tracking. But its capability reply causes uvcvideo to clear the auto-update flag and cache those readings.
This is also where I need to correct my earlier Tiny 2 article.
I had argued that even a compliant camera would yield only one live sample per write. That explanation was wrong. Ricardo Ribalda pointed out the read-side rollback path I had missed. At the end of a read transaction, the driver invalidates an AUTO_UPDATE control's cache, allowing the next read to fetch from the device. It does not need a control-change interrupt between every pair of polls.
Restoring the existing flag is sufficient. A separate live-read buffer was unnecessary. A wider probe also disproved my earlier claim that the firmware returned a uniform 0x03 stub for every control: the response varies; the affected PTZ capability bits are wrong.
The userspace write lesson survived review: set pan and tilt together in one VIDIOC_S_EXT_CTRLS call. Both axes share one UVC payload. If separate writes merge against live position while the first axis is still moving, the second write can replace the first axis's target with an intermediate position.
On September 29, Hans de Goede merged the fixup infrastructure and both camera entries into the uvcvideo maintainer's for-next branch. The Tail 2 entry is commit 7a7c915d6bae.
That is a maintainer-tree merge. It does not establish availability in a released or distribution kernel. At the September 29 checkpoint, the separate v5 patch exposing volatile-control flags with corrected event handling was still under review. The fixups already provide live reads without that separate patch.
The updated kernel write-up records the mechanism, review corrections, and hardware results.
What is usable now
The Tail 2 module in obsbot-mcp exposes 16 tools covering discovery, status, device information, zoom, recentering, portrait rotation, roll trim, tracking mode and speed, five preset operations, and snapshots.
For network discovery, call obsbot_tail2_scan, configure OBSBOT_TAIL2_HOSTS, or pass the camera's IP as the camera parameter. Registered cameras can also be selected by MAC address or device name.
The HTTP/WebSocket control implementation is shared across Windows, Linux, and macOS, with no platform-specific native helper. The USB kernel fixups are a separate result of the investigation; those network tools do not require them.
Arbitrary network yaw/pitch commands, live network pose feedback, and a shipped Tail 2 USB transport remain gaps. The Tiny 2's “point at this pixel” workflow should not be assumed to work on the Tail 2 just because both cameras appear in the same project.
The recurring lesson was that decoding a payload is only the beginning. A response can acknowledge a write that never happened. A status block can omit the one measurement needed to verify arrival. Two devices can share a GUID while speaking different protocols. And a convincing explanation of a driver can still be missing the function that changes the conclusion.
The useful API is the one that preserves those distinctions all the way up to the caller.
Top comments (0)