Fix onboard WebKit preview and archive board telemetry locally
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import {command, type Sensor, type SensorTransport} from '@mission-core/sensor-sdk';
|
||||
import {MEDIA_PROTOCOL} from './previewFrames';
|
||||
|
||||
export interface PreviewChannel {
|
||||
readyState:string;
|
||||
onmessage:((event:{data:string|ArrayBuffer})=>void)|null;
|
||||
onclose:(()=>void)|null;
|
||||
send:(value:string)=>void;
|
||||
close:()=>void;
|
||||
}
|
||||
|
||||
class LocalChannel extends EventTarget implements PreviewChannel {
|
||||
readyState='open';
|
||||
onmessage:PreviewChannel['onmessage']=null;
|
||||
onclose:PreviewChannel['onclose']=null;
|
||||
constructor(private acknowledge:(value:string)=>void){
|
||||
super();
|
||||
this.addEventListener('message',event=>this.onmessage?.({data:(event as MessageEvent).data}));
|
||||
this.addEventListener('close',()=>this.onclose?.());
|
||||
}
|
||||
send(value:string){this.acknowledge(value);}
|
||||
close(){this.readyState='closed';}
|
||||
receive(data:string|ArrayBuffer){if(this.readyState==='open')this.dispatchEvent(new MessageEvent('message',{data}));}
|
||||
ended(){this.close();this.dispatchEvent(new Event('close'));}
|
||||
}
|
||||
|
||||
/** Framing may split at any TCP boundary. Only complete bounded messages leave. */
|
||||
export function localPreviewFrames(receive:(kind:number,payload:Uint8Array)=>void) {
|
||||
let pending=new Uint8Array(0);
|
||||
return (input:Uint8Array)=>{
|
||||
const joined=new Uint8Array(pending.length+input.length);joined.set(pending);joined.set(input,pending.length);
|
||||
let offset=0;
|
||||
while(joined.length-offset>=5){
|
||||
const kind=joined[offset], length=new DataView(joined.buffer,joined.byteOffset+offset+1,4).getUint32(0);
|
||||
if(kind>6||length>32768)throw new Error('Invalid local preview frame');
|
||||
if(joined.length-offset<5+length)break;
|
||||
receive(kind,joined.slice(offset+5,offset+5+length));offset+=5+length;
|
||||
}
|
||||
pending=joined.slice(offset);
|
||||
};
|
||||
}
|
||||
|
||||
/** Bounded completed responses work in the onboard WebKitGTK carrier. */
|
||||
export function localPreview(device:Sensor,transport:SensorTransport,parameters:Record<string,unknown>,failed:()=>void) {
|
||||
const carrier=transport.localPreview!;
|
||||
const abort=new AbortController();let peer='',after=Number(parameters.after)||0;
|
||||
const makeChannel=()=>new LocalChannel(value=>{if(value==='keepalive')return;const message=JSON.parse(value);if(!Number.isSafeInteger(message.ack)||message.ack<after)throw new Error('Invalid ACK');after=message.ack;});
|
||||
const rrd=makeChannel(),camera=makeChannel();
|
||||
const close=()=>{if(abort.signal.aborted)return;abort.abort();rrd.close();camera.close();if(peer)void transport.submit(command(device,'close-peer',{peer_id:peer})).catch(()=>{});};
|
||||
const start=async()=>{
|
||||
let response=await carrier.open(command(device,'offer',parameters),abort.signal);
|
||||
const text=new TextDecoder();
|
||||
const parse=localPreviewFrames((kind,payload)=>{
|
||||
if(kind===0){const hello=JSON.parse(text.decode(payload));if(peer||hello.media_protocol!==MEDIA_PROTOCOL||!/^peer_[0-9a-f]{32}$/.test(hello.peer_id))throw new Error('Invalid local preview');peer=hello.peer_id;return;}
|
||||
if(!peer)throw new Error('Missing preview admission');
|
||||
const channel=kind<=2||kind===6?rrd:camera;
|
||||
if(kind>=5){channel.ended();return;}
|
||||
channel.receive(kind%2?text.decode(payload):payload.buffer as ArrayBuffer);
|
||||
});
|
||||
while(!abort.signal.aborted){
|
||||
if(!response.ok)throw new Error('Local preview unavailable');
|
||||
const bytes=await response.arrayBuffer();
|
||||
if(bytes.byteLength>196608)throw new Error('Local preview budget');
|
||||
if(abort.signal.aborted)return;
|
||||
parse(new Uint8Array(bytes));
|
||||
if(!peer)throw new Error('Missing preview admission');
|
||||
if(!abort.signal.aborted)response=await carrier.read(peer,after,abort.signal);
|
||||
}
|
||||
};
|
||||
return {rrd,camera,start:async()=>{try{await start();}catch(error){if(!abort.signal.aborted)failed();throw error;}},close};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||
import { MEDIA_PROTOCOL, previewFrames, previewRecording } from './previewFrames';
|
||||
import { previewCamera } from './previewCamera';
|
||||
import { previewFreshness } from './previewFreshness';
|
||||
import {localPreview, type PreviewChannel} from './localPreview';
|
||||
export type PreviewStatus = {
|
||||
lidar: string;
|
||||
camera: string;
|
||||
@@ -76,13 +77,28 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
}
|
||||
} };
|
||||
const viewer = native, freshness = previewFreshness();
|
||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
||||
rrd.binaryType = 'arraybuffer';
|
||||
camera.binaryType = 'arraybuffer';
|
||||
if (!transport.localPreview && typeof RTCPeerConnection !== 'function') {
|
||||
update({lidar:'Этот браузер не поддерживает просмотр. Откройте сцену в Mission Core.',camera:'Просмотр недоступен',retry:false});
|
||||
return;
|
||||
}
|
||||
let pc:RTCPeerConnection|null=null;
|
||||
try {if(!transport.localPreview)pc=new RTCPeerConnection({iceServers:[]});}
|
||||
catch {update({lidar:'Не удалось открыть канал просмотра',retry:false});return;}
|
||||
const local=transport.localPreview?localPreview(device,transport,{acquisition_id:device.control?.acquisition_id,view_id:view.id,after:view.recording.after},()=>fail()):null;
|
||||
let rrd:PreviewChannel|RTCDataChannel, camera:PreviewChannel|RTCDataChannel;
|
||||
try {
|
||||
rrd = local?.rrd ?? pc!.createDataChannel('rrd', { ordered: true });
|
||||
camera = local?.camera ?? pc!.createDataChannel('camera', { ordered: true });
|
||||
} catch {
|
||||
local?.close();pc?.close();
|
||||
update({lidar:'Не удалось открыть канал просмотра',camera:'Просмотр недоступен',retry:false});return;
|
||||
}
|
||||
if('binaryType' in rrd)rrd.binaryType = 'arraybuffer';
|
||||
if('binaryType' in camera)camera.binaryType = 'arraybuffer';
|
||||
const closeConnection=()=>{local?.close();pc?.close();};
|
||||
const {channel, previousRecording} = view;
|
||||
const fail = () => { if (!active || failed)
|
||||
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
|
||||
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); closeConnection(); };
|
||||
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
|
||||
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
|
||||
let decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
|
||||
@@ -101,7 +117,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
fail(); };
|
||||
camera.onclose = () => { if (active && !failed)
|
||||
cameraFail(); };
|
||||
rrd.onmessage = event => {
|
||||
rrd.onmessage = (event:{data:string|ArrayBuffer}) => {
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
@@ -110,7 +126,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
if (value.type === 'preview-unavailable' && value.code === 'resume-expired') {
|
||||
failed = true; clearInterval(follow); clearInterval(keepalive);
|
||||
update({retry:false, presented:false, cameraPresented:false, lidar:'Сессия просмотра истекла. Закройте и откройте пространственную сцену.'});
|
||||
pc.close(); return;
|
||||
closeConnection(); return;
|
||||
}
|
||||
if (batch || value.type !== 'rrd-batch' || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !value.lidar)
|
||||
throw new Error('Invalid RRD batch');
|
||||
@@ -123,7 +139,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
fail();
|
||||
}
|
||||
};
|
||||
camera.onmessage = event => {
|
||||
camera.onmessage = (event:{data:string|ArrayBuffer}) => {
|
||||
if (!active || failed)
|
||||
return;
|
||||
try {
|
||||
@@ -176,11 +192,13 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||
}, 250);
|
||||
if(local){await local.start();return;}
|
||||
if(!pc)return;
|
||||
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') {
|
||||
pc.onicegatheringstatechange = () => { if (pc?.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
resolve();
|
||||
} };
|
||||
@@ -201,11 +219,11 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
if (answer.media_protocol !== MEDIA_PROTOCOL) {
|
||||
failed = true;
|
||||
clearInterval(follow);
|
||||
pc.close();
|
||||
closeConnection();
|
||||
update({ lidar: 'Для просмотра требуется обновление приложения на БК', camera: 'Камера: ожидаем обновление', retry: false });
|
||||
return;
|
||||
}
|
||||
pc.onconnectionstatechange = () => { if (active && ['failed', 'closed'].includes(pc.connectionState))
|
||||
pc.onconnectionstatechange = () => { if (active && ['failed', 'closed'].includes(pc?.connectionState ?? 'closed'))
|
||||
fail(); };
|
||||
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });
|
||||
keepalive = setInterval(() => { for (const c of [rrd, camera])
|
||||
@@ -222,9 +240,8 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
camera.onmessage = null;
|
||||
camera.onclose = null;
|
||||
rrd.onclose = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.close();
|
||||
if(pc){pc.onconnectionstatechange = null;pc.onicegatheringstatechange = null;}
|
||||
closeConnection();
|
||||
rrdFrames.close();
|
||||
cameraFrames.close();
|
||||
decoder.close();
|
||||
|
||||
@@ -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.13"
|
||||
VERSION = "0.1.14"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"src/k1link/viewer/__init__.py",
|
||||
"src/k1link/viewer/metrics.py",
|
||||
"src/k1link/viewer/node_media.py",
|
||||
"src/k1link/viewer/node_local_media.py",
|
||||
"src/k1link/viewer/node_rerun.py",
|
||||
"src/k1link/viewer/recorded.py",
|
||||
"src/k1link/viewer/recorded_blueprint_lifecycle.py",
|
||||
|
||||
Reference in New Issue
Block a user