openclaw / src /cli /nodes-cli /register.camera.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
11.2 kB
// 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 <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 },
);
}