obsbot-mcp 0.7.0 → 0.9.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/README.md +111 -20
- package/dist/build-info.json +5 -0
- package/dist/device/manager.d.ts +8 -0
- package/dist/device/manager.js +37 -4
- package/dist/device/manager.js.map +1 -1
- package/dist/geometry/aim.d.ts +24 -13
- package/dist/geometry/aim.js +26 -13
- package/dist/geometry/aim.js.map +1 -1
- package/dist/ipc/build-id.d.ts +25 -0
- package/dist/ipc/build-id.js +60 -0
- package/dist/ipc/build-id.js.map +1 -0
- package/dist/ipc/client.d.ts +36 -0
- package/dist/ipc/client.js +106 -4
- package/dist/ipc/client.js.map +1 -1
- package/dist/ipc/coordinator.d.ts +91 -12
- package/dist/ipc/coordinator.js +293 -57
- package/dist/ipc/coordinator.js.map +1 -1
- package/dist/ipc/gate.d.ts +14 -0
- package/dist/ipc/gate.js +45 -0
- package/dist/ipc/gate.js.map +1 -0
- package/dist/ipc/owner.d.ts +27 -1
- package/dist/ipc/owner.js +67 -4
- package/dist/ipc/owner.js.map +1 -1
- package/dist/ipc/protocol.d.ts +30 -0
- package/dist/ipc/protocol.js +40 -0
- package/dist/ipc/protocol.js.map +1 -1
- package/dist/ipc/rendezvous.d.ts +37 -11
- package/dist/ipc/rendezvous.js +143 -30
- package/dist/ipc/rendezvous.js.map +1 -1
- package/dist/mcp/server.js +40 -12
- package/dist/mcp/server.js.map +1 -1
- package/dist/tail2/api.d.ts +381 -0
- package/dist/tail2/api.js +547 -0
- package/dist/tail2/api.js.map +1 -0
- package/dist/tail2/mdns.d.ts +48 -0
- package/dist/tail2/mdns.js +216 -0
- package/dist/tail2/mdns.js.map +1 -0
- package/dist/tail2/move.d.ts +41 -0
- package/dist/tail2/move.js +79 -0
- package/dist/tail2/move.js.map +1 -0
- package/dist/tail2/registry.d.ts +100 -0
- package/dist/tail2/registry.js +200 -0
- package/dist/tail2/registry.js.map +1 -0
- package/dist/tail2/snapshot.d.ts +54 -0
- package/dist/tail2/snapshot.js +157 -0
- package/dist/tail2/snapshot.js.map +1 -0
- package/dist/tail2/tools.d.ts +7 -0
- package/dist/tail2/tools.js +772 -0
- package/dist/tail2/tools.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
- package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
- package/native/prebuilt/linux-x64/obsbot-helper +0 -0
- package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
- package/package.json +4 -1
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { srtSnapshot, ndiToolsInstalled } from "./snapshot.js";
|
|
3
|
+
import { moveAbsolute } from "./move.js";
|
|
4
|
+
/**
|
|
5
|
+
* MCP tools for the OBSBOT Tail 2 (network camera — HTTP/WS control; its USB
|
|
6
|
+
* UVC mode is not used here, see TAIL2-PROTOCOL.md §11). A separate tool family from the Tiny 2's:
|
|
7
|
+
* the two cameras share semantics but not transports, and pretending otherwise
|
|
8
|
+
* would make every schema a lie somewhere. Where a tool mirrors a Tiny 2 tool
|
|
9
|
+
* (zoom, recenter, ai tracking) the semantics match on purpose; where the
|
|
10
|
+
* Tail 2 has no Tiny 2 counterpart (portrait rotation, roll bias) the tool is
|
|
11
|
+
* new.
|
|
12
|
+
*
|
|
13
|
+
* The `camera` selector here is a MAC address, device_name, or host — unlike
|
|
14
|
+
* the Tiny 2's serial-number selector. Omitted with exactly one Tail 2
|
|
15
|
+
* registered resolves to it; omitted with none tells the caller how to add
|
|
16
|
+
* one; omitted with several names them all.
|
|
17
|
+
*/
|
|
18
|
+
// Same defensive coercions as the Tiny 2 tools (see src/mcp/tools.ts): some
|
|
19
|
+
// MCP clients serialize numbers/booleans as strings, and z.coerce.boolean()
|
|
20
|
+
// would map "false" to true.
|
|
21
|
+
const num = () => z.preprocess((v) => (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v), z.number());
|
|
22
|
+
const bool = () => z.preprocess((v) => (v === "true" ? true : v === "false" ? false : v), z.boolean());
|
|
23
|
+
const withCamera = (shape) => z.object({ ...shape, camera: z.string().optional() });
|
|
24
|
+
/**
|
|
25
|
+
* Bundle-verified enums from the camera's own web app (TAIL2-PROTOCOL.md §3).
|
|
26
|
+
* `none` is excluded from the tool's mode list — it is what `enabled:false`
|
|
27
|
+
* sends, and advertising it alongside `enabled` would make two ways to say
|
|
28
|
+
* the same thing, one of which is always wrong.
|
|
29
|
+
*/
|
|
30
|
+
const TAIL2_AI_MODES = [
|
|
31
|
+
"humanTrackingSingleMode",
|
|
32
|
+
"humanTrackingGroupMode",
|
|
33
|
+
"animalTrackingNormal",
|
|
34
|
+
"animalTrackingCloseUp",
|
|
35
|
+
"objectTracking",
|
|
36
|
+
"objectTrackingNormal",
|
|
37
|
+
"objectTrackingCloseUp",
|
|
38
|
+
];
|
|
39
|
+
const TAIL2_TRACK_SPEEDS = ["superLazy", "lazy", "slow", "fast", "crazy", "customized"];
|
|
40
|
+
const devicesSchema = z.object({});
|
|
41
|
+
const scanSchema = z.object({});
|
|
42
|
+
const statusSchema = withCamera({});
|
|
43
|
+
const infoSchema = withCamera({});
|
|
44
|
+
const zoomSchema = withCamera({
|
|
45
|
+
ratio: num().pipe(z.number().min(1.0).max(12.0)),
|
|
46
|
+
speed: num().pipe(z.number().int().min(1).max(10)).default(5),
|
|
47
|
+
});
|
|
48
|
+
const recenterSchema = withCamera({});
|
|
49
|
+
const gimbalSpeedSchema = withCamera({
|
|
50
|
+
yaw: num().pipe(z.number().min(-150).max(150)).optional(),
|
|
51
|
+
pitch: num().pipe(z.number().min(-150).max(150)).optional(),
|
|
52
|
+
roll: num().pipe(z.number().min(-150).max(150)).optional(),
|
|
53
|
+
durationMs: num().pipe(z.number().int().min(100).max(5000)).optional(),
|
|
54
|
+
});
|
|
55
|
+
const gimbalMoveSchema = withCamera({
|
|
56
|
+
yaw: num().pipe(z.number().min(-178).max(178)).optional(),
|
|
57
|
+
pitch: num().pipe(z.number().min(-178).max(178)).optional(),
|
|
58
|
+
});
|
|
59
|
+
const gimbalInvertSchema = withCamera({ enable: bool().optional() });
|
|
60
|
+
const portraitSchema = withCamera({ enable: bool() });
|
|
61
|
+
const rollBiasSchema = withCamera({ angle: num().pipe(z.number().min(-90).max(90)) });
|
|
62
|
+
const aiTrackSchema = withCamera({
|
|
63
|
+
enabled: bool(),
|
|
64
|
+
mode: z.enum(TAIL2_AI_MODES).default("humanTrackingSingleMode"),
|
|
65
|
+
});
|
|
66
|
+
const trackSpeedSchema = withCamera({ speed: z.enum(TAIL2_TRACK_SPEEDS) });
|
|
67
|
+
// Preset slots: the Tail 2's API is 0-based (ids 0–2) but the tools expose
|
|
68
|
+
// 1-based slots (1|2|3) to match the Tiny 2's preset tools, so a caller's
|
|
69
|
+
// mental model carries across both cameras.
|
|
70
|
+
const slotSchema = num().pipe(z.number().int().min(1).max(3));
|
|
71
|
+
const presetListSchema = withCamera({});
|
|
72
|
+
const presetSaveSchema = withCamera({ slot: slotSchema, name: z.string().max(40).optional() });
|
|
73
|
+
const presetSlotSchema = withCamera({ slot: slotSchema });
|
|
74
|
+
const presetRenameSchema = withCamera({ slot: slotSchema, name: z.string().max(40) });
|
|
75
|
+
const snapshotSchema = withCamera({
|
|
76
|
+
resolution: num().pipe(z.number().int().min(256).max(1920)).default(640),
|
|
77
|
+
quality: num().pipe(z.number().int().min(1).max(100)).default(80),
|
|
78
|
+
});
|
|
79
|
+
const PRESET_SHAPE_NOTE = `unlike the Tiny 2, save OVERWRITES an occupied slot (no create-once), and ` +
|
|
80
|
+
`there is no explicit-pose write in this API at all — a slot always captures ` +
|
|
81
|
+
`the camera's CURRENT pose (measured 2026-09-26; TAIL2-PROTOCOL.md §3)`;
|
|
82
|
+
const SETTLED_NOTE = `settled:false means the command was sent and acknowledged but the readback ` +
|
|
83
|
+
`had not caught up within the polling budget — the camera moves slower than ` +
|
|
84
|
+
`it acknowledges. Retry the read (or the command) rather than assuming failure.`;
|
|
85
|
+
export function createTail2Tools(registry, grab = (host, o) => srtSnapshot(host, o)) {
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
name: "obsbot_tail2_devices",
|
|
89
|
+
description: "List registered OBSBOT Tail 2 cameras (network cameras; MAC-keyed). Each entry: " +
|
|
90
|
+
"mac (the identity — pass it as `camera` to any obsbot_tail2_* tool), name, and the " +
|
|
91
|
+
"host(s) it has answered on. Registration is not liveness: an offline camera still " +
|
|
92
|
+
"appears here; use obsbot_tail2_status to check one is reachable.",
|
|
93
|
+
schema: devicesSchema,
|
|
94
|
+
handler: async () => ({
|
|
95
|
+
cameras: registry.list().map((e) => ({ mac: e.mac, name: e.name, hosts: e.hosts })),
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "obsbot_tail2_scan",
|
|
100
|
+
description: "Discover OBSBOT Tail 2 cameras on the network and register them. Listens for the " +
|
|
101
|
+
"camera's own mDNS announcements (~5s — it multicasts its MAC, name and IPs every " +
|
|
102
|
+
"few seconds, the same channel OBSBOT Center discovers by) and falls back to an HTTP " +
|
|
103
|
+
"subnet sweep only when nothing was heard (multicast-filtered networks; ~20s on a " +
|
|
104
|
+
"full /24). Returns the cameras found; they stay registered afterwards. Run this " +
|
|
105
|
+
"once to onboard a Tail 2 whose IP you don't know.",
|
|
106
|
+
schema: scanSchema,
|
|
107
|
+
handler: async () => {
|
|
108
|
+
const found = await registry.scan();
|
|
109
|
+
return {
|
|
110
|
+
found: found.map((e) => ({ mac: e.mac, name: e.name, hosts: e.hosts })),
|
|
111
|
+
registered: registry.list().length,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "obsbot_tail2_status",
|
|
117
|
+
description: "Read a Tail 2's full live status block (one WebSocket push): power, recording, " +
|
|
118
|
+
"portrait rotation, AI mode + tracking settings, zoom ratio, roll bias, focus modes, " +
|
|
119
|
+
"NDI/RTSP/SRT/RTMP enablement, SD card, preset list with poses (degrees + zoom " +
|
|
120
|
+
"ratio), and per-subsystem health (gimbal/AI/battery/lens/ToF). NOTE: no live " +
|
|
121
|
+
"yaw/pitch — the Tail 2 reports pose only via saved presets, so gimbal moves are " +
|
|
122
|
+
"open-loop (same limitation as the Tiny 2 on Linux).",
|
|
123
|
+
schema: statusSchema,
|
|
124
|
+
handler: async (args) => {
|
|
125
|
+
const { camera } = statusSchema.parse(args);
|
|
126
|
+
const { api } = await registry.resolve(camera);
|
|
127
|
+
return { camera: (await api.info()).mac, ...(await api.status()) };
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "obsbot_tail2_info",
|
|
132
|
+
description: "Read a Tail 2's identity and static configuration in one call: device_info (name, " +
|
|
133
|
+
"MAC, wired/wireless IPs), range (zoom 1.0–12.0, focus 1–100, exposure, white " +
|
|
134
|
+
"balance 2000–10000K, image adjust ranges), and networkconfig (NDI/stream encoder " +
|
|
135
|
+
"settings). Read-only.",
|
|
136
|
+
schema: infoSchema,
|
|
137
|
+
handler: async (args) => {
|
|
138
|
+
const { camera } = infoSchema.parse(args);
|
|
139
|
+
const { api } = await registry.resolve(camera);
|
|
140
|
+
return {
|
|
141
|
+
device: await api.info(),
|
|
142
|
+
ranges: await api.ranges(),
|
|
143
|
+
network: await api.networkConfig(),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "obsbot_tail2_zoom",
|
|
149
|
+
description: "Set absolute zoom ratio on a Tail 2 (1.0–12.0 — twelve x, six times the Tiny 2's " +
|
|
150
|
+
"range; scale is the camera's own ratio, not a magnification multiple). speed 1–10 " +
|
|
151
|
+
"is REQUIRED by the firmware and defaults to 5. Zoom ramps mechanically; the tool " +
|
|
152
|
+
"polls the readback and returns settled:false if it hadn't arrived within ~5s — the " +
|
|
153
|
+
"command was still sent. Hardware-verified 2026-09-26.",
|
|
154
|
+
schema: zoomSchema,
|
|
155
|
+
handler: async (args) => {
|
|
156
|
+
const { ratio, speed, camera } = zoomSchema.parse(args);
|
|
157
|
+
const { api } = await registry.resolve(camera);
|
|
158
|
+
const r = await api.zoomSet(ratio, speed);
|
|
159
|
+
return { ok: true, ratio: r.ratio, settled: r.settled, note: SETTLED_NOTE };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "obsbot_tail2_recenter",
|
|
164
|
+
description: "Recenter a Tail 2's gimbal (yaw 0 / pitch 0 / roll 0). Returns as soon as the " +
|
|
165
|
+
"command is acknowledged — the Tail 2 exposes no live pose to verify arrival " +
|
|
166
|
+
"against (open-loop, like the Tiny 2 on Linux), and note that commanding a reset " +
|
|
167
|
+
"can drop AI tracking to none (observed once 2026-09-26; re-enable with " +
|
|
168
|
+
"obsbot_tail2_ai_track if that matters). Hardware-verified 2026-09-26.",
|
|
169
|
+
schema: recenterSchema,
|
|
170
|
+
handler: async (args) => {
|
|
171
|
+
const { camera } = recenterSchema.parse(args);
|
|
172
|
+
const { api } = await registry.resolve(camera);
|
|
173
|
+
await api.recenter();
|
|
174
|
+
return { ok: true };
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "obsbot_tail2_gimbal_position",
|
|
179
|
+
description: "Read the Tail 2's live gimbal pose in degrees (yaw/pitch/roll) plus the zoom ratio. " +
|
|
180
|
+
"The API has no direct pose read — this works by saving the current pose into an empty " +
|
|
181
|
+
"preset slot, reading it back, and deleting the slot (takes ~2s). Requires at least one " +
|
|
182
|
+
"empty preset slot: overwriting a preset is irreversible (no pose-by-value write exists " +
|
|
183
|
+
"to restore it). Leftover 'pose-probe' slots from crashed reads are deleted, not trusted.",
|
|
184
|
+
schema: withCamera({}),
|
|
185
|
+
handler: async (args) => {
|
|
186
|
+
const { camera } = withCamera({}).parse(args);
|
|
187
|
+
const { api } = await registry.resolve(camera);
|
|
188
|
+
return await api.readPose();
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "obsbot_tail2_gimbal_speed",
|
|
193
|
+
description: "Jog the Tail 2's gimbal: per-axis SPEED (sign = direction, magnitude = speed, ±150 of " +
|
|
194
|
+
"the firmware's ±178 range) with an automatic stop after durationMs (default 500, max " +
|
|
195
|
+
"5000). This is the joystick primitive (POST ptz/gimbalcontrol) — there is no absolute " +
|
|
196
|
+
"move endpoint, so the camera moves continuously until stopped; this tool always stops " +
|
|
197
|
+
"it. Measured: command 20 ≈ 7.9°/s, and a POSITIVE yaw command DECREASES the recorded " +
|
|
198
|
+
"yaw (obsbot_tail2_gimbal_position's convention). Disable AI tracking first or it will " +
|
|
199
|
+
"fight the move.",
|
|
200
|
+
schema: gimbalSpeedSchema,
|
|
201
|
+
handler: async (args) => {
|
|
202
|
+
const { camera, yaw, pitch, roll, durationMs } = gimbalSpeedSchema.parse(args);
|
|
203
|
+
if (yaw === undefined && pitch === undefined && roll === undefined) {
|
|
204
|
+
throw new Error("give at least one of yaw, pitch, roll (a speed to drive)");
|
|
205
|
+
}
|
|
206
|
+
const { api } = await registry.resolve(camera);
|
|
207
|
+
const t0 = Date.now();
|
|
208
|
+
try {
|
|
209
|
+
await api.gimbalSpeed(yaw ?? 0, pitch ?? 0, roll ?? 0);
|
|
210
|
+
await new Promise((r) => setTimeout(r, durationMs ?? 500));
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await api.gimbalStop();
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, droveMs: Date.now() - t0, stopped: true };
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "obsbot_tail2_gimbal_move",
|
|
220
|
+
description: "Move the Tail 2's gimbal to an absolute yaw/pitch in degrees — the Tail 2's pose-record " +
|
|
221
|
+
"convention (obsbot_tail2_gimbal_position reports the same axes). There is no move-to-" +
|
|
222
|
+
"angle endpoint in the API, so this is a closed loop over the speed primitive: jog, read " +
|
|
223
|
+
"the pose (each read ~2s), correct, up to 5 rounds, tolerance ±1.5°. Slow by design — " +
|
|
224
|
+
"expect ~5-15s. REFUSES while AI tracking is active (tracking drives the gimbal itself " +
|
|
225
|
+
"and would fight the loop); obsbot_tail2_ai_track enabled:false first.",
|
|
226
|
+
schema: gimbalMoveSchema,
|
|
227
|
+
handler: async (args) => {
|
|
228
|
+
const { camera, yaw, pitch } = gimbalMoveSchema.parse(args);
|
|
229
|
+
if (yaw === undefined && pitch === undefined) {
|
|
230
|
+
throw new Error("give at least one of yaw, pitch to move to");
|
|
231
|
+
}
|
|
232
|
+
const { api } = await registry.resolve(camera);
|
|
233
|
+
const { mode } = await api.aiModeGet();
|
|
234
|
+
if (mode !== "none") {
|
|
235
|
+
throw new Error(`AI tracking is active (${mode}) and drives the gimbal itself — it would fight the move. ` +
|
|
236
|
+
`Call obsbot_tail2_ai_track with enabled:false first.`);
|
|
237
|
+
}
|
|
238
|
+
const result = await moveAbsolute({
|
|
239
|
+
readPose: () => api.readPose(),
|
|
240
|
+
speedCmd: (y, p) => api.gimbalSpeed(y, p, 0),
|
|
241
|
+
stop: () => api.gimbalStop(),
|
|
242
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
243
|
+
}, { yaw, pitch });
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
...result,
|
|
247
|
+
note: result.converged
|
|
248
|
+
? undefined
|
|
249
|
+
: "did not converge within the iteration budget — the pose is as close as it got; retry or jog with obsbot_tail2_gimbal_speed",
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "obsbot_tail2_gimbal_invert",
|
|
255
|
+
description: "Read or set the Tail 2's control-direction inversion (gimbalinvert). Call with no " +
|
|
256
|
+
"arguments to read; pass enable to write (verified by readback).",
|
|
257
|
+
schema: gimbalInvertSchema,
|
|
258
|
+
handler: async (args) => {
|
|
259
|
+
const { camera, enable } = gimbalInvertSchema.parse(args);
|
|
260
|
+
const { api } = await registry.resolve(camera);
|
|
261
|
+
if (enable === undefined)
|
|
262
|
+
return await api.gimbalInvertGet();
|
|
263
|
+
const r = await api.gimbalInvertSet(enable);
|
|
264
|
+
return { ok: true, ...r };
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "obsbot_tail2_record",
|
|
269
|
+
description: "Read (bare call) or control (enable) the Tail 2's recording switch over REST. Recording " +
|
|
270
|
+
"needs storage — with no SD card the state still reads (\"off\") but starting will not " +
|
|
271
|
+
"succeed; check obsbot_tail2_status's sdcard field first.",
|
|
272
|
+
schema: withCamera({ enable: bool().optional() }),
|
|
273
|
+
handler: async (args) => {
|
|
274
|
+
const { camera, enable } = withCamera({ enable: bool().optional() }).parse(args);
|
|
275
|
+
const { api } = await registry.resolve(camera);
|
|
276
|
+
if (enable === undefined)
|
|
277
|
+
return await api.recordGet();
|
|
278
|
+
const r = await api.recordSet(enable);
|
|
279
|
+
return { ok: true, ...r };
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "obsbot_tail2_capture_photo",
|
|
284
|
+
description: "Trigger a still-photo capture on the Tail 2 (POST capture/trigger). Needs storage (SD " +
|
|
285
|
+
"card — check obsbot_tail2_status's sdcard field). The ack means the camera accepted the " +
|
|
286
|
+
"trigger; the photo lands on the card (see obsbot_tail2_status / album).",
|
|
287
|
+
schema: withCamera({}),
|
|
288
|
+
handler: async (args) => {
|
|
289
|
+
const { camera } = withCamera({}).parse(args);
|
|
290
|
+
const { api } = await registry.resolve(camera);
|
|
291
|
+
await api.captureTrigger();
|
|
292
|
+
return { ok: true };
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "obsbot_tail2_track_target",
|
|
297
|
+
description: "Tap-to-track: give a normalized frame coordinate (x/y 0.01-0.99, top-left ~0,0) and the " +
|
|
298
|
+
"camera engages AI tracking on the subject there — measured to arm humanTrackingSingleMode " +
|
|
299
|
+
"on its own from mode none. Divide a snapshot pixel by frameWidth/frameHeight and pass it " +
|
|
300
|
+
"straight in. The mode that engages depends on what is at the point, so read it back " +
|
|
301
|
+
"(obsbot_tail2_status ai_mode) when it matters; objectTracking cannot be entered this way " +
|
|
302
|
+
"(it demands a bounding box the API grammar for which is not yet decoded).",
|
|
303
|
+
schema: withCamera({
|
|
304
|
+
x: num().pipe(z.number().min(0.01).max(0.99)),
|
|
305
|
+
y: num().pipe(z.number().min(0.01).max(0.99)),
|
|
306
|
+
}),
|
|
307
|
+
handler: async (args) => {
|
|
308
|
+
const schema = withCamera({
|
|
309
|
+
x: num().pipe(z.number().min(0.01).max(0.99)),
|
|
310
|
+
y: num().pipe(z.number().min(0.01).max(0.99)),
|
|
311
|
+
});
|
|
312
|
+
const { camera, x, y } = schema.parse(args);
|
|
313
|
+
const { api } = await registry.resolve(camera);
|
|
314
|
+
await api.targetSelect(x, y);
|
|
315
|
+
const { mode } = await api.aiModeGet();
|
|
316
|
+
return { ok: true, x, y, aiMode: mode };
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "obsbot_tail2_focus_point",
|
|
321
|
+
description: "Tap-to-focus: move the focus window to a normalized frame coordinate (x/y 0.01-0.99, " +
|
|
322
|
+
"top-left ~0,0) AND start a point focus there. Effective in afc/afs focus modes only — " +
|
|
323
|
+
"in mf the motor position is the tool (obsbot_tail2_focus). Bare call reads the current " +
|
|
324
|
+
"window. Divide a snapshot pixel by frameWidth/frameHeight and pass it straight in.",
|
|
325
|
+
schema: withCamera({
|
|
326
|
+
x: num().pipe(z.number().min(0.01).max(0.99)).optional(),
|
|
327
|
+
y: num().pipe(z.number().min(0.01).max(0.99)).optional(),
|
|
328
|
+
}),
|
|
329
|
+
handler: async (args) => {
|
|
330
|
+
const schema = withCamera({
|
|
331
|
+
x: num().pipe(z.number().min(0.01).max(0.99)).optional(),
|
|
332
|
+
y: num().pipe(z.number().min(0.01).max(0.99)).optional(),
|
|
333
|
+
});
|
|
334
|
+
const { camera, x, y } = schema.parse(args);
|
|
335
|
+
const { api } = await registry.resolve(camera);
|
|
336
|
+
if (x === undefined && y === undefined)
|
|
337
|
+
return await api.focusWindowGet();
|
|
338
|
+
if (x === undefined || y === undefined) {
|
|
339
|
+
throw new Error("x and y go together — give both or neither");
|
|
340
|
+
}
|
|
341
|
+
const { mode } = await api.focusModeGet();
|
|
342
|
+
if (mode === "mf") {
|
|
343
|
+
throw new Error(`focus window applies in afc/afs only (current: ${mode}). ` +
|
|
344
|
+
`In mf, drive the motor with obsbot_tail2_focus position instead.`);
|
|
345
|
+
}
|
|
346
|
+
const r = await api.focusWindowSet(x, y);
|
|
347
|
+
return { ok: true, ...r, pointFocusStarted: true };
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "obsbot_tail2_focus",
|
|
352
|
+
description: "Read (bare call) or set the Tail 2's focus: mode afc|afs|mf, and position (0-100, the " +
|
|
353
|
+
"focus motor) which is only valid in mf — the camera errors on it in afc/afs and this " +
|
|
354
|
+
"tool refuses rather than trigger that. Bare call reports the mode, and the position " +
|
|
355
|
+
"when in mf.",
|
|
356
|
+
schema: withCamera({
|
|
357
|
+
mode: z.enum(["afc", "afs", "mf"]).optional(),
|
|
358
|
+
position: num().pipe(z.number().min(0).max(100)).optional(),
|
|
359
|
+
}),
|
|
360
|
+
handler: async (args) => {
|
|
361
|
+
const { camera, mode, position } = withCamera({
|
|
362
|
+
mode: z.enum(["afc", "afs", "mf"]).optional(),
|
|
363
|
+
position: num().pipe(z.number().min(0).max(100)).optional(),
|
|
364
|
+
}).parse(args);
|
|
365
|
+
const { api } = await registry.resolve(camera);
|
|
366
|
+
const out = {};
|
|
367
|
+
if (mode !== undefined)
|
|
368
|
+
Object.assign(out, await api.focusModeSet(mode));
|
|
369
|
+
const current = await api.focusModeGet();
|
|
370
|
+
if (position !== undefined) {
|
|
371
|
+
if (current.mode !== "mf") {
|
|
372
|
+
throw new Error(`focus position is only valid in mf (current: ${current.mode}). Set mode:"mf" first.`);
|
|
373
|
+
}
|
|
374
|
+
Object.assign(out, await api.focusPositionSet(position));
|
|
375
|
+
}
|
|
376
|
+
else if (current.mode === "mf") {
|
|
377
|
+
// Readable only in mf (HTTP 500 otherwise — measured).
|
|
378
|
+
out.position = (await api.focusPositionGet()).position;
|
|
379
|
+
}
|
|
380
|
+
return { mode: current.mode, ...out };
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: "obsbot_tail2_exposure",
|
|
385
|
+
description: "Read (bare call) or set the Tail 2's exposure. mode manual|auto; in AUTO: face " +
|
|
386
|
+
"(face-priority AE, global|face) and evbias (-3.0..3.0, ~0.3 steps); in MANUAL: iso " +
|
|
387
|
+
"(100-6400) and shutter (\"1/N\", 1/6400..1/30). Manual values only exist in manual mode " +
|
|
388
|
+
"and face/evbias only in auto — a bare call reports whichever set the current mode " +
|
|
389
|
+
"exposes. Note: the camera's manual ISO granularity is finer than the doc claims " +
|
|
390
|
+
"(a live-AE value like 894 reads back).",
|
|
391
|
+
schema: withCamera({
|
|
392
|
+
mode: z.enum(["manual", "auto"]).optional(),
|
|
393
|
+
face: bool().optional(),
|
|
394
|
+
evbias: num().pipe(z.number().min(-3).max(3)).optional(),
|
|
395
|
+
iso: num().pipe(z.number().min(100).max(6400)).optional(),
|
|
396
|
+
shutter: z.string().regex(/^1\/\d+$/).optional(),
|
|
397
|
+
}),
|
|
398
|
+
handler: async (args) => {
|
|
399
|
+
const schema = withCamera({
|
|
400
|
+
mode: z.enum(["manual", "auto"]).optional(),
|
|
401
|
+
face: bool().optional(),
|
|
402
|
+
evbias: num().pipe(z.number().min(-3).max(3)).optional(),
|
|
403
|
+
iso: num().pipe(z.number().min(100).max(6400)).optional(),
|
|
404
|
+
shutter: z.string().regex(/^1\/\d+$/).optional(),
|
|
405
|
+
});
|
|
406
|
+
const { camera, mode, face, evbias, iso, shutter } = schema.parse(args);
|
|
407
|
+
const { api } = await registry.resolve(camera);
|
|
408
|
+
const out = {};
|
|
409
|
+
if (mode !== undefined)
|
|
410
|
+
Object.assign(out, await api.exposureModeSet(mode));
|
|
411
|
+
if (face !== undefined)
|
|
412
|
+
Object.assign(out, await api.exposureAutoModeSet(face ? "face" : "global"));
|
|
413
|
+
if (evbias !== undefined)
|
|
414
|
+
Object.assign(out, await api.exposureEvbiasSet(evbias));
|
|
415
|
+
if (iso !== undefined)
|
|
416
|
+
Object.assign(out, await api.exposureIsoSet(iso));
|
|
417
|
+
if (shutter !== undefined)
|
|
418
|
+
Object.assign(out, await api.exposureShutterSet(shutter));
|
|
419
|
+
// Mode-appropriate reads (the other mode's values are gated off —
|
|
420
|
+
// HTTP 500 — so only what exists now is reported). The AE sub-mode's
|
|
421
|
+
// key is also "mode" — renamed so it cannot clobber the exposure mode.
|
|
422
|
+
const { mode: current } = await api.exposureModeGet();
|
|
423
|
+
const read = { mode: current };
|
|
424
|
+
if (current === "auto") {
|
|
425
|
+
read.autoMode = (await api.exposureAutoModeGet()).mode;
|
|
426
|
+
Object.assign(read, await api.exposureEvbiasGet());
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
Object.assign(read, await api.exposureIsoGet());
|
|
430
|
+
Object.assign(read, await api.exposureShutterGet());
|
|
431
|
+
}
|
|
432
|
+
return { ...read, ...(Object.keys(out).length ? { applied: out } : {}) };
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "obsbot_tail2_image_adjust",
|
|
437
|
+
description: "Read (bare call) or set the Tail 2's image style: brightness/contrast/hue/saturation/" +
|
|
438
|
+
"sharpness, each an absolute 0-100, plus styleMode standard|outdoor|pastel|manual. " +
|
|
439
|
+
"Individual control writes are MODE-GATED (measured): they apply only in styleMode " +
|
|
440
|
+
"manual — HTTP 500 \"style mode is not manual\" otherwise — so this tool applies a " +
|
|
441
|
+
"given styleMode FIRST and refuses a control write in a preset mode. A styleMode switch " +
|
|
442
|
+
"preserves the current values. Bare call returns the full style bundle.",
|
|
443
|
+
schema: withCamera({
|
|
444
|
+
control: z.enum(["brightness", "contrast", "hue", "saturation", "sharpness"]).optional(),
|
|
445
|
+
value: num().pipe(z.number().min(0).max(100)).optional(),
|
|
446
|
+
styleMode: z.enum(["standard", "outdoor", "pastel", "manual"]).optional(),
|
|
447
|
+
}),
|
|
448
|
+
handler: async (args) => {
|
|
449
|
+
const schema = withCamera({
|
|
450
|
+
control: z.enum(["brightness", "contrast", "hue", "saturation", "sharpness"]).optional(),
|
|
451
|
+
value: num().pipe(z.number().min(0).max(100)).optional(),
|
|
452
|
+
styleMode: z.enum(["standard", "outdoor", "pastel", "manual"]).optional(),
|
|
453
|
+
});
|
|
454
|
+
const { camera, control, value, styleMode } = schema.parse(args);
|
|
455
|
+
if ((control === undefined) !== (value === undefined)) {
|
|
456
|
+
throw new Error("control and value go together — give both or neither");
|
|
457
|
+
}
|
|
458
|
+
const { api } = await registry.resolve(camera);
|
|
459
|
+
const out = {};
|
|
460
|
+
if (styleMode !== undefined)
|
|
461
|
+
Object.assign(out, await api.styleModeSet(styleMode));
|
|
462
|
+
if (control !== undefined && value !== undefined) {
|
|
463
|
+
const { mode } = await api.styleGet();
|
|
464
|
+
if (mode !== "manual") {
|
|
465
|
+
throw new Error(`style writes apply only in styleMode manual (current: ${mode}). ` +
|
|
466
|
+
`Pass styleMode:"manual" alongside, or switch first.`);
|
|
467
|
+
}
|
|
468
|
+
Object.assign(out, await api.styleSet(control, value));
|
|
469
|
+
}
|
|
470
|
+
return { ...await api.styleGet(), ...(Object.keys(out).length ? { applied: out } : {}) };
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: "obsbot_tail2_hdr",
|
|
475
|
+
description: "Read (bare call) or set (enabled) the Tail 2's HDR.",
|
|
476
|
+
schema: withCamera({ enabled: bool().optional() }),
|
|
477
|
+
handler: async (args) => {
|
|
478
|
+
const { camera, enabled } = withCamera({ enabled: bool().optional() }).parse(args);
|
|
479
|
+
const { api } = await registry.resolve(camera);
|
|
480
|
+
if (enabled === undefined)
|
|
481
|
+
return await api.hdrGet();
|
|
482
|
+
const r = await api.hdrSet(enabled);
|
|
483
|
+
return { ok: true, ...r };
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: "obsbot_tail2_wb",
|
|
488
|
+
description: "Read (bare call) or set the Tail 2's white balance: mode auto|daylight|fluorescent|" +
|
|
489
|
+
"tungsten|cloudy|manual, plus temperature in Kelvin (2000-10000) which applies in " +
|
|
490
|
+
"manual mode.",
|
|
491
|
+
schema: withCamera({
|
|
492
|
+
mode: z.enum(["auto", "daylight", "fluorescent", "tungsten", "cloudy", "manual"]).optional(),
|
|
493
|
+
temperature: num().pipe(z.number().min(2000).max(10000)).optional(),
|
|
494
|
+
}),
|
|
495
|
+
handler: async (args) => {
|
|
496
|
+
const schema = withCamera({
|
|
497
|
+
mode: z.enum(["auto", "daylight", "fluorescent", "tungsten", "cloudy", "manual"]).optional(),
|
|
498
|
+
temperature: num().pipe(z.number().min(2000).max(10000)).optional(),
|
|
499
|
+
});
|
|
500
|
+
const { camera, mode, temperature } = schema.parse(args);
|
|
501
|
+
const { api } = await registry.resolve(camera);
|
|
502
|
+
if (mode === undefined && temperature === undefined)
|
|
503
|
+
return await api.wbConfigGet();
|
|
504
|
+
const r = await api.wbConfigSet(mode ?? "manual", temperature);
|
|
505
|
+
return { ok: true, ...r };
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: "obsbot_tail2_stream",
|
|
510
|
+
description: "Read (bare call) or set the Tail 2's active network output: ndi|rtsp|srt|off — exactly " +
|
|
511
|
+
"ONE is active at a time (setting srt displaces ndi, and a camera reboot resets to off). " +
|
|
512
|
+
"This is the programmatic way to arm SRT for obsbot_tail2_snapshot without touching " +
|
|
513
|
+
"OBSBOT Center.",
|
|
514
|
+
schema: withCamera({ output: z.enum(["ndi", "rtsp", "srt", "off"]).optional() }),
|
|
515
|
+
handler: async (args) => {
|
|
516
|
+
const { camera, output } = withCamera({
|
|
517
|
+
output: z.enum(["ndi", "rtsp", "srt", "off"]).optional(),
|
|
518
|
+
}).parse(args);
|
|
519
|
+
const { api } = await registry.resolve(camera);
|
|
520
|
+
if (output === undefined)
|
|
521
|
+
return await api.streamControlGet();
|
|
522
|
+
const r = await api.streamControlSet(output);
|
|
523
|
+
return { ok: true, ...r };
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "obsbot_tail2_only_me",
|
|
528
|
+
description: "Read (bare call) or set (enabled) the Tail 2's OnlyMe human-tracking switch — track " +
|
|
529
|
+
"only the nearest/locked person rather than reframing for everyone.",
|
|
530
|
+
schema: withCamera({ enabled: bool().optional() }),
|
|
531
|
+
handler: async (args) => {
|
|
532
|
+
const { camera, enabled } = withCamera({ enabled: bool().optional() }).parse(args);
|
|
533
|
+
const { api } = await registry.resolve(camera);
|
|
534
|
+
if (enabled === undefined)
|
|
535
|
+
return await api.onlyMeGet();
|
|
536
|
+
const r = await api.onlyMeSet(enabled);
|
|
537
|
+
return { ok: true, ...r };
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "obsbot_tail2_audio",
|
|
542
|
+
description: "Read (bare call) or set the Tail 2's audio input: volume 0-100 and mute. Changes apply " +
|
|
543
|
+
"to the encoded/recorded audio stream.",
|
|
544
|
+
schema: withCamera({
|
|
545
|
+
volume: num().pipe(z.number().min(0).max(100)).optional(),
|
|
546
|
+
mute: bool().optional(),
|
|
547
|
+
}),
|
|
548
|
+
handler: async (args) => {
|
|
549
|
+
const schema = withCamera({
|
|
550
|
+
volume: num().pipe(z.number().min(0).max(100)).optional(),
|
|
551
|
+
mute: bool().optional(),
|
|
552
|
+
});
|
|
553
|
+
const { camera, volume, mute } = schema.parse(args);
|
|
554
|
+
const { api } = await registry.resolve(camera);
|
|
555
|
+
const out = {};
|
|
556
|
+
if (volume !== undefined)
|
|
557
|
+
Object.assign(out, await api.audioVolumeSet(volume));
|
|
558
|
+
if (mute !== undefined)
|
|
559
|
+
Object.assign(out, await api.audioMuteSet(mute));
|
|
560
|
+
return {
|
|
561
|
+
...(await api.audioVolumeGet()),
|
|
562
|
+
...(await api.audioMuteGet()),
|
|
563
|
+
...(Object.keys(out).length ? { applied: out } : {}),
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "obsbot_tail2_portrait",
|
|
569
|
+
description: "Rotate a Tail 2's barrel 90° for portrait framing (enable) or back to landscape " +
|
|
570
|
+
"(disable) — a motorized physical rotation no Tiny 2 has. Takes ~1.5s; the tool " +
|
|
571
|
+
"polls the status push for the new orientation and returns settled:false if the " +
|
|
572
|
+
"motor hadn't finished within ~5s. MEASURED 2026-09-26: while the motor is in " +
|
|
573
|
+
"motion the camera can acknowledge a write and silently drop it — if settled stays " +
|
|
574
|
+
"false, retry the command. Hardware-verified both directions.",
|
|
575
|
+
schema: portraitSchema,
|
|
576
|
+
handler: async (args) => {
|
|
577
|
+
const { enable, camera } = portraitSchema.parse(args);
|
|
578
|
+
const { api } = await registry.resolve(camera);
|
|
579
|
+
const r = await api.portraitSet(enable);
|
|
580
|
+
return { ok: true, portrait: r.switch_portrait, settled: r.settled, note: SETTLED_NOTE };
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "obsbot_tail2_roll_bias",
|
|
585
|
+
description: "Set a Tail 2's roll trim angle in degrees (horizon correction / deliberate tilt), " +
|
|
586
|
+
"clamped to ±90. Unlike the Tiny 2 (whose roll slot is inert), this moves the " +
|
|
587
|
+
"image. Verified write+readback on hardware 2026-09-26.",
|
|
588
|
+
schema: rollBiasSchema,
|
|
589
|
+
handler: async (args) => {
|
|
590
|
+
const { angle, camera } = rollBiasSchema.parse(args);
|
|
591
|
+
const { api } = await registry.resolve(camera);
|
|
592
|
+
const r = await api.rollBiasSet(angle);
|
|
593
|
+
return { ok: true, angle: r.angle, settled: r.settled };
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: "obsbot_tail2_ai_track",
|
|
598
|
+
description: "Enable or disable a Tail 2's AI tracking. enabled:false stops tracking (mode `none`). " +
|
|
599
|
+
"enabled:true starts the chosen mode — default humanTrackingSingleMode (single-person " +
|
|
600
|
+
"framing, the camera's power-on default); also group human framing, animal tracking, " +
|
|
601
|
+
"and object tracking (the mode strings are the camera's own enum). NOTE: while " +
|
|
602
|
+
"tracking is active it owns the gimbal — manual moves fight it, and a recenter can " +
|
|
603
|
+
"drop tracking to none. Mode strings verified against firmware 7.2.13.1.",
|
|
604
|
+
schema: aiTrackSchema,
|
|
605
|
+
handler: async (args) => {
|
|
606
|
+
const { enabled, mode, camera } = aiTrackSchema.parse(args);
|
|
607
|
+
const { api } = await registry.resolve(camera);
|
|
608
|
+
const r = await api.aiModeSet(enabled ? mode : "none");
|
|
609
|
+
return {
|
|
610
|
+
ok: true,
|
|
611
|
+
mode: r.mode,
|
|
612
|
+
settled: r.settled,
|
|
613
|
+
...(r.settled ? {} : { note: SETTLED_NOTE }),
|
|
614
|
+
};
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: "obsbot_tail2_track_speed",
|
|
619
|
+
description: "Set a Tail 2's tracking follow speed: superLazy | lazy | slow | fast | crazy | " +
|
|
620
|
+
"customized — the camera's own six-speed enum (NOT the Tiny 2's standard/sport " +
|
|
621
|
+
"pair). Verified by readback on hardware 2026-09-26.",
|
|
622
|
+
schema: trackSpeedSchema,
|
|
623
|
+
handler: async (args) => {
|
|
624
|
+
const { speed, camera } = trackSpeedSchema.parse(args);
|
|
625
|
+
const { api } = await registry.resolve(camera);
|
|
626
|
+
const r = await api.trackSpeedSet(speed);
|
|
627
|
+
return { ok: true, speed: r.speed, settled: r.settled };
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "obsbot_tail2_preset_list",
|
|
632
|
+
description: "List a Tail 2's three preset slots: occupied/empty, decoded name, and pose in " +
|
|
633
|
+
"degrees + zoom ratio (pitch/yaw/roll/ratio). Slot numbers are 1–3 (the camera's " +
|
|
634
|
+
"own ids are 0-based; mapped for consistency with the Tiny 2 preset tools).",
|
|
635
|
+
schema: presetListSchema,
|
|
636
|
+
handler: async (args) => {
|
|
637
|
+
const { camera } = presetListSchema.parse(args);
|
|
638
|
+
const { api } = await registry.resolve(camera);
|
|
639
|
+
const presets = await api.presetsGet();
|
|
640
|
+
return {
|
|
641
|
+
slots: [0, 1, 2].map((id) => {
|
|
642
|
+
const p = presets.find((x) => x.id === id);
|
|
643
|
+
return p
|
|
644
|
+
? { slot: id + 1, occupied: true, name: p.name, pose: { yaw: p.yaw, pitch: p.pitch, roll: p.roll, ratio: p.ratio } }
|
|
645
|
+
: { slot: id + 1, occupied: false, name: null, pose: null };
|
|
646
|
+
}),
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "obsbot_tail2_preset_save",
|
|
652
|
+
description: "Save a Tail 2's CURRENT live pose into a slot (1–3): aim the camera first (via " +
|
|
653
|
+
"tracking, a recall, or physically), then save. name defaults to P1/P2/P3. NOTE: " +
|
|
654
|
+
PRESET_SHAPE_NOTE +
|
|
655
|
+
" — deleting first is unnecessary. Hardware-verified 2026-09-26.",
|
|
656
|
+
schema: presetSaveSchema,
|
|
657
|
+
handler: async (args) => {
|
|
658
|
+
const { slot, name, camera } = presetSaveSchema.parse(args);
|
|
659
|
+
const { api } = await registry.resolve(camera);
|
|
660
|
+
const r = await api.presetSave(slot - 1, name ?? `P${slot}`);
|
|
661
|
+
return { ok: true, slot, settled: r.settled, presets: r.presets };
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
name: "obsbot_tail2_preset_recall",
|
|
666
|
+
description: "Recall a Tail 2 preset slot (1–3): drives the gimbal and zoom to the saved pose. " +
|
|
667
|
+
"Refuses an empty slot. Arrival is verified through the only live observable — the " +
|
|
668
|
+
"zoom ratio from the status push — so gimbal axes are open-loop (the Tail 2 reports " +
|
|
669
|
+
"no live pose). Disable AI tracking first or it will fight the move. " +
|
|
670
|
+
"Hardware-verified 2026-09-26 (zoom 1.5 → saved 3.0 observed).",
|
|
671
|
+
schema: presetSlotSchema,
|
|
672
|
+
handler: async (args) => {
|
|
673
|
+
const { slot, camera } = presetSlotSchema.parse(args);
|
|
674
|
+
const { api } = await registry.resolve(camera);
|
|
675
|
+
const r = await api.presetRecall(slot - 1);
|
|
676
|
+
return { ok: true, slot, settled: r.settled, zoom: r.zoom };
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
name: "obsbot_tail2_preset_delete",
|
|
681
|
+
description: "Delete a Tail 2 preset slot (1–3), freeing it. Verified by the slot disappearing " +
|
|
682
|
+
"from the list.",
|
|
683
|
+
schema: presetSlotSchema,
|
|
684
|
+
handler: async (args) => {
|
|
685
|
+
const { slot, camera } = presetSlotSchema.parse(args);
|
|
686
|
+
const { api } = await registry.resolve(camera);
|
|
687
|
+
const r = await api.presetDelete(slot - 1);
|
|
688
|
+
return { ok: true, slot, settled: r.settled };
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: "obsbot_tail2_preset_rename",
|
|
693
|
+
description: "Rename a Tail 2 preset slot (1–3). Names travel base64 on the wire; the tool " +
|
|
694
|
+
"encodes/decodes transparently (up to 40 chars).",
|
|
695
|
+
schema: presetRenameSchema,
|
|
696
|
+
handler: async (args) => {
|
|
697
|
+
const { slot, name, camera } = presetRenameSchema.parse(args);
|
|
698
|
+
const { api } = await registry.resolve(camera);
|
|
699
|
+
const r = await api.presetRename(slot - 1, name);
|
|
700
|
+
return { ok: true, slot, settled: r.settled };
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
name: "obsbot_tail2_snapshot",
|
|
705
|
+
description: "Grab one still frame from a Tail 2 and return it as an image (for framing/lighting " +
|
|
706
|
+
"checks — and for measuring pixels). Source: the camera's SRT output via ffmpeg SRT " +
|
|
707
|
+
"caller (port 5000) — requires SRT listener mode in OBSBOT Center; exclusive with NDI, " +
|
|
708
|
+
"cleared by reboots, and Center only allows changing streaming settings while the output " +
|
|
709
|
+
"is off. Serves immediately once on. If SRT is off, returns a report of what it found " +
|
|
710
|
+
"with the available options and their costs (SRT toggle, NDI Webcam bridge caveats, " +
|
|
711
|
+
"NDI Tools install) so you can pick the cheapest path instead of dead-ending. " +
|
|
712
|
+
"resolution is the longest edge (256–1920, default 640); quality 1–100 (default 80). " +
|
|
713
|
+
"Needs ffmpeg. Frames are 16:9 1080p-sourced.",
|
|
714
|
+
schema: snapshotSchema,
|
|
715
|
+
handler: async (args) => {
|
|
716
|
+
const { resolution, quality, camera } = snapshotSchema.parse(args);
|
|
717
|
+
const entry = await registry.resolve(camera);
|
|
718
|
+
const status = await entry.api.status();
|
|
719
|
+
if (status.srt?.enable === true) {
|
|
720
|
+
const host = entry.hosts[0];
|
|
721
|
+
const snap = await grab(host, { maxDim: resolution, quality });
|
|
722
|
+
return {
|
|
723
|
+
content: [
|
|
724
|
+
{ type: "image", data: snap.base64, mimeType: snap.mime },
|
|
725
|
+
{
|
|
726
|
+
type: "text",
|
|
727
|
+
text: JSON.stringify({ width: snap.width, height: snap.height, source: "srt" }),
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
// SRT off. The NDI Webcam bridge is deliberately NOT attempted
|
|
733
|
+
// automatically: on the reference machine (2026-09-26) its configured
|
|
734
|
+
// output slots delivered uniformly black frames to ffmpeg while other
|
|
735
|
+
// slots hung — an auto-grab would either stall or return a confident
|
|
736
|
+
// black image. Probing the slots also disrupts the bridge app. Report
|
|
737
|
+
// options instead; SRT remains the autonomous path.
|
|
738
|
+
const ndiOn = status.ndi?.enable === true;
|
|
739
|
+
const tools = ndiToolsInstalled();
|
|
740
|
+
const lines = [
|
|
741
|
+
`No video source is currently grabbable from this Tail 2 (SRT off${ndiOn ? ", NDI on" : ", NDI off"}).`,
|
|
742
|
+
"",
|
|
743
|
+
"Options:",
|
|
744
|
+
`1. Enable SRT listener mode in OBSBOT Center for this camera (streaming settings; the toggle ` +
|
|
745
|
+
`disables NDI while active and is cleared by a camera reboot). Serves immediately once on — ` +
|
|
746
|
+
`this tool then grabs autonomously. Recommended for agent-driven use.`,
|
|
747
|
+
...(tools
|
|
748
|
+
? [
|
|
749
|
+
`2. NDI Webcam bridge (NDI Tools is installed): with NDI on and the bridge running, ` +
|
|
750
|
+
`its 'NDI Webcam' DirectShow output can be read with ffmpeg. NOT attempted automatically: ` +
|
|
751
|
+
`on the reference machine the configured slots delivered black frames and probing the ` +
|
|
752
|
+
`app's devices disrupts it. If you verify a slot carries live video (e.g. OBS can preview ` +
|
|
753
|
+
`it), this becomes a no-camera-changes source — but treat it as unverified until then.`,
|
|
754
|
+
` WARNING: never force-kill the Webcam app (taskkill /f) — that corrupts its output-slot ` +
|
|
755
|
+
`configuration; close it through its own UI.`,
|
|
756
|
+
]
|
|
757
|
+
: []),
|
|
758
|
+
`3. Install NDI Tools (https://ndi.video/tools/) for option 2, or an NDI-capable ffmpeg build.`,
|
|
759
|
+
];
|
|
760
|
+
return {
|
|
761
|
+
content: [
|
|
762
|
+
{
|
|
763
|
+
type: "text",
|
|
764
|
+
text: lines.join("\n"),
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
//# sourceMappingURL=tools.js.map
|