From 18fa6bff5fb10cbb91cb9e5d8b66024414d91ecd Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 19 Nov 2025 22:51:38 -0500 Subject: [PATCH] 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 --- bun.lock | 92 +- package.json | 4 +- .../trials/wizard/RobotActionsPanel.tsx | 792 ++++++++++++++---- .../trials/wizard/WizardInterface.tsx | 194 +++-- .../wizard/panels/WizardMonitoringPanel.tsx | 545 +++--------- src/hooks/useWizardRos.ts | 296 +++++++ src/lib/ros/wizard-ros-service.ts | 671 +++++++++++++++ tsconfig.json | 30 +- 8 files changed, 1929 insertions(+), 695 deletions(-) create mode 100644 src/hooks/useWizardRos.ts create mode 100644 src/lib/ros/wizard-ros-service.ts diff --git a/bun.lock b/bun.lock index b7782a2..dbf9901 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index f0064a0..97edc06 100755 --- a/package.json +++ b/package.json @@ -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" ] } diff --git a/src/components/trials/wizard/RobotActionsPanel.tsx b/src/components/trials/wizard/RobotActionsPanel.tsx index 2e75327..60a2dbd 100755 --- a/src/components/trials/wizard/RobotActionsPanel.tsx +++ b/src/components/trials/wizard/RobotActionsPanel.tsx @@ -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, ) => Promise; } +// 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 = {}; + + 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(""); @@ -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,22 +225,87 @@ 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 { - await onExecuteAction( - selectedPluginData.plugin.name, - selectedAction.id, - actionParameters, - ); + // Get action configuration from plugin + const actionDef = ( + selectedPluginData.plugin.actionDefinitions as RobotAction[] + )?.find((def: RobotAction) => def.id === selectedAction.id); - toast.success(`Executed: ${selectedAction.name}`, { - description: `Robot action completed successfully`, - }); + // 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, + actionParameters, + ); + + toast.success(`Executed: ${selectedAction.name}`, { + 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) => { - setActionParameters((prev) => ({ - ...prev, - [paramName]: value, - })); - }; + const handleParameterChange = useCallback( + (paramName: string, value: unknown) => { + setActionParameters((prev) => ({ + ...prev, + [paramName]: value, + })); + }, + [], + ); const renderParameterInput = ( param: NonNullable[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 = {}; - - 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 (
@@ -375,18 +478,68 @@ export function RobotActionsPanel({ if (plugins.length === 0) { return ( - - - - No robot plugins installed for this study. Install plugins from the - study settings to enable robot control. - - +
+
+
+ {rosConnected ? ( + + ) : rosConnecting ? ( + + ) : ( + + )} + ROS Bridge +
+ +
+ + {rosConnected + ? "Connected" + : rosConnecting + ? "Connecting" + : "Disconnected"} + + + {!rosConnected && !rosConnecting && ( + + )} + + {rosConnected && ( + + )} +
+
+ + + + No robot plugins are installed in this study. Install plugins to + control robots during trials. + + +
); } return (
+ {/* Plugin Selection */}
@@ -523,92 +676,429 @@ export function RobotActionsPanel({ )} - - {/* Quick Actions for Common Robot Commands */} - {selectedAction.category === "movement" && selectedPluginData && ( -
- - -
- )} )} - {/* Plugin Info */} + {/* Quick Actions */} + + + + + Quick Actions + + + Common robot actions for quick execution + + + +
+ + + + + + + +
+
+
+
+ ); + + function ConnectionStatus() { + return ( +
+
+ {rosConnected ? ( + + ) : rosConnecting ? ( + + ) : ( + + )} + ROS Bridge +
+ +
+ + {rosConnected + ? "Connected" + : rosConnecting + ? "Connecting" + : "Disconnected"} + + + {!rosConnected && !rosConnecting && ( + + )} + + {rosConnected && ( + + )} +
+
+ ); + } + + return ( +
+
+
+ {rosConnected ? ( + + ) : rosConnecting ? ( + + ) : ( + + )} + ROS Bridge +
+ +
+ + {rosConnected + ? "Connected" + : rosConnecting + ? "Connecting" + : "Disconnected"} + + + {!rosConnected && !rosConnecting && ( + + )} + + {rosConnected && ( + + )} +
+
+ {/* Plugin Selection */} +
+ + +
+ + {/* Action Selection */} {selectedPluginData && ( - - - - {selectedPluginData.plugin.name} -{" "} - {selectedPluginData.plugin.description} -
- - Installed:{" "} - {selectedPluginData.installation.installedAt.toLocaleDateString()}{" "} - | Trust Level: {selectedPluginData.plugin.trustLevel} | Actions:{" "} - { - ( - (selectedPluginData.plugin - .actionDefinitions as RobotAction[]) || [] - ).length - } - -
-
+
+ + +
+ {selectedPluginData && + Object.entries( + groupActionsByCategory( + (selectedPluginData.plugin + .actionDefinitions as RobotAction[]) ?? [], + ), + ).map(([category, actions]) => { + const CategoryIcon = getCategoryIcon(category); + const isExpanded = expandedCategories.has(category); + + return ( + toggleCategory(category)} + > + + + + + {actions.map((action) => ( + + ))} + + + ); + })} +
+
+
)} + + {/* Action Configuration */} + {selectedAction && ( + + + + + {selectedAction?.name} + + {selectedAction?.description} + + + {/* Parameters */} + {selectedAction?.parameters && + (selectedAction.parameters?.length ?? 0) > 0 ? ( +
+ + {selectedAction?.parameters?.map((param, index) => + renderParameterInput(param, index), + )} +
+ ) : ( +

+ This action requires no parameters. +

+ )} + + + + {/* Execute Button */} + +
+
+ )} + + {/* Quick Actions */} + + + + + Quick Actions + + + Common robot actions for quick execution + + + +
+ + + + + + + +
+
+
); } diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx index 207b4d1..8e9084f 100755 --- a/src/components/trials/wizard/WizardInterface.tsx +++ b/src/components/trials/wizard/WizardInterface.tsx @@ -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 { @@ -47,10 +47,10 @@ interface StepData { name: string; description: string | null; type: - | "wizard_action" - | "robot_action" - | "parallel_steps" - | "conditional_branch"; + | "wizard_action" + | "robot_action" + | "parallel_steps" + | "conditional_branch"; parameters: Record; order: number; } @@ -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 | 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 ( - pluginName: string, - actionId: string, - parameters: Record, - ) => { - try { - await executeRobotActionMutation.mutateAsync({ - trialId: trial.id, - pluginName, - actionId, - parameters, - }); - } catch (error) { - console.error("Failed to execute robot action:", error); - } - }; + const handleExecuteRobotAction = useCallback( + async ( + pluginName: string, + actionId: string, + parameters: Record, + ) => { + 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 (
@@ -391,20 +441,17 @@ export const WizardInterface = React.memo(function WizardInterface({
{trial.experiment.name}
{trial.participant.participantCode}
- - Polling + + {rosConnected ? "ROS Connected" : "ROS Offline"}
- {/* Connection Status */} - - - - Using polling mode for trial updates (refreshes every 2 seconds). - - + {/* No connection status alert - ROS connection shown in monitoring panel */} {/* Main Content - Three Panel Layout */}
@@ -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({ } showDividers={true} diff --git a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx index 93dceda..0fa1fb1 100755 --- a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx +++ b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx @@ -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; + sensors: Record; + lastUpdate: Date; + }; + connectRos: () => Promise; + disconnectRos: () => void; + executeRosAction: ( + pluginName: string, + actionId: string, + parameters: Record, + ) => Promise; } 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(null); - const [rosSocket, setRosSocket] = useState(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(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; - 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, - ) => { - if (!rosSocket || !rosConnected) { - setRosError("Robot not connected"); - return; - } - - let message: { - op: string; - topic: string; - type: string; - msg: Record; - }; - - 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({
- WebSocket + ROS Bridge connectRos()} disabled={rosConnecting || rosConnected} > @@ -774,7 +480,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ size="sm" variant="outline" className="w-full text-xs" - onClick={disconnectRos} + onClick={() => disconnectRos()} > Disconnect @@ -801,7 +507,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
1. Check ROS Bridge:{" "} - telnet 134.82.159.25 9090 + telnet localhost 9090
2. NAO6 must be awake and connected
@@ -850,10 +556,10 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ ))} {trialEvents.filter((e) => e.type.includes("robot")) .length === 0 && ( -
- No robot events yet -
- )} +
+ No robot events yet +
+ )}
@@ -905,15 +611,17 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
@@ -923,45 +631,9 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ Subscribe to Topics:
- - - +
+ Subscriptions managed automatically +
@@ -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 @@ -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 @@ -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 @@ -1012,13 +699,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ size="sm" variant="outline" className="text-xs" - onClick={() => - executeRobotAction("turn_head", { - yaw: 0, - pitch: 0, - speed: 0.3, - }) - } + onClick={() => { + if (rosConnected) { + executeRosAction("nao6-ros2", "turn_head", { + yaw: 0, + pitch: 0, + speed: 0.3, + }).catch(console.error); + } + }} > Center Head @@ -1026,13 +715,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ size="sm" variant="outline" className="text-xs" - onClick={() => - executeRobotAction("turn_head", { - yaw: 0.5, - pitch: 0, - speed: 0.3, - }) - } + onClick={() => { + if (rosConnected) { + executeRosAction("nao6-ros2", "turn_head", { + yaw: 0.5, + pitch: 0, + speed: 0.3, + }).catch(console.error); + } + }} > Look Left @@ -1040,13 +731,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({ size="sm" variant="outline" className="text-xs" - onClick={() => - executeRobotAction("turn_head", { - yaw: -0.5, - pitch: 0, - speed: 0.3, - }) - } + onClick={() => { + if (rosConnected) { + executeRosAction("nao6-ros2", "turn_head", { + yaw: -0.5, + pitch: 0, + speed: 0.3, + }).catch(console.error); + } + }} > Look Right @@ -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 @@ -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 diff --git a/src/hooks/useWizardRos.ts b/src/hooks/useWizardRos.ts new file mode 100644 index 0000000..dcc4388 --- /dev/null +++ b/src/hooks/useWizardRos.ts @@ -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; + disconnect: () => void; + executeRobotAction: ( + pluginName: string, + actionId: string, + parameters: Record, + actionConfig?: { + topic: string; + messageType: string; + payloadMapping: { + type: string; + payload?: Record; + transformFn?: string; + }; + }, + ) => Promise; +} + +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(null); + const [robotStatus, setRobotStatus] = useState({ + connected: false, + battery: 0, + position: { x: 0, y: 0, theta: 0 }, + joints: {}, + sensors: {}, + lastUpdate: new Date(), + }); + const [activeActions, setActiveActions] = useState( + [], + ); + + // Prevent multiple connections + const isInitializedRef = useRef(false); + const connectAttemptRef = useRef(false); + + const serviceRef = useRef(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 => { + 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, + actionConfig?: { + topic: string; + messageType: string; + payloadMapping: { + type: string; + payload?: Record; + transformFn?: string; + }; + }, + ): Promise => { + 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, + }; +} diff --git a/src/lib/ros/wizard-ros-service.ts b/src/lib/ros/wizard-ros-service.ts new file mode 100644 index 0000000..0451ff4 --- /dev/null +++ b/src/lib/ros/wizard-ros-service.ts @@ -0,0 +1,671 @@ +"use client"; + +import { EventEmitter } from "events"; + +export interface RosMessage { + op: string; + topic?: string; + type?: string; + msg?: Record; + service?: string; + args?: Record; + id?: string; + result?: boolean; + values?: Record; +} + +export interface RobotStatus { + connected: boolean; + battery: number; + position: { x: number; y: number; theta: number }; + joints: Record; + sensors: Record; + lastUpdate: Date; +} + +export interface RobotActionExecution { + id: string; + actionId: string; + pluginName: string; + parameters: Record; + 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 = new Map(); + + constructor(url: string = "ws://localhost:9090") { + super(); + this.url = url; + } + + /** + * Connect to ROS bridge WebSocket + */ + async connect(): Promise { + 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, + actionConfig?: { + topic: string; + messageType: string; + payloadMapping: { + type: string; + payload?: Record; + transformFn?: string; + }; + }, + ): Promise { + 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, + ): 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): void { + if ( + msg.name && + msg.position && + Array.isArray(msg.name) && + Array.isArray(msg.position) + ) { + const joints: Record = {}; + + 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): void { + if (typeof msg.percentage === "number") { + this.robotStatus.battery = msg.percentage; + } + } + + /** + * Update sensor data from ROS message + */ + private updateSensorData(topic: string, msg: Record): void { + this.robotStatus.sensors[topic] = msg; + } + + /** + * Execute action with plugin configuration + */ + private async executeWithConfig( + config: { + topic: string; + messageType: string; + payloadMapping: { + type: string; + payload?: Record; + transformFn?: string; + }; + }, + parameters: Record, + ): Promise { + let msg: Record; + + 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, + ): Promise { + 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, + ): 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): 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, + parameters: Record, + ): Record { + const result: Record = {}; + + 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, + parameters, + ); + } else { + result[key] = value; + } + } + + return result; + } + + /** + * Apply transform function for NAO6 actions + */ + private applyTransformFunction( + transformFn: string, + parameters: Record, + ): Record { + 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 { + const service = getWizardRosService(); + + if (!service.getConnectionStatus()) { + await service.connect(); + } + + return service; +} diff --git a/tsconfig.json b/tsconfig.json index 6136f2b..e325c9e 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" + ] }