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 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);
|
||||||
|
});
|
||||||
@@ -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&¤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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user