23 Commits

Author SHA1 Message Date
soconnor 5b5490cb90 fix(wizard-ros): use executeSSH for animations to bypass studyId requirement 2026-04-01 19:48:40 -04:00
soconnor 6b98cad53e fix(api): add missing posture actions (stand, stand_init, sit, crouch) 2026-04-01 19:46:31 -04:00
soconnor 3e2aa894a0 fix(trial-execution): handle SSH actions without requiring ROS connection
- Add startTime parameter to executeRobotActionInternal for proper duration tracking
- SSH actions (animations, posture commands) now work without ROS bridge connection
- Refactor executeAction to handle SSH and ROS paths separately
2026-04-01 19:44:08 -04:00
soconnor 27f633fb4b 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
2026-04-01 19:37:28 -04:00
soconnor 6243b62d3b Fix robot action ID namespacing for animation detection 2026-04-01 19:34:27 -04:00
soconnor f16dd4aa22 fix: handle namespaced action IDs in animation execution 2026-04-01 19:30:51 -04:00
soconnor 7483e4a72b fix: remove double-escaped NAOqi markup from speech transforms 2026-04-01 19:30:21 -04:00
soconnor 426b5e761b fix: allow timeoutMs=0 for wait blocks 2026-04-01 19:28:09 -04:00
soconnor cf21a27995 fix(ui): add number input for sliders, use textarea for text inputs
- Allow typing numbers directly in slider inputs
- Use textarea for text parameters like say_text
2026-04-01 19:20:42 -04:00
soconnor 74b5507769 fix: add transformToEmotionSpeech alias 2026-04-01 19:16:36 -04:00
soconnor 5c67fc106e chore: update robot-plugins submodule 2026-04-01 19:11:30 -04:00
soconnor 4b04f2c415 fix(nao6): route /animation via SSH, clean up working animations
- Fix executeWithConfig to route play_animation actions through SSH
- Remove broken animations: friendly, think, show_sole
- Keep working: bow, hey, show_floor, enthusiastic, yes, no, idontknow
2026-04-01 19:06:21 -04:00
soconnor c959e61f95 fix(wizard): use API route for animations instead of ROS topic
- Add executeAnimationSSH that calls /api/robots/command
- Remove ROS topic publishing for animations
- Fix play_animation_show_sole -> play_animation_friendly
2026-04-01 18:58:26 -04:00
soconnor de1b125b13 feat(nao6): add SSH-based animation execution for NAO6 robot
- Add play_animation actions to robots/command API using qicli SSH
- Add SSH-based animation execution to robot-communication service
- Animations: bow, hey, show_floor, show_sole, enthusiastic, think, yes, no, idontknow

This bypasses ROS2 cross-container issues by using direct SSH connection.
2026-04-01 18:51:40 -04:00
soconnor 143cf2ce50 chore: update robot-plugins with animation actions 2026-04-01 18:35:50 -04:00
soconnor 61c7cc1e94 feat(ros): add animation topic handler for play_animation actions 2026-04-01 18:35:45 -04:00
soconnor 8f330cf5f0 feat(ros): add animation handler and fix gesture action pipeline
- Add AnimationMovement interface: { joint_names, joint_angles, speed?,
  delay_after? } for describing individual frames in a joint animation
- Add executeAnimationSequence() public method: steps through frames,
  publishing each to /joint_angles with configurable per-frame delays
- Add executeSimulationAnimationSequence() for mock/sim mode
- Fix subscribeToRobotTopics: advertise /joint_angles as
  naoqi_bridge_msgs/msg/JointAnglesWithSpeed — without this rosbridge
  silently dropped every gesture publish (root cause of broken gestures)
- Fix executeBuiltinAction: correct ROS2 message type for /joint_angles
  (was naoqi_bridge_msgs/JointAnglesWithSpeed, ROS1 format)
- Add service payloadMapping type to executeWithConfig: routes actions
  to callService() instead of publish() — used by wake_up/rest
- Add wake_up/rest fallback service call chains in executeBuiltinAction
- Route gesture_sequence payloads through executeAnimationSequence
  instead of the old inline loop (which used a broken delay formula)
- Improve sim mode to handle gesture_sequence configs with realistic timing
- Update robot-plugins submodule pointer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 18:25:32 -04:00
soconnor 254805008e feat: add gesture sequence support in wizard-ros-service
- Add transformToGestureSequence for multi-movement gestures
- Update executeWithConfig to handle gesture sequences
- Add sequence execution with delays between movements
- Fix experiment description to be optional
2026-04-01 17:09:33 -04:00
soconnor c923c63099 chore: update robot-plugins submodule 2026-04-01 17:06:07 -04:00
soconnor c05384d1a0 feat: Add Test Action button, fix ros2 config copying, fix transform functions
- Add Test Action button in experiment designer properties panel
- Fix DesignerRoot to copy full ros2 config when adding actions
- Add transformToWaveGoodbye and transformToAnimation cases
- Fix escape sequences for NAOqi markup
- Update TrialForm with FormSection, sidebar, and visible validation
- Add db:reset and db:restart scripts
- Update docker-compose with configurable PostgreSQL and MinIO vars
2026-04-01 17:00:03 -04:00
soconnor c0e5a4ffb8 chore: update robot-plugins with beckon joint angle fix 2026-04-01 16:33:27 -04:00
soconnor 51aaaa5208 chore: update robot-plugins with valid animation names 2026-04-01 16:28:09 -04:00
soconnor e402c51483 chore: update robot-plugins submodule to latest with gesture actions 2026-04-01 16:05:03 -04:00
13 changed files with 1038 additions and 358 deletions
+10 -2
View File
@@ -16,11 +16,19 @@
AUTH_SECRET="" AUTH_SECRET=""
# Drizzle # Drizzle
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio" DATABASE_URL="postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@localhost:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-hristudio}"
# PostgreSQL (used by docker-compose)
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="hristudio"
POSTGRES_PORT="5432"
# MinIO/S3 Configuration # MinIO/S3 Configuration
MINIO_ENDPOINT="http://localhost:9000" MINIO_ENDPOINT="http://localhost:${MINIO_PORT_API:-9000}"
MINIO_REGION="us-east-1" MINIO_REGION="us-east-1"
MINIO_ACCESS_KEY="minioadmin" MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin" MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET_NAME="hristudio-data" MINIO_BUCKET_NAME="hristudio-data"
MINIO_PORT_API="9000"
MINIO_PORT_CONSOLE="9001"
+11 -11
View File
@@ -2,13 +2,13 @@ services:
db: db:
image: postgres:15 image: postgres:15
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: hristudio POSTGRES_DB: ${POSTGRES_DB:-hristudio}
PGSSLMODE: disable PGSSLMODE: disable
command: -c ssl=off command: -c ssl=off
ports: ports:
- "5140:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -20,11 +20,11 @@ services:
minio: minio:
image: minio/minio image: minio/minio
ports: ports:
- "9000:9000" # API - "${MINIO_PORT_API:-9000}:9000" # API
- "9001:9001" # Console - "${MINIO_PORT_CONSOLE:-9001}:9001" # Console
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
volumes: volumes:
- minio_data:/data - minio_data:/data
command: server --console-address ":9001" /data command: server --console-address ":9001" /data
@@ -35,9 +35,9 @@ services:
- minio - minio
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; /usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ACCESS_KEY:-minioadmin} ${MINIO_SECRET_KEY:-minioadmin};
/usr/bin/mc mb myminio/hristudio-data; /usr/bin/mc mb myminio/${MINIO_BUCKET_NAME:-hristudio-data};
/usr/bin/mc anonymous set public myminio/hristudio-data; /usr/bin/mc anonymous set public myminio/${MINIO_BUCKET_NAME:-hristudio-data};
exit 0; exit 0;
" "
+9 -5
View File
@@ -5,22 +5,26 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "bun db:push && bun scripts/seed-dev.ts", "db:seed": "bun db:push && bun scripts/seed-dev.ts",
"dev": "bun run ws-server.ts & next dev --turbo", "db:reset": "docker compose rm -s -f -v db && docker compose up -d db && sleep 2 && bun db:seed",
"db:restart": "docker compose restart db",
"dev": "bun run dev:ws & next dev",
"dev:ws": "bun run ws-server.ts", "dev:ws": "bun run ws-server.ts",
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d", "docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi", "docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "eslint .",
"lint:fix": "next lint --fix", "lint:fix": "eslint . --fix",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "bun run start:ws & next start",
"start:ws": "bun run ws-server.ts",
"start:web": "next start",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+73
View File
@@ -89,6 +89,50 @@ export async function POST(request: NextRequest) {
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`; command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
break; break;
case "play_animation_bow":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/BowShort_1'"`;
break;
case "play_animation_hey":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Hey_1'"`;
break;
case "play_animation_show_floor":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/ShowFloor_1'"`;
break;
case "play_animation_enthusiastic":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Enthusiastic_4'"`;
break;
case "play_animation_yes":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Yes_1'"`;
break;
case "play_animation_no":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/No_3'"`;
break;
case "play_animation_idontknow":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/IDontKnow_1'"`;
break;
case "stand":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Stand 0.5"`;
break;
case "stand_init":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture StandInit 0.5"`;
break;
case "sit":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Sit 0.5"`;
break;
case "crouch":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Crouch 0.5"`;
break;
default: default:
return NextResponse.json( return NextResponse.json(
{ error: `System action ${id} not implemented` }, { error: `System action ${id} not implemented` },
@@ -100,6 +144,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}` },
@@ -34,8 +34,8 @@ const experimentSchema = z.object({
.max(100, "Name too long"), .max(100, "Name too long"),
description: z description: z
.string() .string()
.min(10, "Description must be at least 10 characters") .max(1000, "Description too long")
.max(1000, "Description too long"), .optional(),
studyId: z.string().uuid("Please select a study"), studyId: z.string().uuid("Please select a study"),
estimatedDuration: z estimatedDuration: z
.number() .number()
@@ -123,7 +123,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
if (mode === "edit" && experiment) { if (mode === "edit" && experiment) {
form.reset({ form.reset({
name: experiment.name, name: experiment.name,
description: experiment.description ?? "", description: experiment.description ?? undefined,
studyId: experiment.studyId, studyId: experiment.studyId,
estimatedDuration: experiment.estimatedDuration ?? undefined, estimatedDuration: experiment.estimatedDuration ?? undefined,
status: experiment.status, status: experiment.status,
@@ -1079,14 +1079,18 @@ export function DesignerRoot({
} }
} }
const defExec = actionDef.execution as any;
const execution: ExperimentAction["execution"] = const execution: ExperimentAction["execution"] =
actionDef.execution && defExec &&
(actionDef.execution.transport === "internal" || (defExec.transport === "internal" ||
actionDef.execution.transport === "rest" || defExec.transport === "rest" ||
actionDef.execution.transport === "ros2") defExec.transport === "ros2")
? { ? {
transport: actionDef.execution.transport, transport: defExec.transport,
retryable: actionDef.execution.retryable ?? false, retryable: defExec.retryable ?? false,
timeoutMs: defExec.timeoutMs,
ros2: defExec.ros2,
rest: defExec.rest,
} }
: undefined; : undefined;
@@ -43,7 +43,14 @@ import {
Plus, Plus,
GitBranch, GitBranch,
Trash2, Trash2,
PlayCircle,
Square,
Loader2,
CheckCircle2,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service";
/** /**
* PropertiesPanel * PropertiesPanel
@@ -90,6 +97,10 @@ export function PropertiesPanelBase({
const [localStepDescription, setLocalStepDescription] = useState(""); const [localStepDescription, setLocalStepDescription] = useState("");
const [localParams, setLocalParams] = useState<Record<string, unknown>>({}); const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
// Test action state
const [isTesting, setIsTesting] = useState(false);
const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle");
// Debounce timers // Debounce timers
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined); const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined); const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -168,6 +179,74 @@ export function PropertiesPanelBase({
selectedAction && selectedAction &&
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id)); design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
// Test action handler
const handleTestAction = useCallback(async () => {
if (!selectedAction || !containingStep) return;
setIsTesting(true);
setTestStatus("running");
try {
console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type);
console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2));
console.log("[Test Action] Parameters:", selectedAction.parameters);
// Reset service to ensure clean state for testing
resetWizardRosService();
// Initialize with actual robot connection (not simulation)
const rosService = await initWizardRosService(false);
console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus());
// Build action config from execution descriptor
const execution = selectedAction.execution;
let actionConfig: {
topic: string;
messageType: string;
payloadMapping: {
type: string;
payload?: Record<string, unknown>;
transformFn?: string;
};
} | undefined;
if (execution?.transport === "ros2" && execution.ros2) {
const ros2 = execution.ros2 as any;
actionConfig = {
topic: ros2.topic || "/speech",
messageType: ros2.messageType || "std_msgs/msg/String",
payloadMapping: {
type: ros2.payloadMapping?.type || "static",
payload: ros2.payloadMapping?.payload,
transformFn: ros2.payloadMapping?.transformFn,
},
};
console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2));
}
// Execute the action on the real robot
const result = await rosService.executeRobotAction(
selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core",
selectedAction.type,
selectedAction.parameters,
actionConfig,
);
console.log("[Test Action] Execution result:", result);
setTestStatus("success");
toast.success(`Action "${selectedAction.name}" executed on robot`);
} catch (error) {
setTestStatus("error");
const message = error instanceof Error ? error.message : "Action execution failed";
toast.error(message);
console.error("Test action error:", error);
} finally {
setIsTesting(false);
// Reset status after a delay
setTimeout(() => setTestStatus("idle"), 2000);
}
}, [selectedAction, containingStep]);
/* -------------------------- Action Properties View -------------------------- */ /* -------------------------- Action Properties View -------------------------- */
if (selectedAction && containingStep) { if (selectedAction && containingStep) {
let def = registry.getAction(selectedAction.type); let def = registry.getAction(selectedAction.type);
@@ -277,6 +356,41 @@ export function PropertiesPanelBase({
)} )}
</div> </div>
{/* Test Action Button */}
{selectedAction.execution?.transport !== "internal" && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="w-full gap-1.5"
onClick={handleTestAction}
disabled={isTesting}
>
{testStatus === "running" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
</>
) : testStatus === "success" ? (
<>
<CheckCircle2 className="h-4 w-4 text-green-500" />
Success!
</>
) : testStatus === "error" ? (
<>
<XCircle className="h-4 w-4 text-red-500" />
Failed
</>
) : (
<>
<PlayCircle className="h-4 w-4" />
Test Action
</>
)}
</Button>
</div>
)}
{/* General */} {/* General */}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase"> <div className="text-muted-foreground text-[10px] tracking-wide uppercase">
@@ -803,12 +917,13 @@ const ParameterEditor = React.memo(function ParameterEditor({
if (param.type === "text") { if (param.type === "text") {
control = ( control = (
<Input <textarea
value={(localValue as string) ?? ""} value={(localValue as string) ?? ""}
placeholder={param.placeholder} placeholder={param.placeholder}
onChange={(e) => handleUpdate(e.target.value)} onChange={(e) => handleUpdate(e.target.value)}
onBlur={handleCommit} onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs" rows={3}
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/> />
); );
} else if (param.type === "select") { } else if (param.type === "select") {
@@ -868,14 +983,24 @@ const ParameterEditor = React.memo(function ParameterEditor({
max={max} max={max}
step={step} step={step}
value={[Number(numericVal)]} value={[Number(numericVal)]}
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual onValueChange={(vals) => setLocalValue(vals[0])}
onPointerUp={() => handleUpdate(localValue)} // Commit on release onPointerUp={() => handleUpdate(localValue)}
/>
<Input
type="number"
value={Number(numericVal)}
min={min}
max={max}
step={step}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) {
setLocalValue(Math.max(min, Math.min(max, v)));
}
}}
onBlur={handleCommit}
className="h-7 w-16 text-xs tabular-nums"
/> />
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div> </div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]"> <div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span> <span>{min}</span>
+296 -237
View File
@@ -11,6 +11,7 @@ import {
EntityForm, EntityForm,
FormField, FormField,
FormSection, FormSection,
NextSteps,
Tips, Tips,
} from "~/components/ui/entity-form"; } from "~/components/ui/entity-form";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@@ -26,7 +27,7 @@ import { Textarea } from "~/components/ui/textarea";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Calendar as CalendarIcon, Clock } from "lucide-react"; import { Calendar as CalendarIcon, Clock, Clock2 } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -84,60 +85,61 @@ function DateTimePicker({
return ( return (
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<div className="grid gap-1.5"> <Popover open={open} onOpenChange={setOpen}>
<Label htmlFor="date-picker" className="text-xs"> <PopoverTrigger asChild>
Date <Button
</Label> variant={"outline"}
<Popover open={open} onOpenChange={setOpen}> id="date-picker"
<PopoverTrigger asChild> className={cn(
<Button "w-[200px] justify-start text-left font-normal",
variant={"outline"} !value && "text-muted-foreground",
id="date-picker" )}
className={cn( >
"w-[240px] justify-start text-left font-normal", <CalendarIcon className="mr-2 h-4 w-4" />
!value && "text-muted-foreground", {value ? format(value, "MMM d, yyyy") : <span>Pick a date</span>}
)} </Button>
> </PopoverTrigger>
<CalendarIcon className="mr-2 h-4 w-4" /> <PopoverContent className="w-auto p-0" align="start">
{value ? format(value, "PPP") : <span>Pick a date</span>} <Calendar
</Button> mode="single"
</PopoverTrigger> selected={value}
<PopoverContent className="w-auto p-0" align="start"> onSelect={onDateSelect}
<Calendar initialFocus
mode="single" />
selected={value} </PopoverContent>
onSelect={onDateSelect} </Popover>
initialFocus
/> <div className="relative">
</PopoverContent> <Input
</Popover> id="time-picker"
type="time"
value={timeValue}
onChange={onTimeChange}
disabled={!value}
className="w-[110px]"
/>
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
</div> </div>
<div className="grid gap-1.5"> <Button
<Label htmlFor="time-picker" className="text-xs"> type="button"
Time variant="outline"
</Label> size="sm"
<div className="relative"> onClick={() => onChange(new Date())}
<Input className="h-10 gap-1.5"
id="time-picker" >
type="time" <Clock2 className="h-4 w-4" />
value={timeValue} Now
onChange={onTimeChange} </Button>
disabled={!value}
className="w-[120px]"
/>
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
</div>
</div>
</div> </div>
); );
} }
const trialSchema = z.object({ const trialSchema = z.object({
experimentId: z.string().uuid("Please select an experiment"), experimentId: z.string().min(1, "Please select an experiment *"),
participantId: z.string().uuid("Please select a participant"), participantId: z.string().min(1, "Please select a participant *"),
scheduledAt: z.date(), scheduledAt: z.date({ message: "Scheduled date and time is required *" }),
wizardId: z.string().uuid().optional().or(z.literal("")), wizardId: z.string().optional().or(z.literal("")),
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(), notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
sessionNumber: z sessionNumber: z
.number() .number()
@@ -165,7 +167,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
defaultValues: { defaultValues: {
experimentId: "" as any, experimentId: "" as any,
participantId: "" as any, participantId: "" as any,
scheduledAt: new Date(), scheduledAt: undefined,
wizardId: undefined, wizardId: undefined,
notes: "", notes: "",
sessionNumber: 1, sessionNumber: 1,
@@ -329,6 +331,249 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
return <div>Error loading trial: {fetchError.message}</div>; return <div>Error loading trial: {fetchError.message}</div>;
} }
// Sidebar content
const sidebar = (
<>
<NextSteps
steps={[
{
title: "Configure Experiment",
description: "Ensure the experiment protocol is designed and ready",
completed: !!form.watch("experimentId"),
},
{
title: "Select Participant",
description: "Choose a participant for this trial",
completed: !!form.watch("participantId"),
},
{
title: "Assign Wizard",
description: "Assign a wizard to operate the robot",
},
{
title: "Run Trial",
description: "Execute the trial and collect data",
},
]}
/>
<Tips
tips={[
"Verify experiment status: Only 'Ready' experiments can be used in trials.",
"Check participant availability: Ensure participants are available at the scheduled time.",
"Assign wizards early: Give wizards time to prepare before the trial.",
"Prepare notes: Add any special instructions for the wizard.",
]}
/>
</>
);
// Form fields
const formFields = (
<>
<FormSection
title="Trial Configuration"
description="Select the experiment and participant for this trial."
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="experimentId">
Experiment <span className="text-red-500">*</span>
</Label>
<Select
value={form.watch("experimentId") ?? ""}
onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.experimentId ? "border-red-500 ring-1 ring-red-500" : ""
}
>
<SelectValue
placeholder={
experimentsLoading
? "Loading experiments..."
: "Select an experiment"
}
/>
</SelectTrigger>
<SelectContent>
{experimentsData?.map((experiment) => (
<SelectItem key={experiment.id} value={experiment.id}>
{experiment.name} ({experiment.status})
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.experimentId && (
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.experimentId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground mt-1 text-xs">
Experiment cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="participantId">
Participant <span className="text-red-500">*</span>
</Label>
<Select
value={form.watch("participantId") ?? ""}
onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.participantId ? "border-red-500 ring-1 ring-red-500" : ""
}
>
<SelectValue
placeholder={
participantsLoading
? "Loading participants..."
: "Select a participant"
}
/>
</SelectTrigger>
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name ?? participant.participantCode}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.participantId && (
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.participantId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground mt-1 text-xs">
Participant cannot be changed after creation
</p>
)}
</FormField>
</div>
</FormSection>
<FormSection
title="Scheduling"
description="Set when this trial should be conducted."
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="scheduledAt">
Scheduled Date & Time <span className="text-red-500">*</span>
</Label>
<Controller
control={form.control}
name="scheduledAt"
render={({ field }) => (
<DateTimePicker
value={field.value}
onChange={field.onChange}
/>
)}
/>
{form.formState.errors.scheduledAt && (
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.scheduledAt.message}
</p>
)}
<p className="text-muted-foreground mt-1 text-xs">
When should this trial be conducted?
</p>
</FormField>
<FormField>
<Label htmlFor="sessionNumber">Session Number</Label>
<Input
id="sessionNumber"
type="number"
min="1"
{...form.register("sessionNumber", { valueAsNumber: true })}
placeholder="1"
className={
form.formState.errors.sessionNumber ? "border-red-500 ring-1 ring-red-500" : ""
}
/>
{form.formState.errors.sessionNumber && (
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.sessionNumber.message}
</p>
)}
<p className="text-muted-foreground mt-1 text-xs">
Auto-incremented based on participant history
</p>
</FormField>
</div>
</FormSection>
<FormSection
title="Assignment & Notes"
description="Assign a wizard and add any special instructions."
>
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
value={form.watch("wizardId") ?? "none"}
onValueChange={(value) =>
form.setValue("wizardId", value === "none" ? undefined : value)
}
disabled={usersLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
usersLoading
? "Loading wizards..."
: "Select a wizard (optional)"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wizard assigned</SelectItem>
{usersData?.map(
(user: { id: string; name: string; email: string }) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
Who will operate the robot during this trial?
</p>
</FormField>
<FormField>
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...form.register("notes")}
placeholder="Special instructions for the wizard, environmental setup notes, or other relevant information..."
rows={4}
className={form.formState.errors.notes ? "border-red-500 ring-1 ring-red-500" : ""}
/>
{form.formState.errors.notes && (
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.notes.message}
</p>
)}
<p className="text-muted-foreground mt-1 text-xs">
Optional: Add any special instructions for this trial
</p>
</FormField>
</FormSection>
</>
);
return ( return (
<EntityForm <EntityForm
mode={mode} mode={mode}
@@ -351,196 +596,10 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
error={error} error={error}
sidebar={undefined} sidebar={sidebar}
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"} submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
layout="full-width"
> >
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> {formFields}
{/* Left Column: Main Info (Spans 2) */}
<div className="space-y-6 md:col-span-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="experimentId">Experiment *</Label>
<Select
value={form.watch("experimentId") ?? ""}
onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.experimentId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
experimentsLoading
? "Loading experiments..."
: "Select an experiment"
}
/>
</SelectTrigger>
<SelectContent>
{experimentsData?.map((experiment) => (
<SelectItem key={experiment.id} value={experiment.id}>
{experiment.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.experimentId && (
<p className="text-sm text-red-600">
{form.formState.errors.experimentId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Experiment cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="participantId">Participant *</Label>
<Select
value={form.watch("participantId") ?? ""}
onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.participantId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
participantsLoading
? "Loading participants..."
: "Select a participant"
}
/>
</SelectTrigger>
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name ?? participant.participantCode} (
{participant.participantCode})
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.participantId && (
<p className="text-sm text-red-600">
{form.formState.errors.participantId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Participant cannot be changed after creation
</p>
)}
</FormField>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
<Controller
control={form.control}
name="scheduledAt"
render={({ field }) => (
<DateTimePicker
value={field.value}
onChange={field.onChange}
/>
)}
/>
{form.formState.errors.scheduledAt && (
<p className="text-sm text-red-600">
{form.formState.errors.scheduledAt.message}
</p>
)}
<p className="text-muted-foreground text-xs">
When should this trial be conducted?
</p>
</FormField>
<FormField>
<Label htmlFor="sessionNumber">Session Number</Label>
<Input
id="sessionNumber"
type="number"
min="1"
{...form.register("sessionNumber", { valueAsNumber: true })}
placeholder="1"
className={
form.formState.errors.sessionNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.sessionNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.sessionNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Auto-incremented based on participant history
</p>
</FormField>
</div>
</div>
{/* Right Column: Assignment & Notes (Spans 1) */}
<div className="space-y-6">
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
value={form.watch("wizardId") ?? "none"}
onValueChange={(value) =>
form.setValue("wizardId", value === "none" ? undefined : value)
}
disabled={usersLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
usersLoading
? "Loading wizards..."
: "Select a wizard (optional)"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wizard assigned</SelectItem>
{usersData?.map(
(user: { id: string; name: string; email: string }) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Who will operate the robot?
</p>
</FormField>
<FormField>
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...form.register("notes")}
placeholder="Special instructions..."
rows={5}
className={form.formState.errors.notes ? "border-red-500" : ""}
/>
{form.formState.errors.notes && (
<p className="text-sm text-red-600">
{form.formState.errors.notes.message}
</p>
)}
</FormField>
</div>
</div>
</EntityForm> </EntityForm>
); );
} }
@@ -55,7 +55,7 @@ const actionSourceSchema = z
const executionDescriptorSchema = z const executionDescriptorSchema = z
.object({ .object({
transport: z.enum(["ros2", "rest", "internal"]), transport: z.enum(["ros2", "rest", "internal"]),
timeoutMs: z.number().int().positive().optional(), timeoutMs: z.number().int().min(0).optional(),
retryable: z.boolean().optional(), retryable: z.boolean().optional(),
ros2: z ros2: z
.object({ .object({
+278 -33
View File
@@ -45,6 +45,21 @@ export interface RobotActionExecution {
error?: string; error?: string;
} }
/**
* A single frame in a multi-step joint animation.
* @param joint_names - NAO joint names to move (e.g. "LShoulderPitch")
* @param joint_angles - Target angles in radians, one per joint_name
* @param speed - Movement speed as a fraction of max (0.01.0)
* @param delay_after - Milliseconds to wait after publishing this frame
* before sending the next one. Defaults to 800 ms.
*/
export interface AnimationMovement {
joint_names: string[];
joint_angles: number[];
speed?: number;
delay_after?: number;
}
/** /**
* Unified ROS WebSocket service for wizard interface * Unified ROS WebSocket service for wizard interface
* Manages connection to rosbridge and handles robot action execution * Manages connection to rosbridge and handles robot action execution
@@ -329,6 +344,19 @@ export class WizardRosService extends EventEmitter {
console.log(`[WizardROS] SIMULATION MODE - Executing ${actionId}:`, parameters); console.log(`[WizardROS] SIMULATION MODE - Executing ${actionId}:`, parameters);
// If the action config carries a gesture_sequence payload, run the sim animation handler
if (actionConfig?.payloadMapping?.payload) {
const payload = actionConfig.payloadMapping.payload as { type?: string; movements?: AnimationMovement[] };
if (payload.type === "gesture_sequence" && payload.movements?.length) {
await this.executeSimulationAnimationSequence(payload.movements);
execution.status = "completed";
execution.endTime = new Date();
this.emit("action_completed", execution);
this.activeActions.set(executionId, execution);
return execution;
}
}
// Simulate action execution based on action type // Simulate action execution based on action type
let duration = 500; let duration = 500;
@@ -336,9 +364,8 @@ export class WizardRosService extends EventEmitter {
const text = String(parameters.text || parameters.data || "Hello"); const text = String(parameters.text || parameters.data || "Hello");
const wordCount = text.split(/\s+/).filter(Boolean).length; const wordCount = text.split(/\s+/).filter(Boolean).length;
duration = 1500 + Math.max(1000, wordCount * 300); duration = 1500 + Math.max(1000, wordCount * 300);
} else if (actionId.includes("walk") || actionId.includes("turn") || actionConfig?.topic === "/cmd_vel") { } else if (actionId.includes("walk") || actionId === "stop_walking" || actionConfig?.topic === "/cmd_vel") {
duration = 500; duration = 500;
// Simulate position change
const speed = Number(parameters.speed) || 0.1; const speed = Number(parameters.speed) || 0.1;
if (actionId === "walk_forward") { if (actionId === "walk_forward") {
this.robotStatus.position.x += speed * 0.5; this.robotStatus.position.x += speed * 0.5;
@@ -349,8 +376,10 @@ export class WizardRosService extends EventEmitter {
} else if (actionId === "turn_right") { } else if (actionId === "turn_right") {
this.robotStatus.position.theta += 0.5; this.robotStatus.position.theta += 0.5;
} }
} else if (actionId.includes("head") || actionId.includes("move") || actionConfig?.topic === "/joint_angles") { } else if (actionConfig?.topic === "/joint_angles") {
duration = 1000; duration = 1000;
} else if (actionId === "wake_up" || actionId === "rest" || actionId === "set_posture") {
duration = 2000;
} }
// Simulate async execution // Simulate async execution
@@ -434,7 +463,7 @@ export class WizardRosService extends EventEmitter {
// Execute based on action configuration or built-in mappings // Execute based on action configuration or built-in mappings
if (actionConfig) { if (actionConfig) {
await this.executeWithConfig(actionConfig, parameters); await this.executeWithConfig(actionConfig, parameters, actionId);
} else { } else {
await this.executeBuiltinAction(actionId, parameters); await this.executeBuiltinAction(actionId, parameters);
} }
@@ -460,6 +489,60 @@ export class WizardRosService extends EventEmitter {
return Array.from(this.activeActions.values()); return Array.from(this.activeActions.values());
} }
/**
* Execute a multi-frame joint animation by sending each movement in sequence.
* Each frame is published to /joint_angles then held for delay_after ms
* (default 800 ms) before the next frame is sent.
*/
async executeAnimationSequence(movements: AnimationMovement[]): Promise<void> {
if (!movements.length) {
console.warn("[WizardROS] executeAnimationSequence called with empty movements");
return;
}
for (let i = 0; i < movements.length; i++) {
const movement = movements[i]!;
console.log(
`[WizardROS] Animation frame ${i + 1}/${movements.length}:`,
movement.joint_names,
"→",
movement.joint_angles,
);
this.publish(
"/joint_angles",
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
{
joint_names: movement.joint_names,
joint_angles: movement.joint_angles,
speed: movement.speed ?? 0.3,
},
);
// Always wait after each frame (including the last) so the caller
// can await this method and know the animation has finished.
const delayMs = movement.delay_after ?? 800;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
/**
* Execute a multi-frame joint animation in simulation mode (logs only).
*/
private async executeSimulationAnimationSequence(
movements: AnimationMovement[],
): Promise<void> {
for (let i = 0; i < movements.length; i++) {
const movement = movements[i]!;
console.log(
`[WizardROS] SIMULATION animation frame ${i + 1}/${movements.length}:`,
movement.joint_names,
);
const delayMs = movement.delay_after ?? 800;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
/** /**
* Subscribe to robot sensor topics * Subscribe to robot sensor topics
*/ */
@@ -480,6 +563,7 @@ export class WizardRosService extends EventEmitter {
this.advertise("/speech", "std_msgs/String"); this.advertise("/speech", "std_msgs/String");
this.advertise("/cmd_vel", "geometry_msgs/Twist"); this.advertise("/cmd_vel", "geometry_msgs/Twist");
this.advertise("/joint_angles", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed");
this.advertise("/robot_pose", "geometry_msgs/Pose"); this.advertise("/robot_pose", "geometry_msgs/Pose");
this.advertise("/animation", "std_msgs/String"); this.advertise("/animation", "std_msgs/String");
} }
@@ -632,10 +716,30 @@ export class WizardRosService extends EventEmitter {
type: string; type: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
transformFn?: string; transformFn?: string;
service?: string;
args?: Record<string, unknown>;
sshCommand?: string;
}; };
}, },
parameters: Record<string, unknown>, parameters: Record<string, unknown>,
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
if (config.payloadMapping.type === "service") {
const service = config.payloadMapping.service ?? config.topic;
const args = config.payloadMapping.args
? this.buildTemplatePayload(config.payloadMapping.args, parameters)
: parameters;
await this.callService(service, args);
return;
}
let msg: Record<string, unknown>; let msg: Record<string, unknown>;
if ( if (
@@ -643,22 +747,31 @@ export class WizardRosService extends EventEmitter {
config.payloadMapping.type === "static") && config.payloadMapping.type === "static") &&
config.payloadMapping.payload config.payloadMapping.payload
) { ) {
// Template-based payload construction msg = this.buildTemplatePayload(config.payloadMapping.payload, parameters);
msg = this.buildTemplatePayload(
config.payloadMapping.payload,
parameters,
);
} else if (config.payloadMapping.transformFn) { } else if (config.payloadMapping.transformFn) {
// Custom transform function msg = this.applyTransformFunction(config.payloadMapping.transformFn, parameters);
msg = this.applyTransformFunction(
config.payloadMapping.transformFn,
parameters,
);
} else { } else {
// Direct parameter mapping
msg = parameters; msg = parameters;
} }
// Delegate gesture_sequence payloads to the animation handler
if ((msg as { type?: string }).type === "gesture_sequence") {
const movements = (msg as { movements?: AnimationMovement[] }).movements;
if (!movements?.length) {
console.warn("[WizardROS] gesture_sequence payload has no movements");
return;
}
console.log(`[WizardROS] Delegating to animation handler (${movements.length} frames)`);
await this.executeAnimationSequence(movements);
return;
}
// Route /animation topic through SSH instead of ROS to avoid crashes
if (config.topic === "/animation" && actionId?.startsWith("play_animation_")) {
await this.executeAnimationSSH(actionId);
return;
}
this.publish(config.topic, config.messageType, msg); this.publish(config.topic, config.messageType, msg);
// Wait for action completion based on topic type // Wait for action completion based on topic type
@@ -747,7 +860,7 @@ export class WizardRosService extends EventEmitter {
case "turn_head": case "turn_head":
this.publish( this.publish(
"/joint_angles", "/joint_angles",
"naoqi_bridge_msgs/JointAnglesWithSpeed", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
{ {
joint_names: ["HeadYaw", "HeadPitch"], joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [ joint_angles: [
@@ -760,12 +873,12 @@ export class WizardRosService extends EventEmitter {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
break; break;
case "move_arm": case "move_arm": {
const arm = String(parameters.arm || "right"); const arm = String(parameters.arm || "right");
const prefix = arm.toLowerCase() === "left" ? "L" : "R"; const prefix = arm.toLowerCase() === "left" ? "L" : "R";
this.publish( this.publish(
"/joint_angles", "/joint_angles",
"naoqi_bridge_msgs/JointAnglesWithSpeed", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
{ {
joint_names: [ joint_names: [
`${prefix}ShoulderPitch`, `${prefix}ShoulderPitch`,
@@ -784,6 +897,40 @@ export class WizardRosService extends EventEmitter {
); );
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
break; break;
}
case "wake_up":
// Try known naoqi_driver2 service names in order
for (const svc of [
"/naoqi_driver/ALMotion/wakeUp",
"/naoqi_driver/motion/wake_up",
"/motion/wake_up",
]) {
try {
await this.callService(svc, {});
console.log(`[WizardROS] wake_up succeeded via ${svc}`);
break;
} catch {
// try next
}
}
break;
case "rest":
for (const svc of [
"/naoqi_driver/ALMotion/rest",
"/naoqi_driver/motion/rest",
"/motion/rest",
]) {
try {
await this.callService(svc, {});
console.log(`[WizardROS] rest succeeded via ${svc}`);
break;
} catch {
// try next
}
}
break;
case "emergency_stop": case "emergency_stop":
this.publish("/cmd_vel", "geometry_msgs/Twist", { this.publish("/cmd_vel", "geometry_msgs/Twist", {
@@ -792,6 +939,16 @@ export class WizardRosService extends EventEmitter {
}); });
break; break;
case "play_animation_bow":
case "play_animation_hey":
case "play_animation_show_floor":
case "play_animation_enthusiastic":
case "play_animation_yes":
case "play_animation_no":
case "play_animation_idontknow":
await this.executeAnimationSSH(actionId);
break;
default: default:
throw new Error( throw new Error(
`Unknown action: ${actionId}. Define this action in your robot plugin.`, `Unknown action: ${actionId}. Define this action in your robot plugin.`,
@@ -879,6 +1036,56 @@ export class WizardRosService extends EventEmitter {
}); });
} }
/**
* Execute animation via API route (SSH to robot)
*/
private async executeAnimationSSH(actionId: string): Promise<void> {
const animationMap: Record<string, string> = {
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
"play_animation_no": "animations/Stand/Gestures/No_3",
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
};
const animation = animationMap[actionId];
if (!animation) {
throw new Error(`Unknown animation: ${actionId}`);
}
console.log(`[WizardROS] Executing animation via API: ${animation}`);
// Use executeSSH to run animation via qicli (bypasses studyId requirement)
await this.executeSSHCommand(`qicli call ALAnimationPlayer.run '${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
*/ */
@@ -1042,6 +1249,21 @@ export class WizardRosService extends EventEmitter {
case "transformToEmotionalSpeech": case "transformToEmotionalSpeech":
return this.transformToEmotionalSpeech(parameters); return this.transformToEmotionalSpeech(parameters);
case "transformToEmotionSpeech":
return this.transformToEmotionalSpeech(parameters);
case "transformToWaveGoodbye":
return this.transformToWaveGoodbye(parameters);
case "transformToAnimation":
return this.transformToAnimation(parameters);
case "transformToGestureSequence":
return this.transformToGestureSequence(parameters);
case "transformToWaveGoodbye":
return this.transformToWaveGoodbye(parameters);
default: default:
console.warn(`Unknown transform function: ${transformFn}`); console.warn(`Unknown transform function: ${transformFn}`);
return parameters; return parameters;
@@ -1051,34 +1273,26 @@ export class WizardRosService extends EventEmitter {
/** /**
* Transform parameters for emotional speech * Transform parameters for emotional speech
* NAOqi markup: \rspd=<speed>\<text> * NAOqi markup: \rspd=<speed>\<text>
* For animated speech: ^start(animations/Stand/Gestures/...) * Using pure speech modifiers without animations to avoid sound effects
*/ */
private transformToEmotionalSpeech(parameters: Record<string, unknown>): { private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
data: string; data: string;
} { } {
const text = String(parameters.text || "Hello"); const text = String(parameters.text || "Hello");
const emotion = String(parameters.emotion || "neutral"); const emotion = String(parameters.emotion || "neutral");
const speed = Number(parameters.speed || 1.0);
const speedPercent = Math.round(speed * 100);
let markedText = text; let markedText = text;
switch (emotion) { switch (emotion) {
case "happy": case "happy":
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`; markedText = `\\rspd=120\\ ${text}`;
break;
case "excited":
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
break; break;
case "sad": case "sad":
markedText = `\\rspd=80\\vct=80\\${text}`; markedText = `\\rspd=80\\ ${text}`;
break;
case "calm":
markedText = `\\rspd=90\\${text}`;
break; break;
case "neutral": case "neutral":
default: default:
markedText = `\\rspd=${speedPercent}\\${text}`; markedText = text;
break; break;
} }
@@ -1086,14 +1300,13 @@ export class WizardRosService extends EventEmitter {
} }
/** /**
* Transform for wave goodbye - animated speech with waving * Transform for wave goodbye - speech without animation sound
*/ */
private transformToWaveGoodbye(parameters: Record<string, unknown>): { private transformToWaveGoodbye(parameters: Record<string, unknown>): {
data: string; data: string;
} { } {
const text = String(parameters.text || "Goodbye!"); const text = String(parameters.text || "Goodbye!");
const markedText = `\\rspd=110\\^start(animations/Stand/Gestures/Hey_1) ${text} ^start(animations/Stand/Gestures/Hey_1)`; return { data: text };
return { data: markedText };
} }
/** /**
@@ -1107,6 +1320,38 @@ export class WizardRosService extends EventEmitter {
return { data: markedText }; return { data: markedText };
} }
/**
* Transform for gesture sequences - sends multiple joint angle movements
* Parameters: movements = [{joints: string[], angles: number[], duration: number}, ...]
*/
private transformToGestureSequence(parameters: Record<string, unknown>): {
type: string;
movements: Array<{
joint_names: string[];
joint_angles: number[];
speed: number;
}>;
} {
const movements = parameters.movements as Array<{
joints?: string[];
angles?: number[];
duration?: number;
speed?: number;
}>;
if (!Array.isArray(movements)) {
return { type: "gesture_sequence", movements: [] };
}
const parsedMovements = movements.map((m, index) => ({
joint_names: m.joints || [],
joint_angles: m.angles || [],
speed: m.speed || (m.duration ? 1 / (m.duration / 1000) : 0.3),
}));
return { type: "gesture_sequence", movements: parsedMovements };
}
/** /**
* Schedule reconnection attempt * Schedule reconnection attempt
*/ */
+210 -48
View File
@@ -6,6 +6,10 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export interface RobotCommunicationConfig { export interface RobotCommunicationConfig {
rosBridgeUrl: string; rosBridgeUrl: string;
@@ -22,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>;
};
};
}; };
} }
@@ -161,41 +181,71 @@ export class RobotCommunicationService extends EventEmitter {
* Execute a robot action * Execute a robot action
*/ */
async executeAction(action: RobotAction): Promise<RobotActionResult> { async executeAction(action: RobotAction): Promise<RobotActionResult> {
const actionId = `action_${this.messageId++}`;
const startTime = Date.now();
// Check if this is an SSH-only action (animations, posture, arbitrary SSH commands)
const { implementation, actionId: actionType } = action;
const baseActionId = actionType.includes(".")
? actionType.split(".").pop()
: actionType;
const isAnimationAction = baseActionId?.startsWith("play_animation_");
const sshCommand = implementation.payloadMapping?.sshCommand
|| implementation.ros2?.payloadMapping?.sshCommand;
// SSH actions don't require ROS connection
if (isAnimationAction || sshCommand) {
const timeout = setTimeout(() => {
throw new Error(`SSH action timeout: ${action.actionId}`);
}, 30000);
try {
console.log(`[RobotComm] Executing SSH action: ${action.actionId}`);
const result = await this.executeRobotActionInternal(action, actionId, startTime);
clearTimeout(timeout);
return result;
} catch (error) {
clearTimeout(timeout);
throw error;
}
}
// Non-SSH actions require ROS connection
if (!this.isConnected) { if (!this.isConnected) {
throw new Error("Not connected to ROS bridge"); throw new Error("Not connected to ROS bridge");
} }
const startTime = Date.now(); // Store pending action
const actionId = `action_${this.messageId++}`; const pending = {
resolve: (() => {}) as (result: RobotActionResult) => void,
return new Promise((resolve, reject) => { reject: (() => {}) as (error: Error) => void,
// Set up timeout timeout: setTimeout(() => {
const timeout = setTimeout(() => {
this.pendingActions.delete(actionId); this.pendingActions.delete(actionId);
reject(new Error(`Action timeout: ${action.actionId}`)); pending.reject(new Error(`Action timeout: ${action.actionId}`));
}, 30000); // 30 second timeout }, 30000),
startTime,
};
// Store pending action this.pendingActions.set(actionId, pending);
this.pendingActions.set(actionId, {
resolve,
reject,
timeout,
startTime,
});
try { // Wrap the pending resolve/reject in a way that works with async method
// Log the action we're about to execute return new Promise<RobotActionResult>((resolve, reject) => {
console.log(`[RobotComm] Executing robot action: ${action.actionId}`); pending.resolve = resolve;
console.log(`[RobotComm] Topic: ${action.implementation.topic}`); pending.reject = reject;
console.log(`[RobotComm] Parameters:`, action.parameters);
// Execute action based on type and platform // Execute action
this.executeRobotActionInternal(action, actionId); this.executeRobotActionInternal(action, actionId, startTime)
} catch (error) { .then((result) => {
clearTimeout(timeout); clearTimeout(pending.timeout);
this.pendingActions.delete(actionId); this.pendingActions.delete(actionId);
reject(error); resolve(result);
} })
.catch((error) => {
clearTimeout(pending.timeout);
this.pendingActions.delete(actionId);
reject(error);
});
}); });
} }
@@ -208,17 +258,54 @@ export class RobotCommunicationService extends EventEmitter {
// Private methods // Private methods
private executeRobotActionInternal( private async executeRobotActionInternal(
action: RobotAction, action: RobotAction,
actionId: string, actionId: string,
): void { startTime: number,
const { implementation, parameters } = action; ): Promise<RobotActionResult> {
const { implementation, parameters, actionId: actionType } = action;
// Build ROS message from template // Use SSH for play_animation actions (check both namespaced and non-namespaced)
const message = this.buildRosMessage( const baseActionId = actionType.includes(".")
implementation.messageTemplate, ? actionType.split(".").pop()
parameters, : actionType;
);
if (baseActionId?.startsWith("play_animation_")) {
await this.executeAnimationViaSSH(baseActionId);
return {
success: true,
duration: Date.now() - startTime,
data: { method: "ssh", action: baseActionId },
};
}
// Check for SSH command type
const sshCommand = implementation.payloadMapping?.sshCommand
|| implementation.ros2?.payloadMapping?.sshCommand;
if (sshCommand) {
await this.executeSSHCommand(sshCommand);
return {
success: true,
duration: Date.now() - startTime,
data: { method: "ssh", command: sshCommand },
};
}
// 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
message = this.buildRosMessage(
implementation.messageTemplate,
parameters,
);
}
// Publish to ROS topic // Publish to ROS topic
this.publishToTopic( this.publishToTopic(
@@ -229,19 +316,94 @@ export class RobotCommunicationService extends EventEmitter {
// For actions that complete immediately (like movement commands), // For actions that complete immediately (like movement commands),
// we simulate completion after a short delay // we simulate completion after a short delay
setTimeout(() => { return new Promise((resolve) => {
this.completeAction(actionId, { setTimeout(() => {
success: true, resolve({
duration: success: true,
Date.now() - duration: Date.now() - startTime,
(this.pendingActions.get(actionId)?.startTime || Date.now()), data: {
data: { topic: implementation.topic,
topic: implementation.topic, messageType: implementation.messageType,
messageType: implementation.messageType, message,
message, },
}, });
}); }, 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> {
const animationMap: Record<string, string> = {
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
"play_animation_no": "animations/Stand/Gestures/No_3",
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
};
const animation = animationMap[actionType];
if (!animation) {
throw new Error(`Unknown animation: ${actionType}`);
}
const robotIp = process.env.NAO_IP || "134.82.159.168";
const password = process.env.NAO_PASSWORD || "robolab";
console.log(`[RobotComm] Executing animation via SSH: ${animation}`);
const command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "nao@${robotIp}" "qicli call ALAnimationPlayer.run '${animation}'"`;
const { stdout, stderr } = await execAsync(command);
if (stderr && !stderr.includes("null")) {
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
}
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(
+2 -2
View File
@@ -810,10 +810,10 @@ export class TrialExecutionEngine {
} }
} }
// Prepare robot action // Prepare robot action - use action.type which contains the namespaced format (plugin.actionId)
const robotAction: RobotAction = { const robotAction: RobotAction = {
pluginName: plugin.name, pluginName: plugin.name,
actionId: actionDefinition.id, actionId: action.type, // e.g., "nao6-ros2.play_animation_bow"
parameters, parameters,
implementation: actionDefinition.implementation, implementation: actionDefinition.implementation,
}; };