obsbot-mcp 0.8.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 +17 -1
- package/dist/build-info.json +3 -3
- package/dist/tail2/api.d.ts +163 -0
- package/dist/tail2/api.js +281 -1
- package/dist/tail2/api.js.map +1 -1
- 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/tools.js +402 -0
- package/dist/tail2/tools.js.map +1 -1
- 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 +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Pose } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Absolute gimbal positioning for the Tail 2, built out of the two primitives
|
|
4
|
+
* the HTTP API actually offers: continuous speed moves (POST ptz/gimbalcontrol)
|
|
5
|
+
* and a slow pose readback (save-scratch-preset → GET). There is no
|
|
6
|
+
* move-to-angle endpoint, so this is a closed loop with a very sluggish
|
|
7
|
+
* sensor — a pose read costs ~1.5 s and the preset list lags the save by up
|
|
8
|
+
* to ~1 s. The loop is therefore deliberately conservative: undershoot each
|
|
9
|
+
* jog, re-read, correct; never let a jog run longer than a few seconds.
|
|
10
|
+
*
|
|
11
|
+
* Two facts from the 2026-09-30 hardware session shape it:
|
|
12
|
+
* - SPEED: yaw command 20 for 1 s moved the recorded pose 7.86°, so roughly
|
|
13
|
+
* 0.39 °/s per command unit (ramps included in that one sample).
|
|
14
|
+
* - SIGN: a POSITIVE yaw command DECREASED the recorded yaw. The command
|
|
15
|
+
* sign per axis is a convention we have measured for yaw only; the loop
|
|
16
|
+
* assumes the same for pitch and self-corrects per axis on contradiction
|
|
17
|
+
* (first jog too far away → flip that axis and continue).
|
|
18
|
+
*/
|
|
19
|
+
export interface MoveDeps {
|
|
20
|
+
readPose(): Promise<Pose>;
|
|
21
|
+
speedCmd(yawUnits: number, pitchUnits: number): Promise<void>;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
sleep(ms: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface MoveTarget {
|
|
26
|
+
yaw?: number;
|
|
27
|
+
pitch?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MoveResult {
|
|
30
|
+
pose: Pose;
|
|
31
|
+
/** Axis error in degrees when the loop finished, by axis. */
|
|
32
|
+
error: {
|
|
33
|
+
yaw: number;
|
|
34
|
+
pitch: number;
|
|
35
|
+
};
|
|
36
|
+
iterations: number;
|
|
37
|
+
converged: boolean;
|
|
38
|
+
/** Axes whose command sign had to be flipped after the first jog. */
|
|
39
|
+
signFlipped: string[];
|
|
40
|
+
}
|
|
41
|
+
export declare function moveAbsolute(deps: MoveDeps, target: MoveTarget): Promise<MoveResult>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Rough °/s per command unit at the speeds this loop uses (measured once, ramps included). */
|
|
2
|
+
const EST_DEG_PER_S_PER_UNIT = 0.39;
|
|
3
|
+
/** Aim to cover this fraction of the remaining error per jog: undershoot, then correct. */
|
|
4
|
+
const COVER_FRACTION = 0.75;
|
|
5
|
+
/** Stop when an axis is within this many degrees. */
|
|
6
|
+
const TOLERANCE_DEG = 1.5;
|
|
7
|
+
const MAX_ITERATIONS = 5;
|
|
8
|
+
const MAX_JOG_MS = 3500;
|
|
9
|
+
const MIN_SPEED_UNITS = 8;
|
|
10
|
+
const MAX_SPEED_UNITS = 40;
|
|
11
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
12
|
+
export async function moveAbsolute(deps, target) {
|
|
13
|
+
// Command sign per axis: measured, a positive yaw command decreased the
|
|
14
|
+
// recorded yaw — so to move the record toward +, command −.
|
|
15
|
+
const sign = { yaw: -1, pitch: -1 };
|
|
16
|
+
const signFlipped = [];
|
|
17
|
+
let pose = await deps.readPose();
|
|
18
|
+
const errOf = (p) => ({
|
|
19
|
+
yaw: target.yaw !== undefined ? target.yaw - p.yaw : 0,
|
|
20
|
+
pitch: target.pitch !== undefined ? target.pitch - p.pitch : 0,
|
|
21
|
+
});
|
|
22
|
+
const err = errOf(pose);
|
|
23
|
+
const prevErr = { ...err };
|
|
24
|
+
let iterations = 0;
|
|
25
|
+
let lastCmd = null;
|
|
26
|
+
while (iterations < MAX_ITERATIONS) {
|
|
27
|
+
const errNow = errOf(pose);
|
|
28
|
+
if (Math.abs(errNow.yaw) <= TOLERANCE_DEG && Math.abs(errNow.pitch) <= TOLERANCE_DEG)
|
|
29
|
+
break;
|
|
30
|
+
if (iterations > 0 && lastCmd) {
|
|
31
|
+
// Self-calibration: if an axis we drove got FARTHER away, our assumed
|
|
32
|
+
// command sign for that axis is wrong. Flip it and continue.
|
|
33
|
+
for (const axis of ["yaw", "pitch"]) {
|
|
34
|
+
if (lastCmd[axis] === 0)
|
|
35
|
+
continue;
|
|
36
|
+
if (Math.abs(errNow[axis]) > Math.abs(prevErr[axis]) + 0.5) {
|
|
37
|
+
sign[axis] = -sign[axis];
|
|
38
|
+
if (!signFlipped.includes(axis))
|
|
39
|
+
signFlipped.push(axis);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Speed from the larger remaining error, undershooting on purpose.
|
|
44
|
+
const worst = Math.max(Math.abs(errNow.yaw), Math.abs(errNow.pitch));
|
|
45
|
+
const units = clamp(worst / EST_DEG_PER_S_PER_UNIT, MIN_SPEED_UNITS, MAX_SPEED_UNITS);
|
|
46
|
+
const duration = clamp((worst * COVER_FRACTION) / (units * EST_DEG_PER_S_PER_UNIT) * 1000, 250, MAX_JOG_MS);
|
|
47
|
+
const cmd = {
|
|
48
|
+
// Command direction: the axis sign convention (Δpose = sign × cmd × k)
|
|
49
|
+
// applied to the error's own sign — toward the target, whichever side
|
|
50
|
+
// of it we are on.
|
|
51
|
+
yaw: Math.abs(errNow.yaw) > TOLERANCE_DEG ? sign.yaw * Math.sign(errNow.yaw) * units : 0,
|
|
52
|
+
pitch: Math.abs(errNow.pitch) > TOLERANCE_DEG
|
|
53
|
+
? sign.pitch * Math.sign(errNow.pitch) * units
|
|
54
|
+
: 0,
|
|
55
|
+
};
|
|
56
|
+
if (cmd.yaw === 0 && cmd.pitch === 0)
|
|
57
|
+
break;
|
|
58
|
+
try {
|
|
59
|
+
await deps.speedCmd(cmd.yaw, cmd.pitch);
|
|
60
|
+
lastCmd = cmd;
|
|
61
|
+
Object.assign(prevErr, errNow);
|
|
62
|
+
await deps.sleep(duration);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await deps.stop();
|
|
66
|
+
}
|
|
67
|
+
iterations++;
|
|
68
|
+
pose = await deps.readPose();
|
|
69
|
+
}
|
|
70
|
+
const errFinal = errOf(pose);
|
|
71
|
+
return {
|
|
72
|
+
pose,
|
|
73
|
+
error: errFinal,
|
|
74
|
+
iterations,
|
|
75
|
+
converged: Math.abs(errFinal.yaw) <= TOLERANCE_DEG && Math.abs(errFinal.pitch) <= TOLERANCE_DEG,
|
|
76
|
+
signFlipped,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=move.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"move.js","sourceRoot":"","sources":["../../src/tail2/move.ts"],"names":[],"mappings":"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"}
|
package/dist/tail2/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { srtSnapshot, ndiToolsInstalled } from "./snapshot.js";
|
|
3
|
+
import { moveAbsolute } from "./move.js";
|
|
3
4
|
/**
|
|
4
5
|
* MCP tools for the OBSBOT Tail 2 (network camera — HTTP/WS control; its USB
|
|
5
6
|
* UVC mode is not used here, see TAIL2-PROTOCOL.md §11). A separate tool family from the Tiny 2's:
|
|
@@ -45,6 +46,17 @@ const zoomSchema = withCamera({
|
|
|
45
46
|
speed: num().pipe(z.number().int().min(1).max(10)).default(5),
|
|
46
47
|
});
|
|
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() });
|
|
48
60
|
const portraitSchema = withCamera({ enable: bool() });
|
|
49
61
|
const rollBiasSchema = withCamera({ angle: num().pipe(z.number().min(-90).max(90)) });
|
|
50
62
|
const aiTrackSchema = withCamera({
|
|
@@ -162,6 +174,396 @@ export function createTail2Tools(registry, grab = (host, o) => srtSnapshot(host,
|
|
|
162
174
|
return { ok: true };
|
|
163
175
|
},
|
|
164
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
|
+
},
|
|
165
567
|
{
|
|
166
568
|
name: "obsbot_tail2_portrait",
|
|
167
569
|
description: "Rotate a Tail 2's barrel 90° for portrait framing (enable) or back to landscape " +
|