infinicode 2.8.88 → 2.8.90
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 +10 -9
- package/dist/kernel/federation/transport-http.js +16 -3
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +10 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/preview_agent.py +63 -31
- package/packages/robopark/vision/app_pi_clean.py +53 -5
|
@@ -4004,7 +4004,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
4004
4004
|
var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }
|
|
4005
4005
|
var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;
|
|
4006
4006
|
var previewOn=false, previewTimer=null; // on-demand operator camera preview
|
|
4007
|
-
var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
|
|
4007
|
+
var visionOn=false, visionRetryTimer=null; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
|
|
4008
4008
|
var lkRoom=null, micAnalyser=null, micMonitorSource=null, micMonitorGain=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;
|
|
4009
4009
|
var imgs={}; // preloaded robot concept avatars
|
|
4010
4010
|
var TW=44, TH=22, PARK_W=15, PARK_D=8;
|
|
@@ -6217,8 +6217,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6217
6217
|
directImg.style.display='block';
|
|
6218
6218
|
if(directCamNote)directCamNote.textContent='opening RoboVision camera relay...';
|
|
6219
6219
|
if(directRes)directRes.textContent='CONNECTING';
|
|
6220
|
-
directImg.onerror=function(){ visionOn=false; directImg.style.display='none';
|
|
6221
|
-
directImg.onload=function(){ directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
|
|
6220
|
+
directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable; reconnecting...'; if(directLiveNote) directLiveNote.textContent='camera reconnecting'; if(directRes) directRes.textContent='RETRYING'; if(visionRetryTimer)clearTimeout(visionRetryTimer);visionRetryTimer=setTimeout(function(){if(drawerId&&byId[drawerId]===n&&!visionOn)tryVisionOverlay(n,true);},3000); };
|
|
6221
|
+
directImg.onload=function(){ visionOn=true;if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
|
|
6222
6222
|
directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
|
|
6223
6223
|
return;
|
|
6224
6224
|
var img=$('pk-vision-img'); if(!img) return;
|
|
@@ -6235,9 +6235,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6235
6235
|
img.onload=function(){ img.style.display='block'; };
|
|
6236
6236
|
img.src='http://'+ip+':'+port+'/video_feed';
|
|
6237
6237
|
}
|
|
6238
|
-
function stopVision(){
|
|
6239
|
-
visionOn=false;
|
|
6240
|
-
|
|
6238
|
+
function stopVision(){
|
|
6239
|
+
visionOn=false;
|
|
6240
|
+
if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}
|
|
6241
|
+
var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }
|
|
6241
6242
|
}
|
|
6242
6243
|
function pollRoboparkQuiet(){
|
|
6243
6244
|
fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
|
|
@@ -6874,7 +6875,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6874
6875
|
function set(id,v){ var e=$(id); if(e) e.textContent=v; }
|
|
6875
6876
|
function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }
|
|
6876
6877
|
|
|
6877
|
-
var cameraOpsNode=null,cameraOpsTimer=null;
|
|
6878
|
+
var cameraOpsNode=null,cameraOpsTimer=null,cameraOpsRetryTimer=null;
|
|
6878
6879
|
function cameraOpsFeedUrl(n){
|
|
6879
6880
|
var ref=n&&(n.displayName||n.name||n.nodeId||n.id); if(!ref)return '';
|
|
6880
6881
|
return '/fed/media/robots/'+encodeURIComponent(ref)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
|
|
@@ -6892,13 +6893,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6892
6893
|
}
|
|
6893
6894
|
function reloadCameraOperations(){
|
|
6894
6895
|
if(!cameraOpsNode)return;var img=$('pk-camera-ops-img'),empty=$('pk-camera-ops-empty');if(!img)return;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='opening RoboVision camera...';}
|
|
6895
|
-
img.onload=function(){img.dataset.live='true';if(empty)empty.style.display='none';refreshCameraOperations();};img.onerror=function(){img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='camera relay unavailable / retrying';}refreshCameraOperations();};img.src=cameraOpsFeedUrl(cameraOpsNode);refreshCameraOperations();
|
|
6896
|
+
img.onload=function(){img.dataset.live='true';if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(empty)empty.style.display='none';refreshCameraOperations();};img.onerror=function(){var failedNode=cameraOpsNode;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='camera relay unavailable / retrying';}refreshCameraOperations();if(cameraOpsRetryTimer)clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=setTimeout(function(){if(cameraOpsNode&&cameraOpsNode===failedNode)reloadCameraOperations();},3000);};img.src=cameraOpsFeedUrl(cameraOpsNode);refreshCameraOperations();
|
|
6896
6897
|
}
|
|
6897
6898
|
function openCameraOperations(n){
|
|
6898
6899
|
cameraOpsNode=n;var host=$('pk-camera-ops');if(!host)return;host.classList.add('open');document.body.classList.add('camera-ops-open');var name=n.displayName||n.name||'robot',avatar=rpAvatarFor(name),av=$('pk-camera-ops-avatar');$('pk-camera-ops-name').textContent=name+' / live operations';$('pk-camera-ops-sub').textContent='RoboVision mesh relay / '+(n.connected?'connected':'offline');if(av){if(avatar){av.src=avatar+QS;av.style.display='block';}else{av.removeAttribute('src');av.style.display='none';}}reloadCameraOperations();if(cameraOpsTimer)clearInterval(cameraOpsTimer);cameraOpsTimer=setInterval(refreshCameraOperations,1000);
|
|
6899
6900
|
}
|
|
6900
6901
|
function closeCameraOperations(){
|
|
6901
|
-
var host=$('pk-camera-ops'),img=$('pk-camera-ops-img');if(host)host.classList.remove('open');document.body.classList.remove('camera-ops-open');if(img){img.src='';img.dataset.live='false';}if(cameraOpsTimer){clearInterval(cameraOpsTimer);cameraOpsTimer=null;}cameraOpsNode=null;if(document.fullscreenElement&&document.exitFullscreen)document.exitFullscreen().catch(function(){});
|
|
6902
|
+
var host=$('pk-camera-ops'),img=$('pk-camera-ops-img');if(host)host.classList.remove('open');document.body.classList.remove('camera-ops-open');if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(img){img.src='';img.dataset.live='false';}if(cameraOpsTimer){clearInterval(cameraOpsTimer);cameraOpsTimer=null;}cameraOpsNode=null;if(document.fullscreenElement&&document.exitFullscreen)document.exitFullscreen().catch(function(){});
|
|
6902
6903
|
}
|
|
6903
6904
|
(function bindCameraOperations(){
|
|
6904
6905
|
var close=$('pk-camera-ops-close'),refresh=$('pk-camera-ops-refresh'),nativeBtn=$('pk-camera-ops-native'),trigger=$('pk-camera-ops-trigger'),prod=$('pk-camera-ops-production'),end=$('pk-camera-ops-end');
|
|
@@ -85,7 +85,15 @@ export class MeshServer {
|
|
|
85
85
|
}
|
|
86
86
|
async streamHttp(req, res, target, headers = {}) {
|
|
87
87
|
const ctrl = new AbortController();
|
|
88
|
-
|
|
88
|
+
let clientClosed = false;
|
|
89
|
+
let timeout;
|
|
90
|
+
const armTimeout = () => {
|
|
91
|
+
if (timeout)
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
timeout = setTimeout(() => ctrl.abort('camera upstream timed out'), 10_000);
|
|
94
|
+
};
|
|
95
|
+
req.once('close', () => { clientClosed = true; ctrl.abort(); });
|
|
96
|
+
armTimeout();
|
|
89
97
|
try {
|
|
90
98
|
const upstream = await fetch(target, { headers, signal: ctrl.signal });
|
|
91
99
|
if (!upstream.ok || !upstream.body) {
|
|
@@ -105,6 +113,7 @@ export class MeshServer {
|
|
|
105
113
|
const { done, value } = await reader.read();
|
|
106
114
|
if (done || res.writableEnded)
|
|
107
115
|
break;
|
|
116
|
+
armTimeout();
|
|
108
117
|
if (!res.write(Buffer.from(value)))
|
|
109
118
|
await once(res, 'drain');
|
|
110
119
|
}
|
|
@@ -112,16 +121,20 @@ export class MeshServer {
|
|
|
112
121
|
res.end();
|
|
113
122
|
}
|
|
114
123
|
catch (err) {
|
|
115
|
-
if (
|
|
124
|
+
if (clientClosed)
|
|
116
125
|
return;
|
|
117
126
|
if (!res.headersSent) {
|
|
118
127
|
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
119
|
-
res.end(`robot camera unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
+
res.end(`robot camera unavailable: ${ctrl.signal.aborted ? 'upstream frame timeout' : (err instanceof Error ? err.message : String(err))}`);
|
|
120
129
|
}
|
|
121
130
|
else if (!res.writableEnded) {
|
|
122
131
|
res.end();
|
|
123
132
|
}
|
|
124
133
|
}
|
|
134
|
+
finally {
|
|
135
|
+
if (timeout)
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
}
|
|
125
138
|
}
|
|
126
139
|
streamLocalRoboVision(req, res) {
|
|
127
140
|
void this.streamHttp(req, res, 'http://127.0.0.1:5000/video_feed');
|
|
@@ -60,6 +60,10 @@ ROBOVOICE checkout, and rebuilds Compose once.
|
|
|
60
60
|
The preview agent previously used PyAudio for the selected speaker. BMW's
|
|
61
61
|
proven output is ALSA `aplay -D plughw:2,0`. Infinicode `2.8.86` uses that
|
|
62
62
|
same endpoint for live TTS whenever a Linux output selection contains `hw:`.
|
|
63
|
+
Motion cues must use that same ALSA endpoint and finish before the greeting
|
|
64
|
+
starts. Running a PyAudio cue concurrently with ALSA TTS creates an exclusive
|
|
65
|
+
device race on `hw:2,0`. `playback_started` is valid only after PCM has been
|
|
66
|
+
written to the physical speaker, not merely received from LiveKit.
|
|
63
67
|
|
|
64
68
|
### Silent Conversation After Greeting
|
|
65
69
|
|
|
@@ -78,6 +82,12 @@ motion detection consume a shared latest-frame stream. Starting one
|
|
|
78
82
|
`VideoCapture.read()` calls, which can block the second client and make the hub
|
|
79
83
|
relay time out with zero bytes.
|
|
80
84
|
|
|
85
|
+
Long-running camera operation also requires recovery at every layer: the Pi
|
|
86
|
+
releases and reopens a V4L2 handle after an eight-second blocked read, the mesh
|
|
87
|
+
relay returns an error after ten seconds without an upstream frame, and both
|
|
88
|
+
drawer and fullscreen camera views reconnect every three seconds. A camera
|
|
89
|
+
request must never remain in an unbounded `connecting` state.
|
|
90
|
+
|
|
81
91
|
### Duplicate Speaker Tests
|
|
82
92
|
|
|
83
93
|
Only one robot process may claim a scheduler `speaker_test`. The scheduler
|
package/package.json
CHANGED
|
@@ -83,22 +83,51 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
|
83
83
|
return url
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
87
|
-
"""Play a short local cue without involving the voice pipeline."""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
except Exception:
|
|
91
|
-
return
|
|
92
|
-
sample_rate = 48000
|
|
93
|
-
channels = 2
|
|
86
|
+
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
87
|
+
"""Play a short local cue without involving the voice pipeline."""
|
|
88
|
+
sample_rate = 48000
|
|
89
|
+
channels = 2
|
|
94
90
|
if effect == "motion":
|
|
95
91
|
notes = ((880, 0.09), (1320, 0.13))
|
|
96
92
|
else:
|
|
97
93
|
notes = ((660, 0.10), (440, 0.16))
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
selected = str(selected_output or "default")
|
|
95
|
+
frames = bytearray()
|
|
96
|
+
for frequency, duration in notes:
|
|
97
|
+
count = int(sample_rate * duration)
|
|
98
|
+
for n in range(count):
|
|
99
|
+
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
100
|
+
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
101
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
102
|
+
|
|
103
|
+
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
104
|
+
import re
|
|
105
|
+
import subprocess
|
|
106
|
+
|
|
107
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
108
|
+
if not match:
|
|
109
|
+
return
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
113
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
114
|
+
],
|
|
115
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
116
|
+
)
|
|
117
|
+
if result.returncode:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"audio effect failed on %s: %s",
|
|
120
|
+
match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
import pyaudio
|
|
126
|
+
except Exception:
|
|
127
|
+
return
|
|
128
|
+
pa = pyaudio.PyAudio()
|
|
129
|
+
device_index = None
|
|
130
|
+
try:
|
|
102
131
|
if selected.strip().isdigit():
|
|
103
132
|
device_index = int(selected.strip())
|
|
104
133
|
elif selected.lower() == "default":
|
|
@@ -118,14 +147,7 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
118
147
|
output=True,
|
|
119
148
|
output_device_index=device_index,
|
|
120
149
|
)
|
|
121
|
-
|
|
122
|
-
count = int(sample_rate * duration)
|
|
123
|
-
frames = bytearray()
|
|
124
|
-
for n in range(count):
|
|
125
|
-
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
126
|
-
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
127
|
-
frames.extend(struct.pack("<hh", value, value))
|
|
128
|
-
stream.write(bytes(frames))
|
|
150
|
+
stream.write(bytes(frames))
|
|
129
151
|
stream.stop_stream()
|
|
130
152
|
stream.close()
|
|
131
153
|
except Exception as e:
|
|
@@ -1041,9 +1063,10 @@ class PreviewAgent:
|
|
|
1041
1063
|
# for the LiveKit publisher.
|
|
1042
1064
|
await asyncio.sleep(0.5)
|
|
1043
1065
|
# I/O. Camera/mic join and the preset greeting are the critical path.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1066
|
+
# The cue and TTS share one physical ALSA output. Finish and release
|
|
1067
|
+
# the cue before LiveKit can deliver the greeting; background playback
|
|
1068
|
+
# races aplay and makes greeting delivery intermittently fail busy.
|
|
1069
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1047
1070
|
try:
|
|
1048
1071
|
r = await self._session.post(
|
|
1049
1072
|
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
|
|
@@ -1350,17 +1373,27 @@ class LiveKitPublisher:
|
|
|
1350
1373
|
# steady pace, independent of how unevenly frames actually arrive.
|
|
1351
1374
|
import queue
|
|
1352
1375
|
import threading
|
|
1353
|
-
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1354
|
-
written_frames = [0]
|
|
1355
|
-
PREBUFFER_CHUNKS = 5
|
|
1356
|
-
stream_failed = threading.Event()
|
|
1376
|
+
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1377
|
+
written_frames = [0]
|
|
1378
|
+
PREBUFFER_CHUNKS = 5
|
|
1379
|
+
stream_failed = threading.Event()
|
|
1380
|
+
playback_reported = threading.Event()
|
|
1381
|
+
event_loop = asyncio.get_running_loop()
|
|
1357
1382
|
|
|
1358
1383
|
def _safe_write(chunk: bytes) -> bool:
|
|
1359
1384
|
if stream_failed.is_set():
|
|
1360
1385
|
return False
|
|
1361
|
-
try:
|
|
1362
|
-
out.write(chunk)
|
|
1363
|
-
|
|
1386
|
+
try:
|
|
1387
|
+
out.write(chunk)
|
|
1388
|
+
if not playback_reported.is_set():
|
|
1389
|
+
playback_reported.set()
|
|
1390
|
+
asyncio.run_coroutine_threadsafe(
|
|
1391
|
+
self.agent._report_pipeline(
|
|
1392
|
+
"playback_started", "ok", "First TTS audio chunk written to robot speaker"
|
|
1393
|
+
),
|
|
1394
|
+
event_loop,
|
|
1395
|
+
)
|
|
1396
|
+
return True
|
|
1364
1397
|
except Exception as e:
|
|
1365
1398
|
stream_failed.set()
|
|
1366
1399
|
logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
|
|
@@ -1420,7 +1453,6 @@ class LiveKitPublisher:
|
|
|
1420
1453
|
f"audio out: first frame received for {sid} "
|
|
1421
1454
|
f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
|
|
1422
1455
|
)
|
|
1423
|
-
await self.agent._report_pipeline("playback_started", "ok", "First TTS audio frame reached the robot")
|
|
1424
1456
|
in_rate = af.sample_rate
|
|
1425
1457
|
in_channels = int(getattr(af, "num_channels", 1) or 1)
|
|
1426
1458
|
raw = bytes(af.data)
|
|
@@ -23,6 +23,8 @@ frame_condition = threading.Condition()
|
|
|
23
23
|
latest_frame_bytes = None
|
|
24
24
|
latest_frame_sequence = 0
|
|
25
25
|
camera_worker_started = False
|
|
26
|
+
camera_read_started_at = 0.0
|
|
27
|
+
camera_last_frame_at = 0.0
|
|
26
28
|
audio_input_device = "default"
|
|
27
29
|
audio_output_device = "default"
|
|
28
30
|
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
|
|
@@ -233,6 +235,7 @@ def camera_worker():
|
|
|
233
235
|
global latest_detections, motion_detection_active, motion_detected_state
|
|
234
236
|
global last_motion_time, motion_frame_buffer
|
|
235
237
|
global latest_frame_bytes, latest_frame_sequence
|
|
238
|
+
global camera_read_started_at, camera_last_frame_at, camera
|
|
236
239
|
|
|
237
240
|
prev_gray = None
|
|
238
241
|
|
|
@@ -243,11 +246,24 @@ def camera_worker():
|
|
|
243
246
|
time.sleep(1)
|
|
244
247
|
continue
|
|
245
248
|
|
|
246
|
-
|
|
249
|
+
camera_read_started_at = time.monotonic()
|
|
250
|
+
try:
|
|
251
|
+
# This is the only camera reader. Do not hold camera_lock here:
|
|
252
|
+
# the watchdog must be able to release a wedged V4L2 handle.
|
|
247
253
|
success, frame = cam.read()
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
print(f"Camera read error: {exc}")
|
|
256
|
+
success, frame = False, None
|
|
257
|
+
finally:
|
|
258
|
+
camera_read_started_at = 0.0
|
|
248
259
|
if not success:
|
|
260
|
+
with camera_lock:
|
|
261
|
+
if camera is cam:
|
|
262
|
+
camera.release()
|
|
263
|
+
camera = None
|
|
249
264
|
time.sleep(0.1)
|
|
250
265
|
continue
|
|
266
|
+
camera_last_frame_at = time.monotonic()
|
|
251
267
|
|
|
252
268
|
# Run object detection
|
|
253
269
|
processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
|
|
@@ -312,6 +328,25 @@ def _ensure_camera_worker():
|
|
|
312
328
|
return
|
|
313
329
|
camera_worker_started = True
|
|
314
330
|
threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
|
|
331
|
+
threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _camera_watchdog():
|
|
335
|
+
global camera, camera_read_started_at
|
|
336
|
+
while True:
|
|
337
|
+
time.sleep(2.0)
|
|
338
|
+
started = camera_read_started_at
|
|
339
|
+
if not started or time.monotonic() - started < 8.0:
|
|
340
|
+
continue
|
|
341
|
+
print("Camera read stalled for 8s; releasing V4L2 handle")
|
|
342
|
+
with camera_lock:
|
|
343
|
+
if camera is not None:
|
|
344
|
+
try:
|
|
345
|
+
camera.release()
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
camera = None
|
|
349
|
+
camera_read_started_at = 0.0
|
|
315
350
|
|
|
316
351
|
|
|
317
352
|
def generate_frames():
|
|
@@ -339,10 +374,23 @@ def index():
|
|
|
339
374
|
def video_feed():
|
|
340
375
|
return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
|
341
376
|
|
|
342
|
-
@app.route('/api/detections')
|
|
343
|
-
def get_detections():
|
|
344
|
-
with lock:
|
|
345
|
-
return jsonify(latest_detections)
|
|
377
|
+
@app.route('/api/detections')
|
|
378
|
+
def get_detections():
|
|
379
|
+
with lock:
|
|
380
|
+
return jsonify(latest_detections)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.route('/api/camera/status')
|
|
384
|
+
def camera_status():
|
|
385
|
+
age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
|
|
386
|
+
return jsonify({
|
|
387
|
+
"device": str(current_camera_index),
|
|
388
|
+
"open": bool(camera is not None and camera.isOpened()),
|
|
389
|
+
"worker_started": camera_worker_started,
|
|
390
|
+
"frame_sequence": latest_frame_sequence,
|
|
391
|
+
"last_frame_age_seconds": age,
|
|
392
|
+
"read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
|
|
393
|
+
})
|
|
346
394
|
|
|
347
395
|
@app.route('/api/caption')
|
|
348
396
|
def get_caption():
|