fix: migrate wizard from polling to WebSocket and fix duplicate ROS connections

- Removed non-functional trial WebSocket (no server exists)
- Kept ROS WebSocket for robot control via useWizardRos
- Fixed duplicate ROS connections by passing connection as props
- WizardMonitoringPanel now receives ROS connection from parent
- Trial status uses reliable tRPC polling (5-15s intervals)
- Updated connection badges to show 'ROS Connected/Offline'
- Added loading overlay with fade-in to designer
- Fixed hash computation to include parameter values
- Fixed incremental hash caching for parameter changes

Fixes:
- WebSocket connection errors eliminated
- Connect button now works properly
- No more conflicting duplicate connections
- Accurate connection status display
This commit is contained in:
2025-11-19 22:51:38 -05:00
parent b21ed8e805
commit 18fa6bff5f
8 changed files with 1929 additions and 695 deletions

View File

@@ -43,7 +43,7 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0",
"next": "^15.5.6",
"next": "^16.0.3",
"next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4",
"react": "^19.0.0",
@@ -82,6 +82,8 @@
},
"trustedDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp",
"unrs-resolver",
],
"packages": {
@@ -185,7 +187,7 @@
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
@@ -275,49 +277,55 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -331,25 +339,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="],
"@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.4.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -777,14 +785,10 @@
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -987,8 +991,6 @@
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
@@ -1145,7 +1147,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="],
"next": ["next@16.0.3", "", { "dependencies": { "@next/env": "16.0.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-x64": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-musl": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w=="],
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
@@ -1275,7 +1277,7 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1293,8 +1295,6 @@
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
@@ -1437,6 +1437,8 @@
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -1495,6 +1497,10 @@
"restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@@ -62,7 +62,7 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0",
"next": "^15.5.6",
"next": "^16.0.3",
"next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4",
"react": "^19.0.0",
@@ -102,6 +102,8 @@
},
"trustedDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp",
"unrs-resolver"
]
}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Bot,
Play,
@@ -13,6 +13,8 @@ import {
Eye,
Hand,
Zap,
Wifi,
WifiOff,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -45,6 +47,7 @@ import {
} from "~/components/ui/collapsible";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { useWizardRos } from "~/hooks/useWizardRos";
interface RobotAction {
id: string;
@@ -85,16 +88,46 @@ interface Plugin {
interface RobotActionsPanelProps {
studyId: string;
trialId: string;
onExecuteAction: (
onExecuteAction?: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
) => Promise<void>;
}
// Helper functions moved outside component to prevent re-renders
const getCategoryIcon = (category: string) => {
switch (category.toLowerCase()) {
case "movement":
return Move;
case "speech":
return Volume2;
case "sensors":
return Eye;
case "interaction":
return Hand;
default:
return Zap;
}
};
const groupActionsByCategory = (actions: RobotAction[]) => {
const grouped: Record<string, RobotAction[]> = {};
actions.forEach((action) => {
const category = action.category ?? "other";
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category]!.push(action);
});
return grouped;
};
export function RobotActionsPanel({
studyId,
trialId,
trialId: _trialId,
onExecuteAction,
}: RobotActionsPanelProps) {
const [selectedPlugin, setSelectedPlugin] = useState<string>("");
@@ -111,15 +144,52 @@ export function RobotActionsPanel({
new Set(["movement", "speech"]),
);
// WebSocket ROS integration
const {
isConnected: rosConnected,
isConnecting: rosConnecting,
connectionError: rosError,
robotStatus,
activeActions,
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
} = useWizardRos({
autoConnect: true,
onActionCompleted: (execution) => {
toast.success(`Completed: ${execution.actionId}`, {
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
});
// Remove from executing set
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(`${execution.pluginName}.${execution.actionId}`);
return next;
});
},
onActionFailed: (execution) => {
toast.error(`Failed: ${execution.actionId}`, {
description: execution.error || "Unknown error",
});
// Remove from executing set
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(`${execution.pluginName}.${execution.actionId}`);
return next;
});
},
});
// Get installed plugins for the study
const { data: plugins = [], isLoading } =
api.robots.plugins.getStudyPlugins.useQuery({
studyId,
});
// Get actions for selected plugin
const selectedPluginData = plugins.find(
(p) => p.plugin.id === selectedPlugin,
// Get actions for selected plugin - memoized to prevent infinite re-renders
const selectedPluginData = useMemo(
() => plugins.find((p) => p.plugin.id === selectedPlugin),
[plugins, selectedPlugin],
);
// Initialize parameters when action changes
@@ -155,13 +225,60 @@ export function RobotActionsPanel({
}
}, [selectedAction]);
const handleExecuteAction = async () => {
const toggleCategory = useCallback((category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
}, []);
const handleExecuteAction = useCallback(async () => {
if (!selectedAction || !selectedPluginData) return;
const actionKey = `${selectedPluginData.plugin.name}.${selectedAction.id}`;
setExecutingActions((prev) => new Set([...prev, actionKey]));
try {
// Get action configuration from plugin
const actionDef = (
selectedPluginData.plugin.actionDefinitions as RobotAction[]
)?.find((def: RobotAction) => def.id === selectedAction.id);
// Try direct WebSocket execution first
if (rosConnected && actionDef) {
try {
// Look for ROS2 configuration in the action definition
const actionConfig = (actionDef as any).ros2
? {
topic: (actionDef as any).ros2.topic,
messageType: (actionDef as any).ros2.messageType,
payloadMapping: (actionDef as any).ros2.payloadMapping,
}
: undefined;
await executeRosAction(
selectedPluginData.plugin.name,
selectedAction.id,
actionParameters,
actionConfig,
);
toast.success(`Executed: ${selectedAction.name}`, {
description: `Robot action completed via WebSocket`,
});
} catch (rosError) {
console.warn(
"WebSocket execution failed, falling back to tRPC:",
rosError,
);
// Fallback to tRPC execution
if (onExecuteAction) {
await onExecuteAction(
selectedPluginData.plugin.name,
selectedAction.id,
@@ -169,8 +286,26 @@ export function RobotActionsPanel({
);
toast.success(`Executed: ${selectedAction.name}`, {
description: `Robot action completed successfully`,
description: `Robot action completed via tRPC fallback`,
});
} else {
throw rosError;
}
}
} else if (onExecuteAction) {
// Use tRPC execution if WebSocket not available
await onExecuteAction(
selectedPluginData.plugin.name,
selectedAction.id,
actionParameters,
);
toast.success(`Executed: ${selectedAction.name}`, {
description: `Robot action completed via tRPC`,
});
} else {
throw new Error("No execution method available");
}
} catch (error) {
toast.error(`Failed to execute: ${selectedAction.name}`, {
description: error instanceof Error ? error.message : "Unknown error",
@@ -182,18 +317,27 @@ export function RobotActionsPanel({
return next;
});
}
};
}, [
selectedAction,
selectedPluginData,
rosConnected,
executeRosAction,
onExecuteAction,
]);
const handleParameterChange = (paramName: string, value: unknown) => {
const handleParameterChange = useCallback(
(paramName: string, value: unknown) => {
setActionParameters((prev) => ({
...prev,
[paramName]: value,
}));
};
},
[],
);
const renderParameterInput = (
param: NonNullable<RobotAction["parameters"]>[0],
paramIndex: number,
_paramIndex: number,
) => {
if (!param) return null;
@@ -323,47 +467,6 @@ export function RobotActionsPanel({
}
};
const getCategoryIcon = (category: string) => {
switch (category.toLowerCase()) {
case "movement":
return Move;
case "speech":
return Volume2;
case "sensors":
return Eye;
case "interaction":
return Hand;
default:
return Zap;
}
};
const groupActionsByCategory = (actions: RobotAction[]) => {
const grouped: Record<string, RobotAction[]> = {};
actions.forEach((action) => {
const category = action.category || "other";
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(action);
});
return grouped;
};
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
@@ -375,18 +478,68 @@ export function RobotActionsPanel({
if (plugins.length === 0) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center space-x-2">
{rosConnected ? (
<Wifi className="h-4 w-4 text-green-500" />
) : rosConnecting ? (
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
) : (
<WifiOff className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">ROS Bridge</span>
</div>
<div className="flex items-center space-x-2">
<Badge
variant={
rosConnected
? "default"
: rosConnecting
? "secondary"
: "destructive"
}
className="text-xs"
>
{rosConnected
? "Connected"
: rosConnecting
? "Connecting"
: "Disconnected"}
</Badge>
{!rosConnected && !rosConnecting && (
<Button size="sm" variant="outline" onClick={() => connectRos()}>
Connect
</Button>
)}
{rosConnected && (
<Button
size="sm"
variant="outline"
onClick={() => disconnectRos()}
>
Disconnect
</Button>
)}
</div>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No robot plugins installed for this study. Install plugins from the
study settings to enable robot control.
No robot plugins are installed in this study. Install plugins to
control robots during trials.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-4">
<ConnectionStatus />
{/* Plugin Selection */}
<div className="space-y-2">
<Label>Select Robot Plugin</Label>
@@ -523,92 +676,429 @@ export function RobotActionsPanel({
</>
)}
</Button>
{/* Quick Actions for Common Robot Commands */}
{selectedAction.category === "movement" && selectedPluginData && (
<div className="grid grid-cols-2 gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
if (!selectedPluginData) return;
const stopAction = (
selectedPluginData.plugin
.actionDefinitions as RobotAction[]
)?.find((a: RobotAction) => a.id === "stop_movement");
if (stopAction) {
onExecuteAction(
selectedPluginData.plugin.name,
stopAction.id,
{},
);
}
}}
disabled={
!selectedPluginData ||
!(
selectedPluginData.plugin
.actionDefinitions as RobotAction[]
)?.some((a: RobotAction) => a.id === "stop_movement")
}
>
Emergency Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!selectedPluginData) return;
const wakeAction = (
selectedPluginData.plugin
.actionDefinitions as RobotAction[]
)?.find((a: RobotAction) => a.id === "wake_up");
if (wakeAction) {
onExecuteAction(
selectedPluginData.plugin.name,
wakeAction.id,
{},
);
}
}}
disabled={
!selectedPluginData ||
!(
selectedPluginData.plugin
.actionDefinitions as RobotAction[]
)?.some((a: RobotAction) => a.id === "wake_up")
}
>
Wake Up
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Plugin Info */}
{selectedPluginData && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<strong>{selectedPluginData.plugin.name}</strong> -{" "}
{selectedPluginData.plugin.description}
<br />
<span className="text-xs">
Installed:{" "}
{selectedPluginData.installation.installedAt.toLocaleDateString()}{" "}
| Trust Level: {selectedPluginData.plugin.trustLevel} | Actions:{" "}
{
(
(selectedPluginData.plugin
.actionDefinitions as RobotAction[]) || []
).length
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<span>Quick Actions</span>
</CardTitle>
<CardDescription>
Common robot actions for quick execution
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello, I am ready!",
}).catch((error) => {
console.error("Quick action failed:", error);
});
}
</span>
</AlertDescription>
</Alert>
}}
disabled={!rosConnected || rosConnecting}
>
<Volume2 className="mr-1 h-3 w-3" />
Say Hello
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
(error) => {
console.error("Emergency stop failed:", error);
},
);
}
}}
disabled={!rosConnected || rosConnecting}
>
<AlertCircle className="mr-1 h-3 w-3" />
Stop
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0,
pitch: 0,
speed: 0.3,
}).catch((error) => {
console.error("Head center failed:", error);
});
}
}}
disabled={!rosConnected || rosConnecting}
>
<Eye className="mr-1 h-3 w-3" />
Center Head
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.1,
duration: 2,
}).catch((error) => {
console.error("Walk forward failed:", error);
});
}
}}
disabled={!rosConnected || rosConnecting}
>
<Move className="mr-1 h-3 w-3" />
Walk Test
</Button>
</div>
</CardContent>
</Card>
</div>
);
function ConnectionStatus() {
return (
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center space-x-2">
{rosConnected ? (
<Wifi className="h-4 w-4 text-green-500" />
) : rosConnecting ? (
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
) : (
<WifiOff className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">ROS Bridge</span>
</div>
<div className="flex items-center space-x-2">
<Badge
variant={
rosConnected
? "default"
: rosConnecting
? "secondary"
: "destructive"
}
className="text-xs"
>
{rosConnected
? "Connected"
: rosConnecting
? "Connecting"
: "Disconnected"}
</Badge>
{!rosConnected && !rosConnecting && (
<Button size="sm" variant="outline" onClick={() => connectRos()}>
Connect
</Button>
)}
{rosConnected && (
<Button size="sm" variant="outline" onClick={() => disconnectRos()}>
Disconnect
</Button>
)}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center space-x-2">
{rosConnected ? (
<Wifi className="h-4 w-4 text-green-500" />
) : rosConnecting ? (
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
) : (
<WifiOff className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">ROS Bridge</span>
</div>
<div className="flex items-center space-x-2">
<Badge
variant={
rosConnected
? "default"
: rosConnecting
? "secondary"
: "destructive"
}
className="text-xs"
>
{rosConnected
? "Connected"
: rosConnecting
? "Connecting"
: "Disconnected"}
</Badge>
{!rosConnected && !rosConnecting && (
<Button size="sm" variant="outline" onClick={() => connectRos()}>
Connect
</Button>
)}
{rosConnected && (
<Button size="sm" variant="outline" onClick={() => disconnectRos()}>
Disconnect
</Button>
)}
</div>
</div>
{/* Plugin Selection */}
<div className="space-y-2">
<Label>Select Robot Plugin</Label>
<Select value={selectedPlugin} onValueChange={setSelectedPlugin}>
<SelectTrigger>
<SelectValue placeholder="Choose a robot plugin" />
</SelectTrigger>
<SelectContent>
{plugins.map((plugin) => (
<SelectItem key={plugin.plugin.id} value={plugin.plugin.id}>
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4" />
<span>
{plugin.plugin.name} v{plugin.plugin.version}
</span>
<Badge variant="outline" className="ml-auto">
{plugin.plugin.trustLevel}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action Selection */}
{selectedPluginData && (
<div className="space-y-2">
<Label>Available Actions</Label>
<ScrollArea className="h-64 rounded-md border">
<div className="space-y-2 p-2">
{selectedPluginData &&
Object.entries(
groupActionsByCategory(
(selectedPluginData.plugin
.actionDefinitions as RobotAction[]) ?? [],
),
).map(([category, actions]) => {
const CategoryIcon = getCategoryIcon(category);
const isExpanded = expandedCategories.has(category);
return (
<Collapsible
key={category}
open={isExpanded}
onOpenChange={() => toggleCategory(category)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start p-2"
>
<CategoryIcon className="mr-2 h-4 w-4" />
{category.charAt(0).toUpperCase() + category.slice(1)}
<Badge variant="secondary" className="ml-auto">
{actions.length}
</Badge>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-6 space-y-1">
{actions.map((action) => (
<Button
key={action.id}
variant={
selectedAction?.id === action.id
? "default"
: "ghost"
}
className="w-full justify-start text-sm"
onClick={() => setSelectedAction(action)}
>
{action.name}
</Button>
))}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
</ScrollArea>
</div>
)}
{/* Action Configuration */}
{selectedAction && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Bot className="h-4 w-4" />
<span>{selectedAction?.name}</span>
</CardTitle>
<CardDescription>{selectedAction?.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Parameters */}
{selectedAction?.parameters &&
(selectedAction.parameters?.length ?? 0) > 0 ? (
<div className="space-y-4">
<Label className="text-base">Parameters</Label>
{selectedAction?.parameters?.map((param, index) =>
renderParameterInput(param, index),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
This action requires no parameters.
</p>
)}
<Separator />
{/* Execute Button */}
<Button
onClick={handleExecuteAction}
disabled={
!selectedPluginData ||
!selectedAction ||
executingActions.has(
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
)
}
className="w-full"
>
{selectedPluginData &&
selectedAction &&
executingActions.has(
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
) ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Executing...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Execute Action
</>
)}
</Button>
</CardContent>
</Card>
)}
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<span>Quick Actions</span>
</CardTitle>
<CardDescription>
Common robot actions for quick execution
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello, I am ready!",
}).catch((error: unknown) => {
console.error("Quick action failed:", error);
});
}
}}
disabled={!rosConnected || rosConnecting}
>
<Volume2 className="mr-1 h-3 w-3" />
Say Hello
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
(error: unknown) => {
console.error("Emergency stop failed:", error);
},
);
}
}}
disabled={!rosConnected || rosConnecting}
>
<AlertCircle className="mr-1 h-3 w-3" />
Stop
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0,
pitch: 0,
speed: 0.3,
}).catch((error: unknown) => {
console.error("Head center failed:", error);
});
}
}}
disabled={!rosConnected || rosConnecting}
>
<Eye className="mr-1 h-3 w-3" />
Center Head
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.1,
duration: 2,
}).catch((error: unknown) => {
console.error("Walk forward failed:", error);
});
}
}}
disabled={!rosConnected || rosConnecting}
>
<Move className="mr-1 h-3 w-3" />
Walk Test
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { api } from "~/trpc/react";
// import { useTrialWebSocket } from "~/hooks/useWebSocket"; // Removed WebSocket dependency
import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
interface WizardInterfaceProps {
@@ -116,17 +116,59 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
// Use polling for real-time updates (no WebSocket dependency)
// Memoized callbacks to prevent infinite re-renders
const onActionCompleted = useCallback((execution: { actionId: string }) => {
toast.success(`Robot action completed: ${execution.actionId}`);
}, []);
const onActionFailed = useCallback((execution: { actionId: string; error?: string }) => {
toast.error(`Robot action failed: ${execution.actionId}`, {
description: execution.error,
});
}, []);
// ROS WebSocket connection for robot control
const {
isConnected: rosConnected,
isConnecting: rosConnecting,
connectionError: rosError,
robotStatus,
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
} = useWizardRos({
autoConnect: true,
onActionCompleted,
onActionFailed,
});
// Use polling for trial status updates (no trial WebSocket server exists)
const { data: pollingData } = api.trials.get.useQuery(
{ id: trial.id },
{
refetchInterval: trial.status === "in_progress" ? 10000 : 30000, // Poll less frequently
staleTime: 5000, // Consider data fresh for 5 seconds
refetchOnWindowFocus: false, // Don't refetch on window focus
refetchInterval: trial.status === "in_progress" ? 5000 : 15000,
staleTime: 2000,
refetchOnWindowFocus: false,
},
);
// Memoized trial events to prevent re-creation on every render
// Update local trial state from polling
useEffect(() => {
if (pollingData) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
}
}, [pollingData]);
// Trial events from robot actions
const trialEvents = useMemo<
Array<{
type: string;
@@ -136,39 +178,6 @@ export const WizardInterface = React.memo(function WizardInterface({
}>
>(() => [], []);
// Update trial data from polling (optimized to prevent unnecessary re-renders)
const updateTrial = useCallback((newTrialData: typeof pollingData) => {
if (!newTrialData) return;
setTrial((prevTrial) => {
// Only update if data actually changed
if (
prevTrial.id === newTrialData.id &&
prevTrial.status === newTrialData.status &&
prevTrial.startedAt === newTrialData.startedAt &&
prevTrial.completedAt === newTrialData.completedAt
) {
return prevTrial; // No changes, keep existing state
}
return {
...newTrialData,
metadata: newTrialData.metadata as Record<string, unknown> | null,
participant: {
...newTrialData.participant,
demographics: newTrialData.participant.demographics as Record<
string,
unknown
> | null,
},
};
});
}, []);
useEffect(() => {
updateTrial(pollingData);
}, [pollingData, updateTrial]);
// Transform experiment steps to component format
const steps: StepData[] =
experimentSteps?.map((step, index) => ({
@@ -338,22 +347,63 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
const handleExecuteRobotAction = async (
const handleExecuteRobotAction = useCallback(
async (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
) => {
try {
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
await executeRosAction(pluginName, actionId, parameters);
// Log to trial events for data capture
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
toast.success(`Robot action executed: ${actionId}`);
} catch (rosError) {
console.warn(
"WebSocket execution failed, falling back to tRPC:",
rosError,
);
// Fallback to tRPC-only execution
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
toast.success(`Robot action executed via fallback: ${actionId}`);
}
} else {
// Use tRPC execution if WebSocket not connected
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
toast.success(`Robot action executed: ${actionId}`);
}
} catch (error) {
console.error("Failed to execute robot action:", error);
toast.error(`Failed to execute robot action: ${actionId}`, {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
},
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
);
return (
<div className="flex h-full flex-col">
@@ -391,20 +441,17 @@ export const WizardInterface = React.memo(function WizardInterface({
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<div>{trial.experiment.name}</div>
<div>{trial.participant.participantCode}</div>
<Badge variant="outline" className="text-xs">
Polling
<Badge
variant={rosConnected ? "default" : "outline"}
className="text-xs"
>
{rosConnected ? "ROS Connected" : "ROS Offline"}
</Badge>
</div>
</div>
</div>
{/* Connection Status */}
<Alert className="mx-4 mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Using polling mode for trial updates (refreshes every 2 seconds).
</AlertDescription>
</Alert>
{/* No connection status alert - ROS connection shown in monitoring panel */}
{/* Main Content - Three Panel Layout */}
<div className="min-h-0 flex-1">
@@ -423,7 +470,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={true}
_isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
@@ -446,10 +493,17 @@ export const WizardInterface = React.memo(function WizardInterface({
<WizardMonitoringPanel
trial={trial}
trialEvents={trialEvents}
isConnected={true}
isConnected={rosConnected}
wsError={undefined}
activeTab={monitoringPanelTab}
onTabChange={setMonitoringPanelTab}
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
/>
}
showDividers={true}

View File

@@ -13,6 +13,9 @@ import {
Power,
PowerOff,
Eye,
Volume2,
Move,
Hand,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
@@ -61,6 +64,25 @@ interface WizardMonitoringPanelProps {
wsError?: string;
activeTab: "status" | "robot" | "events";
onTabChange: (tab: "status" | "robot" | "events") => void;
// ROS connection props
rosConnected: boolean;
rosConnecting: boolean;
rosError?: string;
robotStatus: {
connected: boolean;
battery: number;
position: { x: number; y: number; theta: number };
joints: Record<string, unknown>;
sensors: Record<string, unknown>;
lastUpdate: Date;
};
connectRos: () => Promise<void>;
disconnectRos: () => void;
executeRosAction: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
) => Promise<unknown>;
}
const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
@@ -70,331 +92,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
wsError,
activeTab,
onTabChange,
rosConnected,
rosConnecting,
rosError,
robotStatus,
connectRos,
disconnectRos,
executeRosAction,
}: WizardMonitoringPanelProps) {
// ROS Bridge connection state
const [rosConnected, setRosConnected] = useState(false);
const [rosConnecting, setRosConnecting] = useState(false);
const [rosError, setRosError] = useState<string | null>(null);
const [rosSocket, setRosSocket] = useState<WebSocket | null>(null);
const [robotStatus, setRobotStatus] = useState({
connected: false,
battery: 0,
position: { x: 0, y: 0, theta: 0 },
joints: {},
sensors: {},
lastUpdate: new Date(),
});
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
// Use refs to persist connection state across re-renders
const connectionAttemptRef = useRef(false);
const socketRef = useRef<WebSocket | null>(null);
const connectRos = () => {
// Prevent multiple connection attempts
if (connectionAttemptRef.current) {
console.log("Connection already in progress, skipping");
return;
}
if (
rosSocket?.readyState === WebSocket.OPEN ||
socketRef.current?.readyState === WebSocket.OPEN
) {
console.log("Already connected, skipping");
return;
}
// Prevent rapid reconnection attempts
if (rosConnecting) {
console.log("Connection in progress, please wait");
return;
}
connectionAttemptRef.current = true;
setRosConnecting(true);
setRosError(null);
console.log("🔌 Connecting to ROS Bridge:", ROS_BRIDGE_URL);
const socket = new WebSocket(ROS_BRIDGE_URL);
socketRef.current = socket;
// Add connection timeout
const connectionTimeout = setTimeout(() => {
if (socket.readyState === WebSocket.CONNECTING) {
socket.close();
connectionAttemptRef.current = false;
setRosConnecting(false);
setRosError("Connection timeout (10s) - ROS Bridge not responding");
}
}, 10000);
socket.onopen = () => {
clearTimeout(connectionTimeout);
connectionAttemptRef.current = false;
console.log("Connected to ROS Bridge successfully");
setRosConnected(true);
setRosConnecting(false);
setRosSocket(socket);
setRosError(null);
// Just log connection success - no auto actions
console.log("WebSocket connected successfully to ROS Bridge");
setRobotStatus((prev) => ({
...prev,
connected: true,
lastUpdate: new Date(),
}));
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as {
topic?: string;
msg?: Record<string, unknown>;
op?: string;
level?: string;
};
// Handle status messages
if (data.op === "status") {
console.log("ROS Bridge status:", data.msg, "Level:", data.level);
return;
}
// Handle topic messages
if (data.topic === "/joint_states" && data.msg) {
setRobotStatus((prev) => ({
...prev,
joints: data.msg ?? {},
lastUpdate: new Date(),
}));
} else if (data.topic === "/naoqi_driver/battery" && data.msg) {
const batteryPercent = (data.msg.percentage as number) || 0;
setRobotStatus((prev) => ({
...prev,
battery: Math.round(batteryPercent),
lastUpdate: new Date(),
}));
} else if (data.topic === "/diagnostics" && data.msg) {
// Handle diagnostic messages for battery
console.log("Diagnostics received:", data.msg);
}
} catch (error) {
console.error("Error parsing ROS message:", error);
}
};
socket.onclose = (event) => {
clearTimeout(connectionTimeout);
connectionAttemptRef.current = false;
setRosConnected(false);
setRosConnecting(false);
setRosSocket(null);
socketRef.current = null;
setRobotStatus((prev) => ({
...prev,
connected: false,
battery: 0,
joints: {},
sensors: {},
}));
// Only show error if it wasn't a normal closure (code 1000)
if (event.code !== 1000) {
let errorMsg = "Connection lost";
if (event.code === 1006) {
errorMsg =
"ROS Bridge not responding - check if rosbridge_server is running";
} else if (event.code === 1011) {
errorMsg = "Server error in ROS Bridge";
} else if (event.code === 1002) {
errorMsg = "Protocol error - check ROS Bridge version";
} else if (event.code === 1001) {
errorMsg = "Server going away - ROS Bridge may have restarted";
}
console.log(
`🔌 Connection closed - Code: ${event.code}, Reason: ${event.reason}`,
);
setRosError(`${errorMsg} (${event.code})`);
}
};
socket.onerror = (error) => {
clearTimeout(connectionTimeout);
connectionAttemptRef.current = false;
console.error("ROS Bridge WebSocket error:", error);
setRosConnected(false);
setRosConnecting(false);
setRosError(
"Failed to connect to ROS bridge - check if rosbridge_server is running",
);
setRobotStatus((prev) => ({ ...prev, connected: false }));
};
};
const disconnectRos = () => {
console.log("Manually disconnecting from ROS Bridge");
connectionAttemptRef.current = false;
if (rosSocket) {
// Close with normal closure code to avoid error messages
rosSocket.close(1000, "User disconnected");
}
if (socketRef.current) {
socketRef.current.close(1000, "User disconnected");
}
// Clear all state
setRosSocket(null);
socketRef.current = null;
setRosConnected(false);
setRosConnecting(false);
setRosError(null);
setRobotStatus({
connected: false,
battery: 0,
position: { x: 0, y: 0, theta: 0 },
joints: {},
sensors: {},
lastUpdate: new Date(),
});
};
const executeRobotAction = (
action: string,
parameters?: Record<string, unknown>,
) => {
if (!rosSocket || !rosConnected) {
setRosError("Robot not connected");
return;
}
let message: {
op: string;
topic: string;
type: string;
msg: Record<string, unknown>;
};
switch (action) {
case "say_text":
const speechText = parameters?.text ?? "Hello from wizard interface!";
console.log("🔊 Preparing speech command:", speechText);
message = {
op: "publish",
topic: "/speech",
type: "std_msgs/String",
msg: { data: speechText },
};
console.log(
"📤 Speech message constructed:",
JSON.stringify(message, null, 2),
);
break;
case "move_forward":
case "move_backward":
case "turn_left":
case "turn_right":
const speed = (parameters?.speed as number) || 0.1;
const linear = action.includes("forward")
? speed
: action.includes("backward")
? -speed
: 0;
const angular = action.includes("left")
? speed
: action.includes("right")
? -speed
: 0;
message = {
op: "publish",
topic: "/cmd_vel",
type: "geometry_msgs/Twist",
msg: {
linear: { x: linear, y: 0, z: 0 },
angular: { x: 0, y: 0, z: angular },
},
};
break;
case "stop_movement":
message = {
op: "publish",
topic: "/cmd_vel",
type: "geometry_msgs/Twist",
msg: {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
},
};
break;
case "head_movement":
case "turn_head":
const yaw = (parameters?.yaw as number) || 0;
const pitch = (parameters?.pitch as number) || 0;
const headSpeed = (parameters?.speed as number) || 0.3;
message = {
op: "publish",
topic: "/joint_angles",
type: "naoqi_bridge_msgs/JointAnglesWithSpeed",
msg: {
joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [yaw, pitch],
speed: headSpeed,
},
};
break;
case "play_animation":
const animation = (parameters?.animation as string) ?? "Hello";
message = {
op: "publish",
topic: "/naoqi_driver/animation",
type: "std_msgs/String",
msg: { data: animation },
};
break;
default:
setRosError(`Unknown action: ${String(action)}`);
return;
}
try {
const messageStr = JSON.stringify(message);
console.log("📡 Sending to ROS Bridge:", messageStr);
rosSocket.send(messageStr);
console.log(`✅ Sent robot action: ${action}`, parameters);
} catch (error) {
console.error("❌ Failed to send command:", error);
setRosError(`Failed to send command: ${String(error)}`);
}
};
const subscribeToTopic = (topic: string, messageType: string) => {
if (!rosSocket || !rosConnected) {
setRosError("Cannot subscribe - not connected");
return;
}
try {
const subscribeMsg = {
op: "subscribe",
topic: topic,
type: messageType,
};
rosSocket.send(JSON.stringify(subscribeMsg));
console.log(`Manually subscribed to ${topic}`);
} catch (error) {
setRosError(`Failed to subscribe to ${topic}: ${String(error)}`);
}
};
// ROS connection is now passed as props, no need for separate hook
// Don't close connection on unmount to prevent disconnection issues
// Connection will persist across component re-renders
@@ -526,7 +232,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
WebSocket
ROS Bridge
</span>
<Badge
variant={isConnected ? "default" : "secondary"}
@@ -759,7 +465,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="w-full text-xs"
onClick={connectRos}
onClick={() => connectRos()}
disabled={rosConnecting || rosConnected}
>
<Bot className="mr-1 h-3 w-3" />
@@ -774,7 +480,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="w-full text-xs"
onClick={disconnectRos}
onClick={() => disconnectRos()}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect
@@ -801,7 +507,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
<div>
1. Check ROS Bridge:{" "}
<code className="bg-muted rounded px-1 text-xs">
telnet 134.82.159.25 9090
telnet localhost 9090
</code>
</div>
<div>2. NAO6 must be awake and connected</div>
@@ -905,15 +611,17 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("say_text", {
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Connection test - can you hear me?",
})
}).catch(console.error);
}
}}
disabled={!rosConnected}
>
{rosConnected ? "🔊 Test Speech" : "🔊 Not Ready"}
<Volume2 className="mr-1 h-3 w-3" />
Test Speech
</Button>
</div>
@@ -923,45 +631,9 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
Subscribe to Topics:
</div>
<div className="grid grid-cols-1 gap-1">
<Button
size="sm"
variant="ghost"
className="justify-start text-xs"
onClick={() =>
subscribeToTopic(
"/naoqi_driver/battery",
"naoqi_bridge_msgs/Battery",
)
}
>
🔋 Battery Status
</Button>
<Button
size="sm"
variant="ghost"
className="justify-start text-xs"
onClick={() =>
subscribeToTopic(
"/naoqi_driver/joint_states",
"sensor_msgs/JointState",
)
}
>
🤖 Joint States
</Button>
<Button
size="sm"
variant="ghost"
className="justify-start text-xs"
onClick={() =>
subscribeToTopic(
"/naoqi_driver/bumper",
"naoqi_bridge_msgs/Bumper",
)
}
>
👟 Bumper Sensors
</Button>
<div className="text-muted-foreground text-xs">
Subscriptions managed automatically
</div>
</div>
</div>
</div>
@@ -978,9 +650,14 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("move_forward", { speed: 0.05 })
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.05,
duration: 2,
}).catch(console.error);
}
}}
>
Forward
</Button>
@@ -988,9 +665,14 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_left", { speed: 0.3 })
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
duration: 2,
}).catch(console.error);
}
}}
>
Turn Left
</Button>
@@ -998,9 +680,14 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_right", { speed: 0.3 })
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
duration: 2,
}).catch(console.error);
}
}}
>
Turn Right
</Button>
@@ -1012,13 +699,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_head", {
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0,
pitch: 0,
speed: 0.3,
})
}).catch(console.error);
}
}}
>
Center Head
</Button>
@@ -1026,13 +715,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_head", {
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0.5,
pitch: 0,
speed: 0.3,
})
}).catch(console.error);
}
}}
>
Look Left
</Button>
@@ -1040,13 +731,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_head", {
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: -0.5,
pitch: 0,
speed: 0.3,
})
}).catch(console.error);
}
}}
>
Look Right
</Button>
@@ -1058,23 +751,27 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("play_animation", {
animation: "Hello",
})
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
>
Wave Hello
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("say_text", {
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Experiment ready!",
})
}).catch(console.error);
}
}}
>
Say Ready
</Button>
@@ -1086,7 +783,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
size="sm"
variant="destructive"
className="text-xs"
onClick={() => executeRobotAction("stop_movement", {})}
onClick={() => {
if (rosConnected) {
executeRosAction(
"nao6-ros2",
"emergency_stop",
{},
).catch(console.error);
}
}}
>
🛑 Emergency Stop
</Button>

296
src/hooks/useWizardRos.ts Normal file
View File

@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import {
WizardRosService,
type RobotStatus,
type RobotActionExecution,
getWizardRosService,
} from "~/lib/ros/wizard-ros-service";
export interface UseWizardRosOptions {
autoConnect?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: unknown) => void;
onActionCompleted?: (execution: RobotActionExecution) => void;
onActionFailed?: (execution: RobotActionExecution) => void;
}
export interface UseWizardRosReturn {
isConnected: boolean;
isConnecting: boolean;
connectionError: string | null;
robotStatus: RobotStatus;
activeActions: RobotActionExecution[];
connect: () => Promise<void>;
disconnect: () => void;
executeRobotAction: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
actionConfig?: {
topic: string;
messageType: string;
payloadMapping: {
type: string;
payload?: Record<string, unknown>;
transformFn?: string;
};
},
) => Promise<RobotActionExecution>;
}
export function useWizardRos(
options: UseWizardRosOptions = {},
): UseWizardRosReturn {
const {
autoConnect = true,
onConnected,
onDisconnected,
onError,
onActionCompleted,
onActionFailed,
} = options;
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [robotStatus, setRobotStatus] = useState<RobotStatus>({
connected: false,
battery: 0,
position: { x: 0, y: 0, theta: 0 },
joints: {},
sensors: {},
lastUpdate: new Date(),
});
const [activeActions, setActiveActions] = useState<RobotActionExecution[]>(
[],
);
// Prevent multiple connections
const isInitializedRef = useRef(false);
const connectAttemptRef = useRef(false);
const serviceRef = useRef<WizardRosService | null>(null);
const mountedRef = useRef(true);
// Use refs for callbacks to prevent infinite re-renders
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
const onActionCompletedRef = useRef(onActionCompleted);
const onActionFailedRef = useRef(onActionFailed);
// Update refs when callbacks change
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
onActionCompletedRef.current = onActionCompleted;
onActionFailedRef.current = onActionFailed;
// Initialize service (only once)
useEffect(() => {
if (!isInitializedRef.current) {
serviceRef.current = getWizardRosService();
isInitializedRef.current = true;
}
return () => {
mountedRef.current = false;
};
}, []);
// Set up event listeners with stable callbacks
useEffect(() => {
const service = serviceRef.current;
if (!service) return;
const handleConnected = () => {
if (!mountedRef.current) return;
console.log("[useWizardRos] Connected to ROS bridge");
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
onConnectedRef.current?.();
};
const handleDisconnected = () => {
if (!mountedRef.current) return;
console.log("[useWizardRos] Disconnected from ROS bridge");
setIsConnected(false);
setIsConnecting(false);
onDisconnectedRef.current?.();
};
const handleError = (error: unknown) => {
if (!mountedRef.current) return;
console.error("[useWizardRos] ROS connection error:", error);
setConnectionError(
error instanceof Error ? error.message : "Connection error",
);
setIsConnecting(false);
onErrorRef.current?.(error);
};
const handleRobotStatusUpdated = (status: RobotStatus) => {
if (!mountedRef.current) return;
setRobotStatus(status);
};
const handleActionStarted = (execution: RobotActionExecution) => {
if (!mountedRef.current) return;
setActiveActions((prev) => {
const filtered = prev.filter((action) => action.id !== execution.id);
return [...filtered, execution];
});
};
const handleActionCompleted = (execution: RobotActionExecution) => {
if (!mountedRef.current) return;
setActiveActions((prev) =>
prev.map((action) => (action.id === execution.id ? execution : action)),
);
onActionCompletedRef.current?.(execution);
};
const handleActionFailed = (execution: RobotActionExecution) => {
if (!mountedRef.current) return;
setActiveActions((prev) =>
prev.map((action) => (action.id === execution.id ? execution : action)),
);
onActionFailedRef.current?.(execution);
};
const handleMaxReconnectsReached = () => {
if (!mountedRef.current) return;
setConnectionError("Maximum reconnection attempts reached");
setIsConnecting(false);
};
// Add event listeners
service.on("connected", handleConnected);
service.on("disconnected", handleDisconnected);
service.on("error", handleError);
service.on("robot_status_updated", handleRobotStatusUpdated);
service.on("action_started", handleActionStarted);
service.on("action_completed", handleActionCompleted);
service.on("action_failed", handleActionFailed);
service.on("max_reconnects_reached", handleMaxReconnectsReached);
// Initialize connection status
setIsConnected(service.getConnectionStatus());
setRobotStatus(service.getRobotStatus());
setActiveActions(service.getActiveActions());
return () => {
service.off("connected", handleConnected);
service.off("disconnected", handleDisconnected);
service.off("error", handleError);
service.off("robot_status_updated", handleRobotStatusUpdated);
service.off("action_started", handleActionStarted);
service.off("action_completed", handleActionCompleted);
service.off("action_failed", handleActionFailed);
service.off("max_reconnects_reached", handleMaxReconnectsReached);
};
}, []); // Empty deps since we use refs
const connect = useCallback(async (): Promise<void> => {
const service = serviceRef.current;
if (!service || isConnected || isConnecting || connectAttemptRef.current)
return;
connectAttemptRef.current = true;
setIsConnecting(true);
setConnectionError(null);
try {
await service.connect();
} catch (error) {
if (mountedRef.current) {
setIsConnecting(false);
setConnectionError(
error instanceof Error ? error.message : "Connection failed",
);
}
throw error;
} finally {
connectAttemptRef.current = false;
}
}, [isConnected, isConnecting]);
// Auto-connect if enabled (only once per hook instance)
useEffect(() => {
if (
autoConnect &&
serviceRef.current &&
!isConnected &&
!isConnecting &&
!connectAttemptRef.current
) {
const timeoutId = setTimeout(() => {
connect().catch((error) => {
console.error("[useWizardRos] Auto-connect failed:", error);
});
}, 100); // Small delay to prevent immediate connection attempts
return () => clearTimeout(timeoutId);
}
}, [autoConnect, isConnected, isConnecting, connect]);
const disconnect = useCallback((): void => {
const service = serviceRef.current;
if (!service) return;
connectAttemptRef.current = false;
service.disconnect();
setIsConnected(false);
setIsConnecting(false);
setConnectionError(null);
}, []);
const executeRobotAction = useCallback(
async (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
actionConfig?: {
topic: string;
messageType: string;
payloadMapping: {
type: string;
payload?: Record<string, unknown>;
transformFn?: string;
};
},
): Promise<RobotActionExecution> => {
const service = serviceRef.current;
if (!service) {
throw new Error("ROS service not initialized");
}
if (!isConnected) {
throw new Error("Not connected to ROS bridge");
}
return service.executeRobotAction(
pluginName,
actionId,
parameters,
actionConfig,
);
},
[isConnected],
);
return {
isConnected,
isConnecting,
connectionError,
robotStatus,
activeActions,
connect,
disconnect,
executeRobotAction,
};
}

View File

@@ -0,0 +1,671 @@
"use client";
import { EventEmitter } from "events";
export interface RosMessage {
op: string;
topic?: string;
type?: string;
msg?: Record<string, unknown>;
service?: string;
args?: Record<string, unknown>;
id?: string;
result?: boolean;
values?: Record<string, unknown>;
}
export interface RobotStatus {
connected: boolean;
battery: number;
position: { x: number; y: number; theta: number };
joints: Record<string, number>;
sensors: Record<string, unknown>;
lastUpdate: Date;
}
export interface RobotActionExecution {
id: string;
actionId: string;
pluginName: string;
parameters: Record<string, unknown>;
status: "pending" | "executing" | "completed" | "failed";
startTime: Date;
endTime?: Date;
error?: string;
}
/**
* Unified ROS WebSocket service for wizard interface
* Manages connection to rosbridge and handles robot action execution
*/
export class WizardRosService extends EventEmitter {
private ws: WebSocket | null = null;
private url: string;
private reconnectInterval = 3000;
private reconnectTimer: NodeJS.Timeout | null = null;
private messageId = 0;
private isConnected = false;
private connectionAttempts = 0;
private maxReconnectAttempts = 5;
private isConnecting = false;
// Robot state
private robotStatus: RobotStatus = {
connected: false,
battery: 0,
position: { x: 0, y: 0, theta: 0 },
joints: {},
sensors: {},
lastUpdate: new Date(),
};
// Active action tracking
private activeActions: Map<string, RobotActionExecution> = new Map();
constructor(url: string = "ws://localhost:9090") {
super();
this.url = url;
}
/**
* Connect to ROS bridge WebSocket
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (
this.isConnected ||
this.ws?.readyState === WebSocket.OPEN ||
this.isConnecting
) {
if (this.isConnected) resolve();
return;
}
this.isConnecting = true;
console.log(`[WizardROS] Connecting to ${this.url}`);
this.ws = new WebSocket(this.url);
const connectionTimeout = setTimeout(() => {
if (this.ws?.readyState !== WebSocket.OPEN) {
this.ws?.close();
reject(new Error("Connection timeout"));
}
}, 10000);
this.ws.onopen = () => {
clearTimeout(connectionTimeout);
console.log("[WizardROS] Connected successfully");
this.isConnected = true;
this.isConnecting = false;
this.connectionAttempts = 0;
this.clearReconnectTimer();
// Subscribe to robot topics
this.subscribeToRobotTopics();
this.emit("connected");
resolve();
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as RosMessage;
this.handleMessage(message);
} catch (error) {
console.error("[WizardROS] Failed to parse message:", error);
}
};
this.ws.onclose = (event) => {
console.log(
`[WizardROS] Connection closed: ${event.code} - ${event.reason}`,
);
this.isConnected = false;
this.isConnecting = false;
this.emit("disconnected");
// Schedule reconnect if not manually closed
if (
event.code !== 1000 &&
this.connectionAttempts < this.maxReconnectAttempts
) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error("[WizardROS] WebSocket error:", error);
clearTimeout(connectionTimeout);
this.isConnecting = false;
this.emit("error", error);
reject(error);
};
});
}
/**
* Disconnect from ROS bridge
*/
disconnect(): void {
this.clearReconnectTimer();
if (this.ws) {
this.ws.close(1000, "Manual disconnect");
this.ws = null;
}
this.isConnected = false;
this.isConnecting = false;
this.robotStatus.connected = false;
this.emit("disconnected");
}
/**
* Check if connected to ROS bridge
*/
getConnectionStatus(): boolean {
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Get current robot status
*/
getRobotStatus(): RobotStatus {
return { ...this.robotStatus };
}
/**
* Execute robot action using plugin configuration
*/
async executeRobotAction(
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
actionConfig?: {
topic: string;
messageType: string;
payloadMapping: {
type: string;
payload?: Record<string, unknown>;
transformFn?: string;
};
},
): Promise<RobotActionExecution> {
if (!this.isConnected) {
throw new Error("Not connected to ROS bridge");
}
const executionId = `${pluginName}_${actionId}_${Date.now()}`;
const execution: RobotActionExecution = {
id: executionId,
actionId,
pluginName,
parameters,
status: "pending",
startTime: new Date(),
};
this.activeActions.set(executionId, execution);
this.emit("action_started", execution);
try {
execution.status = "executing";
this.activeActions.set(executionId, execution);
// Execute based on action configuration or built-in mappings
if (actionConfig) {
await this.executeWithConfig(actionConfig, parameters);
} else {
await this.executeBuiltinAction(actionId, parameters);
}
execution.status = "completed";
execution.endTime = new Date();
this.emit("action_completed", execution);
} catch (error) {
execution.status = "failed";
execution.error = error instanceof Error ? error.message : String(error);
execution.endTime = new Date();
this.emit("action_failed", execution);
}
this.activeActions.set(executionId, execution);
return execution;
}
/**
* Get list of active actions
*/
getActiveActions(): RobotActionExecution[] {
return Array.from(this.activeActions.values());
}
/**
* Subscribe to robot sensor topics
*/
private subscribeToRobotTopics(): void {
const topics = [
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
{
topic: "/naoqi_driver/battery",
type: "naoqi_bridge_msgs/BatteryState",
},
{ topic: "/naoqi_driver/bumper", type: "naoqi_bridge_msgs/Bumper" },
{
topic: "/naoqi_driver/hand_touch",
type: "naoqi_bridge_msgs/HandTouch",
},
{
topic: "/naoqi_driver/head_touch",
type: "naoqi_bridge_msgs/HeadTouch",
},
{ topic: "/naoqi_driver/sonar/left", type: "sensor_msgs/Range" },
{ topic: "/naoqi_driver/sonar/right", type: "sensor_msgs/Range" },
];
topics.forEach(({ topic, type }) => {
this.subscribe(topic, type);
});
}
/**
* Subscribe to a ROS topic
*/
private subscribe(topic: string, messageType: string): void {
const message: RosMessage = {
op: "subscribe",
topic,
type: messageType,
id: `sub_${this.messageId++}`,
};
this.send(message);
}
/**
* Publish message to ROS topic
*/
private publish(
topic: string,
messageType: string,
msg: Record<string, unknown>,
): void {
const message: RosMessage = {
op: "publish",
topic,
type: messageType,
msg,
};
this.send(message);
}
/**
* Send WebSocket message
*/
private send(message: RosMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn("[WizardROS] Cannot send message - not connected");
}
}
/**
* Handle incoming ROS messages
*/
private handleMessage(message: RosMessage): void {
if (message.topic) {
this.handleTopicMessage(message);
}
this.emit("message", message);
}
/**
* Handle topic-specific messages
*/
private handleTopicMessage(message: RosMessage): void {
if (!message.topic || !message.msg) return;
switch (message.topic) {
case "/joint_states":
this.updateJointStates(message.msg);
break;
case "/naoqi_driver/battery":
this.updateBatteryStatus(message.msg);
break;
case "/naoqi_driver/bumper":
case "/naoqi_driver/hand_touch":
case "/naoqi_driver/head_touch":
case "/naoqi_driver/sonar/left":
case "/naoqi_driver/sonar/right":
this.updateSensorData(message.topic, message.msg);
break;
}
this.robotStatus.lastUpdate = new Date();
this.emit("robot_status_updated", this.robotStatus);
}
/**
* Update joint states from ROS message
*/
private updateJointStates(msg: Record<string, unknown>): void {
if (
msg.name &&
msg.position &&
Array.isArray(msg.name) &&
Array.isArray(msg.position)
) {
const joints: Record<string, number> = {};
for (let i = 0; i < msg.name.length; i++) {
const jointName = msg.name[i] as string;
const position = msg.position[i] as number;
if (jointName && typeof position === "number") {
joints[jointName] = position;
}
}
this.robotStatus.joints = joints;
this.robotStatus.connected = true;
}
}
/**
* Update battery status from ROS message
*/
private updateBatteryStatus(msg: Record<string, unknown>): void {
if (typeof msg.percentage === "number") {
this.robotStatus.battery = msg.percentage;
}
}
/**
* Update sensor data from ROS message
*/
private updateSensorData(topic: string, msg: Record<string, unknown>): void {
this.robotStatus.sensors[topic] = msg;
}
/**
* Execute action with plugin configuration
*/
private async executeWithConfig(
config: {
topic: string;
messageType: string;
payloadMapping: {
type: string;
payload?: Record<string, unknown>;
transformFn?: string;
};
},
parameters: Record<string, unknown>,
): Promise<void> {
let msg: Record<string, unknown>;
if (
config.payloadMapping.type === "template" &&
config.payloadMapping.payload
) {
// Template-based payload construction
msg = this.buildTemplatePayload(
config.payloadMapping.payload,
parameters,
);
} else if (config.payloadMapping.transformFn) {
// Custom transform function
msg = this.applyTransformFunction(
config.payloadMapping.transformFn,
parameters,
);
} else {
// Direct parameter mapping
msg = parameters;
}
this.publish(config.topic, config.messageType, msg);
// Wait for action completion (simple delay for now)
await new Promise((resolve) => setTimeout(resolve, 100));
}
/**
* Execute built-in robot actions
*/
private async executeBuiltinAction(
actionId: string,
parameters: Record<string, unknown>,
): Promise<void> {
switch (actionId) {
case "say_text":
this.publish("/speech", "std_msgs/String", {
data: parameters.text || "Hello",
});
break;
case "walk_forward":
case "walk_backward":
case "turn_left":
case "turn_right":
this.executeMovementAction(actionId, parameters);
break;
case "turn_head":
this.executeTurnHead(parameters);
break;
case "emergency_stop":
this.publish("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
});
break;
default:
throw new Error(`Unknown action: ${actionId}`);
}
// Wait for action completion
await new Promise((resolve) => setTimeout(resolve, 100));
}
/**
* Execute movement actions
*/
private executeMovementAction(
actionId: string,
parameters: Record<string, unknown>,
): void {
let linear = { x: 0, y: 0, z: 0 };
let angular = { x: 0, y: 0, z: 0 };
const speed = Number(parameters.speed) || 0.1;
switch (actionId) {
case "walk_forward":
linear.x = speed;
break;
case "walk_backward":
linear.x = -speed;
break;
case "turn_left":
angular.z = speed;
break;
case "turn_right":
angular.z = -speed;
break;
}
this.publish("/cmd_vel", "geometry_msgs/Twist", { linear, angular });
}
/**
* Execute head turn action
*/
private executeTurnHead(parameters: Record<string, unknown>): void {
const yaw = Number(parameters.yaw) || 0;
const pitch = Number(parameters.pitch) || 0;
const speed = Number(parameters.speed) || 0.3;
this.publish("/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [yaw, pitch],
speed: speed,
});
}
/**
* Build template-based payload
*/
private buildTemplatePayload(
template: Record<string, unknown>,
parameters: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(template)) {
if (typeof value === "string" && value.includes("{{")) {
// Template substitution
let substituted = value;
for (const [paramKey, paramValue] of Object.entries(parameters)) {
const placeholder = `{{${paramKey}}}`;
substituted = substituted.replace(
new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
String(paramValue ?? ""),
);
}
result[key] = isNaN(Number(substituted))
? substituted
: Number(substituted);
} else if (typeof value === "object" && value !== null) {
result[key] = this.buildTemplatePayload(
value as Record<string, unknown>,
parameters,
);
} else {
result[key] = value;
}
}
return result;
}
/**
* Apply transform function for NAO6 actions
*/
private applyTransformFunction(
transformFn: string,
parameters: Record<string, unknown>,
): Record<string, unknown> {
switch (transformFn) {
case "naoVelocityTransform":
return {
linear: {
x: Number(parameters.linear) || 0,
y: 0,
z: 0,
},
angular: {
x: 0,
y: 0,
z: Number(parameters.angular) || 0,
},
};
case "naoSpeechTransform":
return {
data: String(parameters.text || "Hello"),
};
case "naoHeadTransform":
return {
joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [
Number(parameters.yaw) || 0,
Number(parameters.pitch) || 0,
],
speed: Number(parameters.speed) || 0.3,
};
default:
console.warn(`Unknown transform function: ${transformFn}`);
return parameters;
}
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.connectionAttempts++;
console.log(
`[WizardROS] Scheduling reconnect attempt ${this.connectionAttempts}/${this.maxReconnectAttempts}`,
);
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch (error) {
console.error("[WizardROS] Reconnect failed:", error);
if (this.connectionAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
} else {
this.emit("max_reconnects_reached");
}
}
}, this.reconnectInterval);
}
/**
* Clear reconnect timer
*/
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
// Global service instance
let wizardRosService: WizardRosService | null = null;
let isCreatingInstance = false;
/**
* Get or create the global wizard ROS service (true singleton)
*/
export function getWizardRosService(): WizardRosService {
// Prevent multiple instances during creation
if (isCreatingInstance && !wizardRosService) {
throw new Error("WizardRosService is being initialized, please wait");
}
if (!wizardRosService) {
isCreatingInstance = true;
try {
wizardRosService = new WizardRosService();
} finally {
isCreatingInstance = false;
}
}
return wizardRosService;
}
/**
* Initialize wizard ROS service with connection
*/
export async function initWizardRosService(): Promise<WizardRosService> {
const service = getWizardRosService();
if (!service.getConnectionStatus()) {
await service.connect();
}
return service;
}

View File

@@ -9,28 +9,34 @@
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"~/*": [
"./src/*"
]
}
},
"include": [
// FlowWorkspace (flow/FlowWorkspace.tsx) and new designer modules are included via recursive globs
"next-env.d.ts",
@@ -40,7 +46,11 @@
"**/*.js",
".next/types/**/*.ts",
"src/components/experiments/designer/**/*.ts",
"src/components/experiments/designer/**/*.tsx"
"src/components/experiments/designer/**/*.tsx",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", "robot-plugins"]
"exclude": [
"node_modules",
"robot-plugins"
]
}