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.
@@ -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');
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.88",
3
+ "version": "2.8.90",
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",
@@ -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
- try:
89
- import pyaudio
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
- pa = pyaudio.PyAudio()
99
- device_index = None
100
- selected = str(selected_output or "default")
101
- try:
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
- for frequency, duration in notes:
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
- asyncio.create_task(
1045
- asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
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
- return True
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
- 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():