robopark 2.8.9 → 2.8.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robopark",
3
- "version": "2.8.9",
3
+ "version": "2.8.11",
4
4
  "description": "RoboPark fleet control CLI — set up, watch, and drive a fleet of talking robots. The operator front-end over the infinicode mesh.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,12 @@ FROM python:3.11-slim
3
3
 
4
4
  WORKDIR /app
5
5
 
6
- # Install dependencies
6
+ # Install build dependencies for pyaudio / opencv wheels, then Python packages.
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ gcc g++ libportaudio2 portaudio19-dev \
9
+ libgl1 libglib2.0-0 libsm6 libxext6 libxrender-dev \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
7
12
  COPY requirements.txt .
8
13
  RUN pip install --no-cache-dir -r requirements.txt
9
14
 
package/scheduler/main.py CHANGED
@@ -2542,7 +2542,7 @@ async def dashboard():
2542
2542
  <header class="mb-8 flex justify-between items-center">
2543
2543
  <div>
2544
2544
  <h1 class="text-3xl font-bold">🤖 RoboPark Control Center</h1>
2545
- <p class="text-gray-400">Session Scheduler Dashboard</p>
2545
+ <p class="text-gray-400">Session Scheduler Dashboard — Shenzhen Bay Park (test site)</p>
2546
2546
  </div>
2547
2547
  <div class="flex items-center gap-4">
2548
2548
  <button @click="openLiveKitConfig"
@@ -2902,6 +2902,160 @@ async def dashboard():
2902
2902
  </html>
2903
2903
  """
2904
2904
 
2905
+ # ── Test robot client page (browser-based, zero install on robot laptop) ──
2906
+ @app.get("/robot", response_class=HTMLResponse)
2907
+ async def robot_client_page():
2908
+ """Browser-based robot client for LAN testing.
2909
+
2910
+ Opens the laptop's webcam + mic, enrolls with the scheduler, and joins the
2911
+ LiveKit room returned by /request-session. Useful for iterating on scene
2912
+ detection, session triggering, and dashboard streaming before deploying to
2913
+ real Pis.
2914
+ """
2915
+ return """
2916
+ <!DOCTYPE html>
2917
+ <html>
2918
+ <head>
2919
+ <title>RoboPark Test Robot</title>
2920
+ <meta charset="utf-8">
2921
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2922
+ <script src="https://cdn.tailwindcss.com"></script>
2923
+ <script src="https://cdn.jsdelivr.net/npm/livekit-client@1/dist/livekit-client.umd.min.js"></script>
2924
+ </head>
2925
+ <body class="bg-gray-900 text-white min-h-screen p-6">
2926
+ <div class="max-w-xl mx-auto space-y-4">
2927
+ <h1 class="text-2xl font-bold">RoboPark Test Robot</h1>
2928
+ <div id="status" class="text-sm text-gray-400">Idle — configure and start</div>
2929
+
2930
+ <div class="space-y-2">
2931
+ <label class="block text-sm">Scheduler URL</label>
2932
+ <input id="schedulerUrl" value="http://192.168.1.2:8080" class="w-full p-2 rounded bg-gray-800 border border-gray-700">
2933
+ </div>
2934
+ <div class="space-y-2">
2935
+ <label class="block text-sm">Enrollment Token</label>
2936
+ <input id="enrollmentToken" value="" placeholder="Paste token here" class="w-full p-2 rounded bg-gray-800 border border-gray-700">
2937
+ </div>
2938
+ <div class="space-y-2">
2939
+ <label class="block text-sm">Robot Name</label>
2940
+ <input id="robotName" value="laptop-robot" class="w-full p-2 rounded bg-gray-800 border border-gray-700">
2941
+ </div>
2942
+ <div class="flex gap-2">
2943
+ <button id="enrollBtn" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500">1. Enroll</button>
2944
+ <button id="startBtn" class="px-4 py-2 bg-green-600 rounded hover:bg-green-500" disabled>2. Start Camera + Trigger</button>
2945
+ <button id="triggerBtn" class="px-4 py-2 bg-yellow-600 rounded hover:bg-yellow-500" disabled>Trigger Session</button>
2946
+ <button id="stopBtn" class="px-4 py-2 bg-red-600 rounded hover:bg-red-500" disabled>Stop</button>
2947
+ </div>
2948
+ <video id="localVideo" autoplay muted playsinline class="w-full rounded border border-gray-700 bg-black"></video>
2949
+ <div id="log" class="text-xs font-mono h-48 overflow-y-auto bg-gray-800 p-2 rounded"></div>
2950
+ </div>
2951
+
2952
+ <script>
2953
+ const log = (msg) => {
2954
+ const el = document.getElementById('log');
2955
+ el.innerText += `[${new Date().toLocaleTimeString()}] ${msg}\n`;
2956
+ el.scrollTop = el.scrollHeight;
2957
+ };
2958
+ const setStatus = (msg) => document.getElementById('status').innerText = msg;
2959
+
2960
+ const schedulerUrl = () => document.getElementById('schedulerUrl').value.replace(/\/$/, '');
2961
+ let deviceToken = null;
2962
+ let deviceId = null;
2963
+ let room = null;
2964
+ let heartbeatInterval = null;
2965
+ let stream = null;
2966
+
2967
+ async function post(path, body, headers = {}) {
2968
+ const res = await fetch(`${schedulerUrl()}${path}`, {
2969
+ method: 'POST',
2970
+ headers: { 'Content-Type': 'application/json', ...headers },
2971
+ body: JSON.stringify(body)
2972
+ });
2973
+ if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
2974
+ return res.json();
2975
+ }
2976
+
2977
+ document.getElementById('enrollBtn').onclick = async () => {
2978
+ try {
2979
+ const token = document.getElementById('enrollmentToken').value.trim();
2980
+ if (!token) return alert('Enter enrollment token from scheduler logs');
2981
+ const payload = {
2982
+ enrollment_token: token,
2983
+ name: document.getElementById('robotName').value,
2984
+ lan_ip: '192.168.1.x'
2985
+ };
2986
+ const data = await post('/api/devices/enroll', payload);
2987
+ deviceId = data.device_id;
2988
+ deviceToken = data.device_token;
2989
+ log(`Enrolled: ${deviceId}`);
2990
+ setStatus(`Enrolled ${deviceId}`);
2991
+ document.getElementById('startBtn').disabled = false;
2992
+ heartbeatInterval = setInterval(async () => {
2993
+ await fetch(`${schedulerUrl()}/api/devices/${deviceId}/heartbeat`, {
2994
+ method: 'POST',
2995
+ headers: { 'Authorization': `Bearer ${deviceToken}`, 'Content-Type': 'application/json' },
2996
+ body: JSON.stringify({ status: 'online', ip: '192.168.1.x' })
2997
+ });
2998
+ }, 5000);
2999
+ } catch (e) {
3000
+ log(`Enroll error: ${e.message}`);
3001
+ }
3002
+ };
3003
+
3004
+ document.getElementById('startBtn').onclick = async () => {
3005
+ try {
3006
+ stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
3007
+ document.getElementById('localVideo').srcObject = stream;
3008
+ log('Camera + mic active');
3009
+ document.getElementById('triggerBtn').disabled = false;
3010
+ document.getElementById('stopBtn').disabled = false;
3011
+ setStatus('Camera active — ready to trigger');
3012
+ } catch (e) {
3013
+ log(`Media error: ${e.message}`);
3014
+ }
3015
+ };
3016
+
3017
+ document.getElementById('triggerBtn').onclick = async () => {
3018
+ try {
3019
+ setStatus('Requesting session...');
3020
+ const session = await post(`/api/devices/${deviceId}/request-session`, {}, { 'Authorization': `Bearer ${deviceToken}` });
3021
+ log(`Session: ${session.session_id} room=${session.room_name}`);
3022
+
3023
+ const { Room, RoomEvent } = LiveKitClient;
3024
+ room = new Room({
3025
+ adaptiveStream: true,
3026
+ dynacast: true,
3027
+ audioCaptureDefaults: { autoGainControl: true, noiseSuppression: true, echoCancellation: true }
3028
+ });
3029
+ room.on(RoomEvent.Connected, () => log('LiveKit connected'));
3030
+ room.on(RoomEvent.Disconnected, (reason) => log(`LiveKit disconnected: ${reason}`));
3031
+ room.on(RoomEvent.ConnectionStateChanged, (state) => log(`LiveKit state: ${state}`));
3032
+
3033
+ await room.connect(session.server_url, session.token);
3034
+ await room.localParticipant.enableCameraAndMicrophone();
3035
+ log('Published camera + mic to room');
3036
+ await post(`/api/sessions/${session.session_id}/joined`, {}, { 'Authorization': `Bearer ${deviceToken}` });
3037
+ log('Marked session joined (latency recorded)');
3038
+ setStatus(`Streaming: ${session.room_name}`);
3039
+ } catch (e) {
3040
+ log(`Trigger error: ${e.message}`);
3041
+ setStatus('Trigger failed');
3042
+ }
3043
+ };
3044
+
3045
+ document.getElementById('stopBtn').onclick = async () => {
3046
+ if (room) { await room.disconnect(); room = null; }
3047
+ if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
3048
+ if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
3049
+ log('Stopped');
3050
+ setStatus('Stopped');
3051
+ document.getElementById('triggerBtn').disabled = true;
3052
+ document.getElementById('stopBtn').disabled = true;
3053
+ };
3054
+ </script>
3055
+ </body>
3056
+ </html>
3057
+ """
3058
+
2905
3059
  if __name__ == "__main__":
2906
3060
  import uvicorn
2907
3061
  host = os.getenv("SCHEDULER_HOST", "0.0.0.0")
@@ -142,6 +142,7 @@ class PreviewAgent:
142
142
  self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
143
143
  self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
144
144
  self.device_id: Optional[str] = cfg.get("device_id")
145
+ self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
145
146
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
146
147
  self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
147
148
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
@@ -157,7 +158,7 @@ class PreviewAgent:
157
158
  self._publisher: Optional["LiveKitPublisher"] = None
158
159
 
159
160
  async def run(self) -> None:
160
- enrollment_token = os.getenv("ENROLLMENT_TOKEN") or cfg.get("enrollment_token")
161
+ enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
161
162
  if not self.device_token and enrollment_token:
162
163
  self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
163
164
 
@@ -627,7 +628,12 @@ def main() -> None:
627
628
  loop = asyncio.new_event_loop()
628
629
  asyncio.set_event_loop(loop)
629
630
  for sig in (signal.SIGINT, signal.SIGTERM):
630
- loop.add_signal_handler(sig, agent.shutdown)
631
+ try:
632
+ loop.add_signal_handler(sig, agent.shutdown)
633
+ except NotImplementedError:
634
+ # add_signal_handler is POSIX-only (raises on Windows) — fall back to
635
+ # signal.signal, dispatched back onto the loop thread-safely.
636
+ signal.signal(sig, lambda *_: loop.call_soon_threadsafe(agent.shutdown))
631
637
 
632
638
  try:
633
639
  loop.run_until_complete(agent.run())
@@ -0,0 +1,9 @@
1
+ # Python deps for the ROBOT/PI side only (preview_agent.py) — a subset of
2
+ # requirements.txt, which also covers the scheduler server (fastapi/uvicorn/
3
+ # aiosqlite/etc). Keeping this split means a Pi/robot install doesn't pull in
4
+ # server-only packages it will never use.
5
+ httpx==0.27.2
6
+ livekit>=0.18
7
+ opencv-python>=4.8
8
+ pyaudio>=0.2
9
+ picamera2>=0.3; platform_machine == 'aarch64' or platform_machine == 'arm64'