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.
- package/README.md +66 -105
- package/bin/robopark.js +9 -2
- package/conversation/elevenlabs_agent.py +1617 -1617
- package/conversation/supervisor_store.py +189 -189
- package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/robopark/add-robot.js +1 -0
- package/dist/robopark/add-robot.js.map +1 -0
- package/dist/robopark/agent-ctl.js +1 -0
- package/dist/robopark/agent-ctl.js.map +1 -0
- package/dist/robopark/auto-start.js +51 -73
- package/dist/robopark/auto-start.js.map +1 -0
- package/dist/robopark/conversation.js +1 -0
- package/dist/robopark/conversation.js.map +1 -0
- package/dist/robopark/discovery.js +1 -0
- package/dist/robopark/discovery.js.map +1 -0
- package/dist/robopark/doctor.js +1 -0
- package/dist/robopark/doctor.js.map +1 -0
- package/dist/robopark/enroll.js +1 -0
- package/dist/robopark/enroll.js.map +1 -0
- package/dist/robopark/llm-set.js +1 -0
- package/dist/robopark/llm-set.js.map +1 -0
- package/dist/robopark/motor-control.js +1 -0
- package/dist/robopark/motor-control.js.map +1 -0
- package/dist/robopark/preview-agent-launcher.js +1 -0
- package/dist/robopark/preview-agent-launcher.js.map +1 -0
- package/dist/robopark/probe.js +16 -15
- package/dist/robopark/probe.js.map +1 -0
- package/dist/robopark/profile.js +1 -0
- package/dist/robopark/profile.js.map +1 -0
- package/dist/robopark/python-env.js +1 -7
- package/dist/robopark/python-env.js.map +1 -0
- package/dist/robopark/robot-runtime.js +1 -0
- package/dist/robopark/robot-runtime.js.map +1 -0
- package/dist/robopark/scan.js +1 -0
- package/dist/robopark/scan.js.map +1 -0
- package/dist/robopark/screen-control.js +1 -0
- package/dist/robopark/screen-control.js.map +1 -0
- package/dist/robopark/secrets.js +1 -0
- package/dist/robopark/secrets.js.map +1 -0
- package/dist/robopark/serve.js +6 -8
- package/dist/robopark/serve.js.map +1 -0
- package/dist/robopark/server-add.js +1 -0
- package/dist/robopark/server-add.js.map +1 -0
- package/dist/robopark/setup-livekit.js +53 -52
- package/dist/robopark/setup-livekit.js.map +1 -0
- package/dist/robopark/setup.js +1 -0
- package/dist/robopark/setup.js.map +1 -0
- package/dist/robopark/stop-all.js +1 -0
- package/dist/robopark/stop-all.js.map +1 -0
- package/dist/robopark/verify.js +1 -0
- package/dist/robopark/verify.js.map +1 -0
- package/dist/robopark/vision-agent-launcher.js +1 -0
- package/dist/robopark/vision-agent-launcher.js.map +1 -0
- package/dist/robopark/vision-control.js +1 -0
- package/dist/robopark/vision-control.js.map +1 -0
- package/dist/robopark-cli.js +7 -125
- package/dist/robopark-cli.js.map +1 -0
- package/package.json +16 -56
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +76 -76
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +24 -24
- package/pi-client/motor_bridge.py +10 -10
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/main.py +196 -17
- package/scheduler/media_lock.py +57 -57
- package/scheduler/preview_agent.py +1122 -1122
- package/scheduler/production_config.json +73 -113
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
- package/{ui/standalone/public → scheduler/static}/voice-join.html +101 -13
- package/screen/screen_runtime.py +75 -75
- package/vision/app_pi_clean.py +297 -297
- package/vision/audio_server_pi.py +24 -24
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +275 -275
- package/vision/requirements_camera.txt +6 -6
- package/vision/requirements_motor.txt +4 -4
- package/vision/requirements_pi_unified.txt +2 -2
- package/vision/requirements_vision_agent.txt +19 -19
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/dist/kernel/types.js +0 -7
- package/dist/robopark/access.js +0 -99
- package/dist/robopark/control-center-html.js +0 -9191
- package/dist/robopark/control-server.js +0 -148
- package/dist/robopark/deployment-commands.js +0 -47
- package/dist/robopark/frontend-service.js +0 -80
- package/dist/robopark/standalone.js +0 -584
- package/dist/robopark/voice-join-html.js +0 -57
- package/scripts/windows-shim-fix.cjs +0 -58
- package/static/robopark/barracuda.png +0 -0
- package/static/robopark/bmw.png +0 -0
- package/static/robopark/jaguar.png +0 -0
- package/static/robopark/magnus.jpg +0 -0
- package/static/robopark/panda.png +0 -0
- package/static/robopark/tesla.png +0 -0
- package/static/robopark/titan.jpg +0 -0
- package/static/robopark/volt.png +0 -0
- package/static/vendor/elevenlabs-client.LICENSE +0 -21
- package/static/vendor/elevenlabs-client.iife.js +0 -24200
- package/ui/ROBOVOICE_BUILD.json +0 -9
- package/ui/standalone/.next/BUILD_ID +0 -1
- package/ui/standalone/.next/app-build-manifest.json +0 -391
- package/ui/standalone/.next/app-path-routes-manifest.json +0 -55
- package/ui/standalone/.next/build-manifest.json +0 -33
- package/ui/standalone/.next/package.json +0 -1
- package/ui/standalone/.next/prerender-manifest.json +0 -41
- package/ui/standalone/.next/react-loadable-manifest.json +0 -14
- package/ui/standalone/.next/required-server-files.json +0 -362
- package/ui/standalone/.next/routes-manifest.json +0 -246
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js +0 -1
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/opengraph-image-xg4ifa/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/(app)/page.js +0 -2
- package/ui/standalone/.next/server/app/(app)/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/(app)/voice/page.js +0 -12
- package/ui/standalone/.next/server/app/(app)/voice/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/(app)/voice/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/_not-found/page.js +0 -12
- package/ui/standalone/.next/server/app/_not-found/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route.js +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/connection-details/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route.js +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/device-character/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route.js +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/download-piper-model/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/model-status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/models/route.js +0 -1
- package/ui/standalone/.next/server/app/api/models/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/models/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route.js +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/prewarm/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route.js +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/prompt/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route.js +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/reload-tools/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/camera-token/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/character-ai/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/device-access/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/devices/[deviceId]/control/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/live-rooms/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/monitor-token/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/operations/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/[robotId]/voice-engine/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/robots/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/metrics/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/[serverId]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/servers/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/[sessionId]/end/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/sessions/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/[sessionId]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robopark/transcripts/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/robovoice/robots/[robotId]/media-health/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/settings/route.js +0 -1
- package/ui/standalone/.next/server/app/api/settings/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/settings/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/complete/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-groq/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-hass/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-n8n/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/setup/test-ollama/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route.js +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/test-tts/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route.js +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/turn-credentials/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/voices/route.js +0 -1
- package/ui/standalone/.next/server/app/api/voices/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/voices/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/models/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/status/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route.js +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/api/wake-word/upload/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/favicon.ico/route.js +0 -1
- package/ui/standalone/.next/server/app/favicon.ico/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/favicon.ico.body +0 -0
- package/ui/standalone/.next/server/app/favicon.ico.meta +0 -1
- package/ui/standalone/.next/server/app/fed/[...path]/route.js +0 -4
- package/ui/standalone/.next/server/app/fed/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/fed/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/fed/media/robots/[robotId]/video_feed/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/api/[...path]/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/api/robots/[robotId]/stream/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/robopark/page.js +0 -2
- package/ui/standalone/.next/server/app/robopark/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/robopark/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/tailnet/route.js +0 -1
- package/ui/standalone/.next/server/app/tailnet/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/tailnet/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/vendor/livekit-client.umd.js/route_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app/voice-tester/page.js +0 -2
- package/ui/standalone/.next/server/app/voice-tester/page.js.nft.json +0 -1
- package/ui/standalone/.next/server/app/voice-tester/page_client-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/app-paths-manifest.json +0 -55
- package/ui/standalone/.next/server/chunks/1428.js +0 -1
- package/ui/standalone/.next/server/chunks/1692.js +0 -1
- package/ui/standalone/.next/server/chunks/37.js +0 -1
- package/ui/standalone/.next/server/chunks/4876.js +0 -1
- package/ui/standalone/.next/server/chunks/5232.js +0 -9
- package/ui/standalone/.next/server/chunks/5455.js +0 -11
- package/ui/standalone/.next/server/chunks/5611.js +0 -6
- package/ui/standalone/.next/server/chunks/5873.js +0 -22
- package/ui/standalone/.next/server/chunks/6018.js +0 -1
- package/ui/standalone/.next/server/chunks/8755.js +0 -1
- package/ui/standalone/.next/server/chunks/9077.js +0 -11
- package/ui/standalone/.next/server/functions-config-manifest.json +0 -9
- package/ui/standalone/.next/server/middleware-build-manifest.js +0 -1
- package/ui/standalone/.next/server/middleware-manifest.json +0 -6
- package/ui/standalone/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/ui/standalone/.next/server/next-font-manifest.js +0 -1
- package/ui/standalone/.next/server/next-font-manifest.json +0 -1
- package/ui/standalone/.next/server/pages/500.html +0 -1
- package/ui/standalone/.next/server/pages/_app.js +0 -1
- package/ui/standalone/.next/server/pages/_app.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages/_document.js +0 -1
- package/ui/standalone/.next/server/pages/_document.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages/_error.js +0 -19
- package/ui/standalone/.next/server/pages/_error.js.nft.json +0 -1
- package/ui/standalone/.next/server/pages-manifest.json +0 -5
- package/ui/standalone/.next/server/server-reference-manifest.js +0 -1
- package/ui/standalone/.next/server/server-reference-manifest.json +0 -1
- package/ui/standalone/.next/server/webpack-runtime.js +0 -1
- package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_buildManifest.js +0 -1
- package/ui/standalone/.next/static/T3eGaQUuZNb6rhEStcouc/_ssgManifest.js +0 -1
- package/ui/standalone/.next/static/chunks/1255-404bb8de49c90395.js +0 -1
- package/ui/standalone/.next/static/chunks/144d3bae-bd5d150c0cab972e.js +0 -1
- package/ui/standalone/.next/static/chunks/1646.9123ee47220ed70b.js +0 -1
- package/ui/standalone/.next/static/chunks/2333-43c39acb531f7d4e.js +0 -3
- package/ui/standalone/.next/static/chunks/4bd1b696-f785427dddbba9fb.js +0 -1
- package/ui/standalone/.next/static/chunks/5139.c5e46d26064a85db.js +0 -1
- package/ui/standalone/.next/static/chunks/76e8f37e-2ae0e3fe7b44d874.js +0 -1
- package/ui/standalone/.next/static/chunks/9880-53262785284464cc.js +0 -1
- package/ui/standalone/.next/static/chunks/adfb71a7-625c1b5ebd039b6a.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/layout-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/opengraph-image-xg4ifa/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/page-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/(app)/voice/page-72d72d9a70d9c1d4.js +0 -1
- package/ui/standalone/.next/static/chunks/app/_not-found/page-cfd7daa82d71540c.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/connection-details/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/device-character/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/download-piper-model/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/model-status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/models/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/prewarm/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/prompt/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/reload-tools/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/camera-token/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/character-ai/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/device-access/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/devices/[deviceId]/control/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/live-rooms/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/monitor-token/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/operations/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/[robotId]/voice-engine/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/robots/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/metrics/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/[serverId]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/servers/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/[sessionId]/end/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/sessions/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/[sessionId]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robopark/transcripts/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/robovoice/robots/[robotId]/media-health/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/settings/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/complete/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-groq/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-hass/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-n8n/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/setup/test-ollama/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/test-tts/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/turn-credentials/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/voices/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/models/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/status/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/api/wake-word/upload/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/fed/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/fed/media/robots/[robotId]/video_feed/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/layout-fcce963da1d8220f.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/api/[...path]/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/api/robots/[robotId]/stream/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/robopark/page-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/tailnet/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/vendor/livekit-client.umd.js/route-5f312b128a0caf75.js +0 -1
- package/ui/standalone/.next/static/chunks/app/voice-tester/page-ae699e048e0eb834.js +0 -1
- package/ui/standalone/.next/static/chunks/framework-e60c938074ff7136.js +0 -1
- package/ui/standalone/.next/static/chunks/main-9e24e8708ff075fc.js +0 -1
- package/ui/standalone/.next/static/chunks/main-app-a1f59168303c7f84.js +0 -1
- package/ui/standalone/.next/static/chunks/pages/_app-131c90850aef965b.js +0 -1
- package/ui/standalone/.next/static/chunks/pages/_error-e4ba546eb376bdf4.js +0 -1
- package/ui/standalone/.next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
- package/ui/standalone/.next/static/chunks/webpack-5252e5254da079b5.js +0 -1
- package/ui/standalone/.next/static/css/3f478a573f9be82e.css +0 -1
- package/ui/standalone/.next/static/css/51560c4af1d5a21f.css +0 -1
- package/ui/standalone/.next/static/media/27fe93087559d480-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/4d3869dd8a56345a-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/63f3bb9ce29e22d5-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/79e1c5d0f2fe9f68-s.p.otf +0 -0
- package/ui/standalone/.next/static/media/8bf7b2ceda89477b-s.p.woff2 +0 -0
- package/ui/standalone/.next/static/media/98baa4a7809fbf72-s.woff2 +0 -0
- package/ui/standalone/.next/static/media/f7650dfaa438c37c-s.woff2 +0 -0
- package/ui/standalone/package.json +0 -57
- package/ui/standalone/public/caal-logo.svg +0 -3
- package/ui/standalone/public/commit-mono-400-regular.woff +0 -0
- package/ui/standalone/public/cwl-logo-round.png +0 -0
- package/ui/standalone/public/everett-light.woff +0 -0
- package/ui/standalone/public/hey_cal.ppn +0 -0
- package/ui/standalone/public/install-robot-kiosk.sh +0 -242
- package/ui/standalone/public/lk-logo-dark.svg +0 -18
- package/ui/standalone/public/lk-logo.svg +0 -18
- package/ui/standalone/public/lk-wordmark.svg +0 -12
- package/ui/standalone/public/opengraph-image-bg.png +0 -0
- package/ui/standalone/public/porcupine_params.pv +0 -0
- package/ui/standalone/public/robopark-control.html +0 -9200
- package/ui/standalone/public/static/robopark/barracuda.png +0 -0
- package/ui/standalone/public/static/robopark/bmw.png +0 -0
- package/ui/standalone/public/static/robopark/jaguar.png +0 -0
- package/ui/standalone/public/static/robopark/magnus.jpg +0 -0
- package/ui/standalone/public/static/robopark/panda.png +0 -0
- package/ui/standalone/public/static/robopark/tesla.png +0 -0
- package/ui/standalone/public/static/robopark/titan.jpg +0 -0
- package/ui/standalone/public/static/robopark/volt.png +0 -0
- package/ui/standalone/public/tailnet/index.html +0 -8995
- package/ui/standalone/public/transcript-analytics.html +0 -72
- package/ui/standalone/public/vendor/livekit-client.umd.js +0 -2
- package/ui/standalone/server.js +0 -38
- /package/{ui/standalone/public → scheduler/static}/vendor/elevenlabs-client.iife.js +0 -0
|
@@ -3,42 +3,42 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
-
import argparse
|
|
7
|
-
import audioop
|
|
8
|
-
import hmac
|
|
9
|
-
import json
|
|
10
|
-
import math
|
|
11
|
-
import os
|
|
12
|
-
import queue
|
|
13
|
-
import re
|
|
14
|
-
import signal
|
|
15
|
-
import shutil
|
|
16
|
-
import subprocess
|
|
17
|
-
import threading
|
|
6
|
+
import argparse
|
|
7
|
+
import audioop
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import queue
|
|
13
|
+
import re
|
|
14
|
+
import signal
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
18
|
import time
|
|
19
|
-
import urllib.parse
|
|
20
|
-
import urllib.request
|
|
21
|
-
import uuid
|
|
22
|
-
from collections import deque
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from collections import deque
|
|
23
23
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import Callable
|
|
26
26
|
|
|
27
27
|
import pyaudio
|
|
28
28
|
from elevenlabs.client import ElevenLabs
|
|
29
|
-
from elevenlabs.conversational_ai.conversation import AudioInterface, ClientTools, Conversation
|
|
29
|
+
from elevenlabs.conversational_ai.conversation import AudioInterface, ClientTools, Conversation
|
|
30
|
+
|
|
31
|
+
from supervisor_store import SupervisorStore
|
|
30
32
|
|
|
31
|
-
from supervisor_store import SupervisorStore
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
PENDING_MOTION_TTL_SECONDS = 15.0
|
|
34
|
+
SDK_RATE = 16000
|
|
35
|
+
SAMPLE_WIDTH = 2
|
|
36
|
+
TRIGGER_CHUNK = 2048
|
|
37
|
+
OUTPUT_QUEUE_CHUNKS = 256
|
|
38
|
+
SESSION_STOP_GRACE_SECONDS = 3.0
|
|
39
|
+
SESSION_FORCE_CLOSE_SECONDS = 2.0
|
|
40
|
+
SPEAKER_ECHO_TAIL_SECONDS = 0.7
|
|
41
|
+
PENDING_MOTION_TTL_SECONDS = 15.0
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def log(message: str) -> None:
|
|
@@ -96,95 +96,95 @@ def find_device(pa: pyaudio.PyAudio, selector: str, direction: str) -> tuple[int
|
|
|
96
96
|
return matches[0]
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
def candidate_rates(info: dict) -> list[int]:
|
|
99
|
+
def candidate_rates(info: dict) -> list[int]:
|
|
100
100
|
values = [int(float(info.get("defaultSampleRate", 0))), 48000, 44100, 32000, 16000]
|
|
101
|
-
return list(dict.fromkeys(rate for rate in values if rate > 0))
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def stable_audio_label(selector: str) -> str:
|
|
105
|
-
"""Remove the boot-order ALSA suffix while preserving product identity."""
|
|
106
|
-
return re.sub(r"\s*\((?:plug)?hw:\d+,\d+\)\s*$", "", str(selector), flags=re.I).strip()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def resolve_alsa_capture(selector: str) -> str | None:
|
|
110
|
-
"""Resolve a logical USB microphone label to its current ALSA card."""
|
|
111
|
-
if os.name != "posix" or not shutil.which("arecord"):
|
|
112
|
-
return None
|
|
113
|
-
label = stable_audio_label(selector)
|
|
114
|
-
parts = [part.strip() for part in label.split(":") if part.strip() not in {"", "-"}]
|
|
115
|
-
try:
|
|
116
|
-
output = subprocess.run(
|
|
117
|
-
["arecord", "-l"], capture_output=True, text=True, timeout=3, check=False
|
|
118
|
-
).stdout
|
|
119
|
-
except Exception:
|
|
120
|
-
return None
|
|
121
|
-
candidates = []
|
|
122
|
-
for line in output.splitlines():
|
|
123
|
-
match = re.match(r"^card\s+(\d+):.*device\s+(\d+):", line.strip(), re.I)
|
|
124
|
-
if not match:
|
|
125
|
-
continue
|
|
126
|
-
folded = line.casefold()
|
|
127
|
-
if parts and not all(part.casefold() in folded for part in parts):
|
|
128
|
-
continue
|
|
129
|
-
# Exact case is a useful discriminator for the fleet's two generic
|
|
130
|
-
# USB products ("Usb Audio Device" mic vs "USB Audio Device" output).
|
|
131
|
-
case_matches = sum(part in line for part in parts)
|
|
132
|
-
candidates.append((case_matches, f"plughw:{match.group(1)},{match.group(2)}"))
|
|
133
|
-
if not candidates:
|
|
134
|
-
return None
|
|
135
|
-
candidates.sort(reverse=True)
|
|
136
|
-
return candidates[0][1]
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def audio_inventory() -> dict:
|
|
140
|
-
pa = pyaudio.PyAudio()
|
|
141
|
-
try:
|
|
142
|
-
inputs = []
|
|
143
|
-
outputs = []
|
|
144
|
-
for index in range(pa.get_device_count()):
|
|
145
|
-
info = pa.get_device_info_by_index(index)
|
|
146
|
-
row = {
|
|
147
|
-
"index": index,
|
|
148
|
-
"name": str(info.get("name", "unknown")),
|
|
149
|
-
"rate": int(float(info.get("defaultSampleRate", 0))),
|
|
150
|
-
}
|
|
151
|
-
if int(info.get("maxInputChannels", 0)) > 0:
|
|
152
|
-
inputs.append({**row, "channels": int(info["maxInputChannels"])})
|
|
153
|
-
if int(info.get("maxOutputChannels", 0)) > 0:
|
|
154
|
-
outputs.append({**row, "channels": int(info["maxOutputChannels"])})
|
|
155
|
-
return {"inputs": inputs, "outputs": outputs}
|
|
156
|
-
finally:
|
|
157
|
-
pa.terminate()
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def media_lease_status() -> dict:
|
|
161
|
-
"""Report ALSA owners without opening or terminating any media device."""
|
|
162
|
-
owners: dict[int, dict] = {}
|
|
163
|
-
if os.name == "posix" and Path("/proc").exists():
|
|
164
|
-
for process_dir in Path("/proc").glob("[0-9]*"):
|
|
165
|
-
try:
|
|
166
|
-
pid = int(process_dir.name)
|
|
167
|
-
devices = sorted({
|
|
168
|
-
os.readlink(fd)
|
|
169
|
-
for fd in (process_dir / "fd").iterdir()
|
|
170
|
-
if os.readlink(fd).startswith("/dev/snd/")
|
|
171
|
-
})
|
|
172
|
-
if not devices:
|
|
173
|
-
continue
|
|
174
|
-
command = (process_dir / "cmdline").read_bytes().replace(b"\0", b" ").decode(
|
|
175
|
-
"utf-8", errors="replace"
|
|
176
|
-
).strip()
|
|
177
|
-
owners[pid] = {"pid": pid, "command": command, "devices": devices}
|
|
178
|
-
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError, ValueError):
|
|
179
|
-
continue
|
|
180
|
-
rows = list(owners.values())
|
|
181
|
-
foreign = [row for row in rows if row["pid"] != os.getpid()]
|
|
182
|
-
return {
|
|
183
|
-
"authoritative_pid": os.getpid(),
|
|
184
|
-
"available": not foreign,
|
|
185
|
-
"owners": rows,
|
|
186
|
-
"conflicts": foreign,
|
|
187
|
-
}
|
|
101
|
+
return list(dict.fromkeys(rate for rate in values if rate > 0))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stable_audio_label(selector: str) -> str:
|
|
105
|
+
"""Remove the boot-order ALSA suffix while preserving product identity."""
|
|
106
|
+
return re.sub(r"\s*\((?:plug)?hw:\d+,\d+\)\s*$", "", str(selector), flags=re.I).strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_alsa_capture(selector: str) -> str | None:
|
|
110
|
+
"""Resolve a logical USB microphone label to its current ALSA card."""
|
|
111
|
+
if os.name != "posix" or not shutil.which("arecord"):
|
|
112
|
+
return None
|
|
113
|
+
label = stable_audio_label(selector)
|
|
114
|
+
parts = [part.strip() for part in label.split(":") if part.strip() not in {"", "-"}]
|
|
115
|
+
try:
|
|
116
|
+
output = subprocess.run(
|
|
117
|
+
["arecord", "-l"], capture_output=True, text=True, timeout=3, check=False
|
|
118
|
+
).stdout
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
candidates = []
|
|
122
|
+
for line in output.splitlines():
|
|
123
|
+
match = re.match(r"^card\s+(\d+):.*device\s+(\d+):", line.strip(), re.I)
|
|
124
|
+
if not match:
|
|
125
|
+
continue
|
|
126
|
+
folded = line.casefold()
|
|
127
|
+
if parts and not all(part.casefold() in folded for part in parts):
|
|
128
|
+
continue
|
|
129
|
+
# Exact case is a useful discriminator for the fleet's two generic
|
|
130
|
+
# USB products ("Usb Audio Device" mic vs "USB Audio Device" output).
|
|
131
|
+
case_matches = sum(part in line for part in parts)
|
|
132
|
+
candidates.append((case_matches, f"plughw:{match.group(1)},{match.group(2)}"))
|
|
133
|
+
if not candidates:
|
|
134
|
+
return None
|
|
135
|
+
candidates.sort(reverse=True)
|
|
136
|
+
return candidates[0][1]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def audio_inventory() -> dict:
|
|
140
|
+
pa = pyaudio.PyAudio()
|
|
141
|
+
try:
|
|
142
|
+
inputs = []
|
|
143
|
+
outputs = []
|
|
144
|
+
for index in range(pa.get_device_count()):
|
|
145
|
+
info = pa.get_device_info_by_index(index)
|
|
146
|
+
row = {
|
|
147
|
+
"index": index,
|
|
148
|
+
"name": str(info.get("name", "unknown")),
|
|
149
|
+
"rate": int(float(info.get("defaultSampleRate", 0))),
|
|
150
|
+
}
|
|
151
|
+
if int(info.get("maxInputChannels", 0)) > 0:
|
|
152
|
+
inputs.append({**row, "channels": int(info["maxInputChannels"])})
|
|
153
|
+
if int(info.get("maxOutputChannels", 0)) > 0:
|
|
154
|
+
outputs.append({**row, "channels": int(info["maxOutputChannels"])})
|
|
155
|
+
return {"inputs": inputs, "outputs": outputs}
|
|
156
|
+
finally:
|
|
157
|
+
pa.terminate()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def media_lease_status() -> dict:
|
|
161
|
+
"""Report ALSA owners without opening or terminating any media device."""
|
|
162
|
+
owners: dict[int, dict] = {}
|
|
163
|
+
if os.name == "posix" and Path("/proc").exists():
|
|
164
|
+
for process_dir in Path("/proc").glob("[0-9]*"):
|
|
165
|
+
try:
|
|
166
|
+
pid = int(process_dir.name)
|
|
167
|
+
devices = sorted({
|
|
168
|
+
os.readlink(fd)
|
|
169
|
+
for fd in (process_dir / "fd").iterdir()
|
|
170
|
+
if os.readlink(fd).startswith("/dev/snd/")
|
|
171
|
+
})
|
|
172
|
+
if not devices:
|
|
173
|
+
continue
|
|
174
|
+
command = (process_dir / "cmdline").read_bytes().replace(b"\0", b" ").decode(
|
|
175
|
+
"utf-8", errors="replace"
|
|
176
|
+
).strip()
|
|
177
|
+
owners[pid] = {"pid": pid, "command": command, "devices": devices}
|
|
178
|
+
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
rows = list(owners.values())
|
|
181
|
+
foreign = [row for row in rows if row["pid"] != os.getpid()]
|
|
182
|
+
return {
|
|
183
|
+
"authoritative_pid": os.getpid(),
|
|
184
|
+
"available": not foreign,
|
|
185
|
+
"owners": rows,
|
|
186
|
+
"conflicts": foreign,
|
|
187
|
+
}
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
class NativeAudioInterface(AudioInterface):
|
|
@@ -193,99 +193,99 @@ class NativeAudioInterface(AudioInterface):
|
|
|
193
193
|
def __init__(self, input_selector: str, output_selector: str) -> None:
|
|
194
194
|
self.input_selector = input_selector
|
|
195
195
|
self.output_selector = output_selector
|
|
196
|
-
self.pa: pyaudio.PyAudio | None = None
|
|
197
|
-
self.in_stream = None
|
|
198
|
-
self.input_process: subprocess.Popen | None = None
|
|
199
|
-
self.input_backend = "pyaudio"
|
|
200
|
-
self.input_name = input_selector
|
|
201
|
-
self.output_name = output_selector
|
|
202
|
-
self.out_stream = None
|
|
196
|
+
self.pa: pyaudio.PyAudio | None = None
|
|
197
|
+
self.in_stream = None
|
|
198
|
+
self.input_process: subprocess.Popen | None = None
|
|
199
|
+
self.input_backend = "pyaudio"
|
|
200
|
+
self.input_name = input_selector
|
|
201
|
+
self.output_name = output_selector
|
|
202
|
+
self.out_stream = None
|
|
203
203
|
self.input_callback: Callable[[bytes], None] | None = None
|
|
204
|
-
self.output_queue: queue.Queue[bytes] = queue.Queue(maxsize=OUTPUT_QUEUE_CHUNKS)
|
|
205
|
-
self.stop_event = threading.Event()
|
|
206
|
-
self.input_thread: threading.Thread | None = None
|
|
207
|
-
self.output_thread: threading.Thread | None = None
|
|
208
|
-
self.input_frames_per_buffer = 320
|
|
209
|
-
self.input_rate = SDK_RATE
|
|
204
|
+
self.output_queue: queue.Queue[bytes] = queue.Queue(maxsize=OUTPUT_QUEUE_CHUNKS)
|
|
205
|
+
self.stop_event = threading.Event()
|
|
206
|
+
self.input_thread: threading.Thread | None = None
|
|
207
|
+
self.output_thread: threading.Thread | None = None
|
|
208
|
+
self.input_frames_per_buffer = 320
|
|
209
|
+
self.input_rate = SDK_RATE
|
|
210
210
|
self.output_rate = SDK_RATE
|
|
211
211
|
self.output_channels = 1
|
|
212
212
|
self.input_rate_state = None
|
|
213
213
|
self.output_rate_state = None
|
|
214
|
-
self.lock = threading.Lock()
|
|
215
|
-
self.started = threading.Event()
|
|
216
|
-
self.output_chunks = 0
|
|
217
|
-
self.output_bytes = 0
|
|
218
|
-
self.input_chunks = 0
|
|
219
|
-
self.forwarded_input_chunks = 0
|
|
220
|
-
self.input_peak = 0
|
|
221
|
-
self.suppressed_input_chunks = 0
|
|
222
|
-
self.input_read_errors = 0
|
|
223
|
-
self.last_input_error: str | None = None
|
|
224
|
-
self.output_write_errors = 0
|
|
225
|
-
self.last_output_error: str | None = None
|
|
226
|
-
self.fatal_error = threading.Event()
|
|
227
|
-
self.fatal_error_message: str | None = None
|
|
228
|
-
self.suppress_input_until = 0.0
|
|
229
|
-
self.echo_lock = threading.Lock()
|
|
230
|
-
self.echo_gate_announced = False
|
|
231
|
-
|
|
232
|
-
def _suppress_input_for(self, seconds: float) -> None:
|
|
233
|
-
with self.echo_lock:
|
|
234
|
-
self.suppress_input_until = max(
|
|
235
|
-
self.suppress_input_until,
|
|
236
|
-
time.monotonic() + max(0.0, seconds),
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
def _input_is_suppressed(self) -> bool:
|
|
240
|
-
with self.echo_lock:
|
|
241
|
-
return time.monotonic() < self.suppress_input_until
|
|
242
|
-
|
|
243
|
-
def _open_input(self, index: int, info: dict, alsa_device: str | None = None):
|
|
244
|
-
alsa_match = re.search(r"\((?:plug)?hw:(\d+),(\d+)\)", str(info.get("name", "")), re.I)
|
|
245
|
-
if alsa_device is None and alsa_match:
|
|
246
|
-
alsa_device = f"plughw:{alsa_match.group(1)},{alsa_match.group(2)}"
|
|
247
|
-
if os.name == "posix" and alsa_device and shutil.which("arecord"):
|
|
248
|
-
device = alsa_device
|
|
249
|
-
command = [
|
|
250
|
-
"arecord", "-q", "-D", device, "-t", "raw", "-f", "S16_LE",
|
|
251
|
-
"-r", "48000", "-c", "1", "--period-size", "960",
|
|
252
|
-
]
|
|
253
|
-
process = subprocess.Popen(
|
|
254
|
-
command,
|
|
255
|
-
stdout=subprocess.PIPE,
|
|
256
|
-
stderr=subprocess.DEVNULL,
|
|
257
|
-
bufsize=0,
|
|
258
|
-
)
|
|
259
|
-
time.sleep(0.15)
|
|
260
|
-
if process.poll() is not None:
|
|
261
|
-
raise RuntimeError(f"microphone arecord failed on {device} (exit {process.returncode})")
|
|
262
|
-
self.input_process = process
|
|
263
|
-
self.input_backend = f"arecord {device}"
|
|
264
|
-
# This is the same onsite-proven path used by mic ground-truth
|
|
265
|
-
# tests. Several fleet USB interfaces expose near-silent samples
|
|
266
|
-
# when ALSA is asked to capture at 16 kHz directly. Capture at
|
|
267
|
-
# 48 kHz and resample to the ElevenLabs 16 kHz SDK format below.
|
|
268
|
-
self.input_rate = 48000
|
|
269
|
-
self.input_frames_per_buffer = int(self.input_rate / 50)
|
|
270
|
-
return None
|
|
271
|
-
|
|
272
|
-
errors = []
|
|
273
|
-
for rate in candidate_rates(info):
|
|
274
|
-
try:
|
|
275
|
-
frames_per_buffer = max(256, int(rate / 50))
|
|
276
|
-
stream = self.pa.open(
|
|
277
|
-
format=pyaudio.paInt16,
|
|
278
|
-
channels=1,
|
|
279
|
-
rate=rate,
|
|
280
|
-
input=True,
|
|
281
|
-
input_device_index=index,
|
|
282
|
-
frames_per_buffer=frames_per_buffer,
|
|
283
|
-
start=True,
|
|
284
|
-
)
|
|
285
|
-
self.input_rate = rate
|
|
286
|
-
self.input_frames_per_buffer = frames_per_buffer
|
|
287
|
-
self.input_backend = "pyaudio"
|
|
288
|
-
return stream
|
|
214
|
+
self.lock = threading.Lock()
|
|
215
|
+
self.started = threading.Event()
|
|
216
|
+
self.output_chunks = 0
|
|
217
|
+
self.output_bytes = 0
|
|
218
|
+
self.input_chunks = 0
|
|
219
|
+
self.forwarded_input_chunks = 0
|
|
220
|
+
self.input_peak = 0
|
|
221
|
+
self.suppressed_input_chunks = 0
|
|
222
|
+
self.input_read_errors = 0
|
|
223
|
+
self.last_input_error: str | None = None
|
|
224
|
+
self.output_write_errors = 0
|
|
225
|
+
self.last_output_error: str | None = None
|
|
226
|
+
self.fatal_error = threading.Event()
|
|
227
|
+
self.fatal_error_message: str | None = None
|
|
228
|
+
self.suppress_input_until = 0.0
|
|
229
|
+
self.echo_lock = threading.Lock()
|
|
230
|
+
self.echo_gate_announced = False
|
|
231
|
+
|
|
232
|
+
def _suppress_input_for(self, seconds: float) -> None:
|
|
233
|
+
with self.echo_lock:
|
|
234
|
+
self.suppress_input_until = max(
|
|
235
|
+
self.suppress_input_until,
|
|
236
|
+
time.monotonic() + max(0.0, seconds),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _input_is_suppressed(self) -> bool:
|
|
240
|
+
with self.echo_lock:
|
|
241
|
+
return time.monotonic() < self.suppress_input_until
|
|
242
|
+
|
|
243
|
+
def _open_input(self, index: int, info: dict, alsa_device: str | None = None):
|
|
244
|
+
alsa_match = re.search(r"\((?:plug)?hw:(\d+),(\d+)\)", str(info.get("name", "")), re.I)
|
|
245
|
+
if alsa_device is None and alsa_match:
|
|
246
|
+
alsa_device = f"plughw:{alsa_match.group(1)},{alsa_match.group(2)}"
|
|
247
|
+
if os.name == "posix" and alsa_device and shutil.which("arecord"):
|
|
248
|
+
device = alsa_device
|
|
249
|
+
command = [
|
|
250
|
+
"arecord", "-q", "-D", device, "-t", "raw", "-f", "S16_LE",
|
|
251
|
+
"-r", "48000", "-c", "1", "--period-size", "960",
|
|
252
|
+
]
|
|
253
|
+
process = subprocess.Popen(
|
|
254
|
+
command,
|
|
255
|
+
stdout=subprocess.PIPE,
|
|
256
|
+
stderr=subprocess.DEVNULL,
|
|
257
|
+
bufsize=0,
|
|
258
|
+
)
|
|
259
|
+
time.sleep(0.15)
|
|
260
|
+
if process.poll() is not None:
|
|
261
|
+
raise RuntimeError(f"microphone arecord failed on {device} (exit {process.returncode})")
|
|
262
|
+
self.input_process = process
|
|
263
|
+
self.input_backend = f"arecord {device}"
|
|
264
|
+
# This is the same onsite-proven path used by mic ground-truth
|
|
265
|
+
# tests. Several fleet USB interfaces expose near-silent samples
|
|
266
|
+
# when ALSA is asked to capture at 16 kHz directly. Capture at
|
|
267
|
+
# 48 kHz and resample to the ElevenLabs 16 kHz SDK format below.
|
|
268
|
+
self.input_rate = 48000
|
|
269
|
+
self.input_frames_per_buffer = int(self.input_rate / 50)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
errors = []
|
|
273
|
+
for rate in candidate_rates(info):
|
|
274
|
+
try:
|
|
275
|
+
frames_per_buffer = max(256, int(rate / 50))
|
|
276
|
+
stream = self.pa.open(
|
|
277
|
+
format=pyaudio.paInt16,
|
|
278
|
+
channels=1,
|
|
279
|
+
rate=rate,
|
|
280
|
+
input=True,
|
|
281
|
+
input_device_index=index,
|
|
282
|
+
frames_per_buffer=frames_per_buffer,
|
|
283
|
+
start=True,
|
|
284
|
+
)
|
|
285
|
+
self.input_rate = rate
|
|
286
|
+
self.input_frames_per_buffer = frames_per_buffer
|
|
287
|
+
self.input_backend = "pyaudio"
|
|
288
|
+
return stream
|
|
289
289
|
except Exception as exc:
|
|
290
290
|
errors.append(f"{rate}Hz: {exc}")
|
|
291
291
|
raise RuntimeError(f"microphone open failed ({'; '.join(errors)})")
|
|
@@ -312,174 +312,174 @@ class NativeAudioInterface(AudioInterface):
|
|
|
312
312
|
errors.append(f"{rate}Hz/{channels}ch: {exc}")
|
|
313
313
|
raise RuntimeError(f"speaker open failed ({'; '.join(errors)})")
|
|
314
314
|
|
|
315
|
-
def start(self, input_callback: Callable[[bytes], None]):
|
|
316
|
-
with self.lock:
|
|
317
|
-
if self.started.is_set():
|
|
318
|
-
return
|
|
319
|
-
self.input_callback = input_callback
|
|
320
|
-
self.stop_event.clear()
|
|
321
|
-
self.pa = pyaudio.PyAudio()
|
|
322
|
-
input_label = stable_audio_label(self.input_selector)
|
|
323
|
-
resolved_capture = resolve_alsa_capture(input_label)
|
|
324
|
-
if resolved_capture:
|
|
325
|
-
input_index = -1
|
|
326
|
-
input_info = {"name": input_label}
|
|
327
|
-
log(f"microphone identity resolved: {input_label} -> {resolved_capture}")
|
|
328
|
-
else:
|
|
329
|
-
input_index, input_info = find_device(self.pa, input_label, "input")
|
|
330
|
-
output_index, output_info = find_device(
|
|
331
|
-
self.pa, stable_audio_label(self.output_selector), "output"
|
|
332
|
-
)
|
|
333
|
-
self.input_name = str(input_info["name"])
|
|
334
|
-
self.output_name = str(output_info["name"])
|
|
335
|
-
try:
|
|
336
|
-
self.in_stream = self._open_input(input_index, input_info, resolved_capture)
|
|
315
|
+
def start(self, input_callback: Callable[[bytes], None]):
|
|
316
|
+
with self.lock:
|
|
317
|
+
if self.started.is_set():
|
|
318
|
+
return
|
|
319
|
+
self.input_callback = input_callback
|
|
320
|
+
self.stop_event.clear()
|
|
321
|
+
self.pa = pyaudio.PyAudio()
|
|
322
|
+
input_label = stable_audio_label(self.input_selector)
|
|
323
|
+
resolved_capture = resolve_alsa_capture(input_label)
|
|
324
|
+
if resolved_capture:
|
|
325
|
+
input_index = -1
|
|
326
|
+
input_info = {"name": input_label}
|
|
327
|
+
log(f"microphone identity resolved: {input_label} -> {resolved_capture}")
|
|
328
|
+
else:
|
|
329
|
+
input_index, input_info = find_device(self.pa, input_label, "input")
|
|
330
|
+
output_index, output_info = find_device(
|
|
331
|
+
self.pa, stable_audio_label(self.output_selector), "output"
|
|
332
|
+
)
|
|
333
|
+
self.input_name = str(input_info["name"])
|
|
334
|
+
self.output_name = str(output_info["name"])
|
|
335
|
+
try:
|
|
336
|
+
self.in_stream = self._open_input(input_index, input_info, resolved_capture)
|
|
337
337
|
self.out_stream = self._open_output(output_index, output_info)
|
|
338
338
|
except Exception:
|
|
339
339
|
self._close_streams()
|
|
340
340
|
raise
|
|
341
|
-
self.output_thread = threading.Thread(
|
|
342
|
-
target=self._output_loop, name="robot-speaker", daemon=True
|
|
343
|
-
)
|
|
344
|
-
self.input_thread = threading.Thread(
|
|
345
|
-
target=self._input_loop, name="robot-microphone", daemon=True
|
|
346
|
-
)
|
|
347
|
-
self.output_thread.start()
|
|
348
|
-
self.input_thread.start()
|
|
349
|
-
self.started.set()
|
|
350
|
-
log(
|
|
351
|
-
f"audio active: mic {input_info['name']} at {self.input_rate}Hz; "
|
|
352
|
-
f"capture {self.input_backend}; speaker {output_info['name']} "
|
|
353
|
-
f"at {self.output_rate}Hz/{self.output_channels}ch"
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
def status(self) -> dict:
|
|
357
|
-
return {
|
|
358
|
-
"active": self.started.is_set() and not self.stop_event.is_set(),
|
|
359
|
-
"input": self.input_name,
|
|
360
|
-
"input_backend": self.input_backend,
|
|
361
|
-
"input_rate": self.input_rate,
|
|
362
|
-
"input_chunks": self.input_chunks,
|
|
363
|
-
"forwarded_input_chunks": self.forwarded_input_chunks,
|
|
364
|
-
"suppressed_input_chunks": self.suppressed_input_chunks,
|
|
365
|
-
"input_peak": self.input_peak,
|
|
366
|
-
"input_read_errors": self.input_read_errors,
|
|
367
|
-
"last_input_error": self.last_input_error,
|
|
368
|
-
"output": self.output_name,
|
|
369
|
-
"output_rate": self.output_rate,
|
|
370
|
-
"output_channels": self.output_channels,
|
|
371
|
-
"output_chunks": self.output_chunks,
|
|
372
|
-
"output_bytes": self.output_bytes,
|
|
373
|
-
"output_write_errors": self.output_write_errors,
|
|
374
|
-
"last_output_error": self.last_output_error,
|
|
375
|
-
"fatal_error": self.fatal_error_message,
|
|
376
|
-
"echo_gate_active": self._input_is_suppressed(),
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
def _input_loop(self) -> None:
|
|
380
|
-
read_errors = 0
|
|
381
|
-
while not self.stop_event.is_set():
|
|
382
|
-
try:
|
|
383
|
-
if self.input_process is not None:
|
|
384
|
-
data = self.input_process.stdout.read(self.input_frames_per_buffer * SAMPLE_WIDTH)
|
|
385
|
-
if not data and self.input_process.poll() is not None:
|
|
386
|
-
if self.stop_event.is_set():
|
|
387
|
-
break
|
|
388
|
-
raise RuntimeError(f"arecord exited with code {self.input_process.returncode}")
|
|
389
|
-
else:
|
|
390
|
-
data = self.in_stream.read(
|
|
391
|
-
self.input_frames_per_buffer,
|
|
392
|
-
exception_on_overflow=False,
|
|
393
|
-
)
|
|
394
|
-
read_errors = 0
|
|
395
|
-
except Exception as exc:
|
|
396
|
-
read_errors += 1
|
|
397
|
-
self.input_read_errors += 1
|
|
398
|
-
self.last_input_error = str(exc)
|
|
399
|
-
if read_errors <= 3 or read_errors % 50 == 0:
|
|
400
|
-
log(f"microphone read failed ({read_errors}): {exc}")
|
|
401
|
-
if read_errors >= 3:
|
|
402
|
-
self.fatal_error_message = f"microphone capture failed repeatedly: {exc}"
|
|
403
|
-
self.fatal_error.set()
|
|
404
|
-
break
|
|
405
|
-
time.sleep(0.02)
|
|
406
|
-
continue
|
|
407
|
-
if not data:
|
|
408
|
-
continue
|
|
409
|
-
self.input_chunks += 1
|
|
410
|
-
try:
|
|
411
|
-
self.input_peak = max(self.input_peak, audioop.max(data, SAMPLE_WIDTH))
|
|
412
|
-
except Exception:
|
|
413
|
-
pass
|
|
414
|
-
if self._input_is_suppressed() or not self.output_queue.empty():
|
|
415
|
-
self.suppressed_input_chunks += 1
|
|
416
|
-
continue
|
|
417
|
-
try:
|
|
418
|
-
converted, self.input_rate_state = audioop.ratecv(
|
|
419
|
-
data, SAMPLE_WIDTH, 1, self.input_rate, SDK_RATE, self.input_rate_state
|
|
420
|
-
)
|
|
421
|
-
if converted and self.input_callback:
|
|
422
|
-
self.input_callback(converted)
|
|
423
|
-
self.forwarded_input_chunks += 1
|
|
424
|
-
except Exception as exc:
|
|
425
|
-
log(f"microphone callback failed: {exc}")
|
|
426
|
-
|
|
427
|
-
def output(self, audio: bytes):
|
|
428
|
-
if self.stop_event.is_set() or not audio:
|
|
429
|
-
return
|
|
430
|
-
# ElevenLabs delivers mono PCM16 at SDK_RATE. Gate capture for the
|
|
431
|
-
# queued playback duration plus room/speaker decay so the agent cannot
|
|
432
|
-
# transcribe its own response and recursively answer itself.
|
|
433
|
-
self._suppress_input_for((len(audio) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS)
|
|
434
|
-
if not self.echo_gate_announced:
|
|
435
|
-
self.echo_gate_announced = True
|
|
436
|
-
log("speaker echo gate active; microphone STT paused during playback")
|
|
437
|
-
try:
|
|
438
|
-
self.output_queue.put_nowait(audio)
|
|
439
|
-
except queue.Full:
|
|
440
|
-
# Bound latency and memory if the hardware stalls. Fresh speech is
|
|
441
|
-
# more useful than replaying stale buffered audio after recovery.
|
|
442
|
-
try:
|
|
443
|
-
self.output_queue.get_nowait()
|
|
444
|
-
except queue.Empty:
|
|
445
|
-
pass
|
|
446
|
-
try:
|
|
447
|
-
self.output_queue.put_nowait(audio)
|
|
448
|
-
except queue.Full:
|
|
449
|
-
pass
|
|
450
|
-
|
|
451
|
-
def _output_loop(self) -> None:
|
|
452
|
-
write_errors = 0
|
|
453
|
-
while not self.stop_event.is_set():
|
|
341
|
+
self.output_thread = threading.Thread(
|
|
342
|
+
target=self._output_loop, name="robot-speaker", daemon=True
|
|
343
|
+
)
|
|
344
|
+
self.input_thread = threading.Thread(
|
|
345
|
+
target=self._input_loop, name="robot-microphone", daemon=True
|
|
346
|
+
)
|
|
347
|
+
self.output_thread.start()
|
|
348
|
+
self.input_thread.start()
|
|
349
|
+
self.started.set()
|
|
350
|
+
log(
|
|
351
|
+
f"audio active: mic {input_info['name']} at {self.input_rate}Hz; "
|
|
352
|
+
f"capture {self.input_backend}; speaker {output_info['name']} "
|
|
353
|
+
f"at {self.output_rate}Hz/{self.output_channels}ch"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def status(self) -> dict:
|
|
357
|
+
return {
|
|
358
|
+
"active": self.started.is_set() and not self.stop_event.is_set(),
|
|
359
|
+
"input": self.input_name,
|
|
360
|
+
"input_backend": self.input_backend,
|
|
361
|
+
"input_rate": self.input_rate,
|
|
362
|
+
"input_chunks": self.input_chunks,
|
|
363
|
+
"forwarded_input_chunks": self.forwarded_input_chunks,
|
|
364
|
+
"suppressed_input_chunks": self.suppressed_input_chunks,
|
|
365
|
+
"input_peak": self.input_peak,
|
|
366
|
+
"input_read_errors": self.input_read_errors,
|
|
367
|
+
"last_input_error": self.last_input_error,
|
|
368
|
+
"output": self.output_name,
|
|
369
|
+
"output_rate": self.output_rate,
|
|
370
|
+
"output_channels": self.output_channels,
|
|
371
|
+
"output_chunks": self.output_chunks,
|
|
372
|
+
"output_bytes": self.output_bytes,
|
|
373
|
+
"output_write_errors": self.output_write_errors,
|
|
374
|
+
"last_output_error": self.last_output_error,
|
|
375
|
+
"fatal_error": self.fatal_error_message,
|
|
376
|
+
"echo_gate_active": self._input_is_suppressed(),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def _input_loop(self) -> None:
|
|
380
|
+
read_errors = 0
|
|
381
|
+
while not self.stop_event.is_set():
|
|
382
|
+
try:
|
|
383
|
+
if self.input_process is not None:
|
|
384
|
+
data = self.input_process.stdout.read(self.input_frames_per_buffer * SAMPLE_WIDTH)
|
|
385
|
+
if not data and self.input_process.poll() is not None:
|
|
386
|
+
if self.stop_event.is_set():
|
|
387
|
+
break
|
|
388
|
+
raise RuntimeError(f"arecord exited with code {self.input_process.returncode}")
|
|
389
|
+
else:
|
|
390
|
+
data = self.in_stream.read(
|
|
391
|
+
self.input_frames_per_buffer,
|
|
392
|
+
exception_on_overflow=False,
|
|
393
|
+
)
|
|
394
|
+
read_errors = 0
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
read_errors += 1
|
|
397
|
+
self.input_read_errors += 1
|
|
398
|
+
self.last_input_error = str(exc)
|
|
399
|
+
if read_errors <= 3 or read_errors % 50 == 0:
|
|
400
|
+
log(f"microphone read failed ({read_errors}): {exc}")
|
|
401
|
+
if read_errors >= 3:
|
|
402
|
+
self.fatal_error_message = f"microphone capture failed repeatedly: {exc}"
|
|
403
|
+
self.fatal_error.set()
|
|
404
|
+
break
|
|
405
|
+
time.sleep(0.02)
|
|
406
|
+
continue
|
|
407
|
+
if not data:
|
|
408
|
+
continue
|
|
409
|
+
self.input_chunks += 1
|
|
410
|
+
try:
|
|
411
|
+
self.input_peak = max(self.input_peak, audioop.max(data, SAMPLE_WIDTH))
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
if self._input_is_suppressed() or not self.output_queue.empty():
|
|
415
|
+
self.suppressed_input_chunks += 1
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
converted, self.input_rate_state = audioop.ratecv(
|
|
419
|
+
data, SAMPLE_WIDTH, 1, self.input_rate, SDK_RATE, self.input_rate_state
|
|
420
|
+
)
|
|
421
|
+
if converted and self.input_callback:
|
|
422
|
+
self.input_callback(converted)
|
|
423
|
+
self.forwarded_input_chunks += 1
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
log(f"microphone callback failed: {exc}")
|
|
426
|
+
|
|
427
|
+
def output(self, audio: bytes):
|
|
428
|
+
if self.stop_event.is_set() or not audio:
|
|
429
|
+
return
|
|
430
|
+
# ElevenLabs delivers mono PCM16 at SDK_RATE. Gate capture for the
|
|
431
|
+
# queued playback duration plus room/speaker decay so the agent cannot
|
|
432
|
+
# transcribe its own response and recursively answer itself.
|
|
433
|
+
self._suppress_input_for((len(audio) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS)
|
|
434
|
+
if not self.echo_gate_announced:
|
|
435
|
+
self.echo_gate_announced = True
|
|
436
|
+
log("speaker echo gate active; microphone STT paused during playback")
|
|
437
|
+
try:
|
|
438
|
+
self.output_queue.put_nowait(audio)
|
|
439
|
+
except queue.Full:
|
|
440
|
+
# Bound latency and memory if the hardware stalls. Fresh speech is
|
|
441
|
+
# more useful than replaying stale buffered audio after recovery.
|
|
442
|
+
try:
|
|
443
|
+
self.output_queue.get_nowait()
|
|
444
|
+
except queue.Empty:
|
|
445
|
+
pass
|
|
446
|
+
try:
|
|
447
|
+
self.output_queue.put_nowait(audio)
|
|
448
|
+
except queue.Full:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
def _output_loop(self) -> None:
|
|
452
|
+
write_errors = 0
|
|
453
|
+
while not self.stop_event.is_set():
|
|
454
454
|
try:
|
|
455
455
|
data = self.output_queue.get(timeout=0.2)
|
|
456
|
-
except queue.Empty:
|
|
457
|
-
continue
|
|
458
|
-
try:
|
|
459
|
-
self._suppress_input_for(
|
|
460
|
-
(len(data) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS
|
|
461
|
-
)
|
|
462
|
-
converted, self.output_rate_state = audioop.ratecv(
|
|
463
|
-
data, SAMPLE_WIDTH, 1, SDK_RATE, self.output_rate, self.output_rate_state
|
|
464
|
-
)
|
|
465
|
-
if self.output_channels == 2:
|
|
466
|
-
converted = audioop.tostereo(converted, SAMPLE_WIDTH, 1, 1)
|
|
467
|
-
self.out_stream.write(converted, exception_on_underflow=False)
|
|
468
|
-
write_errors = 0
|
|
469
|
-
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
470
|
-
self.output_chunks += 1
|
|
471
|
-
self.output_bytes += len(data)
|
|
472
|
-
if self.output_chunks == 1:
|
|
473
|
-
log("speaker received first agent audio frame")
|
|
474
|
-
except Exception as exc:
|
|
475
|
-
write_errors += 1
|
|
476
|
-
self.output_write_errors += 1
|
|
477
|
-
self.last_output_error = str(exc)
|
|
478
|
-
log(f"speaker write failed: {exc}")
|
|
479
|
-
if write_errors >= 3:
|
|
480
|
-
self.fatal_error_message = f"speaker playback failed repeatedly: {exc}"
|
|
481
|
-
self.fatal_error.set()
|
|
482
|
-
break
|
|
456
|
+
except queue.Empty:
|
|
457
|
+
continue
|
|
458
|
+
try:
|
|
459
|
+
self._suppress_input_for(
|
|
460
|
+
(len(data) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS
|
|
461
|
+
)
|
|
462
|
+
converted, self.output_rate_state = audioop.ratecv(
|
|
463
|
+
data, SAMPLE_WIDTH, 1, SDK_RATE, self.output_rate, self.output_rate_state
|
|
464
|
+
)
|
|
465
|
+
if self.output_channels == 2:
|
|
466
|
+
converted = audioop.tostereo(converted, SAMPLE_WIDTH, 1, 1)
|
|
467
|
+
self.out_stream.write(converted, exception_on_underflow=False)
|
|
468
|
+
write_errors = 0
|
|
469
|
+
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
470
|
+
self.output_chunks += 1
|
|
471
|
+
self.output_bytes += len(data)
|
|
472
|
+
if self.output_chunks == 1:
|
|
473
|
+
log("speaker received first agent audio frame")
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
write_errors += 1
|
|
476
|
+
self.output_write_errors += 1
|
|
477
|
+
self.last_output_error = str(exc)
|
|
478
|
+
log(f"speaker write failed: {exc}")
|
|
479
|
+
if write_errors >= 3:
|
|
480
|
+
self.fatal_error_message = f"speaker playback failed repeatedly: {exc}"
|
|
481
|
+
self.fatal_error.set()
|
|
482
|
+
break
|
|
483
483
|
|
|
484
484
|
def interrupt(self):
|
|
485
485
|
while True:
|
|
@@ -487,22 +487,22 @@ class NativeAudioInterface(AudioInterface):
|
|
|
487
487
|
self.output_queue.get_nowait()
|
|
488
488
|
except queue.Empty:
|
|
489
489
|
break
|
|
490
|
-
self.output_rate_state = None
|
|
491
|
-
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
492
|
-
log("agent playback interrupted")
|
|
493
|
-
|
|
494
|
-
def _close_streams(self) -> None:
|
|
495
|
-
if self.input_process is not None:
|
|
496
|
-
try:
|
|
497
|
-
self.input_process.terminate()
|
|
498
|
-
self.input_process.wait(timeout=1)
|
|
499
|
-
except Exception:
|
|
500
|
-
try:
|
|
501
|
-
self.input_process.kill()
|
|
502
|
-
except Exception:
|
|
503
|
-
pass
|
|
504
|
-
self.input_process = None
|
|
505
|
-
for stream in (self.in_stream, self.out_stream):
|
|
490
|
+
self.output_rate_state = None
|
|
491
|
+
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
492
|
+
log("agent playback interrupted")
|
|
493
|
+
|
|
494
|
+
def _close_streams(self) -> None:
|
|
495
|
+
if self.input_process is not None:
|
|
496
|
+
try:
|
|
497
|
+
self.input_process.terminate()
|
|
498
|
+
self.input_process.wait(timeout=1)
|
|
499
|
+
except Exception:
|
|
500
|
+
try:
|
|
501
|
+
self.input_process.kill()
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
self.input_process = None
|
|
505
|
+
for stream in (self.in_stream, self.out_stream):
|
|
506
506
|
if stream is not None:
|
|
507
507
|
try:
|
|
508
508
|
stream.stop_stream()
|
|
@@ -523,26 +523,26 @@ class NativeAudioInterface(AudioInterface):
|
|
|
523
523
|
|
|
524
524
|
def stop(self):
|
|
525
525
|
with self.lock:
|
|
526
|
-
if self.stop_event.is_set() and not self.started.is_set():
|
|
527
|
-
return
|
|
528
|
-
self.stop_event.set()
|
|
529
|
-
if self.input_process is not None:
|
|
530
|
-
try:
|
|
531
|
-
self.input_process.terminate()
|
|
532
|
-
except Exception:
|
|
533
|
-
pass
|
|
534
|
-
if self.input_thread and self.input_thread is not threading.current_thread():
|
|
535
|
-
self.input_thread.join(timeout=2)
|
|
536
|
-
if self.output_thread and self.output_thread is not threading.current_thread():
|
|
537
|
-
self.output_thread.join(timeout=2)
|
|
538
|
-
self._close_streams()
|
|
539
|
-
self.started.clear()
|
|
540
|
-
self.input_callback = None
|
|
541
|
-
while True:
|
|
542
|
-
try:
|
|
543
|
-
self.output_queue.get_nowait()
|
|
544
|
-
except queue.Empty:
|
|
545
|
-
break
|
|
526
|
+
if self.stop_event.is_set() and not self.started.is_set():
|
|
527
|
+
return
|
|
528
|
+
self.stop_event.set()
|
|
529
|
+
if self.input_process is not None:
|
|
530
|
+
try:
|
|
531
|
+
self.input_process.terminate()
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
if self.input_thread and self.input_thread is not threading.current_thread():
|
|
535
|
+
self.input_thread.join(timeout=2)
|
|
536
|
+
if self.output_thread and self.output_thread is not threading.current_thread():
|
|
537
|
+
self.output_thread.join(timeout=2)
|
|
538
|
+
self._close_streams()
|
|
539
|
+
self.started.clear()
|
|
540
|
+
self.input_callback = None
|
|
541
|
+
while True:
|
|
542
|
+
try:
|
|
543
|
+
self.output_queue.get_nowait()
|
|
544
|
+
except queue.Empty:
|
|
545
|
+
break
|
|
546
546
|
|
|
547
547
|
|
|
548
548
|
class VoiceTrigger:
|
|
@@ -620,7 +620,7 @@ class VoiceTrigger:
|
|
|
620
620
|
log(f"voice trigger unavailable: {exc}")
|
|
621
621
|
|
|
622
622
|
|
|
623
|
-
class MotorRegistry:
|
|
623
|
+
class MotorRegistry:
|
|
624
624
|
def __init__(self, motors: dict[str, int], active_high: bool, default_pulse_ms: int) -> None:
|
|
625
625
|
self.motors = motors
|
|
626
626
|
self.active_high = active_high
|
|
@@ -685,859 +685,859 @@ class MotorRegistry:
|
|
|
685
685
|
device.close()
|
|
686
686
|
except Exception:
|
|
687
687
|
pass
|
|
688
|
-
self.devices.clear()
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
class SchedulerReporter:
|
|
692
|
-
"""Best-effort central oversight for robot-local ElevenLabs calls."""
|
|
693
|
-
|
|
694
|
-
def __init__(self, config: dict, store: SupervisorStore) -> None:
|
|
695
|
-
self.base_url = str(config.get("scheduler_url") or "").rstrip("/")
|
|
696
|
-
self.device_id = str(config.get("scheduler_device_id") or "").strip()
|
|
697
|
-
self.device_token = str(config.get("scheduler_device_token") or "").strip()
|
|
698
|
-
if not (self.base_url and self.device_id and self.device_token):
|
|
699
|
-
try:
|
|
700
|
-
config_dir = Path.home() / ".robopark"
|
|
701
|
-
preview = json.loads((config_dir / "preview_agent.json").read_text(encoding="utf-8"))
|
|
702
|
-
self.base_url = str(preview.get("scheduler_url") or self.base_url).rstrip("/")
|
|
703
|
-
self.device_id = str(preview.get("device_id") or self.device_id).strip()
|
|
704
|
-
token_path = config_dir / "device_token"
|
|
705
|
-
self.device_token = (
|
|
706
|
-
token_path.read_text(encoding="utf-8").strip()
|
|
707
|
-
if token_path.exists()
|
|
708
|
-
else str(preview.get("device_token") or self.device_token).strip()
|
|
709
|
-
)
|
|
710
|
-
except Exception:
|
|
711
|
-
pass
|
|
712
|
-
self.agent_id = str(config.get("agent_id") or "").strip()
|
|
713
|
-
self.branch_id = str(config.get("branch_id") or "").strip() or None
|
|
714
|
-
self.session_id: str | None = None
|
|
715
|
-
self.event_token: str | None = None
|
|
716
|
-
self.sequence = 0
|
|
717
|
-
self.lock = threading.Lock()
|
|
718
|
-
self.store = store
|
|
719
|
-
self.command_handler: Callable[[dict], dict] | None = None
|
|
720
|
-
self.state_provider: Callable[[], dict] | None = None
|
|
721
|
-
self.stop_event = threading.Event()
|
|
722
|
-
self.jobs: queue.Queue[tuple[str, dict, dict] | None] = queue.Queue(maxsize=256)
|
|
723
|
-
self.worker: threading.Thread | None = None
|
|
724
|
-
|
|
725
|
-
@property
|
|
726
|
-
def enabled(self) -> bool:
|
|
727
|
-
return bool(self.base_url and self.device_id and self.device_token)
|
|
728
|
-
|
|
729
|
-
def _post(self, path: str, payload: dict, headers: dict | None = None) -> dict:
|
|
730
|
-
request = urllib.request.Request(
|
|
731
|
-
f"{self.base_url}{path}",
|
|
732
|
-
data=json.dumps(payload).encode("utf-8"),
|
|
733
|
-
headers={"Content-Type": "application/json", **(headers or {})},
|
|
734
|
-
method="POST",
|
|
735
|
-
)
|
|
736
|
-
with urllib.request.urlopen(request, timeout=4) as response:
|
|
737
|
-
body = response.read().decode("utf-8")
|
|
738
|
-
return json.loads(body) if body else {}
|
|
739
|
-
|
|
740
|
-
def _get(self, path: str, headers: dict | None = None) -> dict:
|
|
741
|
-
request = urllib.request.Request(
|
|
742
|
-
f"{self.base_url}{path}", headers=headers or {}, method="GET"
|
|
743
|
-
)
|
|
744
|
-
with urllib.request.urlopen(request, timeout=4) as response:
|
|
745
|
-
body = response.read().decode("utf-8")
|
|
746
|
-
return json.loads(body) if body else {}
|
|
747
|
-
|
|
748
|
-
def _sync_durable_state(self) -> None:
|
|
749
|
-
if not self.enabled:
|
|
750
|
-
return
|
|
751
|
-
headers = {"Authorization": f"Bearer {self.device_token}"}
|
|
752
|
-
if self.state_provider is not None:
|
|
753
|
-
self._post(
|
|
754
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/voice-runtime-state",
|
|
755
|
-
self.state_provider(), headers,
|
|
756
|
-
)
|
|
757
|
-
events = self.store.events_after(0, 250)
|
|
758
|
-
if events:
|
|
759
|
-
result = self._post(
|
|
760
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/telemetry/events",
|
|
761
|
-
{"events": events},
|
|
762
|
-
headers,
|
|
763
|
-
)
|
|
764
|
-
acknowledged = int(result.get("acknowledged_sequence") or 0)
|
|
765
|
-
if acknowledged:
|
|
766
|
-
self.store.acknowledge(acknowledged)
|
|
767
|
-
commands = self._get(
|
|
768
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/pending",
|
|
769
|
-
headers,
|
|
770
|
-
).get("commands") or []
|
|
771
|
-
for command in commands:
|
|
772
|
-
command_id = str(command.get("command_id") or "")
|
|
773
|
-
if not command_id:
|
|
774
|
-
continue
|
|
775
|
-
previous = self.store.command_result(command_id)
|
|
776
|
-
if previous:
|
|
777
|
-
self._post(
|
|
778
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
779
|
-
{"status": previous["status"], "result": previous["result"]},
|
|
780
|
-
headers,
|
|
781
|
-
)
|
|
782
|
-
continue
|
|
783
|
-
self._post(
|
|
784
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
785
|
-
{"status": "acknowledged", "result": {}}, headers,
|
|
786
|
-
)
|
|
787
|
-
self._post(
|
|
788
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
789
|
-
{"status": "running", "result": {}}, headers,
|
|
790
|
-
)
|
|
791
|
-
try:
|
|
792
|
-
if self.command_handler is None:
|
|
793
|
-
raise RuntimeError("local command handler is unavailable")
|
|
794
|
-
result = self.command_handler(command)
|
|
795
|
-
status = "succeeded" if result.get("ok") else "failed"
|
|
796
|
-
except Exception as exc:
|
|
797
|
-
result = {"ok": False, "error": str(exc)}
|
|
798
|
-
status = "failed"
|
|
799
|
-
self.store.complete_command(command_id, status, result)
|
|
800
|
-
self._post(
|
|
801
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
802
|
-
{"status": status, "result": result}, headers,
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
def _run(self) -> None:
|
|
806
|
-
while not self.stop_event.is_set():
|
|
807
|
-
try:
|
|
808
|
-
job = self.jobs.get(timeout=2)
|
|
809
|
-
except queue.Empty:
|
|
810
|
-
job = ()
|
|
811
|
-
if job is None:
|
|
812
|
-
return
|
|
813
|
-
if job:
|
|
814
|
-
path, payload, headers = job
|
|
815
|
-
try:
|
|
816
|
-
self._post(path, payload, headers)
|
|
817
|
-
except Exception as exc:
|
|
818
|
-
log(f"central oversight write failed; event remains local: {exc}")
|
|
819
|
-
try:
|
|
820
|
-
self._sync_durable_state()
|
|
821
|
-
except Exception:
|
|
822
|
-
# Gateway connectivity never changes local voice readiness.
|
|
823
|
-
pass
|
|
824
|
-
|
|
825
|
-
def start_worker(self) -> None:
|
|
826
|
-
if self.worker is None or not self.worker.is_alive():
|
|
827
|
-
self.worker = threading.Thread(target=self._run, name="management-sync", daemon=True)
|
|
828
|
-
self.worker.start()
|
|
829
|
-
|
|
830
|
-
def _enqueue(self, path: str, payload: dict, headers: dict) -> None:
|
|
831
|
-
if not self.enabled:
|
|
832
|
-
return
|
|
833
|
-
self.start_worker()
|
|
834
|
-
try:
|
|
835
|
-
self.jobs.put_nowait((path, payload, headers))
|
|
836
|
-
except queue.Full:
|
|
837
|
-
log("central oversight queue full; dropping telemetry event")
|
|
838
|
-
|
|
839
|
-
def start(self, reason: str) -> None:
|
|
840
|
-
if not self.enabled:
|
|
841
|
-
return
|
|
842
|
-
with self.lock:
|
|
843
|
-
self.session_id = None
|
|
844
|
-
self.event_token = None
|
|
845
|
-
self.sequence = 0
|
|
846
|
-
try:
|
|
847
|
-
result = self._post(
|
|
848
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions",
|
|
849
|
-
{"agent_id": self.agent_id, "branch_id": self.branch_id, "trigger_reason": reason},
|
|
850
|
-
{"Authorization": f"Bearer {self.device_token}"},
|
|
851
|
-
)
|
|
852
|
-
with self.lock:
|
|
853
|
-
self.session_id = str(result.get("session_id") or "") or None
|
|
854
|
-
self.event_token = str(result.get("event_token") or "") or None
|
|
855
|
-
if self.session_id:
|
|
856
|
-
log(f"central oversight session registered: {self.session_id}")
|
|
857
|
-
except Exception as exc:
|
|
858
|
-
log(f"central oversight unavailable; continuing locally: {exc}")
|
|
859
|
-
|
|
860
|
-
def event(self, stage: str, status: str, message: str, details: dict | None = None) -> None:
|
|
861
|
-
pipeline_stage = {
|
|
862
|
-
"session_starting": "voice_worker",
|
|
863
|
-
"provider_connected": "voice_worker",
|
|
864
|
-
"user_transcript": "stt_listening",
|
|
865
|
-
"agent_response": "llm_response",
|
|
866
|
-
"session_error": "voice_worker",
|
|
867
|
-
"session_disposed": "session_ended",
|
|
868
|
-
}.get(stage)
|
|
869
|
-
if pipeline_stage is None:
|
|
870
|
-
return
|
|
871
|
-
pipeline_status = "running" if status == "active" else status
|
|
872
|
-
with self.lock:
|
|
873
|
-
session_id, token = self.session_id, self.event_token
|
|
874
|
-
if not session_id or not token:
|
|
875
|
-
return
|
|
876
|
-
self._enqueue(
|
|
877
|
-
f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/pipeline-events",
|
|
878
|
-
{"stage": pipeline_stage, "status": pipeline_status, "message": message, "source": "robot_hardware", "details": details or {}},
|
|
879
|
-
{"X-RoboPark-Session-Token": token},
|
|
880
|
-
)
|
|
881
|
-
|
|
882
|
-
def turn(self, role: str, text: str) -> None:
|
|
883
|
-
# Transcript delivery is exclusively cursor-based through the durable
|
|
884
|
-
# spool. Posting it here as well produced duplicate historical turns.
|
|
885
|
-
return
|
|
886
|
-
|
|
887
|
-
def end(self, reason: str, duration: float, error: str | None) -> None:
|
|
888
|
-
with self.lock:
|
|
889
|
-
session_id = self.session_id
|
|
890
|
-
self.session_id = None
|
|
891
|
-
self.event_token = None
|
|
892
|
-
if not session_id or not self.enabled:
|
|
893
|
-
return
|
|
894
|
-
self._enqueue(
|
|
895
|
-
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions/{urllib.parse.quote(session_id, safe='')}/end",
|
|
896
|
-
{"reason": reason, "duration_seconds": duration, "error": error},
|
|
897
|
-
{"Authorization": f"Bearer {self.device_token}"},
|
|
898
|
-
)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
class Runtime:
|
|
902
|
-
def __init__(self, config: dict) -> None:
|
|
903
|
-
self.config = config
|
|
904
|
-
# Session disposal is local and deterministic. Recycling the systemd
|
|
905
|
-
# worker after every timeout caused port races and stale ALSA owners.
|
|
906
|
-
self.config["recycle_after_session"] = False
|
|
688
|
+
self.devices.clear()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
class SchedulerReporter:
|
|
692
|
+
"""Best-effort central oversight for robot-local ElevenLabs calls."""
|
|
693
|
+
|
|
694
|
+
def __init__(self, config: dict, store: SupervisorStore) -> None:
|
|
695
|
+
self.base_url = str(config.get("scheduler_url") or "").rstrip("/")
|
|
696
|
+
self.device_id = str(config.get("scheduler_device_id") or "").strip()
|
|
697
|
+
self.device_token = str(config.get("scheduler_device_token") or "").strip()
|
|
698
|
+
if not (self.base_url and self.device_id and self.device_token):
|
|
699
|
+
try:
|
|
700
|
+
config_dir = Path.home() / ".robopark"
|
|
701
|
+
preview = json.loads((config_dir / "preview_agent.json").read_text(encoding="utf-8"))
|
|
702
|
+
self.base_url = str(preview.get("scheduler_url") or self.base_url).rstrip("/")
|
|
703
|
+
self.device_id = str(preview.get("device_id") or self.device_id).strip()
|
|
704
|
+
token_path = config_dir / "device_token"
|
|
705
|
+
self.device_token = (
|
|
706
|
+
token_path.read_text(encoding="utf-8").strip()
|
|
707
|
+
if token_path.exists()
|
|
708
|
+
else str(preview.get("device_token") or self.device_token).strip()
|
|
709
|
+
)
|
|
710
|
+
except Exception:
|
|
711
|
+
pass
|
|
712
|
+
self.agent_id = str(config.get("agent_id") or "").strip()
|
|
713
|
+
self.branch_id = str(config.get("branch_id") or "").strip() or None
|
|
714
|
+
self.session_id: str | None = None
|
|
715
|
+
self.event_token: str | None = None
|
|
716
|
+
self.sequence = 0
|
|
717
|
+
self.lock = threading.Lock()
|
|
718
|
+
self.store = store
|
|
719
|
+
self.command_handler: Callable[[dict], dict] | None = None
|
|
720
|
+
self.state_provider: Callable[[], dict] | None = None
|
|
721
|
+
self.stop_event = threading.Event()
|
|
722
|
+
self.jobs: queue.Queue[tuple[str, dict, dict] | None] = queue.Queue(maxsize=256)
|
|
723
|
+
self.worker: threading.Thread | None = None
|
|
724
|
+
|
|
725
|
+
@property
|
|
726
|
+
def enabled(self) -> bool:
|
|
727
|
+
return bool(self.base_url and self.device_id and self.device_token)
|
|
728
|
+
|
|
729
|
+
def _post(self, path: str, payload: dict, headers: dict | None = None) -> dict:
|
|
730
|
+
request = urllib.request.Request(
|
|
731
|
+
f"{self.base_url}{path}",
|
|
732
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
733
|
+
headers={"Content-Type": "application/json", **(headers or {})},
|
|
734
|
+
method="POST",
|
|
735
|
+
)
|
|
736
|
+
with urllib.request.urlopen(request, timeout=4) as response:
|
|
737
|
+
body = response.read().decode("utf-8")
|
|
738
|
+
return json.loads(body) if body else {}
|
|
739
|
+
|
|
740
|
+
def _get(self, path: str, headers: dict | None = None) -> dict:
|
|
741
|
+
request = urllib.request.Request(
|
|
742
|
+
f"{self.base_url}{path}", headers=headers or {}, method="GET"
|
|
743
|
+
)
|
|
744
|
+
with urllib.request.urlopen(request, timeout=4) as response:
|
|
745
|
+
body = response.read().decode("utf-8")
|
|
746
|
+
return json.loads(body) if body else {}
|
|
747
|
+
|
|
748
|
+
def _sync_durable_state(self) -> None:
|
|
749
|
+
if not self.enabled:
|
|
750
|
+
return
|
|
751
|
+
headers = {"Authorization": f"Bearer {self.device_token}"}
|
|
752
|
+
if self.state_provider is not None:
|
|
753
|
+
self._post(
|
|
754
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/voice-runtime-state",
|
|
755
|
+
self.state_provider(), headers,
|
|
756
|
+
)
|
|
757
|
+
events = self.store.events_after(0, 250)
|
|
758
|
+
if events:
|
|
759
|
+
result = self._post(
|
|
760
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/telemetry/events",
|
|
761
|
+
{"events": events},
|
|
762
|
+
headers,
|
|
763
|
+
)
|
|
764
|
+
acknowledged = int(result.get("acknowledged_sequence") or 0)
|
|
765
|
+
if acknowledged:
|
|
766
|
+
self.store.acknowledge(acknowledged)
|
|
767
|
+
commands = self._get(
|
|
768
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/pending",
|
|
769
|
+
headers,
|
|
770
|
+
).get("commands") or []
|
|
771
|
+
for command in commands:
|
|
772
|
+
command_id = str(command.get("command_id") or "")
|
|
773
|
+
if not command_id:
|
|
774
|
+
continue
|
|
775
|
+
previous = self.store.command_result(command_id)
|
|
776
|
+
if previous:
|
|
777
|
+
self._post(
|
|
778
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
779
|
+
{"status": previous["status"], "result": previous["result"]},
|
|
780
|
+
headers,
|
|
781
|
+
)
|
|
782
|
+
continue
|
|
783
|
+
self._post(
|
|
784
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
785
|
+
{"status": "acknowledged", "result": {}}, headers,
|
|
786
|
+
)
|
|
787
|
+
self._post(
|
|
788
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
789
|
+
{"status": "running", "result": {}}, headers,
|
|
790
|
+
)
|
|
791
|
+
try:
|
|
792
|
+
if self.command_handler is None:
|
|
793
|
+
raise RuntimeError("local command handler is unavailable")
|
|
794
|
+
result = self.command_handler(command)
|
|
795
|
+
status = "succeeded" if result.get("ok") else "failed"
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
result = {"ok": False, "error": str(exc)}
|
|
798
|
+
status = "failed"
|
|
799
|
+
self.store.complete_command(command_id, status, result)
|
|
800
|
+
self._post(
|
|
801
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
802
|
+
{"status": status, "result": result}, headers,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
def _run(self) -> None:
|
|
806
|
+
while not self.stop_event.is_set():
|
|
807
|
+
try:
|
|
808
|
+
job = self.jobs.get(timeout=2)
|
|
809
|
+
except queue.Empty:
|
|
810
|
+
job = ()
|
|
811
|
+
if job is None:
|
|
812
|
+
return
|
|
813
|
+
if job:
|
|
814
|
+
path, payload, headers = job
|
|
815
|
+
try:
|
|
816
|
+
self._post(path, payload, headers)
|
|
817
|
+
except Exception as exc:
|
|
818
|
+
log(f"central oversight write failed; event remains local: {exc}")
|
|
819
|
+
try:
|
|
820
|
+
self._sync_durable_state()
|
|
821
|
+
except Exception:
|
|
822
|
+
# Gateway connectivity never changes local voice readiness.
|
|
823
|
+
pass
|
|
824
|
+
|
|
825
|
+
def start_worker(self) -> None:
|
|
826
|
+
if self.worker is None or not self.worker.is_alive():
|
|
827
|
+
self.worker = threading.Thread(target=self._run, name="management-sync", daemon=True)
|
|
828
|
+
self.worker.start()
|
|
829
|
+
|
|
830
|
+
def _enqueue(self, path: str, payload: dict, headers: dict) -> None:
|
|
831
|
+
if not self.enabled:
|
|
832
|
+
return
|
|
833
|
+
self.start_worker()
|
|
834
|
+
try:
|
|
835
|
+
self.jobs.put_nowait((path, payload, headers))
|
|
836
|
+
except queue.Full:
|
|
837
|
+
log("central oversight queue full; dropping telemetry event")
|
|
838
|
+
|
|
839
|
+
def start(self, reason: str) -> None:
|
|
840
|
+
if not self.enabled:
|
|
841
|
+
return
|
|
842
|
+
with self.lock:
|
|
843
|
+
self.session_id = None
|
|
844
|
+
self.event_token = None
|
|
845
|
+
self.sequence = 0
|
|
846
|
+
try:
|
|
847
|
+
result = self._post(
|
|
848
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions",
|
|
849
|
+
{"agent_id": self.agent_id, "branch_id": self.branch_id, "trigger_reason": reason},
|
|
850
|
+
{"Authorization": f"Bearer {self.device_token}"},
|
|
851
|
+
)
|
|
852
|
+
with self.lock:
|
|
853
|
+
self.session_id = str(result.get("session_id") or "") or None
|
|
854
|
+
self.event_token = str(result.get("event_token") or "") or None
|
|
855
|
+
if self.session_id:
|
|
856
|
+
log(f"central oversight session registered: {self.session_id}")
|
|
857
|
+
except Exception as exc:
|
|
858
|
+
log(f"central oversight unavailable; continuing locally: {exc}")
|
|
859
|
+
|
|
860
|
+
def event(self, stage: str, status: str, message: str, details: dict | None = None) -> None:
|
|
861
|
+
pipeline_stage = {
|
|
862
|
+
"session_starting": "voice_worker",
|
|
863
|
+
"provider_connected": "voice_worker",
|
|
864
|
+
"user_transcript": "stt_listening",
|
|
865
|
+
"agent_response": "llm_response",
|
|
866
|
+
"session_error": "voice_worker",
|
|
867
|
+
"session_disposed": "session_ended",
|
|
868
|
+
}.get(stage)
|
|
869
|
+
if pipeline_stage is None:
|
|
870
|
+
return
|
|
871
|
+
pipeline_status = "running" if status == "active" else status
|
|
872
|
+
with self.lock:
|
|
873
|
+
session_id, token = self.session_id, self.event_token
|
|
874
|
+
if not session_id or not token:
|
|
875
|
+
return
|
|
876
|
+
self._enqueue(
|
|
877
|
+
f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/pipeline-events",
|
|
878
|
+
{"stage": pipeline_stage, "status": pipeline_status, "message": message, "source": "robot_hardware", "details": details or {}},
|
|
879
|
+
{"X-RoboPark-Session-Token": token},
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
def turn(self, role: str, text: str) -> None:
|
|
883
|
+
# Transcript delivery is exclusively cursor-based through the durable
|
|
884
|
+
# spool. Posting it here as well produced duplicate historical turns.
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
def end(self, reason: str, duration: float, error: str | None) -> None:
|
|
888
|
+
with self.lock:
|
|
889
|
+
session_id = self.session_id
|
|
890
|
+
self.session_id = None
|
|
891
|
+
self.event_token = None
|
|
892
|
+
if not session_id or not self.enabled:
|
|
893
|
+
return
|
|
894
|
+
self._enqueue(
|
|
895
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions/{urllib.parse.quote(session_id, safe='')}/end",
|
|
896
|
+
{"reason": reason, "duration_seconds": duration, "error": error},
|
|
897
|
+
{"Authorization": f"Bearer {self.device_token}"},
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
class Runtime:
|
|
902
|
+
def __init__(self, config: dict) -> None:
|
|
903
|
+
self.config = config
|
|
904
|
+
# Session disposal is local and deterministic. Recycling the systemd
|
|
905
|
+
# worker after every timeout caused port races and stale ALSA owners.
|
|
906
|
+
self.config["recycle_after_session"] = False
|
|
907
907
|
self.shutdown = threading.Event()
|
|
908
908
|
self.trigger_event = threading.Event()
|
|
909
909
|
self.trigger_reason = "unknown"
|
|
910
910
|
self.state_lock = threading.Lock()
|
|
911
|
-
self.active = False
|
|
912
|
-
self.last_session_end = 0.0
|
|
913
|
-
self.session_started_at = 0.0
|
|
914
|
-
self.sessions_started = 0
|
|
915
|
-
self.sessions_completed = 0
|
|
916
|
-
self.triggers_accepted = 0
|
|
917
|
-
self.triggers_rejected = 0
|
|
918
|
-
self.pending_motion_at = 0.0
|
|
919
|
-
self.last_error: str | None = None
|
|
920
|
-
self.telemetry_last_error: str | None = None
|
|
921
|
-
self.vision_routed = False
|
|
922
|
-
self.vision_last_error: str | None = None
|
|
923
|
-
self.vision_process: subprocess.Popen | None = None
|
|
924
|
-
self.vision_process_started = False
|
|
925
|
-
self.vision_restarts = 0
|
|
926
|
-
self.recycle_required = False
|
|
927
|
-
self.force_stop_event = threading.Event()
|
|
928
|
-
self.paused = False
|
|
929
|
-
self.apply_when_idle_requested = False
|
|
930
|
-
self.restart_when_idle_requested = False
|
|
931
|
-
self.stop_when_idle_requested = False
|
|
932
|
-
self.local_session_id: str | None = None
|
|
933
|
-
self.lifecycle_events: deque[dict] = deque(maxlen=80)
|
|
934
|
-
self.store = SupervisorStore(
|
|
935
|
-
str(config["config_path"]),
|
|
936
|
-
str(config.get("scheduler_device_id") or config.get("robot_name") or "robot"),
|
|
937
|
-
)
|
|
938
|
-
self.paused = bool(self.store.get_state("paused", False))
|
|
939
|
-
self._initialize_configuration_state()
|
|
940
|
-
self.scheduler_reporter = SchedulerReporter(config, self.store)
|
|
941
|
-
self.scheduler_reporter.command_handler = self.handle_management_command
|
|
942
|
-
self.scheduler_reporter.state_provider = self.reconciliation_state
|
|
943
|
-
self.last_session: dict | None = None
|
|
944
|
-
self.last_trigger_reason: str | None = None
|
|
945
|
-
self.audio_devices = audio_inventory()
|
|
946
|
-
self.current_audio: NativeAudioInterface | None = None
|
|
911
|
+
self.active = False
|
|
912
|
+
self.last_session_end = 0.0
|
|
913
|
+
self.session_started_at = 0.0
|
|
914
|
+
self.sessions_started = 0
|
|
915
|
+
self.sessions_completed = 0
|
|
916
|
+
self.triggers_accepted = 0
|
|
917
|
+
self.triggers_rejected = 0
|
|
918
|
+
self.pending_motion_at = 0.0
|
|
919
|
+
self.last_error: str | None = None
|
|
920
|
+
self.telemetry_last_error: str | None = None
|
|
921
|
+
self.vision_routed = False
|
|
922
|
+
self.vision_last_error: str | None = None
|
|
923
|
+
self.vision_process: subprocess.Popen | None = None
|
|
924
|
+
self.vision_process_started = False
|
|
925
|
+
self.vision_restarts = 0
|
|
926
|
+
self.recycle_required = False
|
|
927
|
+
self.force_stop_event = threading.Event()
|
|
928
|
+
self.paused = False
|
|
929
|
+
self.apply_when_idle_requested = False
|
|
930
|
+
self.restart_when_idle_requested = False
|
|
931
|
+
self.stop_when_idle_requested = False
|
|
932
|
+
self.local_session_id: str | None = None
|
|
933
|
+
self.lifecycle_events: deque[dict] = deque(maxlen=80)
|
|
934
|
+
self.store = SupervisorStore(
|
|
935
|
+
str(config["config_path"]),
|
|
936
|
+
str(config.get("scheduler_device_id") or config.get("robot_name") or "robot"),
|
|
937
|
+
)
|
|
938
|
+
self.paused = bool(self.store.get_state("paused", False))
|
|
939
|
+
self._initialize_configuration_state()
|
|
940
|
+
self.scheduler_reporter = SchedulerReporter(config, self.store)
|
|
941
|
+
self.scheduler_reporter.command_handler = self.handle_management_command
|
|
942
|
+
self.scheduler_reporter.state_provider = self.reconciliation_state
|
|
943
|
+
self.last_session: dict | None = None
|
|
944
|
+
self.last_trigger_reason: str | None = None
|
|
945
|
+
self.audio_devices = audio_inventory()
|
|
946
|
+
self.current_audio: NativeAudioInterface | None = None
|
|
947
947
|
self.voice_trigger = VoiceTrigger(
|
|
948
948
|
config["audio_input"], int(config["voice_threshold"]), self.trigger
|
|
949
949
|
)
|
|
950
|
-
self.httpd: ThreadingHTTPServer | None = None
|
|
951
|
-
self.http_thread: threading.Thread | None = None
|
|
952
|
-
self.motor_registry = MotorRegistry(
|
|
950
|
+
self.httpd: ThreadingHTTPServer | None = None
|
|
951
|
+
self.http_thread: threading.Thread | None = None
|
|
952
|
+
self.motor_registry = MotorRegistry(
|
|
953
953
|
config.get("motors") or {},
|
|
954
954
|
bool(config.get("motor_active_high")),
|
|
955
|
-
int(config.get("motor_pulse_ms") or 300),
|
|
956
|
-
)
|
|
957
|
-
self._event("runtime_ready", "ElevenLabs runtime initialized")
|
|
958
|
-
self.scheduler_reporter.start_worker()
|
|
959
|
-
|
|
960
|
-
def _initialize_configuration_state(self) -> None:
|
|
961
|
-
applied = self.store.get_state("applied_configuration")
|
|
962
|
-
if not applied:
|
|
963
|
-
applied = self._configuration_snapshot(
|
|
964
|
-
int(self.config.get("applied_revision") or self.config.get("desired_revision") or 1)
|
|
965
|
-
)
|
|
966
|
-
self.store.set_state("applied_configuration", applied)
|
|
967
|
-
self.store.set_state("last_known_good_configuration", applied)
|
|
968
|
-
self.config["applied_revision"] = int(applied.get("applied_revision") or 1)
|
|
969
|
-
self.config["desired_revision"] = int(
|
|
970
|
-
self.store.get_state("desired_revision", self.config["applied_revision"])
|
|
971
|
-
)
|
|
972
|
-
|
|
973
|
-
def _configuration_snapshot(self, revision: int | None = None) -> dict:
|
|
974
|
-
applied_revision = int(revision or self.config.get("applied_revision") or 1)
|
|
975
|
-
desired_revision = int(self.config.get("desired_revision") or applied_revision)
|
|
976
|
-
return {
|
|
977
|
-
"voice_engine_default": str(self.config.get("voice_engine_default") or "elevenlabs"),
|
|
978
|
-
"robovoice_enabled": bool(self.config.get("robovoice_enabled", True)),
|
|
979
|
-
"allow_session_override": bool(self.config.get("allow_session_override", True)),
|
|
980
|
-
"apply_changes_when_idle": bool(self.config.get("apply_changes_when_idle", True)),
|
|
981
|
-
"desired_revision": desired_revision,
|
|
982
|
-
"applied_revision": applied_revision,
|
|
983
|
-
"character_id": str(self.config.get("character_id") or self.config.get("robot_name") or ""),
|
|
984
|
-
"elevenlabs": {
|
|
985
|
-
"agent_id": str(self.config.get("agent_id") or ""),
|
|
986
|
-
"branch_id": str(self.config.get("branch_id") or "") or None,
|
|
987
|
-
},
|
|
988
|
-
"robovoice": {"server_id": self.config.get("robovoice_server_id")},
|
|
989
|
-
"audio": {
|
|
990
|
-
"input_identity": str(self.config.get("audio_input") or ""),
|
|
991
|
-
"output_identity": str(self.config.get("audio_output") or ""),
|
|
992
|
-
},
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
def configuration(self) -> dict:
|
|
996
|
-
applied = self.store.get_state("applied_configuration", self._configuration_snapshot())
|
|
997
|
-
staged = self.store.get_state("staged_configuration")
|
|
998
|
-
desired_revision = int(self.store.get_state("desired_revision", applied.get("applied_revision", 1)))
|
|
999
|
-
applied["desired_revision"] = desired_revision
|
|
1000
|
-
return {
|
|
1001
|
-
**applied,
|
|
1002
|
-
"desired_revision": desired_revision,
|
|
1003
|
-
"applied_revision": int(applied.get("applied_revision") or 1),
|
|
1004
|
-
"application_state": (
|
|
1005
|
-
"waiting_for_idle" if staged and self.active else
|
|
1006
|
-
"staged" if staged else "applied"
|
|
1007
|
-
),
|
|
1008
|
-
"staged_configuration": staged,
|
|
1009
|
-
"last_known_good_revision": int(
|
|
1010
|
-
(self.store.get_state("last_known_good_configuration") or applied).get("applied_revision") or 1
|
|
1011
|
-
),
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
@staticmethod
|
|
1015
|
-
def _validate_staged_configuration(payload: dict) -> dict:
|
|
1016
|
-
forbidden = {"api_key", "token", "secret", "password", "device_token"}
|
|
1017
|
-
|
|
1018
|
-
def inspect(value, path="configuration"):
|
|
1019
|
-
if isinstance(value, dict):
|
|
1020
|
-
for key, child in value.items():
|
|
1021
|
-
if any(part in str(key).casefold() for part in forbidden):
|
|
1022
|
-
raise ValueError(f"credentials are not accepted in {path}")
|
|
1023
|
-
inspect(child, f"{path}.{key}")
|
|
1024
|
-
elif isinstance(value, list):
|
|
1025
|
-
for child in value:
|
|
1026
|
-
inspect(child, path)
|
|
1027
|
-
|
|
1028
|
-
inspect(payload)
|
|
1029
|
-
engine = str(payload.get("voice_engine_default") or "elevenlabs").casefold()
|
|
1030
|
-
if engine not in {"elevenlabs", "robovoice"}:
|
|
1031
|
-
raise ValueError("voice_engine_default must be elevenlabs or robovoice")
|
|
1032
|
-
elevenlabs = payload.get("elevenlabs") or {}
|
|
1033
|
-
if engine == "elevenlabs" and not str(elevenlabs.get("agent_id") or "").strip():
|
|
1034
|
-
raise ValueError("elevenlabs.agent_id is required")
|
|
1035
|
-
audio = payload.get("audio") or {}
|
|
1036
|
-
if not str(audio.get("input_identity") or "").strip():
|
|
1037
|
-
raise ValueError("audio.input_identity is required")
|
|
1038
|
-
if not str(audio.get("output_identity") or "").strip():
|
|
1039
|
-
raise ValueError("audio.output_identity is required")
|
|
1040
|
-
return payload
|
|
1041
|
-
|
|
1042
|
-
def stage_configuration(self, payload: dict) -> dict:
|
|
1043
|
-
current = self.configuration()
|
|
1044
|
-
merged = {
|
|
1045
|
-
key: value for key, value in current.items()
|
|
1046
|
-
if key not in {"application_state", "staged_configuration", "last_known_good_revision"}
|
|
1047
|
-
}
|
|
1048
|
-
for key in (
|
|
1049
|
-
"voice_engine_default", "robovoice_enabled", "allow_session_override",
|
|
1050
|
-
"apply_changes_when_idle", "character_id", "elevenlabs", "robovoice", "audio",
|
|
1051
|
-
):
|
|
1052
|
-
if key in payload:
|
|
1053
|
-
merged[key] = payload[key]
|
|
1054
|
-
revision = max(
|
|
1055
|
-
int(payload.get("desired_revision") or 0),
|
|
1056
|
-
int(current["desired_revision"]) + 1,
|
|
1057
|
-
)
|
|
1058
|
-
merged["desired_revision"] = revision
|
|
1059
|
-
merged["applied_revision"] = int(current["applied_revision"])
|
|
1060
|
-
self._validate_staged_configuration(merged)
|
|
1061
|
-
self.store.set_state("desired_revision", revision)
|
|
1062
|
-
self.store.set_state("staged_configuration", merged)
|
|
1063
|
-
self.config["desired_revision"] = revision
|
|
1064
|
-
self._event("configuration_staged", f"Configuration revision {revision} staged", "pending")
|
|
1065
|
-
return self.configuration()
|
|
1066
|
-
|
|
1067
|
-
def apply_staged_configuration(self) -> dict:
|
|
1068
|
-
staged = self.store.get_state("staged_configuration")
|
|
1069
|
-
if not staged:
|
|
1070
|
-
return {"ok": True, "configuration": self.configuration(), "changed": False}
|
|
1071
|
-
if self.active:
|
|
1072
|
-
self.apply_when_idle_requested = True
|
|
1073
|
-
return {"ok": True, "waiting_for_idle": True, "configuration": self.configuration()}
|
|
1074
|
-
previous = self._configuration_snapshot(int(self.config.get("applied_revision") or 1))
|
|
1075
|
-
try:
|
|
1076
|
-
self._validate_staged_configuration(staged)
|
|
1077
|
-
audio = staged["audio"]
|
|
1078
|
-
elevenlabs = staged.get("elevenlabs") or {}
|
|
1079
|
-
if staged["voice_engine_default"] == "elevenlabs":
|
|
1080
|
-
pa = pyaudio.PyAudio()
|
|
1081
|
-
try:
|
|
1082
|
-
find_device(pa, audio["input_identity"], "input")
|
|
1083
|
-
find_device(pa, audio["output_identity"], "output")
|
|
1084
|
-
finally:
|
|
1085
|
-
pa.terminate()
|
|
1086
|
-
self.config.update({
|
|
1087
|
-
"voice_engine_default": staged["voice_engine_default"],
|
|
1088
|
-
"robovoice_enabled": bool(staged.get("robovoice_enabled", True)),
|
|
1089
|
-
"allow_session_override": bool(staged.get("allow_session_override", True)),
|
|
1090
|
-
"apply_changes_when_idle": bool(staged.get("apply_changes_when_idle", True)),
|
|
1091
|
-
"character_id": staged.get("character_id"),
|
|
1092
|
-
"agent_id": elevenlabs.get("agent_id") or self.config.get("agent_id"),
|
|
1093
|
-
"branch_id": elevenlabs.get("branch_id"),
|
|
1094
|
-
"audio_input": audio["input_identity"],
|
|
1095
|
-
"audio_output": audio["output_identity"],
|
|
1096
|
-
"desired_revision": int(staged["desired_revision"]),
|
|
1097
|
-
"applied_revision": int(staged["desired_revision"]),
|
|
1098
|
-
})
|
|
1099
|
-
Path(self.config["config_path"]).write_text(
|
|
1100
|
-
json.dumps(self.config, indent=2) + "\n", encoding="utf-8"
|
|
1101
|
-
)
|
|
1102
|
-
applied = self._configuration_snapshot(self.config["applied_revision"])
|
|
1103
|
-
applied["desired_revision"] = self.config["applied_revision"]
|
|
1104
|
-
self.store.set_state("last_known_good_configuration", previous)
|
|
1105
|
-
self.store.set_state("applied_configuration", applied)
|
|
1106
|
-
self.store.set_state("desired_revision", self.config["applied_revision"])
|
|
1107
|
-
self.store.set_state("staged_configuration", None)
|
|
1108
|
-
self.audio_devices = audio_inventory()
|
|
1109
|
-
self.restart_when_idle_requested = True
|
|
1110
|
-
self._event("configuration_applied", f"Configuration revision {self.config['applied_revision']} applied")
|
|
1111
|
-
return {"ok": True, "changed": True, "configuration": self.configuration()}
|
|
1112
|
-
except Exception as exc:
|
|
1113
|
-
self.store.set_state("applied_configuration", previous)
|
|
1114
|
-
self._event("configuration_rollback", f"Staged configuration rejected: {exc}", "failed")
|
|
1115
|
-
return {"ok": False, "error": str(exc), "rolled_back": True, "configuration": self.configuration()}
|
|
1116
|
-
|
|
1117
|
-
def _event(self, stage: str, message: str, status: str = "ok", **details) -> None:
|
|
1118
|
-
event = {
|
|
1119
|
-
"timestamp": time.time(),
|
|
1120
|
-
"stage": stage,
|
|
1121
|
-
"status": status,
|
|
1122
|
-
"message": message,
|
|
1123
|
-
**details,
|
|
1124
|
-
}
|
|
1125
|
-
self.lifecycle_events.append(event)
|
|
1126
|
-
self._spool_event(f"lifecycle.{stage}", event)
|
|
1127
|
-
self.scheduler_reporter.event(stage, status, message, details)
|
|
1128
|
-
|
|
1129
|
-
def _spool_event(self, event_type: str, payload: dict) -> None:
|
|
1130
|
-
try:
|
|
1131
|
-
self.store.append_event(event_type, payload, self.local_session_id)
|
|
1132
|
-
except Exception as exc:
|
|
1133
|
-
# Observability storage can degrade, but it cannot break media or
|
|
1134
|
-
# provider callbacks. Health exposes the failure for management.
|
|
1135
|
-
self.telemetry_last_error = str(exc)
|
|
1136
|
-
log(f"telemetry spool write failed; voice continues locally: {exc}")
|
|
1137
|
-
|
|
1138
|
-
def status(self) -> dict:
|
|
1139
|
-
with self.state_lock:
|
|
1140
|
-
cooldown_remaining = max(
|
|
1141
|
-
0.0,
|
|
1142
|
-
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end),
|
|
1143
|
-
) if self.last_session_end else 0.0
|
|
1144
|
-
return {
|
|
1145
|
-
"engine": "elevenlabs",
|
|
1146
|
-
"effective_engine": self.configuration()["voice_engine_default"],
|
|
1147
|
-
"engine_source": "robot",
|
|
1148
|
-
"fallback_available": bool(self.config.get("robovoice_enabled", True)),
|
|
1149
|
-
"robot_name": self.config.get("robot_name"),
|
|
1150
|
-
"agent_id": self.config.get("agent_id"),
|
|
1151
|
-
"branch_id": self.config.get("branch_id"),
|
|
1152
|
-
"central_oversight": {
|
|
1153
|
-
"enabled": self.scheduler_reporter.enabled,
|
|
1154
|
-
"session_id": self.scheduler_reporter.session_id,
|
|
1155
|
-
"queued_writes": self.scheduler_reporter.jobs.qsize(),
|
|
1156
|
-
"telemetry": self.store.stats(),
|
|
1157
|
-
},
|
|
1158
|
-
"configuration": self.configuration(),
|
|
1159
|
-
"paused": self.paused,
|
|
1160
|
-
"active": self.active,
|
|
1161
|
-
"queued": self.trigger_event.is_set(),
|
|
1162
|
-
"state": (
|
|
1163
|
-
"active" if self.active else
|
|
1164
|
-
("queued" if self.trigger_event.is_set() else
|
|
1165
|
-
("cooldown" if cooldown_remaining else "armed"))
|
|
1166
|
-
),
|
|
1167
|
-
"cooldown_remaining_ms": int(cooldown_remaining * 1000),
|
|
1168
|
-
"sessions_started": self.sessions_started,
|
|
1169
|
-
"sessions_completed": self.sessions_completed,
|
|
1170
|
-
"triggers_accepted": self.triggers_accepted,
|
|
1171
|
-
"triggers_rejected": self.triggers_rejected,
|
|
1172
|
-
"pending_motion": bool(
|
|
1173
|
-
self.pending_motion_at
|
|
1174
|
-
and time.monotonic() - self.pending_motion_at <= PENDING_MOTION_TTL_SECONDS
|
|
1175
|
-
),
|
|
1176
|
-
"last_error": self.last_error,
|
|
1177
|
-
"telemetry_last_error": self.telemetry_last_error,
|
|
1178
|
-
"last_trigger_reason": self.last_trigger_reason,
|
|
1179
|
-
"session_started_at": self.session_started_at,
|
|
1180
|
-
"session_age_seconds": (
|
|
1181
|
-
round(time.monotonic() - self.session_started_at, 1)
|
|
1182
|
-
if self.active and self.session_started_at else 0
|
|
1183
|
-
),
|
|
1184
|
-
"last_session": self.last_session,
|
|
1185
|
-
"lifecycle_events": list(self.lifecycle_events),
|
|
1186
|
-
"vision_routed": self.vision_routed,
|
|
1187
|
-
"vision_last_error": self.vision_last_error,
|
|
1188
|
-
"vision_managed": bool(self.config.get("manage_vision")),
|
|
1189
|
-
"vision_process_running": bool(
|
|
1190
|
-
self.vision_process is not None and self.vision_process.poll() is None
|
|
1191
|
-
),
|
|
1192
|
-
"vision_restarts": self.vision_restarts,
|
|
1193
|
-
"configured_audio": {
|
|
1194
|
-
"input": self.config["audio_input"],
|
|
1195
|
-
"output": self.config["audio_output"],
|
|
1196
|
-
},
|
|
1197
|
-
"active_audio": self.current_audio.status() if self.current_audio else None,
|
|
1198
|
-
"audio_devices": self.audio_devices,
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
def reconciliation_state(self) -> dict:
|
|
1202
|
-
configuration = self.configuration()
|
|
1203
|
-
return {
|
|
1204
|
-
"applied_revision": int(configuration["applied_revision"]),
|
|
1205
|
-
"desired_revision": int(configuration["desired_revision"]),
|
|
1206
|
-
"effective_engine": "elevenlabs",
|
|
1207
|
-
"state": "active" if self.active else "paused" if self.paused else "armed",
|
|
1208
|
-
"session": ({
|
|
1209
|
-
"session_id": self.local_session_id,
|
|
1210
|
-
"trigger_reason": self.last_trigger_reason,
|
|
1211
|
-
} if self.active else None),
|
|
1212
|
-
"health": {
|
|
1213
|
-
"service_running": True,
|
|
1214
|
-
"endpoint_ready": self.http_thread is None or self.http_thread.is_alive(),
|
|
1215
|
-
"session_active": self.active,
|
|
1216
|
-
"telemetry_connected": self.scheduler_reporter.enabled,
|
|
1217
|
-
},
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
def trigger(self, reason: str) -> dict:
|
|
1221
|
-
with self.state_lock:
|
|
1222
|
-
if self.shutdown.is_set():
|
|
1223
|
-
self.triggers_rejected += 1
|
|
1224
|
-
self._event("trigger_rejected", "Runtime is shutting down", "failed", reason=reason)
|
|
1225
|
-
return {"accepted": False, "reason": "shutting_down"}
|
|
1226
|
-
if self.paused:
|
|
1227
|
-
self.triggers_rejected += 1
|
|
1228
|
-
return {"accepted": False, "reason": "stopped"}
|
|
1229
|
-
if self.active:
|
|
1230
|
-
if reason == "motion":
|
|
1231
|
-
self.pending_motion_at = time.monotonic()
|
|
1232
|
-
self.triggers_rejected += 1
|
|
1233
|
-
self._event("trigger_rejected", "Session active; motion retained", "pending", reason=reason)
|
|
1234
|
-
return {"accepted": False, "reason": "active", "pending": reason == "motion"}
|
|
1235
|
-
cooldown_remaining = (
|
|
1236
|
-
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end)
|
|
1237
|
-
if self.last_session_end else 0.0
|
|
1238
|
-
)
|
|
1239
|
-
if cooldown_remaining > 0:
|
|
1240
|
-
if reason == "motion":
|
|
1241
|
-
self.pending_motion_at = time.monotonic()
|
|
1242
|
-
self.triggers_rejected += 1
|
|
1243
|
-
self._event("trigger_rejected", "Cooldown active; motion retained", "pending", reason=reason)
|
|
1244
|
-
return {
|
|
1245
|
-
"accepted": False,
|
|
1246
|
-
"reason": "cooldown",
|
|
1247
|
-
"pending": reason == "motion",
|
|
1248
|
-
"retry_after_ms": int(cooldown_remaining * 1000),
|
|
1249
|
-
}
|
|
1250
|
-
if self.trigger_event.is_set():
|
|
1251
|
-
self.triggers_rejected += 1
|
|
1252
|
-
self._event("trigger_rejected", "A trigger is already queued", "pending", reason=reason)
|
|
1253
|
-
return {"accepted": False, "reason": "already_queued"}
|
|
1254
|
-
self.trigger_reason = reason
|
|
1255
|
-
self.last_trigger_reason = reason
|
|
1256
|
-
self.trigger_event.set()
|
|
1257
|
-
self.triggers_accepted += 1
|
|
1258
|
-
self._event("trigger_accepted", f"{reason} trigger accepted", "ok", reason=reason)
|
|
1259
|
-
return {"accepted": True, "reason": reason}
|
|
1260
|
-
|
|
1261
|
-
def handle_management_command(self, command: dict) -> dict:
|
|
1262
|
-
operation = str(command.get("operation") or "")
|
|
1263
|
-
payload = command.get("payload") or {}
|
|
1264
|
-
force = bool(command.get("force"))
|
|
1265
|
-
if operation == "voice.trigger":
|
|
1266
|
-
self.paused = False
|
|
1267
|
-
self.store.set_state("paused", False)
|
|
1268
|
-
return {"ok": True, **self.trigger(str(payload.get("reason") or "gateway"))}
|
|
1269
|
-
if operation == "voice.configuration.stage":
|
|
1270
|
-
return {"ok": True, "configuration": self.stage_configuration(payload)}
|
|
1271
|
-
if operation == "voice.configuration.apply_when_idle":
|
|
1272
|
-
self.apply_when_idle_requested = True
|
|
1273
|
-
return {"ok": True, "waiting_for_idle": self.active}
|
|
1274
|
-
if operation == "voice.restart_when_idle":
|
|
1275
|
-
self.restart_when_idle_requested = True
|
|
1276
|
-
return {"ok": True, "waiting_for_idle": self.active}
|
|
1277
|
-
if operation == "voice.stop_when_idle":
|
|
1278
|
-
self.stop_when_idle_requested = True
|
|
1279
|
-
return {"ok": True, "waiting_for_idle": self.active}
|
|
1280
|
-
if operation == "voice.configuration.rollback":
|
|
1281
|
-
previous = self.store.get_state("last_known_good_configuration")
|
|
1282
|
-
if not previous:
|
|
1283
|
-
return {"ok": False, "error": "no last-known-good configuration exists"}
|
|
1284
|
-
previous["desired_revision"] = int(self.configuration()["desired_revision"]) + 1
|
|
1285
|
-
self.store.set_state("staged_configuration", previous)
|
|
1286
|
-
self.store.set_state("desired_revision", previous["desired_revision"])
|
|
1287
|
-
self.apply_when_idle_requested = True
|
|
1288
|
-
return {"ok": True, "waiting_for_idle": self.active}
|
|
1289
|
-
if operation == "voice.media.release_stale":
|
|
1290
|
-
if not force:
|
|
1291
|
-
return {"ok": False, "error": "force authorization is required"}
|
|
1292
|
-
lease = media_lease_status()
|
|
1293
|
-
if self.active:
|
|
1294
|
-
return {"ok": False, "error": "active session owns the media lease", "media_lease": lease}
|
|
1295
|
-
if lease["conflicts"]:
|
|
1296
|
-
return {
|
|
1297
|
-
"ok": False,
|
|
1298
|
-
"error": "foreign live process owns ALSA; stop that named service explicitly",
|
|
1299
|
-
"media_lease": lease,
|
|
1300
|
-
}
|
|
1301
|
-
return {"ok": True, "released": True, "media_lease": lease}
|
|
1302
|
-
if operation in {"voice.restart", "voice.stop", "voice.configuration.apply"}:
|
|
1303
|
-
if not force:
|
|
1304
|
-
return {"ok": False, "error": "force authorization is required"}
|
|
1305
|
-
if operation == "voice.restart":
|
|
1306
|
-
self.restart_when_idle_requested = True
|
|
1307
|
-
elif operation == "voice.stop":
|
|
1308
|
-
self.stop_when_idle_requested = True
|
|
1309
|
-
else:
|
|
1310
|
-
self.apply_when_idle_requested = True
|
|
1311
|
-
if self.active:
|
|
1312
|
-
self.force_stop_event.set()
|
|
1313
|
-
return {"ok": True, "disposing": self.active}
|
|
1314
|
-
return {"ok": False, "error": f"unsupported operation: {operation}"}
|
|
955
|
+
int(config.get("motor_pulse_ms") or 300),
|
|
956
|
+
)
|
|
957
|
+
self._event("runtime_ready", "ElevenLabs runtime initialized")
|
|
958
|
+
self.scheduler_reporter.start_worker()
|
|
959
|
+
|
|
960
|
+
def _initialize_configuration_state(self) -> None:
|
|
961
|
+
applied = self.store.get_state("applied_configuration")
|
|
962
|
+
if not applied:
|
|
963
|
+
applied = self._configuration_snapshot(
|
|
964
|
+
int(self.config.get("applied_revision") or self.config.get("desired_revision") or 1)
|
|
965
|
+
)
|
|
966
|
+
self.store.set_state("applied_configuration", applied)
|
|
967
|
+
self.store.set_state("last_known_good_configuration", applied)
|
|
968
|
+
self.config["applied_revision"] = int(applied.get("applied_revision") or 1)
|
|
969
|
+
self.config["desired_revision"] = int(
|
|
970
|
+
self.store.get_state("desired_revision", self.config["applied_revision"])
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
def _configuration_snapshot(self, revision: int | None = None) -> dict:
|
|
974
|
+
applied_revision = int(revision or self.config.get("applied_revision") or 1)
|
|
975
|
+
desired_revision = int(self.config.get("desired_revision") or applied_revision)
|
|
976
|
+
return {
|
|
977
|
+
"voice_engine_default": str(self.config.get("voice_engine_default") or "elevenlabs"),
|
|
978
|
+
"robovoice_enabled": bool(self.config.get("robovoice_enabled", True)),
|
|
979
|
+
"allow_session_override": bool(self.config.get("allow_session_override", True)),
|
|
980
|
+
"apply_changes_when_idle": bool(self.config.get("apply_changes_when_idle", True)),
|
|
981
|
+
"desired_revision": desired_revision,
|
|
982
|
+
"applied_revision": applied_revision,
|
|
983
|
+
"character_id": str(self.config.get("character_id") or self.config.get("robot_name") or ""),
|
|
984
|
+
"elevenlabs": {
|
|
985
|
+
"agent_id": str(self.config.get("agent_id") or ""),
|
|
986
|
+
"branch_id": str(self.config.get("branch_id") or "") or None,
|
|
987
|
+
},
|
|
988
|
+
"robovoice": {"server_id": self.config.get("robovoice_server_id")},
|
|
989
|
+
"audio": {
|
|
990
|
+
"input_identity": str(self.config.get("audio_input") or ""),
|
|
991
|
+
"output_identity": str(self.config.get("audio_output") or ""),
|
|
992
|
+
},
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
def configuration(self) -> dict:
|
|
996
|
+
applied = self.store.get_state("applied_configuration", self._configuration_snapshot())
|
|
997
|
+
staged = self.store.get_state("staged_configuration")
|
|
998
|
+
desired_revision = int(self.store.get_state("desired_revision", applied.get("applied_revision", 1)))
|
|
999
|
+
applied["desired_revision"] = desired_revision
|
|
1000
|
+
return {
|
|
1001
|
+
**applied,
|
|
1002
|
+
"desired_revision": desired_revision,
|
|
1003
|
+
"applied_revision": int(applied.get("applied_revision") or 1),
|
|
1004
|
+
"application_state": (
|
|
1005
|
+
"waiting_for_idle" if staged and self.active else
|
|
1006
|
+
"staged" if staged else "applied"
|
|
1007
|
+
),
|
|
1008
|
+
"staged_configuration": staged,
|
|
1009
|
+
"last_known_good_revision": int(
|
|
1010
|
+
(self.store.get_state("last_known_good_configuration") or applied).get("applied_revision") or 1
|
|
1011
|
+
),
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
@staticmethod
|
|
1015
|
+
def _validate_staged_configuration(payload: dict) -> dict:
|
|
1016
|
+
forbidden = {"api_key", "token", "secret", "password", "device_token"}
|
|
1017
|
+
|
|
1018
|
+
def inspect(value, path="configuration"):
|
|
1019
|
+
if isinstance(value, dict):
|
|
1020
|
+
for key, child in value.items():
|
|
1021
|
+
if any(part in str(key).casefold() for part in forbidden):
|
|
1022
|
+
raise ValueError(f"credentials are not accepted in {path}")
|
|
1023
|
+
inspect(child, f"{path}.{key}")
|
|
1024
|
+
elif isinstance(value, list):
|
|
1025
|
+
for child in value:
|
|
1026
|
+
inspect(child, path)
|
|
1027
|
+
|
|
1028
|
+
inspect(payload)
|
|
1029
|
+
engine = str(payload.get("voice_engine_default") or "elevenlabs").casefold()
|
|
1030
|
+
if engine not in {"elevenlabs", "robovoice"}:
|
|
1031
|
+
raise ValueError("voice_engine_default must be elevenlabs or robovoice")
|
|
1032
|
+
elevenlabs = payload.get("elevenlabs") or {}
|
|
1033
|
+
if engine == "elevenlabs" and not str(elevenlabs.get("agent_id") or "").strip():
|
|
1034
|
+
raise ValueError("elevenlabs.agent_id is required")
|
|
1035
|
+
audio = payload.get("audio") or {}
|
|
1036
|
+
if not str(audio.get("input_identity") or "").strip():
|
|
1037
|
+
raise ValueError("audio.input_identity is required")
|
|
1038
|
+
if not str(audio.get("output_identity") or "").strip():
|
|
1039
|
+
raise ValueError("audio.output_identity is required")
|
|
1040
|
+
return payload
|
|
1041
|
+
|
|
1042
|
+
def stage_configuration(self, payload: dict) -> dict:
|
|
1043
|
+
current = self.configuration()
|
|
1044
|
+
merged = {
|
|
1045
|
+
key: value for key, value in current.items()
|
|
1046
|
+
if key not in {"application_state", "staged_configuration", "last_known_good_revision"}
|
|
1047
|
+
}
|
|
1048
|
+
for key in (
|
|
1049
|
+
"voice_engine_default", "robovoice_enabled", "allow_session_override",
|
|
1050
|
+
"apply_changes_when_idle", "character_id", "elevenlabs", "robovoice", "audio",
|
|
1051
|
+
):
|
|
1052
|
+
if key in payload:
|
|
1053
|
+
merged[key] = payload[key]
|
|
1054
|
+
revision = max(
|
|
1055
|
+
int(payload.get("desired_revision") or 0),
|
|
1056
|
+
int(current["desired_revision"]) + 1,
|
|
1057
|
+
)
|
|
1058
|
+
merged["desired_revision"] = revision
|
|
1059
|
+
merged["applied_revision"] = int(current["applied_revision"])
|
|
1060
|
+
self._validate_staged_configuration(merged)
|
|
1061
|
+
self.store.set_state("desired_revision", revision)
|
|
1062
|
+
self.store.set_state("staged_configuration", merged)
|
|
1063
|
+
self.config["desired_revision"] = revision
|
|
1064
|
+
self._event("configuration_staged", f"Configuration revision {revision} staged", "pending")
|
|
1065
|
+
return self.configuration()
|
|
1066
|
+
|
|
1067
|
+
def apply_staged_configuration(self) -> dict:
|
|
1068
|
+
staged = self.store.get_state("staged_configuration")
|
|
1069
|
+
if not staged:
|
|
1070
|
+
return {"ok": True, "configuration": self.configuration(), "changed": False}
|
|
1071
|
+
if self.active:
|
|
1072
|
+
self.apply_when_idle_requested = True
|
|
1073
|
+
return {"ok": True, "waiting_for_idle": True, "configuration": self.configuration()}
|
|
1074
|
+
previous = self._configuration_snapshot(int(self.config.get("applied_revision") or 1))
|
|
1075
|
+
try:
|
|
1076
|
+
self._validate_staged_configuration(staged)
|
|
1077
|
+
audio = staged["audio"]
|
|
1078
|
+
elevenlabs = staged.get("elevenlabs") or {}
|
|
1079
|
+
if staged["voice_engine_default"] == "elevenlabs":
|
|
1080
|
+
pa = pyaudio.PyAudio()
|
|
1081
|
+
try:
|
|
1082
|
+
find_device(pa, audio["input_identity"], "input")
|
|
1083
|
+
find_device(pa, audio["output_identity"], "output")
|
|
1084
|
+
finally:
|
|
1085
|
+
pa.terminate()
|
|
1086
|
+
self.config.update({
|
|
1087
|
+
"voice_engine_default": staged["voice_engine_default"],
|
|
1088
|
+
"robovoice_enabled": bool(staged.get("robovoice_enabled", True)),
|
|
1089
|
+
"allow_session_override": bool(staged.get("allow_session_override", True)),
|
|
1090
|
+
"apply_changes_when_idle": bool(staged.get("apply_changes_when_idle", True)),
|
|
1091
|
+
"character_id": staged.get("character_id"),
|
|
1092
|
+
"agent_id": elevenlabs.get("agent_id") or self.config.get("agent_id"),
|
|
1093
|
+
"branch_id": elevenlabs.get("branch_id"),
|
|
1094
|
+
"audio_input": audio["input_identity"],
|
|
1095
|
+
"audio_output": audio["output_identity"],
|
|
1096
|
+
"desired_revision": int(staged["desired_revision"]),
|
|
1097
|
+
"applied_revision": int(staged["desired_revision"]),
|
|
1098
|
+
})
|
|
1099
|
+
Path(self.config["config_path"]).write_text(
|
|
1100
|
+
json.dumps(self.config, indent=2) + "\n", encoding="utf-8"
|
|
1101
|
+
)
|
|
1102
|
+
applied = self._configuration_snapshot(self.config["applied_revision"])
|
|
1103
|
+
applied["desired_revision"] = self.config["applied_revision"]
|
|
1104
|
+
self.store.set_state("last_known_good_configuration", previous)
|
|
1105
|
+
self.store.set_state("applied_configuration", applied)
|
|
1106
|
+
self.store.set_state("desired_revision", self.config["applied_revision"])
|
|
1107
|
+
self.store.set_state("staged_configuration", None)
|
|
1108
|
+
self.audio_devices = audio_inventory()
|
|
1109
|
+
self.restart_when_idle_requested = True
|
|
1110
|
+
self._event("configuration_applied", f"Configuration revision {self.config['applied_revision']} applied")
|
|
1111
|
+
return {"ok": True, "changed": True, "configuration": self.configuration()}
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
self.store.set_state("applied_configuration", previous)
|
|
1114
|
+
self._event("configuration_rollback", f"Staged configuration rejected: {exc}", "failed")
|
|
1115
|
+
return {"ok": False, "error": str(exc), "rolled_back": True, "configuration": self.configuration()}
|
|
1116
|
+
|
|
1117
|
+
def _event(self, stage: str, message: str, status: str = "ok", **details) -> None:
|
|
1118
|
+
event = {
|
|
1119
|
+
"timestamp": time.time(),
|
|
1120
|
+
"stage": stage,
|
|
1121
|
+
"status": status,
|
|
1122
|
+
"message": message,
|
|
1123
|
+
**details,
|
|
1124
|
+
}
|
|
1125
|
+
self.lifecycle_events.append(event)
|
|
1126
|
+
self._spool_event(f"lifecycle.{stage}", event)
|
|
1127
|
+
self.scheduler_reporter.event(stage, status, message, details)
|
|
1128
|
+
|
|
1129
|
+
def _spool_event(self, event_type: str, payload: dict) -> None:
|
|
1130
|
+
try:
|
|
1131
|
+
self.store.append_event(event_type, payload, self.local_session_id)
|
|
1132
|
+
except Exception as exc:
|
|
1133
|
+
# Observability storage can degrade, but it cannot break media or
|
|
1134
|
+
# provider callbacks. Health exposes the failure for management.
|
|
1135
|
+
self.telemetry_last_error = str(exc)
|
|
1136
|
+
log(f"telemetry spool write failed; voice continues locally: {exc}")
|
|
1137
|
+
|
|
1138
|
+
def status(self) -> dict:
|
|
1139
|
+
with self.state_lock:
|
|
1140
|
+
cooldown_remaining = max(
|
|
1141
|
+
0.0,
|
|
1142
|
+
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end),
|
|
1143
|
+
) if self.last_session_end else 0.0
|
|
1144
|
+
return {
|
|
1145
|
+
"engine": "elevenlabs",
|
|
1146
|
+
"effective_engine": self.configuration()["voice_engine_default"],
|
|
1147
|
+
"engine_source": "robot",
|
|
1148
|
+
"fallback_available": bool(self.config.get("robovoice_enabled", True)),
|
|
1149
|
+
"robot_name": self.config.get("robot_name"),
|
|
1150
|
+
"agent_id": self.config.get("agent_id"),
|
|
1151
|
+
"branch_id": self.config.get("branch_id"),
|
|
1152
|
+
"central_oversight": {
|
|
1153
|
+
"enabled": self.scheduler_reporter.enabled,
|
|
1154
|
+
"session_id": self.scheduler_reporter.session_id,
|
|
1155
|
+
"queued_writes": self.scheduler_reporter.jobs.qsize(),
|
|
1156
|
+
"telemetry": self.store.stats(),
|
|
1157
|
+
},
|
|
1158
|
+
"configuration": self.configuration(),
|
|
1159
|
+
"paused": self.paused,
|
|
1160
|
+
"active": self.active,
|
|
1161
|
+
"queued": self.trigger_event.is_set(),
|
|
1162
|
+
"state": (
|
|
1163
|
+
"active" if self.active else
|
|
1164
|
+
("queued" if self.trigger_event.is_set() else
|
|
1165
|
+
("cooldown" if cooldown_remaining else "armed"))
|
|
1166
|
+
),
|
|
1167
|
+
"cooldown_remaining_ms": int(cooldown_remaining * 1000),
|
|
1168
|
+
"sessions_started": self.sessions_started,
|
|
1169
|
+
"sessions_completed": self.sessions_completed,
|
|
1170
|
+
"triggers_accepted": self.triggers_accepted,
|
|
1171
|
+
"triggers_rejected": self.triggers_rejected,
|
|
1172
|
+
"pending_motion": bool(
|
|
1173
|
+
self.pending_motion_at
|
|
1174
|
+
and time.monotonic() - self.pending_motion_at <= PENDING_MOTION_TTL_SECONDS
|
|
1175
|
+
),
|
|
1176
|
+
"last_error": self.last_error,
|
|
1177
|
+
"telemetry_last_error": self.telemetry_last_error,
|
|
1178
|
+
"last_trigger_reason": self.last_trigger_reason,
|
|
1179
|
+
"session_started_at": self.session_started_at,
|
|
1180
|
+
"session_age_seconds": (
|
|
1181
|
+
round(time.monotonic() - self.session_started_at, 1)
|
|
1182
|
+
if self.active and self.session_started_at else 0
|
|
1183
|
+
),
|
|
1184
|
+
"last_session": self.last_session,
|
|
1185
|
+
"lifecycle_events": list(self.lifecycle_events),
|
|
1186
|
+
"vision_routed": self.vision_routed,
|
|
1187
|
+
"vision_last_error": self.vision_last_error,
|
|
1188
|
+
"vision_managed": bool(self.config.get("manage_vision")),
|
|
1189
|
+
"vision_process_running": bool(
|
|
1190
|
+
self.vision_process is not None and self.vision_process.poll() is None
|
|
1191
|
+
),
|
|
1192
|
+
"vision_restarts": self.vision_restarts,
|
|
1193
|
+
"configured_audio": {
|
|
1194
|
+
"input": self.config["audio_input"],
|
|
1195
|
+
"output": self.config["audio_output"],
|
|
1196
|
+
},
|
|
1197
|
+
"active_audio": self.current_audio.status() if self.current_audio else None,
|
|
1198
|
+
"audio_devices": self.audio_devices,
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
def reconciliation_state(self) -> dict:
|
|
1202
|
+
configuration = self.configuration()
|
|
1203
|
+
return {
|
|
1204
|
+
"applied_revision": int(configuration["applied_revision"]),
|
|
1205
|
+
"desired_revision": int(configuration["desired_revision"]),
|
|
1206
|
+
"effective_engine": "elevenlabs",
|
|
1207
|
+
"state": "active" if self.active else "paused" if self.paused else "armed",
|
|
1208
|
+
"session": ({
|
|
1209
|
+
"session_id": self.local_session_id,
|
|
1210
|
+
"trigger_reason": self.last_trigger_reason,
|
|
1211
|
+
} if self.active else None),
|
|
1212
|
+
"health": {
|
|
1213
|
+
"service_running": True,
|
|
1214
|
+
"endpoint_ready": self.http_thread is None or self.http_thread.is_alive(),
|
|
1215
|
+
"session_active": self.active,
|
|
1216
|
+
"telemetry_connected": self.scheduler_reporter.enabled,
|
|
1217
|
+
},
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
def trigger(self, reason: str) -> dict:
|
|
1221
|
+
with self.state_lock:
|
|
1222
|
+
if self.shutdown.is_set():
|
|
1223
|
+
self.triggers_rejected += 1
|
|
1224
|
+
self._event("trigger_rejected", "Runtime is shutting down", "failed", reason=reason)
|
|
1225
|
+
return {"accepted": False, "reason": "shutting_down"}
|
|
1226
|
+
if self.paused:
|
|
1227
|
+
self.triggers_rejected += 1
|
|
1228
|
+
return {"accepted": False, "reason": "stopped"}
|
|
1229
|
+
if self.active:
|
|
1230
|
+
if reason == "motion":
|
|
1231
|
+
self.pending_motion_at = time.monotonic()
|
|
1232
|
+
self.triggers_rejected += 1
|
|
1233
|
+
self._event("trigger_rejected", "Session active; motion retained", "pending", reason=reason)
|
|
1234
|
+
return {"accepted": False, "reason": "active", "pending": reason == "motion"}
|
|
1235
|
+
cooldown_remaining = (
|
|
1236
|
+
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end)
|
|
1237
|
+
if self.last_session_end else 0.0
|
|
1238
|
+
)
|
|
1239
|
+
if cooldown_remaining > 0:
|
|
1240
|
+
if reason == "motion":
|
|
1241
|
+
self.pending_motion_at = time.monotonic()
|
|
1242
|
+
self.triggers_rejected += 1
|
|
1243
|
+
self._event("trigger_rejected", "Cooldown active; motion retained", "pending", reason=reason)
|
|
1244
|
+
return {
|
|
1245
|
+
"accepted": False,
|
|
1246
|
+
"reason": "cooldown",
|
|
1247
|
+
"pending": reason == "motion",
|
|
1248
|
+
"retry_after_ms": int(cooldown_remaining * 1000),
|
|
1249
|
+
}
|
|
1250
|
+
if self.trigger_event.is_set():
|
|
1251
|
+
self.triggers_rejected += 1
|
|
1252
|
+
self._event("trigger_rejected", "A trigger is already queued", "pending", reason=reason)
|
|
1253
|
+
return {"accepted": False, "reason": "already_queued"}
|
|
1254
|
+
self.trigger_reason = reason
|
|
1255
|
+
self.last_trigger_reason = reason
|
|
1256
|
+
self.trigger_event.set()
|
|
1257
|
+
self.triggers_accepted += 1
|
|
1258
|
+
self._event("trigger_accepted", f"{reason} trigger accepted", "ok", reason=reason)
|
|
1259
|
+
return {"accepted": True, "reason": reason}
|
|
1260
|
+
|
|
1261
|
+
def handle_management_command(self, command: dict) -> dict:
|
|
1262
|
+
operation = str(command.get("operation") or "")
|
|
1263
|
+
payload = command.get("payload") or {}
|
|
1264
|
+
force = bool(command.get("force"))
|
|
1265
|
+
if operation == "voice.trigger":
|
|
1266
|
+
self.paused = False
|
|
1267
|
+
self.store.set_state("paused", False)
|
|
1268
|
+
return {"ok": True, **self.trigger(str(payload.get("reason") or "gateway"))}
|
|
1269
|
+
if operation == "voice.configuration.stage":
|
|
1270
|
+
return {"ok": True, "configuration": self.stage_configuration(payload)}
|
|
1271
|
+
if operation == "voice.configuration.apply_when_idle":
|
|
1272
|
+
self.apply_when_idle_requested = True
|
|
1273
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1274
|
+
if operation == "voice.restart_when_idle":
|
|
1275
|
+
self.restart_when_idle_requested = True
|
|
1276
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1277
|
+
if operation == "voice.stop_when_idle":
|
|
1278
|
+
self.stop_when_idle_requested = True
|
|
1279
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1280
|
+
if operation == "voice.configuration.rollback":
|
|
1281
|
+
previous = self.store.get_state("last_known_good_configuration")
|
|
1282
|
+
if not previous:
|
|
1283
|
+
return {"ok": False, "error": "no last-known-good configuration exists"}
|
|
1284
|
+
previous["desired_revision"] = int(self.configuration()["desired_revision"]) + 1
|
|
1285
|
+
self.store.set_state("staged_configuration", previous)
|
|
1286
|
+
self.store.set_state("desired_revision", previous["desired_revision"])
|
|
1287
|
+
self.apply_when_idle_requested = True
|
|
1288
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1289
|
+
if operation == "voice.media.release_stale":
|
|
1290
|
+
if not force:
|
|
1291
|
+
return {"ok": False, "error": "force authorization is required"}
|
|
1292
|
+
lease = media_lease_status()
|
|
1293
|
+
if self.active:
|
|
1294
|
+
return {"ok": False, "error": "active session owns the media lease", "media_lease": lease}
|
|
1295
|
+
if lease["conflicts"]:
|
|
1296
|
+
return {
|
|
1297
|
+
"ok": False,
|
|
1298
|
+
"error": "foreign live process owns ALSA; stop that named service explicitly",
|
|
1299
|
+
"media_lease": lease,
|
|
1300
|
+
}
|
|
1301
|
+
return {"ok": True, "released": True, "media_lease": lease}
|
|
1302
|
+
if operation in {"voice.restart", "voice.stop", "voice.configuration.apply"}:
|
|
1303
|
+
if not force:
|
|
1304
|
+
return {"ok": False, "error": "force authorization is required"}
|
|
1305
|
+
if operation == "voice.restart":
|
|
1306
|
+
self.restart_when_idle_requested = True
|
|
1307
|
+
elif operation == "voice.stop":
|
|
1308
|
+
self.stop_when_idle_requested = True
|
|
1309
|
+
else:
|
|
1310
|
+
self.apply_when_idle_requested = True
|
|
1311
|
+
if self.active:
|
|
1312
|
+
self.force_stop_event.set()
|
|
1313
|
+
return {"ok": True, "disposing": self.active}
|
|
1314
|
+
return {"ok": False, "error": f"unsupported operation: {operation}"}
|
|
1315
1315
|
|
|
1316
1316
|
def start_motion_server(self) -> None:
|
|
1317
1317
|
runtime = self
|
|
1318
1318
|
|
|
1319
|
-
class Handler(BaseHTTPRequestHandler):
|
|
1320
|
-
def _remote(self) -> bool:
|
|
1321
|
-
return self.client_address[0] not in {"127.0.0.1", "::1"}
|
|
1322
|
-
|
|
1323
|
-
def _authorized(self) -> bool:
|
|
1324
|
-
if not self._remote():
|
|
1325
|
-
return True
|
|
1326
|
-
supplied = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("token", [""])[0]
|
|
1327
|
-
expected = str(runtime.config.get("tailscale_status_token") or "")
|
|
1328
|
-
return bool(expected) and hmac.compare_digest(supplied, expected)
|
|
1329
|
-
|
|
1330
|
-
def _reply(self, status: int, payload: dict) -> None:
|
|
1319
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1320
|
+
def _remote(self) -> bool:
|
|
1321
|
+
return self.client_address[0] not in {"127.0.0.1", "::1"}
|
|
1322
|
+
|
|
1323
|
+
def _authorized(self) -> bool:
|
|
1324
|
+
if not self._remote():
|
|
1325
|
+
return True
|
|
1326
|
+
supplied = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("token", [""])[0]
|
|
1327
|
+
expected = str(runtime.config.get("tailscale_status_token") or "")
|
|
1328
|
+
return bool(expected) and hmac.compare_digest(supplied, expected)
|
|
1329
|
+
|
|
1330
|
+
def _reply(self, status: int, payload: dict) -> None:
|
|
1331
1331
|
body = json.dumps(payload).encode()
|
|
1332
1332
|
self.send_response(status)
|
|
1333
1333
|
self.send_header("Content-Type", "application/json")
|
|
1334
1334
|
self.send_header("Content-Length", str(len(body)))
|
|
1335
|
-
self.end_headers()
|
|
1336
|
-
self.wfile.write(body)
|
|
1337
|
-
|
|
1338
|
-
def _body(self) -> dict:
|
|
1339
|
-
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1340
|
-
return json.loads(self.rfile.read(length) or b"{}")
|
|
1341
|
-
|
|
1342
|
-
def _force(self) -> bool:
|
|
1343
|
-
value = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("force", [""])[0]
|
|
1344
|
-
return value.casefold() in {"1", "true", "yes"}
|
|
1345
|
-
|
|
1346
|
-
def _html(self) -> None:
|
|
1347
|
-
body = """<!doctype html><meta name=viewport content='width=device-width'><title>RoboPark audio</title><style>body{font:15px ui-monospace,monospace;background:#111;color:#eee;max-width:900px;margin:30px auto;padding:0 16px}h1{color:#ffd86b}select,button{font:inherit;padding:9px;margin:4px;background:#222;color:#fff;border:1px solid #555;border-radius:6px}pre{white-space:pre-wrap;background:#1b1b1b;padding:18px;border-radius:10px}</style><h1>RoboPark active devices</h1><label>Microphone <select id=i></select></label><label>Speaker <select id=o></select></label><button onclick='save()'>Apply and restart</button><pre id=s>loading...</pre><script>const q=location.search;let first=true;async function p(){let r=await fetch('/status'+q),d=await r.json();document.querySelector('#s').textContent=JSON.stringify(d,null,2);if(first&&d.audio_devices){first=false;for(const [id,key] of [['i','inputs'],['o','outputs']]){let e=document.querySelector('#'+id),selected=id==='i'?d.configured_audio.input:d.configured_audio.output;for(const x of d.audio_devices[key])e.add(new Option(x.name,x.name,x.name===selected,x.name===selected))}}}async function save(){let body={input:document.querySelector('#i').value,output:document.querySelector('#o').value};let r=await fetch('/devices'+q,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});alert(JSON.stringify(await r.json()))}p();setInterval(p,2000)</script>""".encode()
|
|
1348
|
-
self.send_response(200)
|
|
1349
|
-
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1350
|
-
self.send_header("Content-Length", str(len(body)))
|
|
1351
|
-
self.end_headers()
|
|
1352
|
-
self.wfile.write(body)
|
|
1353
|
-
|
|
1354
|
-
def do_GET(self):
|
|
1355
|
-
if not self._authorized():
|
|
1356
|
-
self._reply(403, {"ok": False, "error": "invalid or missing device-status token"})
|
|
1357
|
-
return
|
|
1358
|
-
parsed = urllib.parse.urlparse(self.path)
|
|
1359
|
-
path = parsed.path
|
|
1360
|
-
if path == "/" and "text/html" in self.headers.get("Accept", ""):
|
|
1361
|
-
self._html()
|
|
1362
|
-
return
|
|
1363
|
-
state = runtime.status()
|
|
1364
|
-
if path == "/health":
|
|
1365
|
-
configured = state["configured_audio"]
|
|
1366
|
-
input_names = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1367
|
-
output_names = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1368
|
-
lease = media_lease_status()
|
|
1369
|
-
self._reply(200, {
|
|
1370
|
-
"ok": True,
|
|
1371
|
-
"voice_ready": (
|
|
1372
|
-
configured["input"] in input_names
|
|
1373
|
-
and configured["output"] in output_names
|
|
1374
|
-
and lease["available"]
|
|
1375
|
-
and not runtime.paused
|
|
1376
|
-
),
|
|
1377
|
-
"service_running": True,
|
|
1378
|
-
"endpoint_ready": True,
|
|
1379
|
-
"audio_input_resolved": configured["input"] in input_names,
|
|
1380
|
-
"audio_output_resolved": configured["output"] in output_names,
|
|
1381
|
-
"media_lease_available": lease["available"],
|
|
1382
|
-
"provider_reachable": runtime.last_error is None,
|
|
1383
|
-
"configuration_valid": runtime.store.get_state("staged_configuration") is None,
|
|
1384
|
-
"trigger_armed": not runtime.paused,
|
|
1385
|
-
"session_active": runtime.active,
|
|
1386
|
-
"telemetry_connected": runtime.scheduler_reporter.enabled,
|
|
1387
|
-
"telemetry_mode": "connected" if runtime.scheduler_reporter.enabled else "buffering_locally",
|
|
1388
|
-
"media_lease": lease,
|
|
1389
|
-
})
|
|
1390
|
-
return
|
|
1391
|
-
if path == "/configuration":
|
|
1392
|
-
self._reply(200, {"ok": True, **runtime.configuration()})
|
|
1393
|
-
return
|
|
1394
|
-
if path == "/sessions/current":
|
|
1395
|
-
self._reply(200, {
|
|
1396
|
-
"ok": True,
|
|
1397
|
-
"active": runtime.active,
|
|
1398
|
-
"session": ({
|
|
1399
|
-
"session_id": runtime.local_session_id,
|
|
1400
|
-
"started_at": runtime.session_started_at,
|
|
1401
|
-
"trigger_reason": runtime.last_trigger_reason,
|
|
1402
|
-
"engine": "elevenlabs",
|
|
1403
|
-
"agent_id": runtime.config.get("agent_id"),
|
|
1404
|
-
} if runtime.active else None),
|
|
1405
|
-
"last_session": runtime.last_session,
|
|
1406
|
-
})
|
|
1407
|
-
return
|
|
1408
|
-
if path == "/events":
|
|
1409
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
1410
|
-
after = int(query.get("after", ["0"])[0] or 0)
|
|
1411
|
-
limit = int(query.get("limit", ["250"])[0] or 250)
|
|
1412
|
-
events = runtime.store.events_after(after, limit)
|
|
1413
|
-
self._reply(200, {
|
|
1414
|
-
"ok": True,
|
|
1415
|
-
"events": events,
|
|
1416
|
-
"next_cursor": events[-1]["sequence"] if events else after,
|
|
1417
|
-
"spool": runtime.store.stats(),
|
|
1418
|
-
})
|
|
1419
|
-
return
|
|
1420
|
-
if path not in {"/", "/status", "/state"}:
|
|
1421
|
-
self._reply(404, {"ok": False, "error": "unknown management endpoint"})
|
|
1422
|
-
return
|
|
1423
|
-
self._reply(200, {
|
|
1424
|
-
"ok": True,
|
|
1425
|
-
"armed": True,
|
|
1426
|
-
"motors": runtime.motor_registry.motors,
|
|
1427
|
-
**state,
|
|
1428
|
-
})
|
|
1429
|
-
|
|
1430
|
-
def do_POST(self):
|
|
1431
|
-
parsed_path = urllib.parse.urlparse(self.path)
|
|
1432
|
-
path = parsed_path.path
|
|
1433
|
-
if parsed_path.path == "/devices" and self._authorized():
|
|
1434
|
-
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1435
|
-
try:
|
|
1436
|
-
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
1437
|
-
selected_input = str(payload.get("input") or "").strip()
|
|
1438
|
-
selected_output = str(payload.get("output") or "").strip()
|
|
1439
|
-
valid_inputs = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1440
|
-
valid_outputs = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1441
|
-
if selected_input not in valid_inputs or selected_output not in valid_outputs:
|
|
1442
|
-
raise ValueError("selection must use devices currently registered by PyAudio")
|
|
1443
|
-
runtime.config["audio_input"] = selected_input
|
|
1444
|
-
runtime.config["audio_output"] = selected_output
|
|
1445
|
-
Path(runtime.config["config_path"]).write_text(
|
|
1446
|
-
json.dumps(runtime.config, indent=2) + "\n", encoding="utf-8"
|
|
1447
|
-
)
|
|
1448
|
-
self._reply(202, {"ok": True, "input": selected_input, "output": selected_output, "restarting": True})
|
|
1449
|
-
threading.Timer(0.2, runtime.shutdown.set).start()
|
|
1450
|
-
except Exception as exc:
|
|
1451
|
-
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1452
|
-
return
|
|
1453
|
-
if self._remote():
|
|
1454
|
-
self._reply(403, {"ok": False, "error": "tailnet status endpoint is read-only"})
|
|
1455
|
-
return
|
|
1456
|
-
if path == "/trigger":
|
|
1457
|
-
if not runtime.config["motion_enabled"]:
|
|
1458
|
-
self._reply(409, {"ok": False, "error": "motion trigger is disabled"})
|
|
1459
|
-
return
|
|
1460
|
-
body = self._body()
|
|
1461
|
-
runtime.paused = False
|
|
1462
|
-
runtime.store.set_state("paused", False)
|
|
1463
|
-
admission = runtime.trigger(str(body.get("reason") or "management_test"))
|
|
1464
|
-
self._reply(202 if admission["accepted"] else 409, {"ok": admission["accepted"], **admission})
|
|
1465
|
-
return
|
|
1466
|
-
if path == "/configuration/stage":
|
|
1467
|
-
try:
|
|
1468
|
-
self._reply(202, {"ok": True, **runtime.stage_configuration(self._body())})
|
|
1469
|
-
except Exception as exc:
|
|
1470
|
-
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1471
|
-
return
|
|
1472
|
-
if path == "/configuration/apply-when-idle":
|
|
1473
|
-
runtime.apply_when_idle_requested = True
|
|
1474
|
-
result = runtime.apply_staged_configuration() if not runtime.active else {
|
|
1475
|
-
"ok": True, "waiting_for_idle": True, "configuration": runtime.configuration()
|
|
1476
|
-
}
|
|
1477
|
-
self._reply(202, result)
|
|
1478
|
-
return
|
|
1479
|
-
if path == "/configuration/apply":
|
|
1480
|
-
if not self._force():
|
|
1481
|
-
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1482
|
-
return
|
|
1483
|
-
runtime.apply_when_idle_requested = True
|
|
1484
|
-
if runtime.active:
|
|
1485
|
-
runtime.force_stop_event.set()
|
|
1486
|
-
self._reply(202, {"ok": True, "disposing": runtime.active, "configuration": runtime.configuration()})
|
|
1487
|
-
return
|
|
1488
|
-
if path == "/restart-when-idle":
|
|
1489
|
-
runtime.restart_when_idle_requested = True
|
|
1490
|
-
runtime._event("restart_requested", "Restart scheduled for idle", "pending")
|
|
1491
|
-
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1492
|
-
return
|
|
1493
|
-
if path == "/restart":
|
|
1494
|
-
if not self._force():
|
|
1495
|
-
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1496
|
-
return
|
|
1497
|
-
runtime.restart_when_idle_requested = True
|
|
1498
|
-
if runtime.active:
|
|
1499
|
-
runtime.force_stop_event.set()
|
|
1500
|
-
self._reply(202, {"ok": True, "disposing": runtime.active})
|
|
1501
|
-
return
|
|
1502
|
-
if path == "/stop":
|
|
1503
|
-
runtime.stop_when_idle_requested = True
|
|
1504
|
-
if self._force() and runtime.active:
|
|
1505
|
-
runtime.force_stop_event.set()
|
|
1506
|
-
elif runtime.active:
|
|
1507
|
-
self._reply(202, {"ok": True, "waiting_for_idle": True})
|
|
1508
|
-
return
|
|
1509
|
-
runtime.paused = True
|
|
1510
|
-
runtime.store.set_state("paused", True)
|
|
1511
|
-
runtime.trigger_event.clear()
|
|
1512
|
-
runtime.voice_trigger.stop()
|
|
1513
|
-
runtime._event("runtime_stopped", "Voice triggers paused", "pending")
|
|
1514
|
-
self._reply(202, {"ok": True, "paused": True})
|
|
1515
|
-
return
|
|
1516
|
-
if path == "/events/ack":
|
|
1517
|
-
body = self._body()
|
|
1518
|
-
cursor = runtime.store.acknowledge(int(body.get("sequence") or 0))
|
|
1519
|
-
self._reply(200, {"ok": True, "acknowledged_sequence": cursor})
|
|
1520
|
-
return
|
|
1521
|
-
if path in {"/session/stop", "/recover"}:
|
|
1522
|
-
if not self._force():
|
|
1523
|
-
runtime.stop_when_idle_requested = True
|
|
1524
|
-
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1525
|
-
return
|
|
1526
|
-
runtime.force_stop_event.set()
|
|
1527
|
-
if runtime.current_audio is not None:
|
|
1528
|
-
runtime.current_audio.stop()
|
|
1529
|
-
runtime.pending_motion_at = 0.0
|
|
1530
|
-
runtime.trigger_event.clear()
|
|
1531
|
-
stage = "recovery_requested" if path == "/recover" else "dispose_requested"
|
|
1532
|
-
runtime._event(stage, "Audio released and session disposal requested", "pending")
|
|
1533
|
-
self._reply(202, {
|
|
1534
|
-
"ok": True,
|
|
1535
|
-
"disposing": runtime.active,
|
|
1536
|
-
"audio_released": True,
|
|
1537
|
-
})
|
|
1538
|
-
return
|
|
1539
|
-
if path.startswith("/motors/"):
|
|
1540
|
-
name = path.split("/", 2)[2]
|
|
1335
|
+
self.end_headers()
|
|
1336
|
+
self.wfile.write(body)
|
|
1337
|
+
|
|
1338
|
+
def _body(self) -> dict:
|
|
1339
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1340
|
+
return json.loads(self.rfile.read(length) or b"{}")
|
|
1341
|
+
|
|
1342
|
+
def _force(self) -> bool:
|
|
1343
|
+
value = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("force", [""])[0]
|
|
1344
|
+
return value.casefold() in {"1", "true", "yes"}
|
|
1345
|
+
|
|
1346
|
+
def _html(self) -> None:
|
|
1347
|
+
body = """<!doctype html><meta name=viewport content='width=device-width'><title>RoboPark audio</title><style>body{font:15px ui-monospace,monospace;background:#111;color:#eee;max-width:900px;margin:30px auto;padding:0 16px}h1{color:#ffd86b}select,button{font:inherit;padding:9px;margin:4px;background:#222;color:#fff;border:1px solid #555;border-radius:6px}pre{white-space:pre-wrap;background:#1b1b1b;padding:18px;border-radius:10px}</style><h1>RoboPark active devices</h1><label>Microphone <select id=i></select></label><label>Speaker <select id=o></select></label><button onclick='save()'>Apply and restart</button><pre id=s>loading...</pre><script>const q=location.search;let first=true;async function p(){let r=await fetch('/status'+q),d=await r.json();document.querySelector('#s').textContent=JSON.stringify(d,null,2);if(first&&d.audio_devices){first=false;for(const [id,key] of [['i','inputs'],['o','outputs']]){let e=document.querySelector('#'+id),selected=id==='i'?d.configured_audio.input:d.configured_audio.output;for(const x of d.audio_devices[key])e.add(new Option(x.name,x.name,x.name===selected,x.name===selected))}}}async function save(){let body={input:document.querySelector('#i').value,output:document.querySelector('#o').value};let r=await fetch('/devices'+q,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});alert(JSON.stringify(await r.json()))}p();setInterval(p,2000)</script>""".encode()
|
|
1348
|
+
self.send_response(200)
|
|
1349
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1350
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1351
|
+
self.end_headers()
|
|
1352
|
+
self.wfile.write(body)
|
|
1353
|
+
|
|
1354
|
+
def do_GET(self):
|
|
1355
|
+
if not self._authorized():
|
|
1356
|
+
self._reply(403, {"ok": False, "error": "invalid or missing device-status token"})
|
|
1357
|
+
return
|
|
1358
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
1359
|
+
path = parsed.path
|
|
1360
|
+
if path == "/" and "text/html" in self.headers.get("Accept", ""):
|
|
1361
|
+
self._html()
|
|
1362
|
+
return
|
|
1363
|
+
state = runtime.status()
|
|
1364
|
+
if path == "/health":
|
|
1365
|
+
configured = state["configured_audio"]
|
|
1366
|
+
input_names = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1367
|
+
output_names = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1368
|
+
lease = media_lease_status()
|
|
1369
|
+
self._reply(200, {
|
|
1370
|
+
"ok": True,
|
|
1371
|
+
"voice_ready": (
|
|
1372
|
+
configured["input"] in input_names
|
|
1373
|
+
and configured["output"] in output_names
|
|
1374
|
+
and lease["available"]
|
|
1375
|
+
and not runtime.paused
|
|
1376
|
+
),
|
|
1377
|
+
"service_running": True,
|
|
1378
|
+
"endpoint_ready": True,
|
|
1379
|
+
"audio_input_resolved": configured["input"] in input_names,
|
|
1380
|
+
"audio_output_resolved": configured["output"] in output_names,
|
|
1381
|
+
"media_lease_available": lease["available"],
|
|
1382
|
+
"provider_reachable": runtime.last_error is None,
|
|
1383
|
+
"configuration_valid": runtime.store.get_state("staged_configuration") is None,
|
|
1384
|
+
"trigger_armed": not runtime.paused,
|
|
1385
|
+
"session_active": runtime.active,
|
|
1386
|
+
"telemetry_connected": runtime.scheduler_reporter.enabled,
|
|
1387
|
+
"telemetry_mode": "connected" if runtime.scheduler_reporter.enabled else "buffering_locally",
|
|
1388
|
+
"media_lease": lease,
|
|
1389
|
+
})
|
|
1390
|
+
return
|
|
1391
|
+
if path == "/configuration":
|
|
1392
|
+
self._reply(200, {"ok": True, **runtime.configuration()})
|
|
1393
|
+
return
|
|
1394
|
+
if path == "/sessions/current":
|
|
1395
|
+
self._reply(200, {
|
|
1396
|
+
"ok": True,
|
|
1397
|
+
"active": runtime.active,
|
|
1398
|
+
"session": ({
|
|
1399
|
+
"session_id": runtime.local_session_id,
|
|
1400
|
+
"started_at": runtime.session_started_at,
|
|
1401
|
+
"trigger_reason": runtime.last_trigger_reason,
|
|
1402
|
+
"engine": "elevenlabs",
|
|
1403
|
+
"agent_id": runtime.config.get("agent_id"),
|
|
1404
|
+
} if runtime.active else None),
|
|
1405
|
+
"last_session": runtime.last_session,
|
|
1406
|
+
})
|
|
1407
|
+
return
|
|
1408
|
+
if path == "/events":
|
|
1409
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1410
|
+
after = int(query.get("after", ["0"])[0] or 0)
|
|
1411
|
+
limit = int(query.get("limit", ["250"])[0] or 250)
|
|
1412
|
+
events = runtime.store.events_after(after, limit)
|
|
1413
|
+
self._reply(200, {
|
|
1414
|
+
"ok": True,
|
|
1415
|
+
"events": events,
|
|
1416
|
+
"next_cursor": events[-1]["sequence"] if events else after,
|
|
1417
|
+
"spool": runtime.store.stats(),
|
|
1418
|
+
})
|
|
1419
|
+
return
|
|
1420
|
+
if path not in {"/", "/status", "/state"}:
|
|
1421
|
+
self._reply(404, {"ok": False, "error": "unknown management endpoint"})
|
|
1422
|
+
return
|
|
1423
|
+
self._reply(200, {
|
|
1424
|
+
"ok": True,
|
|
1425
|
+
"armed": True,
|
|
1426
|
+
"motors": runtime.motor_registry.motors,
|
|
1427
|
+
**state,
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
def do_POST(self):
|
|
1431
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
1432
|
+
path = parsed_path.path
|
|
1433
|
+
if parsed_path.path == "/devices" and self._authorized():
|
|
1434
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1435
|
+
try:
|
|
1436
|
+
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
1437
|
+
selected_input = str(payload.get("input") or "").strip()
|
|
1438
|
+
selected_output = str(payload.get("output") or "").strip()
|
|
1439
|
+
valid_inputs = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1440
|
+
valid_outputs = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1441
|
+
if selected_input not in valid_inputs or selected_output not in valid_outputs:
|
|
1442
|
+
raise ValueError("selection must use devices currently registered by PyAudio")
|
|
1443
|
+
runtime.config["audio_input"] = selected_input
|
|
1444
|
+
runtime.config["audio_output"] = selected_output
|
|
1445
|
+
Path(runtime.config["config_path"]).write_text(
|
|
1446
|
+
json.dumps(runtime.config, indent=2) + "\n", encoding="utf-8"
|
|
1447
|
+
)
|
|
1448
|
+
self._reply(202, {"ok": True, "input": selected_input, "output": selected_output, "restarting": True})
|
|
1449
|
+
threading.Timer(0.2, runtime.shutdown.set).start()
|
|
1450
|
+
except Exception as exc:
|
|
1451
|
+
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1452
|
+
return
|
|
1453
|
+
if self._remote():
|
|
1454
|
+
self._reply(403, {"ok": False, "error": "tailnet status endpoint is read-only"})
|
|
1455
|
+
return
|
|
1456
|
+
if path == "/trigger":
|
|
1457
|
+
if not runtime.config["motion_enabled"]:
|
|
1458
|
+
self._reply(409, {"ok": False, "error": "motion trigger is disabled"})
|
|
1459
|
+
return
|
|
1460
|
+
body = self._body()
|
|
1461
|
+
runtime.paused = False
|
|
1462
|
+
runtime.store.set_state("paused", False)
|
|
1463
|
+
admission = runtime.trigger(str(body.get("reason") or "management_test"))
|
|
1464
|
+
self._reply(202 if admission["accepted"] else 409, {"ok": admission["accepted"], **admission})
|
|
1465
|
+
return
|
|
1466
|
+
if path == "/configuration/stage":
|
|
1467
|
+
try:
|
|
1468
|
+
self._reply(202, {"ok": True, **runtime.stage_configuration(self._body())})
|
|
1469
|
+
except Exception as exc:
|
|
1470
|
+
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1471
|
+
return
|
|
1472
|
+
if path == "/configuration/apply-when-idle":
|
|
1473
|
+
runtime.apply_when_idle_requested = True
|
|
1474
|
+
result = runtime.apply_staged_configuration() if not runtime.active else {
|
|
1475
|
+
"ok": True, "waiting_for_idle": True, "configuration": runtime.configuration()
|
|
1476
|
+
}
|
|
1477
|
+
self._reply(202, result)
|
|
1478
|
+
return
|
|
1479
|
+
if path == "/configuration/apply":
|
|
1480
|
+
if not self._force():
|
|
1481
|
+
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1482
|
+
return
|
|
1483
|
+
runtime.apply_when_idle_requested = True
|
|
1484
|
+
if runtime.active:
|
|
1485
|
+
runtime.force_stop_event.set()
|
|
1486
|
+
self._reply(202, {"ok": True, "disposing": runtime.active, "configuration": runtime.configuration()})
|
|
1487
|
+
return
|
|
1488
|
+
if path == "/restart-when-idle":
|
|
1489
|
+
runtime.restart_when_idle_requested = True
|
|
1490
|
+
runtime._event("restart_requested", "Restart scheduled for idle", "pending")
|
|
1491
|
+
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1492
|
+
return
|
|
1493
|
+
if path == "/restart":
|
|
1494
|
+
if not self._force():
|
|
1495
|
+
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1496
|
+
return
|
|
1497
|
+
runtime.restart_when_idle_requested = True
|
|
1498
|
+
if runtime.active:
|
|
1499
|
+
runtime.force_stop_event.set()
|
|
1500
|
+
self._reply(202, {"ok": True, "disposing": runtime.active})
|
|
1501
|
+
return
|
|
1502
|
+
if path == "/stop":
|
|
1503
|
+
runtime.stop_when_idle_requested = True
|
|
1504
|
+
if self._force() and runtime.active:
|
|
1505
|
+
runtime.force_stop_event.set()
|
|
1506
|
+
elif runtime.active:
|
|
1507
|
+
self._reply(202, {"ok": True, "waiting_for_idle": True})
|
|
1508
|
+
return
|
|
1509
|
+
runtime.paused = True
|
|
1510
|
+
runtime.store.set_state("paused", True)
|
|
1511
|
+
runtime.trigger_event.clear()
|
|
1512
|
+
runtime.voice_trigger.stop()
|
|
1513
|
+
runtime._event("runtime_stopped", "Voice triggers paused", "pending")
|
|
1514
|
+
self._reply(202, {"ok": True, "paused": True})
|
|
1515
|
+
return
|
|
1516
|
+
if path == "/events/ack":
|
|
1517
|
+
body = self._body()
|
|
1518
|
+
cursor = runtime.store.acknowledge(int(body.get("sequence") or 0))
|
|
1519
|
+
self._reply(200, {"ok": True, "acknowledged_sequence": cursor})
|
|
1520
|
+
return
|
|
1521
|
+
if path in {"/session/stop", "/recover"}:
|
|
1522
|
+
if not self._force():
|
|
1523
|
+
runtime.stop_when_idle_requested = True
|
|
1524
|
+
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1525
|
+
return
|
|
1526
|
+
runtime.force_stop_event.set()
|
|
1527
|
+
if runtime.current_audio is not None:
|
|
1528
|
+
runtime.current_audio.stop()
|
|
1529
|
+
runtime.pending_motion_at = 0.0
|
|
1530
|
+
runtime.trigger_event.clear()
|
|
1531
|
+
stage = "recovery_requested" if path == "/recover" else "dispose_requested"
|
|
1532
|
+
runtime._event(stage, "Audio released and session disposal requested", "pending")
|
|
1533
|
+
self._reply(202, {
|
|
1534
|
+
"ok": True,
|
|
1535
|
+
"disposing": runtime.active,
|
|
1536
|
+
"audio_released": True,
|
|
1537
|
+
})
|
|
1538
|
+
return
|
|
1539
|
+
if path.startswith("/motors/"):
|
|
1540
|
+
name = path.split("/", 2)[2]
|
|
1541
1541
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1542
1542
|
try:
|
|
1543
1543
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
@@ -1549,229 +1549,229 @@ class Runtime:
|
|
|
1549
1549
|
if not runtime.config["motion_enabled"]:
|
|
1550
1550
|
self._reply(404, {"ok": False, "error": "motion trigger is disabled"})
|
|
1551
1551
|
return
|
|
1552
|
-
admission = runtime.trigger("motion")
|
|
1553
|
-
self._reply(202, {
|
|
1554
|
-
"ok": True,
|
|
1555
|
-
"queued": admission["accepted"],
|
|
1556
|
-
**admission,
|
|
1557
|
-
})
|
|
1552
|
+
admission = runtime.trigger("motion")
|
|
1553
|
+
self._reply(202, {
|
|
1554
|
+
"ok": True,
|
|
1555
|
+
"queued": admission["accepted"],
|
|
1556
|
+
**admission,
|
|
1557
|
+
})
|
|
1558
1558
|
|
|
1559
1559
|
def log_message(self, format, *args):
|
|
1560
1560
|
return
|
|
1561
1561
|
|
|
1562
|
-
ThreadingHTTPServer.allow_reuse_address = True
|
|
1563
|
-
ThreadingHTTPServer.daemon_threads = True
|
|
1564
|
-
self.httpd = ThreadingHTTPServer(
|
|
1565
|
-
(self.config.get("status_bind_host") or self.config["motion_host"], int(self.config["motion_port"])), Handler
|
|
1566
|
-
)
|
|
1567
|
-
self.http_thread = threading.Thread(
|
|
1568
|
-
target=self.httpd.serve_forever,
|
|
1569
|
-
name="motion-webhook",
|
|
1570
|
-
daemon=True,
|
|
1571
|
-
)
|
|
1572
|
-
self.http_thread.start()
|
|
1573
|
-
log(f"robot control endpoint armed at http://{self.config['motion_host']}:{self.config['motion_port']}/")
|
|
1574
|
-
if self.config.get("tailscale_status_ip"):
|
|
1575
|
-
log(
|
|
1576
|
-
f"tailnet device selector: http://{self.config['tailscale_status_ip']}:"
|
|
1577
|
-
f"{self.config['motion_port']}/?token={self.config['tailscale_status_token']}"
|
|
1578
|
-
)
|
|
1579
|
-
if self.config.get("vision_url") and self.config["motion_enabled"]:
|
|
1580
|
-
threading.Thread(
|
|
1581
|
-
target=self._maintain_vision_route,
|
|
1582
|
-
name="robovision-router",
|
|
1583
|
-
daemon=True,
|
|
1584
|
-
).start()
|
|
1585
|
-
|
|
1586
|
-
def _maintain_vision_route(self) -> None:
|
|
1587
|
-
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1588
|
-
payload = json.dumps({"url": target}).encode()
|
|
1589
|
-
arm_payload = json.dumps({"active": True}).encode()
|
|
1590
|
-
last_result = None
|
|
1591
|
-
while not self.shutdown.is_set():
|
|
1592
|
-
try:
|
|
1593
|
-
for endpoint, body in (
|
|
1594
|
-
("/api/motion/webhook", payload),
|
|
1595
|
-
("/api/motion/toggle", arm_payload),
|
|
1596
|
-
):
|
|
1597
|
-
request = urllib.request.Request(
|
|
1598
|
-
f"{self.config['vision_url']}{endpoint}",
|
|
1599
|
-
data=body,
|
|
1600
|
-
headers={"Content-Type": "application/json"},
|
|
1601
|
-
method="POST",
|
|
1602
|
-
)
|
|
1603
|
-
with urllib.request.urlopen(request, timeout=3) as response:
|
|
1604
|
-
if response.status >= 300:
|
|
1605
|
-
raise RuntimeError(f"{endpoint} returned HTTP {response.status}")
|
|
1606
|
-
with self.state_lock:
|
|
1607
|
-
self.vision_routed = True
|
|
1608
|
-
self.vision_last_error = None
|
|
1609
|
-
if last_result is not True:
|
|
1610
|
-
log(f"RoboVision motion armed and routed to {target}")
|
|
1611
|
-
last_result = True
|
|
1612
|
-
# Reassert frequently enough to recover from a replaced camera
|
|
1613
|
-
# process without waiting for an operator or a full service restart.
|
|
1614
|
-
delay = 5
|
|
1615
|
-
except Exception as exc:
|
|
1616
|
-
with self.state_lock:
|
|
1617
|
-
self.vision_routed = False
|
|
1618
|
-
self.vision_last_error = str(exc)
|
|
1619
|
-
if last_result is not False:
|
|
1620
|
-
log(f"RoboVision webhook configuration failed; retrying: {exc}")
|
|
1621
|
-
last_result = False
|
|
1622
|
-
if self.config.get("manage_vision"):
|
|
1623
|
-
self._ensure_vision_process()
|
|
1624
|
-
delay = 2
|
|
1625
|
-
self.shutdown.wait(delay)
|
|
1626
|
-
|
|
1627
|
-
def _ensure_vision_process(self) -> None:
|
|
1628
|
-
process = self.vision_process
|
|
1629
|
-
if process is not None and process.poll() is None:
|
|
1630
|
-
return
|
|
1631
|
-
if process is not None:
|
|
1632
|
-
log(f"managed RoboVision exited with code {process.returncode}; restarting")
|
|
1633
|
-
script = self.config.get("vision_script")
|
|
1634
|
-
python = self.config.get("vision_python")
|
|
1635
|
-
if not script or not python:
|
|
1636
|
-
with self.state_lock:
|
|
1637
|
-
self.vision_last_error = "managed RoboVision runtime path is missing"
|
|
1638
|
-
return
|
|
1639
|
-
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1640
|
-
env = os.environ.copy()
|
|
1641
|
-
env["PYTHONUNBUFFERED"] = "1"
|
|
1642
|
-
env["ROBOPARK_CAMERA_DEVICE"] = str(self.config.get("camera_device") or "auto")
|
|
1643
|
-
try:
|
|
1644
|
-
self.vision_process = subprocess.Popen(
|
|
1645
|
-
[
|
|
1646
|
-
str(python),
|
|
1647
|
-
str(script),
|
|
1648
|
-
"--port", str(self.config["vision_port"]),
|
|
1649
|
-
"--motion-webhook-url", target,
|
|
1650
|
-
"--motion-active",
|
|
1651
|
-
],
|
|
1652
|
-
env=env,
|
|
1653
|
-
)
|
|
1654
|
-
self.vision_process_started = True
|
|
1655
|
-
self.vision_restarts += 1
|
|
1656
|
-
log(
|
|
1657
|
-
f"managed RoboVision started (pid {self.vision_process.pid}) on "
|
|
1658
|
-
f"{self.config['vision_url']} with camera {env['ROBOPARK_CAMERA_DEVICE']}"
|
|
1659
|
-
)
|
|
1660
|
-
except Exception as exc:
|
|
1661
|
-
with self.state_lock:
|
|
1662
|
-
self.vision_last_error = str(exc)
|
|
1663
|
-
log(f"managed RoboVision start failed; retrying: {exc}")
|
|
1664
|
-
|
|
1665
|
-
def _stop_vision_process(self) -> None:
|
|
1666
|
-
process = self.vision_process
|
|
1667
|
-
if not self.vision_process_started or process is None or process.poll() is not None:
|
|
1668
|
-
return
|
|
1669
|
-
process.terminate()
|
|
1670
|
-
try:
|
|
1671
|
-
process.wait(timeout=5)
|
|
1672
|
-
except subprocess.TimeoutExpired:
|
|
1673
|
-
log("forcing managed RoboVision process closed")
|
|
1674
|
-
process.kill()
|
|
1675
|
-
process.wait(timeout=2)
|
|
1676
|
-
log("managed RoboVision stopped")
|
|
1677
|
-
|
|
1678
|
-
def run_session(self, reason: str) -> None:
|
|
1679
|
-
with self.state_lock:
|
|
1680
|
-
self.active = True
|
|
1681
|
-
self.session_started_at = time.monotonic()
|
|
1682
|
-
self.sessions_started += 1
|
|
1683
|
-
session_number = self.sessions_started
|
|
1684
|
-
self.last_error = None
|
|
1685
|
-
self.force_stop_event.clear()
|
|
1686
|
-
self.local_session_id = f"session_{uuid.uuid4().hex}"
|
|
1687
|
-
self.scheduler_reporter.start(reason)
|
|
1688
|
-
if self.scheduler_reporter.session_id:
|
|
1689
|
-
self.local_session_id = self.scheduler_reporter.session_id
|
|
1690
|
-
self._event("session_starting", f"Starting ElevenLabs session #{session_number}", "active", reason=reason)
|
|
1691
|
-
self.voice_trigger.stop()
|
|
1692
|
-
audio = NativeAudioInterface(self.config["audio_input"], self.config["audio_output"])
|
|
1693
|
-
self.current_audio = audio
|
|
1694
|
-
last_activity = [time.monotonic()]
|
|
1695
|
-
agent_busy_until = [0.0]
|
|
1696
|
-
ended = threading.Event()
|
|
1697
|
-
conversation = None
|
|
1698
|
-
client_tools = None
|
|
1699
|
-
|
|
1700
|
-
def on_user(text: str) -> None:
|
|
1701
|
-
last_activity[0] = time.monotonic()
|
|
1702
|
-
self._event("user_transcript", text or "No speech decoded", "ok" if text.strip(" .") else "failed")
|
|
1703
|
-
self.scheduler_reporter.turn("user", text)
|
|
1704
|
-
self._spool_event(
|
|
1705
|
-
"transcript.final",
|
|
1706
|
-
{"role": "user", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1707
|
-
)
|
|
1708
|
-
log(f"user: {text}")
|
|
1562
|
+
ThreadingHTTPServer.allow_reuse_address = True
|
|
1563
|
+
ThreadingHTTPServer.daemon_threads = True
|
|
1564
|
+
self.httpd = ThreadingHTTPServer(
|
|
1565
|
+
(self.config.get("status_bind_host") or self.config["motion_host"], int(self.config["motion_port"])), Handler
|
|
1566
|
+
)
|
|
1567
|
+
self.http_thread = threading.Thread(
|
|
1568
|
+
target=self.httpd.serve_forever,
|
|
1569
|
+
name="motion-webhook",
|
|
1570
|
+
daemon=True,
|
|
1571
|
+
)
|
|
1572
|
+
self.http_thread.start()
|
|
1573
|
+
log(f"robot control endpoint armed at http://{self.config['motion_host']}:{self.config['motion_port']}/")
|
|
1574
|
+
if self.config.get("tailscale_status_ip"):
|
|
1575
|
+
log(
|
|
1576
|
+
f"tailnet device selector: http://{self.config['tailscale_status_ip']}:"
|
|
1577
|
+
f"{self.config['motion_port']}/?token={self.config['tailscale_status_token']}"
|
|
1578
|
+
)
|
|
1579
|
+
if self.config.get("vision_url") and self.config["motion_enabled"]:
|
|
1580
|
+
threading.Thread(
|
|
1581
|
+
target=self._maintain_vision_route,
|
|
1582
|
+
name="robovision-router",
|
|
1583
|
+
daemon=True,
|
|
1584
|
+
).start()
|
|
1585
|
+
|
|
1586
|
+
def _maintain_vision_route(self) -> None:
|
|
1587
|
+
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1588
|
+
payload = json.dumps({"url": target}).encode()
|
|
1589
|
+
arm_payload = json.dumps({"active": True}).encode()
|
|
1590
|
+
last_result = None
|
|
1591
|
+
while not self.shutdown.is_set():
|
|
1592
|
+
try:
|
|
1593
|
+
for endpoint, body in (
|
|
1594
|
+
("/api/motion/webhook", payload),
|
|
1595
|
+
("/api/motion/toggle", arm_payload),
|
|
1596
|
+
):
|
|
1597
|
+
request = urllib.request.Request(
|
|
1598
|
+
f"{self.config['vision_url']}{endpoint}",
|
|
1599
|
+
data=body,
|
|
1600
|
+
headers={"Content-Type": "application/json"},
|
|
1601
|
+
method="POST",
|
|
1602
|
+
)
|
|
1603
|
+
with urllib.request.urlopen(request, timeout=3) as response:
|
|
1604
|
+
if response.status >= 300:
|
|
1605
|
+
raise RuntimeError(f"{endpoint} returned HTTP {response.status}")
|
|
1606
|
+
with self.state_lock:
|
|
1607
|
+
self.vision_routed = True
|
|
1608
|
+
self.vision_last_error = None
|
|
1609
|
+
if last_result is not True:
|
|
1610
|
+
log(f"RoboVision motion armed and routed to {target}")
|
|
1611
|
+
last_result = True
|
|
1612
|
+
# Reassert frequently enough to recover from a replaced camera
|
|
1613
|
+
# process without waiting for an operator or a full service restart.
|
|
1614
|
+
delay = 5
|
|
1615
|
+
except Exception as exc:
|
|
1616
|
+
with self.state_lock:
|
|
1617
|
+
self.vision_routed = False
|
|
1618
|
+
self.vision_last_error = str(exc)
|
|
1619
|
+
if last_result is not False:
|
|
1620
|
+
log(f"RoboVision webhook configuration failed; retrying: {exc}")
|
|
1621
|
+
last_result = False
|
|
1622
|
+
if self.config.get("manage_vision"):
|
|
1623
|
+
self._ensure_vision_process()
|
|
1624
|
+
delay = 2
|
|
1625
|
+
self.shutdown.wait(delay)
|
|
1626
|
+
|
|
1627
|
+
def _ensure_vision_process(self) -> None:
|
|
1628
|
+
process = self.vision_process
|
|
1629
|
+
if process is not None and process.poll() is None:
|
|
1630
|
+
return
|
|
1631
|
+
if process is not None:
|
|
1632
|
+
log(f"managed RoboVision exited with code {process.returncode}; restarting")
|
|
1633
|
+
script = self.config.get("vision_script")
|
|
1634
|
+
python = self.config.get("vision_python")
|
|
1635
|
+
if not script or not python:
|
|
1636
|
+
with self.state_lock:
|
|
1637
|
+
self.vision_last_error = "managed RoboVision runtime path is missing"
|
|
1638
|
+
return
|
|
1639
|
+
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1640
|
+
env = os.environ.copy()
|
|
1641
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
1642
|
+
env["ROBOPARK_CAMERA_DEVICE"] = str(self.config.get("camera_device") or "auto")
|
|
1643
|
+
try:
|
|
1644
|
+
self.vision_process = subprocess.Popen(
|
|
1645
|
+
[
|
|
1646
|
+
str(python),
|
|
1647
|
+
str(script),
|
|
1648
|
+
"--port", str(self.config["vision_port"]),
|
|
1649
|
+
"--motion-webhook-url", target,
|
|
1650
|
+
"--motion-active",
|
|
1651
|
+
],
|
|
1652
|
+
env=env,
|
|
1653
|
+
)
|
|
1654
|
+
self.vision_process_started = True
|
|
1655
|
+
self.vision_restarts += 1
|
|
1656
|
+
log(
|
|
1657
|
+
f"managed RoboVision started (pid {self.vision_process.pid}) on "
|
|
1658
|
+
f"{self.config['vision_url']} with camera {env['ROBOPARK_CAMERA_DEVICE']}"
|
|
1659
|
+
)
|
|
1660
|
+
except Exception as exc:
|
|
1661
|
+
with self.state_lock:
|
|
1662
|
+
self.vision_last_error = str(exc)
|
|
1663
|
+
log(f"managed RoboVision start failed; retrying: {exc}")
|
|
1664
|
+
|
|
1665
|
+
def _stop_vision_process(self) -> None:
|
|
1666
|
+
process = self.vision_process
|
|
1667
|
+
if not self.vision_process_started or process is None or process.poll() is not None:
|
|
1668
|
+
return
|
|
1669
|
+
process.terminate()
|
|
1670
|
+
try:
|
|
1671
|
+
process.wait(timeout=5)
|
|
1672
|
+
except subprocess.TimeoutExpired:
|
|
1673
|
+
log("forcing managed RoboVision process closed")
|
|
1674
|
+
process.kill()
|
|
1675
|
+
process.wait(timeout=2)
|
|
1676
|
+
log("managed RoboVision stopped")
|
|
1677
|
+
|
|
1678
|
+
def run_session(self, reason: str) -> None:
|
|
1679
|
+
with self.state_lock:
|
|
1680
|
+
self.active = True
|
|
1681
|
+
self.session_started_at = time.monotonic()
|
|
1682
|
+
self.sessions_started += 1
|
|
1683
|
+
session_number = self.sessions_started
|
|
1684
|
+
self.last_error = None
|
|
1685
|
+
self.force_stop_event.clear()
|
|
1686
|
+
self.local_session_id = f"session_{uuid.uuid4().hex}"
|
|
1687
|
+
self.scheduler_reporter.start(reason)
|
|
1688
|
+
if self.scheduler_reporter.session_id:
|
|
1689
|
+
self.local_session_id = self.scheduler_reporter.session_id
|
|
1690
|
+
self._event("session_starting", f"Starting ElevenLabs session #{session_number}", "active", reason=reason)
|
|
1691
|
+
self.voice_trigger.stop()
|
|
1692
|
+
audio = NativeAudioInterface(self.config["audio_input"], self.config["audio_output"])
|
|
1693
|
+
self.current_audio = audio
|
|
1694
|
+
last_activity = [time.monotonic()]
|
|
1695
|
+
agent_busy_until = [0.0]
|
|
1696
|
+
ended = threading.Event()
|
|
1697
|
+
conversation = None
|
|
1698
|
+
client_tools = None
|
|
1699
|
+
|
|
1700
|
+
def on_user(text: str) -> None:
|
|
1701
|
+
last_activity[0] = time.monotonic()
|
|
1702
|
+
self._event("user_transcript", text or "No speech decoded", "ok" if text.strip(" .") else "failed")
|
|
1703
|
+
self.scheduler_reporter.turn("user", text)
|
|
1704
|
+
self._spool_event(
|
|
1705
|
+
"transcript.final",
|
|
1706
|
+
{"role": "user", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1707
|
+
)
|
|
1708
|
+
log(f"user: {text}")
|
|
1709
1709
|
|
|
1710
1710
|
def on_agent(text: str) -> None:
|
|
1711
1711
|
now = time.monotonic()
|
|
1712
|
-
last_activity[0] = now
|
|
1713
|
-
agent_busy_until[0] = now + max(1.5, len(text.split()) / 2.2)
|
|
1714
|
-
self._event("agent_response", text or "Empty agent response", "ok" if text.strip() else "failed")
|
|
1715
|
-
self.scheduler_reporter.turn("assistant", text)
|
|
1716
|
-
self._spool_event(
|
|
1717
|
-
"transcript.final",
|
|
1718
|
-
{"role": "assistant", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1719
|
-
)
|
|
1720
|
-
log(f"agent: {text}")
|
|
1712
|
+
last_activity[0] = now
|
|
1713
|
+
agent_busy_until[0] = now + max(1.5, len(text.split()) / 2.2)
|
|
1714
|
+
self._event("agent_response", text or "Empty agent response", "ok" if text.strip() else "failed")
|
|
1715
|
+
self.scheduler_reporter.turn("assistant", text)
|
|
1716
|
+
self._spool_event(
|
|
1717
|
+
"transcript.final",
|
|
1718
|
+
{"role": "assistant", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1719
|
+
)
|
|
1720
|
+
log(f"agent: {text}")
|
|
1721
1721
|
|
|
1722
1722
|
def on_end() -> None:
|
|
1723
1723
|
ended.set()
|
|
1724
1724
|
|
|
1725
|
-
started = time.monotonic()
|
|
1726
|
-
log(f"starting ElevenLabs session #{session_number} ({reason})")
|
|
1727
|
-
try:
|
|
1728
|
-
client = ElevenLabs(api_key=self.config.get("api_key"))
|
|
1729
|
-
client_tools = ClientTools()
|
|
1730
|
-
if self.motor_registry.names():
|
|
1731
|
-
client_tools.register("robotMotor", self.motor_registry.execute)
|
|
1732
|
-
log(f"agent tool robotMotor registered for: {', '.join(self.motor_registry.names())}")
|
|
1733
|
-
conversation = Conversation(
|
|
1734
|
-
client=client,
|
|
1735
|
-
agent_id=self.config["agent_id"],
|
|
1736
|
-
requires_auth=bool(self.config["requires_auth"]),
|
|
1737
|
-
audio_interface=audio,
|
|
1738
|
-
callback_user_transcript=on_user,
|
|
1739
|
-
callback_agent_response=on_agent,
|
|
1740
|
-
callback_end_session=on_end,
|
|
1741
|
-
client_tools=client_tools,
|
|
1742
|
-
)
|
|
1743
|
-
branch_id = str(self.config.get("branch_id") or "").strip()
|
|
1744
|
-
if branch_id:
|
|
1745
|
-
if self.config.get("requires_auth"):
|
|
1746
|
-
query = urllib.parse.urlencode({
|
|
1747
|
-
"agent_id": self.config["agent_id"],
|
|
1748
|
-
"branch_id": branch_id,
|
|
1749
|
-
})
|
|
1750
|
-
request = urllib.request.Request(
|
|
1751
|
-
f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?{query}",
|
|
1752
|
-
headers={
|
|
1753
|
-
"Accept": "application/json",
|
|
1754
|
-
"xi-api-key": str(self.config.get("api_key") or ""),
|
|
1755
|
-
},
|
|
1756
|
-
)
|
|
1757
|
-
with urllib.request.urlopen(request, timeout=15) as response:
|
|
1758
|
-
signed_url = json.loads(response.read().decode("utf-8"))["signed_url"]
|
|
1759
|
-
conversation._get_signed_url = lambda: signed_url
|
|
1760
|
-
else:
|
|
1761
|
-
original_get_wss_url = conversation._get_wss_url
|
|
1762
|
-
|
|
1763
|
-
def branch_wss_url():
|
|
1764
|
-
parsed = urllib.parse.urlparse(original_get_wss_url())
|
|
1765
|
-
params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
1766
|
-
params.append(("branch_id", branch_id))
|
|
1767
|
-
return urllib.parse.urlunparse(
|
|
1768
|
-
parsed._replace(query=urllib.parse.urlencode(params))
|
|
1769
|
-
)
|
|
1770
|
-
|
|
1771
|
-
conversation._get_wss_url = branch_wss_url
|
|
1772
|
-
log(f"pinned ElevenLabs branch {branch_id}")
|
|
1773
|
-
conversation.start_session()
|
|
1774
|
-
self._event("provider_connected", "ElevenLabs websocket connected", "active")
|
|
1725
|
+
started = time.monotonic()
|
|
1726
|
+
log(f"starting ElevenLabs session #{session_number} ({reason})")
|
|
1727
|
+
try:
|
|
1728
|
+
client = ElevenLabs(api_key=self.config.get("api_key"))
|
|
1729
|
+
client_tools = ClientTools()
|
|
1730
|
+
if self.motor_registry.names():
|
|
1731
|
+
client_tools.register("robotMotor", self.motor_registry.execute)
|
|
1732
|
+
log(f"agent tool robotMotor registered for: {', '.join(self.motor_registry.names())}")
|
|
1733
|
+
conversation = Conversation(
|
|
1734
|
+
client=client,
|
|
1735
|
+
agent_id=self.config["agent_id"],
|
|
1736
|
+
requires_auth=bool(self.config["requires_auth"]),
|
|
1737
|
+
audio_interface=audio,
|
|
1738
|
+
callback_user_transcript=on_user,
|
|
1739
|
+
callback_agent_response=on_agent,
|
|
1740
|
+
callback_end_session=on_end,
|
|
1741
|
+
client_tools=client_tools,
|
|
1742
|
+
)
|
|
1743
|
+
branch_id = str(self.config.get("branch_id") or "").strip()
|
|
1744
|
+
if branch_id:
|
|
1745
|
+
if self.config.get("requires_auth"):
|
|
1746
|
+
query = urllib.parse.urlencode({
|
|
1747
|
+
"agent_id": self.config["agent_id"],
|
|
1748
|
+
"branch_id": branch_id,
|
|
1749
|
+
})
|
|
1750
|
+
request = urllib.request.Request(
|
|
1751
|
+
f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?{query}",
|
|
1752
|
+
headers={
|
|
1753
|
+
"Accept": "application/json",
|
|
1754
|
+
"xi-api-key": str(self.config.get("api_key") or ""),
|
|
1755
|
+
},
|
|
1756
|
+
)
|
|
1757
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
1758
|
+
signed_url = json.loads(response.read().decode("utf-8"))["signed_url"]
|
|
1759
|
+
conversation._get_signed_url = lambda: signed_url
|
|
1760
|
+
else:
|
|
1761
|
+
original_get_wss_url = conversation._get_wss_url
|
|
1762
|
+
|
|
1763
|
+
def branch_wss_url():
|
|
1764
|
+
parsed = urllib.parse.urlparse(original_get_wss_url())
|
|
1765
|
+
params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
1766
|
+
params.append(("branch_id", branch_id))
|
|
1767
|
+
return urllib.parse.urlunparse(
|
|
1768
|
+
parsed._replace(query=urllib.parse.urlencode(params))
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
conversation._get_wss_url = branch_wss_url
|
|
1772
|
+
log(f"pinned ElevenLabs branch {branch_id}")
|
|
1773
|
+
conversation.start_session()
|
|
1774
|
+
self._event("provider_connected", "ElevenLabs websocket connected", "active")
|
|
1775
1775
|
if self.motor_registry.names():
|
|
1776
1776
|
deadline = time.monotonic() + 10
|
|
1777
1777
|
while getattr(conversation, "_ws", None) is None and time.monotonic() < deadline:
|
|
@@ -1788,187 +1788,187 @@ class Runtime:
|
|
|
1788
1788
|
"tool returns ok=true."
|
|
1789
1789
|
)
|
|
1790
1790
|
log("registered motor names sent to agent context")
|
|
1791
|
-
while not self.shutdown.is_set() and not self.force_stop_event.is_set() and not ended.wait(0.25):
|
|
1792
|
-
now = time.monotonic()
|
|
1793
|
-
if audio.fatal_error.is_set():
|
|
1794
|
-
self.recycle_required = True
|
|
1795
|
-
raise RuntimeError(audio.fatal_error_message or "fatal audio device failure")
|
|
1796
|
-
if audio.started.is_set() and now - started >= 8 and audio.input_chunks == 0:
|
|
1797
|
-
self.recycle_required = True
|
|
1798
|
-
raise RuntimeError("microphone capture produced no PCM within 8 seconds")
|
|
1799
|
-
thread = getattr(conversation, "_thread", None)
|
|
1791
|
+
while not self.shutdown.is_set() and not self.force_stop_event.is_set() and not ended.wait(0.25):
|
|
1792
|
+
now = time.monotonic()
|
|
1793
|
+
if audio.fatal_error.is_set():
|
|
1794
|
+
self.recycle_required = True
|
|
1795
|
+
raise RuntimeError(audio.fatal_error_message or "fatal audio device failure")
|
|
1796
|
+
if audio.started.is_set() and now - started >= 8 and audio.input_chunks == 0:
|
|
1797
|
+
self.recycle_required = True
|
|
1798
|
+
raise RuntimeError("microphone capture produced no PCM within 8 seconds")
|
|
1799
|
+
thread = getattr(conversation, "_thread", None)
|
|
1800
1800
|
if thread is not None and not thread.is_alive():
|
|
1801
1801
|
break
|
|
1802
|
-
if self.config["max_session"] > 0 and now - started >= self.config["max_session"]:
|
|
1802
|
+
if self.config["max_session"] > 0 and now - started >= self.config["max_session"]:
|
|
1803
1803
|
log("maximum session timeout reached")
|
|
1804
1804
|
break
|
|
1805
1805
|
if (
|
|
1806
|
-
self.config["idle_timeout"] > 0
|
|
1807
|
-
and now - last_activity[0] >= self.config["idle_timeout"]
|
|
1806
|
+
self.config["idle_timeout"] > 0
|
|
1807
|
+
and now - last_activity[0] >= self.config["idle_timeout"]
|
|
1808
1808
|
and now >= agent_busy_until[0]
|
|
1809
1809
|
and audio.started.is_set()
|
|
1810
1810
|
):
|
|
1811
1811
|
log("conversation idle timeout reached")
|
|
1812
1812
|
break
|
|
1813
|
-
except Exception as exc:
|
|
1814
|
-
with self.state_lock:
|
|
1815
|
-
self.last_error = str(exc)
|
|
1816
|
-
log(f"conversation failed: {exc}")
|
|
1817
|
-
self._event("session_error", str(exc), "failed")
|
|
1818
|
-
finally:
|
|
1819
|
-
ws = getattr(conversation, "_ws", None) if conversation is not None else None
|
|
1820
|
-
thread = getattr(conversation, "_thread", None) if conversation is not None else None
|
|
1821
|
-
if conversation is not None:
|
|
1822
|
-
try:
|
|
1823
|
-
conversation.end_session()
|
|
1824
|
-
except Exception as exc:
|
|
1825
|
-
log(f"conversation end request failed: {exc}")
|
|
1826
|
-
audio.stop()
|
|
1827
|
-
self.current_audio = None
|
|
1828
|
-
if thread is not None and thread is not threading.current_thread():
|
|
1829
|
-
thread.join(timeout=SESSION_STOP_GRACE_SECONDS)
|
|
1830
|
-
if thread.is_alive() and ws is not None:
|
|
1831
|
-
log("forcing stale ElevenLabs websocket closed")
|
|
1832
|
-
try:
|
|
1833
|
-
ws.close()
|
|
1834
|
-
except Exception:
|
|
1835
|
-
pass
|
|
1836
|
-
thread.join(timeout=SESSION_FORCE_CLOSE_SECONDS)
|
|
1837
|
-
if thread.is_alive():
|
|
1838
|
-
log("ElevenLabs worker did not exit before disposal deadline")
|
|
1839
|
-
self.recycle_required = True
|
|
1840
|
-
elif conversation is None and client_tools is not None:
|
|
1841
|
-
try:
|
|
1842
|
-
client_tools.stop()
|
|
1843
|
-
except Exception:
|
|
1844
|
-
pass
|
|
1845
|
-
with self.state_lock:
|
|
1846
|
-
self.active = False
|
|
1847
|
-
self.last_session_end = time.monotonic()
|
|
1848
|
-
self.session_started_at = 0.0
|
|
1849
|
-
self.sessions_completed += 1
|
|
1850
|
-
elapsed = time.monotonic() - started
|
|
1851
|
-
self.last_session = {
|
|
1852
|
-
"number": session_number,
|
|
1853
|
-
"reason": reason,
|
|
1854
|
-
"duration_seconds": round(elapsed, 1),
|
|
1855
|
-
"ended_at": time.time(),
|
|
1856
|
-
"error": self.last_error,
|
|
1857
|
-
"audio": audio.status(),
|
|
1858
|
-
}
|
|
1859
|
-
self._event(
|
|
1860
|
-
"session_disposed",
|
|
1861
|
-
f"Session #{session_number} disposed and media released",
|
|
1862
|
-
"failed" if self.last_error else "ok",
|
|
1863
|
-
duration_seconds=round(elapsed, 1),
|
|
1864
|
-
)
|
|
1865
|
-
self.scheduler_reporter.end(
|
|
1866
|
-
"error" if self.last_error else ("operator_stop" if self.force_stop_event.is_set() else "completed"),
|
|
1867
|
-
elapsed,
|
|
1868
|
-
self.last_error,
|
|
1869
|
-
)
|
|
1870
|
-
self.local_session_id = None
|
|
1871
|
-
log(
|
|
1872
|
-
f"session #{session_number} disposed in {elapsed:.1f}s; "
|
|
1873
|
-
f"speaker frames={audio.output_chunks}, bytes={audio.output_bytes}; "
|
|
1874
|
-
f"mic chunks={audio.input_chunks}, forwarded={audio.forwarded_input_chunks}, "
|
|
1875
|
-
f"peak={audio.input_peak}, echo-suppressed={audio.suppressed_input_chunks}; "
|
|
1876
|
-
"triggers will re-arm after cooldown"
|
|
1877
|
-
)
|
|
1878
|
-
|
|
1879
|
-
def _process_idle_requests(self) -> None:
|
|
1880
|
-
if self.active:
|
|
1881
|
-
return
|
|
1882
|
-
if self.apply_when_idle_requested:
|
|
1883
|
-
result = self.apply_staged_configuration()
|
|
1884
|
-
if result.get("ok") and not result.get("waiting_for_idle"):
|
|
1885
|
-
self.apply_when_idle_requested = False
|
|
1886
|
-
if self.stop_when_idle_requested:
|
|
1887
|
-
self.stop_when_idle_requested = False
|
|
1888
|
-
self.paused = True
|
|
1889
|
-
self.store.set_state("paused", True)
|
|
1890
|
-
self.trigger_event.clear()
|
|
1891
|
-
self.voice_trigger.stop()
|
|
1892
|
-
self._event("runtime_stopped", "Voice triggers paused after session disposal", "pending")
|
|
1893
|
-
if self.restart_when_idle_requested:
|
|
1894
|
-
self.restart_when_idle_requested = False
|
|
1895
|
-
self._event("restart_begin", "Clean worker restart beginning", "pending")
|
|
1896
|
-
self.recycle_required = True
|
|
1897
|
-
self.shutdown.set()
|
|
1898
|
-
self.trigger_event.set()
|
|
1899
|
-
|
|
1900
|
-
def run(self) -> None:
|
|
1901
|
-
if self.config.get("always_on") or self.config["motion_enabled"] or self.motor_registry.names():
|
|
1902
|
-
self.start_motion_server()
|
|
1903
|
-
if self.config["voice_trigger_enabled"] and not self.paused:
|
|
1904
|
-
self.voice_trigger.start()
|
|
1905
|
-
if self.config.get("always_on"):
|
|
1906
|
-
self.trigger_reason = "always-on"
|
|
1907
|
-
self.trigger_event.set()
|
|
1908
|
-
log(f"ready: robot={self.config['robot_name']} agent={self.config['agent_id']}")
|
|
1909
|
-
while not self.shutdown.is_set():
|
|
1910
|
-
self._process_idle_requests()
|
|
1911
|
-
if self.shutdown.is_set():
|
|
1912
|
-
break
|
|
1913
|
-
if self.http_thread is not None and not self.http_thread.is_alive():
|
|
1914
|
-
log("robot control endpoint stopped unexpectedly; recycling runtime")
|
|
1915
|
-
self.recycle_required = True
|
|
1916
|
-
break
|
|
1917
|
-
if not self.trigger_event.wait(0.5):
|
|
1918
|
-
continue
|
|
1919
|
-
if self.shutdown.is_set():
|
|
1920
|
-
break
|
|
1921
|
-
self.trigger_event.clear()
|
|
1922
|
-
reason = self.trigger_reason
|
|
1923
|
-
self.run_session(reason)
|
|
1924
|
-
self._process_idle_requests()
|
|
1925
|
-
if self.recycle_required:
|
|
1926
|
-
log("recycling runtime for clean media state")
|
|
1927
|
-
break
|
|
1928
|
-
while not self.shutdown.is_set() and (
|
|
1929
|
-
time.monotonic() - self.last_session_end < self.config["cooldown"]
|
|
1930
|
-
):
|
|
1931
|
-
time.sleep(0.2)
|
|
1932
|
-
with self.state_lock:
|
|
1933
|
-
pending_age = (
|
|
1934
|
-
time.monotonic() - self.pending_motion_at
|
|
1935
|
-
if self.pending_motion_at else float("inf")
|
|
1936
|
-
)
|
|
1937
|
-
if pending_age <= PENDING_MOTION_TTL_SECONDS and not self.shutdown.is_set():
|
|
1938
|
-
self.pending_motion_at = 0.0
|
|
1939
|
-
self.trigger_reason = "motion-continuation"
|
|
1940
|
-
self.trigger_event.set()
|
|
1941
|
-
self.triggers_accepted += 1
|
|
1942
|
-
log("recent motion promoted to a continuation session")
|
|
1943
|
-
elif self.pending_motion_at:
|
|
1944
|
-
self.pending_motion_at = 0.0
|
|
1945
|
-
if self.config.get("always_on") and not self.shutdown.is_set():
|
|
1946
|
-
self.trigger_reason = "always-on-reconnect"
|
|
1947
|
-
self.trigger_event.set()
|
|
1948
|
-
log("always-on session queued for reconnect")
|
|
1949
|
-
if self.config["voice_trigger_enabled"] and not self.shutdown.is_set():
|
|
1950
|
-
self.voice_trigger.start()
|
|
1813
|
+
except Exception as exc:
|
|
1814
|
+
with self.state_lock:
|
|
1815
|
+
self.last_error = str(exc)
|
|
1816
|
+
log(f"conversation failed: {exc}")
|
|
1817
|
+
self._event("session_error", str(exc), "failed")
|
|
1818
|
+
finally:
|
|
1819
|
+
ws = getattr(conversation, "_ws", None) if conversation is not None else None
|
|
1820
|
+
thread = getattr(conversation, "_thread", None) if conversation is not None else None
|
|
1821
|
+
if conversation is not None:
|
|
1822
|
+
try:
|
|
1823
|
+
conversation.end_session()
|
|
1824
|
+
except Exception as exc:
|
|
1825
|
+
log(f"conversation end request failed: {exc}")
|
|
1826
|
+
audio.stop()
|
|
1827
|
+
self.current_audio = None
|
|
1828
|
+
if thread is not None and thread is not threading.current_thread():
|
|
1829
|
+
thread.join(timeout=SESSION_STOP_GRACE_SECONDS)
|
|
1830
|
+
if thread.is_alive() and ws is not None:
|
|
1831
|
+
log("forcing stale ElevenLabs websocket closed")
|
|
1832
|
+
try:
|
|
1833
|
+
ws.close()
|
|
1834
|
+
except Exception:
|
|
1835
|
+
pass
|
|
1836
|
+
thread.join(timeout=SESSION_FORCE_CLOSE_SECONDS)
|
|
1837
|
+
if thread.is_alive():
|
|
1838
|
+
log("ElevenLabs worker did not exit before disposal deadline")
|
|
1839
|
+
self.recycle_required = True
|
|
1840
|
+
elif conversation is None and client_tools is not None:
|
|
1841
|
+
try:
|
|
1842
|
+
client_tools.stop()
|
|
1843
|
+
except Exception:
|
|
1844
|
+
pass
|
|
1845
|
+
with self.state_lock:
|
|
1846
|
+
self.active = False
|
|
1847
|
+
self.last_session_end = time.monotonic()
|
|
1848
|
+
self.session_started_at = 0.0
|
|
1849
|
+
self.sessions_completed += 1
|
|
1850
|
+
elapsed = time.monotonic() - started
|
|
1851
|
+
self.last_session = {
|
|
1852
|
+
"number": session_number,
|
|
1853
|
+
"reason": reason,
|
|
1854
|
+
"duration_seconds": round(elapsed, 1),
|
|
1855
|
+
"ended_at": time.time(),
|
|
1856
|
+
"error": self.last_error,
|
|
1857
|
+
"audio": audio.status(),
|
|
1858
|
+
}
|
|
1859
|
+
self._event(
|
|
1860
|
+
"session_disposed",
|
|
1861
|
+
f"Session #{session_number} disposed and media released",
|
|
1862
|
+
"failed" if self.last_error else "ok",
|
|
1863
|
+
duration_seconds=round(elapsed, 1),
|
|
1864
|
+
)
|
|
1865
|
+
self.scheduler_reporter.end(
|
|
1866
|
+
"error" if self.last_error else ("operator_stop" if self.force_stop_event.is_set() else "completed"),
|
|
1867
|
+
elapsed,
|
|
1868
|
+
self.last_error,
|
|
1869
|
+
)
|
|
1870
|
+
self.local_session_id = None
|
|
1871
|
+
log(
|
|
1872
|
+
f"session #{session_number} disposed in {elapsed:.1f}s; "
|
|
1873
|
+
f"speaker frames={audio.output_chunks}, bytes={audio.output_bytes}; "
|
|
1874
|
+
f"mic chunks={audio.input_chunks}, forwarded={audio.forwarded_input_chunks}, "
|
|
1875
|
+
f"peak={audio.input_peak}, echo-suppressed={audio.suppressed_input_chunks}; "
|
|
1876
|
+
"triggers will re-arm after cooldown"
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
def _process_idle_requests(self) -> None:
|
|
1880
|
+
if self.active:
|
|
1881
|
+
return
|
|
1882
|
+
if self.apply_when_idle_requested:
|
|
1883
|
+
result = self.apply_staged_configuration()
|
|
1884
|
+
if result.get("ok") and not result.get("waiting_for_idle"):
|
|
1885
|
+
self.apply_when_idle_requested = False
|
|
1886
|
+
if self.stop_when_idle_requested:
|
|
1887
|
+
self.stop_when_idle_requested = False
|
|
1888
|
+
self.paused = True
|
|
1889
|
+
self.store.set_state("paused", True)
|
|
1890
|
+
self.trigger_event.clear()
|
|
1891
|
+
self.voice_trigger.stop()
|
|
1892
|
+
self._event("runtime_stopped", "Voice triggers paused after session disposal", "pending")
|
|
1893
|
+
if self.restart_when_idle_requested:
|
|
1894
|
+
self.restart_when_idle_requested = False
|
|
1895
|
+
self._event("restart_begin", "Clean worker restart beginning", "pending")
|
|
1896
|
+
self.recycle_required = True
|
|
1897
|
+
self.shutdown.set()
|
|
1898
|
+
self.trigger_event.set()
|
|
1899
|
+
|
|
1900
|
+
def run(self) -> None:
|
|
1901
|
+
if self.config.get("always_on") or self.config["motion_enabled"] or self.motor_registry.names():
|
|
1902
|
+
self.start_motion_server()
|
|
1903
|
+
if self.config["voice_trigger_enabled"] and not self.paused:
|
|
1904
|
+
self.voice_trigger.start()
|
|
1905
|
+
if self.config.get("always_on"):
|
|
1906
|
+
self.trigger_reason = "always-on"
|
|
1907
|
+
self.trigger_event.set()
|
|
1908
|
+
log(f"ready: robot={self.config['robot_name']} agent={self.config['agent_id']}")
|
|
1909
|
+
while not self.shutdown.is_set():
|
|
1910
|
+
self._process_idle_requests()
|
|
1911
|
+
if self.shutdown.is_set():
|
|
1912
|
+
break
|
|
1913
|
+
if self.http_thread is not None and not self.http_thread.is_alive():
|
|
1914
|
+
log("robot control endpoint stopped unexpectedly; recycling runtime")
|
|
1915
|
+
self.recycle_required = True
|
|
1916
|
+
break
|
|
1917
|
+
if not self.trigger_event.wait(0.5):
|
|
1918
|
+
continue
|
|
1919
|
+
if self.shutdown.is_set():
|
|
1920
|
+
break
|
|
1921
|
+
self.trigger_event.clear()
|
|
1922
|
+
reason = self.trigger_reason
|
|
1923
|
+
self.run_session(reason)
|
|
1924
|
+
self._process_idle_requests()
|
|
1925
|
+
if self.recycle_required:
|
|
1926
|
+
log("recycling runtime for clean media state")
|
|
1927
|
+
break
|
|
1928
|
+
while not self.shutdown.is_set() and (
|
|
1929
|
+
time.monotonic() - self.last_session_end < self.config["cooldown"]
|
|
1930
|
+
):
|
|
1931
|
+
time.sleep(0.2)
|
|
1932
|
+
with self.state_lock:
|
|
1933
|
+
pending_age = (
|
|
1934
|
+
time.monotonic() - self.pending_motion_at
|
|
1935
|
+
if self.pending_motion_at else float("inf")
|
|
1936
|
+
)
|
|
1937
|
+
if pending_age <= PENDING_MOTION_TTL_SECONDS and not self.shutdown.is_set():
|
|
1938
|
+
self.pending_motion_at = 0.0
|
|
1939
|
+
self.trigger_reason = "motion-continuation"
|
|
1940
|
+
self.trigger_event.set()
|
|
1941
|
+
self.triggers_accepted += 1
|
|
1942
|
+
log("recent motion promoted to a continuation session")
|
|
1943
|
+
elif self.pending_motion_at:
|
|
1944
|
+
self.pending_motion_at = 0.0
|
|
1945
|
+
if self.config.get("always_on") and not self.shutdown.is_set():
|
|
1946
|
+
self.trigger_reason = "always-on-reconnect"
|
|
1947
|
+
self.trigger_event.set()
|
|
1948
|
+
log("always-on session queued for reconnect")
|
|
1949
|
+
if self.config["voice_trigger_enabled"] and not self.shutdown.is_set():
|
|
1950
|
+
self.voice_trigger.start()
|
|
1951
1951
|
self.voice_trigger.stop()
|
|
1952
1952
|
self.motor_registry.close()
|
|
1953
|
-
if self.httpd is not None:
|
|
1954
|
-
self.httpd.shutdown()
|
|
1955
|
-
self.httpd.server_close()
|
|
1956
|
-
self._stop_vision_process()
|
|
1957
|
-
self.scheduler_reporter.stop_event.set()
|
|
1958
|
-
try:
|
|
1959
|
-
self.scheduler_reporter.jobs.put_nowait(None)
|
|
1960
|
-
except queue.Full:
|
|
1961
|
-
pass
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
def main() -> None:
|
|
1953
|
+
if self.httpd is not None:
|
|
1954
|
+
self.httpd.shutdown()
|
|
1955
|
+
self.httpd.server_close()
|
|
1956
|
+
self._stop_vision_process()
|
|
1957
|
+
self.scheduler_reporter.stop_event.set()
|
|
1958
|
+
try:
|
|
1959
|
+
self.scheduler_reporter.jobs.put_nowait(None)
|
|
1960
|
+
except queue.Full:
|
|
1961
|
+
pass
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def main() -> None:
|
|
1965
1965
|
parser = argparse.ArgumentParser()
|
|
1966
1966
|
parser.add_argument("--config", required=True)
|
|
1967
|
-
args = parser.parse_args()
|
|
1968
|
-
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
1969
|
-
config["config_path"] = str(Path(args.config).resolve())
|
|
1970
|
-
if not config.get("always_on") and not config.get("motion_enabled") and not config.get("voice_trigger_enabled"):
|
|
1971
|
-
raise SystemExit("at least one trigger must be enabled")
|
|
1967
|
+
args = parser.parse_args()
|
|
1968
|
+
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
1969
|
+
config["config_path"] = str(Path(args.config).resolve())
|
|
1970
|
+
if not config.get("always_on") and not config.get("motion_enabled") and not config.get("voice_trigger_enabled"):
|
|
1971
|
+
raise SystemExit("at least one trigger must be enabled")
|
|
1972
1972
|
runtime = Runtime(config)
|
|
1973
1973
|
|
|
1974
1974
|
def stop(_signum, _frame):
|