feat(control-station): add rover scene telemetry and guarded keyboard control
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 === "datasets" ? (
|
||||
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : 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,157 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
async function moduleAt(path) {
|
||||
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
|
||||
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
}
|
||||
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
|
||||
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
|
||||
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
|
||||
const profile={...base,deadband:0};
|
||||
const arcade={...profile,mode:'arcade'};
|
||||
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
|
||||
const motors=['a','b','c','d'];
|
||||
function frame(now,values=axes(),extra={}) {
|
||||
return {now,link:{state:'live',at:now},
|
||||
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
|
||||
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
|
||||
}
|
||||
function ready(p=base) {
|
||||
const model=new AuthorityModel(p,motors,policy,'boot-A');
|
||||
for(let t=0;t<=200;t+=50)model.step(frame(t));
|
||||
return model;
|
||||
}
|
||||
function core() {
|
||||
const model=ready();
|
||||
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
|
||||
assert.ok(token);return {model,token};
|
||||
}
|
||||
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
|
||||
|
||||
test('tank keeps the two motor sides independent, including reversal',()=>{
|
||||
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
|
||||
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
|
||||
});
|
||||
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
|
||||
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
|
||||
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
|
||||
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
|
||||
});
|
||||
test('selected stick defines the axes, not the number or position of motors',()=>{
|
||||
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
|
||||
});
|
||||
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
|
||||
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
|
||||
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
|
||||
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
|
||||
});
|
||||
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
|
||||
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
|
||||
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
|
||||
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
|
||||
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
|
||||
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
|
||||
}
|
||||
});
|
||||
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
|
||||
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
|
||||
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
|
||||
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
|
||||
});
|
||||
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
|
||||
for(const n of [2,4,10]){
|
||||
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
|
||||
const result=motorDemands({left:.3,right:-.6},bindings);
|
||||
assert.equal(Object.keys(result).length,n);
|
||||
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
|
||||
}
|
||||
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
|
||||
assert.throws(()=>motorDemands({left:1,right:1},[b]));
|
||||
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
|
||||
});
|
||||
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
|
||||
const model=new AuthorityModel(base,motors,policy,'boot');
|
||||
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
|
||||
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
|
||||
assert.equal(model.step(frame(1200)).state,'rc-ready');
|
||||
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
|
||||
});
|
||||
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
|
||||
const {model,token}=core();
|
||||
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
|
||||
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
|
||||
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
|
||||
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
|
||||
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
|
||||
const second=model.step(frame(1750,axes(.5)));
|
||||
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
|
||||
assert.equal(model.step(frame(1800)).state,'rc-manual');
|
||||
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
|
||||
});
|
||||
test('neutral of one channel or a single moving member never completes rearming',()=>{
|
||||
for(const kind of ['axis','drive']){
|
||||
const {model}=core();model.step(frame(300,axes(1)));
|
||||
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
|
||||
}
|
||||
});
|
||||
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
|
||||
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
|
||||
});
|
||||
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
|
||||
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
|
||||
});
|
||||
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
|
||||
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
|
||||
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
|
||||
}
|
||||
});
|
||||
test('one repeated neutral sample cannot qualify stable neutral',()=>{
|
||||
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
|
||||
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
|
||||
assert.equal(model.step(f).state,'hold');
|
||||
});
|
||||
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
|
||||
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
|
||||
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
|
||||
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
|
||||
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
|
||||
});
|
||||
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
|
||||
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
|
||||
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
|
||||
}
|
||||
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
|
||||
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
|
||||
});
|
||||
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
|
||||
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
|
||||
});
|
||||
test('premature or replayed Core request does not acquire authority after neutral',()=>{
|
||||
const model=new AuthorityModel(base,motors,policy,'boot');
|
||||
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
|
||||
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
|
||||
});
|
||||
test('reboot cannot restore authority from serialized profile or old grant',()=>{
|
||||
const {token}=core(), model=ready();
|
||||
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
|
||||
const next=new AuthorityModel(base,motors,policy,'boot-B');
|
||||
for(let t=0;t<=200;t+=50)next.step(frame(t));
|
||||
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
|
||||
assert.notEqual(grant.token,token);
|
||||
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
|
||||
});
|
||||
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
|
||||
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
|
||||
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
|
||||
});
|
||||
|
||||
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
|
||||
const model=ready({...base,mode:'arcade',stick:'right'});
|
||||
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
|
||||
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
|
||||
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
|
||||
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
|
||||
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
|
||||
});
|
||||
@@ -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,23 @@
|
||||
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/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
|
||||
test('arcade mixes directions and opposite keys cancel',()=>{
|
||||
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
|
||||
});
|
||||
test('tank sides and backwards remain independent',()=>{
|
||||
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
|
||||
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
|
||||
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
|
||||
});
|
||||
test('invalid stored settings never grant motion or invalid limits',()=>{
|
||||
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
|
||||
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
|
||||
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
|
||||
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {resolve,join} from 'node:path';
|
||||
|
||||
const root=fileURLToPath(new URL('.',import.meta.url));
|
||||
const destination=process.argv[2];
|
||||
if(!destination)throw Error('Pass an artifact output directory; no server is started.');
|
||||
const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true,
|
||||
format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'},
|
||||
alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')},
|
||||
nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'});
|
||||
const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('</script','<\\/script');
|
||||
const css=result.outputFiles.find(f=>f.path.endsWith('.css')).text.replaceAll('</style','<\\/style');
|
||||
await mkdir(destination,{recursive:true});
|
||||
const html=`<!doctype html><html lang="ru" data-nodedc-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"><title>Mission Core · Профили управления · Прототип</title><style>${css}</style></head><body data-nodedc-ui><div id="root"></div><script>${js}</script></body></html>`;
|
||||
await writeFile(join(destination,'rover-control-preview.html'),html);
|
||||
console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import {useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import '@nodedc/ui-core/styles.css';
|
||||
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
|
||||
import {trace} from './trace';
|
||||
import './preview.css';
|
||||
|
||||
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
|
||||
const percent=(n:number)=>`${Math.round(n*100)}%`;
|
||||
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
|
||||
const storageKey='missioncore.rover-control-preview.v1';
|
||||
function load():ControlProfile {
|
||||
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
|
||||
catch{return {...defaultProfile};}
|
||||
}
|
||||
function App(){
|
||||
const [profile,setProfile]=useState(load);
|
||||
const [axes,setAxes]=useState<Axes>(emptyAxes);
|
||||
const [open,setOpen]=useState(['settings','preview']);
|
||||
const [saved,setSaved]=useState('');
|
||||
const [light,setLight]=useState(false);
|
||||
const change=(patch:Partial<ControlProfile>)=>{
|
||||
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
|
||||
};
|
||||
const result=mix(profile,axes);
|
||||
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
|
||||
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
|
||||
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
|
||||
function save(){
|
||||
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
|
||||
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
|
||||
}
|
||||
function download(){
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
|
||||
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
return <main data-nodedc-ui>
|
||||
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
|
||||
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
|
||||
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
|
||||
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
|
||||
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
|
||||
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
|
||||
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
|
||||
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
|
||||
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
|
||||
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
|
||||
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
|
||||
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
|
||||
{saved&&<p role="status">{saved}</p>}
|
||||
</SettingsCard>},
|
||||
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
|
||||
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
|
||||
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
|
||||
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
|
||||
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
|
||||
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
|
||||
</SettingsCard>},
|
||||
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
|
||||
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
|
||||
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
|
||||
</SettingsCard>},
|
||||
]}/>
|
||||
</main>;
|
||||
}
|
||||
document.documentElement.dataset.nodedcTheme='dark';
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Domain composition only; all controls come from the Design Guideline. */
|
||||
body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); }
|
||||
main { max-width:960px; margin:0 auto; padding:32px 24px 64px; }
|
||||
main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
|
||||
h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; }
|
||||
p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); }
|
||||
main>header p { font-size:var(--nodedc-font-size-xs); }
|
||||
.notice { margin-bottom:24px; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; }
|
||||
.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; }
|
||||
.demands>div { display:flex; flex-direction:column; gap:8px; }
|
||||
.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; }
|
||||
.demands span { font-size:var(--nodedc-font-size-sm); }
|
||||
.trace { display:grid; gap:20px; }
|
||||
.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); }
|
||||
@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} }
|
||||
@@ -0,0 +1,38 @@
|
||||
import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority';
|
||||
import {type ControlProfile} from '../../../../packages/rover-control/src/profile';
|
||||
|
||||
/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */
|
||||
export function trace(profile: ControlProfile): {label: string; decision: Decision}[] {
|
||||
const model = new AuthorityModel(profile, ['left', 'right'], {
|
||||
maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'],
|
||||
}, 'preview');
|
||||
const rows: {label:string;decision:Decision}[] = [];
|
||||
let now = -50, token = '';
|
||||
function step(value=0, link: 'live'|'lost'='live', core=false, request=false) {
|
||||
now+=50;
|
||||
const axes = {leftY:0,rightY:0,leftX:0,rightX:0};
|
||||
axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value;
|
||||
return model.step({now, link:{state:link,at:now},
|
||||
axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])),
|
||||
drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}},
|
||||
requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined,
|
||||
command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined,
|
||||
});
|
||||
}
|
||||
const add=(label:string,decision:Decision)=>rows.push({label,decision});
|
||||
for(let i=0;i<5;i++)step();
|
||||
const grant=step(0,'live',false,true);token=grant.token??'';
|
||||
add('Mission Core получил явный допуск',grant);
|
||||
add('Автономная команда движения',step(0,'live',true));
|
||||
add('Первое отклонение рычага',step(.8,'live',true));
|
||||
for(let i=0;i<20;i++)step(.8);
|
||||
add('Тот же рычаг удерживается',step(.8));
|
||||
add('Остановка подтверждена, оба рычага отпущены',step());
|
||||
for(let i=0;i<3;i++)step();
|
||||
add('Непрерывная нейтраль подтверждена',step());
|
||||
add('Второе отклонение — ручное движение',step(.8));
|
||||
add('Запоздавшая команда прежней задачи',step(.8,'live',true));
|
||||
add('Радиосвязь потеряна',step(0,'lost'));
|
||||
add('Связь вернулась с отклонённым рычагом',step(.8));
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends":"../../tsconfig.app.json",
|
||||
"compilerOptions":{"incremental":false,"tsBuildInfoFile":null},
|
||||
"include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user