29 changed files with 1285 additions and 1269 deletions
+939 -686
View File
File diff suppressed because it is too large Load Diff
+7 -24
View File
@@ -1,13 +1,17 @@
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
// @ts-ignore -- no types for this plugin
import drizzle from "eslint-plugin-drizzle";
import nextVitals from "eslint-config-next/core-web-vitals";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next", "src/trpc/*.js"],
ignores: [".next"],
},
...nextVitals,
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
@@ -34,27 +38,6 @@ export default tseslint.config(
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/non-nullable-type-assertion-style": "off",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/restrict-template-expressions": "warn",
"@typescript-eslint/prefer-promise-reject-errors": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/no-empty-function": "warn",
"react-hooks/refs": "warn",
"react-hooks/set-state-in-effect": "warn",
"react-hooks/immutability": "warn",
"react-hooks/static-components": "warn",
"react-hooks/purity": "warn",
"react-hooks/error-boundaries": "warn",
"react-hooks/preserve-manual-memoization": "warn",
"drizzle/enforce-delete-with-where": [
"error",
{ drizzleObjectName: ["db", "ctx.db"] },
+66 -79
View File
@@ -28,106 +28,107 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.11.3",
"@aws-sdk/client-s3": "^3.1132.0",
"@aws-sdk/s3-request-presigner": "^3.1132.0",
"@better-auth/drizzle-adapter": "~1.6.33",
"@auth/drizzle-adapter": "^1.11.1",
"@aws-sdk/client-s3": "^3.989.0",
"@aws-sdk/s3-request-presigner": "^3.989.0",
"@better-auth/drizzle-adapter": "^1.5.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@shadcn/ui": "^0.0.4",
"@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.102.8",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-table-cell": "^3.31.3",
"@tiptap/extension-table-header": "^3.31.3",
"@tiptap/extension-table-row": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@trpc/client": "^11.18.0",
"@trpc/react-query": "^11.18.0",
"@trpc/server": "^11.18.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@trpc/client": "^11.10.0",
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1",
"bcryptjs": "^3.0.3",
"better-auth": "~1.6.33",
"better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"driver.js": "^1.8.0",
"date-fns": "^4.1.0",
"driver.js": "^1.4.0",
"drizzle-orm": "^0.45.2",
"html2pdf.js": "^0.14.0",
"js-cookie": "^3.0.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.536.0",
"minio": "^8.0.7",
"next": "16",
"next-auth": "^5.0.0-beta.32",
"minio": "^8.0.6",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"postgres": "^3.4.9",
"radix-ui": "^1.6.7",
"postgres": "^3.4.8",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-day-picker": "^9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.88.0",
"react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6",
"react-signature-canvas": "^1.1.0-alpha.2",
"react-webcam": "^7.2.0",
"server-only": "^0.0.1",
"sonner": "^2.0.8",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tiptap-markdown": "^0.9.0",
"uuid": "^13.0.2",
"ws": "^8.21.3",
"zod": "^4.6.5",
"uuid": "^13.0.0",
"ws": "^8.19.0",
"zod": "^4.3.6",
"zustand": "^4.5.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.4.2",
"@types/bun": "^1.3.9",
"@types/crypto-js": "^4.2.2",
"@types/node": "^20.19.43",
"@types/node": "^20.19.33",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.5",
"eslint-config-next": "16.3.5",
"drizzle-kit": "^0.30.6",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.1",
"eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.3.3",
"tailwindcss": "^4.1.18",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vitest": "^4.1.11"
"typescript-eslint": "^8.55.0",
"vitest": "^4.0.18"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
@@ -140,20 +141,6 @@
],
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@better-auth/core": "1.6.33",
"minio": {
"fast-xml-parser": "^5.7.0",
"stream-json": "^3.6.0"
},
"query-string": {
"decode-uri-component": "^0.5.0"
},
"@esbuild-kit/core-utils": {
"esbuild": "^0.25.12"
},
"vite": {
"esbuild": "^0.28.0"
}
"@types/react-dom": "19.2.3"
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ function validateSeedData() {
const seanRole = userRoles.find(
(r) => r.userId === "01234567-89ab-cdef-0123-456789abcde0",
);
if (seanRole?.role === "administrator") {
if (seanRole && seanRole.role === "administrator") {
console.log(` ✅ Sean has administrator role`);
} else {
console.error(` ❌ Sean missing administrator role`);
+1 -1
View File
@@ -20,7 +20,7 @@ interface Subscriber {
const PORT = parseInt(process.env.MOCK_ROBOT_PORT || "9090", 10);
const PUBLISH_INTERVAL = parseInt(process.env.MOCK_PUBLISH_INTERVAL || "100", 10);
const subscribers = new Map<string, Subscriber>();
const subscribers: Map<string, Subscriber> = new Map();
let subscriberIdCounter = 0;
const mockRobotState = {
@@ -29,28 +29,12 @@ export default function RobotIntegrationTutorial() {
<p>HRIStudio supports multiple robot platforms:</p>
<table>
<thead>
<tr>
<th>Robot</th>
<th>Protocol</th>
<th>Capabilities</th>
</tr>
<tr><th>Robot</th><th>Protocol</th><th>Capabilities</th></tr>
</thead>
<tbody>
<tr>
<td>NAO6</td>
<td>ROS2</td>
<td>Speech, movement, gestures, sensors</td>
</tr>
<tr>
<td>TurtleBot3</td>
<td>ROS2</td>
<td>Navigation, sensors</td>
</tr>
<tr>
<td>Mock Robot</td>
<td>WebSocket</td>
<td>All actions (simulation)</td>
</tr>
<tr><td>NAO6</td><td>ROS2</td><td>Speech, movement, gestures, sensors</td></tr>
<tr><td>TurtleBot3</td><td>ROS2</td><td>Navigation, sensors</td></tr>
<tr><td>Mock Robot</td><td>WebSocket</td><td>All actions (simulation)</td></tr>
</tbody>
</table>
@@ -59,70 +43,44 @@ export default function RobotIntegrationTutorial() {
<h3>Network Configuration</h3>
<ol>
<li>Connect NAO6 to your network</li>
<li>
Note the robot&apos;s IP address:
<pre>
<code>
# On the robot, say &quot;What is my IP address?&quot; # Or check
robot&apos;s network settings
</code>
</pre>
<li>Note the robot&apos;s IP address:
<pre><code># On the robot, say &quot;What is my IP address?&quot;
# Or check robot&apos;s network settings</code></pre>
</li>
<li>
Verify network access:
<pre>
<code>
ping nao.local # Or ping the IP directly: ping 192.168.1.100
</code>
</pre>
<li>Verify network access:
<pre><code>ping nao.local
# Or ping the IP directly:
ping 192.168.1.100</code></pre>
</li>
</ol>
<h3>Wake Up Robot</h3>
<p>Before connecting, wake up the robot:</p>
<pre>
<code>
ssh nao@192.168.1.100 # Enter password when prompted # Wake up the
robot python -c &quot;from naoqi import ALProxy; proxy =
ALProxy(&apos;ALMotion&apos;, &apos;192.168.1.100&apos;, 9559);
proxy.wakeUp()&quot;
</code>
</pre>
<pre><code>ssh nao@192.168.1.100
# Enter password when prompted
# Wake up the robot
python -c &quot;from naoqi import ALProxy; proxy = ALProxy('ALMotion', '192.168.1.100', 9559); proxy.wakeUp()&quot;</code></pre>
<h2>Step 2: Start Docker Services</h2>
<pre>
<code>
cd ~/nao6-hristudio-integration # Set robot IP export
NAO_IP=192.168.1.100 # Start services docker compose up -d
</code>
</pre>
<pre><code>cd ~/nao6-hristudio-integration
# Set robot IP
export NAO_IP=192.168.1.100
# Start services
docker compose up -d</code></pre>
<h3>Services Overview</h3>
<table>
<thead>
<tr>
<th>Service</th>
<th>Port</th>
<th>Purpose</th>
</tr>
<tr><th>Service</th><th>Port</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td>nao_driver</td>
<td>-</td>
<td>ROS2 driver for NAO</td>
</tr>
<tr>
<td>ros_bridge</td>
<td>9090</td>
<td>WebSocket bridge</td>
</tr>
<tr>
<td>ros_api</td>
<td>-</td>
<td>Topic introspection</td>
</tr>
<tr><td>nao_driver</td><td>-</td><td>ROS2 driver for NAO</td></tr>
<tr><td>ros_bridge</td><td>9090</td><td>WebSocket bridge</td></tr>
<tr><td>ros_api</td><td>-</td><td>Topic introspection</td></tr>
</tbody>
</table>
@@ -130,49 +88,32 @@ export default function RobotIntegrationTutorial() {
<h3>Install Robot Plugin</h3>
<ol>
<li>
Go to <strong>Plugins</strong> in sidebar
</li>
<li>Go to <strong>Plugins</strong> in sidebar</li>
<li>Select your study</li>
<li>
Click <strong>Browse Plugins</strong>
</li>
<li>
Find <strong>NAO6 Robot (ROS2 Integration)</strong>
</li>
<li>
Click <strong>Install</strong>
</li>
<li>Click <strong>Browse Plugins</strong></li>
<li>Find <strong>NAO6 Robot (ROS2 Integration)</strong></li>
<li>Click <strong>Install</strong></li>
</ol>
<h3>Configure Plugin</h3>
<pre>
<code>
Robot Name: NAO6-Lab Robot IP: 192.168.1.100 WebSocket URL:
ws://localhost:9090
</code>
</pre>
<pre><code>Robot Name: NAO6-Lab
Robot IP: 192.168.1.100
WebSocket URL: ws://localhost:9090</code></pre>
<h3>Environment Variables</h3>
<p>
Create <code>hristudio/.env.local</code>:
</p>
<pre>
<code>
# Robot connection NAO_ROBOT_IP=192.168.1.100 NAO_PASSWORD=robolab
NAO_USERNAME=nao # WebSocket bridge
NEXT_PUBLIC_ROS_BRIDGE_URL=ws://localhost:9090
</code>
</pre>
<p>Create <code>hristudio/.env.local</code>:</p>
<pre><code># Robot connection
NAO_ROBOT_IP=192.168.1.100
NAO_PASSWORD=robolab
NAO_USERNAME=nao
# WebSocket bridge
NEXT_PUBLIC_ROS_BRIDGE_URL=ws://localhost:9090</code></pre>
<h2>Step 4: Test Connection</h2>
<ol>
<li>
Navigate to: <code>http://localhost:3000/nao-test</code>
</li>
<li>
Click <strong>Connect</strong>
</li>
<li>Navigate to: <code>http://localhost:3000/nao-test</code></li>
<li>Click <strong>Connect</strong></li>
<li>Verify connection status shows &quot;Connected&quot;</li>
<li>Test basic actions (Say, Wave, Move)</li>
</ol>
@@ -182,84 +123,47 @@ export default function RobotIntegrationTutorial() {
<h3>Speech Actions</h3>
<table>
<thead>
<tr>
<th>Action</th>
<th>Parameters</th>
<th>Description</th>
</tr>
<tr><th>Action</th><th>Parameters</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td>say_text</td>
<td>text</td>
<td>Speak text</td>
</tr>
<tr>
<td>say_with_emotion</td>
<td>text, emotion</td>
<td>Emotional speech</td>
</tr>
<tr>
<td>set_volume</td>
<td>level</td>
<td>Set speech volume</td>
</tr>
<tr><td>say_text</td><td>text</td><td>Speak text</td></tr>
<tr><td>say_with_emotion</td><td>text, emotion</td><td>Emotional speech</td></tr>
<tr><td>set_volume</td><td>level</td><td>Set speech volume</td></tr>
</tbody>
</table>
<h3>Movement Actions</h3>
<table>
<thead>
<tr>
<th>Action</th>
<th>Parameters</th>
<th>Description</th>
</tr>
<tr><th>Action</th><th>Parameters</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td>walk_forward</td>
<td>speed, duration</td>
<td>Walk forward</td>
</tr>
<tr>
<td>walk_backward</td>
<td>speed</td>
<td>Walk backward</td>
</tr>
<tr>
<td>turn_left</td>
<td>speed</td>
<td>Turn left</td>
</tr>
<tr>
<td>turn_right</td>
<td>speed</td>
<td>Turn right</td>
</tr>
<tr><td>walk_forward</td><td>speed, duration</td><td>Walk forward</td></tr>
<tr><td>walk_backward</td><td>speed</td><td>Walk backward</td></tr>
<tr><td>turn_left</td><td>speed</td><td>Turn left</td></tr>
<tr><td>turn_right</td><td>speed</td><td>Turn right</td></tr>
</tbody>
</table>
<h2>Troubleshooting</h2>
<h3>Robot Not Found</h3>
<pre>
<code>
Error: Cannot connect to robot at 192.168.1.100 Solutions: 1. Verify
IP address: ping 192.168.1.100 2. Check robot is powered on 3. Verify
network connectivity 4. Try nao.local hostname
</code>
</pre>
<pre><code>Error: Cannot connect to robot at 192.168.1.100
Solutions:
1. Verify IP address: ping 192.168.1.100
2. Check robot is powered on
3. Verify network connectivity
4. Try nao.local hostname</code></pre>
<h3>WebSocket Connection Failed</h3>
<pre>
<code>
Error: WebSocket connection to ws://localhost:9090 failed Solutions:
1. Check Docker is running: docker ps 2. Verify ros_bridge container
3. Check port 9090 is not blocked 4. Restart services: docker compose
restart
</code>
</pre>
<pre><code>Error: WebSocket connection to ws://localhost:9090 failed
Solutions:
1. Check Docker is running: docker ps
2. Verify ros_bridge container
3. Check port 9090 is not blocked
4. Restart services: docker compose restart</code></pre>
<div className="mt-8 flex justify-between">
<Button variant="outline" asChild>
+1 -1
View File
@@ -169,7 +169,7 @@ export default function NaoTestPage() {
};
const publishMessage = (topic: string, type: string, msg: any) => {
if (rosSocket?.readyState !== WebSocket.OPEN) {
if (!rosSocket || rosSocket.readyState !== WebSocket.OPEN) {
addLog("Error: Not connected to ROS bridge");
return;
}
@@ -191,7 +191,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes &nbsp; <input type="radio" name="yn" /> No</div>';
break;
case "rating":
const scale = field.settings?.scale ?? 5;
const scale = (field.settings?.scale as number) || 5;
inputField = `<div style="margin-top: 4px;">${Array.from(
{ length: scale },
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
@@ -422,14 +422,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{
FORM_FIELD_TYPES.find(
(f) => f.value === field.type,
)?.icon
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.icon
}{" "}
{
FORM_FIELD_TYPES.find(
(f) => f.value === field.type,
)?.label
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
</Badge>
<Input
@@ -549,9 +547,8 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{
FORM_FIELD_TYPES.find(
(f) => f.value === field.type,
)?.label
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
{field.required && " • Required"}
{field.type === "multiple_choice" &&
@@ -627,7 +624,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from(
{ length: field.settings?.scale ?? 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<button
key={i}
@@ -812,7 +809,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: field.settings?.scale ?? 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
@@ -929,7 +926,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</span>
</div>
<Badge
className={`text-xs ${formStatusColors[response.status!]}`}
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
>
{response.status}
</Badge>
@@ -16,7 +16,7 @@ export default async function EditParticipantPage({
const participant = await api.participants.get({ id: participantId });
if (participant?.studyId !== studyId) {
if (!participant || participant.studyId !== studyId) {
notFound();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "~/lib/auth";
import { db } from "~/server/db";
import { studyMembers } from "~/server/db/schema";
+51 -53
View File
@@ -1,4 +1,4 @@
import { type NextRequest } from "next/server";
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { wsManager } from "~/server/services/websocket-manager";
import { auth } from "~/lib/auth";
@@ -48,7 +48,7 @@ export async function GET(request: NextRequest) {
const pair = new WebSocketPair();
const clientId = generateClientId();
const serverWebSocket = Object.values(pair)[0]!;
const serverWebSocket = Object.values(pair)[0] as WebSocket;
clientConnections.set(clientId, { socket: serverWebSocket, clientId });
@@ -56,61 +56,59 @@ export async function GET(request: NextRequest) {
serverWebSocket.accept();
serverWebSocket.addEventListener("message", (event) => {
void (async () => {
try {
const message = JSON.parse(event.data as string);
serverWebSocket.addEventListener("message", async (event) => {
try {
const message = JSON.parse(event.data as string);
switch (message.type) {
case "heartbeat":
wsManager.sendToClient(clientId, {
type: "heartbeat_response",
data: { timestamp: Date.now() },
});
break;
switch (message.type) {
case "heartbeat":
wsManager.sendToClient(clientId, {
type: "heartbeat_response",
data: { timestamp: Date.now() },
});
break;
case "request_trial_status": {
const status = await wsManager.getTrialStatus(trialId);
wsManager.sendToClient(clientId, {
type: "trial_status",
data: {
trial: status?.trial ?? null,
current_step_index: status?.currentStepIndex ?? 0,
timestamp: Date.now(),
},
});
break;
}
case "request_trial_events": {
const events = await wsManager.getTrialEvents(
trialId,
message.data?.limit ?? 100,
);
wsManager.sendToClient(clientId, {
type: "trial_events_snapshot",
data: { events, timestamp: Date.now() },
});
break;
}
case "ping":
wsManager.sendToClient(clientId, {
type: "pong",
data: { timestamp: Date.now() },
});
break;
default:
console.log(
`[WS] Unknown message type from client ${clientId}:`,
message.type,
);
case "request_trial_status": {
const status = await wsManager.getTrialStatus(trialId);
wsManager.sendToClient(clientId, {
type: "trial_status",
data: {
trial: status?.trial ?? null,
current_step_index: status?.currentStepIndex ?? 0,
timestamp: Date.now(),
},
});
break;
}
} catch (error) {
console.error(`[WS] Error processing message from ${clientId}:`, error);
case "request_trial_events": {
const events = await wsManager.getTrialEvents(
trialId,
message.data?.limit ?? 100,
);
wsManager.sendToClient(clientId, {
type: "trial_events_snapshot",
data: { events, timestamp: Date.now() },
});
break;
}
case "ping":
wsManager.sendToClient(clientId, {
type: "pong",
data: { timestamp: Date.now() },
});
break;
default:
console.log(
`[WS] Unknown message type from client ${clientId}:`,
message.type,
);
}
})();
} catch (error) {
console.error(`[WS] Error processing message from ${clientId}:`, error);
}
});
serverWebSocket.addEventListener("close", () => {
@@ -495,14 +495,12 @@ export function DesignerRoot({
// console.log('[DesignerRoot] Steps changed, scheduling hash recomputation');
const timeoutId = setTimeout(() => {
void (async () => {
// console.log('[DesignerRoot] Executing debounced hash recomputation');
const result = await recomputeHash();
if (result) {
// console.log('[DesignerRoot] Hash recomputed:', result.designHash.slice(0, 16));
}
})();
const timeoutId = setTimeout(async () => {
// console.log('[DesignerRoot] Executing debounced hash recomputation');
const result = await recomputeHash();
if (result) {
// console.log('[DesignerRoot] Hash recomputed:', result.designHash.slice(0, 16));
}
}, 300); // Debounce 300ms
return () => clearTimeout(timeoutId);
@@ -901,7 +899,7 @@ export function DesignerRoot({
// Detect target based on over id
if (overId.startsWith("s-act-")) {
const data = over.data.current;
if (data?.stepId) {
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
@@ -910,7 +908,7 @@ export function DesignerRoot({
} else if (overId.startsWith("container-")) {
// Dropping into a container (e.g. Loop)
const data = over.data.current;
if (data?.stepId) {
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? overId.slice("container-".length);
// If dropping into container, appending is a safe default if specific index logic is missing
@@ -946,7 +944,12 @@ export function DesignerRoot({
if (stepId) {
const current = store.insertionProjection;
// Optimization: avoid redundant updates if projection matches
if (current?.stepId === stepId && current?.index === index) {
if (
current &&
current.stepId === stepId &&
current.parentId === parentId &&
current.index === index
) {
return;
}
@@ -460,7 +460,7 @@ export const createDesignerStore = (props: {
reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(
stepId,
get().steps.find((s) => s.id === stepId)?.actions[from]?.id as string,
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
null,
to,
), // Legacy compat support (only works for root level reorder)
+1 -1
View File
@@ -43,7 +43,7 @@ export function FormFieldRenderer({
className: error ? "border-destructive" : "",
};
const scale = field.settings?.scale ?? 5;
const scale = (field.settings?.scale as number) || 5;
switch (field.type) {
case "text":
@@ -40,7 +40,7 @@ export function ConsentUploadForm({
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
@@ -246,10 +246,9 @@ export function DigitalSignatureModal({
Agreement
</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
By clicking &quot;Submit Signed Document&quot;, you confirm that
you have read and understood the information provided in the
document preview, and you voluntarily agree to participate in
this study.
By clicking "Submit Signed Document", you confirm that you have
read and understood the information provided in the document
preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="mt-2 w-full"
@@ -103,7 +103,9 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
return null;
}, [events, currentEventIndex]);
const rowRefs = React.useRef<Record<string, HTMLTableRowElement | null>>({});
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>(
{},
);
React.useEffect(() => {
if (activeEventId && rowRefs.current[activeEventId]) {
@@ -279,7 +281,7 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
<strong>{d?.command || d?.type || "Action"}</strong>
{text && (
<span className="text-muted-foreground ml-1">
&quot;{text}&quot;
"{text}"
</span>
)}
</span>
@@ -309,7 +309,7 @@ export function ActionControls({
</Card>
{/* Step-Specific Controls */}
{currentStep?.type === "wizard_action" && (
{currentStep && currentStep.type === "wizard_action" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
@@ -80,7 +80,7 @@ export function RobotSettingsModal({
});
// Initialize settings from current configuration
// eslint-disable-next-line react-hooks/exhaustive-deps
useState(() => {
if (currentSettings) {
setSettings(currentSettings as Record<string, unknown>);
@@ -103,7 +103,7 @@ export function RobotSettingsModal({
const renderField = (
key: string,
schema: PropertySchema,
parentPath = "",
parentPath: string = "",
) => {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
const value = getNestedValue(settings, fullPath);
@@ -78,7 +78,10 @@ export function WebcamPanel({
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
if (mediaRecorderRef.current?.state === "recording") {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
console.log("Already recording, skipping start");
return;
}
+2 -2
View File
@@ -151,8 +151,8 @@ type StateListener = (state: GlobalWSState) => void;
class GlobalWebSocketManager {
private ws: WebSocket | null = null;
private subscriptions = new Map<string, Subscription>();
private stateListeners = new Set<StateListener>();
private subscriptions: Map<string, Subscription> = new Map();
private stateListeners: Set<StateListener> = new Set();
private sessionRef: { user: { id: string } } | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
+1 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
type WizardRosService,
WizardRosService,
type RobotStatus,
type RobotActionExecution,
getWizardRosService,
@@ -97,7 +97,7 @@ const steps: ExperimentStep[] = [
}
const first = converted[0];
if (first?.actions.length !== 2) {
if (!first || first.actions.length !== 2) {
throw new Error(
`Expected first converted step to contain 2 actions, got ${first?.actions.length ?? "undefined"}`,
);
@@ -229,7 +229,7 @@ export function convertDatabaseToAction(dbAction: any): ExperimentAction {
// Robust Inference: If properties are missing but Type suggests a plugin (e.g., "nao6-ros2.say_text"),
// assume/infer the pluginId to ensure validation passes.
if (dbAction.type?.includes(".") && !source.pluginId) {
if (dbAction.type && dbAction.type.includes(".") && !source.pluginId) {
const parts = dbAction.type.split(".");
if (parts.length === 2) {
source.kind = "plugin";
+2 -2
View File
@@ -44,7 +44,7 @@ export async function downloadPdfFromHtml(
htmlContent: string,
options: PdfOptions = {},
): Promise<void> {
// Dynamic import to prevent SSR issues with window/document
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import("html2pdf.js")).default;
const { printWrapper, element } = createPrintWrapper(htmlContent);
@@ -61,7 +61,7 @@ export async function generatePdfBlobFromHtml(
htmlContent: string,
options: PdfOptions = {},
): Promise<Blob> {
// Dynamic import to prevent SSR issues with window/document
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
const html2pdf = (await import("html2pdf.js")).default;
const { printWrapper, element } = createPrintWrapper(htmlContent);
+61 -160
View File
@@ -90,15 +90,13 @@ export class WizardRosService extends EventEmitter {
};
// Active action tracking
private activeActions = new Map<string, RobotActionExecution>();
private activeActions: Map<string, RobotActionExecution> = new Map();
constructor(url = "ws://localhost:9090", simulationMode = false) {
constructor(url: string = "ws://localhost:9090", simulationMode: boolean = false) {
super();
this.url = url;
this.simulationMode =
simulationMode ||
(typeof window !== "undefined" &&
process.env.NEXT_PUBLIC_SIMULATION_MODE === "true");
this.simulationMode = simulationMode ||
(typeof window !== "undefined" && process.env.NEXT_PUBLIC_SIMULATION_MODE === "true");
}
/**
@@ -244,13 +242,10 @@ export class WizardRosService extends EventEmitter {
connected: true,
battery: 85,
position: { x: 0, y: 0, theta: 0 },
joints: mockStates.names.reduce(
(acc, name, i) => {
acc[name] = mockStates.positions[i] ?? 0;
return acc;
},
{} as Record<string, number>,
),
joints: mockStates.names.reduce((acc, name, i) => {
acc[name] = mockStates.positions[i] ?? 0;
return acc;
}, {} as Record<string, number>),
sensors: {},
lastUpdate: new Date(),
};
@@ -303,32 +298,11 @@ export class WizardRosService extends EventEmitter {
*/
private getMockJointStates(): { names: string[]; positions: number[] } {
const names = [
"HeadYaw",
"HeadPitch",
"LShoulderPitch",
"LShoulderRoll",
"LElbowYaw",
"LElbowRoll",
"LWristYaw",
"LHand",
"RShoulderPitch",
"RShoulderRoll",
"RElbowYaw",
"RElbowRoll",
"RWristYaw",
"RHand",
"LHipYawPitch",
"LHipRoll",
"LHipPitch",
"LKneePitch",
"LAnklePitch",
"LAnkleRoll",
"RHipYawPitch",
"RHipRoll",
"RHipPitch",
"RKneePitch",
"RAnklePitch",
"RAnkleRoll",
"HeadYaw", "HeadPitch",
"LShoulderPitch", "LShoulderRoll", "LElbowYaw", "LElbowRoll", "LWristYaw", "LHand",
"RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RElbowRoll", "RWristYaw", "RHand",
"LHipYawPitch", "LHipRoll", "LHipPitch", "LKneePitch", "LAnklePitch", "LAnkleRoll",
"RHipYawPitch", "RHipRoll", "RHipPitch", "RKneePitch", "RAnklePitch", "RAnkleRoll",
];
const positions = names.map(() => (Math.random() - 0.5) * 0.1);
return { names, positions };
@@ -368,17 +342,11 @@ export class WizardRosService extends EventEmitter {
execution.status = "executing";
this.activeActions.set(executionId, execution);
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[];
};
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";
@@ -392,19 +360,11 @@ export class WizardRosService extends EventEmitter {
// Simulate action execution based on action type
let duration = 500;
if (
actionId === "say_text" ||
actionId === "say_with_emotion" ||
actionConfig?.topic === "/speech"
) {
if (actionId === "say_text" || actionId === "say_with_emotion" || actionConfig?.topic === "/speech") {
const text = String(parameters.text || parameters.data || "Hello");
const wordCount = text.split(/\s+/).filter(Boolean).length;
duration = 1500 + Math.max(1000, wordCount * 300);
} else if (
actionId.includes("walk") ||
actionId === "stop_walking" ||
actionConfig?.topic === "/cmd_vel"
) {
} else if (actionId.includes("walk") || actionId === "stop_walking" || actionConfig?.topic === "/cmd_vel") {
duration = 500;
const speed = Number(parameters.speed) || 0.1;
if (actionId === "walk_forward") {
@@ -418,11 +378,7 @@ export class WizardRosService extends EventEmitter {
}
} else if (actionConfig?.topic === "/joint_angles") {
duration = 1000;
} else if (
actionId === "wake_up" ||
actionId === "rest" ||
actionId === "set_posture"
) {
} else if (actionId === "wake_up" || actionId === "rest" || actionId === "set_posture") {
duration = 2000;
}
@@ -443,6 +399,8 @@ export class WizardRosService extends EventEmitter {
return execution;
}
/**
* Check if connected to ROS bridge
*/
@@ -483,12 +441,7 @@ export class WizardRosService extends EventEmitter {
// Simulation mode - simulate action execution
if (this.simulationMode) {
return this.executeSimulationAction(
pluginName,
actionId,
parameters,
actionConfig,
);
return this.executeSimulationAction(pluginName, actionId, parameters, actionConfig);
}
const executionId = `${pluginName}_${actionId}_${Date.now()}`;
@@ -541,13 +494,9 @@ export class WizardRosService extends EventEmitter {
* 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> {
async executeAnimationSequence(movements: AnimationMovement[]): Promise<void> {
if (!movements.length) {
console.warn(
"[WizardROS] executeAnimationSequence called with empty movements",
);
console.warn("[WizardROS] executeAnimationSequence called with empty movements");
return;
}
@@ -614,10 +563,7 @@ export class WizardRosService extends EventEmitter {
this.advertise("/speech", "std_msgs/String");
this.advertise("/cmd_vel", "geometry_msgs/Twist");
this.advertise(
"/joint_angles",
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
);
this.advertise("/joint_angles", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed");
this.advertise("/robot_pose", "geometry_msgs/Pose");
this.advertise("/animation", "std_msgs/String");
}
@@ -779,10 +725,7 @@ export class WizardRosService extends EventEmitter {
actionId?: string,
): Promise<void> {
// SSH command actions
if (
config.payloadMapping.type === "ssh" &&
config.payloadMapping.sshCommand
) {
if (config.payloadMapping.type === "ssh" && config.payloadMapping.sshCommand) {
await this.executeSSHCommand(config.payloadMapping.sshCommand);
return;
}
@@ -804,15 +747,9 @@ export class WizardRosService extends EventEmitter {
config.payloadMapping.type === "static") &&
config.payloadMapping.payload
) {
msg = this.buildTemplatePayload(
config.payloadMapping.payload,
parameters,
);
msg = this.buildTemplatePayload(config.payloadMapping.payload, parameters);
} else if (config.payloadMapping.transformFn) {
msg = this.applyTransformFunction(
config.payloadMapping.transformFn,
parameters,
);
msg = this.applyTransformFunction(config.payloadMapping.transformFn, parameters);
} else {
msg = parameters;
}
@@ -824,18 +761,13 @@ export class WizardRosService extends EventEmitter {
console.warn("[WizardROS] gesture_sequence payload has no movements");
return;
}
console.log(
`[WizardROS] Delegating to animation handler (${movements.length} frames)`,
);
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_")
) {
if (config.topic === "/animation" && actionId?.startsWith("play_animation_")) {
await this.executeAnimationSSH(actionId);
return;
}
@@ -1037,10 +969,7 @@ export class WizardRosService extends EventEmitter {
// Simulation mode - return mock responses
if (this.simulationMode) {
console.log(
`[WizardROS] SIMULATION MODE - Service call: ${service}`,
args,
);
console.log(`[WizardROS] SIMULATION MODE - Service call: ${service}`, args);
const mockResponses: Record<string, ServiceResponse> = {
"/naoqi_driver/get_robot_info": {
@@ -1055,32 +984,13 @@ export class WizardRosService extends EventEmitter {
result: true,
values: {
joint_names: [
"HeadYaw",
"HeadPitch",
"LShoulderPitch",
"LShoulderRoll",
"LElbowYaw",
"LElbowRoll",
"LWristYaw",
"LHand",
"RShoulderPitch",
"RShoulderRoll",
"RElbowYaw",
"RElbowRoll",
"RWristYaw",
"RHand",
"LHipYawPitch",
"LHipRoll",
"LHipPitch",
"LKneePitch",
"LAnklePitch",
"LAnkleRoll",
"RHipYawPitch",
"RHipRoll",
"RHipPitch",
"RKneePitch",
"RAnklePitch",
"RAnkleRoll",
"HeadYaw", "HeadPitch", "LShoulderPitch", "LShoulderRoll",
"LElbowYaw", "LElbowRoll", "LWristYaw", "LHand",
"RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RElbowRoll",
"RWristYaw", "RHand", "LHipYawPitch", "LHipRoll",
"LHipPitch", "LKneePitch", "LAnklePitch", "LAnkleRoll",
"RHipYawPitch", "RHipRoll", "RHipPitch", "RKneePitch",
"RAnklePitch", "RAnkleRoll",
],
},
},
@@ -1131,13 +1041,13 @@ export class WizardRosService extends EventEmitter {
*/
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",
"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];
@@ -1148,9 +1058,7 @@ export class WizardRosService extends EventEmitter {
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}'`,
);
await this.executeSSHCommand(`qicli call ALAnimationPlayer.run '${animation}'`);
console.log(`[WizardROS] Animation completed: ${animation}`);
}
@@ -1455,20 +1363,18 @@ export class WizardRosService extends EventEmitter {
`[WizardROS] Scheduling reconnect attempt ${this.connectionAttempts}/${this.maxReconnectAttempts}`,
);
this.reconnectTimer = setTimeout(() => {
void (async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch (error) {
console.warn("[WizardROS] Reconnect failed:", error);
if (this.connectionAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
} else {
this.emit("max_reconnects_reached");
}
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch (error) {
console.warn("[WizardROS] Reconnect failed:", error);
if (this.connectionAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
} else {
this.emit("max_reconnects_reached");
}
})();
}
}, this.reconnectInterval);
}
@@ -1490,9 +1396,7 @@ let isCreatingInstance = false;
/**
* Get or create the global wizard ROS service (true singleton)
*/
export function getWizardRosService(
simulationMode?: boolean,
): WizardRosService {
export function getWizardRosService(simulationMode?: boolean): WizardRosService {
// Prevent multiple instances during creation
if (isCreatingInstance && !wizardRosService) {
throw new Error("WizardRosService is being initialized, please wait");
@@ -1501,10 +1405,9 @@ export function getWizardRosService(
if (!wizardRosService) {
isCreatingInstance = true;
try {
const url =
typeof window !== "undefined"
? process.env.NEXT_PUBLIC_ROS_BRIDGE_URL || "ws://localhost:9090"
: "ws://localhost:9090";
const url = typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_ROS_BRIDGE_URL || "ws://localhost:9090")
: "ws://localhost:9090";
wizardRosService = new WizardRosService(url, simulationMode);
} finally {
isCreatingInstance = false;
@@ -1516,9 +1419,7 @@ export function getWizardRosService(
/**
* Initialize wizard ROS service with connection
*/
export async function initWizardRosService(
simulationMode?: boolean,
): Promise<WizardRosService> {
export async function initWizardRosService(simulationMode?: boolean): Promise<WizardRosService> {
const service = getWizardRosService(simulationMode);
if (simulationMode !== undefined) {
+2 -2
View File
@@ -501,7 +501,7 @@ export const studiesRouter = createTRPCRouter({
),
});
if (membership?.role !== "owner") {
if (!membership || membership.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only study owners can remove members",
@@ -521,7 +521,7 @@ export const studiesRouter = createTRPCRouter({
},
});
if (memberToRemove?.studyId !== studyId) {
if (!memberToRemove || memberToRemove.studyId !== studyId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Member not found",
+31 -45
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import WebSocket from "ws";
@@ -193,9 +193,8 @@ export class RobotCommunicationService extends EventEmitter {
: actionType;
const isAnimationAction = baseActionId?.startsWith("play_animation_");
const sshCommand =
implementation.payloadMapping?.sshCommand ||
implementation.ros2?.payloadMapping?.sshCommand;
const sshCommand = implementation.payloadMapping?.sshCommand
|| implementation.ros2?.payloadMapping?.sshCommand;
// SSH actions don't require ROS connection
if (isAnimationAction || sshCommand) {
@@ -205,11 +204,7 @@ export class RobotCommunicationService extends EventEmitter {
try {
console.log(`[RobotComm] Executing SSH action: ${action.actionId}`);
const result = await this.executeRobotActionInternal(
action,
actionId,
startTime,
);
const result = await this.executeRobotActionInternal(action, actionId, startTime);
clearTimeout(timeout);
return result;
} catch (error) {
@@ -287,9 +282,8 @@ export class RobotCommunicationService extends EventEmitter {
}
// Check for SSH command type
const sshCommand =
implementation.payloadMapping?.sshCommand ||
implementation.ros2?.payloadMapping?.sshCommand;
const sshCommand = implementation.payloadMapping?.sshCommand
|| implementation.ros2?.payloadMapping?.sshCommand;
if (sshCommand) {
await this.executeSSHCommand(sshCommand);
@@ -302,9 +296,8 @@ export class RobotCommunicationService extends EventEmitter {
// Apply transform if specified
let message: Record<string, unknown>;
const transformFn =
implementation.payloadMapping?.transformFn ||
implementation.ros2?.payloadMapping?.transformFn;
const transformFn = implementation.payloadMapping?.transformFn
|| implementation.ros2?.payloadMapping?.transformFn;
if (transformFn) {
message = this.applyTransform(transformFn, parameters);
@@ -359,13 +352,13 @@ export class RobotCommunicationService extends EventEmitter {
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",
"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];
@@ -389,9 +382,7 @@ export class RobotCommunicationService extends EventEmitter {
console.log(`[RobotComm] Animation result: ${stdout}`);
}
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
data: string;
} {
private transformToEmotionalSpeech(parameters: Record<string, unknown>): { data: string } {
const text = String(parameters.text || "Hello");
const emotion = String(parameters.emotion || "neutral");
@@ -406,10 +397,7 @@ export class RobotCommunicationService extends EventEmitter {
}
}
private applyTransform(
transformFn: string,
parameters: Record<string, unknown>,
): Record<string, unknown> {
private applyTransform(transformFn: string, parameters: Record<string, unknown>): Record<string, unknown> {
switch (transformFn) {
case "transformToEmotionalSpeech":
case "transformToEmotionSpeech":
@@ -452,8 +440,8 @@ export class RobotCommunicationService extends EventEmitter {
substituted.includes(":")
) {
// Simple conditional: {{condition ? valueTrue : valueFalse}}
const match = /\{\{(.+?)\s*\?\s*(.+?)\s*:\s*(.+?)\}\}/.exec(
substituted,
const match = substituted.match(
/\{\{(.+?)\s*\?\s*(.+?)\s*:\s*(.+?)\}\}/,
);
if (match && match.length >= 4) {
const condition = match[1];
@@ -599,23 +587,21 @@ export class RobotCommunicationService extends EventEmitter {
`[RobotComm] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${this.config.reconnectInterval}ms`,
);
this.reconnectTimer = setTimeout(() => {
void (async () => {
this.reconnectTimer = null;
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch (error) {
console.error("[RobotComm] Reconnect failed:", error);
try {
await this.connect();
} catch (error) {
console.error("[RobotComm] Reconnect failed:", error);
if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.scheduleReconnect();
} else {
console.error("[RobotComm] Max reconnect attempts reached");
this.emit("max_reconnects_reached");
}
if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.scheduleReconnect();
} else {
console.error("[RobotComm] Max reconnect attempts reached");
this.emit("max_reconnects_reached");
}
})();
}
}, this.config.reconnectInterval);
}
+5 -5
View File
@@ -20,9 +20,9 @@ type OutgoingMessage = {
};
class WebSocketManager {
private clients = new Map<string, ClientConnection>();
private heartbeatIntervals =
new Map<string, ReturnType<typeof setInterval>>();
private clients: Map<string, ClientConnection> = new Map();
private heartbeatIntervals: Map<string, ReturnType<typeof setInterval>> =
new Map();
private getTrialRoomClients(trialId: string): ClientConnection[] {
const clients: ClientConnection[] = [];
@@ -224,7 +224,7 @@ class WebSocketManager {
async getTrialEvents(
trialId: string,
limit = 100,
limit: number = 100,
): Promise<unknown[]> {
const events = await db
.select()
@@ -248,7 +248,7 @@ class WebSocketManager {
return null;
}
getTrialEventsSync(trialId: string, limit = 100): unknown[] {
getTrialEventsSync(trialId: string, limit: number = 100): unknown[] {
return [];
}