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
@@ -1,4 +1,5 @@
1
- from fastapi import FastAPI, HTTPException
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.responses import JSONResponse
2
3
  from fastapi.middleware.cors import CORSMiddleware
3
4
  from pydantic import BaseModel
4
5
  from typing import Optional, List
@@ -6,20 +7,46 @@ import time
6
7
  import threading
7
8
  import json
8
9
  import os
10
+ import hmac
9
11
 
10
- app = FastAPI(title="Motor Control API", version="1.0.0")
12
+ try:
13
+ import lgpio
14
+ except ImportError:
15
+ lgpio = None
11
16
 
12
- # CORS Configuration - Allow all origins for cross-origin requests
17
+ app = FastAPI(title="RoboPark Motor Control API", version="1.2.0")
18
+
19
+ ROBOT_NAME = os.getenv("ROBOPARK_ROBOT_NAME", "robot").strip() or "robot"
20
+
21
+ MOTOR_API_TOKEN = (
22
+ os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
23
+ or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
24
+ )
25
+
26
+
27
+ @app.middleware("http")
28
+ async def require_motor_token(request: Request, call_next):
29
+ """Keep actuation private even if an operator accidentally changes the bind host."""
30
+ if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
31
+ supplied = request.headers.get("x-robopark-motor-token", "").strip()
32
+ if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
33
+ return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
34
+ return await call_next(request)
35
+
36
+ # The dashboard never calls this service directly; robot-local services do.
13
37
  app.add_middleware(
14
38
  CORSMiddleware,
15
- allow_origins=["*"], # Allows all origins
16
- allow_credentials=True,
17
- allow_methods=["*"], # Allows all methods (GET, POST, PUT, DELETE, OPTIONS)
18
- allow_headers=["*"], # Allows all headers
39
+ allow_origins=["http://127.0.0.1", "http://localhost"],
40
+ allow_credentials=False,
41
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
42
+ allow_headers=["content-type", "x-robopark-motor-token"],
19
43
  )
20
44
 
21
45
  # Storage file
22
- MOTORS_FILE = "motors.json"
46
+ MOTORS_FILE = os.getenv(
47
+ "ROBOPARK_MOTORS_FILE",
48
+ os.path.expanduser("~/.robopark/motors.json"),
49
+ )
23
50
 
24
51
  # In-memory storage
25
52
  motors = {}
@@ -28,6 +55,10 @@ remaining_seconds = 0
28
55
  last_action = "None"
29
56
  motor_thread = None
30
57
  stop_flag = False
58
+ motor_error = None
59
+ gpio_handle = None
60
+ claimed_pins = set()
61
+ motor_lock = threading.Lock()
31
62
 
32
63
  # Load motors from file on startup
33
64
  def load_motors():
@@ -37,6 +68,14 @@ def load_motors():
37
68
  if os.path.exists(MOTORS_FILE):
38
69
  with open(MOTORS_FILE, 'r') as f:
39
70
  motors = json.load(f)
71
+ motors = {
72
+ str(name): ({"name": str(name), "gpio": int(value), "active_high": True}
73
+ if not isinstance(value, dict) else {
74
+ "name": str(value.get("name") or name), "gpio": int(value["gpio"]),
75
+ "active_high": bool(value.get("active_high", True)),
76
+ })
77
+ for name, value in motors.items()
78
+ }
40
79
  print(f"[LOAD] Loaded {len(motors)} motors from {MOTORS_FILE}")
41
80
  for name, gpio in motors.items():
42
81
  print(f" - {name}: GPIO {gpio}")
@@ -49,8 +88,13 @@ def load_motors():
49
88
  def save_motors():
50
89
  """Save motors to motors.json file"""
51
90
  try:
52
- with open(MOTORS_FILE, 'w') as f:
91
+ os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
92
+ temporary = f"{MOTORS_FILE}.tmp"
93
+ with open(temporary, 'w') as f:
53
94
  json.dump(motors, f, indent=2)
95
+ f.flush()
96
+ os.fsync(f.fileno())
97
+ os.replace(temporary, MOTORS_FILE)
54
98
  print(f"[SAVE] Motors saved to {MOTORS_FILE}")
55
99
  except Exception as e:
56
100
  print(f"[ERROR] Failed to save motors: {e}")
@@ -62,52 +106,98 @@ load_motors()
62
106
  class MotorConfig(BaseModel):
63
107
  name: str
64
108
  gpio: int
109
+ active_high: bool = True
65
110
 
66
111
  class UpdateMotorConfig(BaseModel):
67
112
  name: str
68
113
  gpio: int
114
+ active_high: bool = True
69
115
 
70
116
  class TriggerMotor(BaseModel):
71
117
  motor_name: str
72
- seconds: int = 5
118
+ seconds: float = 1.0
73
119
 
74
120
  class StopMotors(BaseModel):
75
121
  motor_name: Optional[str] = None
76
122
 
77
123
  class TestConfig(BaseModel):
78
- seconds_on: int = 2
79
- seconds_pause: int = 1
124
+ seconds_on: float = 0.3
125
+ seconds_pause: float = 0.2
80
126
  pins: Optional[List[int]] = None
81
127
 
82
128
  # Helper Functions
83
- def run_motor_thread(motor_name: str, duration: int):
84
- global active_motor, remaining_seconds, last_action, stop_flag
85
-
86
- active_motor = motor_name
87
- remaining_seconds = duration
88
- last_action = f"Triggered {motor_name}"
89
-
90
- print(f"[MOTOR] Starting {motor_name} for {duration} seconds")
91
-
92
- # Simulate motor running
93
- for i in range(duration):
94
- if stop_flag:
95
- print(f"[MOTOR] {motor_name} stopped by user")
96
- break
97
- remaining_seconds = duration - i
98
- time.sleep(1)
99
-
100
- active_motor = None
101
- remaining_seconds = 0
102
- stop_flag = False
103
- print(f"[MOTOR] {motor_name} completed")
129
+ def _motor_value(motor: dict, active: bool) -> int:
130
+ return int(active == bool(motor.get("active_high", True)))
131
+
132
+ def _claim_motor(motor: dict) -> None:
133
+ global gpio_handle
134
+ pin = int(motor["gpio"])
135
+ if pin < 2 or pin > 27:
136
+ raise ValueError(f"BCM GPIO {pin} is outside 2..27")
137
+ if lgpio is None:
138
+ raise RuntimeError("lgpio is not installed; refusing simulated production actuation")
139
+ if gpio_handle is None:
140
+ gpio_handle = lgpio.gpiochip_open(0)
141
+ if pin not in claimed_pins:
142
+ lgpio.gpio_claim_output(gpio_handle, pin, _motor_value(motor, False))
143
+ claimed_pins.add(pin)
144
+
145
+ def _write_motor(motor: dict, active: bool) -> None:
146
+ _claim_motor(motor)
147
+ lgpio.gpio_write(gpio_handle, int(motor["gpio"]), _motor_value(motor, active))
148
+
149
+ def _all_off() -> None:
150
+ for motor in motors.values():
151
+ try:
152
+ if isinstance(motor, dict):
153
+ _write_motor(motor, False)
154
+ except Exception as exc:
155
+ print(f"[GPIO] failed to de-energize {motor}: {exc}")
156
+
157
+ def run_motor_thread(motor_name: str, duration: float):
158
+ global active_motor, remaining_seconds, last_action, stop_flag, motor_error
159
+
160
+ motor = motors[motor_name]
161
+ started = time.monotonic()
162
+ print(f"[MOTOR] Starting {motor_name} for {duration:.3f} seconds")
163
+ try:
164
+ _write_motor(motor, True)
165
+ while not stop_flag and time.monotonic() - started < duration:
166
+ remaining_seconds = max(0, duration - (time.monotonic() - started))
167
+ time.sleep(min(0.05, remaining_seconds or 0.01))
168
+ except Exception as exc:
169
+ motor_error = f"{type(exc).__name__}: {exc}"
170
+ last_action = f"Failed {motor_name}: {motor_error}"
171
+ print(f"[ERROR] Motor {motor_name} failed: {motor_error}")
172
+ finally:
173
+ try:
174
+ _write_motor(motor, False)
175
+ finally:
176
+ with motor_lock:
177
+ active_motor = None
178
+ remaining_seconds = 0
179
+ stop_flag = False
180
+ print(f"[MOTOR] {motor_name} completed and GPIO is OFF")
181
+
182
+ @app.on_event("shutdown")
183
+ def shutdown_gpio() -> None:
184
+ global gpio_handle
185
+ _all_off()
186
+ if lgpio is not None and gpio_handle is not None:
187
+ try:
188
+ lgpio.gpiochip_close(gpio_handle)
189
+ finally:
190
+ gpio_handle = None
191
+ claimed_pins.clear()
104
192
 
105
193
  # API Endpoints
106
194
  @app.get("/")
107
195
  async def root():
108
196
  return {
109
197
  "message": "Motor Control API",
110
- "version": "1.0.0",
198
+ "version": "1.2.0",
199
+ "robot": ROBOT_NAME,
200
+ "authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
111
201
  "endpoints": ["/status", "/test", "/add-motor", "/trigger-motor", "/list-motors", "/stop-motors"]
112
202
  }
113
203
 
@@ -119,17 +209,25 @@ async def get_status():
119
209
  "active_motor": active_motor,
120
210
  "remaining_seconds": remaining_seconds,
121
211
  "last_action": last_action,
122
- "total_motors": len(motors)
212
+ "error": motor_error,
213
+ "total_motors": len(motors),
214
+ "robot": ROBOT_NAME,
215
+ "gpio_available": lgpio is not None,
123
216
  }
124
217
 
125
218
  @app.post("/add-motor")
126
219
  async def add_motor(config: MotorConfig):
220
+ if config.gpio < 2 or config.gpio > 27:
221
+ raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
127
222
  if config.name in motors:
128
223
  raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
224
+ if any(int(item["gpio"]) == config.gpio for item in motors.values()):
225
+ raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
129
226
 
130
227
  motors[config.name] = {
131
228
  "name": config.name,
132
- "gpio": config.gpio
229
+ "gpio": config.gpio,
230
+ "active_high": config.active_high,
133
231
  }
134
232
  save_motors()
135
233
 
@@ -150,8 +248,22 @@ async def list_motors():
150
248
  "motors": motor_list
151
249
  }
152
250
 
251
+ @app.get("/discover")
252
+ async def discover_motors():
253
+ """Return the relay registry and GPIO capability for fleet discovery."""
254
+ motor_list = list(motors.values())
255
+ return {
256
+ "status": "success",
257
+ "robot": ROBOT_NAME,
258
+ "gpio_available": lgpio is not None,
259
+ "count": len(motor_list),
260
+ "motors": motor_list,
261
+ }
262
+
153
263
  @app.put("/update-motor/{motor_name}")
154
264
  async def update_motor(motor_name: str, config: UpdateMotorConfig):
265
+ if config.gpio < 2 or config.gpio > 27:
266
+ raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
155
267
  if motor_name not in motors:
156
268
  raise HTTPException(status_code=404, detail=f"Motor '{motor_name}' not found")
157
269
 
@@ -160,13 +272,16 @@ async def update_motor(motor_name: str, config: UpdateMotorConfig):
160
272
 
161
273
  if config.name != motor_name and config.name in motors:
162
274
  raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
275
+ if any(name != motor_name and int(item["gpio"]) == config.gpio for name, item in motors.items()):
276
+ raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
163
277
 
164
278
  if config.name != motor_name:
165
279
  del motors[motor_name]
166
280
 
167
281
  motors[config.name] = {
168
282
  "name": config.name,
169
- "gpio": config.gpio
283
+ "gpio": config.gpio,
284
+ "active_high": config.active_high,
170
285
  }
171
286
  save_motors()
172
287
 
@@ -199,17 +314,29 @@ async def delete_motor(motor_name: str):
199
314
 
200
315
  @app.post("/trigger-motor")
201
316
  async def trigger_motor(config: TriggerMotor):
202
- global motor_thread, stop_flag
317
+ global motor_thread, stop_flag, active_motor, remaining_seconds, last_action, motor_error
203
318
 
204
319
  if config.motor_name not in motors:
205
320
  raise HTTPException(status_code=404, detail=f"Motor '{config.motor_name}' not found")
206
321
 
207
- if active_motor:
208
- raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
322
+ if config.seconds < 0.05 or config.seconds > 10.0:
323
+ raise HTTPException(status_code=422, detail="Pulse duration must be 0.05..10 seconds")
209
324
 
210
- stop_flag = False
211
- motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds))
212
- motor_thread.start()
325
+ try:
326
+ _claim_motor(motors[config.motor_name])
327
+ except Exception as exc:
328
+ raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
329
+
330
+ with motor_lock:
331
+ if active_motor:
332
+ raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
333
+ stop_flag = False
334
+ motor_error = None
335
+ active_motor = config.motor_name
336
+ remaining_seconds = config.seconds
337
+ last_action = f"Triggered {config.motor_name}"
338
+ motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds), daemon=True)
339
+ motor_thread.start()
213
340
 
214
341
  return {
215
342
  "status": "success",
@@ -224,6 +351,7 @@ async def stop_motors(config: Optional[StopMotors] = None):
224
351
 
225
352
  try:
226
353
  if not active_motor:
354
+ _all_off()
227
355
  return {
228
356
  "status": "success",
229
357
  "message": "No motors are currently running"
@@ -235,6 +363,7 @@ async def stop_motors(config: Optional[StopMotors] = None):
235
363
  # Wait for thread to finish
236
364
  if motor_thread and motor_thread.is_alive():
237
365
  motor_thread.join(timeout=2)
366
+ _all_off()
238
367
 
239
368
  return {
240
369
  "status": "success",
@@ -253,12 +382,29 @@ async def stop_motors(config: Optional[StopMotors] = None):
253
382
 
254
383
  @app.post("/test")
255
384
  async def run_test(config: TestConfig):
256
- global last_action
385
+ global last_action, active_motor, motor_error
257
386
 
258
- if active_motor:
259
- raise HTTPException(status_code=409, detail="A motor is already running")
387
+ if config.seconds_on < 0.05 or config.seconds_on > 10.0:
388
+ raise HTTPException(status_code=422, detail="Test pulse duration must be 0.05..10 seconds")
389
+ if config.seconds_pause < 0 or config.seconds_pause > 10.0:
390
+ raise HTTPException(status_code=422, detail="Test pause must be 0..10 seconds")
260
391
 
261
- pins = config.pins if config.pins else list(range(1, 28))
392
+ registered_pins = {int(m["gpio"]) for m in motors.values() if isinstance(m, dict)}
393
+ pins = config.pins if config.pins else sorted(registered_pins)
394
+ if not pins or any(pin not in registered_pins for pin in pins):
395
+ raise HTTPException(status_code=422, detail="Relay tests are limited to registered GPIO pins")
396
+ try:
397
+ for motor in motors.values():
398
+ if int(motor["gpio"]) in pins:
399
+ _claim_motor(motor)
400
+ except Exception as exc:
401
+ raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
402
+
403
+ with motor_lock:
404
+ if active_motor:
405
+ raise HTTPException(status_code=409, detail="A motor is already running")
406
+ active_motor = "relay-test"
407
+ motor_error = None
262
408
 
263
409
  print(f"[TEST] Running test sequence:")
264
410
  print(f" - Seconds ON: {config.seconds_on}")
@@ -267,19 +413,32 @@ async def run_test(config: TestConfig):
267
413
 
268
414
  last_action = f"Test sequence started"
269
415
 
270
- # Simulate test sequence
271
416
  def test_sequence():
272
- global last_action
273
- for pin in pins:
274
- last_action = f"test:gpio:{pin}"
275
- print(f">>> TESTING GPIO {pin}")
276
- time.sleep(config.seconds_on)
277
- print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
278
- time.sleep(config.seconds_pause)
279
- last_action = "Test sequence completed"
280
- print("[TEST] Test sequence completed")
281
-
282
- test_thread = threading.Thread(target=test_sequence)
417
+ global last_action, active_motor, motor_error
418
+ try:
419
+ for pin in pins:
420
+ last_action = f"test:gpio:{pin}"
421
+ motor = next(m for m in motors.values() if int(m["gpio"]) == pin)
422
+ print(f">>> TESTING GPIO {pin}")
423
+ try:
424
+ _write_motor(motor, True)
425
+ time.sleep(config.seconds_on)
426
+ finally:
427
+ _write_motor(motor, False)
428
+ print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
429
+ time.sleep(config.seconds_pause)
430
+ last_action = "Test sequence completed"
431
+ print("[TEST] Test sequence completed")
432
+ except Exception as exc:
433
+ motor_error = f"{type(exc).__name__}: {exc}"
434
+ last_action = f"Relay test failed: {motor_error}"
435
+ print(f"[ERROR] Relay test failed: {motor_error}")
436
+ finally:
437
+ _all_off()
438
+ with motor_lock:
439
+ active_motor = None
440
+
441
+ test_thread = threading.Thread(target=test_sequence, daemon=True)
283
442
  test_thread.start()
284
443
 
285
444
  return {
@@ -297,8 +456,12 @@ if __name__ == "__main__":
297
456
  print("=" * 60)
298
457
  print("Motor Control API Server")
299
458
  print("=" * 60)
300
- print("Starting server on http://0.0.0.0:8001")
459
+ print("Starting server on http://127.0.0.1:8001")
301
460
  print("API Documentation: http://localhost:8001/docs")
302
461
  print("ReDoc: http://localhost:8001/redoc")
303
462
  print("=" * 60)
304
- uvicorn.run(app, host="0.0.0.0", port=8001)
463
+ uvicorn.run(
464
+ app,
465
+ host=os.getenv("ROBOPARK_MOTOR_HOST", "127.0.0.1"),
466
+ port=int(os.getenv("ROBOPARK_MOTOR_PORT", "8001")),
467
+ )
@@ -0,0 +1,6 @@
1
+ # Camera-only dependencies used by `robopark conversation up`.
2
+ # OpenCV is supplied by apt on Raspberry Pi through --system-site-packages.
3
+ Flask==3.0.0
4
+ Flask-CORS==4.0.0
5
+ requests>=2.31
6
+ numpy>=2.1; python_version >= '3.13'
@@ -0,0 +1,4 @@
1
+ fastapi>=0.115
2
+ uvicorn>=0.30
3
+ pydantic>=2.7
4
+ lgpio==0.2.2.0; platform_system == 'Linux'
@@ -68,6 +68,7 @@ pydantic==2.5.0
68
68
  # ============================================================================
69
69
  # HTTP requests
70
70
  requests==2.31.0
71
+ groq>=0.11
71
72
 
72
73
  # CORS support
73
74
  python-multipart==0.0.6
@@ -0,0 +1,19 @@
1
+ # Runtime requirements for the packaged RoboPark vision-agent.
2
+ #
3
+ # This intentionally excludes the legacy optional YOLO/GPIO stack from
4
+ # requirements_pi_unified.txt. The packaged agent launches app_pi_clean.py and
5
+ # audio_server_pi.py only. Raspberry Pi OpenCV/PyAudio/Picamera2 are supplied
6
+ # by apt and exposed to ~/.robopark/venv through --system-site-packages.
7
+ Flask==3.0.0
8
+ Flask-CORS==4.0.0
9
+ requests>=2.31
10
+ fastapi>=0.115
11
+ uvicorn[standard]>=0.30
12
+ python-multipart>=0.0.12
13
+ sounddevice>=0.4.6
14
+ soundfile>=0.12.1
15
+ groq>=0.11
16
+ lgpio==0.2.2.0; platform_system == 'Linux'
17
+ # Python 3.13 has no compatible NumPy 1.24 wheel. On older Pi images, use the
18
+ # distro NumPy provided through system-site-packages instead.
19
+ numpy>=2.1; python_version >= '3.13'