From 92b60de945e000c20b370c07802c5eedc12d310a Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 15:27:13 +0300 Subject: [PATCH] Preserve RRD preview frames and clarify onboard K1 status --- .../test/k1ManualControl.test.mjs | 19 +++- .../test/k1PreviewFrames.test.mjs | 43 +++++++++ apps/node-agent/packaging/build_deb.py | 2 +- docs/audits/2026-09-07-k1-node-preview-r10.md | 90 +++++++++++++++++++ .../frontend/src/sensors/K1Detail.tsx | 40 +++++---- .../frontend/src/sensors/K1LiveView.tsx | 79 +++++----------- .../frontend/src/sensors/livePreview.css | 6 ++ .../frontend/src/sensors/presentation.ts | 2 + .../frontend/src/sensors/previewCamera.ts | 38 ++++++++ .../frontend/src/sensors/previewFrames.ts | 25 ++++++ .../frontend/src/sensors/useK1Preview.ts | 83 +++++++++++++++++ plugins/xgrids-k1/packaging/build_deb.py | 2 +- .../packaging/mission-core-k1.service | 1 + .../device_plugins/xgrids_k1/ble/scanner.py | 12 +++ .../xgrids_k1/ble/wifi_provisioning.py | 13 +++ src/k1link/viewer/node_media.py | 54 ++++++++--- tests/test_ble_scanner.py | 18 ++++ tests/test_node_media.py | 63 +++++++++++-- tests/test_wifi_provisioning.py | 20 +++++ 19 files changed, 516 insertions(+), 94 deletions(-) create mode 100644 apps/control-station/test/k1PreviewFrames.test.mjs create mode 100644 docs/audits/2026-09-07-k1-node-preview-r10.md create mode 100644 plugins/xgrids-k1/frontend/src/sensors/livePreview.css create mode 100644 plugins/xgrids-k1/frontend/src/sensors/previewCamera.ts create mode 100644 plugins/xgrids-k1/frontend/src/sensors/previewFrames.ts create mode 100644 plugins/xgrids-k1/frontend/src/sensors/useK1Preview.ts diff --git a/apps/control-station/test/k1ManualControl.test.mjs b/apps/control-station/test/k1ManualControl.test.mjs index 121bc3e..68df73e 100644 --- a/apps/control-station/test/k1ManualControl.test.mjs +++ b/apps/control-station/test/k1ManualControl.test.mjs @@ -35,11 +35,28 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{ const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}}; const markup=render(active); assert.match(markup,/Остановить устройство/); - assert.match(markup,/sensor-live-layout/); + assert.match(markup,/k1-preview-spatial/); + assert.match(markup,/>СтатусRerun{ + const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}}; + const markup=render(starting); + assert.match(markup,/]*aria-busy="true"[\s\S]*?nodedc-activity-indicator[\s\S]*?Запускаем устройство<\/button>/); + assert.equal((markup.match(/nodedc-activity-indicator"/g)||[]).length,1); + assert.doesNotMatch(markup,/Остановить устройство|k1-preview-spatial/); +}); +test('completed STOP remains visible while the control connection is checked again',()=>{ + const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}}; + const markup=render(stopped); + assert.match(markup,/Устройство остановлено/); + assert.match(markup,/Проверить состояние K1/); + assert.doesNotMatch(markup,/Инициировать запуск|нет управления/); +}); test('enrollment handoff opens only the verified current session',()=>{ const inventory={fresh:true,items:[device],operations:[]}; assert.equal(enrolledDevice(inventory,'exact-session'),device); diff --git a/apps/control-station/test/k1PreviewFrames.test.mjs b/apps/control-station/test/k1PreviewFrames.test.mjs new file mode 100644 index 0000000..136e7c0 --- /dev/null +++ b/apps/control-station/test/k1PreviewFrames.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import {before,after,test} from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {createServer} from 'vite'; +let server,previewFrames,assertRrd; +before(async()=>{ + server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}}); + ({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts')); +}); +after(async()=>{await server?.close();}); +const array=bytes=>Uint8Array.from(bytes).buffer; +const header=size=>{const value=new Uint8Array(8);value.set([77,67,70,49]);new DataView(value.buffer).setUint32(4,size);return value.buffer;}; +test('native RRD crosses many RTC fragments and reaches decoder once, byte-for-byte',()=>{ + const payload=execFileSync('../../.venv/bin/python',['-c',`import rerun as rr, numpy as np, sys +r=rr.RecordingStream('synthetic-r10-js'); b=r.binary_stream() +r.log('points',rr.Points3D(np.random.default_rng(42).random((5000,3)))) +sys.stdout.buffer.write(b.read())`]); + assert.ok(payload.length>16384); + const calls=[]; + const receiver=previewFrames(value=>{assertRrd(value);calls.push(value);}); + for(let record=0;record<2;record++){ + receiver.push(header(payload.length)); + for(let offset=0;offset{ + let calls=0; + const receiver=previewFrames(value=>{assertRrd(value);calls++;}); + assert.throws(()=>receiver.push(array([1,2,3]))); + assert.throws(()=>receiver.push(header(8*1024*1024+1))); + assert.throws(()=>receiver.push(header(0))); + receiver.push(header(20));receiver.push(array([1,2]));receiver.close(); + assert.equal(calls,0); + receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(13))));receiver.close(); + receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12)))); + assert.equal(calls,0); +}); diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index ac02df2..ee208e9 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,7 +11,7 @@ import sys ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.8.7" +VERSION = "0.8.8" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/docs/audits/2026-09-07-k1-node-preview-r10.md b/docs/audits/2026-09-07-k1-node-preview-r10.md new file mode 100644 index 0000000..047d3b5 --- /dev/null +++ b/docs/audits/2026-09-07-k1-node-preview-r10.md @@ -0,0 +1,90 @@ +# Node K1 preview R10 + +The owner installed R9 and physically confirmed START and STOP. Installed +readback is Node 0.8.7 / optional K1 0.1.6+private.1, both services running +without restarts. The actual service now reports application_authority_available +true. This closes the R9 credential-loading gate, not the live-preview gate. + +Private screenshots, Fleet state and timestamps are retained under +private/acceptance/k1-node087-20260907-preview-r10. The owner reported malformed +RRD notifications, absent camera, misplaced loading feedback and a failed +state check after STOP. Cache clearing for these owner attempts was not +independently confirmed. The agent issued no hardware actions. + +## Proven defects + +NodeMediaPeers split each binary_stream.read() into 16384-byte SCTP messages. +The browser passed each message directly to send_rrd. Installed Rerun SDK +0.36.3 documents send_rrd as accepting an RRD file in a byte array, also +described by the [upstream LogChannel reference](https://ref.rerun.io/docs/js/0.36.2/web-viewer/classes/LogChannel.html). +Native Python reproduction produces a complete RRF2 recording per read; +fragments after the first lack that header. The previous transport test used +a tiny fake payload and could not expose this defect. + +The journal separately reports missing local camera FFmpeg after first PCL. +FFmpeg 6.1.1 is installed at /usr/bin/ffmpeg and is already a declared Debian +dependency, but the reviewed resolver requires an explicit configured path +outside its macOS development fallbacks. The systemd unit now supplies that +path. No resolver fallback, archive ownership or camera activation fence changed. + +The viewer formerly closed the camera channel if an offer preceded camera +activation. It now waits up to 45 seconds for the existing producer and sends +the MIME metadata immediately before opening its bounded delivery. It does not +select or restart a camera and never issues a physical START. + +## Transport and presentation + +missioncore.node-preview/v1 preserves independent payload boundaries with an +MCF1 + big-endian length envelope and ordered fragments of at most 16384 bytes. +Both sides retain the 8 MiB payload bound. The browser assembles exactly one +payload before passing it to the RRD or MSE decoder; incomplete, overflowing +or unframed payloads cannot reach Rerun. The peer response must advertise the +protocol. Backpressure, two-peer admission, private-only ICE and lease cleanup +remain in place. Delivery failures log only channel and exception class. + +The existing vendor contribution remains inside the Fleet/Node sensor slot. +The owner's approved composition is control card, centered compact Status +card, then a full-width Rerun card with its own expand/restore action. A +half-width spatial viewport alongside an empty camera was rejected because +it obscured the primary evidence. A camera companion is displayed below the +full-width spatial view only after decoded frames arrive. This is domain +composition using SettingsCard, Button, IconButton, ActivityIndicator and +existing theme tokens; it introduces no generic visual entity or navigation. + +Pending acquisition feedback stays inside the one pending action. Preview +messages go to the single Status card. Preview reconnect retries only the +disposable media peer, with backoff capped at 30 seconds while the owner keeps +the active view open. It never repeats START/STOP or Wi-Fi provisioning. +The live-acquisition settings, blueprint, LAB and recorded Rerun profiles are +unchanged; shared recorded-viewer host code is unchanged. + +## Follow-up: STOP and read-only verification + +The journal identifies a vanished BlueZ object at add_device_watcher during +the owner's explicit Verify. A retained Python BLEDevice is not proof that +the native BlueZ object still exists. Before the single read-only GATT connect, +Linux now checks its native cache and, only for a vanished object, obtains one +fresh advertisement for the same exact address inside the existing status-read +arbiter lease (maximum eight seconds). Public scan generations and the selected +target remain unchanged. This is a pre-connect refresh, not a retry of a failed +connect or write. macOS behavior and provisioning paths are unchanged. Failure +to recover that exact address remains fail-closed. + +The completed control phase remains visible as Device stopped while the next +control check is pending. Actual state supersedes stale START/STOP command +feedback once capture or completion is observed. The initial START journal +exception was the existing physical reconciliation fence; it is not relaxed. + +## Validation and remaining gates + +100 Python checks cover real loopback WebRTC transporting a native RRD larger +than one fragment without byte loss, delayed camera metadata, Node projection, +package lifecycle and existing Bluetooth/status/provisioning regressions. The +new explicit read test proves a vanished handle is refreshed before exactly +one connect and no GATT write. Native RRD reassembly is also tested in the +actual TypeScript decoder boundary, including malformed/oversized/partial input. + +All 801 frontend tests, Core TypeScript and production build pass. Release +hashes, installation readback and owner fresh-cache UI acceptance are recorded +below when observed. Local synthetic +checks do not establish camera decoding or live stream recovery on the BK. diff --git a/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx b/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx index 9e1d41f..6ef9092 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx @@ -1,37 +1,41 @@ -import {useEffect,useRef,useState} from 'react'; +import {useRef,useState} from 'react'; import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react'; import {perform,type Sensor,type SensorTransport} from './runtime'; import {K1LiveView} from './K1LiveView'; import type {RerunHostFactory} from '@mission-core/sensor-sdk'; import {K1LiveSettings} from './K1LiveSettings'; import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation'; +import {pendingPreview} from './useK1Preview'; export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){ - const [busy,setBusy]=useState(''),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false); + const [busy,setBusy]=useState(''),[settings,setSettings]=useState(false),[operationError,setOperationError]=useState(''); + const [preview,setPreview]=useState(pendingPreview); const running=useRef(false); + const failedAction=useRef(''); const streaming=device.snapshot.acquisition==='streaming'; const manual=k1ManualState(device,enabled),status=k1Status(device,enabled); - useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]); + const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':''); async function act(action:'start'|'stop'|'verify'){ if(running.current||!enabled)return; - running.current=true;setBusy(action);failure(null); + running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null); try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();} - catch(e){failure(e);}finally{running.current=false;setBusy('');} + catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});} + finally{running.current=false;setBusy('');} } - return
-
{status.label}{streaming&&setExpanded(value=>!value)}>}
+ const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected); + const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label); + return
+
{status.label}
setSettings(value=>!value)}>}> - {manual.connected&&!manual.showStop&&} - {manual.showStop&&} - {!manual.connected&& - {device.control?.can_verify&&} - } + actions={manual.connected&&setSettings(value=>!value)}>}> + {manual.connected&&(!manual.showStop||pending==='start')&&} + {manual.showStop&&pending!=='start'&&} + {!manual.connected&&device.control?.can_verify&&} - {manual.connected&&settings&&} - {!streaming&&(busy==='start'||manual.active)&&} - {streaming&&createRerunHost&&setGeneration(value=>value+1)}/>} + + {manual.connected&&settings&&} + {streaming&&createRerunHost&&}
; } diff --git a/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx b/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx index 2b68a2a..83014da 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx @@ -1,63 +1,26 @@ -import {useEffect,useRef,useState} from 'react'; -import {ActivityIndicator,Button,SettingsCard} from '@nodedc/ui-react'; -import {perform,type Sensor,type SensorTransport} from './runtime'; +import {useCallback,useEffect,useRef,useState} from 'react'; +import {Icon,IconButton,SettingsCard} from '@nodedc/ui-react'; import type {RerunHostFactory} from '@mission-core/sensor-sdk'; +import type {Sensor,SensorTransport} from './runtime'; +import {useK1Preview,type PreviewStatus} from './useK1Preview'; +import './livePreview.css'; -function privateCandidate(sdp:string):string{ - return sdp.split('\r\n').filter(line=>{ - if(!line.startsWith('a=candidate:'))return true; - const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number); - return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&& - (parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127)))); - }).join('\r\n'); -} - -export function K1LiveView({device,transport,createRerunHost,onReconnect}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onReconnect:()=>void}){ +export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){ const spatial=useRef(null),video=useRef(null); - const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false); + const [expanded,setExpanded]=useState(false); + const [generation,setGeneration]=useState(0),[status,setStatus]=useState(null); + const attempts=useRef(0); + const report=useCallback((value:PreviewStatus)=>{setStatus(value);onStatus(value);},[onStatus]); + useK1Preview(device,transport,createRerunHost,spatial,video,report,generation); useEffect(()=>{ - let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined; - let keepalive:ReturnType|undefined,follow:ReturnType|undefined; - let closeChannel:(()=>void)|undefined; - const host=createRerunHost(spatial.current!); - const pc=new RTCPeerConnection({iceServers:[]}); - const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true}); - rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer'; - const fail=(message:string)=>{failed=true;if(active){setError(message);setReady(false);}clearInterval(follow);clearInterval(keepalive);pc.close();}; - const cameraFail=(message:string)=>{if(active)setCameraError(message);camera.close();}; - rrd.onclose=()=>{if(active&&!failed)fail('Поток лидара завершён. Обновите состояние устройства.');}; - camera.onclose=()=>{if(active)setCameraError('Поток камеры недоступен. Обновите просмотр.');}; - const run=async()=>{ - const {viewer,mount}=await host.ready;if(!active)return; - await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return; - for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden'); - const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);closeChannel=()=>channel.close(); - rrd.onmessage=event=>{if(!active||!(event.data instanceof ArrayBuffer))return;try{channel.send_rrd(new Uint8Array(event.data));}catch{fail('Поток лидара прерван. Обновите просмотр.');}}; - follow=setInterval(()=>{if(!active)return;try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){setReady(true);viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);}}catch{/* Viewer may still be opening its recording. */}},250); - await pc.setLocalDescription(await pc.createOffer()); - if(pc.iceGatheringState!=='complete')await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),8000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(timeout);resolve();}};}); - if(!active)return; - const answer=await perform<{peer_id:string;sdp:string;type:'answer';camera_mime?:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)}); - peerID=answer.peer_id; - if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;} - if(answer.camera_mime&&typeof MediaSource!=='undefined'&&MediaSource.isTypeSupported(answer.camera_mime)){ - const media=new MediaSource();mediaURL=URL.createObjectURL(media);video.current!.src=mediaURL; - const pending:ArrayBuffer[]=[];let pendingBytes=0,buffer:SourceBuffer|undefined; - const append=()=>{if(!active||!buffer||buffer.updating||media.readyState!=='open')return;try{ - const current=video.current?.currentTime??0; - if(buffer.buffered.length&¤t-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),current-5);return;} - const bytes=pending.shift();if(bytes){pendingBytes-=bytes.byteLength;buffer.appendBuffer(bytes);} - }catch{cameraFail('Поток камеры прерван. Обновите просмотр.');}}; - media.addEventListener('sourceopen',()=>{if(!active)return;buffer=media.addSourceBuffer(answer.camera_mime!);buffer.addEventListener('updateend',()=>{const element=video.current;if(element&&buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-element.currentTime>1)element.currentTime=Math.max(0,end-0.2);void element.play().catch(()=>{});}append();});append();},{once:true}); - camera.onmessage=event=>{if(!(event.data instanceof ArrayBuffer)||!active)return;pendingBytes+=event.data.byteLength;if(pendingBytes>8*1024*1024){cameraFail('Просмотр камеры не успевает за потоком. Обновите просмотр.');return;}pending.push(event.data);append();}; - } - else cameraFail('Камера пока недоступна в этом просмотре.'); - pc.onconnectionstatechange=()=>{if(active&&['failed','disconnected','closed'].includes(pc.connectionState))fail('Связь просмотра прервана. Обновите просмотр.');}; - await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp}); - keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000); - }; - void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.')); - return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);}; - },[device.snapshot.context.session_id,transport,createRerunHost]); - return
{error?:!ready&&}{(error||cameraError)&&}
{cameraError&&}
; + if(!status?.retry)return; + const timer=setTimeout(()=>setGeneration(value=>value+1),Math.min(30000,2000*2**Math.min(attempts.current++,4))); + return()=>clearTimeout(timer); + },[status?.retry]); + useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]); + return setExpanded(value=>!value)}>}> +
+