// Node camera commands: list devices, capture photos, and capture short clips through node.invoke. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; import { type CameraArtifactFacing, type CameraFacing, cameraTempPath, parseCameraClipPayload, parseCameraSnapPayload, resolveCameraClipTarget, resolveCameraSnapTargets, writeCameraPayloadToFile, writeCameraClipPayloadToFile, } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { buildNodeInvokeParams, callNodesGatewayCli, nodesCallOpts, parseOptionalNodeFiniteNumber, parseOptionalNodeNonNegativeInteger, parseOptionalNodePositiveInteger, resolveCliNode, resolveCliNodeId, } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; const parseFacing = (value: string): CameraFacing => { const v = normalizeLowercaseStringOrEmpty(normalizeOptionalString(value) ?? ""); if (v === "front" || v === "back") { return v; } throw new Error(`invalid facing: ${value} (expected front|back)`); }; function getGatewayInvokePayload(raw: unknown): unknown { return typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }).payload : undefined; } /** Register node camera list/snap/clip commands. */ export function registerNodesCameraCommands(nodes: Command) { const camera = nodes.command("camera").description("Capture camera media from a paired node"); nodesCallOpts( camera .command("list") .description("List available cameras on a node") .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera list", async () => { const nodeId = await resolveCliNodeId(opts, opts.node ?? ""); const raw = await callNodesGatewayCli( "node.invoke", opts, buildNodeInvokeParams({ nodeId, command: "camera.list", params: {}, }), ); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = typeof res.payload === "object" && res.payload !== null ? (res.payload as { devices?: unknown }) : {}; const devices = Array.isArray(payload.devices) ? payload.devices.filter( (device): device is Record => typeof device === "object" && device !== null && !Array.isArray(device), ) : []; if (opts.json) { defaultRuntime.writeJson(devices); return; } if (devices.length === 0) { const { muted } = getNodesTheme(); defaultRuntime.log(muted("No cameras reported.")); return; } const { heading, muted } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const rows = devices.map((device) => ({ Name: typeof device.name === "string" ? device.name : "Unknown Camera", Position: typeof device.position === "string" ? device.position : muted("unspecified"), ID: typeof device.id === "string" ? device.id : "", })); defaultRuntime.log(heading("Cameras")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Name", header: "Name", minWidth: 14, flex: true }, { key: "Position", header: "Position", minWidth: 10 }, { key: "ID", header: "ID", minWidth: 10, flex: true }, ], rows, }).trimEnd(), ); }); }), { timeoutMs: 60_000 }, ); nodesCallOpts( camera .command("snap") .description("Capture a photo from a node camera (prints the saved path)") .requiredOption("--node ", "Node id, name, or IP") .option("--facing ", "Camera facing") .option("--device-id ", "Camera device id (from nodes camera list)") .option("--max-width ", "Max width in px (optional)") .option("--quality <0-1>", "JPEG quality (optional; platform-specific default)") .option("--delay-ms ", "Delay before capture in ms (optional; platform-specific default)") .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera snap", async () => { const facingOpt = normalizeLowercaseStringOrEmpty( normalizeOptionalString(opts.facing) ?? "", ); const facing = facingOpt === "" ? undefined : facingOpt === "both" || facingOpt === "front" || facingOpt === "back" ? facingOpt : (() => { throw new Error( `invalid facing: ${String(opts.facing)} (expected front|back|both)`, ); })(); const maxWidth = parseOptionalNodePositiveInteger(opts.maxWidth, "--max-width"); const quality = parseOptionalNodeFiniteNumber(opts.quality, "--quality", { minInclusive: 0, maxInclusive: 1, }); const delayMs = parseOptionalNodeNonNegativeInteger(opts.delayMs, "--delay-ms"); const deviceId = normalizeOptionalString(opts.deviceId); const timeoutMs = parseOptionalNodePositiveInteger( opts.invokeTimeout, "--invoke-timeout", ); const node = await resolveCliNode(opts, normalizeOptionalString(opts.node) ?? ""); const nodeId = node.nodeId; if (deviceId && facing === "both" && node.platform?.toLowerCase() !== "linux") { throw new Error("facing=both is not allowed when --device-id is set"); } const targets = resolveCameraSnapTargets({ facing, platform: node.platform, deviceId, }); const captures: Array<{ facing: CameraArtifactFacing; payload: ReturnType; filePath: string; }> = []; for (const target of targets) { const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.snap", params: { ...(target.requestFacing ? { facing: target.requestFacing } : {}), maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, quality: Number.isFinite(quality) ? quality : undefined, format: "jpg", delayMs: Number.isFinite(delayMs) ? delayMs : undefined, deviceId: deviceId || undefined, }, timeoutMs, }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const payload = parseCameraSnapPayload(getGatewayInvokePayload(raw), { expectedHost: node.remoteIp, }); captures.push({ facing: target.artifactFacing, payload, filePath: cameraTempPath({ kind: "snap", facing: target.artifactFacing, ext: payload.format === "jpeg" ? "jpg" : payload.format, }), }); } const results = []; for (const { facing: artifactFacing, payload, filePath } of captures) { await writeCameraPayloadToFile({ filePath, payload, expectedHost: node.remoteIp, invalidPayloadMessage: "invalid camera.snap payload", }); results.push({ facing: artifactFacing, path: filePath, width: payload.width, height: payload.height, }); } if (opts.json) { defaultRuntime.writeJson({ files: results }); return; } defaultRuntime.log(results.map((r) => shortenHomePath(r.path)).join("\n")); }); }), { timeoutMs: 60_000 }, ); nodesCallOpts( camera .command("clip") .description("Capture a short video clip from a node camera (prints the saved path)") .requiredOption("--node ", "Node id, name, or IP") .option("--facing ", "Camera facing", "front") .option("--device-id ", "Camera device id (from nodes camera list)") .option( "--duration ", "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", "3000", ) .option("--no-audio", "Disable audio capture") .option("--invoke-timeout ", "Node invoke timeout in ms (default 90000)", "90000") .action(async (opts: NodesRpcOpts & { audio?: boolean }) => { await runNodesCommand("camera clip", async () => { const facing = parseFacing(opts.facing ?? "front"); const durationMs = parseDurationMs(opts.duration ?? "3000"); const includeAudio = opts.audio !== false; const timeoutMs = parseOptionalNodePositiveInteger( opts.invokeTimeout, "--invoke-timeout", ); const deviceId = normalizeOptionalString(opts.deviceId); const node = await resolveCliNode(opts, normalizeOptionalString(opts.node) ?? ""); const nodeId = node.nodeId; const target = resolveCameraClipTarget({ facing, platform: node.platform }); const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.clip", params: { facing: target.requestFacing, durationMs: Number.isFinite(durationMs) ? durationMs : undefined, includeAudio, format: "mp4", deviceId: deviceId || undefined, }, timeoutMs, }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); const payload = parseCameraClipPayload(getGatewayInvokePayload(raw)); const filePath = await writeCameraClipPayloadToFile({ payload, facing: target.artifactFacing, expectedHost: node.remoteIp, }); if (opts.json) { defaultRuntime.writeJson({ file: { facing: target.artifactFacing, path: filePath, durationMs: payload.durationMs, hasAudio: payload.hasAudio, }, }); return; } defaultRuntime.log(shortenHomePath(filePath)); }); }), { timeoutMs: 90_000 }, ); }