robopark 2.8.35 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
package/package.json CHANGED
@@ -1,23 +1,39 @@
1
1
  {
2
2
  "name": "robopark",
3
- "version": "2.8.35",
4
- "description": "RoboPark fleet control CLI — set up, watch, and drive a fleet of talking robots. The operator front-end over the infinicode mesh.",
3
+ "version": "3.0.0",
4
+ "description": "Standalone packaged RoboPark control center and supervised robot runtime.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "robopark": "./bin/robopark.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/robopark.js",
11
+ "dist",
11
12
  "scheduler",
12
13
  "pi-client",
14
+ "conversation",
13
15
  "vision",
16
+ "screen",
14
17
  "README.md"
15
18
  ],
19
+ "scripts": {
20
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
21
+ "build": "npm run clean && tsc -p tsconfig.json",
22
+ "test": "node --test \"test/**/*.test.mjs\"",
23
+ "prepack": "npm run build && npm test"
24
+ },
16
25
  "engines": {
17
26
  "node": ">=20"
18
27
  },
19
28
  "dependencies": {
20
- "infinicode": "^2.8.9"
29
+ "chalk": "^5.3.0",
30
+ "commander": "^12.1.0",
31
+ "conf": "^12.0.0",
32
+ "execa": "^9.3.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.14.0",
36
+ "typescript": "^5.4.5"
21
37
  },
22
38
  "keywords": [
23
39
  "robopark",
@@ -26,8 +42,8 @@
26
42
  "livekit",
27
43
  "raspberry-pi",
28
44
  "cli",
29
- "infinicode",
30
- "mesh"
45
+ "supervisor",
46
+ "camera"
31
47
  ],
32
48
  "author": "Ra Kai'Un",
33
49
  "license": "MIT",
@@ -1,29 +1,29 @@
1
- #!/bin/bash
2
- set -e
3
- echo "=== reset perms (cleanup from previous attempt) ==="
4
- sudo rm -rf /opt/robopark-pi-client/.venv 2>/dev/null || true
5
- sudo chown -R robopark:robopark /opt/robopark-pi-client
6
-
7
- echo "=== apt deps (no signing strict) ==="
8
- sudo apt-get -o Acquire::AllowInsecureRepositories=true update 2>&1 | tail -3
9
- sudo apt-get install -y --no-install-recommends python3-venv python3-pip libatlas3-base libasound2t64 portaudio19-dev libjpeg-dev libpng-dev ffmpeg 2>&1 | tail -3
10
- echo "apt-done"
11
-
12
- echo "=== create venv as robopark ==="
13
- sudo -u robopark python3 -m venv /opt/robopark-pi-client/.venv
14
- sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet --upgrade pip
15
- sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet httpx
16
- echo "httpx-done"
17
-
18
- echo "=== media stack (optional, may take a while) ==="
19
- sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet livekit livekit-api numpy 2>&1 | tail -3
20
- echo "lk-done"
21
- sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet opencv-python-headless 2>&1 | tail -3
22
- echo "cv-done"
23
- sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet sounddevice 2>&1 | tail -3
24
- echo "sd-done"
25
-
26
- echo "=== final state ==="
27
- ls -la /opt/robopark-pi-client
28
- ls /opt/robopark-pi-client/.venv/bin | head -5
29
- echo "all-done"
1
+ #!/bin/bash
2
+ set -e
3
+ echo "=== reset perms (cleanup from previous attempt) ==="
4
+ sudo rm -rf /opt/robopark-pi-client/.venv 2>/dev/null || true
5
+ sudo chown -R robopark:robopark /opt/robopark-pi-client
6
+
7
+ echo "=== apt deps (no signing strict) ==="
8
+ sudo apt-get -o Acquire::AllowInsecureRepositories=true update 2>&1 | tail -3
9
+ sudo apt-get install -y --no-install-recommends python3-venv python3-pip libatlas3-base libasound2t64 portaudio19-dev libjpeg-dev libpng-dev ffmpeg 2>&1 | tail -3
10
+ echo "apt-done"
11
+
12
+ echo "=== create venv as robopark ==="
13
+ sudo -u robopark python3 -m venv /opt/robopark-pi-client/.venv
14
+ sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet --upgrade pip
15
+ sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet httpx
16
+ echo "httpx-done"
17
+
18
+ echo "=== media stack (optional, may take a while) ==="
19
+ sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet livekit livekit-api numpy 2>&1 | tail -3
20
+ echo "lk-done"
21
+ sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet opencv-python-headless 2>&1 | tail -3
22
+ echo "cv-done"
23
+ sudo -u robopark /opt/robopark-pi-client/.venv/bin/pip install --quiet sounddevice 2>&1 | tail -3
24
+ echo "sd-done"
25
+
26
+ echo "=== final state ==="
27
+ ls -la /opt/robopark-pi-client
28
+ ls /opt/robopark-pi-client/.venv/bin | head -5
29
+ echo "all-done"
@@ -22,10 +22,12 @@
22
22
 
23
23
  import argparse
24
24
  import asyncio
25
+ import glob
25
26
  import json
26
27
  import logging
27
28
  import os
28
29
  import signal
30
+ import subprocess
29
31
  import sys
30
32
  import time
31
33
  from pathlib import Path
@@ -42,6 +44,44 @@ logging.basicConfig(
42
44
  log = logging.getLogger("robopark-pi")
43
45
 
44
46
 
47
+ def get_device_inventory() -> dict:
48
+ """Report real Linux media devices so the remote dashboard can select them."""
49
+ inventory = {
50
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}],
51
+ "audio_input": [{"id": "default", "name": "System default input"}],
52
+ "audio_output": [{"id": "default", "name": "System default output"}],
53
+ "platform": sys.platform,
54
+ }
55
+ for path in sorted(glob.glob("/dev/video*")):
56
+ inventory["video"].append({"id": path, "name": path, "backend": "v4l2"})
57
+
58
+ try:
59
+ import pyaudio
60
+ pa = pyaudio.PyAudio()
61
+ for index in range(pa.get_device_count()):
62
+ info = pa.get_device_info_by_index(index)
63
+ name = str(info.get("name", f"Audio device {index}"))
64
+ item = {"id": str(index), "name": name, "host_api": str(info.get("hostApi", ""))}
65
+ if info.get("maxInputChannels", 0) > 0:
66
+ inventory["audio_input"].append(item.copy())
67
+ if info.get("maxOutputChannels", 0) > 0:
68
+ inventory["audio_output"].append(item.copy())
69
+ pa.terminate()
70
+ except Exception as exc:
71
+ # Keep the device visible even when PortAudio is not installed; ALSA
72
+ # names are still useful for an operator diagnosing a Pi remotely.
73
+ log.debug("PyAudio inventory unavailable: %s", exc)
74
+ for command, key, kind in (("arecord", "audio_input", "input"), ("aplay", "audio_output", "output")):
75
+ try:
76
+ output = subprocess.run([command, "-L"], capture_output=True, text=True, timeout=3, check=False).stdout
77
+ for name in (line.strip() for line in output.splitlines()):
78
+ if name and not name.startswith("(") and name not in {x["id"] for x in inventory[key]}:
79
+ inventory[key].append({"id": name, "name": name, "backend": "alsa", "kind": kind})
80
+ except Exception:
81
+ pass
82
+ return inventory
83
+
84
+
45
85
  class PiClient:
46
86
  def __init__(self, args: argparse.Namespace):
47
87
  self.scheduler_url = args.scheduler_url.rstrip("/")
@@ -57,6 +97,9 @@ class PiClient:
57
97
  self.config_poll_interval = args.config_poll_interval
58
98
  self.join_room = args.join_room
59
99
  self.room_name = args.room_name
100
+ self.video_device = args.video_device
101
+ self.audio_device = args.audio_device
102
+ self.audio_output_device = args.audio_output_device
60
103
 
61
104
  self.device_id: Optional[str] = None
62
105
  self.device_token: Optional[str] = None
@@ -130,7 +173,11 @@ class PiClient:
130
173
  self._save_state()
131
174
 
132
175
  async def heartbeat(self):
133
- body = {"status": "online", "ip": self.tailscale_ip or self.lan_ip}
176
+ body = {
177
+ "status": "online",
178
+ "ip": self.tailscale_ip or self.lan_ip,
179
+ "device_inventory": get_device_inventory(),
180
+ }
134
181
  async with httpx.AsyncClient(timeout=10.0) as c:
135
182
  r = await c.post(
136
183
  f"{self.scheduler_url}/api/devices/{self.device_id}/heartbeat",
@@ -187,6 +234,9 @@ class PiClient:
187
234
  scheduler_url=self.scheduler_url,
188
235
  device_id=self.device_id,
189
236
  device_token=self.device_token,
237
+ video_device=self.video_device,
238
+ audio_device=self.audio_device,
239
+ audio_output_device=self.audio_output_device,
190
240
  )
191
241
 
192
242
  # ---- main loop ------------------------------------------------------
@@ -236,6 +286,12 @@ class PiClient:
236
286
  self.production_mode = bool(cfg.get("production_mode", False))
237
287
  if cfg.get("character_id"):
238
288
  self.character_id = cfg["character_id"]
289
+ if cfg.get("video_device") is not None:
290
+ self.video_device = cfg["video_device"]
291
+ if cfg.get("audio_device") is not None:
292
+ self.audio_device = cfg["audio_device"]
293
+ if cfg.get("audio_output_device") is not None:
294
+ self.audio_output_device = cfg["audio_output_device"]
239
295
  except Exception as e:
240
296
  log.debug(f"Config poll error: {e}")
241
297
 
@@ -269,6 +325,9 @@ def parse_args() -> argparse.Namespace:
269
325
  p.add_argument("--character-id", default=None, help="Override character assignment")
270
326
  p.add_argument("--room-name", default="robopark-pi-standby",
271
327
  help="LiveKit room to join")
328
+ p.add_argument("--video-device", default="auto", help="Camera path/index, picamera, or auto")
329
+ p.add_argument("--audio-device", default="default", help="Microphone name/index or default")
330
+ p.add_argument("--audio-output-device", default="default", help="Speaker name/index or default")
272
331
  p.add_argument("--heartbeat-interval", type=float, default=10.0)
273
332
  p.add_argument("--config-poll-interval", type=float, default=30.0)
274
333
  p.add_argument("--join-room", action="store_true",
@@ -302,4 +361,4 @@ def main():
302
361
 
303
362
 
304
363
  if __name__ == "__main__":
305
- main()
364
+ main()
@@ -1,41 +1,41 @@
1
- #!/usr/bin/env bash
2
- # Install RoboPark Pi Client on a Raspberry Pi
3
- # Usage:
4
- # sudo ./install.sh --scheduler-url http://100.64.1.5:8080 \
5
- # --enrollment-token <TOKEN>
6
- set -euo pipefail
7
-
8
- SCHEDULER_URL=""
9
- ENROLLMENT_TOKEN=""
10
-
11
- while [[ $# -gt 0 ]]; do
12
- case "$1" in
13
- --scheduler-url) SCHEDULER_URL="$2"; shift 2 ;;
14
- --enrollment-token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
15
- *) echo "Unknown arg: $1"; exit 1 ;;
16
- esac
17
- done
18
-
19
- if [[ -z "$SCHEDULER_URL" || -z "$ENROLLMENT_TOKEN" ]]; then
20
- echo "Usage: $0 --scheduler-url URL --enrollment-token TOKEN"
21
- exit 1
22
- fi
23
-
24
- id "robopark" 2>/dev/null || useradd -r -s /bin/false robopark
25
- install -d -m 755 -o robopark -g robopark /opt/robopark-pi-client
26
- install -d -m 700 -o robopark -g robopark /etc/robopark
27
- cp -r ./* /opt/robopark-pi-client/
28
- chown -R robopark:robopark /opt/robopark-pi-client
29
-
30
- sudo -u robopark python3 -m venv /opt/robopark-pi-client/.venv
31
- /opt/robopark-pi-client/.venv/bin/pip install --upgrade pip
32
- /opt/robopark-pi-client/.venv/bin/pip install -r /opt/robopark-pi-client/requirements.txt
33
- # On Pi you typically want the full media stack:
34
- /opt/robopark-pi-client/.venv/bin/pip install livekit livekit-api sounddevice numpy opencv-python av
35
-
36
- cp /opt/robopark-pi-client/robopark-pi-client.service /etc/systemd/system/
37
- systemctl daemon-reload
38
- systemctl enable --now robopark-pi-client.service
39
-
40
- echo "First-boot enrollment will run as part of the service start."
1
+ #!/usr/bin/env bash
2
+ # Install RoboPark Pi Client on a Raspberry Pi
3
+ # Usage:
4
+ # sudo ./install.sh --scheduler-url http://100.64.1.5:8080 \
5
+ # --enrollment-token <TOKEN>
6
+ set -euo pipefail
7
+
8
+ SCHEDULER_URL=""
9
+ ENROLLMENT_TOKEN=""
10
+
11
+ while [[ $# -gt 0 ]]; do
12
+ case "$1" in
13
+ --scheduler-url) SCHEDULER_URL="$2"; shift 2 ;;
14
+ --enrollment-token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
15
+ *) echo "Unknown arg: $1"; exit 1 ;;
16
+ esac
17
+ done
18
+
19
+ if [[ -z "$SCHEDULER_URL" || -z "$ENROLLMENT_TOKEN" ]]; then
20
+ echo "Usage: $0 --scheduler-url URL --enrollment-token TOKEN"
21
+ exit 1
22
+ fi
23
+
24
+ id "robopark" 2>/dev/null || useradd -r -s /bin/false robopark
25
+ install -d -m 755 -o robopark -g robopark /opt/robopark-pi-client
26
+ install -d -m 700 -o robopark -g robopark /etc/robopark
27
+ cp -r ./* /opt/robopark-pi-client/
28
+ chown -R robopark:robopark /opt/robopark-pi-client
29
+
30
+ sudo -u robopark python3 -m venv /opt/robopark-pi-client/.venv
31
+ /opt/robopark-pi-client/.venv/bin/pip install --upgrade pip
32
+ /opt/robopark-pi-client/.venv/bin/pip install -r /opt/robopark-pi-client/requirements.txt
33
+ # On Pi you typically want the full media stack:
34
+ /opt/robopark-pi-client/.venv/bin/pip install livekit livekit-api sounddevice numpy opencv-python av
35
+
36
+ cp /opt/robopark-pi-client/robopark-pi-client.service /etc/systemd/system/
37
+ systemctl daemon-reload
38
+ systemctl enable --now robopark-pi-client.service
39
+
40
+ echo "First-boot enrollment will run as part of the service start."
41
41
  echo "Tail logs with: journalctl -u robopark-pi-client -f"
@@ -1,55 +1,55 @@
1
- #!/usr/bin/env bash
2
- # Join a LiveKit conversation from the Pi.
3
- #
4
- # Calls the local Pi UI's /api/join endpoint, which itself asks the scheduler
5
- # for a short-lived LiveKit access token. The script prints the meet URL
6
- # AND (if --open) opens it in the default browser on the Pi.
7
- #
8
- # Usage:
9
- # ./join_convo.sh
10
- # ./join_convo.sh --room robopark-pi-test --identity alice --open
11
- # PI_UI=http://192.168.1.159:8081 ./join_convo.sh --open
12
-
13
- set -euo pipefail
14
-
15
- PI_UI="${PI_UI:-http://127.0.0.1:8081}"
16
- ROOM="${ROOM:-robopark-pi-test}"
17
- IDENTITY="${IDENTITY:-}"
18
- OPEN=0
19
-
20
- while [[ $# -gt 0 ]]; do
21
- case "$1" in
22
- --room) ROOM="$2"; shift 2 ;;
23
- --identity) IDENTITY="$2"; shift 2 ;;
24
- --open) OPEN=1; shift ;;
25
- --pi-ui) PI_UI="$2"; shift 2 ;;
26
- -h|--help)
27
- sed -n '2,12p' "$0"; exit 0 ;;
28
- *) echo "unknown arg: $1" >&2; exit 1 ;;
29
- esac
30
- done
31
-
32
- BODY=$(jq -nc --arg room "$ROOM" --arg identity "$IDENTITY" \
33
- '{room:$room, identity:$identity}')
34
-
35
- echo "Requesting token from $PI_UI (room=$ROOM identity=$IDENTITY)..."
36
- RESP=$(curl -fsS -X POST "$PI_UI/api/join" \
37
- -H 'Content-Type: application/json' -d "$BODY")
38
-
39
- URL=$(echo "$RESP" | jq -r '
40
- "https://meet.livekit.io/custom?livekitUrl=" + (.url | @uri) +
41
- "&token=" + (.token | @uri)
42
- ')
43
-
44
- EXPIRES=$(echo "$RESP" | jq -r '.expires_at')
45
- echo "Token expires: $EXPIRES"
46
- echo "Meet URL:"
47
- echo " $URL"
48
-
49
- if [[ $OPEN -eq 1 ]]; then
50
- if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true
51
- elif command -v sensible-browser >/dev/null 2>&1; then sensible-browser "$URL" >/dev/null 2>&1 || true
52
- elif command -v open >/dev/null 2>&1; then open "$URL" >/dev/null 2>&1 || true
53
- else echo "no browser opener found; copy the URL above" >&2; exit 2; fi
54
- echo "(opened in browser)"
1
+ #!/usr/bin/env bash
2
+ # Join a LiveKit conversation from the Pi.
3
+ #
4
+ # Calls the local Pi UI's /api/join endpoint, which itself asks the scheduler
5
+ # for a short-lived LiveKit access token. The script prints the meet URL
6
+ # AND (if --open) opens it in the default browser on the Pi.
7
+ #
8
+ # Usage:
9
+ # ./join_convo.sh
10
+ # ./join_convo.sh --room robopark-pi-test --identity alice --open
11
+ # PI_UI=http://192.168.1.159:8081 ./join_convo.sh --open
12
+
13
+ set -euo pipefail
14
+
15
+ PI_UI="${PI_UI:-http://127.0.0.1:8081}"
16
+ ROOM="${ROOM:-robopark-pi-test}"
17
+ IDENTITY="${IDENTITY:-}"
18
+ OPEN=0
19
+
20
+ while [[ $# -gt 0 ]]; do
21
+ case "$1" in
22
+ --room) ROOM="$2"; shift 2 ;;
23
+ --identity) IDENTITY="$2"; shift 2 ;;
24
+ --open) OPEN=1; shift ;;
25
+ --pi-ui) PI_UI="$2"; shift 2 ;;
26
+ -h|--help)
27
+ sed -n '2,12p' "$0"; exit 0 ;;
28
+ *) echo "unknown arg: $1" >&2; exit 1 ;;
29
+ esac
30
+ done
31
+
32
+ BODY=$(jq -nc --arg room "$ROOM" --arg identity "$IDENTITY" \
33
+ '{room:$room, identity:$identity}')
34
+
35
+ echo "Requesting token from $PI_UI (room=$ROOM identity=$IDENTITY)..."
36
+ RESP=$(curl -fsS -X POST "$PI_UI/api/join" \
37
+ -H 'Content-Type: application/json' -d "$BODY")
38
+
39
+ URL=$(echo "$RESP" | jq -r '
40
+ "https://meet.livekit.io/custom?livekitUrl=" + (.url | @uri) +
41
+ "&token=" + (.token | @uri)
42
+ ')
43
+
44
+ EXPIRES=$(echo "$RESP" | jq -r '.expires_at')
45
+ echo "Token expires: $EXPIRES"
46
+ echo "Meet URL:"
47
+ echo " $URL"
48
+
49
+ if [[ $OPEN -eq 1 ]]; then
50
+ if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true
51
+ elif command -v sensible-browser >/dev/null 2>&1; then sensible-browser "$URL" >/dev/null 2>&1 || true
52
+ elif command -v open >/dev/null 2>&1; then open "$URL" >/dev/null 2>&1 || true
53
+ else echo "no browser opener found; copy the URL above" >&2; exit 2; fi
54
+ echo "(opened in browser)"
55
55
  fi
@@ -78,6 +78,9 @@ async def run_livekit(
78
78
  scheduler_url: str = "",
79
79
  device_id: str = "",
80
80
  device_token: str = "",
81
+ video_device: str = "auto",
82
+ audio_device: str = "default",
83
+ audio_output_device: str = "default",
81
84
  ):
82
85
  """Join a LiveKit room, publish mic + cam, subscribe to data channel.
83
86
 
@@ -140,7 +143,8 @@ async def run_livekit(
140
143
  try:
141
144
  stream = rtc.AudioStream(track=track)
142
145
  # Open output stream at 48kHz mono (Pi speaker)
143
- out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0)
146
+ output = None if str(audio_output_device).lower() in ("", "default", "auto") else audio_output_device
147
+ out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0, device=output)
144
148
  out.start()
145
149
  _active_streams[sid] = out
146
150
 
@@ -203,10 +207,13 @@ async def run_livekit(
203
207
  except Exception as e:
204
208
  log.warning(f"sounddevice unavailable, skipping mic: {e}")
205
209
  return
206
- # The USB mic is ALSA device index 1 (1 in, 0 out). Default is output-only.
207
- MIC_DEVICE = 1
210
+ selected_mic = str(audio_device or "default")
211
+ MIC_DEVICE = None if selected_mic.lower() in ("", "default", "auto") else selected_mic
208
212
  try:
209
- log.info(f"mic: opening device {MIC_DEVICE} ({sd.query_devices()[MIC_DEVICE]['name']})")
213
+ if MIC_DEVICE is None:
214
+ log.info("mic: opening system default input")
215
+ else:
216
+ log.info(f"mic: opening device {MIC_DEVICE}")
210
217
  except Exception:
211
218
  log.warning(f"mic: device {MIC_DEVICE} not found, trying default")
212
219
  MIC_DEVICE = None
@@ -241,9 +248,11 @@ async def run_livekit(
241
248
  except Exception as e:
242
249
  log.warning(f"opencv unavailable, skipping camera: {e}")
243
250
  return
244
- cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
251
+ selected_camera = str(video_device or "auto")
252
+ camera_index = 0 if selected_camera.lower() in ("", "auto", "default", "first") else selected_camera
253
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_V4L2)
245
254
  if not cap.isOpened():
246
- log.warning("camera /dev/video0 not available")
255
+ log.warning(f"camera {selected_camera} not available")
247
256
  return
248
257
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
249
258
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
@@ -286,4 +295,4 @@ async def run_livekit(
286
295
  except Exception: pass
287
296
  try: await room.disconnect()
288
297
  except Exception: pass
289
- log.info("left room, will loop to reconnect")
298
+ log.info("left room, will loop to reconnect")
@@ -11,6 +11,7 @@ Command wire format (JSON on the data channel):
11
11
  """
12
12
 
13
13
  import asyncio
14
+ import os
14
15
  import json
15
16
  import logging
16
17
  from typing import Awaitable, Callable, Optional
@@ -24,6 +25,8 @@ class MotorBridge:
24
25
  def __init__(self, motor_server_url: Optional[str], dry_run: bool = False):
25
26
  self.motor_server_url = (motor_server_url or "").rstrip("/")
26
27
  self.dry_run = dry_run
28
+ token = os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip() or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
29
+ self.headers = {"X-RoboPark-Motor-Token": token} if token else {}
27
30
  self._lock = asyncio.Lock() # the local motor server allows one motor at a time
28
31
 
29
32
  async def handle_command(self, raw: str | bytes) -> str:
@@ -60,7 +63,7 @@ class MotorBridge:
60
63
  async with httpx.AsyncClient(timeout=seconds + 5.0) as c:
61
64
  r = await c.post(
62
65
  f"{self.motor_server_url}/trigger-motor",
63
- json={"motor_name": name, "seconds": seconds},
66
+ json={"motor_name": name, "seconds": seconds}, headers=self.headers,
64
67
  )
65
68
  if r.status_code == 200:
66
69
  return f"moved {name} for {seconds}s"
@@ -75,8 +78,8 @@ class MotorBridge:
75
78
  return "dry-run: stop"
76
79
  try:
77
80
  async with httpx.AsyncClient(timeout=5.0) as c:
78
- r = await c.post(f"{self.motor_server_url}/stop-motors", json={})
81
+ r = await c.post(f"{self.motor_server_url}/stop-motors", json={}, headers=self.headers)
79
82
  return f"stop {r.status_code}"
80
83
  except Exception as e:
81
84
  log.error(f"stop_all failed: {e}")
82
- return f"error: {e}"
85
+ return f"error: {e}"
@@ -0,0 +1,75 @@
1
+ {
2
+ "schema_version": 1,
3
+ "revision": 1,
4
+ "tailnet": "tail2086c6.ts.net",
5
+ "gateway": {
6
+ "name": "robo1",
7
+ "dns": "robo1.tail2086c6.ts.net",
8
+ "tailscale_ip": "100.84.147.24",
9
+ "scheduler_url": "http://robo1.tail2086c6.ts.net:8080",
10
+ "dashboard_url": "http://robo1.tail2086c6.ts.net:8080",
11
+ "robovoice_url": "https://robo1.tail2086c6.ts.net:3443",
12
+ "livekit_url": "wss://robo1.tail2086c6.ts.net:3443",
13
+ "management_url": "http://robo1.tail2086c6.ts.net:47913"
14
+ },
15
+ "voice_video_devices": [
16
+ {
17
+ "name": "jaguar-voice",
18
+ "dns": "jaguar-voice.tail2086c6.ts.net",
19
+ "tailscale_ip": "100.107.146.50",
20
+ "character": "jaguar"
21
+ },
22
+ {
23
+ "name": "magnus-voice",
24
+ "dns": "magnus-voice.tail2086c6.ts.net",
25
+ "tailscale_ip": "100.92.64.10",
26
+ "character": "magnus"
27
+ },
28
+ {
29
+ "name": "paladin-voice",
30
+ "dns": "paladin-voice.tail2086c6.ts.net",
31
+ "tailscale_ip": "100.116.232.45",
32
+ "character": "palladin"
33
+ },
34
+ {
35
+ "name": "ronin-voice",
36
+ "dns": "ronin-voice.tail2086c6.ts.net",
37
+ "tailscale_ip": "100.64.213.41",
38
+ "character": "ronin"
39
+ },
40
+ {
41
+ "name": "titan-voice",
42
+ "dns": "titan-voice.tail2086c6.ts.net",
43
+ "tailscale_ip": "100.126.121.92",
44
+ "character": "titan"
45
+ },
46
+ {
47
+ "name": "vixen-voice",
48
+ "dns": "vixen-voice.tail2086c6.ts.net",
49
+ "tailscale_ip": "100.112.220.46",
50
+ "character": "vixen"
51
+ }
52
+ ],
53
+ "motor_camera_devices": [
54
+ {
55
+ "name": "bmw",
56
+ "dns": "bmw.tail2086c6.ts.net",
57
+ "tailscale_ip": "100.84.123.35",
58
+ "character": "vixen"
59
+ },
60
+ {
61
+ "name": "titan",
62
+ "dns": "titan.tail2086c6.ts.net",
63
+ "tailscale_ip": "100.78.77.96",
64
+ "character": "titan"
65
+ }
66
+ ],
67
+ "unassigned_tailnet_devices": [
68
+ {
69
+ "name": "voicevoice",
70
+ "dns": "voicevoice.tail2086c6.ts.net",
71
+ "tailscale_ip": "100.79.79.58",
72
+ "reason": "Existing tagged voice device has no verified character binding."
73
+ }
74
+ ]
75
+ }