feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source": "DCD-006_rover_v020.blend",
|
||||
"source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79",
|
||||
"model": "DCD-006 v020 complete rover",
|
||||
"source_object_count": 1426,
|
||||
"render_mesh_count": 1,
|
||||
"original_bounds": [
|
||||
[
|
||||
-0.527999997138977,
|
||||
-0.45249998569488525,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.6350000500679016,
|
||||
0.45249998569488525,
|
||||
0.734000027179718
|
||||
]
|
||||
],
|
||||
"dimensions_m": [
|
||||
1.1630001068115234,
|
||||
0.9049999713897705,
|
||||
0.734000027179718
|
||||
],
|
||||
"translation_m": [
|
||||
-0.05350002646446228,
|
||||
-0.0,
|
||||
-0.0
|
||||
],
|
||||
"glb_bytes": 54960576,
|
||||
"glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79",
|
||||
"material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e",
|
||||
"material_rules": {
|
||||
"metal": 1,
|
||||
"rubber": 2,
|
||||
"steel": 6,
|
||||
"paint": 3
|
||||
},
|
||||
"source_saved": false
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
@@ -822,7 +822,7 @@ export default function App() {
|
||||
/>
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {RoverState} from '../../core/fleet/useRoverControl';
|
||||
import {drivePositions} from '../../../../../plugins/vesc/frontend/src/model';
|
||||
const number=(v:number|undefined,digits=1)=>typeof v==='number'&&Number.isFinite(v)?v.toLocaleString('ru-RU',{maximumFractionDigits:digits}):'—';
|
||||
export function RoverTelemetry({state}:{state:RoverState}){
|
||||
const devices=state.snapshot.devices??[],profile=state.snapshot.profile;
|
||||
const positions=drivePositions(profile?.layout??null);
|
||||
const assigned=Object.entries(profile?.bindings??{}).map(([slot,binding])=>({
|
||||
id:binding.device_id,uuid:binding.uuid,label:positions[slot]??slot,
|
||||
reading:devices.find(d=>d.id===binding.device_id&&d.uuid===binding.uuid),
|
||||
}));
|
||||
const readings=[...assigned,...devices.filter(d=>!assigned.some(a=>a.id===d.id&&a.uuid===d.uuid)).map(d=>({...d,reading:d}))];
|
||||
return <div className="rover-telemetry">
|
||||
{!state.fresh?<p>Нет свежих показаний VESC.</p>:!readings.length?<p>Получение показаний VESC…</p>:readings.map(d=>{
|
||||
const fresh=!!d.reading&&d.reading.age_ms<1000,v=fresh?d.reading?.values:undefined;
|
||||
return <section key={d.uuid}><strong>{d.label}</strong><span>VESC {d.uuid.slice(0,6).toUpperCase()}{!d.reading?' · Нет связи':!fresh?' · Нет свежих данных':''}</span>
|
||||
<dl><div><dt>Ток мотора</dt><dd>{number(v?.motor_current_a)} А</dd></div>
|
||||
<div><dt>Ток батареи</dt><dd>{number(v?.input_current_a)} А</dd></div>
|
||||
<div><dt>Напряжение</dt><dd>{number(v?.input_voltage_v)} В</dd></div>
|
||||
<div><dt>Обороты, ERPM</dt><dd>{number(v?.erpm,0)}</dd></div>
|
||||
<div><dt>Контроллер</dt><dd>{number(v?.mos_temperature_c)} °C</dd></div>
|
||||
<div><dt>Ошибка VESC</dt><dd>{v?v.fault_code===0?'Нет':String(v.fault_code):'—'}</dd></div></dl>
|
||||
</section>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,Icon,IconButton,KeyButton,LoadingRegion,Select,StatusBadge,Switch,TextField,ToastStack,Window} from '@nodedc/ui-react';
|
||||
import {keysFor,roverSettings,type RoverMode} from '../../core/fleet/roverInput';
|
||||
import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
|
||||
import type {RoverController} from '../../core/fleet/useRoverControl';
|
||||
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
|
||||
import './rover.css';
|
||||
const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
|
||||
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
|
||||
|
||||
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
|
||||
const storage=`missioncore.rover-view.v1:${vehicleID}`;
|
||||
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
|
||||
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
|
||||
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
|
||||
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
|
||||
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
|
||||
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
|
||||
useEffect(()=>{if(!active)stop();},[active,stop]);
|
||||
useEffect(()=>{
|
||||
if(!c.ready||!active||open||!stage.current)return;
|
||||
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
|
||||
input.current=binding;c.setInputGuard(binding.valid);
|
||||
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
|
||||
},[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
|
||||
const showSettings=()=>{stop();setOpen(true);};
|
||||
const preparing=c.pending||(c.armed&&!c.ready);
|
||||
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
|
||||
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
|
||||
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
|
||||
return <>
|
||||
{createPortal(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
|
||||
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
|
||||
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
|
||||
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
|
||||
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
|
||||
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
|
||||
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
|
||||
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
|
||||
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
|
||||
{c.ready&&<div className="rover-view__controls">
|
||||
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
|
||||
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
|
||||
disabled={!c.ready||!active} pressed={held.has(code)}
|
||||
onPointerDown={e=>{if(!c.ready||!e.isTrusted||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);stage.current?.focus();input.current?.pointerDown(e.pointerId,code);}}
|
||||
onPointerUp={e=>input.current?.pointerUp(e.pointerId)}
|
||||
onPointerCancel={e=>input.current?.pointerCancel(e.pointerId)}
|
||||
onLostPointerCapture={e=>input.current?.pointerCancel(e.pointerId)}>{code.slice(3)}</KeyButton>)}
|
||||
</div>
|
||||
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
|
||||
</div>}
|
||||
<div className="rover-view__authority">
|
||||
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
|
||||
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
|
||||
<Select label="3D-модель" value={settings.model} options={[{value:'',label:'Не выбрана'},{value:'dcd006-v020',label:'DCD-006 · полный ровер v020'}]} onChange={model=>setSettings(v=>({...v,model}))}/>
|
||||
<TextField label="Предел тока каждого мотора, А" type="number" min={.5} max={30} step={.5} value={settings.currentA} onChange={e=>setSettings(v=>({...v,currentA:Number(e.target.value)}))}/>
|
||||
<TextField label="Скорость, ERPM" hint="300–3000" description="Электрические обороты двигателя. Скорость по земле требует параметров трансмиссии." type="number" min={300} max={3000} step={100} value={settings.maxErpm} onChange={e=>setSettings(v=>({...v,maxErpm:Number(e.target.value)}))}/>
|
||||
<Switch label="Моторы остановлены, движение можно начинать" checked={confirmed} onChange={setConfirmed}/>
|
||||
{availability&&<StatusBadge tone="warning">{availability}</StatusBadge>}
|
||||
<p>Движение — только при удержании клавиш или кнопок. При уходе из окна команда снимается. Команда с пульта прекращает удалённое управление.</p>
|
||||
<Button variant="primary" tone="neutral" disabled={!confirmed||!c.canArm||!(settings.currentA>=.5&&settings.currentA<=30&&settings.maxErpm>=300&&settings.maxErpm<=3000)} loading={c.pending}
|
||||
onClick={async()=>{const acquired=await c.arm(settings.currentA,settings.maxErpm);if(acquired){setOpen(false);if(document.hasFocus())stage.current?.focus();}}}>Включить управление</Button>
|
||||
</div></Window>
|
||||
<ToastStack onDismiss={c.clearError} items={c.error?[{id:'rover-control-error',tone:'error',title:'Управление остановлено',description:c.error}]:[]}/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"source": "NodeDC ThreeDAssetNode / PlayCanvasViewer",
|
||||
"files": {
|
||||
"PlayCanvasViewer.tsx": "34cd6df2e47a7714288aeb5afb2859b1058eb91dc1819abbf296c6a8937a1533",
|
||||
"playcanvasPostFx.ts": "91cfb7f8ba821fef14681c6c25a61f430ce25aae65b0f181d359f38e37ccd014",
|
||||
"sceneTree.ts": "eead378c2402f69e2c68cd1fc2a5ec9028555dced53858c600c599f613dda516",
|
||||
"environment-map.png": "793f72dce207c1a4d2bdb262610f688bd655a79066298b672b642f228cf7d230"
|
||||
},
|
||||
"adaptations": [
|
||||
"Domain asset URL and error/loading callbacks",
|
||||
"No fallback to a different vehicle",
|
||||
"Removed unused node-editor type helper; rendering settings unchanged",
|
||||
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
|
||||
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
|
||||
export type PostFxSettings = {
|
||||
lighting: {
|
||||
exposure: number
|
||||
skyBoxIntensity: number
|
||||
}
|
||||
envAtlas: {
|
||||
enabled: boolean
|
||||
background: boolean
|
||||
reflection: boolean
|
||||
intensity: number
|
||||
reflectionIntensity: number
|
||||
brightness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
toneMapping: number
|
||||
mip: number
|
||||
rotation: number
|
||||
}
|
||||
skybox: {
|
||||
enabled: boolean
|
||||
background: boolean
|
||||
reflection: boolean
|
||||
intensity: number
|
||||
reflectionIntensity: number
|
||||
mip: number
|
||||
rotation: number
|
||||
colorA: string
|
||||
colorB: string
|
||||
}
|
||||
rendering: {
|
||||
backgroundColor: string
|
||||
wireframe: boolean
|
||||
renderFormat: number
|
||||
renderFormatFallback0: number
|
||||
renderFormatFallback1: number
|
||||
stencil: boolean
|
||||
renderTargetScale: number
|
||||
samples: number
|
||||
sharpness: number
|
||||
toneMapping: number
|
||||
sceneColorMap: boolean
|
||||
sceneDepthMap: boolean
|
||||
fog: 'none' | 'linear' | 'exp' | 'exp2'
|
||||
fogColor: string
|
||||
fogRange: [number, number]
|
||||
fogDensity: number
|
||||
fogStart: number
|
||||
fogEnd: number
|
||||
}
|
||||
grid: {
|
||||
enabled: boolean
|
||||
colorX: string
|
||||
colorZ: string
|
||||
colorMain: string
|
||||
alphaX: number
|
||||
alphaZ: number
|
||||
alphaMain: number
|
||||
dotsEnabled: boolean
|
||||
dotsColor: string
|
||||
dotsAlpha: number
|
||||
dotsDiameter: number
|
||||
crossEnabled: boolean
|
||||
crossColor: string
|
||||
crossAlpha: number
|
||||
crossLength: number
|
||||
crossWidth: number
|
||||
fadeStart: number
|
||||
fadeEnd: number
|
||||
}
|
||||
ssao: {
|
||||
type: 'none' | 'lighting' | 'combine'
|
||||
blurEnabled: boolean
|
||||
intensity: number
|
||||
radius: number
|
||||
samples: number
|
||||
power: number
|
||||
minAngle: number
|
||||
scale: number
|
||||
}
|
||||
bloom: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
lastMipLevel: number
|
||||
}
|
||||
chromaticAberration: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
}
|
||||
taa: {
|
||||
enabled: boolean
|
||||
jitter: number
|
||||
}
|
||||
grading: {
|
||||
enabled: boolean
|
||||
brightness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
tint: string
|
||||
}
|
||||
lut: {
|
||||
intensity: number
|
||||
textureUrl?: string | null
|
||||
}
|
||||
vignette: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
inner: number
|
||||
outer: number
|
||||
curvature: number
|
||||
color: string
|
||||
}
|
||||
directionalLight: {
|
||||
enabled: boolean
|
||||
color: string
|
||||
intensity: number
|
||||
azimuth: number
|
||||
elevation: number
|
||||
castShadows: boolean
|
||||
shadowIntensity: number
|
||||
shadowDistance: number
|
||||
shadowResolution: number
|
||||
shadowBias: number
|
||||
normalOffsetBias: number
|
||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||
vsmBlurSize: number
|
||||
}
|
||||
shadowCatcher: {
|
||||
enabled: boolean
|
||||
size: number
|
||||
yOffset: number
|
||||
lightIntensity: number
|
||||
lightColor: string
|
||||
lightAzimuth: number
|
||||
lightElevation: number
|
||||
shadowIntensity: number
|
||||
shadowDistance: number
|
||||
shadowResolution: number
|
||||
shadowBias: number
|
||||
normalOffsetBias: number
|
||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||
vsmBlurSize: number
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_POSTFX: PostFxSettings = {
|
||||
lighting: {
|
||||
exposure: 1.21,
|
||||
skyBoxIntensity: 0.86,
|
||||
},
|
||||
envAtlas: {
|
||||
enabled: true,
|
||||
background: false,
|
||||
reflection: true,
|
||||
intensity: 0.76,
|
||||
reflectionIntensity: 0.6,
|
||||
brightness: 1.65,
|
||||
contrast: 1,
|
||||
saturation: 1,
|
||||
toneMapping: 0,
|
||||
mip: 1,
|
||||
rotation: 247,
|
||||
},
|
||||
skybox: {
|
||||
enabled: false,
|
||||
background: false,
|
||||
reflection: true,
|
||||
intensity: 0.62,
|
||||
reflectionIntensity: 1,
|
||||
mip: 0,
|
||||
rotation: 0,
|
||||
colorA: '#c5bfbf',
|
||||
colorB: '#c7bcbc',
|
||||
},
|
||||
rendering: {
|
||||
backgroundColor: '#111113',
|
||||
wireframe: false,
|
||||
renderFormat: 18,
|
||||
renderFormatFallback0: 12,
|
||||
renderFormatFallback1: 14,
|
||||
stencil: false,
|
||||
renderTargetScale: 1,
|
||||
samples: 4,
|
||||
sharpness: 0,
|
||||
toneMapping: 4,
|
||||
sceneColorMap: false,
|
||||
sceneDepthMap: false,
|
||||
fog: 'exp',
|
||||
fogColor: '#dcc2ff',
|
||||
fogRange: [0, 100],
|
||||
fogDensity: 0.008,
|
||||
fogStart: 0,
|
||||
fogEnd: 100,
|
||||
},
|
||||
grid: {
|
||||
enabled: true,
|
||||
colorX: '#ffffff',
|
||||
colorZ: '#ffffff',
|
||||
colorMain: '#7a3cff',
|
||||
alphaX: 0.18,
|
||||
alphaZ: 0.18,
|
||||
alphaMain: 0.6,
|
||||
dotsEnabled: false,
|
||||
dotsColor: '#ffffff',
|
||||
dotsAlpha: 0.5,
|
||||
dotsDiameter: 0.06,
|
||||
crossEnabled: false,
|
||||
crossColor: '#ffffff',
|
||||
crossAlpha: 0.5,
|
||||
crossLength: 0.5,
|
||||
crossWidth: 0.06,
|
||||
fadeStart: 0,
|
||||
fadeEnd: 0,
|
||||
},
|
||||
ssao: {
|
||||
type: 'none',
|
||||
blurEnabled: true,
|
||||
intensity: 0.5,
|
||||
radius: 30,
|
||||
samples: 12,
|
||||
power: 6,
|
||||
minAngle: 10,
|
||||
scale: 1,
|
||||
},
|
||||
bloom: {
|
||||
enabled: true,
|
||||
intensity: 0.03,
|
||||
lastMipLevel: 4,
|
||||
},
|
||||
chromaticAberration: {
|
||||
enabled: true,
|
||||
intensity: 30,
|
||||
},
|
||||
taa: {
|
||||
enabled: false,
|
||||
jitter: 1,
|
||||
},
|
||||
grading: {
|
||||
enabled: true,
|
||||
brightness: 0.837,
|
||||
contrast: 1.1,
|
||||
saturation: 1.126,
|
||||
tint: '#ffffff',
|
||||
},
|
||||
lut: {
|
||||
intensity: 1,
|
||||
textureUrl: null,
|
||||
},
|
||||
vignette: {
|
||||
enabled: true,
|
||||
intensity: 1,
|
||||
inner: 0.25,
|
||||
outer: 1.52,
|
||||
curvature: 0.78,
|
||||
color: '#000000',
|
||||
},
|
||||
directionalLight: {
|
||||
enabled: true,
|
||||
color: '#ffffff',
|
||||
intensity: 0.4,
|
||||
azimuth: 0,
|
||||
elevation: 0,
|
||||
castShadows: false,
|
||||
shadowIntensity: 0.5,
|
||||
shadowDistance: 16,
|
||||
shadowResolution: 1024,
|
||||
shadowBias: 0,
|
||||
normalOffsetBias: 0,
|
||||
shadowType: 'vsm16',
|
||||
vsmBlurSize: 8,
|
||||
},
|
||||
shadowCatcher: {
|
||||
enabled: true,
|
||||
size: 9,
|
||||
yOffset: 0.001,
|
||||
lightIntensity: 0.3,
|
||||
lightColor: '#ffffff',
|
||||
lightAzimuth: 20,
|
||||
lightElevation: 60,
|
||||
shadowIntensity: 0.29,
|
||||
shadowDistance: 16,
|
||||
shadowResolution: 2048,
|
||||
shadowBias: 0,
|
||||
normalOffsetBias: 0,
|
||||
shadowType: 'vsm16',
|
||||
vsmBlurSize: 8,
|
||||
},
|
||||
}
|
||||
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
|
||||
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
|
||||
|
||||
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
|
||||
const merge = (target: any, source: any) => {
|
||||
if (!isObject(source)) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (isObject(value)) {
|
||||
if (!isObject(target[key])) target[key] = {}
|
||||
merge(target[key], value)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
merge(out, raw)
|
||||
|
||||
const legacy: any = raw as any
|
||||
if (legacy?.hdrLight) {
|
||||
const src = legacy.hdrLight
|
||||
const env = legacy?.envAtlas ?? {}
|
||||
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
|
||||
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
|
||||
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
|
||||
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
|
||||
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
|
||||
}
|
||||
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
|
||||
if (typeof legacy.hdrReflection.enabled === 'boolean') {
|
||||
out.envAtlas.reflection = legacy.hdrReflection.enabled
|
||||
}
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
|
||||
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
|
||||
out.envAtlas.brightness = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
|
||||
out.envAtlas.contrast = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
|
||||
out.envAtlas.saturation = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
|
||||
out.envAtlas.toneMapping = 0
|
||||
}
|
||||
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
|
||||
out.skybox.reflectionIntensity = out.skybox.intensity
|
||||
}
|
||||
if (out.grid && typeof out.grid.enabled !== 'boolean') {
|
||||
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorX !== 'string') {
|
||||
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorZ !== 'string') {
|
||||
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorMain !== 'string') {
|
||||
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaX !== 'number') {
|
||||
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaZ !== 'number') {
|
||||
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaMain !== 'number') {
|
||||
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
|
||||
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsColor !== 'string') {
|
||||
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
|
||||
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
|
||||
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
|
||||
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossColor !== 'string') {
|
||||
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
|
||||
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossLength !== 'number') {
|
||||
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossWidth !== 'number') {
|
||||
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
|
||||
}
|
||||
return out as PostFxSettings
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as pc from 'playcanvas'
|
||||
|
||||
export type SceneTreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
children: SceneTreeNode[]
|
||||
}
|
||||
|
||||
export function buildSceneTree(root: pc.GraphNode, map: Map<string, pc.Entity>): SceneTreeNode {
|
||||
const id = String((root as any).getGuid?.() ?? (root as any)._guid ?? root.name)
|
||||
const children = (root.children || []).map((c) => buildSceneTree(c, map))
|
||||
map.set(id, root as pc.Entity)
|
||||
return {
|
||||
id,
|
||||
name: root.name || 'Entity',
|
||||
enabled: root.enabled !== false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.rover-view { position:relative; height:100%; min-height:200px; overflow:hidden; container-type:inline-size; }
|
||||
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
|
||||
.rover-view .rover-view__scene { position:absolute; inset:0; }
|
||||
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
|
||||
.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); }
|
||||
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
|
||||
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
|
||||
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
|
||||
.rover-key-KeyW { grid-column:2; }.rover-key-KeyA { grid-column:1; grid-row:2; }.rover-key-KeyS { grid-column:2; grid-row:2; }.rover-key-KeyD { grid-column:3; grid-row:2; }
|
||||
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
|
||||
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
|
||||
@container (max-width: 380px) {
|
||||
.rover-view__authority { top:48px; bottom:auto; }
|
||||
}
|
||||
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
|
||||
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
|
||||
.rover-telemetry { height:100%; overflow:auto; padding:var(--nodedc-space-3); display:flex; flex-wrap:wrap; align-content:start; gap:var(--nodedc-space-4); font-size:var(--nodedc-font-size-sm); }
|
||||
.rover-telemetry section { flex:1; min-width:170px; }.rover-telemetry section > span { display:block; color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-xs); margin-top:var(--nodedc-space-1); }
|
||||
.rover-telemetry dl { display:grid; gap:var(--nodedc-space-1); margin-bottom:0; }.rover-telemetry dl > div { display:flex; justify-content:space-between; gap:var(--nodedc-space-3); }
|
||||
.rover-telemetry dt { color:var(--nodedc-text-secondary); }.rover-telemetry dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
@@ -1,3 +1,4 @@
|
||||
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
@@ -10,4 +11,4 @@ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
|
||||
// Node-executed camera controls use the existing sensor contribution contract;
|
||||
// they do not register a desktop capture/AI runtime or a new product workspace.
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi,vescSensorUi]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
const layouts=new Map<string,BoardLayoutStore>();
|
||||
export function boardLayout(vehicleID:string):BoardLayoutStore {
|
||||
let layout=layouts.get(vehicleID);
|
||||
if(!layout){
|
||||
const path=`/${encodeURIComponent(vehicleID)}/board-layout`;
|
||||
layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})});
|
||||
layouts.set(vehicleID,layout);
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {keyDemand,keysFor,type Demand,type RoverMode} from './roverInput';
|
||||
|
||||
// The network heartbeat is not evidence that a physical key is still held.
|
||||
// Allow the initial OS repeat delay, then require continuing keyboard evidence.
|
||||
export const firstKeyLeaseMs=1000,repeatKeyLeaseMs=300;
|
||||
export function bindRoverHoldInput(scope:HTMLElement,mode:RoverMode,callbacks:{
|
||||
held:(keys:Set<string>)=>void;demand:(value:Demand)=>void;pause:()=>void;stop:()=>void;
|
||||
},now:()=>number=()=>performance.now()){
|
||||
const doc=scope.ownerDocument,win=doc.defaultView!;
|
||||
const keyboard=new Set<string>(),pointers=new Map<number,string>();
|
||||
let deadline=0,closed=false,suspended=false;
|
||||
const clear=()=>{keyboard.clear();pointers.clear();deadline=0;callbacks.held(new Set());};
|
||||
const pause=()=>{if(closed||suspended)return;suspended=true;clear();callbacks.pause();};
|
||||
const stop=()=>{if(closed)return;closed=true;clear();callbacks.stop();};
|
||||
const focused=()=>!doc.hidden&&doc.hasFocus()&&scope.contains(doc.activeElement);
|
||||
const valid=()=>{
|
||||
if(closed)return false;
|
||||
if(!focused()||(keyboard.size>0&&now()>=deadline)){pause();return false;}
|
||||
return true;
|
||||
};
|
||||
const publish=()=>{const held=new Set([...keyboard,...pointers.values()]);callbacks.held(held);callbacks.demand(keyDemand(mode,held));};
|
||||
const down=(e:KeyboardEvent)=>{
|
||||
if(!e.isTrusted||closed)return;
|
||||
if(e.code==='Space'||e.code==='Escape'){e.preventDefault();stop();return;}
|
||||
if(e.altKey||e.ctrlKey||e.metaKey){pause();return;}
|
||||
if(!keysFor(mode).includes(e.code)||!valid())return;
|
||||
if((e.target as HTMLElement)?.closest('input,textarea,select,[role=dialog],[role=listbox]')){pause();return;}
|
||||
// A repeat delivered after focus returns cannot become a fresh press.
|
||||
if(e.repeat&&!keyboard.has(e.code))return;
|
||||
e.preventDefault();suspended=false;keyboard.add(e.code);
|
||||
deadline=now()+(e.repeat?repeatKeyLeaseMs:firstKeyLeaseMs);publish();
|
||||
};
|
||||
const up=(e:KeyboardEvent)=>{
|
||||
if(!e.isTrusted||closed||!keyboard.delete(e.code))return;
|
||||
e.preventDefault();if(!keyboard.size)deadline=0;publish();
|
||||
};
|
||||
const blur=(e:Event)=>{if(e.target===win)pause();};
|
||||
const focusOut=(e:FocusEvent)=>{if(scope.contains(e.target as Node)&&!scope.contains(e.relatedTarget as Node|null))pause();};
|
||||
const focusIn=()=>{if(!focused())pause();};
|
||||
const hidden=()=>{if(doc.hidden)pause();};
|
||||
const outside=(e:Event)=>{if(!scope.contains(e.target as Node))pause();};
|
||||
const pointerUp=(e:PointerEvent)=>{if(!closed&&pointers.delete(e.pointerId))publish();};
|
||||
const pointerCancel=(e:PointerEvent)=>{if(pointers.has(e.pointerId))pause();};
|
||||
win.addEventListener('keydown',down,true);win.addEventListener('keyup',up,true);
|
||||
win.addEventListener('blur',blur,true);win.addEventListener('pagehide',stop);
|
||||
doc.addEventListener('focusout',focusOut,true);doc.addEventListener('focusin',focusIn,true);
|
||||
doc.addEventListener('visibilitychange',hidden);doc.addEventListener('pointerdown',outside,true);
|
||||
doc.addEventListener('pointerup',pointerUp,true);doc.addEventListener('pointercancel',pointerCancel,true);
|
||||
doc.addEventListener('contextmenu',pause,true);
|
||||
const timer=win.setInterval(valid,50);
|
||||
return {
|
||||
valid,
|
||||
pointerDown(id:number,code:string){if(valid()){suspended=false;pointers.set(id,code);publish();}},
|
||||
pointerUp(id:number){if(!closed&&pointers.delete(id))publish();},
|
||||
pointerCancel(id:number){if(pointers.has(id))pause();},
|
||||
dispose(){
|
||||
closed=true;clear();callbacks.demand({left:0,right:0});win.clearInterval(timer);
|
||||
win.removeEventListener('keydown',down,true);win.removeEventListener('keyup',up,true);
|
||||
win.removeEventListener('blur',blur,true);win.removeEventListener('pagehide',stop);
|
||||
doc.removeEventListener('focusout',focusOut,true);doc.removeEventListener('focusin',focusIn,true);
|
||||
doc.removeEventListener('visibilitychange',hidden);doc.removeEventListener('pointerdown',outside,true);
|
||||
doc.removeEventListener('pointerup',pointerUp,true);doc.removeEventListener('pointercancel',pointerCancel,true);
|
||||
doc.removeEventListener('contextmenu',pause,true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type RoverMode='arcade'|'tank';
|
||||
export type Demand={left:number;right:number};
|
||||
export const keysFor=(mode:RoverMode)=>mode==='arcade'?['KeyW','KeyA','KeyS','KeyD']:['KeyQ','KeyE','KeyA','KeyD'];
|
||||
export function keyDemand(mode:RoverMode,held:ReadonlySet<string>):Demand {
|
||||
const axis=(positive:string,negative:string)=>Number(held.has(positive))-Number(held.has(negative));
|
||||
if(mode==='tank')return {left:axis('KeyQ','KeyA'),right:axis('KeyE','KeyD')};
|
||||
const forward=axis('KeyW','KeyS'),turn=axis('KeyD','KeyA');
|
||||
const scale=Math.max(1,Math.abs(forward)+Math.abs(turn));
|
||||
return {left:(forward+turn)/scale,right:(forward-turn)/scale};
|
||||
}
|
||||
export const defaultRoverSettings={version:1,mode:'arcade' as RoverMode,currentA:30,maxErpm:2000,model:''};
|
||||
export function roverSettings(value:unknown):typeof defaultRoverSettings {
|
||||
const v=value as Partial<typeof defaultRoverSettings>|null;
|
||||
return {...defaultRoverSettings,...(v?.version===1?{
|
||||
mode:v.mode==='tank'?'tank':'arcade',
|
||||
currentA:typeof v.currentA==='number'&&Number.isFinite(v.currentA)?Math.max(.5,Math.min(30,v.currentA)):30,
|
||||
maxErpm:typeof v.maxErpm==='number'&&Number.isFinite(v.maxErpm)?Math.max(300,Math.min(3000,v.maxErpm)):2000,
|
||||
model:v.model==='dcd006-v020'?v.model:'',
|
||||
}:{} )};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
configurationArchive:{list:(device,before)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations/${encodeURIComponent(version)}`)},
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import type {Demand} from './roverInput';
|
||||
|
||||
export interface RoverReading {
|
||||
id:string;uuid:string;slot:string|null;label:string;age_ms:number;
|
||||
values:{motor_current_a:number;input_current_a:number;input_voltage_v:number;erpm:number;duty:number;mos_temperature_c:number;fault_code:number};
|
||||
}
|
||||
export interface RoverState {
|
||||
fresh:boolean;controlling:boolean;
|
||||
snapshot:{supported?:boolean;instance?:string;state?:string;session_id?:string|null;message?:string|null;devices?:RoverReading[];
|
||||
profile?:{layout:null|'1x1'|'2x2';bindings:Record<string,{device_id:string;uuid:string}>}};
|
||||
}
|
||||
const empty:RoverState={fresh:false,controlling:false,snapshot:{}};
|
||||
async function request<T>(url:string,body?:unknown,keepalive=false):Promise<T>{
|
||||
const response=await fetch(url,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:undefined,
|
||||
body:body?JSON.stringify(body):undefined,cache:'no-store',signal:AbortSignal.timeout(body?350:1000),keepalive});
|
||||
const result=await response.json();
|
||||
if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Канал управления недоступен.');
|
||||
return result;
|
||||
}
|
||||
export function useRoverControl(vehicleID:string,visible:boolean){
|
||||
const url=`/api/v1/fleet/${encodeURIComponent(vehicleID)}/rover`;
|
||||
const [state,setState]=useState<RoverState>(empty),[armed,setArmed]=useState(false),[pending,setPending]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [connecting,setConnecting]=useState(true),armPending=useRef(false);
|
||||
const session=useRef<string|null>(null),seq=useRef(0),demand=useRef<Demand>({left:0,right:0}),sending=useRef(false),generation=useRef(0);
|
||||
const inputGuard=useRef<(()=>boolean)|null>(null);
|
||||
const setInputGuard=useCallback((guard:(()=>boolean)|null)=>{inputGuard.current=guard;},[]);
|
||||
const stop=useCallback(()=>{
|
||||
generation.current++;armPending.current=false;const id=session.current;session.current=null;demand.current={left:0,right:0};setArmed(false);setPending(false);
|
||||
if(id)void request(url+'/command',{session_id:id,sequence:++seq.current,left:0,right:0,stop:true},true).catch(()=>{});
|
||||
},[url]);
|
||||
const send=useCallback(async()=>{
|
||||
const id=session.current;if(!id)return;
|
||||
// Check on every heartbeat, even when a previous request is still pending.
|
||||
const inputValid=(demand.current.left===0&&demand.current.right===0)||inputGuard.current?.();
|
||||
if(document.hidden||!document.hasFocus()||!inputValid)demand.current={left:0,right:0};
|
||||
if(sending.current)return;
|
||||
sending.current=true;
|
||||
try{await request(url+'/command',{session_id:id,sequence:++seq.current,...demand.current,stop:false});}
|
||||
catch(e){if(session.current===id){stop();setError(e instanceof Error?e.message:'Команда не подтверждена.');}}
|
||||
finally{sending.current=false;}
|
||||
},[url,stop]);
|
||||
const setDemand=useCallback((next:Demand)=>{demand.current=next;void send();},[send]);
|
||||
const pauseInput=useCallback(()=>{demand.current={left:0,right:0};void send();},[send]);
|
||||
const arm=useCallback(async(currentA:number,maxErpm:number)=>{
|
||||
if(session.current||armPending.current)return false;const g=++generation.current;armPending.current=true;setError(null);setPending(true);
|
||||
try{
|
||||
const result=await request<{session_id:string}>(url+'/arm',{standstill_confirmed:true,current_a:currentA,max_erpm:maxErpm});
|
||||
if(g!==generation.current){void request(url+'/command',{session_id:result.session_id,sequence:1,left:0,right:0,stop:true},true).catch(()=>{});return false;}
|
||||
// Retire every read started before this acknowledgement, including reads
|
||||
// begun while POST /arm was pending. They describe the previous authority.
|
||||
generation.current++;armPending.current=false;setPending(false);
|
||||
seq.current=0;demand.current={left:0,right:0};session.current=result.session_id;setArmed(true);void send();
|
||||
return true;
|
||||
}catch(e){if(g===generation.current)setError(e instanceof Error?e.message:'Не удалось включить управление.');return false;}
|
||||
finally{if(g===generation.current){armPending.current=false;setPending(false);}}
|
||||
},[url,send]);
|
||||
useEffect(()=>{
|
||||
if(!visible){stop();setState(empty);setConnecting(false);return;}
|
||||
let alive=true,busy=false,lastFault='';const started=performance.now();setConnecting(true);
|
||||
const poll=async()=>{
|
||||
if(busy)return;busy=true;const before=generation.current;
|
||||
try{const value=await request<RoverState>(url);if(alive&&before===generation.current){setState(value);
|
||||
setConnecting(!value.fresh&&performance.now()-started<2000);
|
||||
if(session.current&&(!value.fresh||!value.controlling)){stop();}
|
||||
const fault=value.snapshot.state==='fault'?value.snapshot.message??'':'';
|
||||
if(fault&&lastFault!==fault){lastFault=fault;setError(fault);}
|
||||
}}catch{if(alive&&before===generation.current){setState(empty);setConnecting(false);if(session.current)stop();}}finally{busy=false;}
|
||||
};
|
||||
void poll();const timer=setInterval(()=>void poll(),200),heartbeat=setInterval(()=>void send(),100);
|
||||
const blur=()=>pauseInput(),hidden=()=>{if(document.hidden)pauseInput();};
|
||||
window.addEventListener('blur',blur);window.addEventListener('pagehide',stop);document.addEventListener('visibilitychange',hidden);
|
||||
return()=>{alive=false;clearInterval(timer);clearInterval(heartbeat);window.removeEventListener('blur',blur);window.removeEventListener('pagehide',stop);document.removeEventListener('visibilitychange',hidden);stop();};
|
||||
},[url,visible,stop,send,pauseInput]);
|
||||
const ready=armed&&state.fresh&&state.snapshot.session_id===session.current&&['ready','driving'].includes(state.snapshot.state??'');
|
||||
const canArm=state.fresh&&state.snapshot.supported===true&&!state.controlling&&!['preparing','ready','driving','stopping'].includes(state.snapshot.state??'');
|
||||
return {state,armed,pending,error,connecting,canArm,arm,stop,pauseInput,setDemand,setInputGuard,ready,clearError:()=>setError(null)};
|
||||
}
|
||||
export type RoverController=ReturnType<typeof useRoverControl>;
|
||||
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "missions":
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {useMemo} from 'react';
|
||||
import {useMemo,type ReactNode} from 'react';
|
||||
import {boardLayout} from '../../core/fleet/boardLayout';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
export function VehicleSensors({vehicleID,enabled,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} board={{layout,computer,description}}/>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "
|
||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
||||
|
||||
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
|
||||
export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
@@ -20,7 +20,6 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
@@ -44,25 +43,24 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
|
||||
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
<div><Button onClick={() => {setSelected(null);}}>К списку аппаратов</Button></div>
|
||||
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
</SettingsCard>}/>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
|
||||
@@ -11,8 +11,11 @@ import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
import {RoverView} from '../../../components/rover/RoverView';
|
||||
import {RoverTelemetry} from '../../../components/rover/RoverTelemetry';
|
||||
import {useRoverControl} from '../../../core/fleet/useRoverControl';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back,configure,headerToolsHost}:{vehicleID:string;name:string;enabled:boolean;back:()=>void;configure:()=>void;headerToolsHost?:HTMLElement|null}){
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
@@ -27,17 +30,20 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}});
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
|
||||
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
@@ -51,7 +57,7 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
@@ -63,6 +69,8 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
||||
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
|
||||
@@ -11,6 +11,8 @@ export function ObservationMount({element,className='observation-mount'}:{elemen
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
|
||||
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
|
||||
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const code=ts.transpileModule(readFileSync(new URL('../../../packages/sensor-ui/src/boardLayout.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {createBoardLayoutStore,defaultBoardLayout}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const tick=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
function server(){
|
||||
let value=structuredClone(defaultBoardLayout);const calls=[];
|
||||
return {calls,read:async()=>structuredClone(value),patch:async(section,open)=>{
|
||||
calls.push({section,open});await tick();
|
||||
value={...value,revision:value.revision+1,open_sections:defaultBoardLayout.open_sections.filter(id=>id===section?open:value.open_sections.includes(id))};
|
||||
return structuredClone(value);
|
||||
}};
|
||||
}
|
||||
test('rapid toggles persist after navigation and preserve all-closed state',async()=>{
|
||||
const api=server();const store=createBoardLayoutStore(api);await store.load();
|
||||
const unsubscribe=store.subscribe(()=>{});
|
||||
store.change(['settings','devices']);store.change(['devices']);store.change([]);
|
||||
unsubscribe();
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
const reopened=createBoardLayoutStore(api);await reopened.load();
|
||||
assert.deepEqual(reopened.getSnapshot().value.open_sections,[]);
|
||||
assert.equal(api.calls.length,3);
|
||||
});
|
||||
test('a later toggle wins while an older patch is still in flight',async()=>{
|
||||
const api=server();const store=createBoardLayoutStore(api);await store.load();
|
||||
store.change(['settings','devices']);store.change(['computer','settings','devices']);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.deepEqual((await api.read()).open_sections,defaultBoardLayout.open_sections);
|
||||
});
|
||||
test('different vehicles and simultaneous section changes do not clobber one another',async()=>{
|
||||
const api=server(),other=server();const a=createBoardLayoutStore(api),b=createBoardLayoutStore(api),c=createBoardLayoutStore(other);
|
||||
await Promise.all([a.load(),b.load(),c.load()]);
|
||||
a.change(['settings','devices']);b.change(['computer','devices']);
|
||||
while(a.getSnapshot().saving||b.getSnapshot().saving)await tick();
|
||||
assert.deepEqual((await api.read()).open_sections,['devices']);
|
||||
assert.deepEqual(c.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
|
||||
});
|
||||
test('failed save reports failure, rolls back, and explicit reload recovers',async()=>{
|
||||
const api=server();let fail=true;
|
||||
const store=createBoardLayoutStore({...api,patch:(...args)=>fail?Promise.reject(new Error('offline')):api.patch(...args)});
|
||||
await store.load();store.change([]);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.ok(store.getSnapshot().error);
|
||||
assert.deepEqual(store.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
|
||||
fail=false;await store.load();store.change(['devices']);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.equal(store.getSnapshot().error,null);
|
||||
assert.deepEqual((await api.read()).open_sections,['devices']);
|
||||
});
|
||||
test('invalid layout cannot overwrite stored preferences',async()=>{
|
||||
let writes=0;
|
||||
const store=createBoardLayoutStore({read:async()=>({...defaultBoardLayout,open_sections:['motor-start']}),patch:async()=>{writes++;return defaultBoardLayout;}});
|
||||
await store.load();store.change([]);
|
||||
assert.equal(store.getSnapshot().ready,false);assert.ok(store.getSnapshot().error);assert.equal(writes,0);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
|
||||
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
|
||||
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
|
||||
}}]});
|
||||
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
async function fixture(run){
|
||||
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
|
||||
let focus=true,hidden=false;const calls=[],timers=[];globalThis.roverEffects=[];
|
||||
globalThis.window={addEventListener(){},removeEventListener(){}};
|
||||
globalThis.document={get hidden(){return hidden;},hasFocus:()=>focus,addEventListener(){},removeEventListener(){}};
|
||||
globalThis.setInterval=(f,ms)=>{const timer={f,ms};timers.push(timer);return timer;};globalThis.clearInterval=()=>{};
|
||||
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});return {ok:true,json:async()=>url.endsWith('/arm')?{session_id:'fixture-session'}:{fresh:true,controlling:true,snapshot:{state:'ready'}}};};
|
||||
const c=useRoverControl('fixture',true);const cleanup=globalThis.roverEffects.map(f=>f());
|
||||
try{await flush();await c.arm(30,2000);await flush();await run({c,calls,timers,blur:()=>{focus=false;},focus:()=>{focus=true;},hide:()=>{hidden=true;}});}
|
||||
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
||||
}
|
||||
test('command heartbeat sends neutral on lost blur, keeps session, and accepts new focused input',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();
|
||||
assert.equal(f.calls.at(-1).body.left,1);f.blur();f.timers.find(t=>t.ms===100).f();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.focus();f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
|
||||
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
|
||||
}));
|
||||
test('expired physical input prevents heartbeat from renewing remembered demand',async()=>fixture(async f=>{
|
||||
let valid=true;f.c.setInputGuard(()=>valid);f.c.setDemand({left:1,right:-1});await flush();valid=false;
|
||||
f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.stop,false);
|
||||
}));
|
||||
test('no registered input scope cannot issue motion',async()=>fixture(async f=>{
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.stop,false);
|
||||
assert.equal(f.calls.some(x=>x.body?.left===1),false);
|
||||
}));
|
||||
test('hidden document sends only neutral without a visibility event',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.hide();f.c.setDemand({left:1,right:1});await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.some(x=>x.body?.left===1),false);
|
||||
}));
|
||||
|
||||
test('explicit pause clears demand without revoking session; explicit stop still revokes it',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();f.c.pauseInput();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
|
||||
f.c.stop();await flush();assert.equal(f.calls.at(-1).body.stop,true);
|
||||
const count=f.calls.length;f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.length,count);
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
|
||||
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
|
||||
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
|
||||
}}]});
|
||||
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};};
|
||||
const response=value=>({ok:true,json:async()=>value});
|
||||
const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}};
|
||||
async function fixture(run){
|
||||
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
|
||||
const calls=[],timers=[];globalThis.roverEffects=[];
|
||||
globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}};
|
||||
globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{};
|
||||
let armResponse=null,nextPoll=null;
|
||||
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});
|
||||
if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'});
|
||||
if(!body)return nextPoll?nextPoll.promise:response(idle);
|
||||
return response({accepted_sequence:body.sequence});
|
||||
};
|
||||
const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f());
|
||||
try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});}
|
||||
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
||||
}
|
||||
test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
|
||||
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
|
||||
p.resolve(response(idle));await flush();f.heartbeat();await flush();
|
||||
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session');
|
||||
assert.equal(f.calls.at(-1).body.session_id,'new-session');
|
||||
}));
|
||||
test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
|
||||
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
|
||||
p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush();
|
||||
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session');
|
||||
}));
|
||||
test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{
|
||||
assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,true);
|
||||
}));
|
||||
test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000);
|
||||
a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush();
|
||||
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
|
||||
}));
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/roverHoldInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {bindRoverHoldInput}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
class Events {
|
||||
listeners=new Map();timers=new Map();next=0;
|
||||
addEventListener(type,fn){if(!this.listeners.has(type))this.listeners.set(type,new Set());this.listeners.get(type).add(fn);}
|
||||
removeEventListener(type,fn){this.listeners.get(type)?.delete(fn);}
|
||||
emit(type,detail={}){const e={target:this,isTrusted:true,preventDefault(){},...detail};for(const fn of [...(this.listeners.get(type)??[])])fn(e);}
|
||||
setInterval(fn){const id=++this.next;this.timers.set(id,fn);return id;}
|
||||
clearInterval(id){this.timers.delete(id);}
|
||||
tick(){for(const fn of this.timers.values())fn();}
|
||||
}
|
||||
function fixture(mode='arcade'){
|
||||
let time=0,focus=true;const win=new Events(),doc=new Events();doc.defaultView=win;doc.hidden=false;doc.hasFocus=()=>focus;
|
||||
const scope={ownerDocument:doc,closest:()=>null},child={closest:()=>null};scope.contains=x=>x===scope||x===child;doc.activeElement=scope;
|
||||
const states=[],demands=[];let stops=0,pauses=0;
|
||||
const binding=bindRoverHoldInput(scope,mode,{held:k=>states.push([...k]),demand:d=>demands.push(d),stop:()=>stops++,pause:()=>{pauses++;demands.push({left:0,right:0});}},()=>time);
|
||||
const key=(code,repeat=false)=>win.emit('keydown',{code,repeat,target:scope});
|
||||
return {win,doc,scope,child,binding,states,demands,key,stops:()=>stops,pauses:()=>pauses,advance:ms=>{time+=ms;win.tick();},loseFocus:()=>{focus=false;},restoreFocus:()=>{focus=true;},up:code=>win.emit('keyup',{code,target:scope})};
|
||||
}
|
||||
test('blur clears D, ignores a late repeat, but accepts a fresh press without rearming',()=>{
|
||||
const f=fixture();f.key('KeyD');assert.deepEqual(f.demands.at(-1),{left:1,right:-1});
|
||||
f.win.emit('blur');assert.equal(f.pauses(),1);assert.equal(f.stops(),0);assert.deepEqual(f.states.at(-1),[]);
|
||||
assert.deepEqual(f.demands.at(-1),{left:0,right:0});const count=f.demands.length;
|
||||
f.key('KeyD',true);assert.equal(f.demands.length,count);
|
||||
f.key('KeyW');assert.deepEqual(f.demands.at(-1),{left:1,right:1});assert.equal(f.binding.valid(),true);f.binding.dispose();
|
||||
});
|
||||
test('heartbeat validity catches missing blur event using document focus',()=>{
|
||||
const f=fixture();f.key('KeyD');f.loseFocus();assert.equal(f.binding.valid(),false);assert.equal(f.pauses(),1);
|
||||
f.restoreFocus();assert.equal(f.binding.valid(),true);f.binding.dispose();
|
||||
});
|
||||
test('focus polling catches missing event or moving to another control',()=>{
|
||||
const f=fixture();f.key('KeyW');f.doc.activeElement={};f.advance(50);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('lost keyup AND missing focus notifications still expire repeated D',()=>{
|
||||
const f=fixture();f.key('KeyD');f.advance(500);f.key('KeyD',true);f.advance(299);assert.equal(f.pauses(),0);
|
||||
f.advance(1);assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.key('KeyD',true);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('first press allows OS repeat delay but never an indefinite hold',()=>{
|
||||
const f=fixture();f.key('KeyW');f.advance(999);assert.equal(f.pauses(),0);f.advance(1);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('continuous repeat renews hold; keyup immediately sends neutral',()=>{
|
||||
const f=fixture();f.key('KeyW');f.advance(600);
|
||||
for(let i=0;i<30;i++){f.key('KeyW',true);f.advance(100);}
|
||||
assert.equal(f.pauses(),0);f.up('KeyW');assert.deepEqual(f.demands.at(-1),{left:0,right:0});f.advance(1200);assert.equal(f.pauses(),0);f.binding.dispose();
|
||||
});
|
||||
test('diagonal input survives single-key OS repeat; release recomputes demand',()=>{
|
||||
const f=fixture();f.key('KeyW');f.key('KeyA');
|
||||
for(let i=0;i<20;i++){f.key('KeyA',true);f.advance(100);}
|
||||
assert.equal(f.pauses(),0);assert.deepEqual(f.demands.at(-1),{left:0,right:1});
|
||||
f.up('KeyA');assert.deepEqual(f.demands.at(-1),{left:1,right:1});f.up('KeyW');f.binding.dispose();
|
||||
});
|
||||
test('repeat without a fresh press and untrusted events never command motion',()=>{
|
||||
const f=fixture();f.key('KeyD',true);f.win.emit('keydown',{code:'KeyW',isTrusted:false,target:f.scope});assert.equal(f.demands.length,0);f.binding.dispose();
|
||||
});
|
||||
test('focus inside the view is allowed; leaving or hiding it stops',()=>{
|
||||
for(const event of ['focusout','visibilitychange','pagehide','outside']){
|
||||
const f=fixture();f.key('KeyW');f.doc.emit('focusout',{target:f.scope,relatedTarget:f.child});assert.equal(f.pauses(),0);
|
||||
if(event==='focusout')f.doc.emit(event,{target:f.scope,relatedTarget:null});
|
||||
else if(event==='visibilitychange'){f.doc.hidden=true;f.doc.emit(event);}
|
||||
else if(event==='pagehide')f.win.emit(event);
|
||||
else f.doc.emit('pointerdown',{target:{}});
|
||||
assert.equal(event==='pagehide'?f.stops():f.pauses(),1,event);f.binding.dispose();
|
||||
}
|
||||
});
|
||||
test('pointer capture lost or cancelled releases every input',()=>{
|
||||
for(const mode of ['arcade','tank']){
|
||||
const f=fixture(mode);f.binding.pointerDown(1,mode==='arcade'?'KeyW':'KeyQ');f.binding.pointerCancel(1);
|
||||
assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.binding.dispose();
|
||||
}
|
||||
});
|
||||
test('document pointerup releases even if button handler does not receive it',()=>{
|
||||
const f=fixture();f.binding.pointerDown(1,'KeyD');f.doc.emit('pointerup',{pointerId:1});assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.pauses(),0);f.binding.dispose();
|
||||
});
|
||||
test('modifier shortcuts pause; cleanup removes listeners, timer and held demand',()=>{
|
||||
const f=fixture();f.key('KeyD');f.win.emit('keydown',{code:'Tab',metaKey:true,target:f.scope});assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.win.timers.size,0);
|
||||
assert.equal([...f.win.listeners.values(),...f.doc.listeners.values()].reduce((sum,x)=>sum+x.size,0),0);
|
||||
});
|
||||
|
||||
test('Space and Escape remain explicit session stops',()=>{
|
||||
for(const code of ['Space','Escape']){const f=fixture();f.key('KeyW');f.key(code);assert.equal(f.stops(),1);assert.equal(f.binding.valid(),false);f.binding.dispose();}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../plugins/vesc/frontend/src/model.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {vescLabel,testInput,speedInput,rotationResult,driveTestIds}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const controller={online:true,prepared:true,verified:true,vesc_status:{identity:{uuid:'synthetic'},readable:true}};
|
||||
|
||||
test('speed mode requires board capability and reports measured rotation time',()=>{
|
||||
assert.ok(speedInput('1200',undefined).error);
|
||||
const bounds={min_erpm:300,max_erpm:3000,duration_basis:'measured_speed'};
|
||||
assert.equal(speedInput('1200',bounds).error,null);
|
||||
for(const value of ['', 'NaN','299','3001'])assert.ok(speedInput(value,bounds).error);
|
||||
const text=rotationResult({outcome:'stopped',rotation_s:2.5,release_confirmed:true,limits_restored:true});
|
||||
assert.match(text,/2,5 с/);
|
||||
assert.match(text,/Проверка остановлена/);
|
||||
assert.doesNotMatch(text,/время вращения набрано/);
|
||||
});
|
||||
|
||||
test('USB discovery and model preparation never claim a verified VESC',()=>{
|
||||
assert.equal(vescLabel({...controller,prepared:false},true).label,'Требуется подготовка');
|
||||
assert.equal(vescLabel({...controller,vesc_status:{identity:null,readable:false}},true).tone,'warning');
|
||||
});
|
||||
test('fresh read capability is distinct from unsupported firmware and offline state',()=>{
|
||||
assert.equal(vescLabel(controller,true).label,'Готов к чтению');
|
||||
assert.equal(vescLabel(controller,false).label,'Нет связи');
|
||||
assert.equal(vescLabel({...controller,online:false},true).label,'Нет связи');
|
||||
assert.equal(vescLabel({...controller,vesc_status:{identity:{uuid:'synthetic'},readable:false}},true).label,'Прошивка не поддерживается');
|
||||
});
|
||||
|
||||
test('test fields accept entered values only inside the board capability bounds',()=>{
|
||||
const bounds={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
|
||||
assert.equal(testInput('3.7','7.2',bounds).valid,true);
|
||||
assert.equal(testInput('5','10',bounds).valid,true);
|
||||
for(const [amps,seconds] of [['','5'],['5',''],['60','10'],['5','11'],['NaN','1'],['Infinity','1']])assert.equal(testInput(amps,seconds,bounds).valid,false);
|
||||
assert.equal(testInput('2','1.5',undefined).valid,false);
|
||||
const overlong=testInput('5','15',bounds);
|
||||
assert.equal(overlong.valid,false);
|
||||
assert.equal(overlong.currentError,null);
|
||||
assert.equal(overlong.durationError,'Длительность должна быть от 0,5 до 10 с.');
|
||||
assert.equal(testInput('5','10',bounds).durationError,null);
|
||||
});
|
||||
|
||||
// Capabilities come from the installed board: a newer Core must retain old bounds.
|
||||
test('extended board accepts 30 A / 30 s without changing older board bounds',()=>{
|
||||
const old={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
|
||||
const expanded={...old,max_current_a:30,max_duration_s:30,continuous_current:true};
|
||||
assert.equal(testInput('30','30',expanded).valid,true);
|
||||
assert.equal(testInput('30.1','30',expanded).valid,false);
|
||||
assert.equal(testInput('30','30.1',expanded).valid,false);
|
||||
assert.equal(testInput('30','30',old).valid,false);
|
||||
});
|
||||
|
||||
|
||||
test('group spin requires a complete unique profile containing the selected controller',()=>{
|
||||
const profile={layout:'1x1',revision:3,bindings:{'left.1':{device_id:'left',uuid:'a'},'right.1':{device_id:'right',uuid:'b'}}};
|
||||
assert.deepEqual(driveTestIds(profile,'left'),['left','right']);
|
||||
assert.deepEqual(driveTestIds(profile,'unassigned'),[]);
|
||||
assert.deepEqual(driveTestIds({...profile,bindings:{'left.1':profile.bindings['left.1']}},'left'),[]);
|
||||
assert.deepEqual(driveTestIds({...profile,bindings:{...profile.bindings,'right.1':profile.bindings['left.1']}},'left'),[]);
|
||||
assert.deepEqual(driveTestIds(undefined,'left'),[]);
|
||||
});
|
||||
|
||||
test('four-wheel profile preserves front and rear membership for common testing',()=>{
|
||||
const ids=['lf','lr','rf','rr'];
|
||||
const bindings=Object.fromEntries(['left.1','left.2','right.1','right.2'].map((slot,i)=>[slot,{device_id:ids[i],uuid:ids[i]}]));
|
||||
assert.deepEqual(driveTestIds({layout:'2x2',revision:4,bindings},'rf'),ids);
|
||||
});
|
||||
@@ -307,6 +307,9 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
payload["devices"] = inv["items"]
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
if batch := p.Sensors.ConfigurationBatch(binding.BindingID); batch != nil {
|
||||
payload["vesc_configurations"] = batch
|
||||
}
|
||||
}
|
||||
if p.DeviceEnrollment != nil {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
@@ -337,6 +340,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
if p.Sensors != nil {
|
||||
p.Sensors.AcknowledgeConfigurations(binding.BindingID, result["vesc_configurations_ack"], payload["vesc_configurations"])
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
p.Sensors.Acknowledge(ack)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Sensors) vescArchive(path string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
for i := range sensorModels {
|
||||
if sensorModels[i].ID == "vesc.controller" {
|
||||
return s.modelDriver(ctx, &sensorModels[i], path, nil)
|
||||
}
|
||||
}
|
||||
panic("shipped VESC model is missing")
|
||||
}
|
||||
|
||||
func archiveCursorName(binding string) string {
|
||||
return "vesc-archive-" + digest(binding)[:24] + ".cursor"
|
||||
}
|
||||
|
||||
// Each pairing receives the complete immutable history, including backups
|
||||
// made locally and while Core was offline. ACK follows durable Core storage.
|
||||
func (s *Sensors) ConfigurationBatch(binding string) map[string]any {
|
||||
var after int64
|
||||
data, _ := os.ReadFile(filepath.Join(s.root, archiveCursorName(binding)))
|
||||
if json.Unmarshal(data, &after) != nil || after < 0 {
|
||||
after = 0
|
||||
}
|
||||
result, err := s.vescArchive("/archive-export?after=" + strconv.FormatInt(after, 10))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Sensors) AcknowledgeConfigurations(binding string, ack json.RawMessage, batch any) {
|
||||
value, ok := batch.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var sequence int64
|
||||
if json.Unmarshal(ack, &sequence) != nil || sequence < 0 {
|
||||
return
|
||||
}
|
||||
next, ok := value["next"].(float64)
|
||||
if !ok || next != float64(sequence) {
|
||||
return
|
||||
}
|
||||
// A failed cursor write causes replay; immutable archive insertion deduplicates it.
|
||||
_ = s.write(archiveCursorName(binding), sequence)
|
||||
}
|
||||
|
||||
func (s *Sensors) configurationRoutes(mux *http.ServeMux, server *Server) {
|
||||
for _, route := range []string{"GET /api/device-configurations/{device}", "GET /api/device-configurations/{device}/{version}"} {
|
||||
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
device := r.PathValue("device")
|
||||
model := modelForDevice(device)
|
||||
if model == nil || model.ID != "vesc.controller" {
|
||||
reply(w, 404, map[string]string{"error": "История не найдена"})
|
||||
return
|
||||
}
|
||||
path := "/archives/" + url.PathEscape(device)
|
||||
if version := r.PathValue("version"); version != "" {
|
||||
path += "/" + url.PathEscape(version)
|
||||
} else {
|
||||
path += "?before=" + url.QueryEscape(r.URL.Query().Get("before"))
|
||||
}
|
||||
result, err := s.vescArchive(path)
|
||||
if err != nil {
|
||||
reply(w, 503, map[string]string{"error": "Архив конфигураций недоступен"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVESCArchiveAckRequiresExactBatchAndPairingScope(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
batch := map[string]any{"next": float64(9)}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`10`), batch)
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-a"))); !os.IsNotExist(err) {
|
||||
t.Fatal("incorrect ACK persisted")
|
||||
}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`9`), batch)
|
||||
data, err := os.ReadFile(filepath.Join(s.root, archiveCursorName("pair-a")))
|
||||
if err != nil || string(data) != "9" {
|
||||
t.Fatalf("durable cursor: %q %v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-b"))); !os.IsNotExist(err) {
|
||||
t.Fatal("history skipped on another pairing")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type sensorModel struct {
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
ActionTimeouts map[string]time.Duration
|
||||
// This model may prepare an attachment only to read its protocol identity.
|
||||
ProtocolIdentity bool
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
@@ -44,6 +46,11 @@ var sensorModels = []sensorModel{
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second},
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")},
|
||||
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.6.3",
|
||||
Vendor: "0483", Product: "5740", USBName: "ChibiOS/RT Virtual COM Port", Socket: "/run/mission-core-vesc/driver.sock",
|
||||
PrepareUnit: "mission-core-node-vesc-prepare.service", Report: "/var/lib/mission-core-node-profiles/vesc/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"vesc.link.check": 45 * time.Second, "vesc.motor.run": 90 * time.Second, "vesc.drive.run": 120 * time.Second, "vesc.hall.measure": 60 * time.Second, "vesc.foc.calibrate": 300 * time.Second, "vesc.motor.pulse": 60 * time.Second, "vesc.control.release": 60 * time.Second},
|
||||
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
@@ -74,6 +81,22 @@ func modelDeviceID(model *sensorModel, serial string) string {
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func preparedDeviceID(command SensorCommand, result any) string {
|
||||
model := modelForDevice(command.Session.DeviceID)
|
||||
if model == nil {
|
||||
return ""
|
||||
}
|
||||
if !model.ProtocolIdentity {
|
||||
return command.Session.DeviceID
|
||||
}
|
||||
if value, ok := result.(map[string]any); ok {
|
||||
if id, ok := value["device_id"].(string); ok && modelForDevice(id) == model {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
@@ -110,7 +133,7 @@ func discoverSensors(root string) []usbSensor {
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
if item.model.ProtocolIdentity || !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *pro
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранного устройства", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
@@ -217,18 +217,20 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
return nil, errors.New("Устройство переподключено во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
matches := item["id"] == c.Session.DeviceID || (model.ProtocolIdentity && item["attachment_id"] == c.Session.DeviceID)
|
||||
if !matches || item["online"] != true || item["prepared"] != true || (model.ProtocolIdentity && item["verified"] != true) {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.DeviceID, _ = item["id"].(string)
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс устройства.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
@@ -237,7 +239,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
message = "Не удалось проверить выбранное устройство."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
@@ -249,7 +251,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
return nil, errors.New("Драйвер установлен, но устройство не ответило. Проверьте питание и USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
|
||||
t.Helper()
|
||||
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
|
||||
for key, value := range map[string]string{"idVendor": "0483", "idProduct": "5740"} {
|
||||
if err := os.WriteFile(filepath.Join(s.usbRoot, port, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCProvisionalIdentityIsReadOnlyAndDoesNotWeakenCameras(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
fakeVESC(t, s, "1-3", "3")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatal("both controllers must be visible")
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if item["initializable"] != true || item["prepared"] != false {
|
||||
t.Fatal("identity bootstrap unavailable or falsely prepared")
|
||||
}
|
||||
c := sensorTestCommand()
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
for _, action := range []string{"start", "stop", "option", "settings.apply", "firmware.write"} {
|
||||
c.Action = action
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("VESC acquired write authority", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items[0].(map[string]any)["id"] == items[1].(map[string]any)["id"] {
|
||||
t.Fatal("duplicate USB serial collapsed")
|
||||
}
|
||||
fakeUSB(t, s.usbRoot, "2-2", "same", "Insta360 X4", "4")
|
||||
fakeUSB(t, s.usbRoot, "2-3", "same", "Insta360 X4", "5")
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["kind"] == "insta360.x4" && item["initializable"] != false {
|
||||
t.Fatal("camera guard weakened")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCPreparePromotesAttachmentToProtocolUUID(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
first := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
attachment := first["id"].(string)
|
||||
model := modelForDevice(attachment)
|
||||
stable := modelDeviceID(model, "uuid:synthetic-controller")
|
||||
var installed atomic.Bool
|
||||
s.runPreparation = func(_ context.Context, unit string) error {
|
||||
if unit != "mission-core-node-vesc-prepare.service" {
|
||||
t.Error("wrong profile")
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
item := s.discovery(stable, "12", true)
|
||||
item["prepared"] = true
|
||||
item["verified"] = true
|
||||
item["configured"] = true
|
||||
item["attachment_id"] = attachment
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "protocol_session"
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action != "verify" || command.Session.DeviceID != stable || command.Session.SessionID != "protocol_session" {
|
||||
t.Error("verification did not follow protocol identity")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": stable}}), nil
|
||||
})
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: attachment, SessionID: sensorSessionID(first)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "complete" {
|
||||
t.Fatal(s.Get(c.ID))
|
||||
}
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["id"] != stable {
|
||||
t.Fatal("provisional row survived promotion")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[stable], s.initialized[attachment]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("initialization persisted transport identity")
|
||||
}
|
||||
// A stale runtime attachment is never presented as a currently connected controller.
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "1-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items = s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["online"] != false {
|
||||
t.Fatal("unplugged driver observation accepted")
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,9 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
}
|
||||
for _, op := range s.operations {
|
||||
if op.Command.Action == "prepare" && op.State == "complete" {
|
||||
s.initialized[op.Command.Session.DeviceID] = true
|
||||
if id := preparedDeviceID(op.Command, op.Result); id != "" {
|
||||
s.initialized[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
@@ -192,7 +194,7 @@ func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path stri
|
||||
}
|
||||
response, e := client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
return nil, errors.New("Служба устройства недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
@@ -241,8 +243,24 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
if model.ProtocolIdentity {
|
||||
if !currentCameraSnapshot(item, model) {
|
||||
continue
|
||||
}
|
||||
attachment, _ := item["attachment_id"].(string)
|
||||
matched := false
|
||||
for _, candidate := range usb {
|
||||
if candidate.model == model && candidate.id == attachment {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seen[attachment] = true
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
if model.PrepareUnit != "" && !model.ProtocolIdentity {
|
||||
item["configured"] = s.initialized[id]
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
@@ -325,13 +343,18 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
// VESC bootstrap grants only a fixed identity read on the selected attachment.
|
||||
// This does not promote the attachment to stable identity or allow motor control.
|
||||
if online && model.ProtocolIdentity {
|
||||
initializable = true
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.config.backup"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
@@ -365,10 +388,10 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||
}
|
||||
}
|
||||
@@ -411,11 +434,11 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
err = errors.New("Устройство не обнаружено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
err = errors.New("Устройство отключено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
err = errors.New("Не удалось однозначно определить устройство. Проверьте его идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
@@ -451,7 +474,12 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err == nil && c.Action == "prepare" {
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
initializedID := preparedDeviceID(c, result)
|
||||
if initializedID == "" {
|
||||
err = errors.New("Драйвер не подтвердил личность устройства.")
|
||||
} else {
|
||||
s.initialized[initializedID] = true
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
err = e
|
||||
uncertain = true
|
||||
@@ -496,6 +524,7 @@ func (s *Sensors) RemoteResults() []any {
|
||||
return out
|
||||
}
|
||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
||||
s.configurationRoutes(mux, server)
|
||||
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
|
||||
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
if server.authorized(w, r) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-vesc-prepare.service" || unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
@@ -36,6 +36,7 @@ def provenance():
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||
"vesc_plugin_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/vesc").rglob("*")) if p.is_file() and not any(x in p.relative_to(ROOT.parents[1]).parts for x in ("__pycache__", "build"))},
|
||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BINARY_VERSION = "0.8.21"
|
||||
VERSION = "0.8.21-3"
|
||||
BINARY_VERSION = "0.8.35"
|
||||
VERSION = "0.8.35-1"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
@@ -44,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -111,6 +111,8 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
import runpy
|
||||
files.extend(runpy.run_path(str(ROOT.parents[1] / "plugins/vesc/packaging/payload.py"))["payload"]())
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def files(root):
|
||||
@@ -73,6 +73,13 @@ def build(qualified, node_only=False):
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
native_root = REPO / "plugins/vesc"
|
||||
native = json.loads((native_root / "packaging/native-runtime.json").read_text())
|
||||
native_path = native_root / "build/native-runtime" / native["file"]
|
||||
native_bytes = native_path.read_bytes()
|
||||
if len(native_bytes) != native["bytes"] or hashlib.sha256(native_bytes).hexdigest() != native["sha256"]:
|
||||
raise ValueError("Qualified VESC Tool runtime changed")
|
||||
entries[REPO.name + "/plugins/vesc/build/native-runtime/" + native["file"]] = native_path
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
|
||||
@@ -5,7 +5,7 @@ mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных драйверов на этом Ubuntu-компьютере.' 'Подготовка VESC и X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log"
|
||||
mc_node_install_result=${PIPESTATUS[0]}
|
||||
|
||||
@@ -351,7 +351,7 @@ def main():
|
||||
if report["state"] != "complete":
|
||||
raise RuntimeError(report["error"])
|
||||
print(
|
||||
"Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.",
|
||||
"Mission Core Node обновлён. VESC и X4 можно подготовить из списка устройств в Node или Core.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ def main():
|
||||
sys.path.insert(0, str(node / "packaging"))
|
||||
from build_deb import BINARY_VERSION, VERSION, build
|
||||
|
||||
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
|
||||
binary = node / "build/node-agent-linux-amd64"
|
||||
run(
|
||||
"node-binary",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-vesc/clear_runtime_cache.py
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
@@ -15,6 +16,9 @@ case "$1" in
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
if [ -f /var/lib/mission-core-node-profiles/vesc/preparation.json ]; then
|
||||
systemctl start mission-core-node-vesc-prepare.service
|
||||
fi
|
||||
if [ -f /run/mission-core-node-k1-upgrade-active ]; then
|
||||
# A jointly upgraded plugin starts itself after its own configuration.
|
||||
# Restore it here only when that package is already configured.
|
||||
|
||||
@@ -18,6 +18,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -38,6 +42,7 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
(umask 077; : > /run/mission-core-node-k1-upgrade-active)
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
systemctl stop mission-core-vesc.service 2>/dev/null || true
|
||||
systemctl stop mission-core-node-monitor.service 2>/dev/null || true
|
||||
fi
|
||||
. /etc/os-release
|
||||
|
||||
@@ -17,6 +17,10 @@ if [ -d /run/systemd/system ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -30,8 +34,19 @@ if [ -d /run/systemd/system ]; then
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
upgrade|remove|deconfigure)
|
||||
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
|
||||
systemctl disable --now mission-core-vesc.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
if cmp -s /usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules /etc/udev/rules.d/70-mission-core-vesc.rules; then
|
||||
rm /etc/udev/rules.d/70-mission-core-vesc.rules
|
||||
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
||||
fi
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
const configurationArchive:NonNullable<SensorTransport['configurationArchive']>={list:(device,before)=>request(`/api/device-configurations/${encodeURIComponent(device)}${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>request(`/api/device-configurations/${encodeURIComponent(device)}/${encodeURIComponent(version)}`)};
|
||||
const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
Reference in New Issue
Block a user