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