infinicode 2.8.12 → 2.8.15

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 (32) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +49 -9
  3. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  4. package/dist/robopark/preview-agent-launcher.js +8 -0
  5. package/dist/robopark-cli.js +3 -0
  6. package/package.json +3 -1
  7. package/packages/robopark/pi-client/README.md +17 -0
  8. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  9. package/packages/robopark/pi-client/client.py +305 -0
  10. package/packages/robopark/pi-client/install.sh +41 -0
  11. package/packages/robopark/pi-client/join_convo.sh +55 -0
  12. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  13. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  14. package/packages/robopark/pi-client/pi_ui.py +375 -0
  15. package/packages/robopark/pi-client/requirements.txt +10 -0
  16. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  17. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  18. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  19. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  20. package/packages/robopark/scheduler/main.py +45 -9
  21. package/packages/robopark/scheduler/preview_agent.py +132 -3
  22. package/packages/robopark/vision/.env.example +7 -0
  23. package/packages/robopark/vision/README.md +63 -0
  24. package/packages/robopark/vision/app_pi_clean.py +307 -0
  25. package/packages/robopark/vision/audio_server_pi.py +751 -0
  26. package/packages/robopark/vision/install.sh +34 -0
  27. package/packages/robopark/vision/motor_server.py +304 -0
  28. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  29. package/packages/robopark/vision/run.sh +244 -0
  30. package/packages/robopark/vision/services/services.sh +12 -0
  31. package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  32. package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -825,6 +825,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
825
825
  var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
826
826
  var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
827
827
  var previewOn=false, previewTimer=null; // on-demand operator camera preview
828
+ var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
828
829
  var lkRoom=null, micAnalyser=null, audioCtx=null;
829
830
  var imgs={}; // preloaded robot concept avatars
830
831
  var TW=44, TH=22, PARK_W=15, PARK_D=8;
@@ -1175,10 +1176,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
1175
1176
  if(drawerTimer) clearInterval(drawerTimer);
1176
1177
  drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);
1177
1178
  }
1179
+ // Resolve a mesh node's *scheduler* robot id — n.name is the mesh display
1180
+ // name, which is not necessarily the scheduler's robots.id (e.g. an
1181
+ // enrolled device's id is a generated dev_xxxx, distinct from its name).
1182
+ // Falls back to n.name if telemetry hasn't matched a row yet.
1183
+ function schedRow(n){
1184
+ var row=roboparkByName[n.name];
1185
+ if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }
1186
+ return row;
1187
+ }
1188
+ function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }
1178
1189
  function closeDrawer(){
1179
1190
  // tell the scheduler to end any live preview so the robot stops its cam
1180
- if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(pn.name)+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
1191
+ if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
1181
1192
  previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1193
+ stopVision();
1182
1194
  $('pk-drawer').classList.remove('open'); drawerId=null;
1183
1195
  if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
1184
1196
  if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
@@ -1195,7 +1207,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1195
1207
  var note=$('pk-golivenote'); if(note) note.textContent='requesting preview…';
1196
1208
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first
1197
1209
  idleCam('connecting');
1198
- fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/preview/start'+QS, {method:'POST', cache:'no-store'})
1210
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})
1199
1211
  .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1200
1212
  .then(function(info){
1201
1213
  if(!previewOn) return; // stopped while awaiting
@@ -1203,7 +1215,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1203
1215
  if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview · robot opening camera…'; }
1204
1216
  else { idleCam('no-sdk'); }
1205
1217
  if(previewTimer) clearInterval(previewTimer);
1206
- previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
1218
+ previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
1207
1219
  })
1208
1220
  .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });
1209
1221
  }
@@ -1211,14 +1223,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
1211
1223
  previewOn=false; updateGoLive();
1212
1224
  if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1213
1225
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1214
- fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/preview/stop'+QS, {method:'POST'}).catch(function(){});
1226
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});
1215
1227
  var note=$('pk-golivenote'); if(note) note.textContent='';
1216
1228
  idleCam('idle');
1217
1229
  }
1230
+ // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),
1231
+ // so the browser hits the robot's LAN IP straight on, no scheduler proxy.
1232
+ // The scheduler learns that IP from the device's own heartbeats.
1233
+ function toggleVision(n){
1234
+ var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');
1235
+ if(visionOn){
1236
+ visionOn=false; if(img){ img.src=''; img.style.display='none'; }
1237
+ if(btn) btn.textContent='🔍 Show overlay';
1238
+ if(note) note.textContent="object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)";
1239
+ return;
1240
+ }
1241
+ var row=schedRow(n);
1242
+ var ip=row&&row.lan_ip;
1243
+ if(!ip){ if(note) note.textContent='no known IP for this robot yet — waiting for a heartbeat with lan_ip'; return; }
1244
+ var port=(row&&row.vision_port)||5000;
1245
+ visionOn=true;
1246
+ if(btn) btn.textContent='■ Hide overlay';
1247
+ if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';
1248
+ if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }
1249
+ }
1250
+ function stopVision(){
1251
+ visionOn=false;
1252
+ var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }
1253
+ }
1218
1254
  function pollRoboparkQuiet(){
1219
1255
  fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
1220
1256
  var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
1221
- list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; });
1257
+ list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });
1222
1258
  refreshDrawer();
1223
1259
  }).catch(function(){});
1224
1260
  }
@@ -1237,6 +1273,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
1237
1273
  +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
1238
1274
  +'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>'
1239
1275
  +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>')
1276
+ + sect('🔍 vision overlay',
1277
+ '<div class="pk-cam" id="pk-vision-wrap"><img id="pk-vision-img" style="width:100%;display:none;border-radius:inherit" alt="vision feed"/></div>'
1278
+ +'<div class="pk-note" id="pk-vision-note">object/motion detection overlay from the robot\'s vision server (RoboVisionAI_PI)</div>'
1279
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-vision-toggle">🔍 Show overlay</button></div>')
1240
1280
  + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
1241
1281
  + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
1242
1282
  + sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
@@ -1244,7 +1284,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
1244
1284
  + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');
1245
1285
  startCam(n); startMeters(n);
1246
1286
  var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
1247
- var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(n.name); });
1287
+ var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });
1288
+ var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
1248
1289
  var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
1249
1290
  } else {
1250
1291
  b.innerHTML =
@@ -1260,8 +1301,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1260
1301
  setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
1261
1302
  var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
1262
1303
  var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
1263
- var row=roboparkByName[n.name];
1264
- if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }
1304
+ var row=schedRow(n);
1265
1305
  if(row){
1266
1306
  set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
1267
1307
  set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
@@ -1281,7 +1321,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1281
1321
  idleCam('connecting');
1282
1322
  // scheduler mints a subscribe-only viewer token for the robot's current
1283
1323
  // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).
1284
- fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
1324
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
1285
1325
  .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1286
1326
  .then(function(info){
1287
1327
  if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
@@ -8,6 +8,9 @@ export interface PreviewAgentOptions {
8
8
  width: string;
9
9
  height: string;
10
10
  fps: string;
11
+ visionWebhookPort?: string;
12
+ visionTriggerCooldown?: string;
13
+ visionSessionSeconds?: string;
11
14
  saveConfig?: boolean;
12
15
  foreground?: boolean;
13
16
  }
@@ -41,6 +41,12 @@ export async function roboparkPreviewAgent(opts) {
41
41
  args.push('--device-token', opts.deviceToken);
42
42
  if (opts.enrollmentToken)
43
43
  args.push('--enrollment-token', opts.enrollmentToken);
44
+ if (opts.visionWebhookPort)
45
+ args.push('--vision-webhook-port', opts.visionWebhookPort);
46
+ if (opts.visionTriggerCooldown)
47
+ args.push('--vision-trigger-cooldown', opts.visionTriggerCooldown);
48
+ if (opts.visionSessionSeconds)
49
+ args.push('--vision-session-seconds', opts.visionSessionSeconds);
44
50
  if (opts.saveConfig)
45
51
  args.push('--save-config');
46
52
  console.log(chalk.bold('\n robopark preview-agent'));
@@ -49,6 +55,8 @@ export async function roboparkPreviewAgent(opts) {
49
55
  console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
50
56
  console.log(` video: ${chalk.cyan(opts.videoDevice)}`);
51
57
  console.log(` audio: ${chalk.cyan(opts.audioDevice)}`);
58
+ if (opts.visionWebhookPort)
59
+ console.log(` vision webhook: ${chalk.cyan(':' + opts.visionWebhookPort)} (point RoboVisionAI_PI's motion webhook here)`);
52
60
  console.log();
53
61
  if (opts.foreground) {
54
62
  const proc = spawn(python, args, { stdio: 'inherit' });
@@ -99,6 +99,9 @@ program
99
99
  .option('--width <px>', 'video width', '640')
100
100
  .option('--height <px>', 'video height', '480')
101
101
  .option('--fps <n>', 'video fps', '15')
102
+ .option('--vision-webhook-port <port>', 'local port for RoboVisionAI_PI\'s motion webhook (0 disables)', '5057')
103
+ .option('--vision-trigger-cooldown <sec>', 'min seconds between motion-triggered sessions', '20')
104
+ .option('--vision-session-seconds <sec>', 'how long a motion-triggered session holds the publisher', '90')
102
105
  .option('--save-config', 'write settings to ~/.robopark/preview_agent.json')
103
106
  .option('--foreground', 'stay in foreground; do not detach')
104
107
  .action(async (opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.12",
3
+ "version": "2.8.15",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -27,6 +27,8 @@
27
27
  "bin/robopark.js",
28
28
  "dist",
29
29
  "packages/robopark/scheduler",
30
+ "packages/robopark/pi-client",
31
+ "packages/robopark/vision",
30
32
  ".opencode/plugins",
31
33
  ".opencode/themes",
32
34
  ".opencode/tui.json",
@@ -0,0 +1,17 @@
1
+ # robopark-pi-client — full robot client (reference)
2
+
3
+ The full on-robot client: enrollment, heartbeat, motor control, and a
4
+ LiveKit publish loop (`livekit_bridge.py`) that joins a standing per-device
5
+ room whenever `production_mode` is on. `pi_ui.py` serves a touchscreen UI
6
+ with a manual "Join Conversation" button — the human-in-the-loop trigger.
7
+
8
+ This is a reference implementation shipped for Pi deployment; it is not
9
+ currently wired into `robopark setup --robot` (which uses `../scheduler/
10
+ preview_agent.py` for the on-demand preview + vision-trigger flow — see
11
+ `../vision/README.md`). `client.py`'s standing-room model and the scheduler's
12
+ `request-session`-per-trigger model are two different session strategies;
13
+ reconciling them into one is follow-up work, not done here.
14
+
15
+ Install: `pip install -r requirements.txt` (`livekit`/`livekit-api` are
16
+ commented out — Pi-only, install separately with the `livekit` SDK).
17
+ `install.sh`/`*.service` are Linux/Pi systemd wrappers.
@@ -0,0 +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"
@@ -0,0 +1,305 @@
1
+ # RoboPark Pi Client
2
+ # Run on the Raspberry Pi (or any device) to:
3
+ # 1. Enroll with the scheduler (first boot) using --enrollment-token
4
+ # 2. Persist device_id + device_token to --state-file (default ./device.json)
5
+ # 3. Heartbeat every --heartbeat-interval seconds
6
+ # 4. Join a LiveKit room (if `livekit` SDK is installed) to publish mic + camera
7
+ # and subscribe to the agent's data channel for motor commands
8
+ # 5. Forward motor commands to the local RoboVisionAI_PI motor server
9
+ #
10
+ # Quick start:
11
+ # python client.py \
12
+ # --scheduler-url http://100.64.1.5:8080 \
13
+ # --enrollment-token <TOKEN-FROM-SCHEDULER-LOGS> \
14
+ # --name pipi \
15
+ # --tailscale-ip 100.64.1.10 \
16
+ # --lan-ip 192.168.1.159 \
17
+ # --motor-server-url http://192.168.1.159:8001 \
18
+ # --livekit-url ws://100.64.1.5:7880
19
+ #
20
+ # Subsequent runs (token is in device.json):
21
+ # python client.py --scheduler-url http://100.64.1.5:8080
22
+
23
+ import argparse
24
+ import asyncio
25
+ import json
26
+ import logging
27
+ import os
28
+ import signal
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ import httpx
35
+
36
+ from motor_bridge import MotorBridge
37
+
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
41
+ )
42
+ log = logging.getLogger("robopark-pi")
43
+
44
+
45
+ class PiClient:
46
+ def __init__(self, args: argparse.Namespace):
47
+ self.scheduler_url = args.scheduler_url.rstrip("/")
48
+ self.state_file = Path(args.state_file)
49
+ self.enrollment_token = args.enrollment_token
50
+ self.name = args.name
51
+ self.tailscale_ip = args.tailscale_ip
52
+ self.lan_ip = args.lan_ip
53
+ self.motor_server_url = args.motor_server_url
54
+ self.livekit_url = args.livekit_url
55
+ self.character_id = args.character_id
56
+ self.heartbeat_interval = args.heartbeat_interval
57
+ self.config_poll_interval = args.config_poll_interval
58
+ self.join_room = args.join_room
59
+ self.room_name = args.room_name
60
+
61
+ self.device_id: Optional[str] = None
62
+ self.device_token: Optional[str] = None
63
+ self.production_mode: bool = False
64
+ self.motor_bridge = MotorBridge(self.motor_server_url, dry_run=args.dry_run_motors)
65
+
66
+ self._stop = asyncio.Event()
67
+ self._lk_task: Optional[asyncio.Task] = None
68
+
69
+ # ---- credential persistence ----------------------------------------
70
+
71
+ def _load_state(self) -> bool:
72
+ if not self.state_file.exists():
73
+ return False
74
+ try:
75
+ data = json.loads(self.state_file.read_text())
76
+ self.device_id = data.get("device_id")
77
+ self.device_token = data.get("device_token")
78
+ # allow CLI overrides to backfill missing fields from a stale state file
79
+ if not self.tailscale_ip: self.tailscale_ip = data.get("tailscale_ip")
80
+ if not self.lan_ip: self.lan_ip = data.get("lan_ip")
81
+ if not self.motor_server_url: self.motor_server_url = data.get("motor_server_url")
82
+ if not self.livekit_url: self.livekit_url = data.get("livekit_url")
83
+ if not self.character_id: self.character_id = data.get("character_id")
84
+ if not self.name: self.name = data.get("name")
85
+ return bool(self.device_id and self.device_token)
86
+ except Exception as e:
87
+ log.warning(f"Could not load state file {self.state_file}: {e}")
88
+ return False
89
+
90
+ def _save_state(self):
91
+ data = {
92
+ "device_id": self.device_id,
93
+ "device_token": self.device_token,
94
+ "name": self.name,
95
+ "tailscale_ip": self.tailscale_ip,
96
+ "lan_ip": self.lan_ip,
97
+ "motor_server_url": self.motor_server_url,
98
+ "livekit_url": self.livekit_url,
99
+ "character_id": self.character_id,
100
+ "saved_at": time.time(),
101
+ }
102
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
103
+ self.state_file.write_text(json.dumps(data, indent=2))
104
+ try:
105
+ os.chmod(self.state_file, 0o600)
106
+ except Exception:
107
+ pass
108
+ log.info(f"Saved credentials to {self.state_file}")
109
+
110
+ # ---- scheduler API --------------------------------------------------
111
+
112
+ async def enroll(self):
113
+ body = {
114
+ "enrollment_token": self.enrollment_token,
115
+ "name": self.name,
116
+ "tailscale_ip": self.tailscale_ip,
117
+ "lan_ip": self.lan_ip,
118
+ "motor_server_url": self.motor_server_url,
119
+ "livekit_url": self.livekit_url,
120
+ "character_id": self.character_id,
121
+ }
122
+ async with httpx.AsyncClient(timeout=15.0) as c:
123
+ r = await c.post(f"{self.scheduler_url}/api/devices/enroll", json=body)
124
+ if r.status_code != 200:
125
+ raise RuntimeError(f"Enroll failed ({r.status_code}): {r.text}")
126
+ data = r.json()
127
+ self.device_id = data["device_id"]
128
+ self.device_token = data["device_token"]
129
+ log.info(f"Enrolled as device_id={self.device_id}")
130
+ self._save_state()
131
+
132
+ async def heartbeat(self):
133
+ body = {"status": "online", "ip": self.tailscale_ip or self.lan_ip}
134
+ async with httpx.AsyncClient(timeout=10.0) as c:
135
+ r = await c.post(
136
+ f"{self.scheduler_url}/api/devices/{self.device_id}/heartbeat",
137
+ headers={"Authorization": f"Bearer {self.device_token}"},
138
+ json=body,
139
+ )
140
+ if r.status_code == 401:
141
+ log.error("Device token rejected; re-enrollment required")
142
+ # wipe creds so next loop tries to enroll again
143
+ self.device_token = None
144
+ if self.state_file.exists():
145
+ self.state_file.unlink()
146
+ return None
147
+ r.raise_for_status()
148
+ return r.json()
149
+
150
+ async def fetch_config(self) -> dict:
151
+ async with httpx.AsyncClient(timeout=10.0) as c:
152
+ r = await c.get(
153
+ f"{self.scheduler_url}/api/devices/{self.device_id}/config",
154
+ headers={"Authorization": f"Bearer {self.device_token}"},
155
+ )
156
+ r.raise_for_status()
157
+ return r.json()
158
+
159
+ # ---- LiveKit (optional) ---------------------------------------------
160
+
161
+ async def livekit_loop(self):
162
+ """Join a LiveKit room, publish mic + cam, subscribe to data channel.
163
+ Skips silently if the `livekit` SDK is not installed."""
164
+ try:
165
+ from livekit_bridge import run_livekit # local module
166
+ except ImportError:
167
+ log.warning(
168
+ "livekit_bridge module not available (livekit SDK missing). "
169
+ "Heartbeat + motor bridge will still work; install the `livekit` "
170
+ "and `livekit-api` packages on the Pi to enable rooms."
171
+ )
172
+ return
173
+
174
+ # Default room: robopark-<device_id>. Allow override via --room-name.
175
+ room = (self.room_name if self.room_name and not self.room_name.startswith("robopark-pi-standby")
176
+ else f"robopark-{self.device_id}")
177
+ # LiveKit URL: prefer the one the scheduler told us about, fall back to CLI.
178
+ lk_url = self.livekit_url or None
179
+
180
+ await run_livekit(
181
+ livekit_url=lk_url,
182
+ room_name=room,
183
+ identity=f"pi:{self.device_id}",
184
+ on_motor_command=self.motor_bridge.handle_command,
185
+ stop_event=self._stop,
186
+ production_mode_provider=lambda: self.production_mode,
187
+ scheduler_url=self.scheduler_url,
188
+ device_id=self.device_id,
189
+ device_token=self.device_token,
190
+ )
191
+
192
+ # ---- main loop ------------------------------------------------------
193
+
194
+ async def run(self):
195
+ # 1. credentials
196
+ if not self._load_state():
197
+ if not self.enrollment_token:
198
+ log.error(
199
+ "No saved credentials and no --enrollment-token. "
200
+ "First boot requires --enrollment-token from the scheduler."
201
+ )
202
+ return
203
+ await self.enroll()
204
+
205
+ log.info(f"Device {self.device_id} starting main loop")
206
+ # 2. start LiveKit worker in background (no-op if SDK missing)
207
+ if self.join_room:
208
+ self._lk_task = asyncio.create_task(self.livekit_loop())
209
+ hb_failures = 0
210
+ try:
211
+ while not self._stop.is_set():
212
+ try:
213
+ hb = await self.heartbeat()
214
+ hb_failures = 0
215
+ if hb:
216
+ self.production_mode = bool(hb.get("production_mode", False))
217
+ if self.production_mode and self._lk_task is None and self.join_room:
218
+ log.info("Production mode ON -> joining LiveKit room")
219
+ self._lk_task = asyncio.create_task(self.livekit_loop())
220
+ elif not self.production_mode and self._lk_task is not None:
221
+ log.info("Production mode OFF -> leaving LiveKit room")
222
+ self._lk_task.cancel()
223
+ try:
224
+ await self._lk_task
225
+ except (asyncio.CancelledError, Exception):
226
+ pass
227
+ self._lk_task = None
228
+ except Exception as e:
229
+ hb_failures += 1
230
+ log.warning(f"Heartbeat error ({hb_failures}): {e}")
231
+
232
+ # periodic config poll (covers cases where production_mode flips but hb is slow)
233
+ try:
234
+ if int(time.time()) % int(self.config_poll_interval) < int(self.heartbeat_interval):
235
+ cfg = await self.fetch_config()
236
+ self.production_mode = bool(cfg.get("production_mode", False))
237
+ if cfg.get("character_id"):
238
+ self.character_id = cfg["character_id"]
239
+ except Exception as e:
240
+ log.debug(f"Config poll error: {e}")
241
+
242
+ try:
243
+ await asyncio.wait_for(self._stop.wait(), timeout=self.heartbeat_interval)
244
+ except asyncio.TimeoutError:
245
+ pass
246
+ finally:
247
+ self._stop.set()
248
+ if self._lk_task:
249
+ self._lk_task.cancel()
250
+ try:
251
+ await self._lk_task
252
+ except Exception:
253
+ pass
254
+
255
+
256
+ def parse_args() -> argparse.Namespace:
257
+ p = argparse.ArgumentParser(description="RoboPark Pi Client")
258
+ p.add_argument("--scheduler-url", required=True, help="e.g. http://100.64.1.5:8080")
259
+ p.add_argument("--enrollment-token", default=None,
260
+ help="One-time enrollment token (first boot only)")
261
+ p.add_argument("--state-file", default="./device.json",
262
+ help="Where to persist device_id + device_token")
263
+ p.add_argument("--name", default=None, help="Device display name")
264
+ p.add_argument("--tailscale-ip", default=None)
265
+ p.add_argument("--lan-ip", default=None)
266
+ p.add_argument("--motor-server-url", default=None,
267
+ help="Base URL of the local RoboVisionAI_PI motor server")
268
+ p.add_argument("--livekit-url", default=None, help="e.g. ws://100.64.1.5:7880")
269
+ p.add_argument("--character-id", default=None, help="Override character assignment")
270
+ p.add_argument("--room-name", default="robopark-pi-standby",
271
+ help="LiveKit room to join")
272
+ p.add_argument("--heartbeat-interval", type=float, default=10.0)
273
+ p.add_argument("--config-poll-interval", type=float, default=30.0)
274
+ p.add_argument("--join-room", action="store_true",
275
+ help="Attempt to join the LiveKit room (requires livekit SDK)")
276
+ p.add_argument("--dry-run-motors", action="store_true",
277
+ help="Log motor commands without calling the motor server")
278
+ return p.parse_args()
279
+
280
+
281
+ async def _amain():
282
+ args = parse_args()
283
+ client = PiClient(args)
284
+ loop = asyncio.get_running_loop()
285
+ for sig in (signal.SIGINT, signal.SIGTERM):
286
+ try:
287
+ loop.add_signal_handler(sig, client._stop.set)
288
+ except NotImplementedError:
289
+ pass # Windows
290
+
291
+ try:
292
+ await client.run()
293
+ except KeyboardInterrupt:
294
+ pass
295
+
296
+
297
+ def main():
298
+ try:
299
+ asyncio.run(_amain())
300
+ except KeyboardInterrupt:
301
+ pass
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()
@@ -0,0 +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."
41
+ echo "Tail logs with: journalctl -u robopark-pi-client -f"
@@ -0,0 +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)"
55
+ fi