infinicode 2.8.108 → 2.8.114
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/README.md +20 -2
- package/dist/cli.js +23 -0
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +15 -4
- package/dist/commands/mesh-install.d.ts +1 -0
- package/dist/commands/mesh-install.js +28 -11
- package/dist/commands/mesh-link.d.ts +10 -0
- package/dist/commands/mesh-link.js +44 -0
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +404 -155
- package/dist/kernel/federation/index.d.ts +2 -0
- package/dist/kernel/federation/index.js +1 -0
- package/dist/kernel/federation/mesh-link.d.ts +16 -0
- package/dist/kernel/federation/mesh-link.js +52 -0
- package/dist/kernel/federation/transport-http.js +24 -0
- package/dist/kernel/mcp/mcp-server.js +61 -0
- package/dist/robopark/python-env.d.ts +1 -0
- package/dist/robopark/python-env.js +3 -0
- package/dist/robopark/robot-runtime.d.ts +1 -0
- package/dist/robopark/robot-runtime.js +52 -10
- package/dist/robopark/setup.d.ts +1 -0
- package/dist/robopark/setup.js +2 -0
- package/dist/robopark-cli.js +4 -0
- package/docs/ROBOPARK_ROBOVOICE_SIMPLIFICATION_PLAN.html +139 -0
- package/package.json +5 -2
- package/packages/robopark/pi-client/motor_bridge.py +10 -7
- package/packages/robopark/scheduler/main.py +144 -19
- package/packages/robopark/scheduler/robot_supervisor.py +46 -15
- package/packages/robopark/vision/motor_server.py +44 -18
- package/scripts/integrate-robovoice-control-center.mjs +423 -0
|
@@ -10,7 +10,8 @@ Command wire format (JSON on the data channel):
|
|
|
10
10
|
{"op": "stop"}
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
import asyncio
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
14
15
|
import json
|
|
15
16
|
import logging
|
|
16
17
|
from typing import Awaitable, Callable, Optional
|
|
@@ -20,10 +21,12 @@ import httpx
|
|
|
20
21
|
log = logging.getLogger("robopark-pi.motor")
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
class MotorBridge:
|
|
24
|
-
def __init__(self, motor_server_url: Optional[str], dry_run: bool = False):
|
|
24
|
+
class MotorBridge:
|
|
25
|
+
def __init__(self, motor_server_url: Optional[str], dry_run: bool = False):
|
|
25
26
|
self.motor_server_url = (motor_server_url or "").rstrip("/")
|
|
26
|
-
self.dry_run = dry_run
|
|
27
|
+
self.dry_run = dry_run
|
|
28
|
+
token = os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip() or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
29
|
+
self.headers = {"X-RoboPark-Motor-Token": token} if token else {}
|
|
27
30
|
self._lock = asyncio.Lock() # the local motor server allows one motor at a time
|
|
28
31
|
|
|
29
32
|
async def handle_command(self, raw: str | bytes) -> str:
|
|
@@ -60,7 +63,7 @@ class MotorBridge:
|
|
|
60
63
|
async with httpx.AsyncClient(timeout=seconds + 5.0) as c:
|
|
61
64
|
r = await c.post(
|
|
62
65
|
f"{self.motor_server_url}/trigger-motor",
|
|
63
|
-
json={"motor_name": name, "seconds": seconds},
|
|
66
|
+
json={"motor_name": name, "seconds": seconds}, headers=self.headers,
|
|
64
67
|
)
|
|
65
68
|
if r.status_code == 200:
|
|
66
69
|
return f"moved {name} for {seconds}s"
|
|
@@ -75,8 +78,8 @@ class MotorBridge:
|
|
|
75
78
|
return "dry-run: stop"
|
|
76
79
|
try:
|
|
77
80
|
async with httpx.AsyncClient(timeout=5.0) as c:
|
|
78
|
-
r = await c.post(f"{self.motor_server_url}/stop-motors", json={})
|
|
81
|
+
r = await c.post(f"{self.motor_server_url}/stop-motors", json={}, headers=self.headers)
|
|
79
82
|
return f"stop {r.status_code}"
|
|
80
83
|
except Exception as e:
|
|
81
84
|
log.error(f"stop_all failed: {e}")
|
|
82
|
-
return f"error: {e}"
|
|
85
|
+
return f"error: {e}"
|
|
@@ -219,9 +219,11 @@ class DeviceEnrollRequest(BaseModel):
|
|
|
219
219
|
|
|
220
220
|
class DeviceBootstrapRequest(BaseModel):
|
|
221
221
|
name: str
|
|
222
|
+
character_id: Optional[str] = None
|
|
222
223
|
lan_ip: Optional[str] = None
|
|
223
224
|
tailscale_ip: Optional[str] = None
|
|
224
225
|
livekit_url: Optional[str] = None
|
|
226
|
+
motor_server_url: Optional[str] = None
|
|
225
227
|
|
|
226
228
|
class DeviceEnrollResponse(BaseModel):
|
|
227
229
|
device_id: str
|
|
@@ -235,6 +237,7 @@ class DeviceHeartbeat(BaseModel):
|
|
|
235
237
|
production_mode: Optional[bool] = None
|
|
236
238
|
device_inventory: Optional[dict] = None
|
|
237
239
|
livekit_url: Optional[str] = None
|
|
240
|
+
motor_server_url: Optional[str] = None
|
|
238
241
|
|
|
239
242
|
class PipelineEventPayload(BaseModel):
|
|
240
243
|
stage: str
|
|
@@ -2982,25 +2985,25 @@ async def bootstrap_device(
|
|
|
2982
2985
|
await db.execute(
|
|
2983
2986
|
"""UPDATE devices SET token_hash = ?, status = 'enrolled',
|
|
2984
2987
|
lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
|
|
2985
|
-
livekit_url = COALESCE(?, livekit_url),
|
|
2988
|
+
livekit_url = COALESCE(?, livekit_url), motor_server_url = COALESCE(?, motor_server_url),
|
|
2989
|
+
character_id = COALESCE(?, character_id),
|
|
2986
2990
|
enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
|
|
2987
|
-
(new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
|
|
2991
|
+
(new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
|
|
2992
|
+
payload.motor_server_url, payload.character_id, now, device_id),
|
|
2988
2993
|
)
|
|
2989
2994
|
else:
|
|
2990
2995
|
device_id = f"dev_{secrets.token_hex(4)}"
|
|
2991
2996
|
await db.execute(
|
|
2992
2997
|
"""INSERT INTO devices
|
|
2993
|
-
(id, name, lan_ip, tailscale_ip, livekit_url, token_hash, status, enrolled_at, created_at)
|
|
2994
|
-
VALUES (?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
2995
|
-
(device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
|
|
2998
|
+
(id, name, lan_ip, tailscale_ip, livekit_url, motor_server_url, character_id, token_hash, status, enrolled_at, created_at)
|
|
2999
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
3000
|
+
(device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
|
|
3001
|
+
payload.motor_server_url, payload.character_id, new_hash, now, now),
|
|
2996
3002
|
)
|
|
2997
3003
|
await db.execute(
|
|
2998
3004
|
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
2999
3005
|
(device_id, name),
|
|
3000
3006
|
)
|
|
3001
|
-
for field in ("motor_registry", "motor_sequences"):
|
|
3002
|
-
if field in fields:
|
|
3003
|
-
fields[field] = json.dumps(fields[field], separators=(",", ":"))
|
|
3004
3007
|
await db.execute("UPDATE robots SET name = ? WHERE id = ?", (name, device_id))
|
|
3005
3008
|
await db.commit()
|
|
3006
3009
|
|
|
@@ -3165,7 +3168,8 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
|
3165
3168
|
lan_ip = COALESCE(?, lan_ip),
|
|
3166
3169
|
last_seen_ip = COALESCE(?, last_seen_ip),
|
|
3167
3170
|
device_inventory = COALESCE(?, device_inventory),
|
|
3168
|
-
livekit_url = COALESCE(?, livekit_url)
|
|
3171
|
+
livekit_url = COALESCE(?, livekit_url),
|
|
3172
|
+
motor_server_url = COALESCE(?, motor_server_url)
|
|
3169
3173
|
WHERE id = ?""",
|
|
3170
3174
|
(
|
|
3171
3175
|
payload.status or "online",
|
|
@@ -3174,6 +3178,7 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
|
|
|
3174
3178
|
payload.ip,
|
|
3175
3179
|
json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
|
|
3176
3180
|
payload.livekit_url,
|
|
3181
|
+
payload.motor_server_url,
|
|
3177
3182
|
device_id,
|
|
3178
3183
|
),
|
|
3179
3184
|
) as cur:
|
|
@@ -3738,6 +3743,49 @@ async def device_supervisor_output(device_id: str, payload: dict, authorization:
|
|
|
3738
3743
|
session_id=result_payload.get("session_id"), source="robot_supervisor",
|
|
3739
3744
|
details={"request_id": request_id, "duration_ms": result_payload.get("duration_ms")},
|
|
3740
3745
|
))
|
|
3746
|
+
if kind == "motor_discover" and ok and isinstance(result_payload.get("registry"), list):
|
|
3747
|
+
device = await _resolve_device_by_id_or_name(device_id)
|
|
3748
|
+
model = _device_row_to_model(device)
|
|
3749
|
+
discovered_ids = {
|
|
3750
|
+
str(item.get("id")) for item in result_payload["registry"] if isinstance(item, dict)
|
|
3751
|
+
}
|
|
3752
|
+
reconciled_sequences = []
|
|
3753
|
+
for sequence in model.motor_sequences:
|
|
3754
|
+
clean_steps = [
|
|
3755
|
+
step for step in (sequence.get("steps") or [])
|
|
3756
|
+
if str(step.get("motor_id")) in discovered_ids
|
|
3757
|
+
]
|
|
3758
|
+
reconciled_sequences.append({**sequence, "steps": clean_steps})
|
|
3759
|
+
registry, sequences, greeting = _sanitize_motor_profile(MotorProfilePayload(
|
|
3760
|
+
registry=result_payload["registry"],
|
|
3761
|
+
sequences=reconciled_sequences,
|
|
3762
|
+
greeting_sequence_id=model.greeting_motor_sequence_id,
|
|
3763
|
+
))
|
|
3764
|
+
result_payload.update({
|
|
3765
|
+
"registry": registry,
|
|
3766
|
+
"sequences": sequences,
|
|
3767
|
+
"greeting_sequence_id": greeting,
|
|
3768
|
+
"persisted": True,
|
|
3769
|
+
})
|
|
3770
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3771
|
+
await db.execute(
|
|
3772
|
+
"UPDATE devices SET motor_registry=?, motor_sequences=?, greeting_motor_sequence_id=? WHERE id=?",
|
|
3773
|
+
(json.dumps(registry), json.dumps(sequences), greeting, device_id),
|
|
3774
|
+
)
|
|
3775
|
+
await db.execute(
|
|
3776
|
+
"UPDATE shell_requests SET result_payload=? WHERE id=?",
|
|
3777
|
+
(json.dumps(result_payload), request_id),
|
|
3778
|
+
)
|
|
3779
|
+
await db.commit()
|
|
3780
|
+
character_id = await _sync_character_motor_sequences(device_id, sequences)
|
|
3781
|
+
result_payload["character_id"] = character_id
|
|
3782
|
+
result_payload["allowed_motor_sequences"] = [
|
|
3783
|
+
item["id"] for item in sequences if item.get("steps")
|
|
3784
|
+
]
|
|
3785
|
+
await _log_history(
|
|
3786
|
+
"motor", "device", device_id, "registry_discovered", "robot_supervisor",
|
|
3787
|
+
f"motors={len(registry)}",
|
|
3788
|
+
)
|
|
3741
3789
|
return {"status": "ok"}
|
|
3742
3790
|
|
|
3743
3791
|
|
|
@@ -4030,19 +4078,44 @@ def _sanitize_motor_profile(payload: MotorProfilePayload) -> tuple[list, list, O
|
|
|
4030
4078
|
raise HTTPException(422, f"Invalid or duplicate sequence id: {sequence_id!r}")
|
|
4031
4079
|
sequence_ids.add(sequence_id)
|
|
4032
4080
|
steps = []
|
|
4081
|
+
total_duration_ms = 0
|
|
4033
4082
|
for step in (raw.get("steps") or [])[:40]:
|
|
4034
4083
|
motor_id = str(step.get("motor_id") or "")
|
|
4035
4084
|
if motor_id not in ids:
|
|
4036
4085
|
raise HTTPException(422, f"Sequence {sequence_id} references unknown motor {motor_id}")
|
|
4037
4086
|
motor = next(item for item in registry if item["id"] == motor_id)
|
|
4038
4087
|
duration = max(50, min(motor["max_duration_ms"], int(step.get("duration_ms", 500))))
|
|
4039
|
-
|
|
4088
|
+
delay = max(0, min(30000, int(step.get("delay_ms", 0))))
|
|
4089
|
+
total_duration_ms += delay + duration
|
|
4090
|
+
if total_duration_ms > 60000:
|
|
4091
|
+
raise HTTPException(422, f"Sequence {sequence_id} exceeds the 60 second safety limit")
|
|
4092
|
+
steps.append({"motor_id": motor_id, "delay_ms": delay, "duration_ms": duration})
|
|
4040
4093
|
sequences.append({"id": sequence_id, "name": str(raw.get("name") or sequence_id)[:80], "steps": steps})
|
|
4041
4094
|
greeting = payload.greeting_sequence_id or None
|
|
4042
4095
|
if greeting and greeting not in sequence_ids:
|
|
4043
4096
|
raise HTTPException(422, "Greeting sequence does not exist")
|
|
4044
4097
|
return registry, sequences, greeting
|
|
4045
4098
|
|
|
4099
|
+
async def _sync_character_motor_sequences(device_id: str, sequences: list) -> Optional[str]:
|
|
4100
|
+
"""Point the assigned character at this robot's validated sequence IDs."""
|
|
4101
|
+
sequence_ids = [str(item["id"]) for item in sequences if item.get("id") and item.get("steps")]
|
|
4102
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
4103
|
+
db.row_factory = aiosqlite.Row
|
|
4104
|
+
async with db.execute(
|
|
4105
|
+
"SELECT COALESCE(rvc.character_preset_id, d.character_id) AS character_id "
|
|
4106
|
+
"FROM devices d LEFT JOIN robot_voice_configs rvc ON rvc.robot_id=d.id WHERE d.id=?",
|
|
4107
|
+
(device_id,),
|
|
4108
|
+
) as cursor:
|
|
4109
|
+
row = await cursor.fetchone()
|
|
4110
|
+
character_id = row["character_id"] if row else None
|
|
4111
|
+
if character_id:
|
|
4112
|
+
await db.execute(
|
|
4113
|
+
"UPDATE character_presets SET motors=?, updated_at=? WHERE id=?",
|
|
4114
|
+
(json.dumps(sequence_ids), datetime.utcnow().isoformat(), character_id),
|
|
4115
|
+
)
|
|
4116
|
+
await db.commit()
|
|
4117
|
+
return character_id
|
|
4118
|
+
|
|
4046
4119
|
@app.get("/api/robots/{robot_id}/motor-profile")
|
|
4047
4120
|
async def robot_motor_profile(robot_id: str):
|
|
4048
4121
|
device = await _resolve_device_by_id_or_name(robot_id)
|
|
@@ -4059,8 +4132,11 @@ async def save_robot_motor_profile(robot_id: str, payload: MotorProfilePayload):
|
|
|
4059
4132
|
await db.execute("UPDATE devices SET motor_registry=?, motor_sequences=?, greeting_motor_sequence_id=? WHERE id=?",
|
|
4060
4133
|
(json.dumps(registry), json.dumps(sequences), greeting, device["id"]))
|
|
4061
4134
|
await db.commit()
|
|
4135
|
+
character_id = await _sync_character_motor_sequences(device["id"], sequences)
|
|
4062
4136
|
await _log_history("motor", "device", device["id"], "profile_saved", "operator", f"motors={len(registry)}, sequences={len(sequences)}")
|
|
4063
|
-
return {"status": "ok", "robot_id": device["id"], "registry": registry, "sequences": sequences,
|
|
4137
|
+
return {"status": "ok", "robot_id": device["id"], "registry": registry, "sequences": sequences,
|
|
4138
|
+
"greeting_sequence_id": greeting, "character_id": character_id,
|
|
4139
|
+
"allowed_motor_sequences": [item["id"] for item in sequences if item.get("steps")]}
|
|
4064
4140
|
|
|
4065
4141
|
async def _queue_motor_sequence(device_id: str, sequence_id: str, session_id: Optional[str] = None) -> dict:
|
|
4066
4142
|
device = await _resolve_device_by_id_or_name(device_id)
|
|
@@ -4068,6 +4144,8 @@ async def _queue_motor_sequence(device_id: str, sequence_id: str, session_id: Op
|
|
|
4068
4144
|
sequence = next((item for item in model.motor_sequences if item.get("id") == sequence_id), None)
|
|
4069
4145
|
if not sequence:
|
|
4070
4146
|
raise HTTPException(404, "Motor sequence not found")
|
|
4147
|
+
if not sequence.get("steps"):
|
|
4148
|
+
raise HTTPException(409, "Motor sequence has no steps")
|
|
4071
4149
|
if not model.motor_server_url:
|
|
4072
4150
|
raise HTTPException(409, "Robot has no motor_server_url")
|
|
4073
4151
|
request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
|
|
@@ -4130,8 +4208,45 @@ async def run_room_motor_sequence(
|
|
|
4130
4208
|
session = await cursor.fetchone()
|
|
4131
4209
|
if not session:
|
|
4132
4210
|
raise HTTPException(404, "No active session bound to this room")
|
|
4211
|
+
if agent_authorized and not fleet_authorized:
|
|
4212
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
4213
|
+
db.row_factory = aiosqlite.Row
|
|
4214
|
+
async with db.execute(
|
|
4215
|
+
"SELECT COALESCE(rvc.character_preset_id, d.character_id) AS character_id "
|
|
4216
|
+
"FROM devices d LEFT JOIN robot_voice_configs rvc ON rvc.robot_id=d.id WHERE d.id=?",
|
|
4217
|
+
(session["robot_id"],),
|
|
4218
|
+
) as cursor:
|
|
4219
|
+
character_row = await cursor.fetchone()
|
|
4220
|
+
character_id = character_row["character_id"] if character_row else None
|
|
4221
|
+
allowed = []
|
|
4222
|
+
if character_id:
|
|
4223
|
+
async with db.execute("SELECT motors FROM character_presets WHERE id=?", (character_id,)) as cursor:
|
|
4224
|
+
preset = await cursor.fetchone()
|
|
4225
|
+
if preset and preset["motors"]:
|
|
4226
|
+
try:
|
|
4227
|
+
allowed = [str(item) for item in json.loads(preset["motors"]) if isinstance(item, str)]
|
|
4228
|
+
except Exception:
|
|
4229
|
+
allowed = []
|
|
4230
|
+
if sequence_id not in allowed:
|
|
4231
|
+
raise HTTPException(
|
|
4232
|
+
403,
|
|
4233
|
+
f"Character {character_id or 'unassigned'} is not allowed to run motor sequence {sequence_id}",
|
|
4234
|
+
)
|
|
4133
4235
|
return await _queue_motor_sequence(session["robot_id"], sequence_id, session["id"])
|
|
4134
4236
|
|
|
4237
|
+
@app.post("/api/robots/{robot_id}/motors/stop")
|
|
4238
|
+
async def stop_robot_motors(robot_id: str):
|
|
4239
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
4240
|
+
model = _device_row_to_model(device)
|
|
4241
|
+
if not model.motor_server_url:
|
|
4242
|
+
raise HTTPException(409, "Robot has no motor_server_url")
|
|
4243
|
+
request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
|
|
4244
|
+
"motor_server_url": model.motor_server_url,
|
|
4245
|
+
"stop_all": True,
|
|
4246
|
+
})
|
|
4247
|
+
await _log_history("motor", "device", model.id, "emergency_stop_queued", "operator", request_id)
|
|
4248
|
+
return {"queued": True, "request_id": request_id, "device_id": model.id}
|
|
4249
|
+
|
|
4135
4250
|
@app.post("/api/robots/{robot_id}/motors/{motor_id}/test")
|
|
4136
4251
|
async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300):
|
|
4137
4252
|
device = await _resolve_device_by_id_or_name(robot_id)
|
|
@@ -4425,13 +4540,14 @@ class VoiceStackCreate(BaseModel):
|
|
|
4425
4540
|
wake_word_timeout: float = 3.0
|
|
4426
4541
|
|
|
4427
4542
|
# Effective configuration returned to the ROBOVOICE agent for a given room/robot.
|
|
4428
|
-
class RobotVoiceConfig(BaseModel):
|
|
4543
|
+
class RobotVoiceConfig(BaseModel):
|
|
4429
4544
|
character_id: Optional[str] = None
|
|
4430
4545
|
character_name: Optional[str] = None
|
|
4431
4546
|
system_prompt: Optional[str] = None
|
|
4432
4547
|
voice_stack_id: Optional[str] = None
|
|
4433
4548
|
voice_stack: Optional[VoiceStack] = None
|
|
4434
|
-
greeting_phrases: List[str] = []
|
|
4549
|
+
greeting_phrases: List[str] = []
|
|
4550
|
+
allowed_motor_sequences: List[str] = []
|
|
4435
4551
|
|
|
4436
4552
|
def _load_robovoice_settings() -> dict:
|
|
4437
4553
|
"""Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
|
|
@@ -4612,8 +4728,9 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
4612
4728
|
|
|
4613
4729
|
# Determine character preset + name
|
|
4614
4730
|
character_id = None
|
|
4615
|
-
character_name = None
|
|
4616
|
-
system_prompt = None
|
|
4731
|
+
character_name = None
|
|
4732
|
+
system_prompt = None
|
|
4733
|
+
allowed_motor_sequences = []
|
|
4617
4734
|
async with db.execute(
|
|
4618
4735
|
"SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
|
|
4619
4736
|
) as c:
|
|
@@ -4630,12 +4747,19 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
4630
4747
|
character_id = row["character_id"]
|
|
4631
4748
|
if character_id:
|
|
4632
4749
|
async with db.execute(
|
|
4633
|
-
"SELECT name, description, system_prompt FROM character_presets WHERE id = ?", (character_id,)
|
|
4750
|
+
"SELECT name, description, system_prompt, motors FROM character_presets WHERE id = ?", (character_id,)
|
|
4634
4751
|
) as c:
|
|
4635
4752
|
row = await c.fetchone()
|
|
4636
4753
|
if row:
|
|
4637
|
-
character_name = row["name"]
|
|
4638
|
-
system_prompt = row["system_prompt"]
|
|
4754
|
+
character_name = row["name"]
|
|
4755
|
+
system_prompt = row["system_prompt"]
|
|
4756
|
+
try:
|
|
4757
|
+
allowed_motor_sequences = [
|
|
4758
|
+
str(item) for item in _json.loads(row["motors"] or "[]")
|
|
4759
|
+
if isinstance(item, str)
|
|
4760
|
+
]
|
|
4761
|
+
except Exception:
|
|
4762
|
+
allowed_motor_sequences = []
|
|
4639
4763
|
if not character_name:
|
|
4640
4764
|
async with db.execute(
|
|
4641
4765
|
"SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
|
|
@@ -4663,8 +4787,9 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
4663
4787
|
character_name=character_name,
|
|
4664
4788
|
system_prompt=system_prompt,
|
|
4665
4789
|
voice_stack_id=voice_stack.id if voice_stack else None,
|
|
4666
|
-
voice_stack=voice_stack,
|
|
4790
|
+
voice_stack=voice_stack,
|
|
4667
4791
|
greeting_phrases=greeting_phrases,
|
|
4792
|
+
allowed_motor_sequences=allowed_motor_sequences,
|
|
4668
4793
|
)
|
|
4669
4794
|
|
|
4670
4795
|
async def _resolve_session_voice_config(session_row) -> RobotVoiceConfig:
|
|
@@ -59,7 +59,24 @@ BACKOFF_BASE_SECONDS = 2.0
|
|
|
59
59
|
BACKOFF_MAX_SECONDS = 60.0
|
|
60
60
|
LOG_ROTATE_BYTES = 5 * 1024 * 1024 # rotate a service's log past 5MB
|
|
61
61
|
POLL_INTERVAL_SECONDS = 2.0
|
|
62
|
-
STATUS_REPORT_INTERVAL_SECONDS = 10.0
|
|
62
|
+
STATUS_REPORT_INTERVAL_SECONDS = 10.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _scheduler_headers(token: str) -> dict[str, str]:
|
|
66
|
+
"""Authenticate the enrolled device and, when present, the mesh proxy."""
|
|
67
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
68
|
+
mesh_token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
69
|
+
if mesh_token:
|
|
70
|
+
headers["X-RoboPark-Mesh-Token"] = mesh_token
|
|
71
|
+
return headers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _motor_headers() -> dict[str, str]:
|
|
75
|
+
token = (
|
|
76
|
+
os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
|
|
77
|
+
or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
78
|
+
)
|
|
79
|
+
return {"X-RoboPark-Motor-Token": token} if token else {}
|
|
63
80
|
|
|
64
81
|
|
|
65
82
|
def _load_robopark_identity() -> Optional[tuple[str, str, str]]:
|
|
@@ -97,7 +114,7 @@ def _report_status(states: "list[ServiceState]", identity: tuple[str, str, str])
|
|
|
97
114
|
payload = {"services": [s.status_dict() for s in states]}
|
|
98
115
|
url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-status"
|
|
99
116
|
try:
|
|
100
|
-
resp = httpx.post(url, json=payload, headers=
|
|
117
|
+
resp = httpx.post(url, json=payload, headers=_scheduler_headers(token), timeout=5.0)
|
|
101
118
|
resp.raise_for_status()
|
|
102
119
|
return resp.json().get("commands", [])
|
|
103
120
|
except Exception as e:
|
|
@@ -398,7 +415,7 @@ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload
|
|
|
398
415
|
url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-output"
|
|
399
416
|
body = {"kind": kind, "service": service, "payload": payload, "request_id": request_id}
|
|
400
417
|
try:
|
|
401
|
-
httpx.post(url, json=body, headers=
|
|
418
|
+
httpx.post(url, json=body, headers=_scheduler_headers(token), timeout=5.0)
|
|
402
419
|
except Exception as e:
|
|
403
420
|
logger.debug(f"supervisor-output POST failed: {e}")
|
|
404
421
|
|
|
@@ -445,7 +462,7 @@ def _discover_motor_registry(params: dict) -> dict:
|
|
|
445
462
|
if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
|
|
446
463
|
return {"ok": False, "error": "motor server must be robot-local"}
|
|
447
464
|
try:
|
|
448
|
-
with httpx.Client(timeout=5.0) as client:
|
|
465
|
+
with httpx.Client(timeout=5.0, headers=_motor_headers()) as client:
|
|
449
466
|
response = client.get(f"{base}/list-motors")
|
|
450
467
|
response.raise_for_status()
|
|
451
468
|
raw_motors = response.json().get("motors", [])
|
|
@@ -487,7 +504,11 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
487
504
|
completed = []
|
|
488
505
|
gpio_log = []
|
|
489
506
|
try:
|
|
490
|
-
with httpx.Client(timeout=8.0) as client:
|
|
507
|
+
with httpx.Client(timeout=8.0, headers=_motor_headers()) as client:
|
|
508
|
+
if params.get("stop_all"):
|
|
509
|
+
response = client.post(f"{base}/stop-motors", json={})
|
|
510
|
+
response.raise_for_status()
|
|
511
|
+
return {"ok": True, "stopped": True, "sequence_id": "emergency-stop"}
|
|
491
512
|
existing = client.get(f"{base}/list-motors").json().get("motors", [])
|
|
492
513
|
existing_names = {str(item.get("name")) for item in existing}
|
|
493
514
|
for motor_id, motor in registry.items():
|
|
@@ -556,7 +577,7 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
556
577
|
completed.append({"motor_id": motor_id, "gpio": int(motor["gpio"]), "duration_ms": duration_ms})
|
|
557
578
|
except Exception as exc:
|
|
558
579
|
try:
|
|
559
|
-
httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
|
|
580
|
+
httpx.post(f"{base}/stop-motors", json={}, headers=_motor_headers(), timeout=3.0)
|
|
560
581
|
except Exception:
|
|
561
582
|
pass
|
|
562
583
|
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
|
|
@@ -1095,12 +1116,17 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
1095
1116
|
}
|
|
1096
1117
|
|
|
1097
1118
|
|
|
1098
|
-
def run(config_path: Path) -> None:
|
|
1099
|
-
global _CURRENT_LOG_DIR
|
|
1100
|
-
|
|
1119
|
+
def run(config_path: Path, commands_only: bool = False) -> None:
|
|
1120
|
+
global _CURRENT_LOG_DIR
|
|
1121
|
+
if commands_only:
|
|
1122
|
+
# Unified robot-runtime already owns preview, vision, audio and motors.
|
|
1123
|
+
# Do not require a legacy supervisor config or launch duplicate owners.
|
|
1124
|
+
services, log_dir = [], LOG_DIR
|
|
1125
|
+
else:
|
|
1126
|
+
services, log_dir = _load_config(config_path)
|
|
1101
1127
|
_CURRENT_LOG_DIR = log_dir
|
|
1102
1128
|
enabled = [s for s in services if s.enabled]
|
|
1103
|
-
if not enabled:
|
|
1129
|
+
if not enabled and not commands_only:
|
|
1104
1130
|
raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
|
|
1105
1131
|
|
|
1106
1132
|
states = [ServiceState(spec=s) for s in enabled]
|
|
@@ -1116,7 +1142,10 @@ def run(config_path: Path) -> None:
|
|
|
1116
1142
|
signal.signal(signal.SIGINT, _on_signal)
|
|
1117
1143
|
signal.signal(signal.SIGTERM, _on_signal)
|
|
1118
1144
|
|
|
1119
|
-
|
|
1145
|
+
if commands_only:
|
|
1146
|
+
logger.info("command-only supervisor active; waiting for scheduler requests")
|
|
1147
|
+
else:
|
|
1148
|
+
logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
|
|
1120
1149
|
|
|
1121
1150
|
last_status_report_at = 0.0
|
|
1122
1151
|
try:
|
|
@@ -1157,7 +1186,7 @@ def run(config_path: Path) -> None:
|
|
|
1157
1186
|
_execute_command(target, cmd.get("action", "restart"))
|
|
1158
1187
|
elif kind == "supervisor_action":
|
|
1159
1188
|
logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
|
|
1160
|
-
elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_sequence"):
|
|
1189
|
+
elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_discover", "motor_sequence"):
|
|
1161
1190
|
# run on a background thread so we don't block
|
|
1162
1191
|
# the 2s poll loop on a slow command
|
|
1163
1192
|
import threading
|
|
@@ -1181,9 +1210,11 @@ def main() -> None:
|
|
|
1181
1210
|
sys.stderr.reconfigure(line_buffering=True)
|
|
1182
1211
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
1183
1212
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1184
|
-
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
|
|
1185
|
-
|
|
1186
|
-
|
|
1213
|
+
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
|
|
1214
|
+
parser.add_argument("--commands-only", action="store_true",
|
|
1215
|
+
help="consume remote commands without launching duplicate robot services")
|
|
1216
|
+
args = parser.parse_args()
|
|
1217
|
+
run(args.config, commands_only=args.commands_only)
|
|
1187
1218
|
|
|
1188
1219
|
|
|
1189
1220
|
if __name__ == "__main__":
|
|
@@ -1,30 +1,50 @@
|
|
|
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
|
|
5
6
|
import time
|
|
6
7
|
import threading
|
|
7
|
-
import json
|
|
8
|
+
import json
|
|
8
9
|
import os
|
|
10
|
+
import hmac
|
|
9
11
|
|
|
10
12
|
try:
|
|
11
13
|
import lgpio
|
|
12
14
|
except ImportError:
|
|
13
15
|
lgpio = None
|
|
14
16
|
|
|
15
|
-
app = FastAPI(title="Motor Control API", version="1.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
17
|
+
app = FastAPI(title="Motor Control API", version="1.1.0")
|
|
18
|
+
|
|
19
|
+
MOTOR_API_TOKEN = (
|
|
20
|
+
os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
|
|
21
|
+
or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.middleware("http")
|
|
26
|
+
async def require_motor_token(request: Request, call_next):
|
|
27
|
+
"""Keep actuation private even if an operator accidentally changes the bind host."""
|
|
28
|
+
if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
|
|
29
|
+
supplied = request.headers.get("x-robopark-motor-token", "").strip()
|
|
30
|
+
if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
|
|
31
|
+
return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
|
|
32
|
+
return await call_next(request)
|
|
33
|
+
|
|
34
|
+
# The dashboard never calls this service directly; robot-local services do.
|
|
35
|
+
app.add_middleware(
|
|
36
|
+
CORSMiddleware,
|
|
37
|
+
allow_origins=["http://127.0.0.1", "http://localhost"],
|
|
38
|
+
allow_credentials=False,
|
|
39
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
40
|
+
allow_headers=["content-type", "x-robopark-motor-token"],
|
|
41
|
+
)
|
|
25
42
|
|
|
26
43
|
# Storage file
|
|
27
|
-
MOTORS_FILE =
|
|
44
|
+
MOTORS_FILE = os.getenv(
|
|
45
|
+
"ROBOPARK_MOTORS_FILE",
|
|
46
|
+
os.path.expanduser("~/.robopark/motors.json"),
|
|
47
|
+
)
|
|
28
48
|
|
|
29
49
|
# In-memory storage
|
|
30
50
|
motors = {}
|
|
@@ -63,11 +83,16 @@ def load_motors():
|
|
|
63
83
|
print(f"[ERROR] Failed to load motors: {e}")
|
|
64
84
|
motors = {}
|
|
65
85
|
|
|
66
|
-
def save_motors():
|
|
86
|
+
def save_motors():
|
|
67
87
|
"""Save motors to motors.json file"""
|
|
68
88
|
try:
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
|
|
90
|
+
temporary = f"{MOTORS_FILE}.tmp"
|
|
91
|
+
with open(temporary, 'w') as f:
|
|
92
|
+
json.dump(motors, f, indent=2)
|
|
93
|
+
f.flush()
|
|
94
|
+
os.fsync(f.fileno())
|
|
95
|
+
os.replace(temporary, MOTORS_FILE)
|
|
71
96
|
print(f"[SAVE] Motors saved to {MOTORS_FILE}")
|
|
72
97
|
except Exception as e:
|
|
73
98
|
print(f"[ERROR] Failed to save motors: {e}")
|
|
@@ -167,8 +192,9 @@ def shutdown_gpio() -> None:
|
|
|
167
192
|
@app.get("/")
|
|
168
193
|
async def root():
|
|
169
194
|
return {
|
|
170
|
-
"message": "Motor Control API",
|
|
171
|
-
"version": "1.
|
|
195
|
+
"message": "Motor Control API",
|
|
196
|
+
"version": "1.1.0",
|
|
197
|
+
"authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
|
|
172
198
|
"endpoints": ["/status", "/test", "/add-motor", "/trigger-motor", "/list-motors", "/stop-motors"]
|
|
173
199
|
}
|
|
174
200
|
|