obsbot-mcp 0.4.1 → 0.6.0

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.
package/dist/mcp/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
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";
2
+ import { encodeSetRunStatus, zoomRatioToUnits, encodeAiTrackSpeed, encodeAiTracking, encodeAiMode, encodeFaceAe, encodeVendorProbe, encodeVendorGet, encodeZoomWithSpeed, encodeFaceFocus, encodeSetExposure, decodeStatus, encodeFov, encodeHdr, percentToRange, rangeToPercent, 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
+ import { aimAtPixel, GIMBAL_YAW_LIMIT_DEG, GIMBAL_PITCH_LIMIT_DEG, FOV_MAGNIFICATION, magnificationFromZoomRatio, zoomRatioFromMagnification, MIN_MAGNIFICATION, MAX_MAGNIFICATION, } from "../geometry/aim.js";
3
4
  import { verifyFraming } from "./framing.js";
4
5
  import { parseFrame } from "../codec/frame.js";
5
6
  import { OP_BY_NAME } from "../codec/opcodes.js";
@@ -8,6 +9,61 @@ import { CameraBusyError } from "../transport/transport.js";
8
9
  import { ensureReady, msg } from "./ready.js";
9
10
  import { CaptureError } from "../capture/manager.js";
10
11
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
12
+ /**
13
+ * The camera's total magnification relative to wide, from its reported state.
14
+ *
15
+ * A discrete FOV mode and a continuous zoom are two ways of writing to one
16
+ * scale, so this returns one number either way. Fails (`ok: false`) for
17
+ * anything that cannot be trusted:
18
+ * - `reason: "unknown-fov"` — the status byte didn't decode, a state to
19
+ * refuse on rather than guess at.
20
+ * - `reason: "implausible-zoom"` — a `"custom"` mode whose derived
21
+ * magnification falls outside the camera's known range
22
+ * [MIN_MAGNIFICATION, MAX_MAGNIFICATION] — a corrupt or implausible
23
+ * `zoomPercent` reading (e.g. a garbled status byte reporting
24
+ * zoomPercent > 100). Callers should refuse rather than pass this through:
25
+ * the geometry module's own guard (halfAngleTangents in src/geometry/aim.ts)
26
+ * would otherwise be the only thing standing between a bad reading and a
27
+ * silently wrong — or NaN/Infinity — aim.
28
+ */
29
+ export function resolveMagnification(status) {
30
+ if (status.fovMode === "unknown")
31
+ return { ok: false, reason: "unknown-fov" };
32
+ if (status.fovMode === "custom") {
33
+ const m = magnificationFromZoomRatio(1 + status.zoomPercent / 100);
34
+ if (!Number.isFinite(m) || m < MIN_MAGNIFICATION || m > MAX_MAGNIFICATION) {
35
+ return { ok: false, reason: "implausible-zoom", zoomPercent: status.zoomPercent };
36
+ }
37
+ return { ok: true, magnification: m };
38
+ }
39
+ return { ok: true, magnification: FOV_MAGNIFICATION[status.fovMode] };
40
+ }
41
+ /**
42
+ * Refuse a frameWidth/frameHeight pair that isn't 16:9. Shared by
43
+ * `obsbot_aim_at_pixel` and `obsbot_zoom_to_fit` — both take frame dimensions
44
+ * from the same `obsbot_capture_snapshot` result, and both are equally exposed
45
+ * to a caller that swaps width and height.
46
+ *
47
+ * 16:9 is preserved by the capture path at every resolution (verified at
48
+ * 256x144, 1280x720, 1920x1080), so a non-16:9 pair did not come from that path
49
+ * as-is — it was transposed (width/height swapped) or came from somewhere else
50
+ * entirely, since passing the values through unchanged always yields 16:9. For
51
+ * `obsbot_zoom_to_fit` a transposed pair is worse than a bad aim: it also flips
52
+ * which axis `Math.min(frameWidth/width, frameHeight/height)` selects, so the
53
+ * camera would frame the wrong region at the wrong zoom while still reporting
54
+ * `ok: true`.
55
+ */
56
+ function refuseIfNot169(frameWidth, frameHeight) {
57
+ if (Math.abs(frameWidth / frameHeight - 16 / 9) > 0.02) {
58
+ return {
59
+ ok: false,
60
+ error: `frame ${frameWidth}x${frameHeight} is not 16:9, but obsbot_capture_snapshot always ` +
61
+ `returns 16:9 frames. This looks like frameWidth/frameHeight were transposed, or came ` +
62
+ `from something other than that tool's result.`,
63
+ };
64
+ }
65
+ return null;
66
+ }
11
67
  // Some MCP clients serialize numbers and booleans as strings when the advertised
12
68
  // inputSchema lacks type info. We now advertise a proper JSON Schema (see
13
69
  // mcp/server.ts), but also accept string-encoded values defensively so the tools
@@ -113,6 +169,33 @@ const imageExposureManualSchema = withCamera({
113
169
  level: num().pipe(z.number().min(0).max(100)).default(50),
114
170
  });
115
171
  const gimbalPositionSchema = withCamera({});
172
+ const captureSourceEnum = z.enum(["device", "virtual", "ndi"]);
173
+ // `source` is a DECLARATION, not a selector: these tools never fetch a frame, they
174
+ // are handed one. It exists because the frame's origin is otherwise unknowable —
175
+ // see frameSourceNote — and it defaults to `device`, which is what every caller
176
+ // got implicitly before.
177
+ const aimAtPixelSchema = withCamera({
178
+ x: num().pipe(z.number().finite()),
179
+ y: num().pipe(z.number().finite()),
180
+ frameWidth: num().pipe(z.number().finite().min(1)),
181
+ frameHeight: num().pipe(z.number().finite().min(1)),
182
+ source: captureSourceEnum.default("device"),
183
+ });
184
+ // width/height/x/y are deliberately unconstrained beyond "finite": a negative
185
+ // width or an off-frame region is a valid INPUT (the schema's job), just not a
186
+ // valid REGION (the handler's job, so it can return a structured ok:false with
187
+ // the reason instead of a schema parse throw — matching how aimAtPixelSchema's
188
+ // own x/y-in-frame check works one level up, in the handler, not the schema).
189
+ const zoomToFitSchema = withCamera({
190
+ x: num().pipe(z.number().finite()),
191
+ y: num().pipe(z.number().finite()),
192
+ width: num().pipe(z.number().finite()),
193
+ height: num().pipe(z.number().finite()),
194
+ frameWidth: num().pipe(z.number().finite().min(1)),
195
+ frameHeight: num().pipe(z.number().finite().min(1)),
196
+ margin: num().pipe(z.number().finite().min(0)).default(0.1),
197
+ source: captureSourceEnum.default("device"),
198
+ });
116
199
  const presetListSchema = withCamera({});
117
200
  const presetSaveSchema = withCamera({
118
201
  slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
@@ -149,10 +232,15 @@ const PRESET_NAME_MAX = 40;
149
232
  const snapshotSchema = withCamera({
150
233
  resolution: num().pipe(z.number().min(256).max(1920)).default(640),
151
234
  quality: num().pipe(z.number().min(1).max(100)).default(80),
152
- settleMs: num().pipe(z.number().min(0).max(5000)).default(600),
235
+ // Ceiling raised from 5000 on 2026-07-25. A source that connects lazily needs
236
+ // longer than a UVC camera does to hand over its first frame — NDI Webcam Input
237
+ // measured 4-5s — and 5000 sat right on that threshold, so the one value that
238
+ // worked was also the largest the schema allowed. The helper now budgets its
239
+ // own grace period on top of this, so callers rarely need to raise it at all;
240
+ // the headroom is for the genuinely slow case rather than the normal one.
241
+ settleMs: num().pipe(z.number().min(0).max(15000)).default(600),
153
242
  source: z.enum(["device", "virtual", "ndi"]).default("device"),
154
243
  });
155
- const captureSourceEnum = z.enum(["device", "virtual", "ndi"]);
156
244
  const recordStartSchema = z.object({
157
245
  durationSec: num().pipe(z.number().positive()).optional(),
158
246
  audio: bool().default(true),
@@ -261,6 +349,147 @@ async function getPresetSlots(t) {
261
349
  // window. Latency is the cheapest thing we can spend here.
262
350
  const PRESET_READ_DEFAULTS = { attempts: 3, backoffMs: [200, 500, 1000] };
263
351
  const napMs = (ms) => new Promise((r) => setTimeout(r, ms));
352
+ // Pan/tilt readback is a float now (see transport/linux.ts, transport/macos.ts),
353
+ // which can carry many more digits than the hardware's ±1° accuracy justifies —
354
+ // e.g. 5.975277777777778 from an arc-second division. Round to 2 decimal places
355
+ // for reporting: enough to show sub-degree structure (useful on Linux, where it
356
+ // reflects the last commanded pose) without implying precision the readout
357
+ // doesn't have.
358
+ const round2 = (deg) => Math.round(deg * 100) / 100;
359
+ // motionPollMs is the sensitivity knob for readSteadyStatus: zoomPercent is an
360
+ // integer, so a beat of B ms detects any ramp travelling faster than 1000/B
361
+ // percentage points per second. 80ms => 12.5 pt/s, against a measured UVC ramp of
362
+ // ~42 pt/s (2026-07-25: a full ratio 1->2 sweep took 2397ms) — comfortable margin,
363
+ // while costing every aim only one extra status read and 80ms.
364
+ const ZOOM_SETTLE_DEFAULTS = {
365
+ pollMs: 100,
366
+ timeoutMs: 3000,
367
+ motionPollMs: 80,
368
+ };
369
+ // zoomPercent tracks the zoom's actual travel, not the commanded value, which is
370
+ // what makes it a usable arrival signal — a status read taken right after
371
+ // commanding the write can catch it mid-ramp (observed: commanding ratio 1.5
372
+ // read back zoomPercent 33 in transit before settling at 50). zoomPercent is on
373
+ // the SAME 0-100 scale resolveMagnification reads it on (ratio = 1 + pct/100),
374
+ // so the target is expressed on that scale too, not the raw device units
375
+ // zoomSet takes (those come from zoomRange(), which can differ per device).
376
+ const ZOOM_SETTLE_TOLERANCE_PCT = 1;
377
+ /**
378
+ * Poll the status block until `zoomPercent` shows the zoom has actually arrived
379
+ * at `targetRatio`, or give up after the bound. Returns false rather than
380
+ * throwing on timeout: a camera moving slower than expected (or a hardware zoom
381
+ * that overshoots/undershoots slightly) is information the caller needs, not a
382
+ * failure — the caller decides what to do with `settled:false`, this function
383
+ * just refuses to lie about it.
384
+ *
385
+ * A transient read failure inside the loop is swallowed the same way: by the
386
+ * time this runs, the gimbal move and the zoom write have already happened, so
387
+ * throwing here would lose the `target`/`ratio` the caller needs to know what
388
+ * the camera just did. A read that throws is treated as "not settled yet" —
389
+ * the loop keeps polling until the deadline and resolves `false` rather than
390
+ * rejecting, so the tool still returns its normal result.
391
+ */
392
+ async function waitForZoomSettle(t, targetRatio, opts = {}) {
393
+ const { pollMs, timeoutMs } = { ...ZOOM_SETTLE_DEFAULTS, ...opts };
394
+ const targetPct = (targetRatio - 1) * 100;
395
+ const deadline = Date.now() + timeoutMs;
396
+ for (;;) {
397
+ try {
398
+ const status = decodeStatus(await t.recvStatus());
399
+ if (Math.abs(status.zoomPercent - targetPct) <= ZOOM_SETTLE_TOLERANCE_PCT)
400
+ return true;
401
+ }
402
+ catch {
403
+ // Transient read failure — treat as not-yet-settled rather than propagating.
404
+ }
405
+ if (Date.now() >= deadline)
406
+ return false;
407
+ await napMs(pollMs);
408
+ }
409
+ }
410
+ async function readSteadyStatus(t, opts = {}) {
411
+ const { motionPollMs } = { ...ZOOM_SETTLE_DEFAULTS, ...opts };
412
+ const firstPct = decodeStatus(await t.recvStatus()).zoomPercent;
413
+ await napMs(motionPollMs);
414
+ // The SECOND read is the one handed back: it is the fresher of the two, and by
415
+ // the time it is returned it has been confirmed equal to the first anyway.
416
+ const block = await t.recvStatus();
417
+ const status = decodeStatus(block);
418
+ if (status.zoomPercent !== firstPct) {
419
+ return { ok: false, fromPct: firstPct, toPct: status.zoomPercent };
420
+ }
421
+ return { ok: true, block, status };
422
+ }
423
+ /** The refusal both aiming tools give when {@link readSteadyStatus} sees travel. */
424
+ const zoomMovingError = (r, verb) => `the zoom is still moving (zoomPercent read ${r.fromPct} then ${r.toPct}), so the frame you ` +
425
+ `measured was captured at a magnification the camera has already left — ${verb} from it would ` +
426
+ `be wrong in proportion. Wait for the zoom to finish, take a fresh snapshot, and try again.`;
427
+ /**
428
+ * Read the focus control for {@link obsbot_status}.
429
+ *
430
+ * Focus is a standard UVC control, not a field of the 60-byte vendor status
431
+ * block, so reporting it costs two extra reads. Worth it: obsbot_focus_auto and
432
+ * obsbot_focus_manual were write-only, and answering "is it still in autofocus?"
433
+ * previously meant going underneath the tool surface to the helper's camctrl_get
434
+ * by hand.
435
+ *
436
+ * NEVER THROWS. The status block is what this tool is for, and focus is an
437
+ * addition to it — trading awake/aiMode/zoomPercent for a failed focus query
438
+ * would be a bad deal on any device or platform that cannot answer it. A failure
439
+ * reports mode "unknown" and omits the position, which is also what an
440
+ * unrecognised flags value gets: the same refuse-to-guess rule the FOV and
441
+ * AI-mode decodes already follow.
442
+ *
443
+ * focusPosition is reported ONLY in manual mode, where it is the setpoint and
444
+ * round-trips exactly (write 25, read 25). Under autofocus the device does not
445
+ * expose the motor: MEASURED 2026-07-25, the value stayed pinned at the last
446
+ * written position across a 40-degree pan, a zoom from 3.34x to 1x, and 9s of
447
+ * settling — a scene change that must have re-focused the lens. It is the same
448
+ * setpoint-echo this camera does for pan/tilt. Reporting that number under
449
+ * autofocus would look like a live focus distance while being a stale write, so
450
+ * the field is omitted instead and the caller can tell the difference.
451
+ *
452
+ * In manual mode the position is normalised onto the same 0-100 scale
453
+ * obsbot_focus_manual accepts, so the control reads back in the units it was
454
+ * written in.
455
+ */
456
+ async function readFocus(t) {
457
+ try {
458
+ const { value, flags } = await t.camCtrlGet(CAMERA_CONTROL_FOCUS);
459
+ const focusMode = flags === UVC_FLAG_AUTO ? "auto" : flags === UVC_FLAG_MANUAL ? "manual" : "unknown";
460
+ if (focusMode !== "manual")
461
+ return { focusMode };
462
+ const { min, max } = await t.camCtrlRange(CAMERA_CONTROL_FOCUS);
463
+ return { focusMode, focusPosition: rangeToPercent(value, min, max) };
464
+ }
465
+ catch {
466
+ return { focusMode: "unknown" };
467
+ }
468
+ }
469
+ /**
470
+ * What a non-device frame declaration commits the caller to.
471
+ *
472
+ * obsbot_aim_at_pixel and obsbot_zoom_to_fit compute an angle from the CAMERA's
473
+ * own field of view and magnification, but they only ever receive pixels — there
474
+ * is nothing in x/y/frameWidth/frameHeight from which the frame's origin could be
475
+ * inferred. A frame that OBSBOT Center or OBS has rescaled, cropped or letterboxed
476
+ * therefore yields a confidently wrong aim, reported as a clean success.
477
+ *
478
+ * A blanket refusal would be wrong: a feed configured as a true pass-through aims
479
+ * correctly. MEASURED 2026-07-25 against an OBS -> NDI chain, a dedicated
480
+ * (uncomposited) output at 1080p30 matched the camera's own frame to fill 1.0000
481
+ * and scale 1.03 +/- 0.03, and obsbot_zoom_to_fit framed a target through it
482
+ * correctly. The SAME chain via OBS's canvas measured 0.900 scale with a 96px
483
+ * offset -- wrong by 11% plus a fixed bias -- and looked identical in the reply.
484
+ *
485
+ * So the caller declares the feed and this states the assumption back, where it
486
+ * lands in the transcript rather than in a README nobody re-reads mid-task.
487
+ */
488
+ const frameSourceNote = (source) => `this aim was computed from a '${source}' frame, and is only correct if that feed is an ` +
489
+ `unmodified pass-through of the camera (no rescale, crop, letterbox or reframing) at the same ` +
490
+ `field of view — 1080p60 is a 1.214x crop of 1080p30 on this camera, and a compositor's canvas ` +
491
+ `scaling is invisible in the picture. Verify once by commanding a known gimbal rotation and ` +
492
+ `checking features move by the predicted number of pixels; a 'device' frame needs no such check.`;
264
493
  const allEmpty = (slots) => slots.every((s) => !s.occupied);
265
494
  /**
266
495
  * Read the preset slots with bounded retry, and confirm the one verdict whose error
@@ -302,7 +531,7 @@ async function readPresetSlots(t, gate, opts = {}) {
302
531
  }
303
532
  throw lastErr;
304
533
  }
305
- export function createTools(mgr, capture, debug = false, presetRead = {}) {
534
+ export function createTools(mgr, capture, debug = false, presetRead = {}, zoomSettle = {}) {
306
535
  // Per-camera resolution derived from the manager. Every camera-addressing tool
307
536
  // takes an optional `camera` selector (the serial); it threads through here so
308
537
  // the transport, reconnect controller and readiness gate all target that one
@@ -375,13 +604,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
375
604
  {
376
605
  name: "obsbot_gimbal_move",
377
606
  description: "Move the gimbal to an absolute yaw/pitch angle (degrees); positive yaw pans to the " +
378
- "camera's left, positive pitch tilts down. Yaw is clamped to [-150,150], pitch to " +
379
- "[-90,90]. Absolute positioning (1:1 degrees), verified on hardware.",
607
+ `camera's left, positive pitch tilts down. Yaw is clamped to ` +
608
+ `[-${GIMBAL_YAW_LIMIT_DEG},${GIMBAL_YAW_LIMIT_DEG}], pitch to ` +
609
+ `[-${GIMBAL_PITCH_LIMIT_DEG},${GIMBAL_PITCH_LIMIT_DEG}]. ` +
610
+ "Absolute positioning (1:1 degrees), verified on hardware.",
380
611
  schema: ptzMoveAngleSchema,
381
612
  handler: async (args) => {
382
613
  const parsed = ptzMoveAngleSchema.parse(args);
383
- const yaw = clamp(parsed.yaw, -150, 150);
384
- const pitch = clamp(parsed.pitch, -90, 90);
614
+ const yaw = clamp(parsed.yaw, -GIMBAL_YAW_LIMIT_DEG, GIMBAL_YAW_LIMIT_DEG);
615
+ const pitch = clamp(parsed.pitch, -GIMBAL_PITCH_LIMIT_DEG, GIMBAL_PITCH_LIMIT_DEG);
385
616
  const roll = parsed.roll;
386
617
  const ready = await gate(parsed.camera);
387
618
  if (!ready.ok)
@@ -451,7 +682,11 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
451
682
  name: "obsbot_zoom_uvc",
452
683
  description: "Standard UVC zoom: set an absolute zoom ratio, clamped to [1.0, 2.0]. Snaps to the " +
453
684
  "requested target exactly (unlike obsbot_zoom_vendor, whose ratio scale differs and " +
454
- "may not land exactly where asked).",
685
+ "may not land exactly where asked). Waits for the zoom to actually arrive and returns " +
686
+ "{ settled }: the ramp is not instant (a full 1.0->2.0 sweep takes about 2.4s), and " +
687
+ "obsbot_aim_at_pixel and obsbot_zoom_to_fit both refuse while it is in flight, so this " +
688
+ "returning early would just move the failure downstream. settled:false means the zoom " +
689
+ "had not arrived within the timeout — the command was still sent.",
455
690
  schema: zoomAbsoluteSchema,
456
691
  handler: async (args) => {
457
692
  const parsed = zoomAbsoluteSchema.parse(args);
@@ -459,7 +694,14 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
459
694
  const t = await getTransport(parsed.camera);
460
695
  const { min, max } = await t.zoomRange();
461
696
  await t.zoomSet(zoomRatioToUnits(ratio, min, max));
462
- return { ok: true, ratio };
697
+ // Deliberately NOT done for obsbot_zoom_vendor. waitForZoomSettle needs the
698
+ // target on the zoomPercent scale, and that tool's ratio scale is a different
699
+ // one that "may not land exactly where asked" — so it would report
700
+ // settled:false on a perfectly good zoom. The alternative signal, "zoomPercent
701
+ // stopped changing", reads as stopped during the ~140ms before the ramp starts
702
+ // moving, which would be a worse lie than saying nothing.
703
+ const settled = await waitForZoomSettle(t, ratio, zoomSettle);
704
+ return { ok: true, ratio, settled };
463
705
  },
464
706
  },
465
707
  {
@@ -545,10 +787,19 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
545
787
  },
546
788
  {
547
789
  name: "obsbot_status",
548
- description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed }: " +
790
+ description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed, " +
791
+ "fovMode, zoomPercent, focusMode, focusPosition }: " +
549
792
  "faceAe is whether auto-exposure is metering for a detected face; " +
550
793
  "aiMode is the current AI framing (no-tracking|normal|upper-body|close-up|headless|" +
551
- "lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown. " +
794
+ "lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown; " +
795
+ "fovMode is the field-of-view mode (wide|medium|narrow|custom|unknown), where custom means " +
796
+ "a continuous zoom overrode the discrete modes; zoomPercent is the zoom position, 0-100; " +
797
+ "focusMode is auto|manual|unknown. focusPosition is present ONLY in manual mode, on the " +
798
+ "same 0-100 scale obsbot_focus_manual takes: under autofocus this camera does not expose " +
799
+ "the motor, it echoes the last written value, so reporting it would look like a live " +
800
+ "focus distance while being stale. Focus is a standard UVC control rather than a field " +
801
+ "of the status block, so it costs an extra read; a device that cannot answer it reports " +
802
+ "focusMode unknown rather than failing the whole read. " +
552
803
  "Under --debug the result also carries `raw`: the full 60-byte status block as hex " +
553
804
  "(for reverse-engineering undecoded offsets).",
554
805
  schema: getStatusSchema,
@@ -557,7 +808,12 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
557
808
  const t = await getTransport(camera);
558
809
  try {
559
810
  const block = await t.recvStatus();
560
- return { ok: true, ...decodeStatus(block), ...(debug ? { raw: block.toString("hex") } : {}) };
811
+ return {
812
+ ok: true,
813
+ ...decodeStatus(block),
814
+ ...(await readFocus(t)),
815
+ ...(debug ? { raw: block.toString("hex") } : {}),
816
+ };
561
817
  }
562
818
  catch (e) {
563
819
  return { ok: false, error: `could not read camera status: ${e.message}` };
@@ -696,7 +952,8 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
696
952
  name: "obsbot_gimbal_position",
697
953
  description: "Read the gimbal's current absolute yaw/pitch in degrees (positive yaw = camera's " +
698
954
  "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, " +
955
+ "hardware readout accurate to ±1°, reported rounded to 2 decimal places (finer digits " +
956
+ "would be noise, not precision): it is valid during a move as well as after one, " +
700
957
  "and reflects motion the host did not command (speed moves, recenter, tracking).",
701
958
  schema: gimbalPositionSchema,
702
959
  handler: async (args) => {
@@ -706,7 +963,288 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
706
963
  const tilt = await t.camCtrlGet(CAMERA_CONTROL_TILT);
707
964
  // UVC pan value is degrees, same sign as our yaw (+ = camera-left). UVC tilt
708
965
  // is degrees but positive = up, so negate to match our +pitch = down convention.
709
- return { yaw: pan.value, pitch: -tilt.value };
966
+ return { yaw: round2(pan.value), pitch: round2(-tilt.value) };
967
+ },
968
+ },
969
+ {
970
+ name: "obsbot_aim_at_pixel",
971
+ description: "Point the camera at a specific pixel in a frame you just captured. Give the pixel's x/y " +
972
+ "and the frameWidth/frameHeight from THE SAME obsbot_capture_snapshot result — mixing a " +
973
+ "pixel from one frame with dimensions from another aims at the wrong place and cannot be " +
974
+ "detected. `source` DECLARES which feed the frame came from (default device); it cannot be " +
975
+ "inferred from the pixels. A virtual or ndi frame is accepted, but only aims correctly if " +
976
+ "that feed is an unmodified pass-through of the camera — a compositor's rescale or " +
977
+ "letterbox is invisible in the picture and silently wrong here — so a non-device " +
978
+ "declaration comes back with that assumption stated. Takes no " +
979
+ "field-of-view or zoom argument: it reads the camera's magnification from its reported " +
980
+ "state, a discrete FOV mode or a continuous zoom alike, so it works at any zoom. Refuses " +
981
+ "when AI tracking is active (tracking moves the gimbal itself and would fight the aim), " +
982
+ "when the FOV mode can't be decoded, or when a corrupt zoom reading would resolve to an " +
983
+ "implausible magnification, so it never aims on an assumption it cannot check. If the " +
984
+ "camera was asleep, waking it moves the gimbal and invalidates the " +
985
+ "frame you measured, so the call refuses instead of aiming on stale geometry — take a fresh " +
986
+ "snapshot and retry. Returns clamped:true if the target was outside the gimbal's range; the " +
987
+ "camera still moves, to the nearest reachable pose. Refuses (ok:false) instead of moving when " +
988
+ "the pixel lies past vertical from the current pose — reachable only by an \"over the top\" " +
989
+ "rotation that would swing the camera toward the opposite side of the room, not toward the " +
990
+ "target; tilt toward the pixel first, then re-aim.",
991
+ schema: aimAtPixelSchema,
992
+ handler: async (args) => {
993
+ const { x, y, frameWidth, frameHeight, camera, source } = aimAtPixelSchema.parse(args);
994
+ const aspectRefusal = refuseIfNot169(frameWidth, frameHeight);
995
+ if (aspectRefusal)
996
+ return aspectRefusal;
997
+ if (x < 0 || x > frameWidth || y < 0 || y > frameHeight) {
998
+ return { ok: false, error: `pixel (${x},${y}) is outside the ${frameWidth}x${frameHeight} frame` };
999
+ }
1000
+ // The gate wakes a sleeping camera and waits for it to settle. If it had
1001
+ // to wake the camera, the gimbal just moved out from under the frame the
1002
+ // caller measured — refuse below rather than read a pose that no longer
1003
+ // corresponds to that frame.
1004
+ const ready = await gate(camera);
1005
+ if (!ready.ok)
1006
+ return ready;
1007
+ const t = ready.transport;
1008
+ if (ready.woke) {
1009
+ return {
1010
+ ok: false,
1011
+ error: "the camera was asleep and waking it moved the gimbal, so the frame you measured no " +
1012
+ "longer matches where the camera is pointing. Take a fresh snapshot and aim again.",
1013
+ };
1014
+ }
1015
+ const steady = await readSteadyStatus(t, zoomSettle);
1016
+ if (!steady.ok)
1017
+ return { ok: false, error: zoomMovingError(steady, "aiming") };
1018
+ const { block, status } = steady;
1019
+ if (status.aiMode === "unknown") {
1020
+ return {
1021
+ ok: false,
1022
+ error: "could not read the camera's AI-tracking mode (the status block didn't decode this " +
1023
+ "read — this can happen during a brief mode-switch transient); retry the aim.",
1024
+ };
1025
+ }
1026
+ if (status.aiMode !== "no-tracking") {
1027
+ return {
1028
+ ok: false,
1029
+ error: `AI tracking is active (${status.aiMode}); it moves the gimbal itself and would ` +
1030
+ `fight the aim. Disable it with obsbot_ai_track {enabled:false} first.`,
1031
+ };
1032
+ }
1033
+ // One function decides whether the camera's reported state resolves to a
1034
+ // usable magnification — discrete mode or continuous zoom alike — so there
1035
+ // is exactly one place that decides to refuse rather than guess, AND
1036
+ // exactly one place that decides WHY (see MagnificationResult).
1037
+ const resolved = resolveMagnification(status);
1038
+ if (!resolved.ok) {
1039
+ if (resolved.reason === "unknown-fov") {
1040
+ // block[0x11] is STATUS_OFF_FOV_MODE (src/codec/commands.ts) — same offset
1041
+ // decodeStatus reads to produce fovMode, surfaced raw since "unknown" means
1042
+ // it didn't match any known value.
1043
+ return {
1044
+ ok: false,
1045
+ error: `could not read the camera's FOV mode (raw byte 0x${block[0x11].toString(16).padStart(2, "0")}); ` +
1046
+ `refusing to guess it. Set a known mode with obsbot_image_fov (e.g. {fov:"wide"}) and retry.`,
1047
+ };
1048
+ }
1049
+ // reason "implausible-zoom": fovMode is "custom" but the zoom it derives
1050
+ // from is implausible — e.g. zoomPercent far outside 0-100 — so the
1051
+ // resulting magnification falls outside the camera's known range. That is
1052
+ // a corrupt reading, not a real optical state; refuse rather than aim
1053
+ // from a bad number.
1054
+ return {
1055
+ ok: false,
1056
+ error: `the camera's zoom reading (zoomPercent ${resolved.zoomPercent}) resolves to a ` +
1057
+ `magnification outside the camera's known range [${MIN_MAGNIFICATION}, ` +
1058
+ `${MAX_MAGNIFICATION}]; refusing rather than aim from what looks like a corrupt ` +
1059
+ `status read. Retry, or set a known mode with obsbot_image_fov.`,
1060
+ };
1061
+ }
1062
+ const magnification = resolved.magnification;
1063
+ // Same read path as obsbot_gimbal_position: UVC pan is degrees with our
1064
+ // yaw sign; UVC tilt is degrees but positive = up, so negate it.
1065
+ const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
1066
+ const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
1067
+ // resolveMagnification already folded fovMode and zoomPercent into one
1068
+ // number — a discrete mode's inherent crop and a continuous zoom are two
1069
+ // ways of writing to the SAME scale, so there is nothing left to combine.
1070
+ const aim = aimAtPixel(x, y, { width: frameWidth, height: frameHeight }, { magnification }, { yaw, pitch });
1071
+ // Over the top is not an ordinary clamp: the target ray points behind
1072
+ // the camera's current heading, so the nearest reachable yaw is on the
1073
+ // FAR side of the gimbal's range, not near the target. Moving there
1074
+ // would slew ~150 degrees into the opposite corner of the room while
1075
+ // reporting clamped:true, which reads as "landed short" rather than
1076
+ // "went the wrong way" — refuse instead of moving.
1077
+ if (aim.overTheTop) {
1078
+ return {
1079
+ ok: false,
1080
+ error: `pixel (${x},${y}) lies past vertical from the current pose (yaw ${yaw.toFixed(1)}, ` +
1081
+ `pitch ${pitch.toFixed(1)}); it is not reachable while keeping the image upright, only ` +
1082
+ `by an "over the top" rotation that would swing the camera the opposite way. Tilt toward ` +
1083
+ `the pixel first (e.g. obsbot_gimbal_move), then re-aim from the new pose.`,
1084
+ };
1085
+ }
1086
+ await t.gimbalSet(aim.target.yaw, aim.target.pitch, 0);
1087
+ return {
1088
+ ok: true,
1089
+ target: aim.target,
1090
+ offset: aim.offset,
1091
+ clamped: aim.clamped,
1092
+ fovMode: status.fovMode,
1093
+ current: { yaw, pitch },
1094
+ source,
1095
+ ...(source === "device" ? {} : { note: frameSourceNote(source) }),
1096
+ ...(ready.reconnected ? { reconnected: true } : {}),
1097
+ };
1098
+ },
1099
+ },
1100
+ {
1101
+ name: "obsbot_zoom_to_fit",
1102
+ description: "Frame a region of a frame you just captured: centre the gimbal on it and zoom so the " +
1103
+ "region fills the frame. Give x/y/width/height of the region plus the frameWidth/frameHeight " +
1104
+ "from THE SAME obsbot_capture_snapshot result — mixing a region from one frame with " +
1105
+ "dimensions from another frames the wrong place and cannot be detected. Must come from a " +
1106
+ "snapshot, and takes the same `source` declaration as obsbot_aim_at_pixel. `margin` (default 0.1) " +
1107
+ "backs the zoom off by that fraction so the region isn't framed edge-to-edge; the tighter of " +
1108
+ "the region's two axes decides the zoom, so the WHOLE region stays visible rather than being " +
1109
+ "cropped on one side. Moves the gimbal BEFORE zooming, since zooming first can push the " +
1110
+ "region's centre out of frame. Refuses on the same conditions as obsbot_aim_at_pixel: AI " +
1111
+ "tracking active, the camera was asleep (waking it moves the gimbal and invalidates the " +
1112
+ "frame), the FOV mode can't be decoded, a corrupt zoom reading, or the region's centre lying " +
1113
+ "past vertical from the current pose, or a frame that isn't 16:9 (obsbot_capture_snapshot " +
1114
+ "always returns 16:9; a non-16:9 pair looks transposed). Also refuses a region that isn't " +
1115
+ "within the frame (edges included), or has non-positive width/height. The requested zoom is " +
1116
+ "clamped to the camera's [1x, 4x] magnification range and reported via `clamped`; a partial " +
1117
+ "fit still moves and zooms to the limit. Zoom ramps rather than jumping, so the tool polls " +
1118
+ "the status block for up to 3s waiting for it to arrive and returns `settled:false` (not an " +
1119
+ "error) if it didn't — " +
1120
+ "a frame captured mid-ramp is at an unknown magnification, so check `settled` before trusting " +
1121
+ "a follow-up snapshot.",
1122
+ schema: zoomToFitSchema,
1123
+ handler: async (args) => {
1124
+ const { x, y, width, height, frameWidth, frameHeight, margin, camera, source } = zoomToFitSchema.parse(args);
1125
+ // Pure input validation first, no I/O — same placement as obsbot_aim_at_pixel's
1126
+ // own frame/pixel checks, and the same order: the aspect check runs before
1127
+ // the region-bounds check, matching obsbot_aim_at_pixel.
1128
+ const aspectRefusal = refuseIfNot169(frameWidth, frameHeight);
1129
+ if (aspectRefusal)
1130
+ return aspectRefusal;
1131
+ // The region must lie within the frame, edges included: a region touching
1132
+ // the frame's own edges is valid (a region that already IS the full frame
1133
+ // must pass — the full-frame fit test depends on it), only crossing them
1134
+ // is refused.
1135
+ if (width <= 0 || height <= 0 ||
1136
+ x < 0 || y < 0 ||
1137
+ x + width > frameWidth || y + height > frameHeight) {
1138
+ return {
1139
+ ok: false,
1140
+ error: `region (${x},${y}) ${width}x${height} is not inside the ${frameWidth}x${frameHeight} ` +
1141
+ `frame, or has non-positive width/height.`,
1142
+ };
1143
+ }
1144
+ // The gate wakes a sleeping camera and waits for it to settle. If it had to
1145
+ // wake the camera, the gimbal just moved out from under the frame the caller
1146
+ // measured — refuse below rather than frame a region that no longer means
1147
+ // what it did (same reasoning as obsbot_aim_at_pixel).
1148
+ const ready = await gate(camera);
1149
+ if (!ready.ok)
1150
+ return ready;
1151
+ const t = ready.transport;
1152
+ if (ready.woke) {
1153
+ return {
1154
+ ok: false,
1155
+ error: "the camera was asleep and waking it moved the gimbal, so the frame you measured no " +
1156
+ "longer matches where the camera is pointing. Take a fresh snapshot and try again.",
1157
+ };
1158
+ }
1159
+ const steady = await readSteadyStatus(t, zoomSettle);
1160
+ if (!steady.ok)
1161
+ return { ok: false, error: zoomMovingError(steady, "framing") };
1162
+ const { block, status } = steady;
1163
+ if (status.aiMode === "unknown") {
1164
+ return {
1165
+ ok: false,
1166
+ error: "could not read the camera's AI-tracking mode (the status block didn't decode this " +
1167
+ "read — this can happen during a brief mode-switch transient); retry.",
1168
+ };
1169
+ }
1170
+ if (status.aiMode !== "no-tracking") {
1171
+ return {
1172
+ ok: false,
1173
+ error: `AI tracking is active (${status.aiMode}); it moves the gimbal itself and would ` +
1174
+ `fight the framing. Disable it with obsbot_ai_track {enabled:false} first.`,
1175
+ };
1176
+ }
1177
+ const resolved = resolveMagnification(status);
1178
+ if (!resolved.ok) {
1179
+ if (resolved.reason === "unknown-fov") {
1180
+ return {
1181
+ ok: false,
1182
+ error: `could not read the camera's FOV mode (raw byte 0x${block[0x11].toString(16).padStart(2, "0")}); ` +
1183
+ `refusing to guess it. Set a known mode with obsbot_image_fov (e.g. {fov:"wide"}) and retry.`,
1184
+ };
1185
+ }
1186
+ return {
1187
+ ok: false,
1188
+ error: `the camera's zoom reading (zoomPercent ${resolved.zoomPercent}) resolves to a ` +
1189
+ `magnification outside the camera's known range [${MIN_MAGNIFICATION}, ` +
1190
+ `${MAX_MAGNIFICATION}]; refusing rather than frame from what looks like a corrupt ` +
1191
+ `status read. Retry, or set a known mode with obsbot_image_fov.`,
1192
+ };
1193
+ }
1194
+ const magnification = resolved.magnification;
1195
+ // Same read path as obsbot_gimbal_position / obsbot_aim_at_pixel.
1196
+ const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
1197
+ const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
1198
+ // Aim uses the CURRENT magnification: it has to match the optics the
1199
+ // caller's frame was actually captured at, not the new fitted zoom.
1200
+ const centerX = x + width / 2;
1201
+ const centerY = y + height / 2;
1202
+ const aim = aimAtPixel(centerX, centerY, { width: frameWidth, height: frameHeight }, { magnification }, { yaw, pitch });
1203
+ if (aim.overTheTop) {
1204
+ return {
1205
+ ok: false,
1206
+ error: `region centre (${centerX},${centerY}) lies past vertical from the current pose (yaw ` +
1207
+ `${yaw.toFixed(1)}, pitch ${pitch.toFixed(1)}); it is not reachable while keeping the ` +
1208
+ `image upright, only by an "over the top" rotation that would swing the camera the ` +
1209
+ `opposite way. Tilt toward the region first (e.g. obsbot_gimbal_move), then re-aim.`,
1210
+ };
1211
+ }
1212
+ // required = m * min(frameW/width, frameH/height) / (1+margin). The MIN
1213
+ // (not MAX) is deliberate: it's the axis that needs LESS extra zoom to fill,
1214
+ // and zooming only that far keeps the OTHER axis from overflowing the frame
1215
+ // — i.e. it's what keeps the whole region visible instead of cropping it.
1216
+ // Note the frame's aspect ratio and VERTICAL_TANGENT_CORRECTION (src/geometry/aim.ts)
1217
+ // do NOT appear here: writing out the fit condition on each axis, those terms
1218
+ // scale tanH and tanV identically on both sides and cancel. They matter for
1219
+ // AIMING at the region's centre (already folded into `aim` above via
1220
+ // aimAtPixel) and not at all for how far to zoom.
1221
+ const requiredRaw = (magnification * Math.min(frameWidth / width, frameHeight / height)) / (1 + margin);
1222
+ const requiredMagnification = clamp(requiredRaw, MIN_MAGNIFICATION, MAX_MAGNIFICATION);
1223
+ const fitClamped = requiredMagnification !== requiredRaw;
1224
+ const ratio = zoomRatioFromMagnification(requiredMagnification);
1225
+ // Move BEFORE zoom. Zoom is centre-preserving but not target-preserving:
1226
+ // zooming first can push the region's centre out of frame entirely, after
1227
+ // which the move would be aiming at a pixel that no longer means what it
1228
+ // did when it was measured.
1229
+ await t.gimbalSet(aim.target.yaw, aim.target.pitch, 0);
1230
+ const { min, max } = await t.zoomRange();
1231
+ await t.zoomSet(zoomRatioToUnits(ratio, min, max));
1232
+ // Zoom ramps rather than jumping, so a status read taken immediately after
1233
+ // the write can catch it mid-transit — settled:false is a RESULT for the
1234
+ // caller to check, not thrown as an error, since a slower-than-expected
1235
+ // zoom is information, not a failure of this call.
1236
+ const settled = await waitForZoomSettle(t, ratio, zoomSettle);
1237
+ return {
1238
+ ok: true,
1239
+ target: aim.target,
1240
+ ratio,
1241
+ magnification: requiredMagnification,
1242
+ clamped: fitClamped || aim.clamped,
1243
+ settled,
1244
+ source,
1245
+ ...(source === "device" ? {} : { note: frameSourceNote(source) }),
1246
+ ...(ready.reconnected ? { reconnected: true } : {}),
1247
+ };
710
1248
  },
711
1249
  },
712
1250
  {
@@ -1086,7 +1624,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
1086
1624
  { type: "image", data: snap.base64, mimeType: snap.mime },
1087
1625
  {
1088
1626
  type: "text",
1089
- text: JSON.stringify({ width: snap.width, height: snap.height, source }),
1627
+ text: JSON.stringify({
1628
+ width: snap.width,
1629
+ height: snap.height,
1630
+ source,
1631
+ // Frame rate picks the field of view on this camera, so the
1632
+ // negotiated format is part of what a pixel means. Reported
1633
+ // when the platform helper knows it; see Snapshot.sourceFormat.
1634
+ ...(snap.sourceFormat ? { sourceFormat: snap.sourceFormat } : {}),
1635
+ }),
1090
1636
  },
1091
1637
  ],
1092
1638
  };