| |
| 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; |
| } |
|
|
| |
| 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 <idOrNameOrIp>", "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<string, unknown> => |
| 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 <idOrNameOrIp>", "Node id, name, or IP") |
| .option("--facing <front|back|both>", "Camera facing") |
| .option("--device-id <id>", "Camera device id (from nodes camera list)") |
| .option("--max-width <px>", "Max width in px (optional)") |
| .option("--quality <0-1>", "JPEG quality (optional; platform-specific default)") |
| .option("--delay-ms <ms>", "Delay before capture in ms (optional; platform-specific default)") |
| .option("--invoke-timeout <ms>", "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<typeof parseCameraSnapPayload>; |
| 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 <idOrNameOrIp>", "Node id, name, or IP") |
| .option("--facing <front|back>", "Camera facing", "front") |
| .option("--device-id <id>", "Camera device id (from nodes camera list)") |
| .option( |
| "--duration <ms|10s|1m>", |
| "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", |
| "3000", |
| ) |
| .option("--no-audio", "Disable audio capture") |
| .option("--invoke-timeout <ms>", "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 }, |
| ); |
| } |
|
|