infinicode 2.8.13 β 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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark-cli.js +3 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +63 -0
- package/packages/robopark/vision/app_pi_clean.py +307 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- package/packages/robopark/vision/services/services.sh +12 -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;
|
|
@@ -1189,6 +1190,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1189
1190
|
// tell the scheduler to end any live preview so the robot stops its cam
|
|
1190
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){} } }
|
|
1191
1192
|
previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
|
|
1193
|
+
stopVision();
|
|
1192
1194
|
$('pk-drawer').classList.remove('open'); drawerId=null;
|
|
1193
1195
|
if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
|
|
1194
1196
|
if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
|
|
@@ -1225,10 +1227,34 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1225
1227
|
var note=$('pk-golivenote'); if(note) note.textContent='';
|
|
1226
1228
|
idleCam('idle');
|
|
1227
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
|
+
}
|
|
1228
1254
|
function pollRoboparkQuiet(){
|
|
1229
1255
|
fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
|
|
1230
1256
|
var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
|
|
1231
|
-
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; });
|
|
1232
1258
|
refreshDrawer();
|
|
1233
1259
|
}).catch(function(){});
|
|
1234
1260
|
}
|
|
@@ -1247,6 +1273,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1247
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>'
|
|
1248
1274
|
+'<div class="pk-note" id="pk-camnote">seeking LiveKit trackβ¦</div>'
|
|
1249
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>')
|
|
1250
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>')
|
|
1251
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>')
|
|
1252
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>')
|
|
@@ -1254,6 +1284,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1254
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>');
|
|
1255
1285
|
startCam(n); startMeters(n);
|
|
1256
1286
|
var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
|
|
1287
|
+
var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });
|
|
1257
1288
|
var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
|
|
1258
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); });
|
|
1259
1290
|
} else {
|
|
@@ -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' });
|
package/dist/robopark-cli.js
CHANGED
|
@@ -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.
|
|
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
|