feat(nao6): add SSH-based posture actions (wake_up, rest, stand, sit, crouch)

- Update plugin with sshCommand payloadMapping type
- Add server-side SSH command execution in robot-communication.ts
- Add client-side SSH command execution in wizard-ros-service.ts
- Update API route to handle executeSSH action
This commit is contained in:
2026-04-01 19:37:28 -04:00
parent 6243b62d3b
commit 27f633fb4b
3 changed files with 151 additions and 5 deletions
+29
View File
@@ -128,6 +128,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
case "executeSSH": {
const { command } = parameters ?? {};
if (!command) {
return NextResponse.json(
{ error: "Missing command parameter" },
{ status: 400 },
);
}
console.log(`[Robots API] Executing SSH command: ${command}`);
const sshCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
try {
const { stdout, stderr } = await execAsync(sshCmd);
if (stderr && !stderr.includes("null") && stderr.trim()) {
console.warn(`[Robots API] SSH stderr: ${stderr}`);
}
console.log(`[Robots API] SSH result: ${stdout}`);
return NextResponse.json({ success: true, stdout, stderr });
} catch (error) {
console.error(`[Robots API] SSH command failed:`, error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "SSH command failed" },
{ status: 500 },
);
}
}
default: default:
return NextResponse.json( return NextResponse.json(
{ error: `Unknown action: ${action}` }, { error: `Unknown action: ${action}` },
+30
View File
@@ -718,11 +718,18 @@ export class WizardRosService extends EventEmitter {
transformFn?: string; transformFn?: string;
service?: string; service?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
sshCommand?: string;
}; };
}, },
parameters: Record<string, unknown>, parameters: Record<string, unknown>,
actionId?: string, actionId?: string,
): Promise<void> { ): Promise<void> {
// SSH command actions
if (config.payloadMapping.type === "ssh" && config.payloadMapping.sshCommand) {
await this.executeSSHCommand(config.payloadMapping.sshCommand);
return;
}
// Service-call actions — no topic publish involved // Service-call actions — no topic publish involved
if (config.payloadMapping.type === "service") { if (config.payloadMapping.type === "service") {
const service = config.payloadMapping.service ?? config.topic; const service = config.payloadMapping.service ?? config.topic;
@@ -1068,6 +1075,29 @@ export class WizardRosService extends EventEmitter {
console.log(`[WizardROS] Animation completed: ${animation}`); console.log(`[WizardROS] Animation completed: ${animation}`);
} }
/**
* Execute an arbitrary SSH command via the API
*/
private async executeSSHCommand(command: string): Promise<void> {
console.log(`[WizardROS] Executing SSH command: ${command}`);
const response = await fetch("/api/robots/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "executeSSH",
command,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`SSH command failed: ${error}`);
}
console.log(`[WizardROS] SSH command completed: ${command}`);
}
/** /**
* Set Autonomous Life state with fallbacks * Set Autonomous Life state with fallbacks
*/ */
+88 -1
View File
@@ -26,6 +26,22 @@ export interface RobotAction {
topic: string; topic: string;
messageType: string; messageType: string;
messageTemplate: Record<string, unknown>; messageTemplate: Record<string, unknown>;
payloadMapping?: {
type?: string;
transformFn?: string;
payload?: Record<string, unknown>;
};
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: {
type?: string;
transformFn?: string;
payload?: Record<string, unknown>;
};
};
}; };
} }
@@ -238,11 +254,39 @@ export class RobotCommunicationService extends EventEmitter {
return; return;
} }
// Check for SSH command type
const sshCommand = implementation.payloadMapping?.sshCommand
|| implementation.ros2?.payloadMapping?.sshCommand;
if (sshCommand) {
this.executeSSHCommand(sshCommand).then(() => {
this.completeAction(actionId, {
success: true,
duration:
Date.now() -
(this.pendingActions.get(actionId)?.startTime || Date.now()),
data: { method: "ssh", command: sshCommand },
});
}).catch((error) => {
this.pendingActions.get(actionId)?.reject(error);
});
return;
}
// Apply transform if specified
let message: Record<string, unknown>;
const transformFn = implementation.payloadMapping?.transformFn
|| implementation.ros2?.payloadMapping?.transformFn;
if (transformFn) {
message = this.applyTransform(transformFn, parameters);
} else {
// Build ROS message from template // Build ROS message from template
const message = this.buildRosMessage( message = this.buildRosMessage(
implementation.messageTemplate, implementation.messageTemplate,
parameters, parameters,
); );
}
// Publish to ROS topic // Publish to ROS topic
this.publishToTopic( this.publishToTopic(
@@ -268,6 +312,23 @@ export class RobotCommunicationService extends EventEmitter {
}, 100); }, 100);
} }
private async executeSSHCommand(command: string): Promise<void> {
const robotIp = process.env.NAO_IP || "134.82.159.168";
const password = process.env.NAO_PASSWORD || "robolab";
console.log(`[RobotComm] Executing SSH command: ${command}`);
const sshCommand = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
const { stdout, stderr } = await execAsync(sshCommand);
if (stderr && !stderr.includes("null") && stderr.trim()) {
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
}
console.log(`[RobotComm] SSH result: ${stdout}`);
}
private async executeAnimationViaSSH(actionType: string): Promise<void> { private async executeAnimationViaSSH(actionType: string): Promise<void> {
const animationMap: Record<string, string> = { const animationMap: Record<string, string> = {
"play_animation_bow": "animations/Stand/Gestures/BowShort_1", "play_animation_bow": "animations/Stand/Gestures/BowShort_1",
@@ -300,6 +361,32 @@ export class RobotCommunicationService extends EventEmitter {
console.log(`[RobotComm] Animation result: ${stdout}`); console.log(`[RobotComm] Animation result: ${stdout}`);
} }
private transformToEmotionalSpeech(parameters: Record<string, unknown>): { data: string } {
const text = String(parameters.text || "Hello");
const emotion = String(parameters.emotion || "neutral");
switch (emotion) {
case "happy":
return { data: `\\rspd=120\\ ${text}` };
case "sad":
return { data: `\\rspd=80\\ ${text}` };
case "neutral":
default:
return { data: text };
}
}
private applyTransform(transformFn: string, parameters: Record<string, unknown>): Record<string, unknown> {
switch (transformFn) {
case "transformToEmotionalSpeech":
case "transformToEmotionSpeech":
return this.transformToEmotionalSpeech(parameters);
default:
console.warn(`[RobotComm] Unknown transform: ${transformFn}`);
return parameters;
}
}
private buildRosMessage( private buildRosMessage(
template: Record<string, unknown>, template: Record<string, unknown>,
parameters: Record<string, unknown>, parameters: Record<string, unknown>,