feat: add node location support

This commit is contained in:
Peter Steinberger
2026-01-04 00:54:44 +01:00
parent 52f59e6dc1
commit e1dd764504
32 changed files with 1398 additions and 8 deletions

View File

@@ -1217,6 +1217,22 @@ const NodesToolSchema = Type.Union([
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("location_get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: Type.Optional(
Type.Union([
Type.Literal("coarse"),
Type.Literal("balanced"),
Type.Literal("precise"),
]),
),
}),
]);
function createNodesTool(): AnyAgentTool {
@@ -1224,7 +1240,7 @@ function createNodesTool(): AnyAgentTool {
label: "Nodes",
name: "nodes",
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen).",
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -1516,6 +1532,36 @@ function createNodesTool(): AnyAgentTool {
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
}

View File

@@ -158,4 +158,47 @@ describe("nodes-cli coverage", () => {
delivery: "overlay",
});
});
it("invokes location.get with params", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerNodesCli } = await import("./nodes-cli.js");
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
[
"nodes",
"location",
"get",
"--node",
"mac-1",
"--accuracy",
"precise",
"--max-age",
"1000",
"--location-timeout",
"5000",
"--invoke-timeout",
"6000",
],
{ from: "user" },
);
const invoke = callGateway.mock.calls.find(
(call) => call[0]?.method === "node.invoke",
)?.[0];
expect(invoke).toBeTruthy();
expect(invoke?.params?.command).toBe("location.get");
expect(invoke?.params?.params).toEqual({
maxAgeMs: 1000,
desiredAccuracy: "precise",
timeoutMs: 5000,
});
expect(invoke?.params?.timeoutMs).toBe(6000);
});
});

View File

@@ -45,6 +45,9 @@ type NodesRpcOpts = {
quality?: string;
delayMs?: string;
deviceId?: string;
maxAge?: string;
accuracy?: string;
locationTimeout?: string;
duration?: string;
screen?: string;
fps?: string;
@@ -1204,4 +1207,101 @@ export function registerNodesCli(program: Command) {
}),
{ timeoutMs: 180_000 },
);
const location = nodes
.command("location")
.description("Fetch location from a paired node");
nodesCallOpts(
location
.command("get")
.description("Fetch the current location from a node")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--max-age <ms>", "Use cached location newer than this (ms)")
.option(
"--accuracy <coarse|balanced|precise>",
"Desired accuracy (default: balanced/precise depending on node setting)",
)
.option("--location-timeout <ms>", "Location fix timeout (ms)", "10000")
.option(
"--invoke-timeout <ms>",
"Node invoke timeout in ms (default 20000)",
"20000",
)
.action(async (opts: NodesRpcOpts) => {
try {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const maxAgeMs = opts.maxAge
? Number.parseInt(String(opts.maxAge), 10)
: undefined;
const desiredAccuracyRaw =
typeof opts.accuracy === "string"
? opts.accuracy.trim().toLowerCase()
: undefined;
const desiredAccuracy =
desiredAccuracyRaw === "coarse" ||
desiredAccuracyRaw === "balanced" ||
desiredAccuracyRaw === "precise"
? desiredAccuracyRaw
: undefined;
const timeoutMs = opts.locationTimeout
? Number.parseInt(String(opts.locationTimeout), 10)
: undefined;
const invokeTimeoutMs = opts.invokeTimeout
? Number.parseInt(String(opts.invokeTimeout), 10)
: undefined;
const invokeParams: Record<string, unknown> = {
nodeId,
command: "location.get",
params: {
maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : undefined,
desiredAccuracy,
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
},
idempotencyKey: randomIdempotencyKey(),
};
if (
typeof invokeTimeoutMs === "number" &&
Number.isFinite(invokeTimeoutMs)
) {
invokeParams.timeoutMs = invokeTimeoutMs;
}
const raw = (await callGatewayCli(
"node.invoke",
opts,
invokeParams,
)) as unknown;
const res =
typeof raw === "object" && raw !== null
? (raw as { payload?: unknown })
: {};
const payload =
res.payload && typeof res.payload === "object"
? (res.payload as Record<string, unknown>)
: {};
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
return;
}
const lat = payload.lat;
const lon = payload.lon;
const acc = payload.accuracyMeters;
if (typeof lat === "number" && typeof lon === "number") {
const accText =
typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : "";
defaultRuntime.log(`${lat},${lon}${accText}`);
return;
}
defaultRuntime.log(JSON.stringify(payload));
} catch (err) {
defaultRuntime.error(`nodes location get failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
{ timeoutMs: 30_000 },
);
}