infinicode 2.8.89 → 2.8.91

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.
@@ -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'; directImg.src=''; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable on robot'; if(directLiveNote) directLiveNote.textContent='camera unavailable'; if(directRes) directRes.textContent='OFFLINE'; };
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
- var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }
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
- req.once('close', () => ctrl.abort());
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 (ctrl.signal.aborted)
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');
@@ -82,6 +82,12 @@ motion detection consume a shared latest-frame stream. Starting one
82
82
  `VideoCapture.read()` calls, which can block the second client and make the hub
83
83
  relay time out with zero bytes.
84
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
+
85
91
  ### Duplicate Speaker Tests
86
92
 
87
93
  Only one robot process may claim a scheduler `speaker_test`. The scheduler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.89",
3
+ "version": "2.8.91",
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",
@@ -455,6 +455,9 @@ class PreviewAgent:
455
455
  self._vision_session_id: Optional[str] = None
456
456
  self._vision_trigger_in_flight = False
457
457
  self._speaker_test_in_flight = False
458
+ # The USB adapter is an exclusive ALSA endpoint. Keep operator tests
459
+ # and motion cues from racing each other while LiveKit is paused.
460
+ self._speaker_operation_lock = asyncio.Lock()
458
461
  self._vision_hard_deadline: float = 0.0
459
462
  self._vision_last_activity: float = 0.0
460
463
  self.production_mode = False
@@ -896,18 +899,20 @@ class PreviewAgent:
896
899
  return
897
900
  self._speaker_test_in_flight = True
898
901
  from robot_supervisor import _speaker_roundtrip_test
899
- # LiveKit owns the USB microphone continuously. Release that owner
900
- # for an explicit hardware test, then restore the same session.
901
- restore_state = self._current_state if self._publisher else None
902
- if restore_state:
903
- await self._stop_publisher()
904
- await asyncio.sleep(1.0)
905
- try:
906
- result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
907
- result["media_owner_paused"] = bool(restore_state)
908
- finally:
909
- if restore_state and restore_state.active:
910
- await self._start_publisher(restore_state)
902
+ async with self._speaker_operation_lock:
903
+ # LiveKit owns the USB microphone and speaker continuously.
904
+ # Pause it while holding the same lock as the motion cue so
905
+ # nothing can reopen ALSA before the explicit test starts.
906
+ restore_state = self._current_state if self._publisher else None
907
+ if restore_state:
908
+ await self._stop_publisher()
909
+ await asyncio.sleep(1.0)
910
+ try:
911
+ result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
912
+ result["media_owner_paused"] = bool(restore_state)
913
+ finally:
914
+ if restore_state and restore_state.active:
915
+ await self._start_publisher(restore_state)
911
916
  result_response = await self._session.post(
912
917
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
913
918
  json={"kind": "speaker_test", "service": None, "payload": result,
@@ -1066,7 +1071,8 @@ class PreviewAgent:
1066
1071
  # The cue and TTS share one physical ALSA output. Finish and release
1067
1072
  # the cue before LiveKit can deliver the greeting; background playback
1068
1073
  # races aplay and makes greeting delivery intermittently fail busy.
1069
- await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1074
+ async with self._speaker_operation_lock:
1075
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1070
1076
  try:
1071
1077
  r = await self._session.post(
1072
1078
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -1317,6 +1323,11 @@ class LiveKitPublisher:
1317
1323
  process.wait(timeout=2)
1318
1324
  except subprocess.TimeoutExpired:
1319
1325
  process.terminate()
1326
+ try:
1327
+ process.wait(timeout=1)
1328
+ except subprocess.TimeoutExpired:
1329
+ process.kill()
1330
+ process.wait(timeout=1)
1320
1331
 
1321
1332
  def close(self) -> None:
1322
1333
  return
@@ -1493,16 +1504,24 @@ class LiveKitPublisher:
1493
1504
  else:
1494
1505
  data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
1495
1506
  write_queue.put(data)
1496
- except Exception as e:
1497
- logger.warning(f"audio out stream error: {e}")
1498
- finally:
1499
- write_queue.put(None)
1500
- writer_thread.join(timeout=2.0)
1507
+ except Exception as e:
1508
+ logger.warning(f"audio out stream error: {e}")
1509
+ finally:
1510
+ cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
1511
+ if cancelling:
1512
+ # On publisher teardown, close ALSA first. Waiting for the
1513
+ # writer while aplay still owns the device leaves hw:X,Y busy
1514
+ # long enough for the queued dashboard test to fail.
1515
+ stream_failed.set()
1516
+ out.stop_stream()
1517
+ write_queue.put(None)
1518
+ writer_thread.join(timeout=2.0)
1501
1519
  logger.info(
1502
1520
  f"audio out: {sid} received {frame_count} frames, "
1503
1521
  f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1504
1522
  )
1505
- out.stop_stream()
1523
+ if not cancelling:
1524
+ out.stop_stream()
1506
1525
  out.close()
1507
1526
  if pa is not None:
1508
1527
  pa.terminate()
@@ -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
- with camera_lock:
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():