infinicode 2.8.92 → 2.8.93
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 +1 -1
- package/dist/robopark/auto-start.js +51 -11
- package/dist/robopark/robot-runtime.js +48 -0
- package/dist/robopark/setup.js +4 -1
- package/dist/robopark/vision-agent-launcher.js +27 -6
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +15 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/media_lock.py +57 -0
- package/packages/robopark/scheduler/preview_agent.py +125 -45
- package/packages/robopark/scheduler/robot_supervisor.py +23 -18
- package/packages/robopark/vision/app_pi_clean.py +12 -7
- package/packages/robopark/vision/audio_server_pi.py +24 -5
|
@@ -6888,7 +6888,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6888
6888
|
var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class="metric"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class="metric"><b>'+esc(active?'in session':'standing by')+'</b><span>conversation</span></div><div class="metric"><b>'+(row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'--')+'</b><span>average latency</span></div><div class="metric"><b>'+(row.drop_rate!=null?Math.round(row.drop_rate*100)+'%':'--')+'</b><span>session drops</span></div>';
|
|
6889
6889
|
var insights=$('pk-camera-ops-insights');if(insights)insights.innerHTML='<span>Camera relay</span><b class="'+(cameraLive?'ok':'bad')+'">'+(cameraLive?'LIVE':'CONNECTING')+'</b><span>Mesh node</span><b class="'+(n.connected?'ok':'bad')+'">'+(n.connected?'connected':'offline')+'</b><span>Production mode</span><b class="'+(device.production_mode?'ok':'')+'">'+(device.production_mode?'armed':'disabled')+'</b><span>Readiness</span><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>Heartbeat</span><b>'+(row.heartbeat_age_seconds!=null?Math.round(row.heartbeat_age_seconds)+'s ago':'--')+'</b><span>Current room</span><b>'+esc(String(row.current_session_id||row.active_session_id||'none').slice(0,24))+'</b><span>Triggers</span><b>'+esc(row.trigger_count!=null?row.trigger_count:0)+'</b><span>Sessions</span><b>'+esc(row.sessions_total!=null?row.sessions_total:0)+'</b>';
|
|
6890
6890
|
var stages=[['Robot online',!!n.connected],['Camera streaming',cameraLive],['Motion detector armed',!!device.production_mode],['Scheduler session',active],['Voice pipeline',readiness==='in_session'||active]],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s){return '<div><i class="'+(s[1]?'ok':'warn')+'"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?'ready':'waiting')+'</b></div>';}).join('');
|
|
6891
|
-
var inv=device.device_inventory||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
|
|
6891
|
+
var inv=device.device_inventory||{},mh=inv.media_health||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Camera permission</span><b class="'+(mh.camera_access===false?'bad':'ok')+'">'+(mh.camera_access===false?'DENIED':mh.camera_access===true?'granted':'--')+'</b><span>Audio permission</span><b class="'+(mh.audio_access===false?'bad':'ok')+'">'+(mh.audio_access===false?'DENIED':mh.audio_access===true?'granted':'--')+'</b><span>Camera worker</span><b class="'+(mh.camera_stalled?'bad':'ok')+'">'+(mh.camera_stalled?'STALLED':mh.camera_worker?'streaming':'--')+'</b><span>Frame age</span><b>'+(mh.camera_frame_age_seconds!=null?Number(mh.camera_frame_age_seconds).toFixed(1)+'s':'--')+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
|
|
6892
6892
|
var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}
|
|
6893
6893
|
}
|
|
6894
6894
|
function reloadCameraOperations(){
|
|
@@ -65,19 +65,56 @@ async function registerSystemd(service) {
|
|
|
65
65
|
const envLines = Object.entries(service.env ?? {})
|
|
66
66
|
.map(([k, v]) => `Environment="${k}=${v.replace(/"/g, '\\"')}"`)
|
|
67
67
|
.join('\n');
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
const mediaService = service.role === 'robot-runtime' || service.role === 'robot-preview-agent' || service.role === 'robot-vision-agent';
|
|
69
|
+
const mediaGroups = mediaService
|
|
70
|
+
? ['audio', 'video', 'render', 'input', 'plugdev'].filter(group => spawnSync('getent', ['group', group], { stdio: 'ignore' }).status === 0)
|
|
71
|
+
: [];
|
|
72
|
+
const mediaPolicy = mediaService ? [
|
|
73
|
+
mediaGroups.length ? `SupplementaryGroups=${mediaGroups.join(' ')}` : '',
|
|
74
|
+
'UMask=0007',
|
|
75
|
+
'LimitNOFILE=65536',
|
|
76
|
+
'TasksMax=512',
|
|
77
|
+
'OOMScoreAdjust=-500',
|
|
78
|
+
].filter(Boolean).join('\n') : '';
|
|
79
|
+
let mediaRulesInstalled = false;
|
|
80
|
+
if (mediaService) {
|
|
81
|
+
const rulesPath = '/etc/udev/rules.d/70-robopark-media.rules';
|
|
82
|
+
const rules = [
|
|
83
|
+
'# Managed by RoboPark. Shared hardware profile for production robots.',
|
|
84
|
+
'SUBSYSTEM=="video4linux", GROUP="video", MODE="0660"',
|
|
85
|
+
'SUBSYSTEM=="video4linux", ENV{ID_BUS}=="usb", ATTR{index}=="0", SYMLINK+="robopark-camera"',
|
|
86
|
+
'SUBSYSTEM=="sound", GROUP="audio", MODE="0660"',
|
|
87
|
+
'',
|
|
88
|
+
].join('\n');
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(rulesPath, rules, 'utf8');
|
|
91
|
+
spawnSync('udevadm', ['control', '--reload-rules'], { stdio: 'ignore' });
|
|
92
|
+
spawnSync('udevadm', ['trigger', '--subsystem-match=video4linux'], { stdio: 'ignore' });
|
|
93
|
+
spawnSync('udevadm', ['trigger', '--subsystem-match=sound'], { stdio: 'ignore' });
|
|
94
|
+
mediaRulesInstalled = true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// The explicit systemd groups below still cover standard distro rules.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const unit = `[Unit]
|
|
101
|
+
Description=RoboPark ${service.role} for ${service.name}
|
|
102
|
+
After=network-online.target sound.target
|
|
103
|
+
Wants=network-online.target sound.target
|
|
104
|
+
StartLimitIntervalSec=120
|
|
105
|
+
StartLimitBurst=20
|
|
106
|
+
|
|
107
|
+
[Service]
|
|
108
|
+
Type=simple
|
|
109
|
+
ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
|
|
76
110
|
WorkingDirectory=${service.workingDir ?? homedir()}
|
|
77
111
|
Restart=always
|
|
78
|
-
RestartSec=
|
|
112
|
+
RestartSec=3
|
|
79
113
|
KillMode=control-group
|
|
80
|
-
TimeoutStopSec=
|
|
114
|
+
TimeoutStopSec=20
|
|
115
|
+
SendSIGKILL=yes
|
|
116
|
+
FinalKillSignal=SIGKILL
|
|
117
|
+
${mediaPolicy}
|
|
81
118
|
${envLines}
|
|
82
119
|
|
|
83
120
|
[Install]
|
|
@@ -93,7 +130,10 @@ WantedBy=multi-user.target
|
|
|
93
130
|
if (enabled.status !== 0) {
|
|
94
131
|
return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
|
|
95
132
|
}
|
|
96
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
message: `enabled and started systemd unit ${unitName}${mediaRulesInstalled ? ' with persistent media permissions' : ''}`,
|
|
136
|
+
};
|
|
97
137
|
}
|
|
98
138
|
catch (e) {
|
|
99
139
|
return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
|
|
@@ -15,7 +15,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { resolveInfinicodeBin } from './serve.js';
|
|
17
17
|
const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
|
|
18
|
+
const VISION_STATUS_URL = 'http://127.0.0.1:5000/api/camera/status';
|
|
18
19
|
const RESTART_DELAY_MS = 5_000;
|
|
20
|
+
const MEDIA_WATCHDOG_INTERVAL_MS = 10_000;
|
|
19
21
|
function resetStaleEnrollment() {
|
|
20
22
|
const configDir = join(homedir(), '.robopark');
|
|
21
23
|
const tokenPath = join(configDir, 'device_token');
|
|
@@ -97,6 +99,9 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
97
99
|
command: process.execPath,
|
|
98
100
|
args: [cli, 'vision-agent', '--foreground', '--port', '5000',
|
|
99
101
|
'--motion-webhook-url', 'http://127.0.0.1:5057/'],
|
|
102
|
+
env: {
|
|
103
|
+
ROBOPARK_CAMERA_DEVICE: opts.videoDevice ?? '/dev/robopark-camera',
|
|
104
|
+
},
|
|
100
105
|
});
|
|
101
106
|
}
|
|
102
107
|
const previewArgs = [cli, 'preview-agent', '--foreground',
|
|
@@ -122,6 +127,10 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
122
127
|
? 'http://127.0.0.1:5000/api/media/inventory'
|
|
123
128
|
: '',
|
|
124
129
|
ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
|
|
130
|
+
// Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
|
|
131
|
+
// from these USB product labels on every inventory refresh.
|
|
132
|
+
ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
|
|
133
|
+
ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
|
|
125
134
|
// Inventory changes should reach the Control Center promptly.
|
|
126
135
|
HEARTBEAT_INTERVAL: '5',
|
|
127
136
|
},
|
|
@@ -182,6 +191,43 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
182
191
|
signalChild(entry, 'SIGTERM');
|
|
183
192
|
});
|
|
184
193
|
};
|
|
194
|
+
let visionFailureCount = 0;
|
|
195
|
+
let visionRecoveryInFlight = false;
|
|
196
|
+
const monitorVision = async () => {
|
|
197
|
+
if (stopping || recycling || visionRecoveryInFlight || opts.vision === false)
|
|
198
|
+
return;
|
|
199
|
+
const vision = children.find(entry => entry.label === 'vision');
|
|
200
|
+
if (!vision?.child)
|
|
201
|
+
return;
|
|
202
|
+
let healthy = false;
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(VISION_STATUS_URL, { signal: AbortSignal.timeout(2_000) });
|
|
205
|
+
if (response.ok) {
|
|
206
|
+
const status = await response.json();
|
|
207
|
+
const frameTooOld = status.worker_started === true
|
|
208
|
+
&& typeof status.last_frame_age_seconds === 'number'
|
|
209
|
+
&& status.last_frame_age_seconds > 15;
|
|
210
|
+
healthy = !status.read_stalled && !frameTooOld;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
healthy = false;
|
|
215
|
+
}
|
|
216
|
+
visionFailureCount = healthy ? 0 : visionFailureCount + 1;
|
|
217
|
+
if (visionFailureCount < 3)
|
|
218
|
+
return;
|
|
219
|
+
visionRecoveryInFlight = true;
|
|
220
|
+
console.error(chalk.yellow(' media watchdog: RoboVision unhealthy for 30s; recycling camera/audio pair'));
|
|
221
|
+
try {
|
|
222
|
+
await stopChild(vision);
|
|
223
|
+
if (!stopping)
|
|
224
|
+
start(vision);
|
|
225
|
+
visionFailureCount = 0;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
visionRecoveryInFlight = false;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
185
231
|
const recycleAfterMeshUpdate = async () => {
|
|
186
232
|
if (stopping || recycling)
|
|
187
233
|
return;
|
|
@@ -231,8 +277,10 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
231
277
|
: chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
|
|
232
278
|
});
|
|
233
279
|
}
|
|
280
|
+
const mediaWatchdog = setInterval(() => { void monitorVision(); }, MEDIA_WATCHDOG_INTERVAL_MS);
|
|
234
281
|
const shutdown = () => {
|
|
235
282
|
stopping = true;
|
|
283
|
+
clearInterval(mediaWatchdog);
|
|
236
284
|
for (const entry of [...children, preview])
|
|
237
285
|
signalChild(entry, 'SIGTERM');
|
|
238
286
|
};
|
package/dist/robopark/setup.js
CHANGED
|
@@ -196,6 +196,9 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
196
196
|
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
197
197
|
// on this box, or RoboVisionAI_PI isn't installed).
|
|
198
198
|
const visionEnabled = opts.vision !== false;
|
|
199
|
+
const defaultVideoDevice = process.platform === 'linux' && opts.autoStart
|
|
200
|
+
? '/dev/robopark-camera'
|
|
201
|
+
: '/dev/video0';
|
|
199
202
|
const runtimeArgs = [
|
|
200
203
|
'robot', 'up',
|
|
201
204
|
'--name', internal.name,
|
|
@@ -204,7 +207,7 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
204
207
|
'--token', internal.token,
|
|
205
208
|
'--port', String(internal.port),
|
|
206
209
|
networkFlag,
|
|
207
|
-
'--video-device', opts.videoDevice ??
|
|
210
|
+
'--video-device', opts.videoDevice ?? defaultVideoDevice,
|
|
208
211
|
'--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio (hw:3,0)',
|
|
209
212
|
];
|
|
210
213
|
if (opts.enrollmentToken)
|
|
@@ -49,13 +49,34 @@ export async function roboparkVisionAgent(opts) {
|
|
|
49
49
|
console.log(` audio: ${chalk.green('RoboVision audio server :8000')}`);
|
|
50
50
|
console.log();
|
|
51
51
|
if (opts.foreground) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
52
|
+
const audio = audioScript
|
|
53
|
+
? spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } })
|
|
54
|
+
: null;
|
|
55
|
+
const camera = spawn(python, args, { stdio: 'inherit' });
|
|
56
|
+
let stopping = false;
|
|
57
|
+
const stopChildren = () => {
|
|
58
|
+
if (stopping)
|
|
59
|
+
return;
|
|
60
|
+
stopping = true;
|
|
61
|
+
camera.kill('SIGTERM');
|
|
62
|
+
audio?.kill('SIGTERM');
|
|
63
|
+
};
|
|
64
|
+
process.once('SIGINT', stopChildren);
|
|
65
|
+
process.once('SIGTERM', stopChildren);
|
|
66
|
+
// If either required media service exits, tear down its sibling. The
|
|
67
|
+
// robot runtime then restarts one coherent pair instead of accumulating
|
|
68
|
+
// orphan audio servers and duplicate owners of ports/devices.
|
|
57
69
|
await new Promise((resolve) => {
|
|
58
|
-
|
|
70
|
+
let resolved = false;
|
|
71
|
+
const finish = () => {
|
|
72
|
+
if (resolved)
|
|
73
|
+
return;
|
|
74
|
+
resolved = true;
|
|
75
|
+
stopChildren();
|
|
76
|
+
resolve();
|
|
77
|
+
};
|
|
78
|
+
camera.once('close', finish);
|
|
79
|
+
audio?.once('close', finish);
|
|
59
80
|
});
|
|
60
81
|
return;
|
|
61
82
|
}
|
|
@@ -127,3 +127,18 @@ $s.events | Where-Object session_id -eq $id | Select-Object timestamp,stage,stat
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
Required result: `playback_started ok`.
|
|
130
|
+
|
|
131
|
+
## Fleet Media Reliability Contract (2.8.93+)
|
|
132
|
+
|
|
133
|
+
- Linux setup installs persistent udev permissions for `video4linux` and
|
|
134
|
+
`sound`, plus `/dev/robopark-camera` for the primary USB capture interface.
|
|
135
|
+
- The systemd runtime receives available media groups and uses control-group
|
|
136
|
+
shutdown so descendants cannot survive an update or restart.
|
|
137
|
+
- `/run/lock/robopark-speaker.lock` and
|
|
138
|
+
`/run/lock/robopark-microphone.lock` serialize production media and tests.
|
|
139
|
+
- The vision launcher owns both camera and audio children. If either exits,
|
|
140
|
+
the runtime restarts one coherent pair instead of leaving an orphan owner.
|
|
141
|
+
- RoboVision opens its shared camera worker at boot. The runtime polls camera
|
|
142
|
+
status and recycles media after three consecutive unhealthy checks.
|
|
143
|
+
- Park media operations display camera/audio permission, camera worker state,
|
|
144
|
+
and latest frame age from `device_inventory.media_health`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Cross-process media ownership for Linux robot appliances."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MediaLock:
|
|
12
|
+
def __init__(self, kind: str, timeout: float = 5.0):
|
|
13
|
+
self.kind = kind
|
|
14
|
+
self.timeout = timeout
|
|
15
|
+
self.fd: Optional[int] = None
|
|
16
|
+
|
|
17
|
+
def acquire(self) -> "MediaLock":
|
|
18
|
+
if not sys.platform.startswith("linux"):
|
|
19
|
+
return self
|
|
20
|
+
import fcntl
|
|
21
|
+
|
|
22
|
+
lock_dir = Path("/run/lock") if os.access("/run/lock", os.W_OK) else Path("/tmp")
|
|
23
|
+
path = lock_dir / f"robopark-{self.kind}.lock"
|
|
24
|
+
self.fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o660)
|
|
25
|
+
deadline = time.monotonic() + self.timeout
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
29
|
+
os.ftruncate(self.fd, 0)
|
|
30
|
+
os.write(self.fd, f"{os.getpid()}\n".encode("ascii"))
|
|
31
|
+
return self
|
|
32
|
+
except BlockingIOError:
|
|
33
|
+
if time.monotonic() >= deadline:
|
|
34
|
+
self.release()
|
|
35
|
+
raise TimeoutError(f"{self.kind} is owned by another RoboPark media process")
|
|
36
|
+
time.sleep(0.05)
|
|
37
|
+
|
|
38
|
+
def release(self) -> None:
|
|
39
|
+
if self.fd is None:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
if sys.platform.startswith("linux"):
|
|
43
|
+
import fcntl
|
|
44
|
+
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
45
|
+
finally:
|
|
46
|
+
os.close(self.fd)
|
|
47
|
+
self.fd = None
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "MediaLock":
|
|
50
|
+
return self.acquire()
|
|
51
|
+
|
|
52
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
53
|
+
self.release()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def media_lock(kind: str, timeout: float = 5.0) -> MediaLock:
|
|
57
|
+
return MediaLock(kind, timeout)
|
|
@@ -70,6 +70,31 @@ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
|
70
70
|
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
71
|
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
72
|
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stable_audio_label(value: object) -> str:
|
|
76
|
+
"""Compare USB product names without volatile ALSA card coordinates."""
|
|
77
|
+
import re
|
|
78
|
+
return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
|
|
82
|
+
if preferred:
|
|
83
|
+
wanted = _stable_audio_label(preferred)
|
|
84
|
+
match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
|
|
85
|
+
if match and match.get("name"):
|
|
86
|
+
return str(match["name"])
|
|
87
|
+
selected_text = str(selected)
|
|
88
|
+
selected_label = _stable_audio_label(selected_text)
|
|
89
|
+
match = next(
|
|
90
|
+
(
|
|
91
|
+
item for item in items
|
|
92
|
+
if str(item.get("id")) == selected_text
|
|
93
|
+
or _stable_audio_label(item.get("name")) == selected_label
|
|
94
|
+
),
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
73
98
|
|
|
74
99
|
|
|
75
100
|
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
@@ -103,17 +128,23 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
103
128
|
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
104
129
|
import re
|
|
105
130
|
import subprocess
|
|
131
|
+
from media_lock import media_lock
|
|
106
132
|
|
|
107
133
|
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
108
134
|
if not match:
|
|
109
135
|
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
try:
|
|
137
|
+
with media_lock("speaker", timeout=3.0):
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
[
|
|
140
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
141
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
142
|
+
],
|
|
143
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
144
|
+
)
|
|
145
|
+
except TimeoutError as exc:
|
|
146
|
+
logger.warning("audio effect skipped: %s", exc)
|
|
147
|
+
return
|
|
117
148
|
if result.returncode:
|
|
118
149
|
logger.warning(
|
|
119
150
|
"audio effect failed on %s: %s",
|
|
@@ -225,7 +256,38 @@ def _get_device_inventory() -> dict:
|
|
|
225
256
|
pa.terminate()
|
|
226
257
|
except Exception as e:
|
|
227
258
|
logger.debug(f"audio inventory unavailable: {e}")
|
|
228
|
-
|
|
259
|
+
|
|
260
|
+
media_health = {
|
|
261
|
+
"service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
262
|
+
"camera_access": None,
|
|
263
|
+
"audio_access": None,
|
|
264
|
+
"camera_worker": None,
|
|
265
|
+
"camera_stalled": None,
|
|
266
|
+
"camera_frame_age_seconds": None,
|
|
267
|
+
}
|
|
268
|
+
if sys.platform.startswith("linux"):
|
|
269
|
+
camera_nodes = [
|
|
270
|
+
str(item.get("id")) for item in inventory.get("video", [])
|
|
271
|
+
if str(item.get("id", "")).startswith("/dev/video")
|
|
272
|
+
]
|
|
273
|
+
sound_nodes = glob.glob("/dev/snd/pcm*")
|
|
274
|
+
media_health["camera_access"] = bool(camera_nodes) and all(
|
|
275
|
+
os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
|
|
276
|
+
)
|
|
277
|
+
media_health["audio_access"] = bool(sound_nodes) and all(
|
|
278
|
+
os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
|
|
282
|
+
if camera_response.is_success:
|
|
283
|
+
camera_status = camera_response.json()
|
|
284
|
+
media_health["camera_worker"] = bool(camera_status.get("worker_started"))
|
|
285
|
+
media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
|
|
286
|
+
media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
inventory["media_health"] = media_health
|
|
290
|
+
|
|
229
291
|
_DEVICE_INVENTORY_CACHE = inventory
|
|
230
292
|
_DEVICE_INVENTORY_CACHE_AT = now
|
|
231
293
|
logger.info(
|
|
@@ -707,26 +769,18 @@ class PreviewAgent:
|
|
|
707
769
|
self.audio_device = data["audio_device"]
|
|
708
770
|
inventory = data.get("device_inventory") or {}
|
|
709
771
|
selected = str(self.audio_device)
|
|
710
|
-
self.audio_capture_device =
|
|
711
|
-
(
|
|
712
|
-
|
|
713
|
-
for item in inventory.get("audio_input", [])
|
|
714
|
-
if str(item.get("id")) == selected and item.get("name")
|
|
715
|
-
),
|
|
716
|
-
self.audio_device,
|
|
772
|
+
self.audio_capture_device = _resolve_inventory_audio(
|
|
773
|
+
inventory.get("audio_input", []), selected,
|
|
774
|
+
os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
|
|
717
775
|
)
|
|
718
776
|
if data.get("audio_output_device") is not None:
|
|
719
777
|
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
720
778
|
self.audio_output_device = data["audio_output_device"]
|
|
721
779
|
inventory = data.get("device_inventory") or {}
|
|
722
780
|
selected = str(self.audio_output_device)
|
|
723
|
-
self.audio_playback_device =
|
|
724
|
-
(
|
|
725
|
-
|
|
726
|
-
for item in inventory.get("audio_output", [])
|
|
727
|
-
if str(item.get("id")) == selected and item.get("name")
|
|
728
|
-
),
|
|
729
|
-
self.audio_output_device,
|
|
781
|
+
self.audio_playback_device = _resolve_inventory_audio(
|
|
782
|
+
inventory.get("audio_output", []), selected,
|
|
783
|
+
os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
|
|
730
784
|
)
|
|
731
785
|
if changed:
|
|
732
786
|
await self._sync_robovision_media_config()
|
|
@@ -1275,6 +1329,7 @@ class LiveKitPublisher:
|
|
|
1275
1329
|
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1276
1330
|
pa = None
|
|
1277
1331
|
output_device_index = None
|
|
1332
|
+
speaker_guard = None
|
|
1278
1333
|
|
|
1279
1334
|
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1280
1335
|
# but the numeric indices are not stable across PyAudio builds. More
|
|
@@ -1284,23 +1339,33 @@ class LiveKitPublisher:
|
|
|
1284
1339
|
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1285
1340
|
import re
|
|
1286
1341
|
import subprocess
|
|
1342
|
+
from media_lock import media_lock
|
|
1287
1343
|
|
|
1288
1344
|
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1289
1345
|
if not match:
|
|
1290
1346
|
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1291
1347
|
return
|
|
1292
1348
|
alsa_device = f"plug{match.group(1)}"
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1349
|
+
try:
|
|
1350
|
+
speaker_guard = media_lock("speaker", timeout=8.0).acquire()
|
|
1351
|
+
process = subprocess.Popen(
|
|
1352
|
+
[
|
|
1353
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1354
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1355
|
+
],
|
|
1356
|
+
stdin=subprocess.PIPE,
|
|
1357
|
+
stderr=subprocess.PIPE,
|
|
1358
|
+
)
|
|
1359
|
+
except Exception as exc:
|
|
1360
|
+
if speaker_guard is not None:
|
|
1361
|
+
speaker_guard.release()
|
|
1362
|
+
logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
|
|
1363
|
+
return
|
|
1301
1364
|
if process.stdin is None:
|
|
1302
1365
|
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1303
1366
|
process.kill()
|
|
1367
|
+
process.wait(timeout=1)
|
|
1368
|
+
speaker_guard.release()
|
|
1304
1369
|
return
|
|
1305
1370
|
|
|
1306
1371
|
class _AplayOutput:
|
|
@@ -1522,6 +1587,8 @@ class LiveKitPublisher:
|
|
|
1522
1587
|
out.close()
|
|
1523
1588
|
if pa is not None:
|
|
1524
1589
|
pa.terminate()
|
|
1590
|
+
if speaker_guard is not None:
|
|
1591
|
+
speaker_guard.release()
|
|
1525
1592
|
|
|
1526
1593
|
async def stop(self) -> None:
|
|
1527
1594
|
self._stop_event.set()
|
|
@@ -1864,21 +1931,30 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1864
1931
|
def __init__(self, device: str):
|
|
1865
1932
|
import re
|
|
1866
1933
|
import subprocess
|
|
1934
|
+
from media_lock import media_lock
|
|
1867
1935
|
|
|
1868
1936
|
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
1869
1937
|
if not match:
|
|
1870
1938
|
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
1871
1939
|
alsa_device = f"plug{match.group(1)}"
|
|
1872
1940
|
self.buffer = bytearray()
|
|
1873
|
-
self.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1941
|
+
self.media_guard = media_lock("microphone", timeout=5.0).acquire()
|
|
1942
|
+
try:
|
|
1943
|
+
self.process = subprocess.Popen(
|
|
1944
|
+
[
|
|
1945
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
1946
|
+
"-f", "S16_LE", "-r", "48000", "-c", "1",
|
|
1947
|
+
],
|
|
1948
|
+
stdout=subprocess.PIPE,
|
|
1949
|
+
stderr=subprocess.PIPE,
|
|
1950
|
+
)
|
|
1951
|
+
except Exception:
|
|
1952
|
+
self.media_guard.release()
|
|
1953
|
+
raise
|
|
1881
1954
|
if self.process.stdout is None:
|
|
1955
|
+
self.process.kill()
|
|
1956
|
+
self.process.wait(timeout=1)
|
|
1957
|
+
self.media_guard.release()
|
|
1882
1958
|
raise RuntimeError("arecord did not provide a PCM stream")
|
|
1883
1959
|
self.alsa_device = alsa_device
|
|
1884
1960
|
|
|
@@ -1904,13 +1980,16 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1904
1980
|
)
|
|
1905
1981
|
|
|
1906
1982
|
def stop(self):
|
|
1907
|
-
|
|
1908
|
-
self.process.
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1983
|
+
try:
|
|
1984
|
+
if self.process.poll() is None:
|
|
1985
|
+
self.process.terminate()
|
|
1986
|
+
try:
|
|
1987
|
+
self.process.wait(timeout=2)
|
|
1988
|
+
except Exception:
|
|
1989
|
+
self.process.kill()
|
|
1990
|
+
self.process.wait(timeout=1)
|
|
1991
|
+
finally:
|
|
1992
|
+
self.media_guard.release()
|
|
1914
1993
|
|
|
1915
1994
|
|
|
1916
1995
|
class PyAudioCapture(AudioCapture):
|
|
@@ -2000,6 +2079,7 @@ def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
|
2000
2079
|
return AlsaAudioCapture(device)
|
|
2001
2080
|
except Exception as e:
|
|
2002
2081
|
logger.warning(f"ALSA capture unavailable for {device}: {e}")
|
|
2082
|
+
raise
|
|
2003
2083
|
try:
|
|
2004
2084
|
import pyaudio # noqa: F401
|
|
2005
2085
|
return PyAudioCapture(device if device != "default" else None)
|
|
@@ -600,24 +600,29 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
|
600
600
|
# ALSA can retain an exclusive USB handle briefly after the LiveKit
|
|
601
601
|
# playback stream closes. Retry for a bounded period instead of declaring
|
|
602
602
|
# a valid device unsupported on the first EBUSY response.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
603
|
+
try:
|
|
604
|
+
from media_lock import media_lock
|
|
605
|
+
with media_lock("speaker", timeout=8.0):
|
|
606
|
+
for attempt in range(5):
|
|
607
|
+
if attempt:
|
|
608
|
+
time.sleep(0.5)
|
|
609
|
+
try:
|
|
610
|
+
completed = subprocess.run(
|
|
611
|
+
command,
|
|
612
|
+
input=raw,
|
|
613
|
+
stdout=subprocess.DEVNULL,
|
|
614
|
+
stderr=subprocess.PIPE,
|
|
615
|
+
timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
|
|
616
|
+
check=False,
|
|
617
|
+
)
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
620
|
+
continue
|
|
621
|
+
if completed.returncode == 0:
|
|
622
|
+
return True, alsa_device
|
|
623
|
+
last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
|
|
624
|
+
except TimeoutError as exc:
|
|
625
|
+
return False, str(exc)
|
|
621
626
|
return False, f"{alsa_device}: {last_error}"
|
|
622
627
|
|
|
623
628
|
|