robopark 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (408) hide show
  1. package/README.md +66 -105
  2. package/bin/robopark.js +9 -2
  3. package/conversation/elevenlabs_agent.py +1617 -1617
  4. package/conversation/supervisor_store.py +189 -189
  5. package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
  6. package/dist/config-schema.js.map +1 -0
  7. package/dist/robopark/add-robot.js +1 -0
  8. package/dist/robopark/add-robot.js.map +1 -0
  9. package/dist/robopark/agent-ctl.js +1 -0
  10. package/dist/robopark/agent-ctl.js.map +1 -0
  11. package/dist/robopark/auto-start.js +51 -73
  12. package/dist/robopark/auto-start.js.map +1 -0
  13. package/dist/robopark/conversation.js +1 -0
  14. package/dist/robopark/conversation.js.map +1 -0
  15. package/dist/robopark/discovery.js +1 -0
  16. package/dist/robopark/discovery.js.map +1 -0
  17. package/dist/robopark/doctor.js +1 -0
  18. package/dist/robopark/doctor.js.map +1 -0
  19. package/dist/robopark/enroll.js +1 -0
  20. package/dist/robopark/enroll.js.map +1 -0
  21. package/dist/robopark/llm-set.js +1 -0
  22. package/dist/robopark/llm-set.js.map +1 -0
  23. package/dist/robopark/motor-control.js +1 -0
  24. package/dist/robopark/motor-control.js.map +1 -0
  25. package/dist/robopark/preview-agent-launcher.js +1 -0
  26. package/dist/robopark/preview-agent-launcher.js.map +1 -0
  27. package/dist/robopark/probe.js +16 -15
  28. package/dist/robopark/probe.js.map +1 -0
  29. package/dist/robopark/profile.js +1 -0
  30. package/dist/robopark/profile.js.map +1 -0
  31. package/dist/robopark/python-env.js +1 -7
  32. package/dist/robopark/python-env.js.map +1 -0
  33. package/dist/robopark/robot-runtime.js +1 -0
  34. package/dist/robopark/robot-runtime.js.map +1 -0
  35. package/dist/robopark/scan.js +1 -0
  36. package/dist/robopark/scan.js.map +1 -0
  37. package/dist/robopark/screen-control.js +1 -0
  38. package/dist/robopark/screen-control.js.map +1 -0
  39. package/dist/robopark/secrets.js +1 -0
  40. package/dist/robopark/secrets.js.map +1 -0
  41. package/dist/robopark/serve.js +6 -8
  42. package/dist/robopark/serve.js.map +1 -0
  43. package/dist/robopark/server-add.js +1 -0
  44. package/dist/robopark/server-add.js.map +1 -0
  45. package/dist/robopark/setup-livekit.js +53 -52
  46. package/dist/robopark/setup-livekit.js.map +1 -0
  47. package/dist/robopark/setup.js +1 -0
  48. package/dist/robopark/setup.js.map +1 -0
  49. package/dist/robopark/stop-all.js +1 -0
  50. package/dist/robopark/stop-all.js.map +1 -0
  51. package/dist/robopark/verify.js +1 -0
  52. package/dist/robopark/verify.js.map +1 -0
  53. package/dist/robopark/vision-agent-launcher.js +1 -0
  54. package/dist/robopark/vision-agent-launcher.js.map +1 -0
  55. package/dist/robopark/vision-control.js +1 -0
  56. package/dist/robopark/vision-control.js.map +1 -0
  57. package/dist/robopark-cli.js +7 -125
  58. package/dist/robopark-cli.js.map +1 -0
  59. package/package.json +16 -56
  60. package/pi-client/_install_steps.sh +29 -29
  61. package/pi-client/client.py +76 -76
  62. package/pi-client/install.sh +40 -40
  63. package/pi-client/join_convo.sh +54 -54
  64. package/pi-client/livekit_bridge.py +24 -24
  65. package/pi-client/motor_bridge.py +10 -10
  66. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  67. package/scheduler/main.py +196 -17
  68. package/scheduler/media_lock.py +57 -57
  69. package/scheduler/preview_agent.py +1122 -1122
  70. package/scheduler/production_config.json +73 -113
  71. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
  72. package/{ui/standalone/public → scheduler/static}/voice-join.html +101 -13
  73. package/screen/screen_runtime.py +75 -75
  74. package/vision/app_pi_clean.py +297 -297
  75. package/vision/audio_server_pi.py +24 -24
  76. package/vision/install.sh +34 -34
  77. package/vision/motor_server.py +275 -275
  78. package/vision/requirements_camera.txt +6 -6
  79. package/vision/requirements_motor.txt +4 -4
  80. package/vision/requirements_pi_unified.txt +2 -2
  81. package/vision/requirements_vision_agent.txt +19 -19
  82. package/vision/run.sh +244 -244
  83. package/vision/services/services.sh +12 -12
  84. package/dist/kernel/types.js +0 -7
  85. package/dist/robopark/access.js +0 -99
  86. package/dist/robopark/control-center-html.js +0 -9191
  87. package/dist/robopark/control-server.js +0 -148
  88. package/dist/robopark/deployment-commands.js +0 -47
  89. package/dist/robopark/frontend-service.js +0 -80
  90. package/dist/robopark/standalone.js +0 -584
  91. package/dist/robopark/voice-join-html.js +0 -57
  92. package/scripts/windows-shim-fix.cjs +0 -58
  93. package/static/robopark/barracuda.png +0 -0
  94. package/static/robopark/bmw.png +0 -0
  95. package/static/robopark/jaguar.png +0 -0
  96. package/static/robopark/magnus.jpg +0 -0
  97. package/static/robopark/panda.png +0 -0
  98. package/static/robopark/tesla.png +0 -0
  99. package/static/robopark/titan.jpg +0 -0
  100. package/static/robopark/volt.png +0 -0
  101. package/static/vendor/elevenlabs-client.LICENSE +0 -21
  102. package/static/vendor/elevenlabs-client.iife.js +0 -24200
  103. package/ui/ROBOVOICE_BUILD.json +0 -9
  104. package/ui/standalone/.next/BUILD_ID +0 -1
  105. package/ui/standalone/.next/app-build-manifest.json +0 -391
  106. package/ui/standalone/.next/app-path-routes-manifest.json +0 -55
  107. package/ui/standalone/.next/build-manifest.json +0 -33
  108. package/ui/standalone/.next/package.json +0 -1
  109. package/ui/standalone/.next/prerender-manifest.json +0 -41
  110. package/ui/standalone/.next/react-loadable-manifest.json +0 -14
  111. package/ui/standalone/.next/required-server-files.json +0 -362
  112. package/ui/standalone/.next/routes-manifest.json +0 -246
  113. package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js +0 -1
  114. package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js.nft.json +0 -1
  115. package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route_client-reference-manifest.js +0 -1
  116. package/ui/standalone/.next/server/app/(app)/page.js +0 -2
  117. package/ui/standalone/.next/server/app/(app)/page.js.nft.json +0 -1
  118. package/ui/standalone/.next/server/app/(app)/page_client-reference-manifest.js +0 -1
  119. package/ui/standalone/.next/server/app/(app)/voice/page.js +0 -12
  120. package/ui/standalone/.next/server/app/(app)/voice/page.js.nft.json +0 -1
  121. package/ui/standalone/.next/server/app/(app)/voice/page_client-reference-manifest.js +0 -1
  122. package/ui/standalone/.next/server/app/_not-found/page.js +0 -12
  123. package/ui/standalone/.next/server/app/_not-found/page.js.nft.json +0 -1
  124. package/ui/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +0 -1
  125. package/ui/standalone/.next/server/app/api/connection-details/route.js +0 -1
  126. package/ui/standalone/.next/server/app/api/connection-details/route.js.nft.json +0 -1
  127. package/ui/standalone/.next/server/app/api/connection-details/route_client-reference-manifest.js +0 -1
  128. package/ui/standalone/.next/server/app/api/device-character/route.js +0 -1
  129. package/ui/standalone/.next/server/app/api/device-character/route.js.nft.json +0 -1
  130. package/ui/standalone/.next/server/app/api/device-character/route_client-reference-manifest.js +0 -1
  131. package/ui/standalone/.next/server/app/api/download-piper-model/route.js +0 -1
  132. package/ui/standalone/.next/server/app/api/download-piper-model/route.js.nft.json +0 -1
  133. package/ui/standalone/.next/server/app/api/download-piper-model/route_client-reference-manifest.js +0 -1
  134. package/ui/standalone/.next/server/app/api/model-status/route.js +0 -1
  135. package/ui/standalone/.next/server/app/api/model-status/route.js.nft.json +0 -1
  136. package/ui/standalone/.next/server/app/api/model-status/route_client-reference-manifest.js +0 -1
  137. package/ui/standalone/.next/server/app/api/models/route.js +0 -1
  138. package/ui/standalone/.next/server/app/api/models/route.js.nft.json +0 -1
  139. package/ui/standalone/.next/server/app/api/models/route_client-reference-manifest.js +0 -1
  140. package/ui/standalone/.next/server/app/api/prewarm/route.js +0 -1
  141. package/ui/standalone/.next/server/app/api/prewarm/route.js.nft.json +0 -1
  142. package/ui/standalone/.next/server/app/api/prewarm/route_client-reference-manifest.js +0 -1
  143. package/ui/standalone/.next/server/app/api/prompt/route.js +0 -1
  144. package/ui/standalone/.next/server/app/api/prompt/route.js.nft.json +0 -1
  145. package/ui/standalone/.next/server/app/api/prompt/route_client-reference-manifest.js +0 -1
  146. package/ui/standalone/.next/server/app/api/reload-tools/route.js +0 -1
  147. package/ui/standalone/.next/server/app/api/reload-tools/route.js.nft.json +0 -1
  148. package/ui/standalone/.next/server/app/api/reload-tools/route_client-reference-manifest.js +0 -1
  149. package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js +0 -1
  150. package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js.nft.json +0 -1
  151. package/ui/standalone/.next/server/app/api/robopark/camera-token/route_client-reference-manifest.js +0 -1
  152. package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js +0 -1
  153. package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js.nft.json +0 -1
  154. package/ui/standalone/.next/server/app/api/robopark/character-ai/route_client-reference-manifest.js +0 -1
  155. package/ui/standalone/.next/server/app/api/robopark/device-access/route.js +0 -1
  156. package/ui/standalone/.next/server/app/api/robopark/device-access/route.js.nft.json +0 -1
  157. package/ui/standalone/.next/server/app/api/robopark/device-access/route_client-reference-manifest.js +0 -1
  158. package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js +0 -1
  159. package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js.nft.json +0 -1
  160. package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route_client-reference-manifest.js +0 -1
  161. package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js +0 -1
  162. package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js.nft.json +0 -1
  163. package/ui/standalone/.next/server/app/api/robopark/live-rooms/route_client-reference-manifest.js +0 -1
  164. package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js +0 -1
  165. package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js.nft.json +0 -1
  166. package/ui/standalone/.next/server/app/api/robopark/monitor-token/route_client-reference-manifest.js +0 -1
  167. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js +0 -1
  168. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js.nft.json +0 -1
  169. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route_client-reference-manifest.js +0 -1
  170. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js +0 -1
  171. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js.nft.json +0 -1
  172. package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route_client-reference-manifest.js +0 -1
  173. package/ui/standalone/.next/server/app/api/robopark/robots/route.js +0 -1
  174. package/ui/standalone/.next/server/app/api/robopark/robots/route.js.nft.json +0 -1
  175. package/ui/standalone/.next/server/app/api/robopark/robots/route_client-reference-manifest.js +0 -1
  176. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js +0 -1
  177. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js.nft.json +0 -1
  178. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route_client-reference-manifest.js +0 -1
  179. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js +0 -1
  180. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js.nft.json +0 -1
  181. package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route_client-reference-manifest.js +0 -1
  182. package/ui/standalone/.next/server/app/api/robopark/servers/route.js +0 -1
  183. package/ui/standalone/.next/server/app/api/robopark/servers/route.js.nft.json +0 -1
  184. package/ui/standalone/.next/server/app/api/robopark/servers/route_client-reference-manifest.js +0 -1
  185. package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js +0 -1
  186. package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js.nft.json +0 -1
  187. package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route_client-reference-manifest.js +0 -1
  188. package/ui/standalone/.next/server/app/api/robopark/sessions/route.js +0 -1
  189. package/ui/standalone/.next/server/app/api/robopark/sessions/route.js.nft.json +0 -1
  190. package/ui/standalone/.next/server/app/api/robopark/sessions/route_client-reference-manifest.js +0 -1
  191. package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js +0 -1
  192. package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js.nft.json +0 -1
  193. package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route_client-reference-manifest.js +0 -1
  194. package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js +0 -1
  195. package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js.nft.json +0 -1
  196. package/ui/standalone/.next/server/app/api/robopark/transcripts/route_client-reference-manifest.js +0 -1
  197. package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js +0 -1
  198. package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js.nft.json +0 -1
  199. package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route_client-reference-manifest.js +0 -1
  200. package/ui/standalone/.next/server/app/api/settings/route.js +0 -1
  201. package/ui/standalone/.next/server/app/api/settings/route.js.nft.json +0 -1
  202. package/ui/standalone/.next/server/app/api/settings/route_client-reference-manifest.js +0 -1
  203. package/ui/standalone/.next/server/app/api/setup/complete/route.js +0 -1
  204. package/ui/standalone/.next/server/app/api/setup/complete/route.js.nft.json +0 -1
  205. package/ui/standalone/.next/server/app/api/setup/complete/route_client-reference-manifest.js +0 -1
  206. package/ui/standalone/.next/server/app/api/setup/status/route.js +0 -1
  207. package/ui/standalone/.next/server/app/api/setup/status/route.js.nft.json +0 -1
  208. package/ui/standalone/.next/server/app/api/setup/status/route_client-reference-manifest.js +0 -1
  209. package/ui/standalone/.next/server/app/api/setup/test-groq/route.js +0 -1
  210. package/ui/standalone/.next/server/app/api/setup/test-groq/route.js.nft.json +0 -1
  211. package/ui/standalone/.next/server/app/api/setup/test-groq/route_client-reference-manifest.js +0 -1
  212. package/ui/standalone/.next/server/app/api/setup/test-hass/route.js +0 -1
  213. package/ui/standalone/.next/server/app/api/setup/test-hass/route.js.nft.json +0 -1
  214. package/ui/standalone/.next/server/app/api/setup/test-hass/route_client-reference-manifest.js +0 -1
  215. package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js +0 -1
  216. package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js.nft.json +0 -1
  217. package/ui/standalone/.next/server/app/api/setup/test-n8n/route_client-reference-manifest.js +0 -1
  218. package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js +0 -1
  219. package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js.nft.json +0 -1
  220. package/ui/standalone/.next/server/app/api/setup/test-ollama/route_client-reference-manifest.js +0 -1
  221. package/ui/standalone/.next/server/app/api/test-tts/route.js +0 -1
  222. package/ui/standalone/.next/server/app/api/test-tts/route.js.nft.json +0 -1
  223. package/ui/standalone/.next/server/app/api/test-tts/route_client-reference-manifest.js +0 -1
  224. package/ui/standalone/.next/server/app/api/turn-credentials/route.js +0 -1
  225. package/ui/standalone/.next/server/app/api/turn-credentials/route.js.nft.json +0 -1
  226. package/ui/standalone/.next/server/app/api/turn-credentials/route_client-reference-manifest.js +0 -1
  227. package/ui/standalone/.next/server/app/api/voices/route.js +0 -1
  228. package/ui/standalone/.next/server/app/api/voices/route.js.nft.json +0 -1
  229. package/ui/standalone/.next/server/app/api/voices/route_client-reference-manifest.js +0 -1
  230. package/ui/standalone/.next/server/app/api/wake/route.js +0 -1
  231. package/ui/standalone/.next/server/app/api/wake/route.js.nft.json +0 -1
  232. package/ui/standalone/.next/server/app/api/wake/route_client-reference-manifest.js +0 -1
  233. package/ui/standalone/.next/server/app/api/wake-word/models/route.js +0 -1
  234. package/ui/standalone/.next/server/app/api/wake-word/models/route.js.nft.json +0 -1
  235. package/ui/standalone/.next/server/app/api/wake-word/models/route_client-reference-manifest.js +0 -1
  236. package/ui/standalone/.next/server/app/api/wake-word/status/route.js +0 -1
  237. package/ui/standalone/.next/server/app/api/wake-word/status/route.js.nft.json +0 -1
  238. package/ui/standalone/.next/server/app/api/wake-word/status/route_client-reference-manifest.js +0 -1
  239. package/ui/standalone/.next/server/app/api/wake-word/upload/route.js +0 -1
  240. package/ui/standalone/.next/server/app/api/wake-word/upload/route.js.nft.json +0 -1
  241. package/ui/standalone/.next/server/app/api/wake-word/upload/route_client-reference-manifest.js +0 -1
  242. package/ui/standalone/.next/server/app/favicon.ico/route.js +0 -1
  243. package/ui/standalone/.next/server/app/favicon.ico/route.js.nft.json +0 -1
  244. package/ui/standalone/.next/server/app/favicon.ico.body +0 -0
  245. package/ui/standalone/.next/server/app/favicon.ico.meta +0 -1
  246. package/ui/standalone/.next/server/app/fed/[...path]/route.js +0 -4
  247. package/ui/standalone/.next/server/app/fed/[...path]/route.js.nft.json +0 -1
  248. package/ui/standalone/.next/server/app/fed/[...path]/route_client-reference-manifest.js +0 -1
  249. package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js +0 -1
  250. package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js.nft.json +0 -1
  251. package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route_client-reference-manifest.js +0 -1
  252. package/ui/standalone/.next/server/app/robopark/[...path]/route.js +0 -1
  253. package/ui/standalone/.next/server/app/robopark/[...path]/route.js.nft.json +0 -1
  254. package/ui/standalone/.next/server/app/robopark/[...path]/route_client-reference-manifest.js +0 -1
  255. package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js +0 -1
  256. package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js.nft.json +0 -1
  257. package/ui/standalone/.next/server/app/robopark/api/[...path]/route_client-reference-manifest.js +0 -1
  258. package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js +0 -1
  259. package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js.nft.json +0 -1
  260. package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route_client-reference-manifest.js +0 -1
  261. package/ui/standalone/.next/server/app/robopark/page.js +0 -2
  262. package/ui/standalone/.next/server/app/robopark/page.js.nft.json +0 -1
  263. package/ui/standalone/.next/server/app/robopark/page_client-reference-manifest.js +0 -1
  264. package/ui/standalone/.next/server/app/tailnet/route.js +0 -1
  265. package/ui/standalone/.next/server/app/tailnet/route.js.nft.json +0 -1
  266. package/ui/standalone/.next/server/app/tailnet/route_client-reference-manifest.js +0 -1
  267. package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js +0 -1
  268. package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js.nft.json +0 -1
  269. package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route_client-reference-manifest.js +0 -1
  270. package/ui/standalone/.next/server/app/voice-tester/page.js +0 -2
  271. package/ui/standalone/.next/server/app/voice-tester/page.js.nft.json +0 -1
  272. package/ui/standalone/.next/server/app/voice-tester/page_client-reference-manifest.js +0 -1
  273. package/ui/standalone/.next/server/app-paths-manifest.json +0 -55
  274. package/ui/standalone/.next/server/chunks/1428.js +0 -1
  275. package/ui/standalone/.next/server/chunks/1692.js +0 -1
  276. package/ui/standalone/.next/server/chunks/37.js +0 -1
  277. package/ui/standalone/.next/server/chunks/4876.js +0 -1
  278. package/ui/standalone/.next/server/chunks/5232.js +0 -9
  279. package/ui/standalone/.next/server/chunks/5455.js +0 -11
  280. package/ui/standalone/.next/server/chunks/5611.js +0 -6
  281. package/ui/standalone/.next/server/chunks/5873.js +0 -22
  282. package/ui/standalone/.next/server/chunks/6018.js +0 -1
  283. package/ui/standalone/.next/server/chunks/8755.js +0 -1
  284. package/ui/standalone/.next/server/chunks/9077.js +0 -11
  285. package/ui/standalone/.next/server/functions-config-manifest.json +0 -9
  286. package/ui/standalone/.next/server/middleware-build-manifest.js +0 -1
  287. package/ui/standalone/.next/server/middleware-manifest.json +0 -6
  288. package/ui/standalone/.next/server/middleware-react-loadable-manifest.js +0 -1
  289. package/ui/standalone/.next/server/next-font-manifest.js +0 -1
  290. package/ui/standalone/.next/server/next-font-manifest.json +0 -1
  291. package/ui/standalone/.next/server/pages/500.html +0 -1
  292. package/ui/standalone/.next/server/pages/_app.js +0 -1
  293. package/ui/standalone/.next/server/pages/_app.js.nft.json +0 -1
  294. package/ui/standalone/.next/server/pages/_document.js +0 -1
  295. package/ui/standalone/.next/server/pages/_document.js.nft.json +0 -1
  296. package/ui/standalone/.next/server/pages/_error.js +0 -19
  297. package/ui/standalone/.next/server/pages/_error.js.nft.json +0 -1
  298. package/ui/standalone/.next/server/pages-manifest.json +0 -5
  299. package/ui/standalone/.next/server/server-reference-manifest.js +0 -1
  300. package/ui/standalone/.next/server/server-reference-manifest.json +0 -1
  301. package/ui/standalone/.next/server/webpack-runtime.js +0 -1
  302. package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_buildManifest.js +0 -1
  303. package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_ssgManifest.js +0 -1
  304. package/ui/standalone/.next/static/chunks/1255-404bb8de49c90395.js +0 -1
  305. package/ui/standalone/.next/static/chunks/144d3bae-bd5d150c0cab972e.js +0 -1
  306. package/ui/standalone/.next/static/chunks/1646.9123ee47220ed70b.js +0 -1
  307. package/ui/standalone/.next/static/chunks/2333-43c39acb531f7d4e.js +0 -3
  308. package/ui/standalone/.next/static/chunks/4bd1b696-f785427dddbba9fb.js +0 -1
  309. package/ui/standalone/.next/static/chunks/5139.c5e46d26064a85db.js +0 -1
  310. package/ui/standalone/.next/static/chunks/76e8f37e-2ae0e3fe7b44d874.js +0 -1
  311. package/ui/standalone/.next/static/chunks/9880-53262785284464cc.js +0 -1
  312. package/ui/standalone/.next/static/chunks/adfb71a7-625c1b5ebd039b6a.js +0 -1
  313. package/ui/standalone/.next/static/chunks/app/(app)/layout-5f312b128a0caf75.js +0 -1
  314. package/ui/standalone/.next/static/chunks/app/(app)/opengraph-image-xg4ifa/route-5f312b128a0caf75.js +0 -1
  315. package/ui/standalone/.next/static/chunks/app/(app)/page-5f312b128a0caf75.js +0 -1
  316. package/ui/standalone/.next/static/chunks/app/(app)/voice/page-72d72d9a70d9c1d4.js +0 -1
  317. package/ui/standalone/.next/static/chunks/app/_not-found/page-cfd7daa82d71540c.js +0 -1
  318. package/ui/standalone/.next/static/chunks/app/api/connection-details/route-5f312b128a0caf75.js +0 -1
  319. package/ui/standalone/.next/static/chunks/app/api/device-character/route-5f312b128a0caf75.js +0 -1
  320. package/ui/standalone/.next/static/chunks/app/api/download-piper-model/route-5f312b128a0caf75.js +0 -1
  321. package/ui/standalone/.next/static/chunks/app/api/model-status/route-5f312b128a0caf75.js +0 -1
  322. package/ui/standalone/.next/static/chunks/app/api/models/route-5f312b128a0caf75.js +0 -1
  323. package/ui/standalone/.next/static/chunks/app/api/prewarm/route-5f312b128a0caf75.js +0 -1
  324. package/ui/standalone/.next/static/chunks/app/api/prompt/route-5f312b128a0caf75.js +0 -1
  325. package/ui/standalone/.next/static/chunks/app/api/reload-tools/route-5f312b128a0caf75.js +0 -1
  326. package/ui/standalone/.next/static/chunks/app/api/robopark/camera-token/route-5f312b128a0caf75.js +0 -1
  327. package/ui/standalone/.next/static/chunks/app/api/robopark/character-ai/route-5f312b128a0caf75.js +0 -1
  328. package/ui/standalone/.next/static/chunks/app/api/robopark/device-access/route-5f312b128a0caf75.js +0 -1
  329. package/ui/standalone/.next/static/chunks/app/api/robopark/devices/[deviceId]/control/route-5f312b128a0caf75.js +0 -1
  330. package/ui/standalone/.next/static/chunks/app/api/robopark/live-rooms/route-5f312b128a0caf75.js +0 -1
  331. package/ui/standalone/.next/static/chunks/app/api/robopark/monitor-token/route-5f312b128a0caf75.js +0 -1
  332. package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/operations/route-5f312b128a0caf75.js +0 -1
  333. package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/voice-engine/route-5f312b128a0caf75.js +0 -1
  334. package/ui/standalone/.next/static/chunks/app/api/robopark/robots/route-5f312b128a0caf75.js +0 -1
  335. package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/metrics/route-5f312b128a0caf75.js +0 -1
  336. package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/route-5f312b128a0caf75.js +0 -1
  337. package/ui/standalone/.next/static/chunks/app/api/robopark/servers/route-5f312b128a0caf75.js +0 -1
  338. package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/[sessionId]/end/route-5f312b128a0caf75.js +0 -1
  339. package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/route-5f312b128a0caf75.js +0 -1
  340. package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/[sessionId]/route-5f312b128a0caf75.js +0 -1
  341. package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/route-5f312b128a0caf75.js +0 -1
  342. package/ui/standalone/.next/static/chunks/app/api/robovoice/robots/[robotId]/media-health/route-5f312b128a0caf75.js +0 -1
  343. package/ui/standalone/.next/static/chunks/app/api/settings/route-5f312b128a0caf75.js +0 -1
  344. package/ui/standalone/.next/static/chunks/app/api/setup/complete/route-5f312b128a0caf75.js +0 -1
  345. package/ui/standalone/.next/static/chunks/app/api/setup/status/route-5f312b128a0caf75.js +0 -1
  346. package/ui/standalone/.next/static/chunks/app/api/setup/test-groq/route-5f312b128a0caf75.js +0 -1
  347. package/ui/standalone/.next/static/chunks/app/api/setup/test-hass/route-5f312b128a0caf75.js +0 -1
  348. package/ui/standalone/.next/static/chunks/app/api/setup/test-n8n/route-5f312b128a0caf75.js +0 -1
  349. package/ui/standalone/.next/static/chunks/app/api/setup/test-ollama/route-5f312b128a0caf75.js +0 -1
  350. package/ui/standalone/.next/static/chunks/app/api/test-tts/route-5f312b128a0caf75.js +0 -1
  351. package/ui/standalone/.next/static/chunks/app/api/turn-credentials/route-5f312b128a0caf75.js +0 -1
  352. package/ui/standalone/.next/static/chunks/app/api/voices/route-5f312b128a0caf75.js +0 -1
  353. package/ui/standalone/.next/static/chunks/app/api/wake/route-5f312b128a0caf75.js +0 -1
  354. package/ui/standalone/.next/static/chunks/app/api/wake-word/models/route-5f312b128a0caf75.js +0 -1
  355. package/ui/standalone/.next/static/chunks/app/api/wake-word/status/route-5f312b128a0caf75.js +0 -1
  356. package/ui/standalone/.next/static/chunks/app/api/wake-word/upload/route-5f312b128a0caf75.js +0 -1
  357. package/ui/standalone/.next/static/chunks/app/fed/[...path]/route-5f312b128a0caf75.js +0 -1
  358. package/ui/standalone/.next/static/chunks/app/fed/media/robots/[robotId]/video_feed/route-5f312b128a0caf75.js +0 -1
  359. package/ui/standalone/.next/static/chunks/app/layout-fcce963da1d8220f.js +0 -1
  360. package/ui/standalone/.next/static/chunks/app/robopark/[...path]/route-5f312b128a0caf75.js +0 -1
  361. package/ui/standalone/.next/static/chunks/app/robopark/api/[...path]/route-5f312b128a0caf75.js +0 -1
  362. package/ui/standalone/.next/static/chunks/app/robopark/api/robots/[robotId]/stream/route-5f312b128a0caf75.js +0 -1
  363. package/ui/standalone/.next/static/chunks/app/robopark/page-5f312b128a0caf75.js +0 -1
  364. package/ui/standalone/.next/static/chunks/app/tailnet/route-5f312b128a0caf75.js +0 -1
  365. package/ui/standalone/.next/static/chunks/app/vendor/livekit-client.umd.js/route-5f312b128a0caf75.js +0 -1
  366. package/ui/standalone/.next/static/chunks/app/voice-tester/page-ae699e048e0eb834.js +0 -1
  367. package/ui/standalone/.next/static/chunks/framework-e60c938074ff7136.js +0 -1
  368. package/ui/standalone/.next/static/chunks/main-9e24e8708ff075fc.js +0 -1
  369. package/ui/standalone/.next/static/chunks/main-app-a1f59168303c7f84.js +0 -1
  370. package/ui/standalone/.next/static/chunks/pages/_app-131c90850aef965b.js +0 -1
  371. package/ui/standalone/.next/static/chunks/pages/_error-e4ba546eb376bdf4.js +0 -1
  372. package/ui/standalone/.next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
  373. package/ui/standalone/.next/static/chunks/webpack-5252e5254da079b5.js +0 -1
  374. package/ui/standalone/.next/static/css/3f478a573f9be82e.css +0 -1
  375. package/ui/standalone/.next/static/css/51560c4af1d5a21f.css +0 -1
  376. package/ui/standalone/.next/static/media/27fe93087559d480-s.p.otf +0 -0
  377. package/ui/standalone/.next/static/media/4d3869dd8a56345a-s.p.otf +0 -0
  378. package/ui/standalone/.next/static/media/63f3bb9ce29e22d5-s.p.otf +0 -0
  379. package/ui/standalone/.next/static/media/79e1c5d0f2fe9f68-s.p.otf +0 -0
  380. package/ui/standalone/.next/static/media/8bf7b2ceda89477b-s.p.woff2 +0 -0
  381. package/ui/standalone/.next/static/media/98baa4a7809fbf72-s.woff2 +0 -0
  382. package/ui/standalone/.next/static/media/f7650dfaa438c37c-s.woff2 +0 -0
  383. package/ui/standalone/package.json +0 -57
  384. package/ui/standalone/public/caal-logo.svg +0 -3
  385. package/ui/standalone/public/commit-mono-400-regular.woff +0 -0
  386. package/ui/standalone/public/cwl-logo-round.png +0 -0
  387. package/ui/standalone/public/everett-light.woff +0 -0
  388. package/ui/standalone/public/hey_cal.ppn +0 -0
  389. package/ui/standalone/public/install-robot-kiosk.sh +0 -242
  390. package/ui/standalone/public/lk-logo-dark.svg +0 -18
  391. package/ui/standalone/public/lk-logo.svg +0 -18
  392. package/ui/standalone/public/lk-wordmark.svg +0 -12
  393. package/ui/standalone/public/opengraph-image-bg.png +0 -0
  394. package/ui/standalone/public/porcupine_params.pv +0 -0
  395. package/ui/standalone/public/robopark-control.html +0 -9200
  396. package/ui/standalone/public/static/robopark/barracuda.png +0 -0
  397. package/ui/standalone/public/static/robopark/bmw.png +0 -0
  398. package/ui/standalone/public/static/robopark/jaguar.png +0 -0
  399. package/ui/standalone/public/static/robopark/magnus.jpg +0 -0
  400. package/ui/standalone/public/static/robopark/panda.png +0 -0
  401. package/ui/standalone/public/static/robopark/tesla.png +0 -0
  402. package/ui/standalone/public/static/robopark/titan.jpg +0 -0
  403. package/ui/standalone/public/static/robopark/volt.png +0 -0
  404. package/ui/standalone/public/tailnet/index.html +0 -8995
  405. package/ui/standalone/public/transcript-analytics.html +0 -72
  406. package/ui/standalone/public/vendor/livekit-client.umd.js +0 -2
  407. package/ui/standalone/server.js +0 -38
  408. /package/{ui/standalone/public → scheduler/static}/vendor/elevenlabs-client.iife.js +0 -0
@@ -66,38 +66,38 @@ import httpx
66
66
 
67
67
  logger = logging.getLogger("robopark.preview_agent")
68
68
 
69
- _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
- _DEVICE_INVENTORY_CACHE_AT = 0.0
71
- DEVICE_INVENTORY_CACHE_SECONDS = 5.0
72
- ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
73
-
74
-
75
- def _stable_audio_label(value: object) -> str:
76
- """Compare USB product names without volatile ALSA card coordinates."""
77
- import re
78
- return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
79
-
80
-
81
- def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
82
- if preferred:
83
- wanted = _stable_audio_label(preferred)
84
- match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
85
- if match and match.get("name"):
86
- return str(match["name"])
87
- selected_text = str(selected)
88
- selected_label = _stable_audio_label(selected_text)
89
- match = next(
90
- (
91
- item for item in items
92
- if str(item.get("id")) == selected_text
93
- or _stable_audio_label(item.get("name")) == selected_label
94
- ),
95
- None,
96
- )
97
- return str(match.get("name")) if match and match.get("name") else selected_text
98
-
99
-
100
- def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
69
+ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
+ _DEVICE_INVENTORY_CACHE_AT = 0.0
71
+ DEVICE_INVENTORY_CACHE_SECONDS = 5.0
72
+ ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
73
+
74
+
75
+ def _stable_audio_label(value: object) -> str:
76
+ """Compare USB product names without volatile ALSA card coordinates."""
77
+ import re
78
+ return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
79
+
80
+
81
+ def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
82
+ if preferred:
83
+ wanted = _stable_audio_label(preferred)
84
+ match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
85
+ if match and match.get("name"):
86
+ return str(match["name"])
87
+ selected_text = str(selected)
88
+ selected_label = _stable_audio_label(selected_text)
89
+ match = next(
90
+ (
91
+ item for item in items
92
+ if str(item.get("id")) == selected_text
93
+ or _stable_audio_label(item.get("name")) == selected_label
94
+ ),
95
+ None,
96
+ )
97
+ return str(match.get("name")) if match and match.get("name") else selected_text
98
+
99
+
100
+ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
101
101
  """Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
102
102
  if not url:
103
103
  return url
@@ -105,105 +105,105 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
105
105
  prefix = f"{scheme}://localhost"
106
106
  if url.startswith(prefix):
107
107
  return f"{scheme}://127.0.0.1" + url[len(prefix):]
108
- return url
109
-
110
-
111
- def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
112
- """Apply gain and measure peak without audioop (removed in Python 3.13)."""
113
- from array import array
114
-
115
- samples = array("h")
116
- samples.frombytes(data[:len(data) - (len(data) % 2)])
117
- if sys.byteorder != "little":
118
- samples.byteswap()
119
- peak = 0
120
- for index, value in enumerate(samples):
121
- scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
122
- samples[index] = scaled
123
- peak = max(peak, abs(scaled))
124
- if sys.byteorder != "little":
125
- samples.byteswap()
126
- return samples.tobytes(), peak
127
-
128
-
129
- def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
130
- """Linearly resample a PCM16 mono chunk using only the standard library."""
131
- from array import array
132
-
133
- if source_rate == target_rate or len(data) < 4:
134
- return data
135
- source = array("h")
136
- source.frombytes(data[:len(data) - (len(data) % 2)])
137
- if sys.byteorder != "little":
138
- source.byteswap()
139
- target_count = max(1, round(len(source) * target_rate / source_rate))
140
- target = array("h", [0]) * target_count
141
- scale = source_rate / target_rate
142
- last = len(source) - 1
143
- for index in range(target_count):
144
- position = min(last, index * scale)
145
- left = int(position)
146
- right = min(last, left + 1)
147
- fraction = position - left
148
- target[index] = max(-32768, min(32767, round(
149
- source[left] + (source[right] - source[left]) * fraction
150
- )))
151
- if sys.byteorder != "little":
152
- target.byteswap()
153
- return target.tobytes()
154
-
155
-
156
- def _play_audio_effect(selected_output: str | None, effect: str) -> None:
157
- """Play a short local cue without involving the voice pipeline."""
158
- sample_rate = 48000
159
- channels = 2
108
+ return url
109
+
110
+
111
+ def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
112
+ """Apply gain and measure peak without audioop (removed in Python 3.13)."""
113
+ from array import array
114
+
115
+ samples = array("h")
116
+ samples.frombytes(data[:len(data) - (len(data) % 2)])
117
+ if sys.byteorder != "little":
118
+ samples.byteswap()
119
+ peak = 0
120
+ for index, value in enumerate(samples):
121
+ scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
122
+ samples[index] = scaled
123
+ peak = max(peak, abs(scaled))
124
+ if sys.byteorder != "little":
125
+ samples.byteswap()
126
+ return samples.tobytes(), peak
127
+
128
+
129
+ def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
130
+ """Linearly resample a PCM16 mono chunk using only the standard library."""
131
+ from array import array
132
+
133
+ if source_rate == target_rate or len(data) < 4:
134
+ return data
135
+ source = array("h")
136
+ source.frombytes(data[:len(data) - (len(data) % 2)])
137
+ if sys.byteorder != "little":
138
+ source.byteswap()
139
+ target_count = max(1, round(len(source) * target_rate / source_rate))
140
+ target = array("h", [0]) * target_count
141
+ scale = source_rate / target_rate
142
+ last = len(source) - 1
143
+ for index in range(target_count):
144
+ position = min(last, index * scale)
145
+ left = int(position)
146
+ right = min(last, left + 1)
147
+ fraction = position - left
148
+ target[index] = max(-32768, min(32767, round(
149
+ source[left] + (source[right] - source[left]) * fraction
150
+ )))
151
+ if sys.byteorder != "little":
152
+ target.byteswap()
153
+ return target.tobytes()
154
+
155
+
156
+ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
157
+ """Play a short local cue without involving the voice pipeline."""
158
+ sample_rate = 48000
159
+ channels = 2
160
160
  if effect == "motion":
161
161
  notes = ((880, 0.09), (1320, 0.13))
162
162
  else:
163
163
  notes = ((660, 0.10), (440, 0.16))
164
- selected = str(selected_output or "default")
165
- frames = bytearray()
166
- for frequency, duration in notes:
167
- count = int(sample_rate * duration)
168
- for n in range(count):
169
- envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
170
- value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
171
- frames.extend(struct.pack("<hh", value, value))
172
-
173
- if sys.platform.startswith("linux") and "hw:" in selected:
174
- import re
175
- import subprocess
176
- from media_lock import media_lock
177
-
178
- match = re.search(r"\b(hw:\d+,\d+)\b", selected)
179
- if not match:
180
- return
181
- try:
182
- with media_lock("speaker", timeout=3.0):
183
- result = subprocess.run(
184
- [
185
- "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
186
- "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
187
- ],
188
- input=bytes(frames), capture_output=True, timeout=3.0,
189
- )
190
- except TimeoutError as exc:
191
- logger.warning("audio effect skipped: %s", exc)
192
- return
193
- if result.returncode:
194
- logger.warning(
195
- "audio effect failed on %s: %s",
196
- match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
197
- )
198
- return
199
-
200
- try:
201
- import pyaudio
202
- except Exception:
203
- return
204
- pa = pyaudio.PyAudio()
205
- device_index = None
206
- try:
164
+ selected = str(selected_output or "default")
165
+ frames = bytearray()
166
+ for frequency, duration in notes:
167
+ count = int(sample_rate * duration)
168
+ for n in range(count):
169
+ envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
170
+ value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
171
+ frames.extend(struct.pack("<hh", value, value))
172
+
173
+ if sys.platform.startswith("linux") and "hw:" in selected:
174
+ import re
175
+ import subprocess
176
+ from media_lock import media_lock
177
+
178
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected)
179
+ if not match:
180
+ return
181
+ try:
182
+ with media_lock("speaker", timeout=3.0):
183
+ result = subprocess.run(
184
+ [
185
+ "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
186
+ "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
187
+ ],
188
+ input=bytes(frames), capture_output=True, timeout=3.0,
189
+ )
190
+ except TimeoutError as exc:
191
+ logger.warning("audio effect skipped: %s", exc)
192
+ return
193
+ if result.returncode:
194
+ logger.warning(
195
+ "audio effect failed on %s: %s",
196
+ match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
197
+ )
198
+ return
199
+
200
+ try:
201
+ import pyaudio
202
+ except Exception:
203
+ return
204
+ pa = pyaudio.PyAudio()
205
+ device_index = None
206
+ try:
207
207
  if selected.strip().isdigit():
208
208
  device_index = int(selected.strip())
209
209
  elif selected.lower() == "default":
@@ -223,7 +223,7 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
223
223
  output=True,
224
224
  output_device_index=device_index,
225
225
  )
226
- stream.write(bytes(frames))
226
+ stream.write(bytes(frames))
227
227
  stream.stop_stream()
228
228
  stream.close()
229
229
  except Exception as e:
@@ -232,109 +232,109 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
232
232
  pa.terminate()
233
233
 
234
234
 
235
- def _get_device_inventory() -> dict:
236
- """Return discoverable camera and audio devices for dashboard selection."""
237
- global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
238
- now = time.monotonic()
239
- if (_DEVICE_INVENTORY_CACHE is not None
240
- and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
241
- return _DEVICE_INVENTORY_CACHE
242
-
243
- inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
244
- inventory["video"].append({"id": "auto", "name": "Auto detect"})
245
- inventory["video"].append({"id": "none", "name": "Disable camera"})
246
-
247
- # RoboVisionAI_PI owns the production camera. Prefer its native inventory
248
- # so this process never probes an already-open V4L2 device just to fill a
249
- # dashboard dropdown.
250
- robovision_inventory = None
251
- try:
252
- response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
253
- if response.is_success:
254
- robovision_inventory = response.json()
255
- for key in ("video", "audio_input", "audio_output"):
256
- if isinstance(robovision_inventory.get(key), list):
257
- inventory[key] = robovision_inventory[key]
258
- inventory["source"] = robovision_inventory.get("source", "robovision_pi")
259
- except Exception:
260
- pass
261
-
262
- if not robovision_inventory:
263
- try:
264
- import cv2
265
- candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
266
- for candidate in candidates:
267
- value = int(candidate) if os.name == "nt" else candidate
268
- backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
269
- cap = cv2.VideoCapture(value, backend)
270
- if cap.isOpened():
271
- device_id = str(value)
272
- inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
273
- cap.release()
274
- except Exception as e:
275
- logger.debug(f"camera inventory unavailable: {e}")
276
-
277
- if not robovision_inventory:
278
- try:
279
- import pyaudio
280
- pa = pyaudio.PyAudio()
281
- default_in = None
282
- default_out = None
283
- try:
284
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
285
- default_in = wasapi.get("defaultInputDevice")
286
- default_out = wasapi.get("defaultOutputDevice")
287
- except Exception:
288
- pass
289
- inventory["audio_input"].append({"id": "default", "name": "System default input"})
290
- inventory["audio_output"].append({"id": "default", "name": "System default output"})
291
- for i in range(pa.get_device_count()):
292
- info = pa.get_device_info_by_index(i)
293
- name = str(info.get("name", f"Audio device {i}"))
294
- item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
295
- if info.get("maxInputChannels", 0) > 0:
296
- item["default"] = i == default_in
297
- inventory["audio_input"].append(item.copy())
298
- if info.get("maxOutputChannels", 0) > 0:
299
- item["default"] = i == default_out
300
- inventory["audio_output"].append(item.copy())
301
- pa.terminate()
302
- except Exception as e:
303
- logger.debug(f"audio inventory unavailable: {e}")
304
-
305
- media_health = {
306
- "service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
307
- "camera_access": None,
308
- "audio_access": None,
309
- "camera_worker": None,
310
- "camera_stalled": None,
311
- "camera_frame_age_seconds": None,
312
- }
313
- if sys.platform.startswith("linux"):
314
- camera_nodes = [
315
- str(item.get("id")) for item in inventory.get("video", [])
316
- if str(item.get("id", "")).startswith("/dev/video")
317
- ]
318
- sound_nodes = glob.glob("/dev/snd/pcm*")
319
- media_health["camera_access"] = bool(camera_nodes) and all(
320
- os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
321
- )
322
- media_health["audio_access"] = bool(sound_nodes) and all(
323
- os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
324
- )
325
- try:
326
- camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
327
- if camera_response.is_success:
328
- camera_status = camera_response.json()
329
- media_health["camera_worker"] = bool(camera_status.get("worker_started"))
330
- media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
331
- media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
332
- except Exception:
333
- pass
334
- inventory["media_health"] = media_health
335
-
336
- _DEVICE_INVENTORY_CACHE = inventory
337
- _DEVICE_INVENTORY_CACHE_AT = now
235
+ def _get_device_inventory() -> dict:
236
+ """Return discoverable camera and audio devices for dashboard selection."""
237
+ global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
238
+ now = time.monotonic()
239
+ if (_DEVICE_INVENTORY_CACHE is not None
240
+ and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
241
+ return _DEVICE_INVENTORY_CACHE
242
+
243
+ inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
244
+ inventory["video"].append({"id": "auto", "name": "Auto detect"})
245
+ inventory["video"].append({"id": "none", "name": "Disable camera"})
246
+
247
+ # RoboVisionAI_PI owns the production camera. Prefer its native inventory
248
+ # so this process never probes an already-open V4L2 device just to fill a
249
+ # dashboard dropdown.
250
+ robovision_inventory = None
251
+ try:
252
+ response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
253
+ if response.is_success:
254
+ robovision_inventory = response.json()
255
+ for key in ("video", "audio_input", "audio_output"):
256
+ if isinstance(robovision_inventory.get(key), list):
257
+ inventory[key] = robovision_inventory[key]
258
+ inventory["source"] = robovision_inventory.get("source", "robovision_pi")
259
+ except Exception:
260
+ pass
261
+
262
+ if not robovision_inventory:
263
+ try:
264
+ import cv2
265
+ candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
266
+ for candidate in candidates:
267
+ value = int(candidate) if os.name == "nt" else candidate
268
+ backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
269
+ cap = cv2.VideoCapture(value, backend)
270
+ if cap.isOpened():
271
+ device_id = str(value)
272
+ inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
273
+ cap.release()
274
+ except Exception as e:
275
+ logger.debug(f"camera inventory unavailable: {e}")
276
+
277
+ if not robovision_inventory:
278
+ try:
279
+ import pyaudio
280
+ pa = pyaudio.PyAudio()
281
+ default_in = None
282
+ default_out = None
283
+ try:
284
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
285
+ default_in = wasapi.get("defaultInputDevice")
286
+ default_out = wasapi.get("defaultOutputDevice")
287
+ except Exception:
288
+ pass
289
+ inventory["audio_input"].append({"id": "default", "name": "System default input"})
290
+ inventory["audio_output"].append({"id": "default", "name": "System default output"})
291
+ for i in range(pa.get_device_count()):
292
+ info = pa.get_device_info_by_index(i)
293
+ name = str(info.get("name", f"Audio device {i}"))
294
+ item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
295
+ if info.get("maxInputChannels", 0) > 0:
296
+ item["default"] = i == default_in
297
+ inventory["audio_input"].append(item.copy())
298
+ if info.get("maxOutputChannels", 0) > 0:
299
+ item["default"] = i == default_out
300
+ inventory["audio_output"].append(item.copy())
301
+ pa.terminate()
302
+ except Exception as e:
303
+ logger.debug(f"audio inventory unavailable: {e}")
304
+
305
+ media_health = {
306
+ "service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
307
+ "camera_access": None,
308
+ "audio_access": None,
309
+ "camera_worker": None,
310
+ "camera_stalled": None,
311
+ "camera_frame_age_seconds": None,
312
+ }
313
+ if sys.platform.startswith("linux"):
314
+ camera_nodes = [
315
+ str(item.get("id")) for item in inventory.get("video", [])
316
+ if str(item.get("id", "")).startswith("/dev/video")
317
+ ]
318
+ sound_nodes = glob.glob("/dev/snd/pcm*")
319
+ media_health["camera_access"] = bool(camera_nodes) and all(
320
+ os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
321
+ )
322
+ media_health["audio_access"] = bool(sound_nodes) and all(
323
+ os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
324
+ )
325
+ try:
326
+ camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
327
+ if camera_response.is_success:
328
+ camera_status = camera_response.json()
329
+ media_health["camera_worker"] = bool(camera_status.get("worker_started"))
330
+ media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
331
+ media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
332
+ except Exception:
333
+ pass
334
+ inventory["media_health"] = media_health
335
+
336
+ _DEVICE_INVENTORY_CACHE = inventory
337
+ _DEVICE_INVENTORY_CACHE_AT = now
338
338
  logger.info(
339
339
  f"device inventory: {len(inventory['video']) - 2} cameras, "
340
340
  f"{len(inventory['audio_input']) - 1} inputs, "
@@ -385,57 +385,57 @@ def _load_token() -> Optional[str]:
385
385
  return None
386
386
 
387
387
 
388
- def _save_token(token: str) -> None:
389
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
390
- TOKEN_FILE.write_text(token, encoding="utf8")
391
- os.chmod(TOKEN_FILE, 0o600)
392
-
393
-
394
- def _mesh_proxy_headers() -> dict:
395
- """Add hub authentication only when the runtime supplied it.
396
-
397
- Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
398
- /robopark proxy, which needs this separate mesh credential while the normal
399
- Authorization header remains the scheduler device token.
400
- """
401
- token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
402
- return {"X-RoboPark-Mesh-Token": token} if token else {}
403
-
404
-
405
- async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
406
- """Create or recover this robot's scheduler identity using mesh auth."""
407
- async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
408
- response = await client.post(
409
- f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
410
- json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
411
- timeout=30.0,
412
- )
413
- response.raise_for_status()
414
- data = response.json()
415
- device_id = str(data.get("device_id") or "").strip()
416
- device_token = str(data.get("device_token") or "").strip()
417
- if not device_id or not device_token:
418
- raise RuntimeError("mesh bootstrap did not return device credentials")
419
- _save_token(device_token)
420
- cfg = _load_config()
421
- cfg["device_id"] = device_id
422
- cfg["device_token"] = device_token
423
- cfg["scheduler_url"] = scheduler_url
424
- _save_config(cfg)
425
- logger.info("mesh bootstrap resolved scheduler device %s", device_id)
426
- return device_id, device_token
427
-
428
-
429
- async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
430
- """Enroll this Pi and return the exact scheduler identity and token."""
388
+ def _save_token(token: str) -> None:
389
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
390
+ TOKEN_FILE.write_text(token, encoding="utf8")
391
+ os.chmod(TOKEN_FILE, 0o600)
392
+
393
+
394
+ def _mesh_proxy_headers() -> dict:
395
+ """Add hub authentication only when the runtime supplied it.
396
+
397
+ Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
398
+ /robopark proxy, which needs this separate mesh credential while the normal
399
+ Authorization header remains the scheduler device token.
400
+ """
401
+ token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
402
+ return {"X-RoboPark-Mesh-Token": token} if token else {}
403
+
404
+
405
+ async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
406
+ """Create or recover this robot's scheduler identity using mesh auth."""
407
+ async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
408
+ response = await client.post(
409
+ f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
410
+ json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
411
+ timeout=30.0,
412
+ )
413
+ response.raise_for_status()
414
+ data = response.json()
415
+ device_id = str(data.get("device_id") or "").strip()
416
+ device_token = str(data.get("device_token") or "").strip()
417
+ if not device_id or not device_token:
418
+ raise RuntimeError("mesh bootstrap did not return device credentials")
419
+ _save_token(device_token)
420
+ cfg = _load_config()
421
+ cfg["device_id"] = device_id
422
+ cfg["device_token"] = device_token
423
+ cfg["scheduler_url"] = scheduler_url
424
+ _save_config(cfg)
425
+ logger.info("mesh bootstrap resolved scheduler device %s", device_id)
426
+ return device_id, device_token
427
+
428
+
429
+ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
430
+ """Enroll this Pi and return the exact scheduler identity and token."""
431
431
  import socket
432
432
  payload = {
433
433
  "enrollment_token": enrollment_token,
434
- "name": robot_id,
435
- "lan_ip": _get_lan_ip(),
436
- "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
434
+ "name": robot_id,
435
+ "lan_ip": _get_lan_ip(),
436
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
437
437
  }
438
- async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
438
+ async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
439
439
  r = await client.post(
440
440
  f"{scheduler_url.rstrip('/')}/api/devices/enroll",
441
441
  json=payload,
@@ -443,17 +443,17 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> t
443
443
  )
444
444
  r.raise_for_status()
445
445
  data = r.json()
446
- device_id = str(data.get("device_id") or "").strip()
447
- device_token = str(data.get("device_token") or "").strip()
448
- if not device_id or not device_token:
449
- raise RuntimeError("enrollment did not return device credentials")
446
+ device_id = str(data.get("device_id") or "").strip()
447
+ device_token = str(data.get("device_token") or "").strip()
448
+ if not device_id or not device_token:
449
+ raise RuntimeError("enrollment did not return device credentials")
450
450
  _save_token(device_token)
451
451
  cfg = _load_config()
452
- cfg["device_id"] = device_id
452
+ cfg["device_id"] = device_id
453
453
  cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
454
454
  _save_config(cfg)
455
- logger.info("enrolled as device %s", device_id)
456
- return device_id, device_token
455
+ logger.info("enrolled as device %s", device_id)
456
+ return device_id, device_token
457
457
 
458
458
 
459
459
  def _get_lan_ip() -> Optional[str]:
@@ -468,39 +468,39 @@ def _get_lan_ip() -> Optional[str]:
468
468
  return None
469
469
 
470
470
 
471
- async def _send_heartbeat(
472
- scheduler_url: str, device_id: str, token: str
473
- ) -> tuple[Optional[bool], bool]:
474
- try:
475
- inventory = _get_device_inventory()
476
- headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
477
- async with httpx.AsyncClient() as client:
478
- response = await client.post(
479
- f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
480
- json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
481
- "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
482
- headers=headers,
483
- timeout=10.0,
484
- )
485
- if response.status_code in (401, 404):
486
- logger.warning(
487
- "heartbeat credentials rejected for %s (%s)",
488
- device_id,
489
- response.status_code,
490
- )
491
- return None, False
492
- response.raise_for_status()
493
- logger.info(
494
- "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
495
- inventory.get("source", "local"),
496
- len(inventory.get("video", [])),
497
- len(inventory.get("audio_input", [])),
498
- len(inventory.get("audio_output", [])),
499
- )
500
- return bool(response.json().get("production_mode", False)), True
501
- except Exception as e:
502
- logger.warning(f"heartbeat failed: {e}")
503
- return None, True
471
+ async def _send_heartbeat(
472
+ scheduler_url: str, device_id: str, token: str
473
+ ) -> tuple[Optional[bool], bool]:
474
+ try:
475
+ inventory = _get_device_inventory()
476
+ headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
477
+ async with httpx.AsyncClient() as client:
478
+ response = await client.post(
479
+ f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
480
+ json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
481
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
482
+ headers=headers,
483
+ timeout=10.0,
484
+ )
485
+ if response.status_code in (401, 404):
486
+ logger.warning(
487
+ "heartbeat credentials rejected for %s (%s)",
488
+ device_id,
489
+ response.status_code,
490
+ )
491
+ return None, False
492
+ response.raise_for_status()
493
+ logger.info(
494
+ "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
495
+ inventory.get("source", "local"),
496
+ len(inventory.get("video", [])),
497
+ len(inventory.get("audio_input", [])),
498
+ len(inventory.get("audio_output", [])),
499
+ )
500
+ return bool(response.json().get("production_mode", False)), True
501
+ except Exception as e:
502
+ logger.warning(f"heartbeat failed: {e}")
503
+ return None, True
504
504
 
505
505
 
506
506
  @dataclass
@@ -521,18 +521,18 @@ class PreviewAgent:
521
521
  self.device_id: Optional[str] = cfg.get("device_id")
522
522
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
523
523
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
524
- self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
525
- # Scheduler inventory IDs come from RoboVision/sounddevice. They are
526
- # not guaranteed to equal PyAudio's device indexes, so capture must
527
- # use the resolved hardware name reported in that same inventory.
528
- self.audio_capture_device = self.audio_device
529
- self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
530
- # Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
531
- # number the same ALSA cards differently, so resolve the selected ID
532
- # to its hardware name before opening any playback stream.
533
- self.audio_playback_device = self.audio_output_device
534
- self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
535
- self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
524
+ self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
525
+ # Scheduler inventory IDs come from RoboVision/sounddevice. They are
526
+ # not guaranteed to equal PyAudio's device indexes, so capture must
527
+ # use the resolved hardware name reported in that same inventory.
528
+ self.audio_capture_device = self.audio_device
529
+ self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
530
+ # Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
531
+ # number the same ALSA cards differently, so resolve the selected ID
532
+ # to its hardware name before opening any playback stream.
533
+ self.audio_playback_device = self.audio_output_device
534
+ self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
535
+ self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
536
536
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
537
537
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
538
538
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -542,17 +542,17 @@ class PreviewAgent:
542
542
  self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
543
543
  self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
544
544
  self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
545
- self.local_camera_motion = str(
545
+ self.local_camera_motion = str(
546
546
  # Production needs an always-on detector. The preview publisher
547
547
  # remains the single camera owner when a session starts, so this
548
548
  # does not require the separate OpenCV vision server.
549
549
  cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
550
- ).lower() in ("1", "true", "yes", "on")
551
- # The character greeting is the production acknowledgement. A local
552
- # motion beep serializes on the same ALSA output and delays allocation.
553
- self.motion_cue_enabled = str(
554
- cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
555
- ).lower() in ("1", "true", "yes", "on")
550
+ ).lower() in ("1", "true", "yes", "on")
551
+ # The character greeting is the production acknowledgement. A local
552
+ # motion beep serializes on the same ALSA output and delays allocation.
553
+ self.motion_cue_enabled = str(
554
+ cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
555
+ ).lower() in ("1", "true", "yes", "on")
556
556
 
557
557
  self._shutdown = asyncio.Event()
558
558
  self._task: Optional[asyncio.Task] = None
@@ -566,96 +566,96 @@ class PreviewAgent:
566
566
  # with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
567
567
  # _check_vision_session() below for the full design.
568
568
  self._vision_session_id: Optional[str] = None
569
- self._vision_trigger_in_flight = False
570
- self._speaker_test_in_flight = False
571
- # The USB adapter is an exclusive ALSA endpoint. Keep operator tests
572
- # and motion cues from racing each other while LiveKit is paused.
573
- self._speaker_operation_lock = asyncio.Lock()
569
+ self._vision_trigger_in_flight = False
570
+ self._speaker_test_in_flight = False
571
+ # The USB adapter is an exclusive ALSA endpoint. Keep operator tests
572
+ # and motion cues from racing each other while LiveKit is paused.
573
+ self._speaker_operation_lock = asyncio.Lock()
574
574
  self._vision_hard_deadline: float = 0.0
575
575
  self._vision_last_activity: float = 0.0
576
- self.production_mode = False
577
- self._robovision_motion_state: Optional[bool] = None
576
+ self.production_mode = False
577
+ self._robovision_motion_state: Optional[bool] = None
578
578
  self._remote_session_ended = False
579
579
  self._last_device_config_poll = 0.0
580
580
  self._motion_reference = None
581
581
  self._last_motion_sample = 0.0
582
- self._motion_capture: Optional[VideoCapture] = None
583
- self._motion_capture_lock = asyncio.Lock()
584
- self._mesh_bootstrap_ready = False
585
- self._next_mesh_bootstrap = 0.0
586
- self._reported_pipeline: set[tuple[str, str]] = set()
587
-
588
- async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
589
- details: Optional[dict] = None, once: bool = True) -> None:
590
- """Report a real robot-side transition for the Park test timeline."""
591
- if not self._session or not self.device_id or not self.device_token:
592
- return
593
- session_id = self._vision_session_id or self._current_state.session_id
594
- key = (session_id or "idle", stage)
595
- if once and key in self._reported_pipeline:
596
- return
597
- try:
598
- response = await self._session.post(
599
- f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
600
- headers={"Authorization": f"Bearer {self.device_token}"},
601
- json={"stage": stage, "status": status, "message": message,
602
- "session_id": session_id, "source": "preview_agent",
603
- "details": details or {}},
604
- timeout=5.0,
605
- )
606
- response.raise_for_status()
607
- if once:
608
- self._reported_pipeline.add(key)
609
- except Exception as exc:
610
- logger.debug("pipeline event %s was not accepted: %s", stage, exc)
611
-
612
- async def _ensure_mesh_identity(self) -> bool:
613
- if not _mesh_proxy_headers():
614
- return bool(self.device_id and self.device_token)
615
- now = time.monotonic()
616
- if self._mesh_bootstrap_ready:
617
- return True
618
- if now < self._next_mesh_bootstrap:
619
- return False
620
- self._next_mesh_bootstrap = now + 5.0
621
- try:
622
- self.device_id, self.device_token = await _bootstrap_mesh_device(
623
- self.scheduler_url, self.robot_id
624
- )
625
- self._mesh_bootstrap_ready = True
626
- return True
627
- except Exception as exc:
628
- logger.warning("mesh device bootstrap failed: %s", exc)
629
- return False
630
-
631
- async def run(self) -> None:
632
- enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
633
- # A UI-minted token identifies a specific pre-created device row.
634
- # Consume it before generic mesh recovery so heartbeats cannot bind to
635
- # a stale same-name identity.
636
- if not self.device_token and enrollment_token:
637
- try:
638
- self.device_id, self.device_token = await _enroll(
639
- self.scheduler_url, enrollment_token, self.robot_id
640
- )
641
- self._mesh_bootstrap_ready = True
642
- except Exception as exc:
643
- if not _mesh_proxy_headers():
644
- raise
645
- # Enrollment tokens are intentionally one-time. A service
646
- # reinstall can retain the original systemd argument after
647
- # its credential file was removed; recover through the hub's
648
- # authenticated mesh path rather than crash-loop forever.
649
- logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
650
- await self._ensure_mesh_identity()
651
- elif _mesh_proxy_headers():
652
- await self._ensure_mesh_identity()
582
+ self._motion_capture: Optional[VideoCapture] = None
583
+ self._motion_capture_lock = asyncio.Lock()
584
+ self._mesh_bootstrap_ready = False
585
+ self._next_mesh_bootstrap = 0.0
586
+ self._reported_pipeline: set[tuple[str, str]] = set()
587
+
588
+ async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
589
+ details: Optional[dict] = None, once: bool = True) -> None:
590
+ """Report a real robot-side transition for the Park test timeline."""
591
+ if not self._session or not self.device_id or not self.device_token:
592
+ return
593
+ session_id = self._vision_session_id or self._current_state.session_id
594
+ key = (session_id or "idle", stage)
595
+ if once and key in self._reported_pipeline:
596
+ return
597
+ try:
598
+ response = await self._session.post(
599
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
600
+ headers={"Authorization": f"Bearer {self.device_token}"},
601
+ json={"stage": stage, "status": status, "message": message,
602
+ "session_id": session_id, "source": "preview_agent",
603
+ "details": details or {}},
604
+ timeout=5.0,
605
+ )
606
+ response.raise_for_status()
607
+ if once:
608
+ self._reported_pipeline.add(key)
609
+ except Exception as exc:
610
+ logger.debug("pipeline event %s was not accepted: %s", stage, exc)
611
+
612
+ async def _ensure_mesh_identity(self) -> bool:
613
+ if not _mesh_proxy_headers():
614
+ return bool(self.device_id and self.device_token)
615
+ now = time.monotonic()
616
+ if self._mesh_bootstrap_ready:
617
+ return True
618
+ if now < self._next_mesh_bootstrap:
619
+ return False
620
+ self._next_mesh_bootstrap = now + 5.0
621
+ try:
622
+ self.device_id, self.device_token = await _bootstrap_mesh_device(
623
+ self.scheduler_url, self.robot_id
624
+ )
625
+ self._mesh_bootstrap_ready = True
626
+ return True
627
+ except Exception as exc:
628
+ logger.warning("mesh device bootstrap failed: %s", exc)
629
+ return False
630
+
631
+ async def run(self) -> None:
632
+ enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
633
+ # A UI-minted token identifies a specific pre-created device row.
634
+ # Consume it before generic mesh recovery so heartbeats cannot bind to
635
+ # a stale same-name identity.
636
+ if not self.device_token and enrollment_token:
637
+ try:
638
+ self.device_id, self.device_token = await _enroll(
639
+ self.scheduler_url, enrollment_token, self.robot_id
640
+ )
641
+ self._mesh_bootstrap_ready = True
642
+ except Exception as exc:
643
+ if not _mesh_proxy_headers():
644
+ raise
645
+ # Enrollment tokens are intentionally one-time. A service
646
+ # reinstall can retain the original systemd argument after
647
+ # its credential file was removed; recover through the hub's
648
+ # authenticated mesh path rather than crash-loop forever.
649
+ logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
650
+ await self._ensure_mesh_identity()
651
+ elif _mesh_proxy_headers():
652
+ await self._ensure_mesh_identity()
653
653
 
654
654
  if not self.device_token:
655
655
  logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
656
656
  sys.exit(1)
657
657
 
658
- self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
658
+ self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
659
659
 
660
660
  # Must come after self._session exists — _resolve_device_id() guards
661
661
  # on it and silently no-ops otherwise. On a fresh enroll (no cached
@@ -699,13 +699,13 @@ class PreviewAgent:
699
699
  if capture is not None:
700
700
  await asyncio.to_thread(capture.stop)
701
701
 
702
- async def _motion_loop(self) -> None:
703
- """Detect motion from RoboVision's shared stream without owning V4L2."""
704
- while not self._shutdown.is_set():
705
- if not self.production_mode:
706
- await self._stop_motion_capture()
707
- await asyncio.sleep(0.5)
708
- continue
702
+ async def _motion_loop(self) -> None:
703
+ """Detect motion from RoboVision's shared stream without owning V4L2."""
704
+ while not self._shutdown.is_set():
705
+ if not self.production_mode:
706
+ await self._stop_motion_capture()
707
+ await asyncio.sleep(0.5)
708
+ continue
709
709
  if self._vision_session_id or self._current_state.active:
710
710
  await self._stop_motion_capture()
711
711
  await asyncio.sleep(0.25)
@@ -714,17 +714,17 @@ class PreviewAgent:
714
714
  async with self._motion_capture_lock:
715
715
  if self._motion_capture is None:
716
716
  self._motion_capture = await asyncio.to_thread(
717
- create_video_capture,
718
- self.video_device,
719
- self.width,
720
- self.height,
721
- self.fps,
722
- self.robovision_url,
723
- )
717
+ create_video_capture,
718
+ self.video_device,
719
+ self.width,
720
+ self.height,
721
+ self.fps,
722
+ self.robovision_url,
723
+ )
724
724
  self._motion_reference = None
725
725
  if self._motion_capture is None:
726
726
  continue
727
- logger.info("motion sampler connected to RoboVision shared stream")
727
+ logger.info("motion sampler connected to RoboVision shared stream")
728
728
  frame = await asyncio.to_thread(self._motion_capture.read)
729
729
  if frame is not None:
730
730
  self._detect_motion(frame)
@@ -816,7 +816,7 @@ class PreviewAgent:
816
816
  if data.get("trigger"):
817
817
  await self._on_vision_motion({"source": data.get("source", "dashboard")})
818
818
 
819
- async def _poll_device_config(self) -> None:
819
+ async def _poll_device_config(self) -> None:
820
820
  """Apply dashboard-selected camera and microphone IDs before preview."""
821
821
  if not self._session or not self.device_id or not self.device_token:
822
822
  return
@@ -830,53 +830,53 @@ class PreviewAgent:
830
830
  headers={"Authorization": f"Bearer {self.device_token}"},
831
831
  timeout=10.0,
832
832
  )
833
- r.raise_for_status()
834
- data = r.json()
835
- changed = False
836
- if data.get("video_device") is not None:
837
- changed = changed or self.video_device != data["video_device"]
838
- self.video_device = data["video_device"]
839
- if data.get("audio_device") is not None:
840
- changed = changed or self.audio_device != data["audio_device"]
841
- self.audio_device = data["audio_device"]
842
- inventory = data.get("device_inventory") or {}
843
- selected = str(self.audio_device)
844
- self.audio_capture_device = _resolve_inventory_audio(
845
- inventory.get("audio_input", []), selected,
846
- os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
847
- )
848
- if data.get("audio_output_device") is not None:
849
- changed = changed or self.audio_output_device != data["audio_output_device"]
850
- self.audio_output_device = data["audio_output_device"]
851
- inventory = data.get("device_inventory") or {}
852
- selected = str(self.audio_output_device)
853
- self.audio_playback_device = _resolve_inventory_audio(
854
- inventory.get("audio_output", []), selected,
855
- os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
856
- )
857
- if changed:
858
- await self._sync_robovision_media_config()
859
- except Exception as e:
860
- logger.debug(f"device config poll failed: {e}")
861
-
862
- async def _sync_robovision_media_config(self) -> None:
863
- """Apply scheduler media choices to the local RoboVisionAI_PI owner."""
864
- try:
865
- response = await self._session.post(
866
- f"{self.robovision_url.rstrip('/')}/api/media/config",
867
- json={
868
- "video_device": self.video_device,
869
- "audio_device": self.audio_device,
870
- "audio_output_device": self.audio_output_device,
871
- },
872
- timeout=1.0,
873
- )
874
- if response.is_success:
875
- global _DEVICE_INVENTORY_CACHE
876
- _DEVICE_INVENTORY_CACHE = None
877
- except Exception:
878
- # RoboVision is optional on laptops and simulation nodes.
879
- pass
833
+ r.raise_for_status()
834
+ data = r.json()
835
+ changed = False
836
+ if data.get("video_device") is not None:
837
+ changed = changed or self.video_device != data["video_device"]
838
+ self.video_device = data["video_device"]
839
+ if data.get("audio_device") is not None:
840
+ changed = changed or self.audio_device != data["audio_device"]
841
+ self.audio_device = data["audio_device"]
842
+ inventory = data.get("device_inventory") or {}
843
+ selected = str(self.audio_device)
844
+ self.audio_capture_device = _resolve_inventory_audio(
845
+ inventory.get("audio_input", []), selected,
846
+ os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
847
+ )
848
+ if data.get("audio_output_device") is not None:
849
+ changed = changed or self.audio_output_device != data["audio_output_device"]
850
+ self.audio_output_device = data["audio_output_device"]
851
+ inventory = data.get("device_inventory") or {}
852
+ selected = str(self.audio_output_device)
853
+ self.audio_playback_device = _resolve_inventory_audio(
854
+ inventory.get("audio_output", []), selected,
855
+ os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
856
+ )
857
+ if changed:
858
+ await self._sync_robovision_media_config()
859
+ except Exception as e:
860
+ logger.debug(f"device config poll failed: {e}")
861
+
862
+ async def _sync_robovision_media_config(self) -> None:
863
+ """Apply scheduler media choices to the local RoboVisionAI_PI owner."""
864
+ try:
865
+ response = await self._session.post(
866
+ f"{self.robovision_url.rstrip('/')}/api/media/config",
867
+ json={
868
+ "video_device": self.video_device,
869
+ "audio_device": self.audio_device,
870
+ "audio_output_device": self.audio_output_device,
871
+ },
872
+ timeout=1.0,
873
+ )
874
+ if response.is_success:
875
+ global _DEVICE_INVENTORY_CACHE
876
+ _DEVICE_INVENTORY_CACHE = None
877
+ except Exception:
878
+ # RoboVision is optional on laptops and simulation nodes.
879
+ pass
880
880
 
881
881
  async def _check_vision_session(self) -> None:
882
882
  """Decide whether the active motion-triggered session should keep
@@ -951,7 +951,7 @@ class PreviewAgent:
951
951
  dt = dt.replace(tzinfo=timezone.utc)
952
952
  return dt.timestamp()
953
953
 
954
- async def _end_vision_session(self) -> None:
954
+ async def _end_vision_session(self) -> None:
955
955
  # Tell the scheduler too, not just the local publisher/LiveKit
956
956
  # connection — otherwise the sessions row never gets ended_at set,
957
957
  # and the server's active_sessions count climbs forever until it
@@ -970,100 +970,100 @@ class PreviewAgent:
970
970
  )
971
971
  except Exception as e:
972
972
  logger.warning(f"failed to notify scheduler of session end: {e}")
973
- await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
974
- self._vision_session_id = None
973
+ await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
974
+ self._vision_session_id = None
975
975
  self._vision_trigger_in_flight = False
976
976
  self._vision_hard_deadline = 0.0
977
977
  self._vision_last_activity = 0.0
978
978
  self._remote_session_ended = False
979
979
  await self._stop_publisher()
980
- await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
980
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
981
981
  self._current_state = PreviewState()
982
982
 
983
- async def _heartbeat_loop(self) -> None:
984
- while not self._shutdown.is_set():
985
- if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
986
- await self._ensure_mesh_identity()
987
- if self.device_id and self.device_token:
988
- production_mode, credentials_ok = await _send_heartbeat(
989
- self.scheduler_url, self.device_id, self.device_token
990
- )
991
- if not credentials_ok:
992
- self._mesh_bootstrap_ready = False
993
- self._next_mesh_bootstrap = 0.0
994
- if production_mode is not None:
995
- self.production_mode = production_mode
996
- # Exactly one motion detector is active. With the default
997
- # local sampler enabled, preview reads RoboVision's shared
998
- # MJPEG stream and RoboVision only owns capture/encoding.
999
- robovision_motion = bool(production_mode and not self.local_camera_motion)
1000
- if self._robovision_motion_state != robovision_motion:
1001
- try:
1002
- response = await self._session.post(
1003
- f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
1004
- json={"enabled": robovision_motion}, timeout=1.5,
1005
- )
1006
- response.raise_for_status()
1007
- self._robovision_motion_state = robovision_motion
1008
- except Exception as exc:
1009
- logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
1010
- await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
1011
- inventory = _get_device_inventory()
1012
- real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
1013
- real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1014
- real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1015
- await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
1016
- await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
1017
- await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
1018
- if not production_mode and self._vision_session_id:
1019
- await self._end_vision_session()
1020
- await self._poll_speaker_test()
983
+ async def _heartbeat_loop(self) -> None:
984
+ while not self._shutdown.is_set():
985
+ if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
986
+ await self._ensure_mesh_identity()
987
+ if self.device_id and self.device_token:
988
+ production_mode, credentials_ok = await _send_heartbeat(
989
+ self.scheduler_url, self.device_id, self.device_token
990
+ )
991
+ if not credentials_ok:
992
+ self._mesh_bootstrap_ready = False
993
+ self._next_mesh_bootstrap = 0.0
994
+ if production_mode is not None:
995
+ self.production_mode = production_mode
996
+ # Exactly one motion detector is active. With the default
997
+ # local sampler enabled, preview reads RoboVision's shared
998
+ # MJPEG stream and RoboVision only owns capture/encoding.
999
+ robovision_motion = bool(production_mode and not self.local_camera_motion)
1000
+ if self._robovision_motion_state != robovision_motion:
1001
+ try:
1002
+ response = await self._session.post(
1003
+ f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
1004
+ json={"enabled": robovision_motion}, timeout=1.5,
1005
+ )
1006
+ response.raise_for_status()
1007
+ self._robovision_motion_state = robovision_motion
1008
+ except Exception as exc:
1009
+ logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
1010
+ await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
1011
+ inventory = _get_device_inventory()
1012
+ real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
1013
+ real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1014
+ real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1015
+ await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
1016
+ await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
1017
+ await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
1018
+ if not production_mode and self._vision_session_id:
1019
+ await self._end_vision_session()
1020
+ await self._poll_speaker_test()
1021
1021
  try:
1022
1022
  await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
1023
- except asyncio.TimeoutError:
1024
- pass
1025
-
1026
- async def _poll_speaker_test(self) -> None:
1027
- """Execute queued speaker tests even when no optional supervisor runs."""
1028
- if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
1029
- return
1030
- headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
1031
- try:
1032
- response = await self._session.get(
1033
- f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
1034
- headers=headers, timeout=5.0,
1035
- )
1036
- response.raise_for_status()
1037
- request = response.json().get("request")
1038
- if not request:
1039
- return
1040
- self._speaker_test_in_flight = True
1041
- from robot_supervisor import _speaker_roundtrip_test
1042
- async with self._speaker_operation_lock:
1043
- # LiveKit owns the USB microphone and speaker continuously.
1044
- # Pause it while holding the same lock as the motion cue so
1045
- # nothing can reopen ALSA before the explicit test starts.
1046
- restore_state = self._current_state if self._publisher else None
1047
- if restore_state:
1048
- await self._stop_publisher()
1049
- await asyncio.sleep(1.0)
1050
- try:
1051
- result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
1052
- result["media_owner_paused"] = bool(restore_state)
1053
- finally:
1054
- if restore_state and restore_state.active:
1055
- await self._start_publisher(restore_state)
1056
- result_response = await self._session.post(
1057
- f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
1058
- json={"kind": "speaker_test", "service": None, "payload": result,
1059
- "request_id": request.get("id")},
1060
- headers=headers, timeout=8.0,
1061
- )
1062
- result_response.raise_for_status()
1063
- except Exception as e:
1064
- logger.warning(f"speaker test execution failed: {e}")
1065
- finally:
1066
- self._speaker_test_in_flight = False
1023
+ except asyncio.TimeoutError:
1024
+ pass
1025
+
1026
+ async def _poll_speaker_test(self) -> None:
1027
+ """Execute queued speaker tests even when no optional supervisor runs."""
1028
+ if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
1029
+ return
1030
+ headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
1031
+ try:
1032
+ response = await self._session.get(
1033
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
1034
+ headers=headers, timeout=5.0,
1035
+ )
1036
+ response.raise_for_status()
1037
+ request = response.json().get("request")
1038
+ if not request:
1039
+ return
1040
+ self._speaker_test_in_flight = True
1041
+ from robot_supervisor import _speaker_roundtrip_test
1042
+ async with self._speaker_operation_lock:
1043
+ # LiveKit owns the USB microphone and speaker continuously.
1044
+ # Pause it while holding the same lock as the motion cue so
1045
+ # nothing can reopen ALSA before the explicit test starts.
1046
+ restore_state = self._current_state if self._publisher else None
1047
+ if restore_state:
1048
+ await self._stop_publisher()
1049
+ await asyncio.sleep(1.0)
1050
+ try:
1051
+ result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
1052
+ result["media_owner_paused"] = bool(restore_state)
1053
+ finally:
1054
+ if restore_state and restore_state.active:
1055
+ await self._start_publisher(restore_state)
1056
+ result_response = await self._session.post(
1057
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
1058
+ json={"kind": "speaker_test", "service": None, "payload": result,
1059
+ "request_id": request.get("id")},
1060
+ headers=headers, timeout=8.0,
1061
+ )
1062
+ result_response.raise_for_status()
1063
+ except Exception as e:
1064
+ logger.warning(f"speaker test execution failed: {e}")
1065
+ finally:
1066
+ self._speaker_test_in_flight = False
1067
1067
 
1068
1068
  async def _fetch_preview_state(self) -> PreviewState:
1069
1069
  if not self._session or not self.device_token:
@@ -1102,25 +1102,25 @@ class PreviewAgent:
1102
1102
  return
1103
1103
  await self._start_publisher(state)
1104
1104
 
1105
- async def _start_publisher(self, state: PreviewState) -> bool:
1106
- await self._stop_publisher()
1107
- if not state.url or not state.token or not state.room:
1108
- return False
1105
+ async def _start_publisher(self, state: PreviewState) -> bool:
1106
+ await self._stop_publisher()
1107
+ if not state.url or not state.token or not state.room:
1108
+ return False
1109
1109
  pub = None
1110
1110
  try:
1111
1111
  pub = LiveKitPublisher(state.url, state.token, state.room, self)
1112
1112
  await pub.start()
1113
- self._publisher = pub
1114
- logger.info(f"joined preview room {state.room}")
1115
- return True
1113
+ self._publisher = pub
1114
+ logger.info(f"joined preview room {state.room}")
1115
+ return True
1116
1116
  except Exception as e:
1117
1117
  logger.error(f"failed to start publisher: {e}")
1118
1118
  if pub is not None:
1119
1119
  try:
1120
1120
  await pub.stop()
1121
- except Exception as cleanup_error:
1122
- logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
1123
- return False
1121
+ except Exception as cleanup_error:
1122
+ logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
1123
+ return False
1124
1124
 
1125
1125
  async def _stop_publisher(self) -> None:
1126
1126
  if self._publisher:
@@ -1198,20 +1198,20 @@ class PreviewAgent:
1198
1198
  if not self.device_id or not self.device_token or not self._session:
1199
1199
  logger.warning("vision motion event received but not enrolled yet — ignoring")
1200
1200
  return
1201
- self._vision_trigger_in_flight = True
1202
- # Observability must not add a scheduler round trip before the actual
1203
- # session request. The reporter handles and logs its own errors.
1204
- asyncio.create_task(self._report_pipeline(
1205
- "motion_detected", "ok",
1206
- f"Motion received from {payload.get('source', 'camera')}", once=False,
1207
- ))
1208
- logger.info("motion detected by RoboVisionAI_PI — requesting a session")
1209
- # RoboVision owns the physical camera and exposes a shared stream, so
1210
- # there is no capture handle to tear down and no reason to sleep here.
1211
- if self.motion_cue_enabled:
1212
- logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
1213
- async with self._speaker_operation_lock:
1214
- await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1201
+ self._vision_trigger_in_flight = True
1202
+ # Observability must not add a scheduler round trip before the actual
1203
+ # session request. The reporter handles and logs its own errors.
1204
+ asyncio.create_task(self._report_pipeline(
1205
+ "motion_detected", "ok",
1206
+ f"Motion received from {payload.get('source', 'camera')}", once=False,
1207
+ ))
1208
+ logger.info("motion detected by RoboVisionAI_PI — requesting a session")
1209
+ # RoboVision owns the physical camera and exposes a shared stream, so
1210
+ # there is no capture handle to tear down and no reason to sleep here.
1211
+ if self.motion_cue_enabled:
1212
+ logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
1213
+ async with self._speaker_operation_lock:
1214
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1215
1215
  try:
1216
1216
  r = await self._session.post(
1217
1217
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -1220,10 +1220,10 @@ class PreviewAgent:
1220
1220
  )
1221
1221
  r.raise_for_status()
1222
1222
  data = r.json()
1223
- except Exception as e:
1224
- logger.error(f"request-session failed: {e}")
1225
- await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
1226
- self._vision_trigger_in_flight = False
1223
+ except Exception as e:
1224
+ logger.error(f"request-session failed: {e}")
1225
+ await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
1226
+ self._vision_trigger_in_flight = False
1227
1227
  return
1228
1228
  state = PreviewState(
1229
1229
  active=True,
@@ -1238,44 +1238,44 @@ class PreviewAgent:
1238
1238
  self._vision_trigger_in_flight = False
1239
1239
  self._vision_hard_deadline = now + self.vision_session_seconds
1240
1240
  self._vision_last_activity = now
1241
- self._remote_session_ended = False
1242
- self._current_state = state
1243
- await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
1241
+ self._remote_session_ended = False
1242
+ self._current_state = state
1243
+ await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
1244
1244
  # If the scheduler returned a voice config, let the agent know by
1245
1245
  # posting it to our local webhook endpoint. The preview agent itself
1246
1246
  # does not consume it, but this makes the config observable locally
1247
1247
  # and lets downstream components (audio server, vision, etc.) adapt.
1248
1248
  if voice_config and session_id:
1249
1249
  logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
1250
- joined = await self._start_publisher(state)
1251
- if not joined:
1252
- await self._report_pipeline(
1253
- "livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
1254
- details={"server_url": state.url}, once=False,
1255
- )
1256
- if session_id and self._session:
1257
- try:
1258
- response = await self._session.post(
1259
- f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
1260
- params={"reason": "livekit_join_failed"},
1261
- headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
1262
- )
1263
- response.raise_for_status()
1264
- except Exception as e:
1265
- logger.debug(f"could not end failed LiveKit session: {e}")
1266
- self._vision_session_id = None
1267
- self._vision_hard_deadline = 0.0
1268
- self._vision_last_activity = 0.0
1269
- self._current_state = PreviewState()
1270
- return
1271
- if session_id and self._session:
1272
- try:
1273
- response = await self._session.post(
1274
- f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
1275
- headers={"Authorization": f"Bearer {self.device_token}"},
1276
- timeout=10.0,
1277
- )
1278
- response.raise_for_status()
1250
+ joined = await self._start_publisher(state)
1251
+ if not joined:
1252
+ await self._report_pipeline(
1253
+ "livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
1254
+ details={"server_url": state.url}, once=False,
1255
+ )
1256
+ if session_id and self._session:
1257
+ try:
1258
+ response = await self._session.post(
1259
+ f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
1260
+ params={"reason": "livekit_join_failed"},
1261
+ headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
1262
+ )
1263
+ response.raise_for_status()
1264
+ except Exception as e:
1265
+ logger.debug(f"could not end failed LiveKit session: {e}")
1266
+ self._vision_session_id = None
1267
+ self._vision_hard_deadline = 0.0
1268
+ self._vision_last_activity = 0.0
1269
+ self._current_state = PreviewState()
1270
+ return
1271
+ if session_id and self._session:
1272
+ try:
1273
+ response = await self._session.post(
1274
+ f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
1275
+ headers={"Authorization": f"Bearer {self.device_token}"},
1276
+ timeout=10.0,
1277
+ )
1278
+ response.raise_for_status()
1279
1279
  except Exception as e:
1280
1280
  logger.debug(f"could not mark session joined: {e}")
1281
1281
 
@@ -1299,49 +1299,49 @@ class LiveKitPublisher:
1299
1299
  self.audio_source: Optional[rtc.AudioSource] = None
1300
1300
  self.audio_track: Optional[rtc.LocalAudioTrack] = None
1301
1301
  self._stop_event = asyncio.Event()
1302
- self._tasks: list[asyncio.Task] = []
1303
- self._capture: Optional["VideoCapture"] = None
1304
- self._mic_capture: Optional["AudioCapture"] = None
1305
- self._mic_streaming = asyncio.Event()
1306
- self._mic_error: Optional[str] = None
1307
- # The launch hardware has no acoustic echo cancellation. Publishing
1308
- # the amplified USB microphone while the robot speaker plays TTS makes
1309
- # the voice worker hear itself and trigger barge-in, truncating or
1310
- # chopping its own response. Keep the track alive with silence while
1311
- # playback is active, plus a short room-echo decay tail.
1312
- self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
1313
- "1", "true", "yes", "on",
1314
- )
1315
- self._speaker_playback_active = threading.Event()
1316
- self._speaker_gate_until = 0.0
1317
- self._speaker_started_at = 0.0
1318
- self._barge_in_until = 0.0
1319
- self._echo_mic_floor = 0.0
1320
- self._barge_in_candidate_frames = 0
1321
- self._speaker_echo_tail = max(
1322
- 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
1323
- )
1324
- self._adaptive_barge_in = str(
1325
- os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
1326
- ).lower() in ("1", "true", "yes", "on")
1327
- self._barge_in_min_peak = max(
1328
- 256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
1329
- )
1330
- self._barge_in_ratio = max(
1331
- 1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
1332
- )
1333
- self._barge_in_hold = max(
1334
- 0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
1335
- )
1302
+ self._tasks: list[asyncio.Task] = []
1303
+ self._capture: Optional["VideoCapture"] = None
1304
+ self._mic_capture: Optional["AudioCapture"] = None
1305
+ self._mic_streaming = asyncio.Event()
1306
+ self._mic_error: Optional[str] = None
1307
+ # The launch hardware has no acoustic echo cancellation. Publishing
1308
+ # the amplified USB microphone while the robot speaker plays TTS makes
1309
+ # the voice worker hear itself and trigger barge-in, truncating or
1310
+ # chopping its own response. Keep the track alive with silence while
1311
+ # playback is active, plus a short room-echo decay tail.
1312
+ self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
1313
+ "1", "true", "yes", "on",
1314
+ )
1315
+ self._speaker_playback_active = threading.Event()
1316
+ self._speaker_gate_until = 0.0
1317
+ self._speaker_started_at = 0.0
1318
+ self._barge_in_until = 0.0
1319
+ self._echo_mic_floor = 0.0
1320
+ self._barge_in_candidate_frames = 0
1321
+ self._speaker_echo_tail = max(
1322
+ 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
1323
+ )
1324
+ self._adaptive_barge_in = str(
1325
+ os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
1326
+ ).lower() in ("1", "true", "yes", "on")
1327
+ self._barge_in_min_peak = max(
1328
+ 256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
1329
+ )
1330
+ self._barge_in_ratio = max(
1331
+ 1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
1332
+ )
1333
+ self._barge_in_hold = max(
1334
+ 0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
1335
+ )
1336
1336
  # Motion sampling state belongs to the publisher instance. Keeping it
1337
1337
  # initialized here prevents shutdown/reopen paths from raising while
1338
1338
  # the camera is being handed between preview and voice sessions.
1339
1339
  self._last_motion_sample = 0.0
1340
1340
 
1341
- async def start(self) -> None:
1342
- self.room = self.rtc.Room()
1343
- await self.room.connect(self.url, self.token)
1344
- await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
1341
+ async def start(self) -> None:
1342
+ self.room = self.rtc.Room()
1343
+ await self.room.connect(self.url, self.token)
1344
+ await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
1345
1345
 
1346
1346
  # Register after signaling. Registering during Room.connect can invoke
1347
1347
  # callbacks while the native LiveKit participant state is incomplete;
@@ -1352,10 +1352,10 @@ class LiveKitPublisher:
1352
1352
  logger.info(f"track_subscribed: kind={track.kind} from={participant.identity} sid={publication.sid}")
1353
1353
  if track.kind != self.rtc.TrackKind.KIND_AUDIO:
1354
1354
  return
1355
- if participant.identity == self.room.local_participant.identity:
1356
- return
1357
- self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
1358
- self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
1355
+ if participant.identity == self.room.local_participant.identity:
1356
+ return
1357
+ self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
1358
+ self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
1359
1359
 
1360
1360
  self.room.on("track_subscribed", _on_track_subscribed)
1361
1361
  logger.info("audio out: track_subscribed listener registered")
@@ -1369,8 +1369,8 @@ class LiveKitPublisher:
1369
1369
  if video_enabled:
1370
1370
  try:
1371
1371
  self._capture = await asyncio.to_thread(
1372
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
1373
- self.agent.robovision_url if self.agent.use_robovision_camera else None,
1372
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
1373
+ self.agent.robovision_url if self.agent.use_robovision_camera else None,
1374
1374
  )
1375
1375
  except Exception as e:
1376
1376
  # Camera availability must not block the microphone/session.
@@ -1407,16 +1407,16 @@ class LiveKitPublisher:
1407
1407
  self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
1408
1408
  vopts = self.rtc.TrackPublishOptions()
1409
1409
  vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
1410
- await self.room.local_participant.publish_track(self.video_track, vopts)
1411
- self.video_source.capture_frame(first_frame)
1412
- await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1410
+ await self.room.local_participant.publish_track(self.video_track, vopts)
1411
+ self.video_source.capture_frame(first_frame)
1412
+ await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1413
1413
 
1414
- if audio_enabled:
1415
- self.audio_source = self.rtc.AudioSource(48000, 1)
1416
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1417
- aopts = self.rtc.TrackPublishOptions()
1418
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1419
- await self.room.local_participant.publish_track(self.audio_track, aopts)
1414
+ if audio_enabled:
1415
+ self.audio_source = self.rtc.AudioSource(48000, 1)
1416
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1417
+ aopts = self.rtc.TrackPublishOptions()
1418
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1419
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
1420
1420
 
1421
1421
  # Register speaker playback (subscribe to the voice agent's TTS audio
1422
1422
  # track) BEFORE opening the camera. Camera open is a slow/occasionally
@@ -1425,130 +1425,130 @@ class LiveKitPublisher:
1425
1425
  # entire event loop — including receiving the agent's greeting audio
1426
1426
  # — until it finished, so a short "Hello friend!" greeting could be
1427
1427
  # over and gone before we ever got a chance to subscribe to it.
1428
- if audio_enabled:
1429
- self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
1430
- try:
1431
- await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1432
- except asyncio.TimeoutError:
1433
- detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
1434
- await self.agent._report_pipeline(
1435
- "microphone_published", "blocked",
1436
- f"Microphone track published but PCM capture did not start: {detail}",
1437
- {"device": self.agent.audio_capture_device, "error": detail},
1438
- )
1439
- logger.warning(
1440
- "microphone PCM is not ready (%s); keeping the session alive while capture retries",
1441
- detail,
1442
- )
1428
+ if audio_enabled:
1429
+ self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
1430
+ try:
1431
+ await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1432
+ except asyncio.TimeoutError:
1433
+ detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
1434
+ await self.agent._report_pipeline(
1435
+ "microphone_published", "blocked",
1436
+ f"Microphone track published but PCM capture did not start: {detail}",
1437
+ {"device": self.agent.audio_capture_device, "error": detail},
1438
+ )
1439
+ logger.warning(
1440
+ "microphone PCM is not ready (%s); keeping the session alive while capture retries",
1441
+ detail,
1442
+ )
1443
1443
 
1444
1444
  if self._capture and self.video_source:
1445
1445
  self._tasks.append(asyncio.create_task(self._video_loop()))
1446
1446
 
1447
- async def _play_remote_audio(self, track, sid: str) -> None:
1448
- OUT_RATE = 48000
1449
- OUT_CHANNELS = 2
1450
- selected_output = str(self.agent.audio_playback_device or "default")
1451
- pa = None
1452
- output_device_index = None
1453
- speaker_guard = None
1454
-
1455
- # RoboVision inventories Linux devices through sounddevice/PortAudio,
1456
- # but the numeric indices are not stable across PyAudio builds. More
1457
- # importantly, BMW's production USB adapter is already proven through
1458
- # ALSA's plughw conversion path. Use that exact endpoint for live TTS
1459
- # instead of reopening the unrelated PyAudio index.
1460
- if sys.platform.startswith("linux") and "hw:" in selected_output:
1461
- import re
1462
- import subprocess
1463
- from media_lock import media_lock
1464
-
1465
- match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1466
- if not match:
1467
- logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1468
- return
1469
- alsa_device = f"plug{match.group(1)}"
1470
- try:
1471
- speaker_guard = media_lock("speaker", timeout=8.0).acquire()
1472
- process = subprocess.Popen(
1473
- [
1474
- "aplay", "-q", "-D", alsa_device, "-t", "raw",
1475
- "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1476
- ],
1477
- stdin=subprocess.PIPE,
1478
- stderr=subprocess.PIPE,
1479
- )
1480
- except Exception as exc:
1481
- if speaker_guard is not None:
1482
- speaker_guard.release()
1483
- logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
1484
- return
1485
- if process.stdin is None:
1486
- logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1487
- process.kill()
1488
- process.wait(timeout=1)
1489
- speaker_guard.release()
1490
- return
1491
-
1492
- class _AplayOutput:
1493
- def write(self, chunk: bytes) -> None:
1494
- if process.poll() is not None:
1495
- detail = ""
1496
- if process.stderr is not None:
1497
- detail = process.stderr.read().decode("utf-8", errors="replace").strip()
1498
- raise OSError(detail or f"aplay exited {process.returncode}")
1499
- process.stdin.write(chunk)
1500
- process.stdin.flush()
1501
-
1502
- def stop_stream(self) -> None:
1503
- if process.stdin and not process.stdin.closed:
1504
- process.stdin.close()
1505
- try:
1506
- process.wait(timeout=2)
1507
- except subprocess.TimeoutExpired:
1508
- process.terminate()
1509
- try:
1510
- process.wait(timeout=1)
1511
- except subprocess.TimeoutExpired:
1512
- process.kill()
1513
- process.wait(timeout=1)
1514
-
1515
- def close(self) -> None:
1516
- return
1517
-
1518
- out = _AplayOutput()
1519
- output_device_index = alsa_device
1520
- else:
1521
- try:
1522
- import pyaudio
1523
- except Exception as e:
1524
- logger.warning(f"pyaudio unavailable for playback: {e}")
1525
- return
1526
- pa = pyaudio.PyAudio()
1527
- # Same MME-vs-WASAPI gotcha as mic capture (see
1528
- # PyAudioCapture._resolve_device): prefer the endpoint backing the
1529
- # Windows volume mixer instead of the silent MME default.
1530
- if selected_output.strip().isdigit():
1531
- output_device_index = int(selected_output.strip())
1532
- try:
1533
- if selected_output.lower() == "default":
1534
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1535
- idx = wasapi.get("defaultOutputDevice")
1536
- if idx is not None and idx >= 0:
1537
- output_device_index = idx
1538
- elif output_device_index is None:
1539
- needle = selected_output.lower()
1540
- for i in range(pa.get_device_count()):
1541
- info = pa.get_device_info_by_index(i)
1542
- if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1543
- output_device_index = i
1544
- break
1545
- except Exception as e:
1546
- logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1547
- out = pa.open(
1548
- format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1549
- output_device_index=output_device_index,
1550
- frames_per_buffer=960,
1551
- )
1447
+ async def _play_remote_audio(self, track, sid: str) -> None:
1448
+ OUT_RATE = 48000
1449
+ OUT_CHANNELS = 2
1450
+ selected_output = str(self.agent.audio_playback_device or "default")
1451
+ pa = None
1452
+ output_device_index = None
1453
+ speaker_guard = None
1454
+
1455
+ # RoboVision inventories Linux devices through sounddevice/PortAudio,
1456
+ # but the numeric indices are not stable across PyAudio builds. More
1457
+ # importantly, BMW's production USB adapter is already proven through
1458
+ # ALSA's plughw conversion path. Use that exact endpoint for live TTS
1459
+ # instead of reopening the unrelated PyAudio index.
1460
+ if sys.platform.startswith("linux") and "hw:" in selected_output:
1461
+ import re
1462
+ import subprocess
1463
+ from media_lock import media_lock
1464
+
1465
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1466
+ if not match:
1467
+ logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1468
+ return
1469
+ alsa_device = f"plug{match.group(1)}"
1470
+ try:
1471
+ speaker_guard = media_lock("speaker", timeout=8.0).acquire()
1472
+ process = subprocess.Popen(
1473
+ [
1474
+ "aplay", "-q", "-D", alsa_device, "-t", "raw",
1475
+ "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1476
+ ],
1477
+ stdin=subprocess.PIPE,
1478
+ stderr=subprocess.PIPE,
1479
+ )
1480
+ except Exception as exc:
1481
+ if speaker_guard is not None:
1482
+ speaker_guard.release()
1483
+ logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
1484
+ return
1485
+ if process.stdin is None:
1486
+ logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1487
+ process.kill()
1488
+ process.wait(timeout=1)
1489
+ speaker_guard.release()
1490
+ return
1491
+
1492
+ class _AplayOutput:
1493
+ def write(self, chunk: bytes) -> None:
1494
+ if process.poll() is not None:
1495
+ detail = ""
1496
+ if process.stderr is not None:
1497
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip()
1498
+ raise OSError(detail or f"aplay exited {process.returncode}")
1499
+ process.stdin.write(chunk)
1500
+ process.stdin.flush()
1501
+
1502
+ def stop_stream(self) -> None:
1503
+ if process.stdin and not process.stdin.closed:
1504
+ process.stdin.close()
1505
+ try:
1506
+ process.wait(timeout=2)
1507
+ except subprocess.TimeoutExpired:
1508
+ process.terminate()
1509
+ try:
1510
+ process.wait(timeout=1)
1511
+ except subprocess.TimeoutExpired:
1512
+ process.kill()
1513
+ process.wait(timeout=1)
1514
+
1515
+ def close(self) -> None:
1516
+ return
1517
+
1518
+ out = _AplayOutput()
1519
+ output_device_index = alsa_device
1520
+ else:
1521
+ try:
1522
+ import pyaudio
1523
+ except Exception as e:
1524
+ logger.warning(f"pyaudio unavailable for playback: {e}")
1525
+ return
1526
+ pa = pyaudio.PyAudio()
1527
+ # Same MME-vs-WASAPI gotcha as mic capture (see
1528
+ # PyAudioCapture._resolve_device): prefer the endpoint backing the
1529
+ # Windows volume mixer instead of the silent MME default.
1530
+ if selected_output.strip().isdigit():
1531
+ output_device_index = int(selected_output.strip())
1532
+ try:
1533
+ if selected_output.lower() == "default":
1534
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1535
+ idx = wasapi.get("defaultOutputDevice")
1536
+ if idx is not None and idx >= 0:
1537
+ output_device_index = idx
1538
+ elif output_device_index is None:
1539
+ needle = selected_output.lower()
1540
+ for i in range(pa.get_device_count()):
1541
+ info = pa.get_device_info_by_index(i)
1542
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1543
+ output_device_index = i
1544
+ break
1545
+ except Exception as e:
1546
+ logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1547
+ out = pa.open(
1548
+ format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1549
+ output_device_index=output_device_index,
1550
+ frames_per_buffer=960,
1551
+ )
1552
1552
  logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
1553
1553
  frame_count = 0
1554
1554
  mismatch_logged = False
@@ -1567,60 +1567,60 @@ class LiveKitPublisher:
1567
1567
  # steady pace, independent of how unevenly frames actually arrive.
1568
1568
  import queue
1569
1569
  import threading
1570
- write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1571
- written_frames = [0]
1572
- PREBUFFER_CHUNKS = 3
1573
- stream_failed = threading.Event()
1574
- playback_reported = threading.Event()
1575
- event_loop = asyncio.get_running_loop()
1576
- echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
1577
-
1578
- def _outbound_peak(chunk: bytes) -> int:
1579
- if len(chunk) < 2:
1580
- return 0
1581
- try:
1582
- samples = memoryview(chunk).cast("h")
1583
- # Stereo duplication means sampling every eighth value is
1584
- # sufficient and keeps the writer thread lightweight.
1585
- return max((abs(int(value)) for value in samples[::8]), default=0)
1586
- except (TypeError, ValueError):
1587
- return 0
1588
-
1589
- def _open_echo_gate() -> None:
1590
- if not self._half_duplex:
1591
- return
1592
- if not self._speaker_playback_active.is_set():
1593
- self._speaker_started_at = time.monotonic()
1594
- self._echo_mic_floor = 0.0
1595
- self._barge_in_candidate_frames = 0
1596
- self._speaker_playback_active.set()
1597
- self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1598
-
1599
- def _extend_echo_gate() -> None:
1600
- if self._half_duplex:
1601
- self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1602
-
1603
- def _safe_write(chunk: bytes) -> bool:
1604
- if stream_failed.is_set():
1605
- return False
1606
- try:
1607
- audible = _outbound_peak(chunk) >= echo_gate_peak
1608
- if audible:
1609
- _open_echo_gate()
1610
- elif time.monotonic() >= self._speaker_gate_until:
1611
- self._speaker_playback_active.clear()
1612
- out.write(chunk)
1613
- if audible:
1614
- _extend_echo_gate()
1615
- if not playback_reported.is_set():
1616
- playback_reported.set()
1617
- asyncio.run_coroutine_threadsafe(
1618
- self.agent._report_pipeline(
1619
- "playback_started", "ok", "First TTS audio chunk written to robot speaker"
1620
- ),
1621
- event_loop,
1622
- )
1623
- return True
1570
+ write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1571
+ written_frames = [0]
1572
+ PREBUFFER_CHUNKS = 3
1573
+ stream_failed = threading.Event()
1574
+ playback_reported = threading.Event()
1575
+ event_loop = asyncio.get_running_loop()
1576
+ echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
1577
+
1578
+ def _outbound_peak(chunk: bytes) -> int:
1579
+ if len(chunk) < 2:
1580
+ return 0
1581
+ try:
1582
+ samples = memoryview(chunk).cast("h")
1583
+ # Stereo duplication means sampling every eighth value is
1584
+ # sufficient and keeps the writer thread lightweight.
1585
+ return max((abs(int(value)) for value in samples[::8]), default=0)
1586
+ except (TypeError, ValueError):
1587
+ return 0
1588
+
1589
+ def _open_echo_gate() -> None:
1590
+ if not self._half_duplex:
1591
+ return
1592
+ if not self._speaker_playback_active.is_set():
1593
+ self._speaker_started_at = time.monotonic()
1594
+ self._echo_mic_floor = 0.0
1595
+ self._barge_in_candidate_frames = 0
1596
+ self._speaker_playback_active.set()
1597
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1598
+
1599
+ def _extend_echo_gate() -> None:
1600
+ if self._half_duplex:
1601
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1602
+
1603
+ def _safe_write(chunk: bytes) -> bool:
1604
+ if stream_failed.is_set():
1605
+ return False
1606
+ try:
1607
+ audible = _outbound_peak(chunk) >= echo_gate_peak
1608
+ if audible:
1609
+ _open_echo_gate()
1610
+ elif time.monotonic() >= self._speaker_gate_until:
1611
+ self._speaker_playback_active.clear()
1612
+ out.write(chunk)
1613
+ if audible:
1614
+ _extend_echo_gate()
1615
+ if not playback_reported.is_set():
1616
+ playback_reported.set()
1617
+ asyncio.run_coroutine_threadsafe(
1618
+ self.agent._report_pipeline(
1619
+ "playback_started", "ok", "First TTS audio chunk written to robot speaker"
1620
+ ),
1621
+ event_loop,
1622
+ )
1623
+ return True
1624
1624
  except Exception as e:
1625
1625
  stream_failed.set()
1626
1626
  logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
@@ -1675,11 +1675,11 @@ class LiveKitPublisher:
1675
1675
  async for frame in stream:
1676
1676
  af = frame.frame if hasattr(frame, "frame") else frame
1677
1677
  frame_count += 1
1678
- if frame_count == 1:
1679
- logger.info(
1680
- f"audio out: first frame received for {sid} "
1681
- f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
1682
- )
1678
+ if frame_count == 1:
1679
+ logger.info(
1680
+ f"audio out: first frame received for {sid} "
1681
+ f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
1682
+ )
1683
1683
  in_rate = af.sample_rate
1684
1684
  in_channels = int(getattr(af, "num_channels", 1) or 1)
1685
1685
  raw = bytes(af.data)
@@ -1720,34 +1720,34 @@ class LiveKitPublisher:
1720
1720
  else:
1721
1721
  data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
1722
1722
  write_queue.put(data)
1723
- except Exception as e:
1724
- logger.warning(f"audio out stream error: {e}")
1725
- finally:
1726
- cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
1727
- if cancelling:
1728
- # On publisher teardown, close ALSA first. Waiting for the
1729
- # writer while aplay still owns the device leaves hw:X,Y busy
1730
- # long enough for the queued dashboard test to fail.
1731
- stream_failed.set()
1732
- out.stop_stream()
1733
- write_queue.put(None)
1734
- writer_thread.join(timeout=2.0)
1723
+ except Exception as e:
1724
+ logger.warning(f"audio out stream error: {e}")
1725
+ finally:
1726
+ cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
1727
+ if cancelling:
1728
+ # On publisher teardown, close ALSA first. Waiting for the
1729
+ # writer while aplay still owns the device leaves hw:X,Y busy
1730
+ # long enough for the queued dashboard test to fail.
1731
+ stream_failed.set()
1732
+ out.stop_stream()
1733
+ write_queue.put(None)
1734
+ writer_thread.join(timeout=2.0)
1735
1735
  logger.info(
1736
1736
  f"audio out: {sid} received {frame_count} frames, "
1737
1737
  f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1738
1738
  )
1739
- if not cancelling:
1740
- out.stop_stream()
1741
- out.close()
1742
- if pa is not None:
1743
- pa.terminate()
1744
- if speaker_guard is not None:
1745
- speaker_guard.release()
1746
- if self._half_duplex:
1747
- self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1748
- self._speaker_playback_active.clear()
1749
-
1750
- async def stop(self) -> None:
1739
+ if not cancelling:
1740
+ out.stop_stream()
1741
+ out.close()
1742
+ if pa is not None:
1743
+ pa.terminate()
1744
+ if speaker_guard is not None:
1745
+ speaker_guard.release()
1746
+ if self._half_duplex:
1747
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1748
+ self._speaker_playback_active.clear()
1749
+
1750
+ async def stop(self) -> None:
1751
1751
  self._stop_event.set()
1752
1752
  for t in self._tasks:
1753
1753
  t.cancel()
@@ -1755,10 +1755,10 @@ class LiveKitPublisher:
1755
1755
  await t
1756
1756
  except asyncio.CancelledError:
1757
1757
  pass
1758
- self._tasks.clear()
1759
- if self._mic_capture:
1760
- await asyncio.to_thread(self._mic_capture.stop)
1761
- self._mic_capture = None
1758
+ self._tasks.clear()
1759
+ if self._mic_capture:
1760
+ await asyncio.to_thread(self._mic_capture.stop)
1761
+ self._mic_capture = None
1762
1762
  if self._capture:
1763
1763
  self._capture.stop()
1764
1764
  self._capture = None
@@ -1817,38 +1817,38 @@ class LiveKitPublisher:
1817
1817
  except Exception as e:
1818
1818
  logger.debug(f"preview motion sampling failed: {e}")
1819
1819
 
1820
- async def _audio_retry_loop(self) -> None:
1821
- """Keep microphone capture alive across transient ALSA ownership errors."""
1822
- while not self._stop_event.is_set():
1823
- try:
1824
- await self._audio_loop()
1825
- except asyncio.CancelledError:
1826
- raise
1827
- except Exception as e:
1828
- self._mic_error = str(e)
1829
- logger.warning(
1830
- f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
1831
- )
1832
- finally:
1833
- mic = self._mic_capture
1834
- self._mic_capture = None
1835
- if mic is not None:
1836
- await asyncio.to_thread(mic.stop)
1837
- if not self._stop_event.is_set():
1838
- try:
1839
- await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
1840
- except asyncio.TimeoutError:
1841
- pass
1842
-
1843
- async def _audio_loop(self) -> None:
1820
+ async def _audio_retry_loop(self) -> None:
1821
+ """Keep microphone capture alive across transient ALSA ownership errors."""
1822
+ while not self._stop_event.is_set():
1823
+ try:
1824
+ await self._audio_loop()
1825
+ except asyncio.CancelledError:
1826
+ raise
1827
+ except Exception as e:
1828
+ self._mic_error = str(e)
1829
+ logger.warning(
1830
+ f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
1831
+ )
1832
+ finally:
1833
+ mic = self._mic_capture
1834
+ self._mic_capture = None
1835
+ if mic is not None:
1836
+ await asyncio.to_thread(mic.stop)
1837
+ if not self._stop_event.is_set():
1838
+ try:
1839
+ await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
1840
+ except asyncio.TimeoutError:
1841
+ pass
1842
+
1843
+ async def _audio_loop(self) -> None:
1844
1844
  assert self.audio_source is not None
1845
- mic = create_audio_capture(self.agent.audio_capture_device)
1846
- if mic is None:
1845
+ mic = create_audio_capture(self.agent.audio_capture_device)
1846
+ if mic is None:
1847
1847
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
1848
- return
1849
- self._mic_capture = mic
1850
- self._mic_error = None
1851
- logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1848
+ return
1849
+ self._mic_capture = mic
1850
+ self._mic_error = None
1851
+ logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1852
1852
  # Read in bigger batches (100ms) instead of one 20ms frame per
1853
1853
  # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
1854
1854
  # overhead (~30ms observed on this machine) that dominates when the
@@ -1863,14 +1863,14 @@ class LiveKitPublisher:
1863
1863
  samples_per_frame = int(48000 * frame_ms / 1000)
1864
1864
  samples_per_batch = int(48000 * BATCH_MS / 1000)
1865
1865
  bytes_per_frame = samples_per_frame * 2 # int16 mono
1866
- mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1866
+ mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1867
1867
  _diag_peak = 0
1868
1868
  _diag_count = 0
1869
1869
  _diag_last_log = time.monotonic()
1870
1870
  _diag_read_ms = 0.0
1871
- _diag_publish_ms = 0.0
1872
- _echo_gate_was_active = False
1873
- batch_interval = BATCH_MS / 1000
1871
+ _diag_publish_ms = 0.0
1872
+ _echo_gate_was_active = False
1873
+ batch_interval = BATCH_MS / 1000
1874
1874
  while not self._stop_event.is_set():
1875
1875
  _iter_start = time.monotonic()
1876
1876
  _t0 = time.monotonic()
@@ -1878,67 +1878,67 @@ class LiveKitPublisher:
1878
1878
  _t1 = time.monotonic()
1879
1879
  if batch is not None:
1880
1880
  data = bytes(batch.data)
1881
- _pub_start = time.monotonic()
1882
- for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1883
- chunk = data[off:off + bytes_per_frame]
1884
- now = time.monotonic()
1885
- echo_gate_active = self._half_duplex and (
1886
- self._speaker_playback_active.is_set()
1887
- or now < self._speaker_gate_until
1888
- )
1889
- # Learn the microphone's speaker-echo floor while output is
1890
- # active. A nearby visitor speaking produces a fast peak well
1891
- # above that floor; reopen the mic briefly so LiveKit VAD can
1892
- # cancel normal TTS. The initial 300 ms remains protected.
1893
- _, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
1894
- if echo_gate_active and self._adaptive_barge_in:
1895
- if self._echo_mic_floor <= 0:
1896
- self._echo_mic_floor = float(raw_peak)
1897
- else:
1898
- self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
1899
- threshold = max(
1900
- self._barge_in_min_peak,
1901
- int(self._echo_mic_floor * self._barge_in_ratio),
1902
- )
1903
- warmed = now - self._speaker_started_at >= 0.3
1904
- if warmed and raw_peak >= threshold:
1905
- self._barge_in_candidate_frames += 1
1906
- else:
1907
- self._barge_in_candidate_frames = 0
1908
- if self._barge_in_candidate_frames >= 3:
1909
- if now >= self._barge_in_until:
1910
- logger.info(
1911
- "audio_loop: adaptive barge-in opened mic "
1912
- "(peak=%d threshold=%d echo_floor=%d)",
1913
- raw_peak, threshold, int(self._echo_mic_floor),
1914
- )
1915
- self._barge_in_until = now + self._barge_in_hold
1916
- barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
1917
- if echo_gate_active and not barge_in_active:
1918
- # Preserve 20 ms frame cadence; only suppress content.
1919
- # Stopping publication would create gaps and destabilize
1920
- # VAD/endpointing when listening resumes.
1921
- chunk = b"\x00" * len(chunk)
1922
- p = 0
1923
- else:
1924
- chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1925
- if echo_gate_active != _echo_gate_was_active:
1926
- logger.info(
1927
- "audio_loop: speaker echo gate %s",
1928
- "active" if echo_gate_active else "released",
1929
- )
1930
- _echo_gate_was_active = echo_gate_active
1931
- frame = self.rtc.AudioFrame(
1881
+ _pub_start = time.monotonic()
1882
+ for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1883
+ chunk = data[off:off + bytes_per_frame]
1884
+ now = time.monotonic()
1885
+ echo_gate_active = self._half_duplex and (
1886
+ self._speaker_playback_active.is_set()
1887
+ or now < self._speaker_gate_until
1888
+ )
1889
+ # Learn the microphone's speaker-echo floor while output is
1890
+ # active. A nearby visitor speaking produces a fast peak well
1891
+ # above that floor; reopen the mic briefly so LiveKit VAD can
1892
+ # cancel normal TTS. The initial 300 ms remains protected.
1893
+ _, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
1894
+ if echo_gate_active and self._adaptive_barge_in:
1895
+ if self._echo_mic_floor <= 0:
1896
+ self._echo_mic_floor = float(raw_peak)
1897
+ else:
1898
+ self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
1899
+ threshold = max(
1900
+ self._barge_in_min_peak,
1901
+ int(self._echo_mic_floor * self._barge_in_ratio),
1902
+ )
1903
+ warmed = now - self._speaker_started_at >= 0.3
1904
+ if warmed and raw_peak >= threshold:
1905
+ self._barge_in_candidate_frames += 1
1906
+ else:
1907
+ self._barge_in_candidate_frames = 0
1908
+ if self._barge_in_candidate_frames >= 3:
1909
+ if now >= self._barge_in_until:
1910
+ logger.info(
1911
+ "audio_loop: adaptive barge-in opened mic "
1912
+ "(peak=%d threshold=%d echo_floor=%d)",
1913
+ raw_peak, threshold, int(self._echo_mic_floor),
1914
+ )
1915
+ self._barge_in_until = now + self._barge_in_hold
1916
+ barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
1917
+ if echo_gate_active and not barge_in_active:
1918
+ # Preserve 20 ms frame cadence; only suppress content.
1919
+ # Stopping publication would create gaps and destabilize
1920
+ # VAD/endpointing when listening resumes.
1921
+ chunk = b"\x00" * len(chunk)
1922
+ p = 0
1923
+ else:
1924
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1925
+ if echo_gate_active != _echo_gate_was_active:
1926
+ logger.info(
1927
+ "audio_loop: speaker echo gate %s",
1928
+ "active" if echo_gate_active else "released",
1929
+ )
1930
+ _echo_gate_was_active = echo_gate_active
1931
+ frame = self.rtc.AudioFrame(
1932
1932
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1933
1933
  )
1934
- await self.audio_source.capture_frame(frame)
1935
- if not self._mic_streaming.is_set():
1936
- self._mic_streaming.set()
1937
- await self.agent._report_pipeline(
1938
- "microphone_published", "ok",
1939
- "Microphone track published with live PCM frames",
1940
- )
1941
- _diag_peak = max(_diag_peak, p)
1934
+ await self.audio_source.capture_frame(frame)
1935
+ if not self._mic_streaming.is_set():
1936
+ self._mic_streaming.set()
1937
+ await self.agent._report_pipeline(
1938
+ "microphone_published", "ok",
1939
+ "Microphone track published with live PCM frames",
1940
+ )
1941
+ _diag_peak = max(_diag_peak, p)
1942
1942
  _diag_count += 1
1943
1943
  _diag_read_ms += (_t1 - _t0) * 1000
1944
1944
  _diag_publish_ms += (time.monotonic() - _pub_start) * 1000
@@ -2070,26 +2070,26 @@ class Picamera2Capture(VideoCapture):
2070
2070
  pass
2071
2071
 
2072
2072
 
2073
- def create_video_capture(device: str, width: int, height: int, fps: int,
2074
- robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
2073
+ def create_video_capture(device: str, width: int, height: int, fps: int,
2074
+ robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
2075
2075
  if device.lower() in ("none", "", "false", "null"):
2076
2076
  return None
2077
- try:
2077
+ try:
2078
2078
  from livekit import rtc
2079
2079
  _ = rtc.VideoSource
2080
2080
  except Exception as e:
2081
2081
  logger.error(f"livekit python sdk not installed: {e}")
2082
- return None
2083
-
2084
- if robovision_url:
2085
- stream_url = f"{robovision_url.rstrip('/')}/video_feed"
2086
- try:
2087
- cap = OpencvVideoCapture(stream_url, width, height, fps)
2088
- logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
2089
- return cap
2090
- except Exception as e:
2091
- logger.error(f"RoboVision camera stream unavailable: {e}")
2092
- return None
2082
+ return None
2083
+
2084
+ if robovision_url:
2085
+ stream_url = f"{robovision_url.rstrip('/')}/video_feed"
2086
+ try:
2087
+ cap = OpencvVideoCapture(stream_url, width, height, fps)
2088
+ logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
2089
+ return cap
2090
+ except Exception as e:
2091
+ logger.error(f"RoboVision camera stream unavailable: {e}")
2092
+ return None
2093
2093
 
2094
2094
  # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
2095
2095
  if device.lower() in ("auto", "default", "first"):
@@ -2120,121 +2120,121 @@ def create_video_capture(device: str, width: int, height: int, fps: int,
2120
2120
  # Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
2121
2121
  # -----------------------------------------------------------------------------
2122
2122
 
2123
- class AudioCapture:
2123
+ class AudioCapture:
2124
2124
  def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
2125
2125
  raise NotImplementedError
2126
2126
 
2127
- def stop(self) -> None:
2128
- raise NotImplementedError
2129
-
2130
-
2131
- class AlsaAudioCapture(AudioCapture):
2132
- """Capture Linux PCM through the same ALSA path used by onsite tests."""
2133
-
2134
- def __init__(self, device: str):
2135
- import os
2136
- import re
2137
- import select
2138
- import subprocess
2139
- from media_lock import media_lock
2140
-
2141
- match = re.search(r"\b(hw:\d+,\d+)\b", device)
2142
- if not match:
2143
- raise ValueError(f"no ALSA hardware address in {device!r}")
2144
- self.buffer = bytearray()
2145
- self.media_guard = media_lock("microphone", timeout=1.5).acquire()
2146
- self.process = None
2147
- self.source_rate = 48000
2148
- errors = []
2149
- # The fleet USB microphone normally accepts 48 kHz through ALSA's
2150
- # plug layer. Some firmware revisions expose only native 44.1 kHz;
2151
- # accept that rate and resample below rather than publishing silence.
2152
- for source_rate in (48000, 44100):
2153
- alsa_device = f"plug{match.group(1)}"
2154
- process = None
2155
- try:
2156
- process = subprocess.Popen(
2157
- [
2158
- "arecord", "-q", "-D", alsa_device, "-t", "raw",
2159
- "-f", "S16_LE", "-r", str(source_rate), "-c", "1",
2160
- "--period-size", str(max(256, source_rate // 50)),
2161
- ],
2162
- stdout=subprocess.PIPE,
2163
- stderr=subprocess.PIPE,
2164
- bufsize=0,
2165
- )
2166
- if process.stdout is None:
2167
- raise RuntimeError("arecord did not provide a PCM stream")
2168
- ready, _, _ = select.select([process.stdout], [], [], 2.0)
2169
- if not ready:
2170
- if process.poll() is None:
2171
- raise TimeoutError("arecord produced no PCM within 2 seconds")
2172
- detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2173
- raise OSError(detail or f"arecord exited {process.returncode}")
2174
- first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
2175
- if not first:
2176
- detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2177
- raise OSError(detail or "arecord returned an empty PCM frame")
2178
- self.process = process
2179
- self.source_rate = source_rate
2180
- self.alsa_device = alsa_device
2181
- if source_rate != 48000:
2182
- first = _pcm16_resample_mono(first, source_rate, 48000)
2183
- self.buffer.extend(first)
2184
- break
2185
- except Exception as exc:
2186
- errors.append(f"{source_rate}Hz: {exc}")
2187
- if process is not None:
2188
- if process.poll() is None:
2189
- process.terminate()
2190
- try:
2191
- process.wait(timeout=1)
2192
- except subprocess.TimeoutExpired:
2193
- process.kill()
2194
- process.wait(timeout=1)
2195
- if self.process is None:
2196
- self.media_guard.release()
2197
- raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
2198
-
2199
- def read(self, samples_per_frame: int):
2200
- import os
2201
- from livekit import rtc
2202
-
2203
- bytes_needed = samples_per_frame * 2
2204
- source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
2205
- while len(self.buffer) < bytes_needed:
2206
- chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
2207
- if not chunk:
2208
- detail = ""
2209
- if self.process.stderr is not None:
2210
- detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
2211
- raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2212
- if self.source_rate != 48000:
2213
- chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
2214
- self.buffer.extend(chunk)
2215
- data = bytes(self.buffer[:bytes_needed])
2216
- del self.buffer[:bytes_needed]
2217
- return rtc.AudioFrame(
2218
- data=data,
2219
- sample_rate=48000,
2220
- num_channels=1,
2221
- samples_per_channel=samples_per_frame,
2222
- )
2223
-
2224
- def stop(self):
2225
- try:
2226
- if self.process.poll() is None:
2227
- self.process.terminate()
2228
- try:
2229
- self.process.wait(timeout=2)
2230
- except Exception:
2231
- self.process.kill()
2232
- self.process.wait(timeout=1)
2233
- finally:
2234
- self.media_guard.release()
2235
-
2236
-
2237
- class PyAudioCapture(AudioCapture):
2127
+ def stop(self) -> None:
2128
+ raise NotImplementedError
2129
+
2130
+
2131
+ class AlsaAudioCapture(AudioCapture):
2132
+ """Capture Linux PCM through the same ALSA path used by onsite tests."""
2133
+
2134
+ def __init__(self, device: str):
2135
+ import os
2136
+ import re
2137
+ import select
2138
+ import subprocess
2139
+ from media_lock import media_lock
2140
+
2141
+ match = re.search(r"\b(hw:\d+,\d+)\b", device)
2142
+ if not match:
2143
+ raise ValueError(f"no ALSA hardware address in {device!r}")
2144
+ self.buffer = bytearray()
2145
+ self.media_guard = media_lock("microphone", timeout=1.5).acquire()
2146
+ self.process = None
2147
+ self.source_rate = 48000
2148
+ errors = []
2149
+ # The fleet USB microphone normally accepts 48 kHz through ALSA's
2150
+ # plug layer. Some firmware revisions expose only native 44.1 kHz;
2151
+ # accept that rate and resample below rather than publishing silence.
2152
+ for source_rate in (48000, 44100):
2153
+ alsa_device = f"plug{match.group(1)}"
2154
+ process = None
2155
+ try:
2156
+ process = subprocess.Popen(
2157
+ [
2158
+ "arecord", "-q", "-D", alsa_device, "-t", "raw",
2159
+ "-f", "S16_LE", "-r", str(source_rate), "-c", "1",
2160
+ "--period-size", str(max(256, source_rate // 50)),
2161
+ ],
2162
+ stdout=subprocess.PIPE,
2163
+ stderr=subprocess.PIPE,
2164
+ bufsize=0,
2165
+ )
2166
+ if process.stdout is None:
2167
+ raise RuntimeError("arecord did not provide a PCM stream")
2168
+ ready, _, _ = select.select([process.stdout], [], [], 2.0)
2169
+ if not ready:
2170
+ if process.poll() is None:
2171
+ raise TimeoutError("arecord produced no PCM within 2 seconds")
2172
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2173
+ raise OSError(detail or f"arecord exited {process.returncode}")
2174
+ first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
2175
+ if not first:
2176
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2177
+ raise OSError(detail or "arecord returned an empty PCM frame")
2178
+ self.process = process
2179
+ self.source_rate = source_rate
2180
+ self.alsa_device = alsa_device
2181
+ if source_rate != 48000:
2182
+ first = _pcm16_resample_mono(first, source_rate, 48000)
2183
+ self.buffer.extend(first)
2184
+ break
2185
+ except Exception as exc:
2186
+ errors.append(f"{source_rate}Hz: {exc}")
2187
+ if process is not None:
2188
+ if process.poll() is None:
2189
+ process.terminate()
2190
+ try:
2191
+ process.wait(timeout=1)
2192
+ except subprocess.TimeoutExpired:
2193
+ process.kill()
2194
+ process.wait(timeout=1)
2195
+ if self.process is None:
2196
+ self.media_guard.release()
2197
+ raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
2198
+
2199
+ def read(self, samples_per_frame: int):
2200
+ import os
2201
+ from livekit import rtc
2202
+
2203
+ bytes_needed = samples_per_frame * 2
2204
+ source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
2205
+ while len(self.buffer) < bytes_needed:
2206
+ chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
2207
+ if not chunk:
2208
+ detail = ""
2209
+ if self.process.stderr is not None:
2210
+ detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
2211
+ raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2212
+ if self.source_rate != 48000:
2213
+ chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
2214
+ self.buffer.extend(chunk)
2215
+ data = bytes(self.buffer[:bytes_needed])
2216
+ del self.buffer[:bytes_needed]
2217
+ return rtc.AudioFrame(
2218
+ data=data,
2219
+ sample_rate=48000,
2220
+ num_channels=1,
2221
+ samples_per_channel=samples_per_frame,
2222
+ )
2223
+
2224
+ def stop(self):
2225
+ try:
2226
+ if self.process.poll() is None:
2227
+ self.process.terminate()
2228
+ try:
2229
+ self.process.wait(timeout=2)
2230
+ except Exception:
2231
+ self.process.kill()
2232
+ self.process.wait(timeout=1)
2233
+ finally:
2234
+ self.media_guard.release()
2235
+
2236
+
2237
+ class PyAudioCapture(AudioCapture):
2238
2238
  def __init__(self, device: str | int | None):
2239
2239
  import pyaudio
2240
2240
  self.pa = pyaudio.PyAudio()
@@ -2313,16 +2313,16 @@ def has_audio() -> bool:
2313
2313
  return False
2314
2314
 
2315
2315
 
2316
- def create_audio_capture(device: str) -> Optional[AudioCapture]:
2317
- if device.lower() in ("none", "", "false", "null"):
2318
- return None
2319
- if sys.platform.startswith("linux") and "hw:" in device:
2320
- try:
2321
- return AlsaAudioCapture(device)
2322
- except Exception as e:
2323
- logger.warning(f"ALSA capture unavailable for {device}: {e}")
2324
- raise
2325
- try:
2316
+ def create_audio_capture(device: str) -> Optional[AudioCapture]:
2317
+ if device.lower() in ("none", "", "false", "null"):
2318
+ return None
2319
+ if sys.platform.startswith("linux") and "hw:" in device:
2320
+ try:
2321
+ return AlsaAudioCapture(device)
2322
+ except Exception as e:
2323
+ logger.warning(f"ALSA capture unavailable for {device}: {e}")
2324
+ raise
2325
+ try:
2326
2326
  import pyaudio # noqa: F401
2327
2327
  return PyAudioCapture(device if device != "default" else None)
2328
2328
  except Exception as e:
@@ -2342,9 +2342,9 @@ def main() -> None:
2342
2342
  parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
2343
2343
  parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
2344
2344
  parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
2345
- parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
2346
- parser.add_argument("--robovision-url", default=None,
2347
- help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
2345
+ parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
2346
+ parser.add_argument("--robovision-url", default=None,
2347
+ help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
2348
2348
  parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
2349
2349
  parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
2350
2350
  parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -2367,11 +2367,11 @@ def main() -> None:
2367
2367
  "scheduler_url": args.scheduler_url,
2368
2368
  "robot_id": args.robot_id,
2369
2369
  "video_device": args.video_device,
2370
- "audio_device": args.audio_device,
2371
- "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
2372
- "use_robovision_camera": bool(args.robovision_url) or os.getenv(
2373
- "ROBOVISION_CAMERA", "true"
2374
- ).lower() in ("1", "true", "yes", "on"),
2370
+ "audio_device": args.audio_device,
2371
+ "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
2372
+ "use_robovision_camera": bool(args.robovision_url) or os.getenv(
2373
+ "ROBOVISION_CAMERA", "true"
2374
+ ).lower() in ("1", "true", "yes", "on"),
2375
2375
  "video_width": args.width,
2376
2376
  "video_height": args.height,
2377
2377
  "video_fps": args.fps,