Fix onboard WebKit preview and archive board telemetry locally

This commit is contained in:
DCCONSTRUCTIONS
2026-09-08 01:37:52 +03:00
parent d8afb61229
commit f0802d2713
46 changed files with 2623 additions and 40 deletions
@@ -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();
+1 -1
View File
@@ -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",