import type { OpenClawPluginApi } from "../plugin-api.js"; import type { AgenticROSConfig } from "@agenticros/core"; import type { TransportConfig } from "@agenticros/core"; import { getTransport, getTransportMode, switchTransport } from "../service.js"; import { clearDiscoveryCache } from "../context/robot-context.js"; const VALID_MODES = ["rosbridge", "local", "webrtc", "zenoh"] as const; type Mode = (typeof VALID_MODES)[number]; function isValidMode(value: string): value is Mode { return (VALID_MODES as readonly string[]).includes(value); } /** * Register the /transport command for runtime transport switching. */ export function registerTransportCommand(api: OpenClawPluginApi, config: AgenticROSConfig): void { api.registerCommand({ name: "transport", description: "Show or switch the ROS2 transport mode (rosbridge, webrtc, local, zenoh)", async handler(ctx) { const args = (ctx.args ?? "").trim(); // No args → show current status if (!args) { return showStatus(); } // Parse mode from first arg const parts = args.split(/\s+/); const modeArg = parts[0]; if (!isValidMode(modeArg)) { return { text: `Unknown transport mode: "${modeArg}". Valid modes: ${VALID_MODES.join(", ")}`, }; } // Build TransportConfig from base config + overrides let transportConfig: TransportConfig; try { transportConfig = buildTransportConfig(modeArg, parts.slice(1), config); } catch (err) { return { text: `Invalid arguments: ${String(err)}` }; } // Perform the switch try { await switchTransport(transportConfig, api.logger); clearDiscoveryCache(); return { text: formatSwitchSuccess(transportConfig) }; } catch (err) { return { text: `Failed to switch transport: ${String(err)}\nYou can retry with /transport ${modeArg}`, }; } }, }); } function showStatus(): { text: string } { const mode = getTransportMode(); if (!mode) { return { text: "Transport: not active" }; } try { const transport = getTransport(); const status = transport.getStatus(); return { text: `Transport: ${mode} (${status})` }; } catch { return { text: `Transport: ${mode} (unknown status)` }; } } /** * Build a TransportConfig from base config + positional/key=value overrides. */ function buildTransportConfig( mode: Mode, overrides: string[], config: AgenticROSConfig, ): TransportConfig { switch (mode) { case "rosbridge": { const base = { ...config.rosbridge }; for (const arg of overrides) { if (arg.startsWith("ws://") || arg.startsWith("wss://")) { // Positional URL override base.url = arg; } else if (arg.includes("=")) { const [key, ...rest] = arg.split("="); const value = rest.join("="); applyOverride(base, key, value); } else { throw new Error(`Unexpected argument: "${arg}"`); } } return { mode: "rosbridge", rosbridge: base }; } case "webrtc": { const base = { ...config.webrtc }; for (const arg of overrides) { if (!arg.includes("=")) { throw new Error(`Unexpected argument: "${arg}". Use key=value format.`); } const [key, ...rest] = arg.split("="); const value = rest.join("="); applyOverride(base, key, value); } return { mode: "webrtc", webrtc: base }; } case "local": { const base = { ...config.local }; for (const arg of overrides) { if (!arg.includes("=")) { throw new Error(`Unexpected argument: "${arg}". Use key=value format.`); } const [key, ...rest] = arg.split("="); const value = rest.join("="); applyOverride(base, key, value); } return { mode: "local", local: base }; } case "zenoh": { const base = { ...config.zenoh }; for (const arg of overrides) { if (arg.includes("=")) { const [key, ...rest] = arg.split("="); const value = rest.join("="); applyOverride(base as Record, key, value); } else if (arg.startsWith("ws://") || arg.startsWith("wss://") || /^[a-z]+\/\S+/.test(arg)) { base.routerEndpoint = arg; } else { throw new Error(`Unexpected argument: "${arg}". Use key=value or router endpoint.`); } } return { mode: "zenoh", zenoh: base }; } } } /** Apply a key=value override to a config object. */ function applyOverride(obj: Record, key: string, value: string): void { if (!(key in obj)) { throw new Error(`Unknown config key: "${key}"`); } const existing = obj[key]; // Coerce value to match existing type if (typeof existing === "number") { const num = Number(value); if (Number.isNaN(num)) { throw new Error(`"${key}" expects a number, got "${value}"`); } obj[key] = num; } else if (typeof existing === "boolean") { obj[key] = value === "true"; } else { obj[key] = value; } } function formatSwitchSuccess(config: TransportConfig): string { switch (config.mode) { case "rosbridge": return `Switched to rosbridge transport (${config.rosbridge.url})`; case "webrtc": { const robotId = config.webrtc.robotId; return `Switched to webrtc transport (robotId: ${robotId})`; } case "local": return `Switched to local transport (domainId: ${config.local?.domainId ?? 0})`; case "zenoh": return `Switched to zenoh transport (${config.zenoh.routerEndpoint})`; default: { const _: never = config; return `Switched to ${(_ as TransportConfig).mode} transport`; } } }