Preserve RRD preview frames and clarify onboard K1 status
This commit is contained in:
@@ -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,/>Статус</);
|
||||
assert.match(markup,/>Rerun</);
|
||||
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
||||
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
||||
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',()=>{
|
||||
const inventory={fresh:true,items:[device],operations:[]};
|
||||
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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {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<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 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 <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
|
||||
<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 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 <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="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!busy} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||
{manual.connected&&!manual.showStop&&<Button variant="primary" disabled={!!busy||!manual.canStart} aria-busy={busy==='start'}
|
||||
icon={busy==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{busy==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||
{manual.showStop&&<Button disabled={!manual.connected||!!busy||(!streaming&&!device.control?.can_stop)} aria-busy={busy==='stop'}
|
||||
icon={busy==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{busy==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||
{!manual.connected&&<SettingsCard align="center" title={k1ConnectionNotice(device,enabled)}>
|
||||
{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>}
|
||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
|
||||
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
|
||||
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||
{!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>}
|
||||
</SettingsCard>
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{!streaming&&(busy==='start'||manual.active)&&<ActivityIndicator label="Запускаем K1 и ожидаем живые данные"/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost} onReconnect={()=>setGeneration(value=>value+1)}/>}
|
||||
<SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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<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(()=>{
|
||||
let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined;
|
||||
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|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<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&¤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 <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>;
|
||||
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 <SettingsCard title="Rerun" className={expanded?'k1-preview sensor-viewer-expanded':'k1-preview'}
|
||||
actions={<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}>
|
||||
<div className="k1-preview-spatial" ref={spatial}/>
|
||||
<video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
</SettingsCard>;
|
||||
}
|
||||
|
||||
@@ -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'} {
|
||||
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
|
||||
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
|
||||
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 {
|
||||
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
|
||||
if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.';
|
||||
if(device.control?.reason_code==='application_authority_unavailable')
|
||||
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
||||
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]);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
||||
from debian import package # noqa: E402
|
||||
from runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.6"
|
||||
VERSION = "0.1.7"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
|
||||
@@ -16,6 +16,7 @@ RuntimeDirectoryMode=0750
|
||||
LoadCredentialEncrypted=k1-application
|
||||
UMask=0007
|
||||
Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2
|
||||
Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
|
||||
@@ -838,6 +838,18 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
|
||||
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(
|
||||
captured: CapturedDiscoveredDevice,
|
||||
) -> BLEDevice | None:
|
||||
|
||||
@@ -26,6 +26,7 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
mark_captured_device_gatt_validated,
|
||||
retrieve_connected_device_capture,
|
||||
retrieve_known_device_capture_for_status_read,
|
||||
status_read_device_is_current,
|
||||
)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
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"
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
progress.operation_stage = "gatt-contract"
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from itertools import chain
|
||||
from uuid import uuid4
|
||||
|
||||
import aioice.ice
|
||||
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(
|
||||
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")
|
||||
@@ -90,12 +98,11 @@ class NodeMediaPeers:
|
||||
await self.close(identifier)
|
||||
|
||||
entry["tasks"].append(asyncio.create_task(expiry()))
|
||||
snapshot = self.camera.snapshot()
|
||||
return {
|
||||
"peer_id": identifier,
|
||||
"sdp": pc.localDescription.sdp,
|
||||
"type": "answer",
|
||||
"camera_mime": (snapshot.get("delivery") or {}).get("media_type"),
|
||||
"media_protocol": MEDIA_PROTOCOL,
|
||||
"profile": "live-acquisition",
|
||||
"transport": "webrtc-rrd-fmp4",
|
||||
}
|
||||
@@ -104,17 +111,43 @@ class NodeMediaPeers:
|
||||
raise
|
||||
|
||||
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")
|
||||
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
|
||||
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
|
||||
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
|
||||
raise RuntimeError("Preview consumer unavailable")
|
||||
await asyncio.sleep(0.01)
|
||||
channel.send(payload[offset : offset + 16384])
|
||||
channel.send(part)
|
||||
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):
|
||||
subscriber = lease = None
|
||||
try:
|
||||
@@ -122,11 +155,9 @@ class NodeMediaPeers:
|
||||
if channel.label == "rrd":
|
||||
subscriber = await asyncio.to_thread(self.hub.subscribe)
|
||||
else:
|
||||
generation = self.camera.snapshot().get("generation")
|
||||
if generation is None:
|
||||
channel.close()
|
||||
lease = await self.camera_delivery(identifier, channel)
|
||||
if lease is None:
|
||||
return
|
||||
lease = await asyncio.to_thread(self.camera.open_delivery, generation)
|
||||
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
|
||||
if subscriber:
|
||||
payload = await asyncio.to_thread(subscriber.read)
|
||||
@@ -144,8 +175,11 @@ class NodeMediaPeers:
|
||||
break
|
||||
if payload:
|
||||
await self.send(channel, payload)
|
||||
except (Exception, asyncio.CancelledError):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as error:
|
||||
logger.warning("Node preview delivery failed channel=%s exception=%s",
|
||||
channel.label, type(error).__name__)
|
||||
finally:
|
||||
if subscriber:
|
||||
subscriber.close()
|
||||
|
||||
@@ -1702,3 +1702,21 @@ def test_exact_session_invalidation_does_not_clear_new_scan_handle() -> None:
|
||||
) is not None
|
||||
|
||||
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)
|
||||
|
||||
@@ -4,7 +4,7 @@ import queue
|
||||
import pytest
|
||||
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"
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
class Subscription:
|
||||
def __init__(self):
|
||||
self.output = queue.Queue()
|
||||
self.output.put(b"RRF2-transport-fixture")
|
||||
self.output.put(payload)
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
@@ -68,7 +68,16 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
|
||||
class Camera:
|
||||
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():
|
||||
peers = NodeMediaPeers(Hub(), Camera())
|
||||
@@ -82,7 +91,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
@channel.on("message")
|
||||
def message(data):
|
||||
payloads.append(data)
|
||||
received.set()
|
||||
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
||||
received.set()
|
||||
|
||||
try:
|
||||
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")
|
||||
)
|
||||
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 channel.readyState == "open"
|
||||
finally:
|
||||
@@ -100,3 +113,43 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
assert not peers.items
|
||||
|
||||
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()
|
||||
|
||||
@@ -134,8 +134,10 @@ def test_parse_wifi_status_rejects_short_frame() -> None:
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
current_handle: bool,
|
||||
) -> None:
|
||||
value = bytearray(54)
|
||||
value[0] = 11
|
||||
@@ -167,8 +169,11 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
connected = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, _device: object, **_kwargs: object) -> None:
|
||||
connected.append(_device)
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
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)
|
||||
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"))
|
||||
|
||||
@@ -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["mtu_size"] == 256
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user