obsbot-mcp 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +195 -38
  2. package/dist/codec/commands.d.ts +42 -4
  3. package/dist/codec/commands.js +72 -10
  4. package/dist/codec/commands.js.map +1 -1
  5. package/dist/codec/frame.d.ts +1 -0
  6. package/dist/codec/frame.js +1 -1
  7. package/dist/codec/frame.js.map +1 -1
  8. package/dist/codec/preset.d.ts +40 -0
  9. package/dist/codec/preset.js +198 -0
  10. package/dist/codec/preset.js.map +1 -0
  11. package/dist/codec/types.d.ts +4 -0
  12. package/dist/device/helper-factory.d.ts +25 -0
  13. package/dist/device/helper-factory.js +40 -0
  14. package/dist/device/helper-factory.js.map +1 -0
  15. package/dist/device/manager.d.ts +326 -2
  16. package/dist/device/manager.js +720 -24
  17. package/dist/device/manager.js.map +1 -1
  18. package/dist/ipc/client.d.ts +19 -0
  19. package/dist/ipc/client.js +81 -0
  20. package/dist/ipc/client.js.map +1 -0
  21. package/dist/ipc/coordinator.d.ts +29 -0
  22. package/dist/ipc/coordinator.js +94 -0
  23. package/dist/ipc/coordinator.js.map +1 -0
  24. package/dist/ipc/owner.d.ts +21 -0
  25. package/dist/ipc/owner.js +59 -0
  26. package/dist/ipc/owner.js.map +1 -0
  27. package/dist/ipc/protocol.d.ts +21 -0
  28. package/dist/ipc/protocol.js +56 -0
  29. package/dist/ipc/protocol.js.map +1 -0
  30. package/dist/ipc/rendezvous.d.ts +26 -0
  31. package/dist/ipc/rendezvous.js +93 -0
  32. package/dist/ipc/rendezvous.js.map +1 -0
  33. package/dist/mcp/log-sink.d.ts +12 -0
  34. package/dist/mcp/log-sink.js +27 -0
  35. package/dist/mcp/log-sink.js.map +1 -0
  36. package/dist/mcp/ready.d.ts +16 -4
  37. package/dist/mcp/ready.js +11 -8
  38. package/dist/mcp/ready.js.map +1 -1
  39. package/dist/mcp/render.js +1 -1
  40. package/dist/mcp/render.js.map +1 -1
  41. package/dist/mcp/server.js +68 -18
  42. package/dist/mcp/server.js.map +1 -1
  43. package/dist/mcp/tools.d.ts +5 -3
  44. package/dist/mcp/tools.js +787 -186
  45. package/dist/mcp/tools.js.map +1 -1
  46. package/dist/transport/helper-process.d.ts +36 -0
  47. package/dist/transport/helper-process.js +190 -10
  48. package/dist/transport/helper-process.js.map +1 -1
  49. package/dist/transport/linux.d.ts +40 -0
  50. package/dist/transport/linux.js +92 -2
  51. package/dist/transport/linux.js.map +1 -1
  52. package/dist/transport/macos.d.ts +13 -0
  53. package/dist/transport/macos.js +59 -3
  54. package/dist/transport/macos.js.map +1 -1
  55. package/dist/transport/read-serial.d.ts +26 -0
  56. package/dist/transport/read-serial.js +87 -0
  57. package/dist/transport/read-serial.js.map +1 -0
  58. package/dist/transport/transport.d.ts +24 -0
  59. package/dist/transport/windows.d.ts +4 -0
  60. package/dist/transport/windows.js +31 -4
  61. package/dist/transport/windows.js.map +1 -1
  62. package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
  63. package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
  64. package/native/prebuilt/linux-x64/obsbot-helper +0 -0
  65. package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
  66. package/package.json +3 -2
  67. package/dist/device/session.d.ts +0 -22
  68. package/dist/device/session.js +0 -37
  69. package/dist/device/session.js.map +0 -1
package/dist/mcp/tools.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
- import { encodeSetRunStatus, encodePtzMoveAngle, encodePtzMoveSpeed, encodeRecenter, zoomRatioToUnits, encodeAiTrackSpeed, encodeAiTracking, encodeVendorProbe, encodeZoomWithSpeed, encodeFaceFocus, encodeSetExposureMode, encodeSetExposureValue, decodeStatus, encodeFov, encodeHdr, percentToRange, AI_FRAMING_MODES, AI_TRACK_SPEEDS, FOV_TYPES, UVC_XU_SELECTOR, CAMERA_CONTROL_PAN, CAMERA_CONTROL_TILT, CAMERA_CONTROL_FOCUS, VIDEO_PROCAMP_WHITE_BALANCE, IMAGE_CONTROL_PROP, IMAGE_CONTROLS, UVC_FLAG_AUTO, UVC_FLAG_MANUAL, } from "../codec/commands.js";
2
+ import { encodeSetRunStatus, zoomRatioToUnits, encodeAiTrackSpeed, encodeAiTracking, encodeAiMode, encodeFaceAe, encodeVendorProbe, encodeVendorGet, encodeZoomWithSpeed, encodeFaceFocus, encodeSetExposure, decodeStatus, encodeFov, encodeHdr, percentToRange, AI_FRAMING_MODES, AI_SCENE_MODES, AI_TRACK_SPEEDS, FOV_TYPES, UVC_XU_SELECTOR, CAMERA_CONTROL_PAN, CAMERA_CONTROL_TILT, CAMERA_CONTROL_FOCUS, VIDEO_PROCAMP_WHITE_BALANCE, IMAGE_CONTROL_PROP, IMAGE_CONTROLS, UVC_FLAG_AUTO, UVC_FLAG_MANUAL, } from "../codec/commands.js";
3
3
  import { verifyFraming } from "./framing.js";
4
4
  import { parseFrame } from "../codec/frame.js";
5
+ import { OP_BY_NAME } from "../codec/opcodes.js";
6
+ import { decodePresetList, decodePresetEntry, assemblePresetSlots, implausiblePresetListReason, encodePresetAdd, encodePresetUpdate, encodePresetRecall, encodePresetDelete, encodePresetSetName, } from "../codec/preset.js";
5
7
  import { CameraBusyError } from "../transport/transport.js";
6
- import { ensureReady } from "./ready.js";
8
+ import { ensureReady, msg } from "./ready.js";
7
9
  import { CaptureError } from "../capture/manager.js";
8
10
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
11
  // Some MCP clients serialize numbers and booleans as strings when the advertised
@@ -13,34 +15,47 @@ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
13
15
  // maps any non-empty string (including "false") to true, which would be a bug.
14
16
  const num = () => z.preprocess((v) => (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v), z.number());
15
17
  const bool = () => z.preprocess((v) => (v === "true" ? true : v === "false" ? false : v), z.boolean());
18
+ // Optional per-camera selector, shared by every camera-addressing tool. The value
19
+ // is the camera's serial; the handler forwards it to DeviceManager.get(serial)
20
+ // (via getTransport/gate) so the command targets that camera. Omitted + a single
21
+ // camera attached resolves to that camera, so single-camera callers are unaffected.
22
+ // EXEMPT tools (obsbot_devices, obsbot_capture_stop/list, obsbot_debug_probe) keep
23
+ // a plain z.object and never advertise `camera`.
24
+ const withCamera = (shape) => z.object({ ...shape, camera: z.string().optional() });
16
25
  const listDevicesSchema = z.object({});
17
- const setRunStatusSchema = z.object({ state: z.enum(["run", "sleep"]) });
18
- const ptzMoveAngleSchema = z.object({
26
+ const wakeSchema = withCamera({});
27
+ const sleepSchema = withCamera({});
28
+ const ptzMoveAngleSchema = withCamera({
19
29
  yaw: num(),
20
30
  pitch: num(),
21
31
  roll: num().default(0),
22
32
  });
23
- const ptzMoveSpeedSchema = z.object({
33
+ const ptzMoveSpeedSchema = withCamera({
24
34
  yaw: num(),
25
35
  pitch: num(),
26
36
  roll: num().default(0),
27
37
  autoStopMs: num().default(800),
28
38
  });
29
- const gimbalRecenterSchema = z.object({});
30
- const zoomAbsoluteSchema = z.object({ ratio: num() });
31
- const aiTrackingSchema = z.object({
39
+ const gimbalRecenterSchema = withCamera({});
40
+ const zoomAbsoluteSchema = withCamera({ ratio: num() });
41
+ // mode covers the human framings AND the standalone scene modes (group/whiteboard/
42
+ // desk/hand). For a framing, enable = human tracking with that framing; for a scene
43
+ // mode, `enabled` is implied true (disable via enabled:false, which cancels tracking).
44
+ const AI_TRACKING_MODES = [...AI_FRAMING_MODES, ...AI_SCENE_MODES];
45
+ const aiTrackingSchema = withCamera({
32
46
  enabled: bool(),
33
- mode: z.enum(AI_FRAMING_MODES).default("normal"),
47
+ mode: z.enum(AI_TRACKING_MODES).default("normal"),
34
48
  });
35
- const aiTrackSpeedSchema = z.object({
49
+ const isSceneMode = (m) => AI_SCENE_MODES.includes(m);
50
+ const aiTrackSpeedSchema = withCamera({
36
51
  speed: z.enum(AI_TRACK_SPEEDS),
37
52
  });
38
- const zoomSpeedSchema = z.object({
53
+ const zoomSpeedSchema = withCamera({
39
54
  ratio: num(),
40
55
  speed: num().default(0),
41
56
  });
42
- const faceFocusSchema = z.object({ enabled: bool() });
43
- const getStatusSchema = z.object({});
57
+ const faceFocusSchema = withCamera({ enabled: bool() });
58
+ const getStatusSchema = withCamera({});
44
59
  // Generic RE/spelunking primitive: raw send-bytes / get-bytes on any XU selector,
45
60
  // plus a `query` convenience that frames a table opcode and reads the reply.
46
61
  const probeSchema = z.object({
@@ -51,27 +66,88 @@ const probeSchema = z.object({
51
66
  opcode: z.string().optional(), // table opcode name for mode "query"
52
67
  payloadHex: z.string().optional(), // nested payload for mode "query"
53
68
  });
54
- const fovSchema = z.object({ fov: z.enum(FOV_TYPES) });
55
- const hdrSchema = z.object({ enabled: bool() });
56
- const focusSchema = z.object({
57
- mode: z.enum(["auto", "manual"]),
69
+ // Vendor XU selector: SET_CUR request AND GET_CUR reply mailbox. Protocol
70
+ // constant, not platform-specific — same value as the per-transport
71
+ // VENDOR_XU_SELECTOR (transport/{macos,linux,windows,read-serial}.ts), kept
72
+ // local here rather than imported so this module doesn't need a transport-layer
73
+ // dependency for one constant.
74
+ const PROBE_VENDOR_SELECTOR = 0x02;
75
+ // The reply mailbox retains the PREVIOUS reply until the new one lands, so a
76
+ // single unvalidated read can return stale data — same hazard readSerialVia
77
+ // (transport/read-serial.ts) polls around. Mirrors its POLL_ATTEMPTS.
78
+ const PROBE_QUERY_POLL_ATTEMPTS = 8;
79
+ // ...and its POLL_DELAY_MS. The device does not populate the mailbox instantly, and
80
+ // how long it takes depends on the opcode: cached state (AI_GET_QUICK_STATUS) comes
81
+ // back sub-millisecond, but anything reading persistent storage (UG_GET_SN) is far
82
+ // slower. An un-delayed loop spans only ~1-2ms end to end, so it polls entirely
83
+ // within the gap and reports "no valid reply" for a command the device answered.
84
+ // Measured on darwin-arm64 hardware 2026-07-21, UG_GET_SN over 25 trials:
85
+ // no delay = 24 failures (96%), 30ms delay = 0. f27956f fixed exactly this in
86
+ // read-serial.ts; the probe kept the un-delayed loop until 2026-07-21.
87
+ const PROBE_QUERY_POLL_DELAY_MS = 30;
88
+ // Max gimbal velocity accepted by obsbot_gimbal_move_speed, in degrees per
89
+ // second. Hardware-measured 2026-07-21: 150/160/170 all drove the gimbal
90
+ // linearly, while 180/200/300 each moved it EXACTLY 0° yet still reported
91
+ // success — the firmware ignores an over-range speed rather than saturating.
92
+ // The true cutoff lies somewhere in 170..180; 150 keeps margin under it and
93
+ // matches obsbot_gimbal_move's yaw bound.
94
+ const GIMBAL_MAX_SPEED_DPS = 150;
95
+ const fovSchema = withCamera({ fov: z.enum(FOV_TYPES) });
96
+ const hdrSchema = withCamera({ enabled: bool() });
97
+ const focusAutoSchema = withCamera({});
98
+ const focusManualSchema = withCamera({
58
99
  position: num().pipe(z.number().min(0).max(100)).default(50),
59
100
  });
60
- const whiteBalanceSchema = z.object({
61
- mode: z.enum(["auto", "manual"]),
62
- temperature: num().default(5000),
63
- });
64
- const imageControlSchema = z.object({
101
+ const imageWbAutoSchema = withCamera({});
102
+ const imageWbManualSchema = withCamera({ temperature: num().default(5000) });
103
+ const imageControlSchema = withCamera({
65
104
  control: z.enum(IMAGE_CONTROLS),
66
105
  level: num().pipe(z.number().min(0).max(100)),
67
106
  });
68
- const exposureSchema = z.object({
69
- mode: z.enum(["auto", "manual"]),
107
+ const imageExposureAutoSchema = withCamera({
108
+ // Auto-exposure metering priority: global (whole frame) or face (meters for a
109
+ // detected face). Optional.
110
+ priority: z.enum(["global", "face"]).optional(),
111
+ });
112
+ const imageExposureManualSchema = withCamera({
70
113
  level: num().pipe(z.number().min(0).max(100)).default(50),
71
114
  });
72
- const gimbalPositionSchema = z.object({});
73
- const snapshotSchema = z.object({
74
- maxDim: num().pipe(z.number().min(256).max(1920)).default(1024),
115
+ const gimbalPositionSchema = withCamera({});
116
+ const presetListSchema = withCamera({});
117
+ const presetSaveSchema = withCamera({
118
+ slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
119
+ });
120
+ const presetSlotSchema = withCamera({
121
+ slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
122
+ });
123
+ const presetRecallSchema = presetSlotSchema;
124
+ const presetUpdateSchema = withCamera({
125
+ slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
126
+ });
127
+ const presetRenameSchema = withCamera({
128
+ slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
129
+ name: z.string(),
130
+ });
131
+ const presetDeleteSchema = presetSlotSchema;
132
+ // Rename frame payload is u32le(slot-1) [4 bytes] + ASCII name, inside a fixed 60-byte
133
+ // frame whose payload region starts at offset 16 (see buildFrame). Max payload is
134
+ // 60-16=44 bytes, so the name must fit in 44-4=40 bytes.
135
+ const PRESET_NAME_MAX = 40;
136
+ // `resolution` is the longest edge of the returned image, in pixels. It replaces
137
+ // the old `maxDim`, and the default drops from 1024 to 640: the JPEG is base64'd
138
+ // into a tool response that an LLM reads, so every pixel costs tokens. 640 is
139
+ // enough to judge framing, lighting and exposure; a caller that needs detail asks
140
+ // for it, and pays for it (640 -> ~86KB, 1920 -> ~413KB).
141
+ //
142
+ // Capped at 1920 even though the sensor offers 3840x2160. Reaching 4K needs an
143
+ // explicit device activeFormat change (the session preset alone does not do it --
144
+ // activeFormat governs and stays at 1080p), which mutates shared device state that
145
+ // another app streaming the camera would feel. And a 4K frame is ~1.5MB, roughly
146
+ // 2M base64 characters, which is far past a usable token budget for "let me look
147
+ // at the shot". The cap is honest: asking for more than the ceiling is rejected
148
+ // rather than silently answered with 1080p.
149
+ const snapshotSchema = withCamera({
150
+ resolution: num().pipe(z.number().min(256).max(1920)).default(640),
75
151
  quality: num().pipe(z.number().min(1).max(100)).default(80),
76
152
  settleMs: num().pipe(z.number().min(0).max(5000)).default(600),
77
153
  source: z.enum(["device", "virtual", "ndi"]).default("device"),
@@ -86,12 +162,164 @@ const recordStartSchema = z.object({
86
162
  const previewStartSchema = z.object({ source: captureSourceEnum.default("device") });
87
163
  const captureStopSchema = z.object({ sessionId: z.string() });
88
164
  const captureListSchema = z.object({});
89
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
90
- export function createTools(getTransport, mgr, capture, session, debug = false) {
165
+ // Preset read path: flat XU selectors 12 (list) + 13 (entry cursor). Hardware-verified
166
+ // 2026-07-19 — NOT the framed-reply model (recvVendor + parseFrame); reads on the
167
+ // vendor reply path just return the flat status block for this device. Three steps:
168
+ // 1. GET selector 12 -> <count:u8> <slotIdx:u8> x count
169
+ // 2. echo-write the just-read bytes back to selector 12 -> resets the entry cursor
170
+ // (echo is provably non-destructive; do NOT write zeros/synthesized bytes)
171
+ // 3. GET selector 13, `count` times -> each read returns the next preset entry and
172
+ // advances the cursor, until the exhausted marker (status 0x02)
173
+ //
174
+ // Occupancy gates a create-once resource (save requires EMPTY, everything else
175
+ // requires OCCUPIED), so a falsely-"empty" read here drives an irreversible write.
176
+ // C1/I1/I3 below are all facets of the same unifying fix: never trust device bytes
177
+ // enough to act on them (or echo them back) without a plausibility/consistency check.
178
+ const isAllZero = (b) => b.equals(Buffer.alloc(b.length));
179
+ // A genuinely EMPTY device and a not-serving read BOTH return an all-zero selector-12
180
+ // block (hardware-established 2026-07-19 under controlled conditions: OBSBOT Center
181
+ // closed so nothing could re-assert presets, camera measured awake at the moment of
182
+ // the read, stable across repeated reads). So the block alone cannot tell them apart.
183
+ //
184
+ // Corroborate on the SAME XU surface and handle: the status block. When selector 12
185
+ // read zeros on a genuinely empty device, the status block still returned real
186
+ // non-zero data — a dead link or stale handle would zero BOTH.
187
+ //
188
+ // The check is deliberately TWO-part. decodeStatus reports awake for an ALL-ZERO block
189
+ // (awake === block[0x02] === 0), so testing `awake` alone would let a dead link
190
+ // masquerade as an empty device — manufacturing the exact false-EMPTY that gates the
191
+ // irreversible create-once ADD. Requiring the status block to be non-zero AND awake
192
+ // makes emptiness something we accept only on positive evidence.
193
+ //
194
+ // NOTE: UVC liveness is NOT valid corroboration here — obsbot_gimbal_position returns
195
+ // a correct live pose even while the preset selectors aren't serving.
196
+ async function presetSubsystemServing(t) {
197
+ try {
198
+ const status = await t.recvStatus(60);
199
+ if (isAllZero(status))
200
+ return false;
201
+ return decodeStatus(status).awake;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ async function getPresetSlots(t) {
208
+ const block = await t.xuGetRaw(12, 60);
209
+ // Zero presets is a legitimate device state, and every preset tool routes through
210
+ // here — so refusing it outright left our toolset with no path OUT of it (save was
211
+ // gated behind the read that fails, making the first preset uncreatable where
212
+ // OBSBOT Center doesn't exist). Accept it only with positive corroboration, and
213
+ // skip the echo-write: with no entries there is no cursor to reset, which removes
214
+ // the C1 hazard at its root on this path rather than bypassing the check.
215
+ if (isAllZero(block) && (await presetSubsystemServing(t))) {
216
+ return assemblePresetSlots([]);
217
+ }
218
+ // C1: validate BEFORE the echo-write — see implausiblePresetListReason. A
219
+ // failed/short/garbage read must never be echoed back to a selector whose
220
+ // write semantics for anything but a genuine echo are undecoded.
221
+ const reason = implausiblePresetListReason(block);
222
+ if (reason) {
223
+ throw new Error(`preset list read implausible, refusing to reset the cursor: ${reason}`);
224
+ }
225
+ const list = decodePresetList(block);
226
+ await t.xuRaw(12, block); // echo-write resets the cursor — load-bearing, see above
227
+ // I3: never walk more entries than the device physically has, no matter what
228
+ // `count` claimed. C1 already rejects count>3 above, but this clamp is a
229
+ // second, independent guard against turning a garbage count into up to 255
230
+ // USB control transfers.
231
+ const walkCount = Math.min(list.count, 3);
232
+ const per = [];
233
+ for (let i = 0; i < walkCount; i++) {
234
+ const e = decodePresetEntry(await t.xuGetRaw(13, 60));
235
+ if (e.end)
236
+ break;
237
+ per.push({ slot: e.slot, name: e.name, pose: e.pose });
238
+ }
239
+ // I1: list.slots (from selector 12) is the device's own statement of WHICH
240
+ // slots are occupied. If the entry-cursor walk (selector 13) disagrees — it
241
+ // exhausted early, hit an implausible/all-zero entry, or decoded a different
242
+ // slot set — don't silently trust whichever answer under-reports. A false
243
+ // EMPTY is the dangerous direction for a create-once resource, so fail loudly
244
+ // instead of returning a confident wrong answer.
245
+ const walked = [...new Set(per.map((e) => e.slot - 1))].sort((a, b) => a - b);
246
+ const claimed = [...list.slots].sort((a, b) => a - b);
247
+ const agree = walked.length === claimed.length && walked.every((s, i) => s === claimed[i]);
248
+ if (!agree) {
249
+ throw new Error(`preset list mismatch: selector 12 claims occupied slots [${claimed.join(",")}] but the ` +
250
+ `entry-cursor walk (selector 13) found [${walked.join(",")}]`);
251
+ }
252
+ return assemblePresetSlots(per);
253
+ }
254
+ // This is a mechanical system: the gimbal does not switch between awake and asleep,
255
+ // it travels through them (rising, settling, stowing), and subsystems come back
256
+ // online in some order. A single sample of a system in motion describes one instant,
257
+ // not the state the next call lands in — so a lone implausible read is expected
258
+ // occasionally and is not, by itself, news.
259
+ //
260
+ // Backoff spans ~1.7s, chosen against the observed ~1-2s post-wake self-centering
261
+ // window. Latency is the cheapest thing we can spend here.
262
+ const PRESET_READ_DEFAULTS = { attempts: 3, backoffMs: [200, 500, 1000] };
263
+ const napMs = (ms) => new Promise((r) => setTimeout(r, ms));
264
+ const allEmpty = (slots) => slots.every((s) => !s.occupied);
265
+ /**
266
+ * Read the preset slots with bounded retry, and confirm the one verdict whose error
267
+ * destroys data.
268
+ *
269
+ * Reads can neither damage the camera nor delete anything, so retrying and
270
+ * re-reading is free in the only currencies that matter. Writes are never retried
271
+ * here — the caller's write sits outside this function by construction.
272
+ *
273
+ * EMPTY is confirmed by a second read because it is the sole verdict that authorizes
274
+ * an irreversible create-once ADD: a slot wrongly believed empty gets a customer's
275
+ * preset written over (firmware behaviour there is undecoded). The inverse error is
276
+ * benign — a slot wrongly believed occupied only makes delete a no-op and update
277
+ * fail. Disagreement between the two reads means the device is mid-transition, so we
278
+ * refuse rather than pick a side.
279
+ */
280
+ async function readPresetSlots(t, gate, opts = {}) {
281
+ const { attempts, backoffMs } = { ...PRESET_READ_DEFAULTS, ...opts };
282
+ let lastErr;
283
+ for (let i = 0; i < attempts; i++) {
284
+ if (i > 0) {
285
+ await napMs(backoffMs[Math.min(i - 1, backoffMs.length - 1)]);
286
+ // Re-probe readiness between attempts: the failure mode we chase is the device
287
+ // being partway through a transition, and the wake it may send is a decoded,
288
+ // documented command — not an undecoded write.
289
+ await gate();
290
+ }
291
+ try {
292
+ const slots = await getPresetSlots(t);
293
+ if (allEmpty(slots) && !allEmpty(await getPresetSlots(t))) {
294
+ throw new Error("preset list unstable: one read reported every slot empty, an immediate " +
295
+ "re-read disagreed — refusing to treat this as empty");
296
+ }
297
+ return slots;
298
+ }
299
+ catch (e) {
300
+ lastErr = e;
301
+ }
302
+ }
303
+ throw lastErr;
304
+ }
305
+ export function createTools(mgr, capture, debug = false, presetRead = {}) {
306
+ // Per-camera resolution derived from the manager. Every camera-addressing tool
307
+ // takes an optional `camera` selector (the serial); it threads through here so
308
+ // the transport, reconnect controller and readiness gate all target that one
309
+ // camera. `camera` omitted + a single camera attached => that camera (identical
310
+ // to the pre-selector behaviour). `camera` omitted + several attached => mgr.get
311
+ // throws AmbiguousCameraError, and an unknown serial throws UnknownCameraError;
312
+ // both surface to the caller via the gate's "unreachable" path (its getTransport
313
+ // throwing is already treated as unreachable, and the message is carried through).
314
+ const getTransport = (camera) => mgr.get(camera);
315
+ const reconnectFor = (camera) => ({
316
+ invalidate: () => mgr.invalidate(camera),
317
+ takeReconnected: () => mgr.takeReconnected(camera),
318
+ });
91
319
  // Readiness gate for gimbal/AI commands: probe presence + auto-wake if asleep,
92
320
  // self-heal (invalidate + re-open) on a mid-session disconnect. Returns the
93
321
  // ready transport or an { ok:false } error the handler passes straight through.
94
- const gate = () => ensureReady(getTransport, session);
322
+ const gate = (camera) => ensureReady(() => getTransport(camera), reconnectFor(camera));
95
323
  const needCapture = () => {
96
324
  if (!capture)
97
325
  throw new Error("capture manager not configured");
@@ -104,27 +332,48 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
104
332
  };
105
333
  const toolDefs = [
106
334
  {
107
- name: "obsbot_list_devices",
108
- description: "List connected OBSBOT-compatible video capture devices.",
335
+ name: "obsbot_devices",
336
+ description: "List attached OBSBOT-compatible cameras. Each entry is { serial?, locationId?, name, " +
337
+ "status, reason? }: serial is the value to pass as `camera` to any camera-addressing tool " +
338
+ "(present where obtainable — reading it requires briefly opening the camera); status " +
339
+ "is available (free to bind), bound (already opened by this process), or busy (could not " +
340
+ "be opened and identified, so it can't be targeted here — usually another process holds " +
341
+ "it, but it also covers a camera that opened yet would not answer). On a busy entry, " +
342
+ "`reason` carries the underlying error and distinguishes those cases.",
109
343
  schema: listDevicesSchema,
110
344
  handler: async (args) => {
111
345
  listDevicesSchema.parse(args);
112
- return { devices: await mgr.list() };
346
+ return { cameras: await mgr.listCameras() };
113
347
  },
114
348
  },
115
349
  {
116
- name: "obsbot_set_run_status",
117
- description: "Wake (\"run\") or sleep the camera/gimbal.",
118
- schema: setRunStatusSchema,
350
+ name: "obsbot_wake",
351
+ description: "Wake the camera/gimbal (sends \"run\"). This MOVES the camera: waking un-stows the " +
352
+ "gimbal and brings it back to level (pitch ~0). Most control commands also wake the " +
353
+ "camera implicitly as a side effect.",
354
+ schema: wakeSchema,
119
355
  handler: async (args) => {
120
- const { state } = setRunStatusSchema.parse(args);
121
- const t = await getTransport();
122
- await t.sendVendor(encodeSetRunStatus(state).buildFrame(t.nextSeq()));
123
- return { ok: true, state };
356
+ const { camera } = wakeSchema.parse(args);
357
+ const t = await getTransport(camera);
358
+ await t.sendVendor(encodeSetRunStatus("run").buildFrame(t.nextSeq()));
359
+ return { ok: true, state: "run" };
360
+ },
361
+ },
362
+ {
363
+ name: "obsbot_sleep",
364
+ description: "Sleep the camera/gimbal (sends \"sleep\"). This MOVES the camera: sleeping STOWS the " +
365
+ "gimbal, tilting it face-down to roughly pitch 84°, so obsbot_gimbal_position will read " +
366
+ "~84 rather than the pose you left it in. obsbot_wake un-stows it.",
367
+ schema: sleepSchema,
368
+ handler: async (args) => {
369
+ const { camera } = sleepSchema.parse(args);
370
+ const t = await getTransport(camera);
371
+ await t.sendVendor(encodeSetRunStatus("sleep").buildFrame(t.nextSeq()));
372
+ return { ok: true, state: "sleep" };
124
373
  },
125
374
  },
126
375
  {
127
- name: "obsbot_ptz_move_angle",
376
+ name: "obsbot_gimbal_move",
128
377
  description: "Move the gimbal to an absolute yaw/pitch angle (degrees); positive yaw pans to the " +
129
378
  "camera's left, positive pitch tilts down. Yaw is clamped to [-150,150], pitch to " +
130
379
  "[-90,90]. Absolute positioning (1:1 degrees), verified on hardware.",
@@ -134,81 +383,98 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
134
383
  const yaw = clamp(parsed.yaw, -150, 150);
135
384
  const pitch = clamp(parsed.pitch, -90, 90);
136
385
  const roll = parsed.roll;
137
- const ready = await gate();
386
+ const ready = await gate(parsed.camera);
138
387
  if (!ready.ok)
139
388
  return ready;
140
389
  const t = ready.transport;
141
- // Vendor gimbal frame AI_SET_GIM_MOTOR_DEG. The wire payload order is [roll, pitch,
142
- // yaw] — see encodePtzMoveAngle / gimbal3. Absolute, 1:1 degrees, HW-confirmed. (The
143
- // earlier UVC pan/tilt/roll experiment was abandoned: it's flaky on the OBSBOT
144
- // DirectShow driver, so PTZ rides the vendor frame instead.)
145
- await t.sendVendor(encodePtzMoveAngle(yaw, pitch, roll).buildFrame(t.nextSeq()));
390
+ await t.gimbalSet(yaw, pitch, roll);
146
391
  return ready.reconnected ? { yaw, pitch, roll, reconnected: true } : { yaw, pitch, roll };
147
392
  },
148
393
  },
149
394
  {
150
- name: "obsbot_ptz_move_speed",
151
- description: "Drive the gimbal at a yaw/pitch speed (positive yaw pans to the camera's left, matching " +
152
- "obsbot_ptz_move_angle), then automatically stop after autoStopMs (default 800ms) so it can't run away.",
395
+ name: "obsbot_gimbal_move_speed",
396
+ description: "Drive the gimbal at a yaw/pitch speed in DEGREES PER SECOND (positive yaw pans to the " +
397
+ "camera's left, matching obsbot_gimbal_move), then automatically stop after autoStopMs " +
398
+ "(default 800ms) so it can't run away. Speed is clamped to ±150 °/s; the returned " +
399
+ "yaw/pitch are the speeds actually used.",
153
400
  schema: ptzMoveSpeedSchema,
154
401
  handler: async (args) => {
155
- const { yaw, pitch, roll, autoStopMs } = ptzMoveSpeedSchema.parse(args);
156
- const ready = await gate();
402
+ const parsed = ptzMoveSpeedSchema.parse(args);
403
+ // Degrees per second, measured on hardware 2026-07-21 against the live
404
+ // position readback: 10/1000ms -> 10°, 60/500ms -> 29°, 120/300ms -> 35°,
405
+ // 170/300ms -> 49° — linear across the band.
406
+ //
407
+ // Clamping matters more here than for absolute moves. Past the limit the
408
+ // firmware does not saturate, it silently IGNORES the command: 180, 200
409
+ // and 300 each produced EXACTLY 0° of motion while still reporting
410
+ // success. Unclamped, a caller gets ok:true and a camera that never
411
+ // moved. 150 sits inside the verified-good band (150/160/170 all moved)
412
+ // with margin, and matches obsbot_gimbal_move's yaw bound so the API has
413
+ // one number to remember.
414
+ const yaw = clamp(parsed.yaw, -GIMBAL_MAX_SPEED_DPS, GIMBAL_MAX_SPEED_DPS);
415
+ const pitch = clamp(parsed.pitch, -GIMBAL_MAX_SPEED_DPS, GIMBAL_MAX_SPEED_DPS);
416
+ const roll = clamp(parsed.roll, -GIMBAL_MAX_SPEED_DPS, GIMBAL_MAX_SPEED_DPS);
417
+ const ready = await gate(parsed.camera);
157
418
  if (!ready.ok)
158
419
  return ready;
159
420
  const t = ready.transport;
160
- // Firmware velocity-yaw is inverted relative to position-yaw (AI_SET_GIM_SPEED +yaw
161
- // drives right, AI_SET_GIM_MOTOR_DEG +yaw drives left — HW-observed). Negate so the
162
- // tool contract is consistent: +yaw pans to the camera's left on both PTZ tools.
163
- await t.sendVendor(encodePtzMoveSpeed(-yaw, pitch, roll).buildFrame(t.nextSeq()));
164
- if (autoStopMs > 0) {
165
- await sleep(autoStopMs);
166
- await t.sendVendor(encodePtzMoveSpeed(0, 0, 0).buildFrame(t.nextSeq()));
167
- }
168
- return { ok: true, stopped: autoStopMs > 0, ...(ready.reconnected && { reconnected: true }) };
421
+ // Firmware velocity-yaw is inverted relative to position-yaw: the vendor
422
+ // AI_SET_GIM_SPEED +yaw drives right (opposite of obsbot_gimbal_move).
423
+ // The transport's gimbalSpeed handles this per-platform as needed.
424
+ await t.gimbalSpeed(yaw, pitch, roll, parsed.autoStopMs);
425
+ return {
426
+ ok: true,
427
+ yaw,
428
+ pitch,
429
+ stopped: parsed.autoStopMs > 0,
430
+ ...(ready.reconnected && { reconnected: true }),
431
+ };
169
432
  },
170
433
  },
171
434
  {
172
435
  name: "obsbot_gimbal_recenter",
173
- description: "Recenter the gimbal.",
436
+ description: "Recenter the gimbal — drives it back to yaw 0 / pitch 0 (level and facing forward). " +
437
+ "Returns as soon as the command is sent: the gimbal may still be moving, so poll " +
438
+ "obsbot_gimbal_position if you need to know it has arrived.",
174
439
  schema: gimbalRecenterSchema,
175
440
  handler: async (args) => {
176
- gimbalRecenterSchema.parse(args);
177
- const ready = await gate();
441
+ const { camera } = gimbalRecenterSchema.parse(args);
442
+ const ready = await gate(camera);
178
443
  if (!ready.ok)
179
444
  return ready;
180
445
  const t = ready.transport;
181
- // Vendor recenter GIM_SET_MOTOR (0x00C3 + 6 zero bytes). HW-confirmed to recenter
182
- // the gimbal.
183
- await t.sendVendor(encodeRecenter().buildFrame(t.nextSeq()));
446
+ await t.gimbalRecenter();
184
447
  return { ok: true, ...(ready.reconnected && { reconnected: true }) };
185
448
  },
186
449
  },
187
450
  {
188
- name: "obsbot_zoom_absolute",
189
- description: "Set absolute zoom ratio, clamped to [1.0, 2.0].",
451
+ name: "obsbot_zoom_uvc",
452
+ description: "Standard UVC zoom: set an absolute zoom ratio, clamped to [1.0, 2.0]. Snaps to the " +
453
+ "requested target exactly (unlike obsbot_zoom_vendor, whose ratio scale differs and " +
454
+ "may not land exactly where asked).",
190
455
  schema: zoomAbsoluteSchema,
191
456
  handler: async (args) => {
192
457
  const parsed = zoomAbsoluteSchema.parse(args);
193
458
  const ratio = clamp(parsed.ratio, 1.0, 2.0);
194
- const t = await getTransport();
459
+ const t = await getTransport(parsed.camera);
195
460
  const { min, max } = await t.zoomRange();
196
461
  await t.zoomSet(zoomRatioToUnits(ratio, min, max));
197
462
  return { ok: true, ratio };
198
463
  },
199
464
  },
200
465
  {
201
- name: "obsbot_ai_tracking",
202
- description: "Enable or disable AI subject tracking, and choose the framing sub-mode. When " +
203
- "enabled the camera follows the subject; disabling stops tracking. `mode` forces " +
204
- "the composition: normal | upper-body | close-up | headless | lower-body. After " +
205
- "writing, the tool polls the status block until the framing settles and returns " +
466
+ name: "obsbot_ai_track",
467
+ description: "Enable or disable AI tracking and choose the mode. When enabled the camera " +
468
+ "follows the subject; disabling stops tracking. `mode` is either a human framing " +
469
+ "(normal | upper-body | close-up | headless | lower-body) or a standalone scene " +
470
+ "mode (group | whiteboard | desk | hand); scene modes imply enabled:true. After " +
471
+ "writing, the tool polls the status block until the mode settles and returns " +
206
472
  "{ verified, matched } — the aiMode the device actually landed on (matched:false " +
207
- "means no subject was being tracked, so the framing could not take effect yet).",
473
+ "means no subject was being tracked, so the mode could not take effect yet).",
208
474
  schema: aiTrackingSchema,
209
475
  handler: async (args) => {
210
- const { enabled, mode } = aiTrackingSchema.parse(args);
211
- const ready = await gate();
476
+ const { enabled, mode, camera } = aiTrackingSchema.parse(args);
477
+ const ready = await gate(camera);
212
478
  if (!ready.ok)
213
479
  return ready;
214
480
  const t = ready.transport;
@@ -223,12 +489,17 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
223
489
  // Snapshot the framing before the write so verify can tell a real change
224
490
  // from the pre-write value (and skip the m=6 transient). See verifyFraming.
225
491
  const before = await readAiMode();
226
- // OBSBOT Center toggles tracking + framing with a raw uvcExt write to selector 6,
227
- // NOT a framed V3 command (which the Tiny 2 ACKs but ignores). byte[3] is the
228
- // framing sub-mode. See encodeAiTracking.
229
- await t.xuRaw(UVC_XU_SELECTOR, encodeAiTracking(enabled, mode));
230
- // Verify by readback: aiMode settles to (m=2, n=framing) after a brief m=6
231
- // transient. want == the requested framing (or no-tracking on disable).
492
+ // OBSBOT Center toggles tracking/mode with a raw uvcExt write to selector 6,
493
+ // NOT a framed V3 command (which the Tiny 2 ACKs but ignores). byte[2] is the
494
+ // work mode, byte[3] the human framing sub-mode. A scene mode (group/whiteboard/
495
+ // desk/hand) is its own work mode; a framing is the human work mode. See
496
+ // encodeAiMode / encodeAiTracking.
497
+ const payload = !enabled ? encodeAiMode("none")
498
+ : isSceneMode(mode) ? encodeAiMode(mode)
499
+ : encodeAiTracking(true, mode);
500
+ await t.xuRaw(UVC_XU_SELECTOR, payload);
501
+ // Verify by readback: aiMode settles to the requested mode after a brief m=6
502
+ // transient. The mode name doubles as its aiMode readback value.
232
503
  const want = enabled ? mode : "no-tracking";
233
504
  const { verified, matched } = await verifyFraming(readAiMode, want, before);
234
505
  return { ok: true, enabled, mode, verified, matched, ...(ready.reconnected && { reconnected: true }) };
@@ -240,48 +511,50 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
240
511
  "speed: standard (slower follow) | sport (snappier follow).",
241
512
  schema: aiTrackSpeedSchema,
242
513
  handler: async (args) => {
243
- const { speed } = aiTrackSpeedSchema.parse(args);
244
- const t = await getTransport();
514
+ const { speed, camera } = aiTrackSpeedSchema.parse(args);
515
+ const t = await getTransport(camera);
245
516
  await t.sendVendor(encodeAiTrackSpeed(speed).buildFrame(t.nextSeq()));
246
517
  return { ok: true, speed };
247
518
  },
248
519
  },
249
520
  {
250
- name: "obsbot_zoom_speed",
251
- description: "Zoom to an absolute ratio at a chosen speed. ratio is clamped to [1.0,2.0]; " +
252
- "speed 0=device default, 1-10 slow→fast, 255=maximum.",
521
+ name: "obsbot_zoom_vendor",
522
+ description: "Vendor zoom path with adjustable speed: zoom to a ratio at a chosen speed. This tool's " +
523
+ "ratio scale differs from obsbot_zoom_uvc's and may not land exactly on the requested " +
524
+ "target. ratio is clamped to [1.0,2.0]; speed 0=device default, 1-10 slow→fast, 255=maximum.",
253
525
  schema: zoomSpeedSchema,
254
526
  handler: async (args) => {
255
527
  const parsed = zoomSpeedSchema.parse(args);
256
528
  const ratio = clamp(parsed.ratio, 1.0, 2.0);
257
529
  const speed = clamp(Math.round(parsed.speed), 0, 255);
258
- const t = await getTransport();
530
+ const t = await getTransport(parsed.camera);
259
531
  await t.sendVendor(encodeZoomWithSpeed(Math.round(ratio * 100), speed).buildFrame(t.nextSeq()));
260
532
  return { ok: true, ratio, speed };
261
533
  },
262
534
  },
263
535
  {
264
- name: "obsbot_face_focus",
536
+ name: "obsbot_focus_face",
265
537
  description: "Enable or disable face-priority autofocus.",
266
538
  schema: faceFocusSchema,
267
539
  handler: async (args) => {
268
- const { enabled } = faceFocusSchema.parse(args);
269
- const t = await getTransport();
540
+ const { enabled, camera } = faceFocusSchema.parse(args);
541
+ const t = await getTransport(camera);
270
542
  await t.sendVendor(encodeFaceFocus(enabled).buildFrame(t.nextSeq()));
271
543
  return { ok: true, enabled };
272
544
  },
273
545
  },
274
546
  {
275
- name: "obsbot_get_status",
276
- description: "Read the camera's live status block. Returns { awake, hdr, aiMode, trackSpeed }: " +
547
+ name: "obsbot_status",
548
+ description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed }: " +
549
+ "faceAe is whether auto-exposure is metering for a detected face; " +
277
550
  "aiMode is the current AI framing (no-tracking|normal|upper-body|close-up|headless|" +
278
551
  "lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown. " +
279
552
  "Under --debug the result also carries `raw`: the full 60-byte status block as hex " +
280
553
  "(for reverse-engineering undecoded offsets).",
281
554
  schema: getStatusSchema,
282
555
  handler: async (args) => {
283
- getStatusSchema.parse(args);
284
- const t = await getTransport();
556
+ const { camera } = getStatusSchema.parse(args);
557
+ const t = await getTransport(camera);
285
558
  try {
286
559
  const block = await t.recvStatus();
287
560
  return { ok: true, ...decodeStatus(block), ...(debug ? { raw: block.toString("hex") } : {}) };
@@ -292,13 +565,15 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
292
565
  },
293
566
  },
294
567
  {
295
- name: "obsbot_probe",
568
+ name: "obsbot_debug_probe",
296
569
  description: "RE/diagnostics only — generic XU byte access for reverse-engineering the feedback surface. " +
297
570
  "mode 'get': GET_CUR read `length` bytes from XU `selector` (default selector 6; use a large " +
298
571
  "length to probe the status block's true size, or sweep other selectors). " +
299
572
  "mode 'set': SET_CUR write raw `hex` bytes to XU `selector` (e.g. replay a captured frame). " +
300
- "mode 'query': frame table `opcode` (default AI_GET_QUICK_STATUS) with optional `payloadHex`, " +
301
- "send on the vendor selector, then read the reply frame. Returns raw hex. Not for normal use.",
573
+ "mode 'query': frame table `opcode` (default AI_GET_QUICK_STATUS) — a pure GET (no " +
574
+ "payloadHex) is framed header-only (flags 0x01, the only flavour this device answers for " +
575
+ "a GET); supplying `payloadHex` frames a SET/command instead (flags 0x25) — send on the " +
576
+ "vendor selector, then poll for the matching reply frame. Returns raw hex. Not for normal use.",
302
577
  schema: probeSchema,
303
578
  handler: async (args) => {
304
579
  const { mode, selector, length, hex, opcode, payloadHex } = probeSchema.parse(args);
@@ -316,29 +591,54 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
316
591
  await t.xuRaw(selector, Buffer.from(hex, "hex"));
317
592
  return { ok: true, selector, sent: hex };
318
593
  }
319
- // mode "query": build a framed V3 command, send it, read the reply frame.
594
+ // mode "query": build a framed V3 command and poll the vendor reply mailbox
595
+ // until a valid, matching reply appears.
320
596
  const payload = payloadHex ? Buffer.from(payloadHex, "hex") : Buffer.alloc(0);
321
597
  const name = opcode ?? "AI_GET_QUICK_STATUS";
322
- const frame = encodeVendorProbe(name, payload).buildFrame(t.nextSeq());
323
- const reply = await t.recvVendor(frame, length ?? 60);
324
- let parsed;
325
- try {
326
- const p = parseFrame(reply);
327
- parsed = {
328
- cmd: "0x" + p.cmd.toString(16).padStart(4, "0"),
329
- receiver: p.receiver,
330
- payloadHex: p.payload.toString("hex"),
331
- };
598
+ const op = OP_BY_NAME.get(name);
599
+ if (!op || op.wireCmd === null || op.receiver === null) {
600
+ return { ok: false, error: `opcode "${name}" is not a sendable V3 command` };
332
601
  }
333
- catch (e) {
334
- parsed = { parseError: e.message };
602
+ // A pure GET (no nested payload) only gets an answer on this device when
603
+ // framed header-only (flags 0x01, encodeVendorGet) — encodeVendorProbe's
604
+ // SET flavour (flags 0x25) returns a stale echo instead of a real answer
605
+ // when there's no payload for the device to act on. A supplied payloadHex
606
+ // means an actual SET/command, so that keeps the 0x25 framing.
607
+ const seq = t.nextSeq();
608
+ const frame = (payload.length === 0 ? encodeVendorGet(name) : encodeVendorProbe(name, payload)).buildFrame(seq);
609
+ await t.xuRaw(PROBE_VENDOR_SELECTOR, frame);
610
+ // The reply mailbox retains the PREVIOUS reply until the new one lands, so
611
+ // a single unvalidated read can return stale data. Trust a reply only once
612
+ // it parses cleanly AND both its cmd and seq match what was just sent (same
613
+ // validation readSerialVia does in transport/read-serial.ts).
614
+ const replyLen = length ?? 60;
615
+ for (let i = 0; i < PROBE_QUERY_POLL_ATTEMPTS; i++) {
616
+ await napMs(PROBE_QUERY_POLL_DELAY_MS); // let the reply land before reading
617
+ const raw = await t.xuGetRaw(PROBE_VENDOR_SELECTOR, replyLen);
618
+ try {
619
+ const p = parseFrame(raw);
620
+ if (p.cmd === op.wireCmd && p.seq === seq) {
621
+ return {
622
+ ok: true,
623
+ opcode: name,
624
+ sentFrame: frame.toString("hex"),
625
+ replyHex: raw.toString("hex"),
626
+ parsed: {
627
+ cmd: "0x" + p.cmd.toString(16).padStart(4, "0"),
628
+ receiver: p.receiver,
629
+ payloadHex: p.payload.toString("hex"),
630
+ },
631
+ };
632
+ }
633
+ }
634
+ catch {
635
+ // Not our reply yet (stale mailbox, still in flight, or garbage) — keep polling.
636
+ }
335
637
  }
336
638
  return {
337
- ok: true,
338
- opcode: name,
639
+ ok: false,
640
+ error: `no valid reply for ${name} (seq ${seq}) after ${PROBE_QUERY_POLL_ATTEMPTS} attempts`,
339
641
  sentFrame: frame.toString("hex"),
340
- replyHex: reply.toString("hex"),
341
- parsed,
342
642
  };
343
643
  }
344
644
  catch (e) {
@@ -347,54 +647,61 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
347
647
  },
348
648
  },
349
649
  {
350
- name: "obsbot_fov",
650
+ name: "obsbot_image_fov",
351
651
  description: "Set the field of view. fov: wide (86°) | medium (78°) | narrow (65°).",
352
652
  schema: fovSchema,
353
653
  handler: async (args) => {
354
- const { fov } = fovSchema.parse(args);
355
- const t = await getTransport();
654
+ const { fov, camera } = fovSchema.parse(args);
655
+ const t = await getTransport(camera);
356
656
  await t.xuRaw(UVC_XU_SELECTOR, encodeFov(fov));
357
657
  return { ok: true, fov };
358
658
  },
359
659
  },
360
660
  {
361
- name: "obsbot_hdr",
661
+ name: "obsbot_image_hdr",
362
662
  description: "Toggle HDR/WDR imaging on or off.",
363
663
  schema: hdrSchema,
364
664
  handler: async (args) => {
365
- const { enabled } = hdrSchema.parse(args);
366
- const t = await getTransport();
665
+ const { enabled, camera } = hdrSchema.parse(args);
666
+ const t = await getTransport(camera);
367
667
  await t.xuRaw(UVC_XU_SELECTOR, encodeHdr(enabled));
368
668
  return { ok: true, enabled };
369
669
  },
370
670
  },
371
671
  {
372
- name: "obsbot_focus",
373
- description: "Set focus. mode 'auto' enables continuous autofocus; mode 'manual' sets the " +
374
- "focus motor to position (0-100, near→far), mapped onto the device range.",
375
- schema: focusSchema,
672
+ name: "obsbot_focus_auto",
673
+ description: "Enable continuous autofocus.",
674
+ schema: focusAutoSchema,
376
675
  handler: async (args) => {
377
- const { mode, position } = focusSchema.parse(args);
378
- const t = await getTransport();
379
- if (mode === "auto") {
380
- await t.camCtrlSet(CAMERA_CONTROL_FOCUS, 0, UVC_FLAG_AUTO);
381
- return { ok: true, mode };
382
- }
676
+ const { camera } = focusAutoSchema.parse(args);
677
+ const t = await getTransport(camera);
678
+ await t.camCtrlSet(CAMERA_CONTROL_FOCUS, 0, UVC_FLAG_AUTO);
679
+ return { ok: true, mode: "auto" };
680
+ },
681
+ },
682
+ {
683
+ name: "obsbot_focus_manual",
684
+ description: "Set the focus motor to position (0-100, near→far), mapped onto the device range.",
685
+ schema: focusManualSchema,
686
+ handler: async (args) => {
687
+ const { position, camera } = focusManualSchema.parse(args);
688
+ const t = await getTransport(camera);
383
689
  const { min, max } = await t.camCtrlRange(CAMERA_CONTROL_FOCUS);
384
690
  const value = percentToRange(position, min, max);
385
691
  await t.camCtrlSet(CAMERA_CONTROL_FOCUS, value, UVC_FLAG_MANUAL);
386
- return { ok: true, mode, position, value };
692
+ return { ok: true, mode: "manual", position, value };
387
693
  },
388
694
  },
389
695
  {
390
696
  name: "obsbot_gimbal_position",
391
697
  description: "Read the gimbal's current absolute yaw/pitch in degrees (positive yaw = camera's " +
392
- "left, positive pitch = down) via the standard UVC Pan/Tilt controls. Reports the " +
393
- "actual position, which may lag a move that is still in progress.",
698
+ "left, positive pitch = down) via the standard UVC Pan/Tilt controls. This is a live " +
699
+ "hardware readout accurate to ±1°: it is valid during a move as well as after one, " +
700
+ "and reflects motion the host did not command (speed moves, recenter, tracking).",
394
701
  schema: gimbalPositionSchema,
395
702
  handler: async (args) => {
396
- gimbalPositionSchema.parse(args);
397
- const t = await getTransport();
703
+ const { camera } = gimbalPositionSchema.parse(args);
704
+ const t = await getTransport(camera);
398
705
  const pan = await t.camCtrlGet(CAMERA_CONTROL_PAN);
399
706
  const tilt = await t.camCtrlGet(CAMERA_CONTROL_TILT);
400
707
  // UVC pan value is degrees, same sign as our yaw (+ = camera-left). UVC tilt
@@ -403,33 +710,287 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
403
710
  },
404
711
  },
405
712
  {
406
- name: "obsbot_white_balance",
407
- description: "Set white balance. mode 'auto' enables auto white balance; mode 'manual' sets a " +
408
- "colour temperature in Kelvin (clamped to the device's supported range).",
409
- schema: whiteBalanceSchema,
713
+ name: "obsbot_preset_list",
714
+ description: "Read the three gimbal preset slots (occupied/empty, name, pose in degrees). " +
715
+ "Reads flat XU selectors 12 (list) and 13 (entry cursor), NOT the vendor V3 " +
716
+ "framed-reply path (which is non-functional for preset data on this device).",
717
+ schema: presetListSchema,
410
718
  handler: async (args) => {
411
- const { mode, temperature } = whiteBalanceSchema.parse(args);
412
- const t = await getTransport();
413
- const { min, max } = await t.procAmpRange(VIDEO_PROCAMP_WHITE_BALANCE);
414
- if (mode === "auto") {
415
- await t.procAmpSet(VIDEO_PROCAMP_WHITE_BALANCE, min, UVC_FLAG_AUTO);
416
- return { ok: true, mode };
719
+ const { camera } = presetListSchema.parse(args);
720
+ const ready = await gate(camera);
721
+ if (!ready.ok)
722
+ return ready;
723
+ const t = ready.transport;
724
+ try {
725
+ const slots = await readPresetSlots(t, () => gate(camera), presetRead);
726
+ return { ok: true, slots, ...(ready.reconnected && { reconnected: true }) };
727
+ }
728
+ catch (e) {
729
+ return { ok: false, error: msg(e) };
730
+ }
731
+ },
732
+ },
733
+ {
734
+ name: "obsbot_preset_save",
735
+ description: "Save the gimbal's current live pose (yaw/pitch, via the standard UVC Pan/Tilt " +
736
+ "controls) into preset slot 1|2|3. Slots are create-once on this device — there is " +
737
+ "no overwrite, so an occupied slot is rejected (delete it first). Verifies by " +
738
+ "re-reading the slot list after writing.",
739
+ schema: presetSaveSchema,
740
+ handler: async (args) => {
741
+ const { slot, camera } = presetSaveSchema.parse(args);
742
+ const ready = await gate(camera);
743
+ if (!ready.ok)
744
+ return ready;
745
+ const t = ready.transport;
746
+ let pose;
747
+ try {
748
+ const before = await readPresetSlots(t, () => gate(camera), presetRead);
749
+ if (before[slot - 1].occupied) {
750
+ return { ok: false, error: `slot ${slot} is occupied; update or delete first` };
751
+ }
752
+ // Mirror obsbot_gimbal_position's read path exactly: UVC pan is degrees, same
753
+ // sign as our yaw; UVC tilt is degrees but positive = up, so negate to match
754
+ // our +pitch = down convention.
755
+ const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
756
+ const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
757
+ // T6: ObsbotTransport exposes no zoom getter, so the live zoom ratio can't be
758
+ // read back here — zoom is hardcoded to 1 rather than guessed. Not an oversight.
759
+ pose = { pan: yaw, tilt: pitch, roll: 0, zoom: 1 };
760
+ await t.sendVendor(encodePresetAdd(t.nextSeq(), slot, pose));
761
+ }
762
+ catch (e) {
763
+ return { ok: false, error: `preset save failed: ${msg(e)}` };
417
764
  }
765
+ // M1: the ADD above has committed. From here on, a thrown error must say so —
766
+ // otherwise a retry hits "slot occupied" with no clue the save actually landed.
767
+ try {
768
+ const after = await readPresetSlots(t, () => gate(camera), presetRead);
769
+ if (!after[slot - 1].occupied) {
770
+ return { ok: false, error: "verification failed", expected: "occupied", actual: "empty" };
771
+ }
772
+ return { ok: true, slot: after[slot - 1], ...(ready.reconnected && { reconnected: true }) };
773
+ }
774
+ catch (e) {
775
+ return {
776
+ ok: false,
777
+ error: `preset saved to slot ${slot} but verification failed: ${msg(e)}`,
778
+ };
779
+ }
780
+ },
781
+ },
782
+ {
783
+ name: "obsbot_preset_recall",
784
+ description: "Recall preset slot 1|2|3, driving the gimbal to that slot's saved pose. The slot " +
785
+ "must be occupied. The gimbal may still be moving when this returns — verification " +
786
+ "only confirms the slot is still occupied, not that the pose has arrived.",
787
+ schema: presetRecallSchema,
788
+ handler: async (args) => {
789
+ const { slot, camera } = presetRecallSchema.parse(args);
790
+ const ready = await gate(camera);
791
+ if (!ready.ok)
792
+ return ready;
793
+ const t = ready.transport;
794
+ try {
795
+ const before = await readPresetSlots(t, () => gate(camera), presetRead);
796
+ if (!before[slot - 1].occupied) {
797
+ return { ok: false, error: `slot ${slot} is empty; save first` };
798
+ }
799
+ await t.sendVendor(encodePresetRecall(t.nextSeq(), slot));
800
+ // Sync V4L2 pan/tilt register to the preset's saved pose. The vendor-frame
801
+ // recall physically moves the gimbal but corrupts V4L2 pan_absolute/tilt_absolute
802
+ // on Linux. Issuing gimbalSet (which uses V4L2 on Linux) to the known target
803
+ // both moves the gimbal there and restores the V4L2 register.
804
+ const recallPose = before[slot - 1].pose;
805
+ if (recallPose) {
806
+ await t.gimbalSet(recallPose.pan, recallPose.tilt);
807
+ }
808
+ const after = await readPresetSlots(t, () => gate(camera), presetRead);
809
+ if (!after[slot - 1].occupied) {
810
+ return { ok: false, error: "verification failed", expected: "occupied", actual: "empty" };
811
+ }
812
+ return { ok: true, slot: after[slot - 1], ...(ready.reconnected && { reconnected: true }) };
813
+ }
814
+ catch (e) {
815
+ return { ok: false, error: msg(e) };
816
+ }
817
+ },
818
+ },
819
+ {
820
+ name: "obsbot_preset_update",
821
+ description: "Overwrite preset slot 1|2|3 with the gimbal's current live pose (yaw/pitch via the " +
822
+ "standard UVC Pan/Tilt controls). The slot must already be occupied (save first to " +
823
+ "create it). Verifies by re-reading the slot list after writing.",
824
+ schema: presetUpdateSchema,
825
+ handler: async (args) => {
826
+ const { slot, camera } = presetUpdateSchema.parse(args);
827
+ const ready = await gate(camera);
828
+ if (!ready.ok)
829
+ return ready;
830
+ const t = ready.transport;
831
+ let pose;
832
+ let previous = null;
833
+ try {
834
+ const before = await readPresetSlots(t, () => gate(camera), presetRead);
835
+ if (!before[slot - 1].occupied) {
836
+ return { ok: false, error: `slot ${slot} is empty; save first` };
837
+ }
838
+ // The pose we are about to destroy. Already in hand from the guard read, so
839
+ // handing it back costs nothing and is the only restore path the caller has:
840
+ // the device keeps no history and UPDATE is not reversible.
841
+ previous = before[slot - 1].pose;
842
+ // Mirror obsbot_preset_save's read path exactly: UVC pan is degrees, same sign as
843
+ // our yaw; UVC tilt is degrees but positive = up, so negate to match our +pitch =
844
+ // down convention.
845
+ const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
846
+ const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
847
+ // T6: ObsbotTransport exposes no zoom getter, so the live zoom ratio can't be
848
+ // read back here — zoom is hardcoded to 1 rather than guessed. Not an oversight.
849
+ pose = { pan: yaw, tilt: pitch, roll: 0, zoom: 1 };
850
+ await t.sendVendor(encodePresetUpdate(t.nextSeq(), slot, pose));
851
+ }
852
+ catch (e) {
853
+ return { ok: false, error: `preset update failed: ${msg(e)}` };
854
+ }
855
+ // M1: the UPDATE above has committed. From here on, a thrown error must say so.
856
+ try {
857
+ const after = await readPresetSlots(t, () => gate(camera), presetRead);
858
+ if (!after[slot - 1].occupied) {
859
+ return { ok: false, error: "verification failed", expected: "occupied", actual: "empty" };
860
+ }
861
+ return {
862
+ ok: true,
863
+ slot: after[slot - 1],
864
+ ...(previous && { previous }),
865
+ ...(ready.reconnected && { reconnected: true }),
866
+ };
867
+ }
868
+ catch (e) {
869
+ return {
870
+ ok: false,
871
+ error: `preset updated in slot ${slot} but verification failed: ${msg(e)}`,
872
+ };
873
+ }
874
+ },
875
+ },
876
+ {
877
+ name: "obsbot_preset_rename",
878
+ description: `Rename preset slot 1|2|3. The slot must already be occupied. Names longer than ` +
879
+ `${PRESET_NAME_MAX} bytes are truncated to fit the wire frame. Verifies by re-reading ` +
880
+ "the slot list after writing.",
881
+ schema: presetRenameSchema,
882
+ handler: async (args) => {
883
+ const { slot, name, camera } = presetRenameSchema.parse(args);
884
+ const ready = await gate(camera);
885
+ if (!ready.ok)
886
+ return ready;
887
+ const t = ready.transport;
888
+ try {
889
+ const before = await readPresetSlots(t, () => gate(camera), presetRead);
890
+ if (!before[slot - 1].occupied) {
891
+ return { ok: false, error: `slot ${slot} is empty; save first` };
892
+ }
893
+ const clean = name.slice(0, PRESET_NAME_MAX);
894
+ // The read path (decodePresetEntry) base64-decodes the stored name, but the
895
+ // captured OBSBOT Center rename frame carried raw ASCII ("Preset1", "3reset2A"),
896
+ // so the write side sends raw ASCII here too — per the capture. Whether the
897
+ // device actually expects ASCII or base64 on the WRITE side is NOT yet
898
+ // hardware-confirmed; flagged for the hardware verification task.
899
+ await t.sendVendor(encodePresetSetName(t.nextSeq(), slot, clean));
900
+ const after = await readPresetSlots(t, () => gate(camera), presetRead);
901
+ if (after[slot - 1].name !== clean) {
902
+ return { ok: false, error: "verification failed", expected: clean, actual: after[slot - 1].name };
903
+ }
904
+ return { ok: true, slot: after[slot - 1], ...(ready.reconnected && { reconnected: true }) };
905
+ }
906
+ catch (e) {
907
+ return { ok: false, error: msg(e) };
908
+ }
909
+ },
910
+ },
911
+ {
912
+ name: "obsbot_preset_delete",
913
+ description: "Delete preset slot 1|2|3. The slot must be occupied. Verifies by re-reading the " +
914
+ "slot list after writing.",
915
+ schema: presetDeleteSchema,
916
+ handler: async (args) => {
917
+ const { slot, camera } = presetDeleteSchema.parse(args);
918
+ const ready = await gate(camera);
919
+ if (!ready.ok)
920
+ return ready;
921
+ const t = ready.transport;
922
+ // Tracks whether the destructive write already left the host. If the
923
+ // post-write verify read throws, a bare error reads as "nothing happened"
924
+ // and invites a blind retry of an operation that already committed.
925
+ let sent = false;
926
+ try {
927
+ const before = await readPresetSlots(t, () => gate(camera), presetRead);
928
+ if (!before[slot - 1].occupied) {
929
+ return { ok: false, error: `slot ${slot} is already empty` };
930
+ }
931
+ // Everything the delete is about to destroy, captured from the guard read.
932
+ // Slots are create-once with no device-side history, so this response is the
933
+ // caller's ONLY route back if the wrong slot was named.
934
+ const destroyed = { name: before[slot - 1].name, pose: before[slot - 1].pose };
935
+ await t.sendVendor(encodePresetDelete(t.nextSeq(), slot));
936
+ sent = true;
937
+ const after = await readPresetSlots(t, () => gate(camera), presetRead);
938
+ if (after[slot - 1].occupied) {
939
+ return { ok: false, error: "verification failed", expected: "empty", actual: "occupied" };
940
+ }
941
+ return { ok: true, deleted: destroyed, ...(ready.reconnected && { reconnected: true }) };
942
+ }
943
+ catch (e) {
944
+ if (sent) {
945
+ return {
946
+ ok: false,
947
+ error: `delete was sent and may have been applied, but verification failed: ${msg(e)}`,
948
+ committed: "unknown",
949
+ };
950
+ }
951
+ return { ok: false, error: msg(e) };
952
+ }
953
+ },
954
+ },
955
+ {
956
+ name: "obsbot_image_wb_auto",
957
+ description: "Enable auto white balance.",
958
+ schema: imageWbAutoSchema,
959
+ handler: async (args) => {
960
+ const { camera } = imageWbAutoSchema.parse(args);
961
+ const t = await getTransport(camera);
962
+ const { min } = await t.procAmpRange(VIDEO_PROCAMP_WHITE_BALANCE);
963
+ await t.procAmpSet(VIDEO_PROCAMP_WHITE_BALANCE, min, UVC_FLAG_AUTO);
964
+ return { ok: true, mode: "auto" };
965
+ },
966
+ },
967
+ {
968
+ name: "obsbot_image_wb_manual",
969
+ description: "Set white balance to a colour temperature in Kelvin (clamped to the device's " +
970
+ "supported range).",
971
+ schema: imageWbManualSchema,
972
+ handler: async (args) => {
973
+ const { temperature, camera } = imageWbManualSchema.parse(args);
974
+ const t = await getTransport(camera);
975
+ const { min, max } = await t.procAmpRange(VIDEO_PROCAMP_WHITE_BALANCE);
418
976
  const value = clamp(Math.round(temperature), min, max);
419
977
  await t.procAmpSet(VIDEO_PROCAMP_WHITE_BALANCE, value, UVC_FLAG_MANUAL);
420
- return { ok: true, mode, temperature: value };
978
+ return { ok: true, mode: "manual", temperature: value };
421
979
  },
422
980
  },
423
981
  {
424
- name: "obsbot_image_control",
982
+ name: "obsbot_image_adjust",
425
983
  description: "Adjust a standard image control: control is brightness | contrast | hue | " +
426
984
  "saturation | sharpness | gain | backlight-compensation; level 0-100 is mapped onto " +
427
- "the device's supported range for that control. Standard UVC (IAMVideoProcAmp), no auto.",
985
+ "the device's supported range for that control. Standard UVC (IAMVideoProcAmp), no auto. " +
986
+ "NOTE: `gain` and `backlight-compensation` are NOT implemented on the Tiny 2 — it " +
987
+ "reports them as zero-length controls — so they are refused with an error rather than " +
988
+ "silently doing nothing. The other five work.",
428
989
  schema: imageControlSchema,
429
990
  handler: async (args) => {
430
- const { control, level } = imageControlSchema.parse(args);
991
+ const { control, level, camera } = imageControlSchema.parse(args);
431
992
  const property = IMAGE_CONTROL_PROP[control];
432
- const t = await getTransport();
993
+ const t = await getTransport(camera);
433
994
  const { min, max } = await t.procAmpRange(property);
434
995
  const value = percentToRange(level, min, max);
435
996
  await t.procAmpSet(property, value, UVC_FLAG_MANUAL);
@@ -437,39 +998,68 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
437
998
  },
438
999
  },
439
1000
  {
440
- name: "obsbot_exposure",
441
- description: "Set exposure. mode 'auto' enables auto-exposure; mode 'manual' sets level 0-100 " +
442
- "(0 darkest → 100 brightest), mapped onto the device's exposure range. " +
443
- "Uses proprietary V3 frame protocol (CAM_SET_EXPOSURE_MODE + CAM_SET_EXPOSURE_TINY2) " +
444
- "because the standard UVC/IAMCameraControl V4L2 path is a stub on the Tiny 2.",
445
- schema: exposureSchema,
1001
+ name: "obsbot_image_exposure_auto",
1002
+ description: "Enable auto-exposure. Optional priority 'global' | 'face' selects the metering " +
1003
+ "region (face-priority meters for a detected face). " +
1004
+ "Uses the proprietary V3 frame protocol (CAM_SET_EXPOSURE_TINY2, which carries mode " +
1005
+ "and value in one command) because the standard UVC/IAMCameraControl V4L2 path is a " +
1006
+ "stub on the Tiny 2.",
1007
+ schema: imageExposureAutoSchema,
446
1008
  handler: async (args) => {
447
- const { mode, level } = exposureSchema.parse(args);
448
- const t = await getTransport();
449
- if (mode === "auto") {
450
- await t.sendVendor(encodeSetExposureMode(false).buildFrame(t.nextSeq()));
451
- return { ok: true, mode };
1009
+ const { priority, camera } = imageExposureAutoSchema.parse(args);
1010
+ const t = await getTransport(camera);
1011
+ // Mode and value go in ONE command — CAM_SET_EXPOSURE_TINY2 with a 5-byte
1012
+ // [mode][value] payload. The separate CAM_SET_EXPOSURE_MODE command is inert
1013
+ // on this device, and a 4-byte value payload is silently discarded.
1014
+ // Device exposure range is 1..2500 (read from CAM_GET_EXPOSURE_RANGE_TINY2;
1015
+ // the previous 0..65535 figure came from the Tiny4Linux reference and does
1016
+ // not match this hardware). This branch carries no `level` input, so the
1017
+ // value byte mirrors the combined tool's pre-split default (50%) — the
1018
+ // device only acts on it once manual mode is selected.
1019
+ const raw = percentToRange(50, 1, 2500);
1020
+ await t.sendVendor(encodeSetExposure(false, raw).buildFrame(t.nextSeq()));
1021
+ // Face vs global metering is a sel-6 uvcExt write applied after auto-exposure
1022
+ // is on (readback surfaces at status offset 0x07). See encodeFaceAe.
1023
+ if (priority) {
1024
+ await t.xuRaw(UVC_XU_SELECTOR, encodeFaceAe(priority === "face"));
1025
+ return { ok: true, mode: "auto", priority };
452
1026
  }
453
- // Switch to manual mode via V3 frame protocol (CAM_SET_EXPOSURE_MODE)
454
- await t.sendVendor(encodeSetExposureMode(true).buildFrame(t.nextSeq()));
455
- // Translate 0-100 percentage to raw 16-bit exposure value.
456
- // Tiny 2 exposure range is 0-65535 (confirmed by Tiny4Linux reference).
457
- const raw = percentToRange(level, 0, 65535);
458
- await t.sendVendor(encodeSetExposureValue(raw).buildFrame(t.nextSeq()));
459
- return { ok: true, mode, level, raw };
1027
+ return { ok: true, mode: "auto" };
1028
+ },
1029
+ },
1030
+ {
1031
+ name: "obsbot_image_exposure_manual",
1032
+ description: "Set exposure level 0-100 (0 darkest → 100 brightest), mapped onto the device's " +
1033
+ "exposure range. Also returns `raw`: the device-native exposure value the level mapped " +
1034
+ "to, for diagnostics — `level` is the number to reason with. " +
1035
+ "Uses the proprietary V3 frame protocol (CAM_SET_EXPOSURE_TINY2, which carries mode " +
1036
+ "and value in one command) because the standard UVC/IAMCameraControl V4L2 path is a " +
1037
+ "stub on the Tiny 2.",
1038
+ schema: imageExposureManualSchema,
1039
+ handler: async (args) => {
1040
+ const { level, camera } = imageExposureManualSchema.parse(args);
1041
+ const t = await getTransport(camera);
1042
+ // Device exposure range is 1..2500 (read from CAM_GET_EXPOSURE_RANGE_TINY2;
1043
+ // the previous 0..65535 figure came from the Tiny4Linux reference and does
1044
+ // not match this hardware).
1045
+ const raw = percentToRange(level, 1, 2500);
1046
+ await t.sendVendor(encodeSetExposure(true, raw).buildFrame(t.nextSeq()));
1047
+ return { ok: true, mode: "manual", level, raw };
460
1048
  },
461
1049
  },
462
1050
  {
463
- name: "obsbot_snapshot",
1051
+ name: "obsbot_capture_snapshot",
464
1052
  description: "Grab one still frame from the camera and return it as an image (for you to see " +
465
- "and for framing/lighting/exposure checks). NOTE: before calling, ensure the camera " +
466
- "is focused (call obsbot_focus with mode:'auto' for autofocus) unless otherwise " +
1053
+ "and for framing/lighting/exposure checks). resolution is the longest edge in pixels, " +
1054
+ "256-1920, default 640 — larger images cost proportionally more tokens, so ask for " +
1055
+ "more only when you need the detail. NOTE: before calling, ensure the camera " +
1056
+ "is focused (call obsbot_focus_auto for autofocus) unless otherwise " +
467
1057
  "directed. source: device (default) | virtual | ndi. " +
468
1058
  "If the camera is in use by another app, returns a message instead of an image.",
469
1059
  schema: snapshotSchema,
470
1060
  handler: async (args) => {
471
- const { maxDim, quality, settleMs, source } = snapshotSchema.parse(args);
472
- const t = await getTransport();
1061
+ const { resolution, quality, settleMs, source, camera } = snapshotSchema.parse(args);
1062
+ const t = await getTransport(camera);
473
1063
  let path;
474
1064
  if (source !== "device") {
475
1065
  const devices = await mgr.list();
@@ -488,7 +1078,9 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
488
1078
  path = match.path;
489
1079
  }
490
1080
  try {
491
- const snap = await t.snapshot({ path, maxDim, quality, settleMs });
1081
+ // The helper's wire field is still maxDim (shared with the Windows and
1082
+ // Linux helpers); `resolution` is the tool-facing name.
1083
+ const snap = await t.snapshot({ path, maxDim: resolution, quality, settleMs });
492
1084
  return {
493
1085
  content: [
494
1086
  { type: "image", data: snap.base64, mimeType: snap.mime },
@@ -516,11 +1108,12 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
516
1108
  },
517
1109
  },
518
1110
  {
519
- name: "obsbot_record_start",
1111
+ name: "obsbot_capture_record",
520
1112
  description: "Start recording the camera to an MP4 (for the user). durationSec optional (open-ended " +
521
1113
  "recordings auto-stop after 60 min); audio defaults to on (the OBSBOT mic); outputPath " +
522
- "optional (defaults under Videos\\\\OBSBOT). NOTE: before calling, ensure the camera " +
523
- "is focused (call obsbot_focus with mode:'auto' for autofocus) unless otherwise " +
1114
+ "optional (defaults to ~/Videos/OBSBOT on every platform, including macOS, where that " +
1115
+ "is NOT the usual ~/Movies). NOTE: before calling, ensure the camera " +
1116
+ "is focused (call obsbot_focus_auto for autofocus) unless otherwise " +
524
1117
  "directed. source: device|virtual|ndi. Returns a sessionId " +
525
1118
  "for obsbot_capture_stop.",
526
1119
  schema: recordStartSchema,
@@ -539,9 +1132,9 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
539
1132
  },
540
1133
  },
541
1134
  {
542
- name: "obsbot_preview_start",
1135
+ name: "obsbot_capture_preview",
543
1136
  description: "Open a live preview window of the camera (for the user to watch). NOTE: before calling, " +
544
- "ensure the camera is focused (call obsbot_focus with mode:'auto' for autofocus) unless " +
1137
+ "ensure the camera is focused (call obsbot_focus_auto for autofocus) unless " +
545
1138
  "otherwise directed. source: device|virtual|ndi. " +
546
1139
  "Returns a sessionId for obsbot_capture_stop.",
547
1140
  schema: previewStartSchema,
@@ -582,9 +1175,17 @@ export function createTools(getTransport, mgr, capture, session, debug = false)
582
1175
  },
583
1176
  },
584
1177
  ];
585
- // obsbot_probe is an RE/diagnostics-only tool (raw XU byte access); expose it only
586
- // under --debug so normal deployments don't advertise it. get_status's raw block is
587
- // gated the same way, inside its handler.
588
- return debug ? toolDefs : toolDefs.filter((t) => t.name !== "obsbot_probe");
1178
+ // obsbot_debug_probe is an RE/diagnostics-only tool (raw XU byte access); expose it
1179
+ // only under --debug so normal deployments don't advertise it. get_status's raw
1180
+ // block is gated the same way, inside its handler.
1181
+ const filtered = debug ? toolDefs : toolDefs.filter((t) => t.name !== "obsbot_debug_probe");
1182
+ // obsbot_gimbal_move_speed is hidden on Linux specifically: without live position
1183
+ // feedback there (see LinuxTransport's class comment), a speed×duration burst can't
1184
+ // be verified to stay within the gimbal's mechanical range before it gets there —
1185
+ // unlike an absolute target, which is clamped up front regardless of current
1186
+ // position. obsbot_gimbal_move/obsbot_gimbal_recenter remain fully available.
1187
+ return process.platform === "linux"
1188
+ ? filtered.filter((t) => t.name !== "obsbot_gimbal_move_speed")
1189
+ : filtered;
589
1190
  }
590
1191
  //# sourceMappingURL=tools.js.map