Preserve RRD preview frames and clarify onboard K1 status

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 15:27:13 +03:00
parent 67398fef10
commit 92b60de945
19 changed files with 516 additions and 94 deletions
@@ -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 active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
const markup=render(active); const markup=render(active);
assert.match(markup,/Остановить устройство/); assert.match(markup,/Остановить устройство/);
assert.match(markup,/sensor-live-layout/); assert.match(markup,/k1-preview-spatial/);
assert.match(markup,/>Статус</);
assert.match(markup,/>Rerun</);
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/); assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
assert.equal(presentation.k1ManualState(active,false).canStart,false); assert.equal(presentation.k1ManualState(active,false).canStart,false);
assert.equal(presentation.k1ManualState(active,false).showStop,true); assert.equal(presentation.k1ManualState(active,false).showStop,true);
}); });
test('calibration loader stays inside the primary action',()=>{
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
const markup=render(starting);
assert.match(markup,/<button[^>]*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',()=>{ test('enrollment handoff opens only the verified current session',()=>{
const inventory={fresh:true,items:[device],operations:[]}; const inventory={fresh:true,items:[device],operations:[]};
assert.equal(enrolledDevice(inventory,'exact-session'),device); assert.equal(enrolledDevice(inventory,'exact-session'),device);
@@ -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<payload.length;offset+=16384){
receiver.push(array(payload.subarray(offset,offset+16384)));
if(offset+16384<payload.length)assert.equal(calls.length,record);
}
assert.deepEqual(Buffer.from(calls[record]),payload);
}
assert.equal(calls.length,2);
receiver.close();
});
test('partial, oversized, unframed and invalid records never enter the decoder',()=>{
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);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.7" VERSION = "0.8.8"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package from debian import package
@@ -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.
@@ -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 {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime'; import {perform,type Sensor,type SensorTransport} from './runtime';
import {K1LiveView} from './K1LiveView'; import {K1LiveView} from './K1LiveView';
import type {RerunHostFactory} from '@mission-core/sensor-sdk'; import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {K1LiveSettings} from './K1LiveSettings'; import {K1LiveSettings} from './K1LiveSettings';
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation'; 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<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){ export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;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 running=useRef(false);
const failedAction=useRef('');
const streaming=device.snapshot.acquisition==='streaming'; const streaming=device.snapshot.acquisition==='streaming';
const manual=k1ManualState(device,enabled),status=k1Status(device,enabled); 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'){ async function act(action:'start'|'stop'|'verify'){
if(running.current||!enabled)return; 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();} 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 <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}> const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={status.tone}>{status.label}</StatusBadge>{streaming&&<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}</div></div> const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label);
return <div className="sensor-content">
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК." <SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!busy} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}> actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
{manual.connected&&!manual.showStop&&<Button variant="primary" disabled={!!busy||!manual.canStart} aria-busy={busy==='start'} {manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
icon={busy==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{busy==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>} icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
{manual.showStop&&<Button disabled={!manual.connected||!!busy||(!streaming&&!device.control?.can_stop)} aria-busy={busy==='stop'} {manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
icon={busy==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{busy==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>} icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
{!manual.connected&&<SettingsCard align="center" title={k1ConnectionNotice(device,enabled)}> {!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
{device.control?.can_verify&&<Button disabled={!enabled||!!busy} aria-busy={busy==='verify'} icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
</SettingsCard>}
</SettingsCard> </SettingsCard>
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>} <SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
{!streaming&&(busy==='start'||manual.active)&&<ActivityIndicator label="Запускаем K1 и ожидаем живые данные"/>} {manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost} onReconnect={()=>setGeneration(value=>value+1)}/>} {streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
</div>; </div>;
} }
@@ -1,63 +1,26 @@
import {useEffect,useRef,useState} from 'react'; import {useCallback,useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,SettingsCard} from '@nodedc/ui-react'; import {Icon,IconButton,SettingsCard} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
import type {RerunHostFactory} from '@mission-core/sensor-sdk'; 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{ export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){
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}){
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null); const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(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<PreviewStatus|null>(null);
const attempts=useRef(0);
const report=useCallback((value:PreviewStatus)=>{setStatus(value);onStatus(value);},[onStatus]);
useK1Preview(device,transport,createRerunHost,spatial,video,report,generation);
useEffect(()=>{ useEffect(()=>{
let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined; if(!status?.retry)return;
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined; const timer=setTimeout(()=>setGeneration(value=>value+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));
let closeChannel:(()=>void)|undefined; return()=>clearTimeout(timer);
const host=createRerunHost(spatial.current!); },[status?.retry]);
const pc=new RTCPeerConnection({iceServers:[]}); useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true}); return <SettingsCard title="Rerun" className={expanded?'k1-preview sensor-viewer-expanded':'k1-preview'}
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer'; actions={<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}>
const fail=(message:string)=>{failed=true;if(active){setError(message);setReady(false);}clearInterval(follow);clearInterval(keepalive);pc.close();}; <div className="k1-preview-spatial" ref={spatial}/>
const cameraFail=(message:string)=>{if(active)setCameraError(message);camera.close();}; <video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
rrd.onclose=()=>{if(active&&!failed)fail('Поток лидара завершён. Обновите состояние устройства.');}; </SettingsCard>;
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<void>((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&&current-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 <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}{(error||cameraError)&&<Button onClick={onReconnect}>Восстановить просмотр</Button>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
} }
@@ -0,0 +1,6 @@
.k1-preview {min-width:0;width:100%}
.k1-preview-spatial {position:relative;min-width:0;width:100%;height:clamp(420px,60vh,800px);overflow:hidden}
.k1-preview-spatial iframe {display:block;position:absolute;inset:0;width:100%;height:100%;border:0}
.k1-preview-camera {display:block;width:100%;height:clamp(220px,32vh,360px);object-fit:contain}
.k1-preview-camera[hidden] {display:none}
.k1-preview.sensor-viewer-expanded .k1-preview-spatial {height:calc(100vh - 150px)}
@@ -2,6 +2,7 @@ import type {Sensor} from './runtime';
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} { export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'}; if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
if(device.control?.phase==='completed'&&device.snapshot.acquisition==='idle')return {label:'Устройство остановлено',tone:'neutral'};
if(!device.online||!device.verified)return device.control?.network_applied if(!device.online||!device.verified)return device.control?.network_applied
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'}; ?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'}; if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
@@ -13,6 +14,7 @@ export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutra
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string { export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.'; if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.';
if(device.control?.reason_code==='application_authority_unavailable') if(device.control?.reason_code==='application_authority_unavailable')
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.'; return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
return device.control?.network_applied return device.control?.network_applied
@@ -0,0 +1,38 @@
/** One MSE decoder per preview; the onboard producer owns camera activation. */
export function previewCamera(video:HTMLVideoElement,ready:()=>void,failed:()=>void){
let active=true,media:MediaSource|undefined,buffer:SourceBuffer|undefined,url:string|undefined;
let pending:Uint8Array<ArrayBuffer>[]=[],bytes=0;
const append=()=>{
if(!active||!buffer||buffer.updating||media?.readyState!=='open')return;
try{
if(buffer.buffered.length&&video.currentTime-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),video.currentTime-5);return;}
const next=pending.shift();if(next){bytes-=next.byteLength;buffer.appendBuffer(next);}
}catch{failed();}
};
const decoded=()=>{if(active)ready();};
video.addEventListener('loadeddata',decoded);
return {
open(mime:string){
if(media||typeof MediaSource==='undefined'||!MediaSource.isTypeSupported(mime))throw new Error('Camera format unavailable');
media=new MediaSource();url=URL.createObjectURL(media);video.src=url;
media.addEventListener('sourceopen',()=>{
if(!active)return;
try{
buffer=media!.addSourceBuffer(mime);
buffer.addEventListener('error',failed);
buffer.addEventListener('updateend',()=>{
if(!active)return;
if(buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-video.currentTime>1)video.currentTime=Math.max(0,end-0.2);void video.play().catch(()=>{});}
append();
});append();
}catch{failed();}
},{once:true});
},
push(payload:Uint8Array<ArrayBuffer>){
if(!media)throw new Error('Camera metadata missing');
bytes+=payload.byteLength;if(bytes>8*1024*1024)throw new Error('Camera preview backlog');
pending.push(payload);append();
},
close(){active=false;pending=[];bytes=0;video.removeEventListener('loadeddata',decoded);video.pause();video.removeAttribute('src');video.load();if(url)URL.revokeObjectURL(url);},
};
}
@@ -0,0 +1,25 @@
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
export const MEDIA_PROTOCOL='missioncore.node-preview/v1';
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
return {
push(data:ArrayBuffer) {
const bytes=new Uint8Array(data);
if(!pending){
if(bytes.length!==8||bytes[0]!==77||bytes[1]!==67||bytes[2]!==70||bytes[3]!==49)throw new Error('Invalid preview envelope');
const length=new DataView(data).getUint32(4);
if(length<1||length>MAX_PAYLOAD)throw new Error('Preview exceeds bound');
pending=new Uint8Array(length);offset=0;return;
}
if(!bytes.length||bytes.length>FRAGMENT_BYTES||offset+bytes.length>pending.length)throw new Error('Invalid preview fragment');
pending.set(bytes,offset);offset+=bytes.length;
if(offset===pending.length){const complete=pending;pending=null;offset=0;accept(complete);}
},
close(){pending=null;offset=0;},
};
}
export function assertRrd(bytes:Uint8Array){
if(bytes.length<12||bytes[0]!==82||bytes[1]!==82||bytes[2]!==70||bytes[3]!==50)throw new Error('Invalid RRD recording');
}
@@ -0,0 +1,83 @@
import {useEffect,type RefObject} from 'react';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {perform,type Sensor,type SensorTransport} from './runtime';
import {assertRrd,MEDIA_PROTOCOL,previewFrames} from './previewFrames';
import {previewCamera} from './previewCamera';
export type PreviewStatus={lidar:string;camera:string;retry:boolean};
export const pendingPreview:PreviewStatus={lidar:'Лидар: ожидаем данные',camera:'Камера: ожидаем изображение',retry:false};
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 useK1Preview(device:Sensor,transport:SensorTransport,createRerunHost:RerunHostFactory,
spatial:RefObject<HTMLDivElement|null>,video:RefObject<HTMLVideoElement|null>,onStatus:(value:PreviewStatus)=>void,generation=0){
useEffect(()=>{
let active=true,failed=false,peerID:string|undefined,viewClose:(()=>void)|undefined;
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined,iceTimeout:ReturnType<typeof setTimeout>|undefined;
let status={...pendingPreview};onStatus(status);
const update=(patch:Partial<PreviewStatus>)=>{if(active){status={...status,...patch};onStatus(status);}};
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=()=>{if(!active||failed)return;failed=true;update({lidar:'Лидар: восстанавливаем просмотр',camera:'Камера: ожидаем соединение',retry:true});clearInterval(follow);clearInterval(keepalive);pc.close();};
const cameraFail=()=>{update({camera:'Камера: изображение недоступно'});camera.close();};
const decoder=previewCamera(video.current!,()=>update({camera:'Камера: изображение поступает'}),cameraFail);
const cameraFrames=previewFrames(payload=>decoder.push(payload));
let rrdFrames:ReturnType<typeof previewFrames>|undefined;
rrd.onclose=()=>{if(active&&!failed)fail();};
camera.onclose=()=>{if(active&&!failed)update({camera:'Камера: изображение недоступно'});};
camera.onmessage=event=>{
if(!active)return;
try{
if(typeof event.data==='string'){
const metadata=JSON.parse(event.data);
if(metadata.type!=='camera-ready'||typeof metadata.mime!=='string')throw new Error('Invalid camera metadata');
decoder.open(metadata.mime);
}else if(event.data instanceof ArrayBuffer)cameraFrames.push(event.data);
}catch{cameraFail();}
};
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);viewClose=()=>channel.close();
rrdFrames=previewFrames(payload=>{assertRrd(payload);if(!channel.ready)throw new Error('Viewer unavailable');channel.send_rrd(payload);});
rrd.onmessage=event=>{if(active&&event.data instanceof ArrayBuffer)try{rrdFrames!.push(event.data);}catch{fail();}};
follow=setInterval(()=>{
if(!active||failed)return;
try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){
if(status.lidar!=='Лидар: данные поступают')update({lidar:'Лидар: данные поступают'});
viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);
}}catch{/* Native recording has not opened yet. */}
},250);
await pc.setLocalDescription(await pc.createOffer());
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{
iceTimeout=setTimeout(()=>reject(new Error('ICE timeout')),8000);
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimeout);resolve();}};
});
if(!active)return;
const answer=await perform<{peer_id:string;sdp:string;type:'answer';media_protocol: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.media_protocol!==MEDIA_PROTOCOL){failed=true;clearInterval(follow);pc.close();update({lidar:'Для просмотра требуется обновление приложения на БК',camera:'Камера: ожидаем обновление',retry:false});return;}
pc.onconnectionstatechange=()=>{if(active&&['failed','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);
return()=>{
active=false;clearInterval(keepalive);clearInterval(follow);clearTimeout(iceTimeout);
rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.onicegatheringstatechange=null;pc.close();
rrdFrames?.close();cameraFrames.close();decoder.close();try{viewClose?.();}finally{host.dispose();}
if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});
};
},[device.snapshot.context.session_id,transport,createRerunHost,onStatus,spatial,video,generation]);
}
+1 -1
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402 from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.6" VERSION = "0.1.7"
RESOURCES = ( RESOURCES = (
"plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json", "plugins/xgrids-k1/plugin.manifest.json",
@@ -16,6 +16,7 @@ RuntimeDirectoryMode=0750
LoadCredentialEncrypted=k1-application LoadCredentialEncrypted=k1-application
UMask=0007 UMask=0007
Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2 Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2
Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
NoNewPrivileges=yes NoNewPrivileges=yes
@@ -838,6 +838,18 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
return None return None
async def status_read_device_is_current(device: BLEDevice) -> bool:
"""Check a BlueZ handle before the one explicit status-read connection.
BlueZ may remove an unpaired object after a completed scan session. This
cache check neither scans nor connects and never changes macOS selection.
"""
details = getattr(device, "details", None)
if not sys.platform.startswith("linux") or not isinstance(details, dict):
return True
return await _retrieve_bluez_device(device.address, details) is not None
async def _retrieve_corebluetooth_device( async def _retrieve_corebluetooth_device(
captured: CapturedDiscoveredDevice, captured: CapturedDiscoveredDevice,
) -> BLEDevice | None: ) -> BLEDevice | None:
@@ -26,6 +26,7 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
mark_captured_device_gatt_validated, mark_captured_device_gatt_validated,
retrieve_connected_device_capture, retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read, retrieve_known_device_capture_for_status_read,
status_read_device_is_current,
) )
PROFILE_ID = "xgrids-k1-fw3-wifi-v1" PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
@@ -390,6 +391,18 @@ async def _read_wifi_status_impl(
"Exact BLE device is unavailable; run an explicit recovery or scan.", "Exact BLE device is unavailable; run an explicit recovery or scan.",
) )
if not await status_read_device_is_current(device):
# One explicit read may refresh the exact vanished BlueZ object
# before GATT. No failed connect/write is retried; public discovery
# generations, the pinned target and macOS behavior are unchanged.
progress.operation_stage = "exact-uuid-scan"
active_captured_device = await discover_known_device_capture_for_status_read(
device_macos_uuid, timeout_seconds=min(timeout_seconds, 8.0),
)
device = (captured_device_handle(active_captured_device)
if active_captured_device is not None else None)
if device is None:
raise BleakDeviceNotFoundError(device_macos_uuid, "Exact BLE device unavailable")
progress.operation_stage = "connect" progress.operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
progress.operation_stage = "gatt-contract" progress.operation_stage = "gatt-contract"
+44 -10
View File
@@ -2,14 +2,22 @@
import asyncio import asyncio
import ipaddress import ipaddress
import json
import logging
import queue import queue
import time import time
from contextlib import suppress from contextlib import suppress
from itertools import chain
from uuid import uuid4 from uuid import uuid4
import aioice.ice import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
MEDIA_PROTOCOL = "missioncore.node-preview/v1"
MAX_PAYLOAD = 8 * 1024 * 1024
FRAGMENT_BYTES = 16384
logger = logging.getLogger(__name__)
PRIVATE_NETWORKS = tuple( PRIVATE_NETWORKS = tuple(
ipaddress.ip_network(v) ipaddress.ip_network(v)
for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10") for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
@@ -90,12 +98,11 @@ class NodeMediaPeers:
await self.close(identifier) await self.close(identifier)
entry["tasks"].append(asyncio.create_task(expiry())) entry["tasks"].append(asyncio.create_task(expiry()))
snapshot = self.camera.snapshot()
return { return {
"peer_id": identifier, "peer_id": identifier,
"sdp": pc.localDescription.sdp, "sdp": pc.localDescription.sdp,
"type": "answer", "type": "answer",
"camera_mime": (snapshot.get("delivery") or {}).get("media_type"), "media_protocol": MEDIA_PROTOCOL,
"profile": "live-acquisition", "profile": "live-acquisition",
"transport": "webrtc-rrd-fmp4", "transport": "webrtc-rrd-fmp4",
} }
@@ -104,17 +111,43 @@ class NodeMediaPeers:
raise raise
async def send(self, channel, payload): async def send(self, channel, payload):
if len(payload) > 8 * 1024 * 1024: if not 0 < len(payload) <= MAX_PAYLOAD:
raise RuntimeError("Preview fragment exceeds bound") raise RuntimeError("Preview fragment exceeds bound")
for offset in range(0, len(payload), 16384): # Each binary_stream.read() is an independent RRD. SCTP messages are
# transport fragments, never independently decodable RRD files.
parts = (payload[offset:offset + FRAGMENT_BYTES]
for offset in range(0, len(payload), FRAGMENT_BYTES))
for part in chain((b"MCF1" + len(payload).to_bytes(4, "big"),), parts):
deadline = time.monotonic() + 2 deadline = time.monotonic() + 2
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024: while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline: if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
raise RuntimeError("Preview consumer unavailable") raise RuntimeError("Preview consumer unavailable")
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
channel.send(payload[offset : offset + 16384]) channel.send(part)
await asyncio.sleep(0) await asyncio.sleep(0)
async def camera_delivery(self, identifier, channel):
# Camera activation follows calibration / first PCL. Opening the
# viewer does not select or restart a camera producer.
deadline = time.monotonic() + 45
while identifier in self.items and time.monotonic() < deadline:
state = self.camera.snapshot()
delivery = state.get("delivery") or {}
if state.get("generation") is not None and delivery.get("media_type"):
lease = await asyncio.to_thread(self.camera.open_delivery, state["generation"])
try:
channel.send(json.dumps({
"type": "camera-ready", "mime": delivery["media_type"],
}))
except BaseException:
self.camera.release_delivery(lease, client_closed=True)
raise
return lease
if state.get("phase") == "error":
break
await asyncio.sleep(0.25)
return None
async def deliver(self, identifier, channel): async def deliver(self, identifier, channel):
subscriber = lease = None subscriber = lease = None
try: try:
@@ -122,11 +155,9 @@ class NodeMediaPeers:
if channel.label == "rrd": if channel.label == "rrd":
subscriber = await asyncio.to_thread(self.hub.subscribe) subscriber = await asyncio.to_thread(self.hub.subscribe)
else: else:
generation = self.camera.snapshot().get("generation") lease = await self.camera_delivery(identifier, channel)
if generation is None: if lease is None:
channel.close()
return return
lease = await asyncio.to_thread(self.camera.open_delivery, generation)
while identifier in self.items and time.monotonic() - entry["seen"] < 30: while identifier in self.items and time.monotonic() - entry["seen"] < 30:
if subscriber: if subscriber:
payload = await asyncio.to_thread(subscriber.read) payload = await asyncio.to_thread(subscriber.read)
@@ -144,8 +175,11 @@ class NodeMediaPeers:
break break
if payload: if payload:
await self.send(channel, payload) await self.send(channel, payload)
except (Exception, asyncio.CancelledError): except asyncio.CancelledError:
pass pass
except Exception as error:
logger.warning("Node preview delivery failed channel=%s exception=%s",
channel.label, type(error).__name__)
finally: finally:
if subscriber: if subscriber:
subscriber.close() subscriber.close()
+18
View File
@@ -1702,3 +1702,21 @@ def test_exact_session_invalidation_does_not_clear_new_scan_handle() -> None:
) is not None ) is not None
asyncio.run(scenario()) asyncio.run(scenario())
@pytest.mark.parametrize("platform,present", [("linux", True), ("linux", False), ("darwin", False)])
def test_status_read_native_cache_check_is_linux_only(monkeypatch, platform, present):
from types import SimpleNamespace
calls = []
device = BLEDevice("AA:BB:CC:DD:EE:FF", "synthetic", {"path": "/synthetic/bluez"})
async def retrieve(address, details):
calls.append((address, details))
return device if present else None
monkeypatch.setattr(scanner_module, "sys", SimpleNamespace(platform=platform))
monkeypatch.setattr(scanner_module, "_retrieve_bluez_device", retrieve)
assert asyncio.run(scanner_module.status_read_device_is_current(device)) is (
present or platform == "darwin")
assert len(calls) == (1 if platform == "linux" else 0)
+58 -5
View File
@@ -4,7 +4,7 @@ import queue
import pytest import pytest
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
from k1link.viewer.node_media import NodeMediaPeers, admit_sdp from k1link.viewer.node_media import MEDIA_PROTOCOL, NodeMediaPeers, admit_sdp
SDP_HEADER = "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" SDP_HEADER = "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
@@ -51,7 +51,7 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
class Subscription: class Subscription:
def __init__(self): def __init__(self):
self.output = queue.Queue() self.output = queue.Queue()
self.output.put(b"RRF2-transport-fixture") self.output.put(payload)
def read(self): def read(self):
try: try:
@@ -68,7 +68,16 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
class Camera: class Camera:
def snapshot(self): def snapshot(self):
return {"generation": None} return {"generation": None, "phase": "error"}
# The old test's sub-16KB fake payload missed the actual fragmentation bug.
import numpy as np
import rerun as rr
recording = rr.RecordingStream("synthetic-node-media")
binary = recording.binary_stream()
recording.log("points", rr.Points3D(np.random.default_rng(42).random((5000, 3))))
payload = binary.read()
assert payload.startswith(b"RRF2") and len(payload) > 16384
async def run(): async def run():
peers = NodeMediaPeers(Hub(), Camera()) peers = NodeMediaPeers(Hub(), Camera())
@@ -82,7 +91,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
@channel.on("message") @channel.on("message")
def message(data): def message(data):
payloads.append(data) payloads.append(data)
received.set() if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
received.set()
try: try:
await client.setLocalDescription(await client.createOffer()) await client.setLocalDescription(await client.createOffer())
@@ -91,7 +101,10 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
RTCSessionDescription(sdp=answer["sdp"], type="answer") RTCSessionDescription(sdp=answer["sdp"], type="answer")
) )
await asyncio.wait_for(received.wait(), timeout=8) await asyncio.wait_for(received.wait(), timeout=8)
assert payloads == [b"RRF2-transport-fixture"] assert answer["media_protocol"] == MEDIA_PROTOCOL
assert payloads[0] == b"MCF1" + len(payload).to_bytes(4, "big")
assert b"".join(payloads[1:]) == payload
assert all(len(part) <= 16384 for part in payloads[1:])
assert answer["peer_id"] in peers.items assert answer["peer_id"] in peers.items
assert channel.readyState == "open" assert channel.readyState == "open"
finally: finally:
@@ -100,3 +113,43 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
assert not peers.items assert not peers.items
asyncio.run(run()) asyncio.run(run())
def test_camera_waits_for_post_calibration_producer_and_delivers_metadata():
class Camera:
calls = 0
opened = []
def snapshot(self):
self.calls += 1
return {"generation": None} if self.calls < 2 else {
"generation": 3, "delivery": {"media_type": "video/mp4"}}
def open_delivery(self, generation):
self.opened.append(generation)
return "lease"
class Channel:
messages = []
def send(self, data):
self.messages.append(data)
async def run():
camera, channel = Camera(), Channel()
peers = NodeMediaPeers(None, camera)
peers.items["synthetic"] = {}
assert await peers.camera_delivery("synthetic", channel) == "lease"
assert camera.opened == [3]
import json
assert json.loads(channel.messages[0]) == {"type": "camera-ready", "mime": "video/mp4"}
asyncio.run(run())
def test_installed_camera_uses_declared_os_ffmpeg():
from pathlib import Path
root = Path(__file__).resolve().parents[1]
unit = (root / "plugins/xgrids-k1/packaging/mission-core-k1.service").read_text()
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
assert "iproute2, ffmpeg" in (root / "plugins/xgrids-k1/packaging/build_deb.py").read_text()
+20
View File
@@ -134,8 +134,10 @@ def test_parse_wifi_status_rejects_short_frame() -> None:
parse_wifi_status(bytes(50)) parse_wifi_status(bytes(50))
@pytest.mark.parametrize("current_handle", [True, False])
def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address( def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
current_handle: bool,
) -> None: ) -> None:
value = bytearray(54) value = bytearray(54)
value[0] = 11 value[0] = 11
@@ -167,8 +169,11 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
return status_characteristic return status_characteristic
return None return None
connected = []
class FakeClient: class FakeClient:
def __init__(self, _device: object, **_kwargs: object) -> None: def __init__(self, _device: object, **_kwargs: object) -> None:
connected.append(_device)
self.services = FakeServices() self.services = FakeServices()
self.name = "XGR-K1" self.name = "XGR-K1"
self.mtu_size = 256 self.mtu_size = 256
@@ -197,6 +202,19 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
), ),
) )
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient) monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
fresh_handle, capture, scans = object(), object(), []
async def is_current(_device):
return current_handle
async def refresh_exact(address, **_kwargs):
scans.append(address)
return capture
monkeypatch.setattr(wifi_module, "status_read_device_is_current", is_current)
monkeypatch.setattr(wifi_module, "discover_known_device_capture_for_status_read", refresh_exact)
monkeypatch.setattr(wifi_module, "captured_device_handle", lambda value: fresh_handle)
monkeypatch.setattr(wifi_module, "mark_captured_device_gatt_validated", lambda *_a, **_k: True)
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid")) result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid"))
@@ -207,6 +225,8 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
assert result["max_write_without_response_size"] == 244 assert result["max_write_without_response_size"] == 244
assert result["mtu_size"] == 256 assert result["mtu_size"] == 256
assert result["status"]["ipv4"] == "10.255.254.77" assert result["status"]["ipv4"] == "10.255.254.77"
assert connected == [retained_handle if current_handle else fresh_handle]
assert scans == ([] if current_handle else ["synthetic-corebluetooth-uuid"])
def test_read_wifi_status_recovery_keeps_fresh_retained_handle( def test_read_wifi_status_recovery_keeps_fresh_retained_handle(