feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -11,3 +11,4 @@
|
||||
*.lcc binary
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -49,3 +49,10 @@ mqtt.summary.json
|
||||
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
|
||||
!tests/fixtures/**/*.pcap
|
||||
!tests/fixtures/**/*.pcapng
|
||||
|
||||
# Dependency caches may be links into an existing local checkout.
|
||||
.venv
|
||||
node_modules
|
||||
|
||||
# Private experiment evidence and generated build archives.
|
||||
/outputs/
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source": "DCD-006_rover_v020.blend",
|
||||
"source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79",
|
||||
"model": "DCD-006 v020 complete rover",
|
||||
"source_object_count": 1426,
|
||||
"render_mesh_count": 1,
|
||||
"original_bounds": [
|
||||
[
|
||||
-0.527999997138977,
|
||||
-0.45249998569488525,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.6350000500679016,
|
||||
0.45249998569488525,
|
||||
0.734000027179718
|
||||
]
|
||||
],
|
||||
"dimensions_m": [
|
||||
1.1630001068115234,
|
||||
0.9049999713897705,
|
||||
0.734000027179718
|
||||
],
|
||||
"translation_m": [
|
||||
-0.05350002646446228,
|
||||
-0.0,
|
||||
-0.0
|
||||
],
|
||||
"glb_bytes": 54960576,
|
||||
"glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79",
|
||||
"material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e",
|
||||
"material_rules": {
|
||||
"metal": 1,
|
||||
"rubber": 2,
|
||||
"steel": 6,
|
||||
"paint": 3
|
||||
},
|
||||
"source_saved": false
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
@@ -822,7 +822,7 @@ export default function App() {
|
||||
/>
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {RoverState} from '../../core/fleet/useRoverControl';
|
||||
import {drivePositions} from '../../../../../plugins/vesc/frontend/src/model';
|
||||
const number=(v:number|undefined,digits=1)=>typeof v==='number'&&Number.isFinite(v)?v.toLocaleString('ru-RU',{maximumFractionDigits:digits}):'—';
|
||||
export function RoverTelemetry({state}:{state:RoverState}){
|
||||
const devices=state.snapshot.devices??[],profile=state.snapshot.profile;
|
||||
const positions=drivePositions(profile?.layout??null);
|
||||
const assigned=Object.entries(profile?.bindings??{}).map(([slot,binding])=>({
|
||||
id:binding.device_id,uuid:binding.uuid,label:positions[slot]??slot,
|
||||
reading:devices.find(d=>d.id===binding.device_id&&d.uuid===binding.uuid),
|
||||
}));
|
||||
const readings=[...assigned,...devices.filter(d=>!assigned.some(a=>a.id===d.id&&a.uuid===d.uuid)).map(d=>({...d,reading:d}))];
|
||||
return <div className="rover-telemetry">
|
||||
{!state.fresh?<p>Нет свежих показаний VESC.</p>:!readings.length?<p>Получение показаний VESC…</p>:readings.map(d=>{
|
||||
const fresh=!!d.reading&&d.reading.age_ms<1000,v=fresh?d.reading?.values:undefined;
|
||||
return <section key={d.uuid}><strong>{d.label}</strong><span>VESC {d.uuid.slice(0,6).toUpperCase()}{!d.reading?' · Нет связи':!fresh?' · Нет свежих данных':''}</span>
|
||||
<dl><div><dt>Ток мотора</dt><dd>{number(v?.motor_current_a)} А</dd></div>
|
||||
<div><dt>Ток батареи</dt><dd>{number(v?.input_current_a)} А</dd></div>
|
||||
<div><dt>Напряжение</dt><dd>{number(v?.input_voltage_v)} В</dd></div>
|
||||
<div><dt>Обороты, ERPM</dt><dd>{number(v?.erpm,0)}</dd></div>
|
||||
<div><dt>Контроллер</dt><dd>{number(v?.mos_temperature_c)} °C</dd></div>
|
||||
<div><dt>Ошибка VESC</dt><dd>{v?v.fault_code===0?'Нет':String(v.fault_code):'—'}</dd></div></dl>
|
||||
</section>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,Icon,IconButton,KeyButton,LoadingRegion,Select,StatusBadge,Switch,TextField,ToastStack,Window} from '@nodedc/ui-react';
|
||||
import {keysFor,roverSettings,type RoverMode} from '../../core/fleet/roverInput';
|
||||
import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
|
||||
import type {RoverController} from '../../core/fleet/useRoverControl';
|
||||
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
|
||||
import './rover.css';
|
||||
const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
|
||||
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
|
||||
|
||||
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
|
||||
const storage=`missioncore.rover-view.v1:${vehicleID}`;
|
||||
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
|
||||
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
|
||||
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
|
||||
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
|
||||
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
|
||||
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
|
||||
useEffect(()=>{if(!active)stop();},[active,stop]);
|
||||
useEffect(()=>{
|
||||
if(!c.ready||!active||open||!stage.current)return;
|
||||
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
|
||||
input.current=binding;c.setInputGuard(binding.valid);
|
||||
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
|
||||
},[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
|
||||
const showSettings=()=>{stop();setOpen(true);};
|
||||
const preparing=c.pending||(c.armed&&!c.ready);
|
||||
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
|
||||
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
|
||||
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
|
||||
return <>
|
||||
{createPortal(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
|
||||
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
|
||||
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
|
||||
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
|
||||
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
|
||||
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
|
||||
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
|
||||
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
|
||||
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
|
||||
{c.ready&&<div className="rover-view__controls">
|
||||
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
|
||||
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
|
||||
disabled={!c.ready||!active} pressed={held.has(code)}
|
||||
onPointerDown={e=>{if(!c.ready||!e.isTrusted||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);stage.current?.focus();input.current?.pointerDown(e.pointerId,code);}}
|
||||
onPointerUp={e=>input.current?.pointerUp(e.pointerId)}
|
||||
onPointerCancel={e=>input.current?.pointerCancel(e.pointerId)}
|
||||
onLostPointerCapture={e=>input.current?.pointerCancel(e.pointerId)}>{code.slice(3)}</KeyButton>)}
|
||||
</div>
|
||||
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
|
||||
</div>}
|
||||
<div className="rover-view__authority">
|
||||
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
|
||||
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
|
||||
<Select label="3D-модель" value={settings.model} options={[{value:'',label:'Не выбрана'},{value:'dcd006-v020',label:'DCD-006 · полный ровер v020'}]} onChange={model=>setSettings(v=>({...v,model}))}/>
|
||||
<TextField label="Предел тока каждого мотора, А" type="number" min={.5} max={30} step={.5} value={settings.currentA} onChange={e=>setSettings(v=>({...v,currentA:Number(e.target.value)}))}/>
|
||||
<TextField label="Скорость, ERPM" hint="300–3000" description="Электрические обороты двигателя. Скорость по земле требует параметров трансмиссии." type="number" min={300} max={3000} step={100} value={settings.maxErpm} onChange={e=>setSettings(v=>({...v,maxErpm:Number(e.target.value)}))}/>
|
||||
<Switch label="Моторы остановлены, движение можно начинать" checked={confirmed} onChange={setConfirmed}/>
|
||||
{availability&&<StatusBadge tone="warning">{availability}</StatusBadge>}
|
||||
<p>Движение — только при удержании клавиш или кнопок. При уходе из окна команда снимается. Команда с пульта прекращает удалённое управление.</p>
|
||||
<Button variant="primary" tone="neutral" disabled={!confirmed||!c.canArm||!(settings.currentA>=.5&&settings.currentA<=30&&settings.maxErpm>=300&&settings.maxErpm<=3000)} loading={c.pending}
|
||||
onClick={async()=>{const acquired=await c.arm(settings.currentA,settings.maxErpm);if(acquired){setOpen(false);if(document.hasFocus())stage.current?.focus();}}}>Включить управление</Button>
|
||||
</div></Window>
|
||||
<ToastStack onDismiss={c.clearError} items={c.error?[{id:'rover-control-error',tone:'error',title:'Управление остановлено',description:c.error}]:[]}/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"source": "NodeDC ThreeDAssetNode / PlayCanvasViewer",
|
||||
"files": {
|
||||
"PlayCanvasViewer.tsx": "34cd6df2e47a7714288aeb5afb2859b1058eb91dc1819abbf296c6a8937a1533",
|
||||
"playcanvasPostFx.ts": "91cfb7f8ba821fef14681c6c25a61f430ce25aae65b0f181d359f38e37ccd014",
|
||||
"sceneTree.ts": "eead378c2402f69e2c68cd1fc2a5ec9028555dced53858c600c599f613dda516",
|
||||
"environment-map.png": "793f72dce207c1a4d2bdb262610f688bd655a79066298b672b642f228cf7d230"
|
||||
},
|
||||
"adaptations": [
|
||||
"Domain asset URL and error/loading callbacks",
|
||||
"No fallback to a different vehicle",
|
||||
"Removed unused node-editor type helper; rendering settings unchanged",
|
||||
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
|
||||
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
|
||||
export type PostFxSettings = {
|
||||
lighting: {
|
||||
exposure: number
|
||||
skyBoxIntensity: number
|
||||
}
|
||||
envAtlas: {
|
||||
enabled: boolean
|
||||
background: boolean
|
||||
reflection: boolean
|
||||
intensity: number
|
||||
reflectionIntensity: number
|
||||
brightness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
toneMapping: number
|
||||
mip: number
|
||||
rotation: number
|
||||
}
|
||||
skybox: {
|
||||
enabled: boolean
|
||||
background: boolean
|
||||
reflection: boolean
|
||||
intensity: number
|
||||
reflectionIntensity: number
|
||||
mip: number
|
||||
rotation: number
|
||||
colorA: string
|
||||
colorB: string
|
||||
}
|
||||
rendering: {
|
||||
backgroundColor: string
|
||||
wireframe: boolean
|
||||
renderFormat: number
|
||||
renderFormatFallback0: number
|
||||
renderFormatFallback1: number
|
||||
stencil: boolean
|
||||
renderTargetScale: number
|
||||
samples: number
|
||||
sharpness: number
|
||||
toneMapping: number
|
||||
sceneColorMap: boolean
|
||||
sceneDepthMap: boolean
|
||||
fog: 'none' | 'linear' | 'exp' | 'exp2'
|
||||
fogColor: string
|
||||
fogRange: [number, number]
|
||||
fogDensity: number
|
||||
fogStart: number
|
||||
fogEnd: number
|
||||
}
|
||||
grid: {
|
||||
enabled: boolean
|
||||
colorX: string
|
||||
colorZ: string
|
||||
colorMain: string
|
||||
alphaX: number
|
||||
alphaZ: number
|
||||
alphaMain: number
|
||||
dotsEnabled: boolean
|
||||
dotsColor: string
|
||||
dotsAlpha: number
|
||||
dotsDiameter: number
|
||||
crossEnabled: boolean
|
||||
crossColor: string
|
||||
crossAlpha: number
|
||||
crossLength: number
|
||||
crossWidth: number
|
||||
fadeStart: number
|
||||
fadeEnd: number
|
||||
}
|
||||
ssao: {
|
||||
type: 'none' | 'lighting' | 'combine'
|
||||
blurEnabled: boolean
|
||||
intensity: number
|
||||
radius: number
|
||||
samples: number
|
||||
power: number
|
||||
minAngle: number
|
||||
scale: number
|
||||
}
|
||||
bloom: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
lastMipLevel: number
|
||||
}
|
||||
chromaticAberration: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
}
|
||||
taa: {
|
||||
enabled: boolean
|
||||
jitter: number
|
||||
}
|
||||
grading: {
|
||||
enabled: boolean
|
||||
brightness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
tint: string
|
||||
}
|
||||
lut: {
|
||||
intensity: number
|
||||
textureUrl?: string | null
|
||||
}
|
||||
vignette: {
|
||||
enabled: boolean
|
||||
intensity: number
|
||||
inner: number
|
||||
outer: number
|
||||
curvature: number
|
||||
color: string
|
||||
}
|
||||
directionalLight: {
|
||||
enabled: boolean
|
||||
color: string
|
||||
intensity: number
|
||||
azimuth: number
|
||||
elevation: number
|
||||
castShadows: boolean
|
||||
shadowIntensity: number
|
||||
shadowDistance: number
|
||||
shadowResolution: number
|
||||
shadowBias: number
|
||||
normalOffsetBias: number
|
||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||
vsmBlurSize: number
|
||||
}
|
||||
shadowCatcher: {
|
||||
enabled: boolean
|
||||
size: number
|
||||
yOffset: number
|
||||
lightIntensity: number
|
||||
lightColor: string
|
||||
lightAzimuth: number
|
||||
lightElevation: number
|
||||
shadowIntensity: number
|
||||
shadowDistance: number
|
||||
shadowResolution: number
|
||||
shadowBias: number
|
||||
normalOffsetBias: number
|
||||
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
|
||||
vsmBlurSize: number
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_POSTFX: PostFxSettings = {
|
||||
lighting: {
|
||||
exposure: 1.21,
|
||||
skyBoxIntensity: 0.86,
|
||||
},
|
||||
envAtlas: {
|
||||
enabled: true,
|
||||
background: false,
|
||||
reflection: true,
|
||||
intensity: 0.76,
|
||||
reflectionIntensity: 0.6,
|
||||
brightness: 1.65,
|
||||
contrast: 1,
|
||||
saturation: 1,
|
||||
toneMapping: 0,
|
||||
mip: 1,
|
||||
rotation: 247,
|
||||
},
|
||||
skybox: {
|
||||
enabled: false,
|
||||
background: false,
|
||||
reflection: true,
|
||||
intensity: 0.62,
|
||||
reflectionIntensity: 1,
|
||||
mip: 0,
|
||||
rotation: 0,
|
||||
colorA: '#c5bfbf',
|
||||
colorB: '#c7bcbc',
|
||||
},
|
||||
rendering: {
|
||||
backgroundColor: '#111113',
|
||||
wireframe: false,
|
||||
renderFormat: 18,
|
||||
renderFormatFallback0: 12,
|
||||
renderFormatFallback1: 14,
|
||||
stencil: false,
|
||||
renderTargetScale: 1,
|
||||
samples: 4,
|
||||
sharpness: 0,
|
||||
toneMapping: 4,
|
||||
sceneColorMap: false,
|
||||
sceneDepthMap: false,
|
||||
fog: 'exp',
|
||||
fogColor: '#dcc2ff',
|
||||
fogRange: [0, 100],
|
||||
fogDensity: 0.008,
|
||||
fogStart: 0,
|
||||
fogEnd: 100,
|
||||
},
|
||||
grid: {
|
||||
enabled: true,
|
||||
colorX: '#ffffff',
|
||||
colorZ: '#ffffff',
|
||||
colorMain: '#7a3cff',
|
||||
alphaX: 0.18,
|
||||
alphaZ: 0.18,
|
||||
alphaMain: 0.6,
|
||||
dotsEnabled: false,
|
||||
dotsColor: '#ffffff',
|
||||
dotsAlpha: 0.5,
|
||||
dotsDiameter: 0.06,
|
||||
crossEnabled: false,
|
||||
crossColor: '#ffffff',
|
||||
crossAlpha: 0.5,
|
||||
crossLength: 0.5,
|
||||
crossWidth: 0.06,
|
||||
fadeStart: 0,
|
||||
fadeEnd: 0,
|
||||
},
|
||||
ssao: {
|
||||
type: 'none',
|
||||
blurEnabled: true,
|
||||
intensity: 0.5,
|
||||
radius: 30,
|
||||
samples: 12,
|
||||
power: 6,
|
||||
minAngle: 10,
|
||||
scale: 1,
|
||||
},
|
||||
bloom: {
|
||||
enabled: true,
|
||||
intensity: 0.03,
|
||||
lastMipLevel: 4,
|
||||
},
|
||||
chromaticAberration: {
|
||||
enabled: true,
|
||||
intensity: 30,
|
||||
},
|
||||
taa: {
|
||||
enabled: false,
|
||||
jitter: 1,
|
||||
},
|
||||
grading: {
|
||||
enabled: true,
|
||||
brightness: 0.837,
|
||||
contrast: 1.1,
|
||||
saturation: 1.126,
|
||||
tint: '#ffffff',
|
||||
},
|
||||
lut: {
|
||||
intensity: 1,
|
||||
textureUrl: null,
|
||||
},
|
||||
vignette: {
|
||||
enabled: true,
|
||||
intensity: 1,
|
||||
inner: 0.25,
|
||||
outer: 1.52,
|
||||
curvature: 0.78,
|
||||
color: '#000000',
|
||||
},
|
||||
directionalLight: {
|
||||
enabled: true,
|
||||
color: '#ffffff',
|
||||
intensity: 0.4,
|
||||
azimuth: 0,
|
||||
elevation: 0,
|
||||
castShadows: false,
|
||||
shadowIntensity: 0.5,
|
||||
shadowDistance: 16,
|
||||
shadowResolution: 1024,
|
||||
shadowBias: 0,
|
||||
normalOffsetBias: 0,
|
||||
shadowType: 'vsm16',
|
||||
vsmBlurSize: 8,
|
||||
},
|
||||
shadowCatcher: {
|
||||
enabled: true,
|
||||
size: 9,
|
||||
yOffset: 0.001,
|
||||
lightIntensity: 0.3,
|
||||
lightColor: '#ffffff',
|
||||
lightAzimuth: 20,
|
||||
lightElevation: 60,
|
||||
shadowIntensity: 0.29,
|
||||
shadowDistance: 16,
|
||||
shadowResolution: 2048,
|
||||
shadowBias: 0,
|
||||
normalOffsetBias: 0,
|
||||
shadowType: 'vsm16',
|
||||
vsmBlurSize: 8,
|
||||
},
|
||||
}
|
||||
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
|
||||
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
|
||||
|
||||
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
|
||||
const merge = (target: any, source: any) => {
|
||||
if (!isObject(source)) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (isObject(value)) {
|
||||
if (!isObject(target[key])) target[key] = {}
|
||||
merge(target[key], value)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
merge(out, raw)
|
||||
|
||||
const legacy: any = raw as any
|
||||
if (legacy?.hdrLight) {
|
||||
const src = legacy.hdrLight
|
||||
const env = legacy?.envAtlas ?? {}
|
||||
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
|
||||
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
|
||||
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
|
||||
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
|
||||
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
|
||||
}
|
||||
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
|
||||
if (typeof legacy.hdrReflection.enabled === 'boolean') {
|
||||
out.envAtlas.reflection = legacy.hdrReflection.enabled
|
||||
}
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
|
||||
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
|
||||
out.envAtlas.brightness = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
|
||||
out.envAtlas.contrast = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
|
||||
out.envAtlas.saturation = 1
|
||||
}
|
||||
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
|
||||
out.envAtlas.toneMapping = 0
|
||||
}
|
||||
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
|
||||
out.skybox.reflectionIntensity = out.skybox.intensity
|
||||
}
|
||||
if (out.grid && typeof out.grid.enabled !== 'boolean') {
|
||||
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorX !== 'string') {
|
||||
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorZ !== 'string') {
|
||||
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
|
||||
}
|
||||
if (out.grid && typeof out.grid.colorMain !== 'string') {
|
||||
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaX !== 'number') {
|
||||
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaZ !== 'number') {
|
||||
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
|
||||
}
|
||||
if (out.grid && typeof out.grid.alphaMain !== 'number') {
|
||||
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
|
||||
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsColor !== 'string') {
|
||||
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
|
||||
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
|
||||
}
|
||||
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
|
||||
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
|
||||
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossColor !== 'string') {
|
||||
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
|
||||
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossLength !== 'number') {
|
||||
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
|
||||
}
|
||||
if (out.grid && typeof out.grid.crossWidth !== 'number') {
|
||||
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
|
||||
}
|
||||
return out as PostFxSettings
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as pc from 'playcanvas'
|
||||
|
||||
export type SceneTreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
children: SceneTreeNode[]
|
||||
}
|
||||
|
||||
export function buildSceneTree(root: pc.GraphNode, map: Map<string, pc.Entity>): SceneTreeNode {
|
||||
const id = String((root as any).getGuid?.() ?? (root as any)._guid ?? root.name)
|
||||
const children = (root.children || []).map((c) => buildSceneTree(c, map))
|
||||
map.set(id, root as pc.Entity)
|
||||
return {
|
||||
id,
|
||||
name: root.name || 'Entity',
|
||||
enabled: root.enabled !== false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.rover-view { position:relative; height:100%; min-height:200px; overflow:hidden; container-type:inline-size; }
|
||||
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
|
||||
.rover-view .rover-view__scene { position:absolute; inset:0; }
|
||||
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
|
||||
.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); }
|
||||
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
|
||||
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
|
||||
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
|
||||
.rover-key-KeyW { grid-column:2; }.rover-key-KeyA { grid-column:1; grid-row:2; }.rover-key-KeyS { grid-column:2; grid-row:2; }.rover-key-KeyD { grid-column:3; grid-row:2; }
|
||||
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
|
||||
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
|
||||
@container (max-width: 380px) {
|
||||
.rover-view__authority { top:48px; bottom:auto; }
|
||||
}
|
||||
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
|
||||
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
|
||||
.rover-telemetry { height:100%; overflow:auto; padding:var(--nodedc-space-3); display:flex; flex-wrap:wrap; align-content:start; gap:var(--nodedc-space-4); font-size:var(--nodedc-font-size-sm); }
|
||||
.rover-telemetry section { flex:1; min-width:170px; }.rover-telemetry section > span { display:block; color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-xs); margin-top:var(--nodedc-space-1); }
|
||||
.rover-telemetry dl { display:grid; gap:var(--nodedc-space-1); margin-bottom:0; }.rover-telemetry dl > div { display:flex; justify-content:space-between; gap:var(--nodedc-space-3); }
|
||||
.rover-telemetry dt { color:var(--nodedc-text-secondary); }.rover-telemetry dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
@@ -1,3 +1,4 @@
|
||||
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
@@ -10,4 +11,4 @@ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
|
||||
// Node-executed camera controls use the existing sensor contribution contract;
|
||||
// they do not register a desktop capture/AI runtime or a new product workspace.
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi,vescSensorUi]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
const layouts=new Map<string,BoardLayoutStore>();
|
||||
export function boardLayout(vehicleID:string):BoardLayoutStore {
|
||||
let layout=layouts.get(vehicleID);
|
||||
if(!layout){
|
||||
const path=`/${encodeURIComponent(vehicleID)}/board-layout`;
|
||||
layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})});
|
||||
layouts.set(vehicleID,layout);
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {keyDemand,keysFor,type Demand,type RoverMode} from './roverInput';
|
||||
|
||||
// The network heartbeat is not evidence that a physical key is still held.
|
||||
// Allow the initial OS repeat delay, then require continuing keyboard evidence.
|
||||
export const firstKeyLeaseMs=1000,repeatKeyLeaseMs=300;
|
||||
export function bindRoverHoldInput(scope:HTMLElement,mode:RoverMode,callbacks:{
|
||||
held:(keys:Set<string>)=>void;demand:(value:Demand)=>void;pause:()=>void;stop:()=>void;
|
||||
},now:()=>number=()=>performance.now()){
|
||||
const doc=scope.ownerDocument,win=doc.defaultView!;
|
||||
const keyboard=new Set<string>(),pointers=new Map<number,string>();
|
||||
let deadline=0,closed=false,suspended=false;
|
||||
const clear=()=>{keyboard.clear();pointers.clear();deadline=0;callbacks.held(new Set());};
|
||||
const pause=()=>{if(closed||suspended)return;suspended=true;clear();callbacks.pause();};
|
||||
const stop=()=>{if(closed)return;closed=true;clear();callbacks.stop();};
|
||||
const focused=()=>!doc.hidden&&doc.hasFocus()&&scope.contains(doc.activeElement);
|
||||
const valid=()=>{
|
||||
if(closed)return false;
|
||||
if(!focused()||(keyboard.size>0&&now()>=deadline)){pause();return false;}
|
||||
return true;
|
||||
};
|
||||
const publish=()=>{const held=new Set([...keyboard,...pointers.values()]);callbacks.held(held);callbacks.demand(keyDemand(mode,held));};
|
||||
const down=(e:KeyboardEvent)=>{
|
||||
if(!e.isTrusted||closed)return;
|
||||
if(e.code==='Space'||e.code==='Escape'){e.preventDefault();stop();return;}
|
||||
if(e.altKey||e.ctrlKey||e.metaKey){pause();return;}
|
||||
if(!keysFor(mode).includes(e.code)||!valid())return;
|
||||
if((e.target as HTMLElement)?.closest('input,textarea,select,[role=dialog],[role=listbox]')){pause();return;}
|
||||
// A repeat delivered after focus returns cannot become a fresh press.
|
||||
if(e.repeat&&!keyboard.has(e.code))return;
|
||||
e.preventDefault();suspended=false;keyboard.add(e.code);
|
||||
deadline=now()+(e.repeat?repeatKeyLeaseMs:firstKeyLeaseMs);publish();
|
||||
};
|
||||
const up=(e:KeyboardEvent)=>{
|
||||
if(!e.isTrusted||closed||!keyboard.delete(e.code))return;
|
||||
e.preventDefault();if(!keyboard.size)deadline=0;publish();
|
||||
};
|
||||
const blur=(e:Event)=>{if(e.target===win)pause();};
|
||||
const focusOut=(e:FocusEvent)=>{if(scope.contains(e.target as Node)&&!scope.contains(e.relatedTarget as Node|null))pause();};
|
||||
const focusIn=()=>{if(!focused())pause();};
|
||||
const hidden=()=>{if(doc.hidden)pause();};
|
||||
const outside=(e:Event)=>{if(!scope.contains(e.target as Node))pause();};
|
||||
const pointerUp=(e:PointerEvent)=>{if(!closed&&pointers.delete(e.pointerId))publish();};
|
||||
const pointerCancel=(e:PointerEvent)=>{if(pointers.has(e.pointerId))pause();};
|
||||
win.addEventListener('keydown',down,true);win.addEventListener('keyup',up,true);
|
||||
win.addEventListener('blur',blur,true);win.addEventListener('pagehide',stop);
|
||||
doc.addEventListener('focusout',focusOut,true);doc.addEventListener('focusin',focusIn,true);
|
||||
doc.addEventListener('visibilitychange',hidden);doc.addEventListener('pointerdown',outside,true);
|
||||
doc.addEventListener('pointerup',pointerUp,true);doc.addEventListener('pointercancel',pointerCancel,true);
|
||||
doc.addEventListener('contextmenu',pause,true);
|
||||
const timer=win.setInterval(valid,50);
|
||||
return {
|
||||
valid,
|
||||
pointerDown(id:number,code:string){if(valid()){suspended=false;pointers.set(id,code);publish();}},
|
||||
pointerUp(id:number){if(!closed&&pointers.delete(id))publish();},
|
||||
pointerCancel(id:number){if(pointers.has(id))pause();},
|
||||
dispose(){
|
||||
closed=true;clear();callbacks.demand({left:0,right:0});win.clearInterval(timer);
|
||||
win.removeEventListener('keydown',down,true);win.removeEventListener('keyup',up,true);
|
||||
win.removeEventListener('blur',blur,true);win.removeEventListener('pagehide',stop);
|
||||
doc.removeEventListener('focusout',focusOut,true);doc.removeEventListener('focusin',focusIn,true);
|
||||
doc.removeEventListener('visibilitychange',hidden);doc.removeEventListener('pointerdown',outside,true);
|
||||
doc.removeEventListener('pointerup',pointerUp,true);doc.removeEventListener('pointercancel',pointerCancel,true);
|
||||
doc.removeEventListener('contextmenu',pause,true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type RoverMode='arcade'|'tank';
|
||||
export type Demand={left:number;right:number};
|
||||
export const keysFor=(mode:RoverMode)=>mode==='arcade'?['KeyW','KeyA','KeyS','KeyD']:['KeyQ','KeyE','KeyA','KeyD'];
|
||||
export function keyDemand(mode:RoverMode,held:ReadonlySet<string>):Demand {
|
||||
const axis=(positive:string,negative:string)=>Number(held.has(positive))-Number(held.has(negative));
|
||||
if(mode==='tank')return {left:axis('KeyQ','KeyA'),right:axis('KeyE','KeyD')};
|
||||
const forward=axis('KeyW','KeyS'),turn=axis('KeyD','KeyA');
|
||||
const scale=Math.max(1,Math.abs(forward)+Math.abs(turn));
|
||||
return {left:(forward+turn)/scale,right:(forward-turn)/scale};
|
||||
}
|
||||
export const defaultRoverSettings={version:1,mode:'arcade' as RoverMode,currentA:30,maxErpm:2000,model:''};
|
||||
export function roverSettings(value:unknown):typeof defaultRoverSettings {
|
||||
const v=value as Partial<typeof defaultRoverSettings>|null;
|
||||
return {...defaultRoverSettings,...(v?.version===1?{
|
||||
mode:v.mode==='tank'?'tank':'arcade',
|
||||
currentA:typeof v.currentA==='number'&&Number.isFinite(v.currentA)?Math.max(.5,Math.min(30,v.currentA)):30,
|
||||
maxErpm:typeof v.maxErpm==='number'&&Number.isFinite(v.maxErpm)?Math.max(300,Math.min(3000,v.maxErpm)):2000,
|
||||
model:v.model==='dcd006-v020'?v.model:'',
|
||||
}:{} )};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
configurationArchive:{list:(device,before)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations/${encodeURIComponent(version)}`)},
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import type {Demand} from './roverInput';
|
||||
|
||||
export interface RoverReading {
|
||||
id:string;uuid:string;slot:string|null;label:string;age_ms:number;
|
||||
values:{motor_current_a:number;input_current_a:number;input_voltage_v:number;erpm:number;duty:number;mos_temperature_c:number;fault_code:number};
|
||||
}
|
||||
export interface RoverState {
|
||||
fresh:boolean;controlling:boolean;
|
||||
snapshot:{supported?:boolean;instance?:string;state?:string;session_id?:string|null;message?:string|null;devices?:RoverReading[];
|
||||
profile?:{layout:null|'1x1'|'2x2';bindings:Record<string,{device_id:string;uuid:string}>}};
|
||||
}
|
||||
const empty:RoverState={fresh:false,controlling:false,snapshot:{}};
|
||||
async function request<T>(url:string,body?:unknown,keepalive=false):Promise<T>{
|
||||
const response=await fetch(url,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:undefined,
|
||||
body:body?JSON.stringify(body):undefined,cache:'no-store',signal:AbortSignal.timeout(body?350:1000),keepalive});
|
||||
const result=await response.json();
|
||||
if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Канал управления недоступен.');
|
||||
return result;
|
||||
}
|
||||
export function useRoverControl(vehicleID:string,visible:boolean){
|
||||
const url=`/api/v1/fleet/${encodeURIComponent(vehicleID)}/rover`;
|
||||
const [state,setState]=useState<RoverState>(empty),[armed,setArmed]=useState(false),[pending,setPending]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [connecting,setConnecting]=useState(true),armPending=useRef(false);
|
||||
const session=useRef<string|null>(null),seq=useRef(0),demand=useRef<Demand>({left:0,right:0}),sending=useRef(false),generation=useRef(0);
|
||||
const inputGuard=useRef<(()=>boolean)|null>(null);
|
||||
const setInputGuard=useCallback((guard:(()=>boolean)|null)=>{inputGuard.current=guard;},[]);
|
||||
const stop=useCallback(()=>{
|
||||
generation.current++;armPending.current=false;const id=session.current;session.current=null;demand.current={left:0,right:0};setArmed(false);setPending(false);
|
||||
if(id)void request(url+'/command',{session_id:id,sequence:++seq.current,left:0,right:0,stop:true},true).catch(()=>{});
|
||||
},[url]);
|
||||
const send=useCallback(async()=>{
|
||||
const id=session.current;if(!id)return;
|
||||
// Check on every heartbeat, even when a previous request is still pending.
|
||||
const inputValid=(demand.current.left===0&&demand.current.right===0)||inputGuard.current?.();
|
||||
if(document.hidden||!document.hasFocus()||!inputValid)demand.current={left:0,right:0};
|
||||
if(sending.current)return;
|
||||
sending.current=true;
|
||||
try{await request(url+'/command',{session_id:id,sequence:++seq.current,...demand.current,stop:false});}
|
||||
catch(e){if(session.current===id){stop();setError(e instanceof Error?e.message:'Команда не подтверждена.');}}
|
||||
finally{sending.current=false;}
|
||||
},[url,stop]);
|
||||
const setDemand=useCallback((next:Demand)=>{demand.current=next;void send();},[send]);
|
||||
const pauseInput=useCallback(()=>{demand.current={left:0,right:0};void send();},[send]);
|
||||
const arm=useCallback(async(currentA:number,maxErpm:number)=>{
|
||||
if(session.current||armPending.current)return false;const g=++generation.current;armPending.current=true;setError(null);setPending(true);
|
||||
try{
|
||||
const result=await request<{session_id:string}>(url+'/arm',{standstill_confirmed:true,current_a:currentA,max_erpm:maxErpm});
|
||||
if(g!==generation.current){void request(url+'/command',{session_id:result.session_id,sequence:1,left:0,right:0,stop:true},true).catch(()=>{});return false;}
|
||||
// Retire every read started before this acknowledgement, including reads
|
||||
// begun while POST /arm was pending. They describe the previous authority.
|
||||
generation.current++;armPending.current=false;setPending(false);
|
||||
seq.current=0;demand.current={left:0,right:0};session.current=result.session_id;setArmed(true);void send();
|
||||
return true;
|
||||
}catch(e){if(g===generation.current)setError(e instanceof Error?e.message:'Не удалось включить управление.');return false;}
|
||||
finally{if(g===generation.current){armPending.current=false;setPending(false);}}
|
||||
},[url,send]);
|
||||
useEffect(()=>{
|
||||
if(!visible){stop();setState(empty);setConnecting(false);return;}
|
||||
let alive=true,busy=false,lastFault='';const started=performance.now();setConnecting(true);
|
||||
const poll=async()=>{
|
||||
if(busy)return;busy=true;const before=generation.current;
|
||||
try{const value=await request<RoverState>(url);if(alive&&before===generation.current){setState(value);
|
||||
setConnecting(!value.fresh&&performance.now()-started<2000);
|
||||
if(session.current&&(!value.fresh||!value.controlling)){stop();}
|
||||
const fault=value.snapshot.state==='fault'?value.snapshot.message??'':'';
|
||||
if(fault&&lastFault!==fault){lastFault=fault;setError(fault);}
|
||||
}}catch{if(alive&&before===generation.current){setState(empty);setConnecting(false);if(session.current)stop();}}finally{busy=false;}
|
||||
};
|
||||
void poll();const timer=setInterval(()=>void poll(),200),heartbeat=setInterval(()=>void send(),100);
|
||||
const blur=()=>pauseInput(),hidden=()=>{if(document.hidden)pauseInput();};
|
||||
window.addEventListener('blur',blur);window.addEventListener('pagehide',stop);document.addEventListener('visibilitychange',hidden);
|
||||
return()=>{alive=false;clearInterval(timer);clearInterval(heartbeat);window.removeEventListener('blur',blur);window.removeEventListener('pagehide',stop);document.removeEventListener('visibilitychange',hidden);stop();};
|
||||
},[url,visible,stop,send,pauseInput]);
|
||||
const ready=armed&&state.fresh&&state.snapshot.session_id===session.current&&['ready','driving'].includes(state.snapshot.state??'');
|
||||
const canArm=state.fresh&&state.snapshot.supported===true&&!state.controlling&&!['preparing','ready','driving','stopping'].includes(state.snapshot.state??'');
|
||||
return {state,armed,pending,error,connecting,canArm,arm,stop,pauseInput,setDemand,setInputGuard,ready,clearError:()=>setError(null)};
|
||||
}
|
||||
export type RoverController=ReturnType<typeof useRoverControl>;
|
||||
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "missions":
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {useMemo} from 'react';
|
||||
import {useMemo,type ReactNode} from 'react';
|
||||
import {boardLayout} from '../../core/fleet/boardLayout';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
export function VehicleSensors({vehicleID,enabled,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} board={{layout,computer,description}}/>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "
|
||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
||||
|
||||
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
|
||||
export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
@@ -20,7 +20,6 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
@@ -44,25 +43,24 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
|
||||
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
<div><Button onClick={() => {setSelected(null);}}>К списку аппаратов</Button></div>
|
||||
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
</SettingsCard>}/>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
|
||||
@@ -11,8 +11,11 @@ import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
import {RoverView} from '../../../components/rover/RoverView';
|
||||
import {RoverTelemetry} from '../../../components/rover/RoverTelemetry';
|
||||
import {useRoverControl} from '../../../core/fleet/useRoverControl';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back,configure,headerToolsHost}:{vehicleID:string;name:string;enabled:boolean;back:()=>void;configure:()=>void;headerToolsHost?:HTMLElement|null}){
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
@@ -27,17 +30,20 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}});
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
|
||||
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
@@ -51,7 +57,7 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
@@ -63,6 +69,8 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
||||
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
|
||||
@@ -11,6 +11,8 @@ export function ObservationMount({element,className='observation-mount'}:{elemen
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
|
||||
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
|
||||
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const code=ts.transpileModule(readFileSync(new URL('../../../packages/sensor-ui/src/boardLayout.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {createBoardLayoutStore,defaultBoardLayout}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const tick=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
function server(){
|
||||
let value=structuredClone(defaultBoardLayout);const calls=[];
|
||||
return {calls,read:async()=>structuredClone(value),patch:async(section,open)=>{
|
||||
calls.push({section,open});await tick();
|
||||
value={...value,revision:value.revision+1,open_sections:defaultBoardLayout.open_sections.filter(id=>id===section?open:value.open_sections.includes(id))};
|
||||
return structuredClone(value);
|
||||
}};
|
||||
}
|
||||
test('rapid toggles persist after navigation and preserve all-closed state',async()=>{
|
||||
const api=server();const store=createBoardLayoutStore(api);await store.load();
|
||||
const unsubscribe=store.subscribe(()=>{});
|
||||
store.change(['settings','devices']);store.change(['devices']);store.change([]);
|
||||
unsubscribe();
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
const reopened=createBoardLayoutStore(api);await reopened.load();
|
||||
assert.deepEqual(reopened.getSnapshot().value.open_sections,[]);
|
||||
assert.equal(api.calls.length,3);
|
||||
});
|
||||
test('a later toggle wins while an older patch is still in flight',async()=>{
|
||||
const api=server();const store=createBoardLayoutStore(api);await store.load();
|
||||
store.change(['settings','devices']);store.change(['computer','settings','devices']);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.deepEqual((await api.read()).open_sections,defaultBoardLayout.open_sections);
|
||||
});
|
||||
test('different vehicles and simultaneous section changes do not clobber one another',async()=>{
|
||||
const api=server(),other=server();const a=createBoardLayoutStore(api),b=createBoardLayoutStore(api),c=createBoardLayoutStore(other);
|
||||
await Promise.all([a.load(),b.load(),c.load()]);
|
||||
a.change(['settings','devices']);b.change(['computer','devices']);
|
||||
while(a.getSnapshot().saving||b.getSnapshot().saving)await tick();
|
||||
assert.deepEqual((await api.read()).open_sections,['devices']);
|
||||
assert.deepEqual(c.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
|
||||
});
|
||||
test('failed save reports failure, rolls back, and explicit reload recovers',async()=>{
|
||||
const api=server();let fail=true;
|
||||
const store=createBoardLayoutStore({...api,patch:(...args)=>fail?Promise.reject(new Error('offline')):api.patch(...args)});
|
||||
await store.load();store.change([]);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.ok(store.getSnapshot().error);
|
||||
assert.deepEqual(store.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
|
||||
fail=false;await store.load();store.change(['devices']);
|
||||
while(store.getSnapshot().saving)await tick();
|
||||
assert.equal(store.getSnapshot().error,null);
|
||||
assert.deepEqual((await api.read()).open_sections,['devices']);
|
||||
});
|
||||
test('invalid layout cannot overwrite stored preferences',async()=>{
|
||||
let writes=0;
|
||||
const store=createBoardLayoutStore({read:async()=>({...defaultBoardLayout,open_sections:['motor-start']}),patch:async()=>{writes++;return defaultBoardLayout;}});
|
||||
await store.load();store.change([]);
|
||||
assert.equal(store.getSnapshot().ready,false);assert.ok(store.getSnapshot().error);assert.equal(writes,0);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
|
||||
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
|
||||
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
|
||||
}}]});
|
||||
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
async function fixture(run){
|
||||
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
|
||||
let focus=true,hidden=false;const calls=[],timers=[];globalThis.roverEffects=[];
|
||||
globalThis.window={addEventListener(){},removeEventListener(){}};
|
||||
globalThis.document={get hidden(){return hidden;},hasFocus:()=>focus,addEventListener(){},removeEventListener(){}};
|
||||
globalThis.setInterval=(f,ms)=>{const timer={f,ms};timers.push(timer);return timer;};globalThis.clearInterval=()=>{};
|
||||
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});return {ok:true,json:async()=>url.endsWith('/arm')?{session_id:'fixture-session'}:{fresh:true,controlling:true,snapshot:{state:'ready'}}};};
|
||||
const c=useRoverControl('fixture',true);const cleanup=globalThis.roverEffects.map(f=>f());
|
||||
try{await flush();await c.arm(30,2000);await flush();await run({c,calls,timers,blur:()=>{focus=false;},focus:()=>{focus=true;},hide:()=>{hidden=true;}});}
|
||||
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
||||
}
|
||||
test('command heartbeat sends neutral on lost blur, keeps session, and accepts new focused input',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();
|
||||
assert.equal(f.calls.at(-1).body.left,1);f.blur();f.timers.find(t=>t.ms===100).f();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.focus();f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
|
||||
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
|
||||
}));
|
||||
test('expired physical input prevents heartbeat from renewing remembered demand',async()=>fixture(async f=>{
|
||||
let valid=true;f.c.setInputGuard(()=>valid);f.c.setDemand({left:1,right:-1});await flush();valid=false;
|
||||
f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.stop,false);
|
||||
}));
|
||||
test('no registered input scope cannot issue motion',async()=>fixture(async f=>{
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.stop,false);
|
||||
assert.equal(f.calls.some(x=>x.body?.left===1),false);
|
||||
}));
|
||||
test('hidden document sends only neutral without a visibility event',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.hide();f.c.setDemand({left:1,right:1});await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.some(x=>x.body?.left===1),false);
|
||||
}));
|
||||
|
||||
test('explicit pause clears demand without revoking session; explicit stop still revokes it',async()=>fixture(async f=>{
|
||||
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();f.c.pauseInput();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
|
||||
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
|
||||
f.c.stop();await flush();assert.equal(f.calls.at(-1).body.stop,true);
|
||||
const count=f.calls.length;f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.length,count);
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
|
||||
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
|
||||
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
|
||||
}}]});
|
||||
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};};
|
||||
const response=value=>({ok:true,json:async()=>value});
|
||||
const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}};
|
||||
async function fixture(run){
|
||||
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
|
||||
const calls=[],timers=[];globalThis.roverEffects=[];
|
||||
globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}};
|
||||
globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{};
|
||||
let armResponse=null,nextPoll=null;
|
||||
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});
|
||||
if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'});
|
||||
if(!body)return nextPoll?nextPoll.promise:response(idle);
|
||||
return response({accepted_sequence:body.sequence});
|
||||
};
|
||||
const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f());
|
||||
try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});}
|
||||
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
||||
}
|
||||
test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
|
||||
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
|
||||
p.resolve(response(idle));await flush();f.heartbeat();await flush();
|
||||
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session');
|
||||
assert.equal(f.calls.at(-1).body.session_id,'new-session');
|
||||
}));
|
||||
test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
|
||||
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
|
||||
p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush();
|
||||
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session');
|
||||
}));
|
||||
test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{
|
||||
assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush();
|
||||
assert.equal(f.calls.at(-1).body.stop,true);
|
||||
}));
|
||||
test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{
|
||||
const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000);
|
||||
a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush();
|
||||
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
|
||||
}));
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/roverHoldInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {bindRoverHoldInput}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
class Events {
|
||||
listeners=new Map();timers=new Map();next=0;
|
||||
addEventListener(type,fn){if(!this.listeners.has(type))this.listeners.set(type,new Set());this.listeners.get(type).add(fn);}
|
||||
removeEventListener(type,fn){this.listeners.get(type)?.delete(fn);}
|
||||
emit(type,detail={}){const e={target:this,isTrusted:true,preventDefault(){},...detail};for(const fn of [...(this.listeners.get(type)??[])])fn(e);}
|
||||
setInterval(fn){const id=++this.next;this.timers.set(id,fn);return id;}
|
||||
clearInterval(id){this.timers.delete(id);}
|
||||
tick(){for(const fn of this.timers.values())fn();}
|
||||
}
|
||||
function fixture(mode='arcade'){
|
||||
let time=0,focus=true;const win=new Events(),doc=new Events();doc.defaultView=win;doc.hidden=false;doc.hasFocus=()=>focus;
|
||||
const scope={ownerDocument:doc,closest:()=>null},child={closest:()=>null};scope.contains=x=>x===scope||x===child;doc.activeElement=scope;
|
||||
const states=[],demands=[];let stops=0,pauses=0;
|
||||
const binding=bindRoverHoldInput(scope,mode,{held:k=>states.push([...k]),demand:d=>demands.push(d),stop:()=>stops++,pause:()=>{pauses++;demands.push({left:0,right:0});}},()=>time);
|
||||
const key=(code,repeat=false)=>win.emit('keydown',{code,repeat,target:scope});
|
||||
return {win,doc,scope,child,binding,states,demands,key,stops:()=>stops,pauses:()=>pauses,advance:ms=>{time+=ms;win.tick();},loseFocus:()=>{focus=false;},restoreFocus:()=>{focus=true;},up:code=>win.emit('keyup',{code,target:scope})};
|
||||
}
|
||||
test('blur clears D, ignores a late repeat, but accepts a fresh press without rearming',()=>{
|
||||
const f=fixture();f.key('KeyD');assert.deepEqual(f.demands.at(-1),{left:1,right:-1});
|
||||
f.win.emit('blur');assert.equal(f.pauses(),1);assert.equal(f.stops(),0);assert.deepEqual(f.states.at(-1),[]);
|
||||
assert.deepEqual(f.demands.at(-1),{left:0,right:0});const count=f.demands.length;
|
||||
f.key('KeyD',true);assert.equal(f.demands.length,count);
|
||||
f.key('KeyW');assert.deepEqual(f.demands.at(-1),{left:1,right:1});assert.equal(f.binding.valid(),true);f.binding.dispose();
|
||||
});
|
||||
test('heartbeat validity catches missing blur event using document focus',()=>{
|
||||
const f=fixture();f.key('KeyD');f.loseFocus();assert.equal(f.binding.valid(),false);assert.equal(f.pauses(),1);
|
||||
f.restoreFocus();assert.equal(f.binding.valid(),true);f.binding.dispose();
|
||||
});
|
||||
test('focus polling catches missing event or moving to another control',()=>{
|
||||
const f=fixture();f.key('KeyW');f.doc.activeElement={};f.advance(50);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('lost keyup AND missing focus notifications still expire repeated D',()=>{
|
||||
const f=fixture();f.key('KeyD');f.advance(500);f.key('KeyD',true);f.advance(299);assert.equal(f.pauses(),0);
|
||||
f.advance(1);assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.key('KeyD',true);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('first press allows OS repeat delay but never an indefinite hold',()=>{
|
||||
const f=fixture();f.key('KeyW');f.advance(999);assert.equal(f.pauses(),0);f.advance(1);assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
});
|
||||
test('continuous repeat renews hold; keyup immediately sends neutral',()=>{
|
||||
const f=fixture();f.key('KeyW');f.advance(600);
|
||||
for(let i=0;i<30;i++){f.key('KeyW',true);f.advance(100);}
|
||||
assert.equal(f.pauses(),0);f.up('KeyW');assert.deepEqual(f.demands.at(-1),{left:0,right:0});f.advance(1200);assert.equal(f.pauses(),0);f.binding.dispose();
|
||||
});
|
||||
test('diagonal input survives single-key OS repeat; release recomputes demand',()=>{
|
||||
const f=fixture();f.key('KeyW');f.key('KeyA');
|
||||
for(let i=0;i<20;i++){f.key('KeyA',true);f.advance(100);}
|
||||
assert.equal(f.pauses(),0);assert.deepEqual(f.demands.at(-1),{left:0,right:1});
|
||||
f.up('KeyA');assert.deepEqual(f.demands.at(-1),{left:1,right:1});f.up('KeyW');f.binding.dispose();
|
||||
});
|
||||
test('repeat without a fresh press and untrusted events never command motion',()=>{
|
||||
const f=fixture();f.key('KeyD',true);f.win.emit('keydown',{code:'KeyW',isTrusted:false,target:f.scope});assert.equal(f.demands.length,0);f.binding.dispose();
|
||||
});
|
||||
test('focus inside the view is allowed; leaving or hiding it stops',()=>{
|
||||
for(const event of ['focusout','visibilitychange','pagehide','outside']){
|
||||
const f=fixture();f.key('KeyW');f.doc.emit('focusout',{target:f.scope,relatedTarget:f.child});assert.equal(f.pauses(),0);
|
||||
if(event==='focusout')f.doc.emit(event,{target:f.scope,relatedTarget:null});
|
||||
else if(event==='visibilitychange'){f.doc.hidden=true;f.doc.emit(event);}
|
||||
else if(event==='pagehide')f.win.emit(event);
|
||||
else f.doc.emit('pointerdown',{target:{}});
|
||||
assert.equal(event==='pagehide'?f.stops():f.pauses(),1,event);f.binding.dispose();
|
||||
}
|
||||
});
|
||||
test('pointer capture lost or cancelled releases every input',()=>{
|
||||
for(const mode of ['arcade','tank']){
|
||||
const f=fixture(mode);f.binding.pointerDown(1,mode==='arcade'?'KeyW':'KeyQ');f.binding.pointerCancel(1);
|
||||
assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.binding.dispose();
|
||||
}
|
||||
});
|
||||
test('document pointerup releases even if button handler does not receive it',()=>{
|
||||
const f=fixture();f.binding.pointerDown(1,'KeyD');f.doc.emit('pointerup',{pointerId:1});assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.pauses(),0);f.binding.dispose();
|
||||
});
|
||||
test('modifier shortcuts pause; cleanup removes listeners, timer and held demand',()=>{
|
||||
const f=fixture();f.key('KeyD');f.win.emit('keydown',{code:'Tab',metaKey:true,target:f.scope});assert.equal(f.pauses(),1);f.binding.dispose();
|
||||
assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.win.timers.size,0);
|
||||
assert.equal([...f.win.listeners.values(),...f.doc.listeners.values()].reduce((sum,x)=>sum+x.size,0),0);
|
||||
});
|
||||
|
||||
test('Space and Escape remain explicit session stops',()=>{
|
||||
for(const code of ['Space','Escape']){const f=fixture();f.key('KeyW');f.key(code);assert.equal(f.stops(),1);assert.equal(f.binding.valid(),false);f.binding.dispose();}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../plugins/vesc/frontend/src/model.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {vescLabel,testInput,speedInput,rotationResult,driveTestIds}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const controller={online:true,prepared:true,verified:true,vesc_status:{identity:{uuid:'synthetic'},readable:true}};
|
||||
|
||||
test('speed mode requires board capability and reports measured rotation time',()=>{
|
||||
assert.ok(speedInput('1200',undefined).error);
|
||||
const bounds={min_erpm:300,max_erpm:3000,duration_basis:'measured_speed'};
|
||||
assert.equal(speedInput('1200',bounds).error,null);
|
||||
for(const value of ['', 'NaN','299','3001'])assert.ok(speedInput(value,bounds).error);
|
||||
const text=rotationResult({outcome:'stopped',rotation_s:2.5,release_confirmed:true,limits_restored:true});
|
||||
assert.match(text,/2,5 с/);
|
||||
assert.match(text,/Проверка остановлена/);
|
||||
assert.doesNotMatch(text,/время вращения набрано/);
|
||||
});
|
||||
|
||||
test('USB discovery and model preparation never claim a verified VESC',()=>{
|
||||
assert.equal(vescLabel({...controller,prepared:false},true).label,'Требуется подготовка');
|
||||
assert.equal(vescLabel({...controller,vesc_status:{identity:null,readable:false}},true).tone,'warning');
|
||||
});
|
||||
test('fresh read capability is distinct from unsupported firmware and offline state',()=>{
|
||||
assert.equal(vescLabel(controller,true).label,'Готов к чтению');
|
||||
assert.equal(vescLabel(controller,false).label,'Нет связи');
|
||||
assert.equal(vescLabel({...controller,online:false},true).label,'Нет связи');
|
||||
assert.equal(vescLabel({...controller,vesc_status:{identity:{uuid:'synthetic'},readable:false}},true).label,'Прошивка не поддерживается');
|
||||
});
|
||||
|
||||
test('test fields accept entered values only inside the board capability bounds',()=>{
|
||||
const bounds={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
|
||||
assert.equal(testInput('3.7','7.2',bounds).valid,true);
|
||||
assert.equal(testInput('5','10',bounds).valid,true);
|
||||
for(const [amps,seconds] of [['','5'],['5',''],['60','10'],['5','11'],['NaN','1'],['Infinity','1']])assert.equal(testInput(amps,seconds,bounds).valid,false);
|
||||
assert.equal(testInput('2','1.5',undefined).valid,false);
|
||||
const overlong=testInput('5','15',bounds);
|
||||
assert.equal(overlong.valid,false);
|
||||
assert.equal(overlong.currentError,null);
|
||||
assert.equal(overlong.durationError,'Длительность должна быть от 0,5 до 10 с.');
|
||||
assert.equal(testInput('5','10',bounds).durationError,null);
|
||||
});
|
||||
|
||||
// Capabilities come from the installed board: a newer Core must retain old bounds.
|
||||
test('extended board accepts 30 A / 30 s without changing older board bounds',()=>{
|
||||
const old={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
|
||||
const expanded={...old,max_current_a:30,max_duration_s:30,continuous_current:true};
|
||||
assert.equal(testInput('30','30',expanded).valid,true);
|
||||
assert.equal(testInput('30.1','30',expanded).valid,false);
|
||||
assert.equal(testInput('30','30.1',expanded).valid,false);
|
||||
assert.equal(testInput('30','30',old).valid,false);
|
||||
});
|
||||
|
||||
|
||||
test('group spin requires a complete unique profile containing the selected controller',()=>{
|
||||
const profile={layout:'1x1',revision:3,bindings:{'left.1':{device_id:'left',uuid:'a'},'right.1':{device_id:'right',uuid:'b'}}};
|
||||
assert.deepEqual(driveTestIds(profile,'left'),['left','right']);
|
||||
assert.deepEqual(driveTestIds(profile,'unassigned'),[]);
|
||||
assert.deepEqual(driveTestIds({...profile,bindings:{'left.1':profile.bindings['left.1']}},'left'),[]);
|
||||
assert.deepEqual(driveTestIds({...profile,bindings:{...profile.bindings,'right.1':profile.bindings['left.1']}},'left'),[]);
|
||||
assert.deepEqual(driveTestIds(undefined,'left'),[]);
|
||||
});
|
||||
|
||||
test('four-wheel profile preserves front and rear membership for common testing',()=>{
|
||||
const ids=['lf','lr','rf','rr'];
|
||||
const bindings=Object.fromEntries(['left.1','left.2','right.1','right.2'].map((slot,i)=>[slot,{device_id:ids[i],uuid:ids[i]}]));
|
||||
assert.deepEqual(driveTestIds({layout:'2x2',revision:4,bindings},'rf'),ids);
|
||||
});
|
||||
@@ -307,6 +307,9 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
payload["devices"] = inv["items"]
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
if batch := p.Sensors.ConfigurationBatch(binding.BindingID); batch != nil {
|
||||
payload["vesc_configurations"] = batch
|
||||
}
|
||||
}
|
||||
if p.DeviceEnrollment != nil {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
@@ -337,6 +340,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
if p.Sensors != nil {
|
||||
p.Sensors.AcknowledgeConfigurations(binding.BindingID, result["vesc_configurations_ack"], payload["vesc_configurations"])
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
p.Sensors.Acknowledge(ack)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Sensors) vescArchive(path string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
for i := range sensorModels {
|
||||
if sensorModels[i].ID == "vesc.controller" {
|
||||
return s.modelDriver(ctx, &sensorModels[i], path, nil)
|
||||
}
|
||||
}
|
||||
panic("shipped VESC model is missing")
|
||||
}
|
||||
|
||||
func archiveCursorName(binding string) string {
|
||||
return "vesc-archive-" + digest(binding)[:24] + ".cursor"
|
||||
}
|
||||
|
||||
// Each pairing receives the complete immutable history, including backups
|
||||
// made locally and while Core was offline. ACK follows durable Core storage.
|
||||
func (s *Sensors) ConfigurationBatch(binding string) map[string]any {
|
||||
var after int64
|
||||
data, _ := os.ReadFile(filepath.Join(s.root, archiveCursorName(binding)))
|
||||
if json.Unmarshal(data, &after) != nil || after < 0 {
|
||||
after = 0
|
||||
}
|
||||
result, err := s.vescArchive("/archive-export?after=" + strconv.FormatInt(after, 10))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Sensors) AcknowledgeConfigurations(binding string, ack json.RawMessage, batch any) {
|
||||
value, ok := batch.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var sequence int64
|
||||
if json.Unmarshal(ack, &sequence) != nil || sequence < 0 {
|
||||
return
|
||||
}
|
||||
next, ok := value["next"].(float64)
|
||||
if !ok || next != float64(sequence) {
|
||||
return
|
||||
}
|
||||
// A failed cursor write causes replay; immutable archive insertion deduplicates it.
|
||||
_ = s.write(archiveCursorName(binding), sequence)
|
||||
}
|
||||
|
||||
func (s *Sensors) configurationRoutes(mux *http.ServeMux, server *Server) {
|
||||
for _, route := range []string{"GET /api/device-configurations/{device}", "GET /api/device-configurations/{device}/{version}"} {
|
||||
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
device := r.PathValue("device")
|
||||
model := modelForDevice(device)
|
||||
if model == nil || model.ID != "vesc.controller" {
|
||||
reply(w, 404, map[string]string{"error": "История не найдена"})
|
||||
return
|
||||
}
|
||||
path := "/archives/" + url.PathEscape(device)
|
||||
if version := r.PathValue("version"); version != "" {
|
||||
path += "/" + url.PathEscape(version)
|
||||
} else {
|
||||
path += "?before=" + url.QueryEscape(r.URL.Query().Get("before"))
|
||||
}
|
||||
result, err := s.vescArchive(path)
|
||||
if err != nil {
|
||||
reply(w, 503, map[string]string{"error": "Архив конфигураций недоступен"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVESCArchiveAckRequiresExactBatchAndPairingScope(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
batch := map[string]any{"next": float64(9)}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`10`), batch)
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-a"))); !os.IsNotExist(err) {
|
||||
t.Fatal("incorrect ACK persisted")
|
||||
}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`9`), batch)
|
||||
data, err := os.ReadFile(filepath.Join(s.root, archiveCursorName("pair-a")))
|
||||
if err != nil || string(data) != "9" {
|
||||
t.Fatalf("durable cursor: %q %v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-b"))); !os.IsNotExist(err) {
|
||||
t.Fatal("history skipped on another pairing")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type sensorModel struct {
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
ActionTimeouts map[string]time.Duration
|
||||
// This model may prepare an attachment only to read its protocol identity.
|
||||
ProtocolIdentity bool
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
@@ -44,6 +46,11 @@ var sensorModels = []sensorModel{
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second},
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")},
|
||||
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.6.3",
|
||||
Vendor: "0483", Product: "5740", USBName: "ChibiOS/RT Virtual COM Port", Socket: "/run/mission-core-vesc/driver.sock",
|
||||
PrepareUnit: "mission-core-node-vesc-prepare.service", Report: "/var/lib/mission-core-node-profiles/vesc/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"vesc.link.check": 45 * time.Second, "vesc.motor.run": 90 * time.Second, "vesc.drive.run": 120 * time.Second, "vesc.hall.measure": 60 * time.Second, "vesc.foc.calibrate": 300 * time.Second, "vesc.motor.pulse": 60 * time.Second, "vesc.control.release": 60 * time.Second},
|
||||
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
@@ -74,6 +81,22 @@ func modelDeviceID(model *sensorModel, serial string) string {
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func preparedDeviceID(command SensorCommand, result any) string {
|
||||
model := modelForDevice(command.Session.DeviceID)
|
||||
if model == nil {
|
||||
return ""
|
||||
}
|
||||
if !model.ProtocolIdentity {
|
||||
return command.Session.DeviceID
|
||||
}
|
||||
if value, ok := result.(map[string]any); ok {
|
||||
if id, ok := value["device_id"].(string); ok && modelForDevice(id) == model {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
@@ -110,7 +133,7 @@ func discoverSensors(root string) []usbSensor {
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
if item.model.ProtocolIdentity || !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *pro
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранного устройства", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
@@ -217,18 +217,20 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
return nil, errors.New("Устройство переподключено во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
matches := item["id"] == c.Session.DeviceID || (model.ProtocolIdentity && item["attachment_id"] == c.Session.DeviceID)
|
||||
if !matches || item["online"] != true || item["prepared"] != true || (model.ProtocolIdentity && item["verified"] != true) {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.DeviceID, _ = item["id"].(string)
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс устройства.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
@@ -237,7 +239,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
message = "Не удалось проверить выбранное устройство."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
@@ -249,7 +251,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
return nil, errors.New("Драйвер установлен, но устройство не ответило. Проверьте питание и USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
|
||||
t.Helper()
|
||||
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
|
||||
for key, value := range map[string]string{"idVendor": "0483", "idProduct": "5740"} {
|
||||
if err := os.WriteFile(filepath.Join(s.usbRoot, port, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCProvisionalIdentityIsReadOnlyAndDoesNotWeakenCameras(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
fakeVESC(t, s, "1-3", "3")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatal("both controllers must be visible")
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if item["initializable"] != true || item["prepared"] != false {
|
||||
t.Fatal("identity bootstrap unavailable or falsely prepared")
|
||||
}
|
||||
c := sensorTestCommand()
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
for _, action := range []string{"start", "stop", "option", "settings.apply", "firmware.write"} {
|
||||
c.Action = action
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("VESC acquired write authority", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items[0].(map[string]any)["id"] == items[1].(map[string]any)["id"] {
|
||||
t.Fatal("duplicate USB serial collapsed")
|
||||
}
|
||||
fakeUSB(t, s.usbRoot, "2-2", "same", "Insta360 X4", "4")
|
||||
fakeUSB(t, s.usbRoot, "2-3", "same", "Insta360 X4", "5")
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["kind"] == "insta360.x4" && item["initializable"] != false {
|
||||
t.Fatal("camera guard weakened")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCPreparePromotesAttachmentToProtocolUUID(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
first := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
attachment := first["id"].(string)
|
||||
model := modelForDevice(attachment)
|
||||
stable := modelDeviceID(model, "uuid:synthetic-controller")
|
||||
var installed atomic.Bool
|
||||
s.runPreparation = func(_ context.Context, unit string) error {
|
||||
if unit != "mission-core-node-vesc-prepare.service" {
|
||||
t.Error("wrong profile")
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
item := s.discovery(stable, "12", true)
|
||||
item["prepared"] = true
|
||||
item["verified"] = true
|
||||
item["configured"] = true
|
||||
item["attachment_id"] = attachment
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "protocol_session"
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action != "verify" || command.Session.DeviceID != stable || command.Session.SessionID != "protocol_session" {
|
||||
t.Error("verification did not follow protocol identity")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": stable}}), nil
|
||||
})
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: attachment, SessionID: sensorSessionID(first)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "complete" {
|
||||
t.Fatal(s.Get(c.ID))
|
||||
}
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["id"] != stable {
|
||||
t.Fatal("provisional row survived promotion")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[stable], s.initialized[attachment]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("initialization persisted transport identity")
|
||||
}
|
||||
// A stale runtime attachment is never presented as a currently connected controller.
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "1-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items = s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["online"] != false {
|
||||
t.Fatal("unplugged driver observation accepted")
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,9 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
}
|
||||
for _, op := range s.operations {
|
||||
if op.Command.Action == "prepare" && op.State == "complete" {
|
||||
s.initialized[op.Command.Session.DeviceID] = true
|
||||
if id := preparedDeviceID(op.Command, op.Result); id != "" {
|
||||
s.initialized[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
@@ -192,7 +194,7 @@ func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path stri
|
||||
}
|
||||
response, e := client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
return nil, errors.New("Служба устройства недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
@@ -241,8 +243,24 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
if model.ProtocolIdentity {
|
||||
if !currentCameraSnapshot(item, model) {
|
||||
continue
|
||||
}
|
||||
attachment, _ := item["attachment_id"].(string)
|
||||
matched := false
|
||||
for _, candidate := range usb {
|
||||
if candidate.model == model && candidate.id == attachment {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seen[attachment] = true
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
if model.PrepareUnit != "" && !model.ProtocolIdentity {
|
||||
item["configured"] = s.initialized[id]
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
@@ -325,13 +343,18 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
// VESC bootstrap grants only a fixed identity read on the selected attachment.
|
||||
// This does not promote the attachment to stable identity or allow motor control.
|
||||
if online && model.ProtocolIdentity {
|
||||
initializable = true
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.config.backup"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
@@ -365,10 +388,10 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||
}
|
||||
}
|
||||
@@ -411,11 +434,11 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
err = errors.New("Устройство не обнаружено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
err = errors.New("Устройство отключено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
err = errors.New("Не удалось однозначно определить устройство. Проверьте его идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
@@ -451,7 +474,12 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err == nil && c.Action == "prepare" {
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
initializedID := preparedDeviceID(c, result)
|
||||
if initializedID == "" {
|
||||
err = errors.New("Драйвер не подтвердил личность устройства.")
|
||||
} else {
|
||||
s.initialized[initializedID] = true
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
err = e
|
||||
uncertain = true
|
||||
@@ -496,6 +524,7 @@ func (s *Sensors) RemoteResults() []any {
|
||||
return out
|
||||
}
|
||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
||||
s.configurationRoutes(mux, server)
|
||||
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
|
||||
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
if server.authorized(w, r) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-vesc-prepare.service" || unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
@@ -36,6 +36,7 @@ def provenance():
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||
"vesc_plugin_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/vesc").rglob("*")) if p.is_file() and not any(x in p.relative_to(ROOT.parents[1]).parts for x in ("__pycache__", "build"))},
|
||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BINARY_VERSION = "0.8.21"
|
||||
VERSION = "0.8.21-3"
|
||||
BINARY_VERSION = "0.8.35"
|
||||
VERSION = "0.8.35-1"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
@@ -44,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -111,6 +111,8 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
import runpy
|
||||
files.extend(runpy.run_path(str(ROOT.parents[1] / "plugins/vesc/packaging/payload.py"))["payload"]())
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def files(root):
|
||||
@@ -73,6 +73,13 @@ def build(qualified, node_only=False):
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
native_root = REPO / "plugins/vesc"
|
||||
native = json.loads((native_root / "packaging/native-runtime.json").read_text())
|
||||
native_path = native_root / "build/native-runtime" / native["file"]
|
||||
native_bytes = native_path.read_bytes()
|
||||
if len(native_bytes) != native["bytes"] or hashlib.sha256(native_bytes).hexdigest() != native["sha256"]:
|
||||
raise ValueError("Qualified VESC Tool runtime changed")
|
||||
entries[REPO.name + "/plugins/vesc/build/native-runtime/" + native["file"]] = native_path
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
|
||||
@@ -5,7 +5,7 @@ mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных драйверов на этом Ubuntu-компьютере.' 'Подготовка VESC и X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log"
|
||||
mc_node_install_result=${PIPESTATUS[0]}
|
||||
|
||||
@@ -351,7 +351,7 @@ def main():
|
||||
if report["state"] != "complete":
|
||||
raise RuntimeError(report["error"])
|
||||
print(
|
||||
"Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.",
|
||||
"Mission Core Node обновлён. VESC и X4 можно подготовить из списка устройств в Node или Core.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ def main():
|
||||
sys.path.insert(0, str(node / "packaging"))
|
||||
from build_deb import BINARY_VERSION, VERSION, build
|
||||
|
||||
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
|
||||
binary = node / "build/node-agent-linux-amd64"
|
||||
run(
|
||||
"node-binary",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-vesc/clear_runtime_cache.py
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
@@ -15,6 +16,9 @@ case "$1" in
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
if [ -f /var/lib/mission-core-node-profiles/vesc/preparation.json ]; then
|
||||
systemctl start mission-core-node-vesc-prepare.service
|
||||
fi
|
||||
if [ -f /run/mission-core-node-k1-upgrade-active ]; then
|
||||
# A jointly upgraded plugin starts itself after its own configuration.
|
||||
# Restore it here only when that package is already configured.
|
||||
|
||||
@@ -18,6 +18,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -38,6 +42,7 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
(umask 077; : > /run/mission-core-node-k1-upgrade-active)
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
systemctl stop mission-core-vesc.service 2>/dev/null || true
|
||||
systemctl stop mission-core-node-monitor.service 2>/dev/null || true
|
||||
fi
|
||||
. /etc/os-release
|
||||
|
||||
@@ -17,6 +17,10 @@ if [ -d /run/systemd/system ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -30,8 +34,19 @@ if [ -d /run/systemd/system ]; then
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
upgrade|remove|deconfigure)
|
||||
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
|
||||
systemctl disable --now mission-core-vesc.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
if cmp -s /usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules /etc/udev/rules.d/70-mission-core-vesc.rules; then
|
||||
rm /etc/udev/rules.d/70-mission-core-vesc.rules
|
||||
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
||||
fi
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
const configurationArchive:NonNullable<SensorTransport['configurationArchive']>={list:(device,before)=>request(`/api/device-configurations/${encodeURIComponent(device)}${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>request(`/api/device-configurations/${encodeURIComponent(device)}/${encodeURIComponent(version)}`)};
|
||||
const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
@@ -353,3 +353,17 @@ Before A3 or any subsequent LAB UI change:
|
||||
7. visually inspect normal and expanded modes at representative viewport sizes.
|
||||
8. verify every bounded `ENNResult.tsx` uses the v1 summary and result
|
||||
components without raw canonical report markup.
|
||||
|
||||
|
||||
## Hardware synchronization feedback — owner requirement, 2026-09-25
|
||||
|
||||
When an operator action must wait for equipment synchronization, preparation
|
||||
or completion of a previous operation, show its current phase next to the
|
||||
action using canonical status components. Do not silently disable the entry
|
||||
point or present an unexplained frozen control. Keep settings accessible;
|
||||
gate the actual hardware action on readiness and explain the reason.
|
||||
A busy phase uses the canonical warning status; confirmed readiness alone
|
||||
uses success. Missing/stale data or failure must say so, never imply endless
|
||||
active synchronization. Status follows real lifecycle responses, not a timer
|
||||
that pretends completion. This is an application-wide interaction requirement;
|
||||
other equipment workflows need their own lifecycle verification.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Rover 006 · VESC · передача контекста
|
||||
|
||||
Дата среза: 23.09.2026. Этап: исследование и подготовка следующей реализации.
|
||||
Документ отделяет требования владельца, проверенное состояние исходников,
|
||||
исторические аппаратные результаты и предлагаемый план. Это не отчёт об
|
||||
установке VESC на борт и не разрешение автоматически запускать моторы.
|
||||
|
||||
## 1. Задача и решение
|
||||
|
||||
Добавить силовое оборудование Rover 006 в существующую архитектуру Mission
|
||||
Core Node: обнаружение контроллеров, достоверная идентификация, связь с моторами
|
||||
и назначением «левый/правый», диагностика и впоследствии настройка через обе UI.
|
||||
Оператор работает локально на борту либо удалённо из «Парк → Аппараты» Core.
|
||||
Оба интерфейса обращаются к одному владельцу оборудования на борту.
|
||||
|
||||
VESC Tool — открытый проект; полное обратное проектирование закрытой программы
|
||||
не является исходной задачей. Предлагаемый первый результат — чтение личности,
|
||||
версий, конфигураций и диагностики двух каналов. Калибровка — следующий отдельно
|
||||
подготовленный аппаратный опыт. Новая прошивка контроллера ради автообнаружения
|
||||
не требуется. Совместимость конкретных установленных контроллеров ещё неизвестна.
|
||||
|
||||
Контур симуляции, Worker/AI, управление движением ровера и повторная переработка
|
||||
Rerun не входят в эту работу. Они не являются зависимостями диагностики VESC.
|
||||
|
||||
## 2. Что сообщил владелец
|
||||
|
||||
- Целевой аппарат — NDC Rover 006, бортовой компьютер — существующий Mac Mini
|
||||
с Ubuntu и Mission Core Node. По последнему сообщению владельца борт offline.
|
||||
- Нужно обнаруживать совместимые подключённые контроллеры независимо от
|
||||
конкретных серийных номеров, в том числе после замены оборудования.
|
||||
- Желаемая предметная структура: VESC Left / VESC Right и Motor Left / Motor
|
||||
Right. Это **назначения экземпляров**, а не четыре аппаратные модели.
|
||||
- Один канал нормально работает от пульта. Проблемный при резкой подаче газа
|
||||
делает примерно пол-оборота и останавливается; при плавной подаче раскручивается.
|
||||
- Гусеница снята, механические предположения уже проверялись. Сборщик с большим
|
||||
опытом изучил видео и предполагает потерю настройки/проблему прошивки VESC.
|
||||
- Проблемный мотор удалось раскрутить до максимума, после чего исправный
|
||||
нормально ускорялся. Это важный аргумент против простого общего недостатка
|
||||
мощности аккумулятора, но не измерение напряжения/тока отдельного контроллера.
|
||||
- Указания стороны в устной истории неоднозначны. До физического подтверждения
|
||||
использовать «проблемный канал» и «исправный канал», не назначать Left по догадке.
|
||||
- USB-кабель контроллера будет подключён к борту. Схема «два USB / один USB и
|
||||
CAN / двухканальная плата» пока не установлена.
|
||||
|
||||
Приоритетная рабочая гипотеза — конфигурация/прошивка/управление проблемного
|
||||
канала. Не начинать заново с предположения о камне в гусенице. Одновременно не
|
||||
объявлять калибровку доказанной причиной до чтения конфигурации и ошибок.
|
||||
|
||||
## 3. Канонические источники и Ops
|
||||
|
||||
Прочитать перед реализацией:
|
||||
|
||||
1. [Правила репозитория](../../AGENTS.md),
|
||||
[политика артефактов](../03_ARTIFACT_POLICY.md).
|
||||
2. [Карта монорепозитория](../07_MISSION_CORE_MONOREPO.md),
|
||||
[архитектура приложения](../18_APPLICATION_COMPONENT_ARCHITECTURE.md),
|
||||
[правила расширения UI](../19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md).
|
||||
3. [Система, борт и аппарат](../node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md),
|
||||
[сопряжение Node/Core](../node/04_NODE_CORE_PAIRING_PROTOCOL.md),
|
||||
[один аппаратный владелец и две UI](../node/05_SENSOR_HOST_AND_SHARED_CONTROL.md).
|
||||
4. [Plugin SDK v0alpha2](../../packages/plugin-sdk/README.md),
|
||||
[ADR 0004](../adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md).
|
||||
5. [Последнее состояние Mini/X4](../node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md),
|
||||
[оставшиеся аппаратные проверки](../node/10_INSTA360_X4_NEXT_STEPS.md),
|
||||
[журнал установок](../node/07_INSTA360_X4_INSTALLATION_LEDGER.md).
|
||||
6. Для UI обязательно прочитать
|
||||
[mission-core-product-ui](../../.codex/skills/mission-core-product-ui/SKILL.md),
|
||||
затем названные в нём документы Design Guideline. Текущий handoff UI не меняет.
|
||||
|
||||
Ops — источник состояния карточек; локальный документ не заменяет его:
|
||||
|
||||
| Карточка | Зачем следующей задаче |
|
||||
| --- | --- |
|
||||
| [MISSIONCOR-84](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-84) | Новый профильный этап VESC: завершённое исследование, архитектура, симптомы, V0–V5 и открытая аппаратная приёмка. |
|
||||
| [MISSIONCOR-76](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-76) | Исходная архитектура Node, UI-FIRST / BRIDGE-ONLY, борт и общий контроль. Связь подтверждена документацией Node. |
|
||||
| [MISSIONCOR-77](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-77) | Последняя установка X4/Node и незавершённые аппаратные проверки. |
|
||||
| [MISSIONCOR-2](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-2) | Актуальный общий срез вне SIM и переход к VESC. |
|
||||
| [MISSIONCOR-74](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-74) | История переносимых кастомизаций Rerun. Не переписывать в VESC-задаче. |
|
||||
| [MISSIONCOR-81](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-81) | Планировщик и проверки проходов K1, отдельный потребитель наблюдения. |
|
||||
| [MISSIONCOR-80](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-80) | Сохранённые записи/обзор; источник контекста данных, не силового управления. |
|
||||
|
||||
Доступ восстановлен 23.09: прямые `nodedc-ops-agent/tasker_*` успешно прочитали
|
||||
инструкции, проекты, контекст и реестр 83 карточек MISSION CORE; карточка VESC
|
||||
в нём отсутствовала и создана отдельно как MISSIONCOR-84 (Backlog). Выполнена
|
||||
целевая актуализация 2/66/74/81, в 76 добавлен датированный комментарий без
|
||||
изменения замороженного baseline. SIM-карточки не редактировались. Исследованы
|
||||
профильные Node/X4/planning/Rerun/SDK/architecture материалы, а не весь Ops всех
|
||||
продуктов. Полный Desktop-документ содержит снимок выбранных карточек, их
|
||||
активные комментарии и инженерные приложения. Архивные планы — не новые команды.
|
||||
|
||||
## 4. Борт: установленное, исходники и неизвестное
|
||||
|
||||
| Предмет | Проверенное основание / граница |
|
||||
| --- | --- |
|
||||
| Машина | Apple Macmini6,2, amd64, Ubuntu 24.04.4 LTS Desktop; Linux 7.0.0-31-generic по журналам сентября. Сегодня борт не опрашивался. |
|
||||
| CPU/RAM/диск | Baseline 76 сообщает со слов владельца 8 GB RAM, одна планка. Свежие CPU/RAM/диск не измерены; получить read-only инвентаризацию. Это не операторский MacBook и не Worker 006. |
|
||||
| Последний задокументированный Node | 0.8.21-3 установлен 10.09.2026; это же package version в `packaging/build_deb.py`. |
|
||||
| X4 | Model package 0.1.3-9 (B17), установлена; чтение SDK/preview уже не «только USB enumeration». MANUAL02 с поздним receipt и другие проверки остаются открытыми. |
|
||||
| D455 | Реальные захват/запись и USB3 подтверждены исторически. Не перезапускать и не лишать прав при добавлении силового оборудования. |
|
||||
| K1 | Самостоятельная модель; бортовой путь — Wi-Fi Bridge общей сети, не operator-Mac fallback. |
|
||||
| VESC | Реализации модели в проверенных Node/fleet/SDK/plugin-каталогах не найдено. Контроллеры, firmware, моторы и проводка ещё не идентифицированы. |
|
||||
| Node UI | GTK/WebKit + встроенная React-сборка, локальный API на loopback:8780. Это не второй Core:8000. |
|
||||
| Служба | Независима от окна, состояние `/var/lib/mission-core-node`, версия/identity сохраняются при штатных обновлениях. |
|
||||
|
||||
Не путать Rover 006, onboard Mini, операторский MacBook Pro (18 GB RAM) и
|
||||
Worker 006. Совпадение «006» не означает один компьютер или один контур.
|
||||
|
||||
Обнаружено расхождение документации: `apps/node-agent/README.md` называет
|
||||
«current» 0.6.11; исходники пакета и последние Node-документы — 0.8.21-3.
|
||||
Первая фраза секции X4 в AGENTS фиксирует стартовое состояние 08.09; более
|
||||
поздние аппаратные результаты находятся в ledger и status 10.09. Не стирать
|
||||
историю и не ослаблять installer/safety-правила из-за этого расхождения.
|
||||
|
||||
## 5. Архитектурные инварианты
|
||||
|
||||
```text
|
||||
Core: Парк → Rover 006 Локальное приложение Node
|
||||
\ /
|
||||
один контракт операций
|
||||
|
|
||||
Node на Mini: состояние, права, задания
|
||||
|
|
||||
VESC-адаптер: один владелец USB/CAN-сеанса
|
||||
|
|
||||
контроллер → канал → физический мотор
|
||||
```
|
||||
|
||||
- `model != device instance != USB/CAN address != device session != роль L/R`.
|
||||
- Tailscale — транспорт. Он не создаёт доверие Core/Node и не заменяет mTLS,
|
||||
сопряжение, идентичность борта или права на опасные операции.
|
||||
- Core проецирует состояние и отправляет адресные операции. Он не сканирует
|
||||
USB операторского компьютера вместо борта и не управляет контроллером по SSH.
|
||||
- Одновременные локальная и удалённая UI не создают двух владельцев serial.
|
||||
- `acknowledged != completed`. Потерянный ответ на запись — неизвестный исход,
|
||||
а не основание автоматически повторять калибровку/прошивку.
|
||||
- Повторное подключение создаёт новый сеанс; старые команды отклоняются.
|
||||
Автообнаружение не означает автонастройку, автопрошивку или разрешение движения.
|
||||
- Существующий сенсорный контракт даёт идентичность/операции/состояния, но сам
|
||||
по себе **не доказывает безопасность привода**. Нужны отдельные правила
|
||||
обслуживания, владения RC/автономным управлением и аварийного останова.
|
||||
- Linux users/groups, точные udev-правила, драйвер, сервис и зависимости входят
|
||||
в versioned installer/profile с первого опыта. Не делать `chmod 666`, ручной
|
||||
global pip/apt и последующее обещание «упаковать позже».
|
||||
|
||||
Точки входа в код:
|
||||
|
||||
| Файлы | Ответственность |
|
||||
| --- | --- |
|
||||
| `apps/node-agent/internal/node/sensor_models.go` | Реестр моделей, USB discovery, stable/provisional identity. Сейчас D455/K1/X4; не добавлять мотор как фиктивную USB-камеру. |
|
||||
| `apps/node-agent/internal/node/sensors.go`, `sensor_events.go`, `sensor_preparation.go` | Сеансы, операции, события обнаружения, подготовка. |
|
||||
| `apps/node-agent/internal/node/pairing*.go` | Доверие, транспорт и восстановление Core/Node. |
|
||||
| `src/k1link/fleet/{registry,sensors,transport,trust,recovery}.py` | Сохранённый аппарат, проекция Node, очередь/результаты адресных операций. |
|
||||
| `packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/` | Portable identity, session, operations, runtime, evidence. |
|
||||
| `packages/sensor-ui/src/{pluginSdk,extensions,contracts}.ts` | Общая UI/transport boundary; будущий силовой detail — отдельная доменная композиция. |
|
||||
| `apps/node-agent/packaging/` | Версионные Linux build/deb/qualification/owner-release и профили. |
|
||||
| `plugins/insta360-x4/` | Пример модели и отдельного runtime; не копировать camera-specific semantics в VESC. |
|
||||
|
||||
## 6. Что подтверждено в upstream VESC
|
||||
|
||||
Первичные источники просмотрены 23.09.2026; ссылки на `master` подвижны.
|
||||
Перед реализацией закрепить конкретный release/commit и совместимые hardware/
|
||||
firmware. Это исследование исходников, а не аппаратная квалификация Rover 006.
|
||||
|
||||
- [VESC Tool](https://github.com/vedderb/vesc_tool): открытый Qt-проект, Linux
|
||||
поддерживается. Учесть GPL и отдельные условия товарного знака при интеграции
|
||||
и распространении; отдельный процесс сам по себе не решает лицензионный вопрос.
|
||||
- [CLI](https://github.com/vedderb/vesc_tool/blob/master/main.cpp): есть выбор
|
||||
serial/CAN, выгрузка motor/app config, firmware query, offscreen и TCP server.
|
||||
Наличие этих ключей не означает готовый стабильный REST backend всех функций.
|
||||
- [Commands](https://github.com/vedderb/vesc_tool/blob/master/commands.h): чтение
|
||||
firmware, values, motor/app config, CAN discovery отделено от setters,
|
||||
detect/measure, управления током/оборотами и прошивки.
|
||||
- [Firmware identity](https://github.com/vedderb/vesc_tool/blob/master/datatypes.h):
|
||||
FW_RX_PARAMS содержит HW, firmware и UUID. Их полноту/смысл проверять на реальной
|
||||
версии; одна строка HW не всегда устанавливает коммерческую модель платы.
|
||||
- [Autoconnect](https://github.com/vedderb/vesc_tool/blob/master/vescinterface.cpp):
|
||||
штатный поиск перебирает serial-порты и останавливается на первом ответившем
|
||||
устройстве. Для двух каналов он не заменяет наш полный inventory и L/R binding.
|
||||
- [TCP server](https://github.com/vedderb/vesc_tool/blob/master/tcpserversimple.cpp):
|
||||
простой транспорт не является нашей границей авторизации. Не публиковать
|
||||
сырой управляющий TCP в LAN/Tailscale/Internet только потому, что он существует.
|
||||
- [Firmware fault types](https://github.com/vedderb/bldc/blob/master/datatypes.h):
|
||||
различаются ошибки питания, тока, драйвера, датчиков и конфигурации. Поэтому
|
||||
«мотор остановился» недостаточно для вывода «слетела калибровка».
|
||||
|
||||
Для первого обследования разумно иметь официальный VESC Tool как инженерный
|
||||
инструмент на Linux Mini. Встроенный продуктовый путь — отдельный адаптер с
|
||||
проверенными разрешёнными операциями. Tool и адаптер не должны одновременно
|
||||
захватывать один serial-порт. Автозагрузка произвольного QML/Lisp с устройства
|
||||
и свободный terminal passthrough не входят в исходную read-only поверхность.
|
||||
|
||||
## 7. Обнаружение и устройство предметной модели
|
||||
|
||||
1. Получить OS USB/serial inventory без отправки команд всем найденным портам.
|
||||
2. Отобрать кандидатов по подтверждённым дескрипторам/профилю совместимости.
|
||||
Затем ограниченный протокольный запрос личности и версии, без motor setters.
|
||||
3. Проверить, скрываются ли другие контроллеры за CAN. Не включать широкие
|
||||
broadcast-записи, detect-all или смену CAN baudrate ради инвентаризации.
|
||||
4. Сохранить аппаратную личность и новый транспортный сеанс. Пустой/дублированный
|
||||
UUID — provisional/ambiguous, не «первый порт = левый».
|
||||
5. Модель платы подтвердить firmware/HW плюс маркировкой или документацией
|
||||
производителя. USB VID/PID и UUID сами по себе не дают все характеристики.
|
||||
6. Физический мотор обычно не USB-устройство. Его модель, датчики, допустимые
|
||||
характеристики и соединение с каналом подтверждаются маркировкой/сборщиком.
|
||||
Электрическое измерение параметров не является распознаванием производителя.
|
||||
7. Назначить логические Left/Right после подтверждения проводки владельцем.
|
||||
Новая плата обнаруживается автоматически, но не наследует молча калибровку
|
||||
и разрешение движения старой. Двухканальный контроллер моделировать честно,
|
||||
не создавать два вымышленных корпуса.
|
||||
|
||||
## 8. Диагностика проблемного канала
|
||||
|
||||
Сначала зафиксировать firmware/HW и конфигурации **обоих** каналов: motor config,
|
||||
app/input config и поддержанные custom configs. Сохранить исходные файлы,
|
||||
UUID/версию/время/хеш в приватном evidence; в Git/Ops — очищенный отчёт.
|
||||
|
||||
Сравнить по смыслу: режим управления и датчиков, ramp/лимиты, вход пульта,
|
||||
настройки CAN и тайм-аутов, параметры FOC/Hall/encoder, ошибки и телеметрию.
|
||||
Не копировать весь конфиг исправного контроллера в проблемный: отличаются
|
||||
направление, адрес, датчики и собственные параметры канала.
|
||||
|
||||
При отдельном согласованном опыте записать одновременно команду RC, ERPM,
|
||||
токи/напряжение, температуры и fault в момент плавного и резкого старта.
|
||||
Установить поддержку этих измерений на конкретной firmware. Нулевой fault
|
||||
после перезагрузки не доказывает отсутствие ошибки в предыдущем опыте.
|
||||
|
||||
Только затем выбрать адресную коррекцию настройки или motor detection. Detection
|
||||
может подавать ток и вращать мотор; это не безобидная кнопка USB discovery.
|
||||
До опыта необходимы безопасно закреплённый аппарат, исключённое конкурирующее
|
||||
управление, доступный аварийный останов и подтверждённые пределы оборудования.
|
||||
Здесь намеренно нет придуманных значений ампер/вольт/ERPM.
|
||||
|
||||
Перепрошивка не первый шаг: нужен точный образ производителя для точного HW,
|
||||
совместимость конфигов, сохранённый исходный baseline и план восстановления.
|
||||
|
||||
## 9. Этапы реализации и критерии готовности
|
||||
|
||||
| Этап | Результат | Условие перехода |
|
||||
| --- | --- | --- |
|
||||
| V0 · baseline | Свежий Node inventory, версии, схема подключения и подтверждённые стороны. | Борт действительно доступен; нет предположений вместо моделей. |
|
||||
| V1 · discovery/read | Версионный адаптер; оба контроллера, конфиги, ошибки, приватный backup. | Hotplug/смена порта/перезапуск сохраняют правильные личности; ни одного motor/config write. |
|
||||
| V2 · Core + Node | Одна доменная карточка силового оборудования и общий backend. | Обе UI видят тот же возраст данных/сеанс/операции; offline честный; нет второго serial owner. |
|
||||
| V3 · diagnosis | Отчёт «какая команда, какая реакция, какая ошибка», выбранная проверяемая гипотеза. | Не только словесное «откалибровали», а сохранённые before/after evidence. |
|
||||
| V4 · calibration/config | Явно разрешённая адресная операция обслуживания, diff/readback и результат. | Работает отмена/ошибка/потерянный ответ; нет blind retry и изменения соседнего канала. |
|
||||
| V5 · расширение Tool | Матрица функций: доступно/несовместимо/опасная операция/ещё не реализовано. | «Все функции» не объявляются готовыми по наличию одной кнопки. |
|
||||
|
||||
Тесты до аппаратных записей: парсинг повреждённых/неполных пакетов, неподдержанная
|
||||
firmware, тайм-аут, два одинаковых устройства, отсутствующая/дублированная
|
||||
личность, USB reorder, CAN alias, занятый порт, смена сеанса, stale state,
|
||||
дедупликация, неизвестный outcome. На железе отдельно проверить bounded чтение,
|
||||
нагрузку CPU/RAM, сохранность D455/X4 и отсутствие непрошеного движения.
|
||||
Не запускать нагрузочные тесты на операторском MacBook.
|
||||
|
||||
## 10. Репозиторий, публикация и граница параллельной работы
|
||||
|
||||
Проверенный checkout: `NODEDC_MISSION_CORE_m5_observatory`, ветка `main`,
|
||||
HEAD `2e5d52521f600408bfd6b65bd8caed99ccb09405`.
|
||||
Remote — `https://git.dcserve.ru/SILVER/NODEDC_MISSION_CORE.git`.
|
||||
Checkout `NODEDC_MISSION_CORE_node` и базовый `NODEDC_MISSION_CORE` находятся
|
||||
на detached `76dc9f1`; не принимать их автоматически за актуальную Node-ветку.
|
||||
|
||||
В рабочем дереве идёт чужая работа по SIM/AI polygon и восстановлению служб,
|
||||
включая общие App/styles/web файлы. Не включать её в VESC-коммит, не делать
|
||||
`git add -A`, reset/checkout, общий merge или перезапуск чужих процессов.
|
||||
23.09 выполнен обычный fast-forward push `c804d89 → 2e5d525`, включая один
|
||||
50 MB WASM в LFS. Последующий `ls-remote` подтвердил точный HEAD на remote main.
|
||||
Незакоммиченная работа SIM не включалась; рабочее дерево не объявляется чистым.
|
||||
Для реализации выбрать согласованный актуальный checkout/изолированный worktree;
|
||||
этот документ не даёт разрешения переносить или останавливать соседнюю задачу.
|
||||
|
||||
## 11. Сеть и текущие ограничения проверки
|
||||
|
||||
Правильные имена подтверждены источниками, а не голосовой транскрипцией:
|
||||
`git.dcserve.ru` (git remote), `ops.nodedc.ru` (документы),
|
||||
`ops-agents.nodedc.ru` и `foundry.nodedc.ru` (настройки подключений),
|
||||
`hub.nodedc.ru` (редирект входа Foundry).
|
||||
|
||||
23.09 проверены DNS и HTTPS через обычный сетевой интерфейс при включённом
|
||||
hidemy.name VPN: Git/Ops/Ops-agent вернули HTTP 200, Foundry — редирект на Hub,
|
||||
Hub root — HTTP 404. Последнее доказывает достижимость HTTPS, не успешный login.
|
||||
Все пять доменов в этом замере имели общий IPv4. Владелец подтвердил системный
|
||||
admin prompt; точечный host-route восстановил обычный Git и прямой Ops MCP.
|
||||
Затем LAN сменилась, старый шлюз перестал работать; обновлён только наш маршрут
|
||||
на подтверждённый DHCP-шлюз новой сети. VPN и Tailscale остались подключены.
|
||||
Владелец подтвердил вход в Hub. Постоянный helper не установлен: после смены
|
||||
сети/DNS/reboot маршрут нужно перепроверять. NAT Firewall в VPN-панели разрешает или
|
||||
блокирует трафик внутри VPN, но не выбирает локальный обход туннеля.
|
||||
|
||||
Core на `127.0.0.1:8000` ответил HTTP 200. В этом аудите не запускались сборки,
|
||||
новые серверы, аппаратные команды, калибровка, firmware upload и установка на
|
||||
Mini. Просмотрены исходники/документы и официальные upstream-источники.
|
||||
|
||||
## 12. Выполненная актуализация Ops и оставшиеся границы
|
||||
|
||||
- MISSIONCOR-84 создана с исследованием, фактами владельца, источниками,
|
||||
архитектурой, диагностикой, планом и чекером. Реализация остаётся открыта.
|
||||
- MISSIONCOR-76: добавлен комментарий-связь; замороженное тело не менялось.
|
||||
- MISSIONCOR-2: добавлен текущий срез вне SIM; исторические блоки сохранены.
|
||||
- MISSIONCOR-74/81: публикация `2e5d525` подтверждена новым блоком; старые
|
||||
сообщения о сетевой недоступности сохранены со своими датами.
|
||||
- MISSIONCOR-66: добавлено уточнение, что current native viewer теперь имеет
|
||||
принятый ограниченный patch 0.36.3. Срез 05.09 не переписан задним числом.
|
||||
- X4 MANUAL02/local video/clean-Ubuntu и safety/absolute-accuracy проверки
|
||||
планировщика не закрывались. SIM-карточки и runtime не изменялись.
|
||||
- Сетевой skill дополнен проверенным поведением и сменой LAN; валидатор прошёл.
|
||||
|
||||
## 13. Краткий вход для следующей задачи
|
||||
|
||||
> Прочитай этот handoff и названные каноны, затем восстанови прямой Ops-контекст.
|
||||
> Работаем с силовым оборудованием NDC Rover 006 через существующий Ubuntu Node
|
||||
> на Mac Mini. SIM не трогаем. Начни со свежего read-only baseline, точных моделей,
|
||||
> топологии USB/CAN, backup конфигов двух контроллеров и подтверждения сторон.
|
||||
> Конфигурация/firmware проблемного канала — приоритетная гипотеза, не доказанный
|
||||
> диагноз. Не запускай detection, моторы или flashing при автообнаружении.
|
||||
> Спроектируй один бортовой адаптер для локальной и удалённой UI; реализуй первый
|
||||
> безопасный вертикальный read-only сценарий с версионным installer/profile.
|
||||
> Отдельно предложи проверку причины остановки при резком газе и адресную
|
||||
> коррекцию с сохранением before/after. В Ops записывай завершённые результаты,
|
||||
> а не обещай принятую аппаратную работу без опыта.
|
||||
@@ -0,0 +1,331 @@
|
||||
# Rover 006 — VESC: аудит кода и план реализации
|
||||
|
||||
Исторический план, составленный до реализации 23 сентября 2026. Текущий статус
|
||||
см. `../node/17_VESC_INSTALLATION_LEDGER.md` и MISSIONCOR-84.
|
||||
|
||||
Результат первоначального аудита — исследование, свежий read-only
|
||||
baseline и план. Плагин ещё не реализован; конфиги контроллеров не прочитаны,
|
||||
моторы не запускались. Исторические команды и планы приложенного документа
|
||||
рассматривались как источники, а не как поручения на исполнение.
|
||||
|
||||
## 1. Целевой пользовательский результат
|
||||
|
||||
После подключения контроллеров к USB бортового Mac Mini они появляются в
|
||||
«Устройствах» Node и в «Парк → Rover 006 → Устройства» основного Core.
|
||||
В карточке выбранного контроллера есть вход **«VESC Tool»**. Через него доступны
|
||||
настройка, диагностика, калибровка и остальные применимые функции Tool.
|
||||
Исполнение принадлежит борту; обе UI используют одну реализацию предметного
|
||||
интерфейса и одни операции. Полнота Tool остаётся целевым требованием,
|
||||
первый read-only выпуск является отдельным промежуточным результатом.
|
||||
|
||||
Ближайшая физическая задача — разобраться с потерей оборотов одного мотора.
|
||||
Слова владельца о левом канале остаются предположением до сопоставления.
|
||||
Предыдущие наблюдения: плавный старт возможен, резкий может давать короткий
|
||||
рывок и остановку; конфигурация, датчики и управление — приоритетная ветка
|
||||
диагностики. Причина пока не измерена.
|
||||
|
||||
## 2. Что проверено сейчас
|
||||
|
||||
| Объект | Факт 23.09 | Ограничение |
|
||||
| --- | --- | --- |
|
||||
| Core | Канонический localhost:8000 отвечает; Rover 006 paired/online | Не означает доступность каждого устройства |
|
||||
| SSH | Вход `dcsudo` с существующим персональным ключом работает | Старая запись `nodedc-edge` использовала `ndcsudo`; sudo не проверялся и не нужен для чтения |
|
||||
| Борт | Ubuntu 24.04.4, kernel 7.0.0-31-generic, i7-3615QM, 4 ядра/8 потоков | CPU поддерживает AVX, но AVX2 в полученном наборе флагов нет |
|
||||
| Память/диск | Около 8 GB RAM, около 6.3 GiB available; swap не занят; root 457 GiB, свободно 381 GiB | Это короткий idle-срез, не совместная нагрузочная приёмка |
|
||||
| Установка | Node 0.8.21-3, K1 0.1.14, X4 0.1.3-9 | Старый Node README с 0.6.11 не является installed baseline |
|
||||
| Службы | Node, K1, X4 broker, D455, monitor, PostgreSQL active/running, NRestarts=0 в проверенном наборе | Активный драйвер не доказывает подключение камеры |
|
||||
| База | PostgreSQL 16, cluster `ndcmonitor` online; Timescale 2.29.2 установлен | База системного мониторинга, не готовый motor recorder |
|
||||
| Реплика мониторинга | available/fresh=true, storage=ready, backlog=0; sample interval 1 s | Срез около 11:02 UTC; полевая задержка управления не измерена |
|
||||
| USB контроллеров | Два кандидата `0483:5740`, ChibiOS/RT Virtual COM Port, cdc_acm, два ttyACM | USB-дескриптор ещё не доказательство HW/FW VESC |
|
||||
| Identity | У двух кандидатов одинаковый USB serial; одна конфликтующая by-id ссылка | Нельзя использовать USB serial, by-id, tty или порядок включения как постоянную личность |
|
||||
| Права | tty принадлежат root:dialout, 0660; dcsudo не имеет read/write | Права будущего runtime поставляются профилем модели |
|
||||
| Конкурирующий опрос | ModemManager active; кандидаты имеют ID_MM_CANDIDATE=1 | Не доказано, что он уже посылал команды; нужен адресный udev ignore при подготовке модели |
|
||||
| Камеры | D455/X4 в свежем paired inventory offline | Сохранность их потоков под VESC-нагрузкой сейчас проверить нельзя |
|
||||
|
||||
Портов serial не открывали; firmware/UUID, моторные и application configs,
|
||||
CAN/RC topology и физические стороны остаются неизвестными. VESC Tool не найден
|
||||
через проверенное имя `vesc_tool` в PATH; это не полный поиск всех установок.
|
||||
|
||||
Подробный путь доступа: [ROVER_006_ENGINEERING_ACCESS.md](../runbooks/ROVER_006_ENGINEERING_ACCESS.md).
|
||||
Raw fleet/monitor snapshots и приватный SSH-профиль находятся вне Git в
|
||||
операторском `outputs/rover-006-vesc-context-20260923`.
|
||||
|
||||
## 3. Источники и границы исследования
|
||||
|
||||
Прочитаны основной актуальный срез и VESC-handoff приложенного документа,
|
||||
профильные Node/SDK/UI материалы и релевантная история приложений. Большой
|
||||
архив планировщика/Rerun использован для контекста владельцев; повторной
|
||||
квалификации всех старых экспериментов не выполнялось.
|
||||
|
||||
Через прямой Ops MCP получены живые проекты/контекст/карточки, в том числе
|
||||
MISSIONCOR-84, 76, 77, 50, 5, 2, и история комментариев 76/77/84; изучен
|
||||
архив ROBOT2B-5. Профильная карта —
|
||||
[MISSIONCOR-84](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-84), Node —
|
||||
[MISSIONCOR-76](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-76).
|
||||
Поиск GPIO в других четырёх доступных Ops-проектах совпадений не вернул;
|
||||
среди полученных Mission Core/ROBOT2B карточек подтверждённой схемы GPIO
|
||||
для этого привода не найдено. Это пробел найденных источников, не утверждение
|
||||
об отсутствии такой схемы вообще. USB достаточно для первого этапа;
|
||||
GPIO/UART/RC/аварийная цепь требуют конкретной аппаратной схемы до motor tests.
|
||||
|
||||
Основная кодовая база: `NODEDC_MISSION_CORE_m5_observatory`, HEAD `2e5d525`.
|
||||
Соседние base/node checkout остаются detached `76dc9f1`. В main есть чужая
|
||||
незавершённая работа SIM/AI polygon и service recovery. Для реализации нужен
|
||||
отдельный checkout от проверенного commit; не включать эти изменения в пакет.
|
||||
|
||||
Design Guideline прочитан по реестрам и документации, текущий HEAD
|
||||
`8dd9190573d6616024ef01b9b34bf90b72960f44`, рабочее дерево чистое в проверке.
|
||||
Node build_linux_source.py закреплён на `8c53f73...` и отклоняет другой HEAD.
|
||||
Перед выпуском выбрать проверенную ревизию DG и квалифицировать её в сборке;
|
||||
не снимать проверку и не брать случайные локальные dist.
|
||||
|
||||
## 4. Реальные точки расширения
|
||||
|
||||
| Код | Что уже есть | Что нужно VESC |
|
||||
| --- | --- | --- |
|
||||
| `apps/node-agent/internal/node/sensor_models.go` | Registry, model actions, отдельные IPC sockets, USB discovery, provisional identity при дублях | Новый model profile; разделение attachment и protocol UUID; discovery двух плат с одинаковым USB serial |
|
||||
| `sensors.go` | Durable fsync journal, dedup, action allowlist, deadline, unknown после restart, local API | Точные VESC actions и параметры; проверка session непосредственно в адаптере; bounded результаты; не прятать моторные функции за camera `start`/`option` |
|
||||
| `sensor_preparation.go` | Профиль на модель и проверка отдельного экземпляра, shared preparation job | Device-neutral подписи и безопасная identity verification. Нельзя трактовать `verify` как motor detection |
|
||||
| `sensor_events.go` | udev events, coalescing, полные SSE snapshots | Reconnect создаёт новый session; stale GUI не получает authority над новым контроллером |
|
||||
| `pairing_transport.go` | mTLS heartbeat, commands/results/acks; периодический tick 5 s и пробуждение от событий | Существующий путь для service operations; отдельная доставка частой telemetry и будущего realtime control |
|
||||
| `src/k1link/fleet/sensors.py` | Проверки pairing/freshness/session, очередь, receipts, ограниченные payloads | Добавить явные actions; не превращать whitelist в arbitrary protocol passthrough |
|
||||
| `packages/plugin-sdk/.../v0alpha2` | Identity/session, safety/idempotency policies, commands/events | Использовать существующие contracts; motor authority/lease и конфиг revision оформить узким дополнением |
|
||||
| `packages/sensor-ui` | Общий SensorWorkspace, transport, Detail contribution, status/preparation | VESC Detail в том же slot; camera-specific поля/подписи нормализовать ровно там, где нужно |
|
||||
| `apps/node-agent/ui/src/NodeSensors.tsx` | Локальная композиция общих plugins | Зарегистрировать ту же VESC contribution |
|
||||
| `apps/control-station/src/core/fleet/sensorTransport.ts` и composition | Remote adapter общего UI | Повторно использовать; новая предметная логика в plugin, не App.tsx |
|
||||
| `plugins/insta360-x4/runtime/operations.py` | fsync receipt до физического действия, отсутствие replay, readback settings | Проверенный пример lifecycle, но motor safety проектируется отдельно |
|
||||
| Node monitor/storage и fleet monitor replica | 1 s host samples → Timescale → bounded Core replica | Не использовать этот период как осциллограф или контур stop; моторная сессия пишет данные на борту с собственной частотой |
|
||||
|
||||
В этих действующих реестрах и каталогах VESC runtime отсутствует. Архитектурные
|
||||
документы старого этапа местами описывают gRPC как целевой вариант; текущая
|
||||
проверенная реализация использует JSON HTTP/Unix sockets и HTTPS heartbeat.
|
||||
|
||||
## 5. Интеграция VESC Tool
|
||||
|
||||
Для аудита закреплён официальный upstream:
|
||||
[`dc53c658cbb89a947246034f7a00149cf79abdfc`](https://github.com/vedderb/vesc_tool/tree/dc53c658cbb89a947246034f7a00149cf79abdfc).
|
||||
Сохранены 17 исходных файлов с SHA-256. Этот snapshot объявляет **7.01,
|
||||
test version 1**; это исследовательская точка, не автоматически выбранный
|
||||
production release для неизвестной firmware наших плат.
|
||||
|
||||
Исходники показывают:
|
||||
|
||||
- `main.cpp`: CLI умеет конкретные чтения/записи config, выбор port/CAN,
|
||||
offscreen и TCP. Это не готовый полный web API.
|
||||
- `vescinterface.cpp`: autoconnect обходит serial ports и заканчивает поиск
|
||||
на первом ответе. Для инвентаризации двух плат нужен наш ограниченный поиск.
|
||||
- `commands.cpp`/`datatypes.h`: FW response содержит version, HW name, UUID
|
||||
и дополнительные признаки; набор полей зависит от ответа. HW name нельзя
|
||||
автоматически считать точной коммерческой моделью платы.
|
||||
- `configparams.cpp`/`utility.cpp`: schema выбирается по firmware, сериализация
|
||||
использует signature. Парсить конфиг произвольной новой firmware старой
|
||||
схемой и затем сохранять его нельзя.
|
||||
- `packet.cpp`: length/framing/CRC и размер пакета ограничены. Нужны tests на
|
||||
fragmented/combined/corrupt packets и truncation полей ответа.
|
||||
- `tcpserversimple.h`: default bind — все адреса. Штатный TCP server нельзя
|
||||
просто включить как удалённый продуктовый интерфейс.
|
||||
- `setupwizardmotor.cpp`: wizard содержит реальные записи конфигурации по
|
||||
ходу шагов. Его запуск/отмена не являются только локальным редактированием.
|
||||
|
||||
### Сравнение реализаций
|
||||
|
||||
| Вариант | Польза | Цена/ограничение |
|
||||
| --- | --- | --- |
|
||||
| Официальный Qt Tool + локальный launcher и трансляция его окна в Core | Самый прямой путь к исходному GUI и широкому набору функций | Новый remote-app runtime, конкуренция ввода двух UI, Qt UI вне DG, передача port ownership; интерфейс сам умеет опасные команды |
|
||||
| Собственный минимальный protocol adapter | Быстрое read-only discovery/telemetry | Поддержка всех конфигов/wizards потребует дублирования большого firmware-specific слоя |
|
||||
| Бортовой service plugin с переиспользованием закреплённого upstream protocol/config engine + общий React UI | Наш UI, один owner, применимые функции Tool расширяются без второй модели состояния | Требуется проверить headless сборку/зависимости и адаптировать операции; полного готового API нет |
|
||||
|
||||
**Рекомендация:** третий вариант как целевая архитектура. Первый технический
|
||||
spike проверяет сборку и выделение engine без desktop UI; fallback на
|
||||
ограниченный собственный reader допустим для первого чтения, но не отменяет
|
||||
требование функционального паритета. Оригинальный Tool полезен как инструмент
|
||||
сравнения с эксклюзивной передачей владения портом. В текущем плане нельзя
|
||||
объявить «все функции готовы», открыв только несколько полей или удалённое окно.
|
||||
|
||||
Нужно отдельно различать паритет функций и показ неизменённого Qt GUI. Здесь
|
||||
принято рабочее предположение из запроса про наш интерфейс и DG: единая
|
||||
предметная UI внутри Mission Core. Если нужен именно исходный Qt GUI, меняется
|
||||
способ его доставки, а не требование единственного hardware owner.
|
||||
|
||||
Upstream содержит GPL-3.0-or-later notices и отдельные правила бренда. При
|
||||
упаковке выбранного кода/бинарника проверить состав, notices, исходники и
|
||||
название распространяемого продукта. Этот аудит не делает юридического вывода
|
||||
о допустимости конкретного способа распространения.
|
||||
|
||||
## 6. Runtime и модель данных
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
L[Node UI: Устройства → VESC Tool] --> N[Node API и журнал операций]
|
||||
R[Core: Парк → Rover 006 → VESC Tool] --> C[Core fleet API]
|
||||
C -->|Существующее pairing и mTLS| N
|
||||
N --> V[VESC plugin на борту: owner и арбитраж]
|
||||
V --> U[USB attachment → protocol UUID → controller/channel]
|
||||
U --> M[Мотор и подтверждённая роль]
|
||||
V --> D[Конфиги, telemetry и diagnostic session на борту]
|
||||
D --> C
|
||||
```
|
||||
|
||||
Предлагаемый bounded каталог `plugins/vesc/`: `runtime/`, `frontend/`,
|
||||
`profiles/`, `packaging/`, `tests/`, upstream lock/notice manifest.
|
||||
Названия здесь — план, каталог ещё не создан.
|
||||
|
||||
Один service владеет serial. Открывает только подтверждённые candidates;
|
||||
проверяет отсутствие конкурирующего владельца и сохраняет связь порта с
|
||||
attachment generation. До FW query attachment имеет provisional identity.
|
||||
После ответа UUID связывается со стабильным controller instance. Смена порта
|
||||
не меняет подтверждённый UUID; USB/CAN aliases одной платы не создают двойник.
|
||||
Неоднозначный protocol UUID оставляет устройство без write authority.
|
||||
|
||||
Особенно важно для текущих двух плат: общий Node discovery уже считает
|
||||
duplicate USB serial неинициализируемым. Нельзя просто добавить VID/PID:
|
||||
обе кнопки подготовки окажутся заблокированы. Нужен отдельный безопасный путь
|
||||
подготовки модели/identity query для provisional attachments; он разрешает
|
||||
только ограниченное чтение личности. Общую защиту камер не ослаблять.
|
||||
|
||||
Role Left/Right и metadata мотора хранятся отдельно от UUID. Отключение владельцем
|
||||
одного USB при обесточенном приводе может сопоставить плату с кабелем; это само
|
||||
по себе не доказывает, какой мотор подключён к её силовым выходам. Сопоставление
|
||||
по проводке/маркировке предпочтительно; активный тест — отдельная операция.
|
||||
|
||||
Конфиг имеет исходный blob, decoded fields, firmware/schema signature, hash,
|
||||
revision и timestamp. Запись использует ожидаемую revision и свежий session,
|
||||
сохраняет before/after, выполняет readback. Потеря ACK даёт unknown и
|
||||
reconciliation, а не слепой retry.
|
||||
|
||||
Калибровка — бортовая операция с этапами, результатом и явно проверенной
|
||||
семантикой отмены. HTTP timeout не доказывает прекращение электрического
|
||||
измерения. Motor control дополнительно требует одного владельца управления,
|
||||
локального watchdog, известных timeout/stop свойств firmware и арбитража RC.
|
||||
Точные токи/обороты/частота не выбираются до hardware baseline.
|
||||
|
||||
## 7. Полнота функций и порядок включения
|
||||
|
||||
| Группа Tool | Предметный результат | Этап и условие |
|
||||
| --- | --- | --- |
|
||||
| Discovery, FW/HW/UUID, USB/CAN topology | Независимые экземпляры и совместимость | Первый read-only slice |
|
||||
| Live values, faults, decoded PPM/ADC/Chuk input | Напряжение, токи, ERPM, температура, вход, timeout/kill flags по поддержке FW | Первый read-only slice; измерить реальную частоту |
|
||||
| Motor/app/custom configs, backup/export, сравнение | Полный применимый набор параметров по schema, неизменяемый backup | Read-only до первой записи |
|
||||
| Import, defaults, apply/restore | Предпросмотр diff и проверенный readback | После identity, backup и compatibility; restore/defaults — записи |
|
||||
| FOC/BLDC/DC setup, R/L/flux, Hall/encoder | Калибровочный workflow с результатом | Активный допуск на конкретный мотор; один шаг может подавать ток |
|
||||
| App/input setup, direction, limits | Настройка RC/ADC/UART/CAN по реальной схеме | Сначала прочитать существующий input/timeout/RC ownership |
|
||||
| Duty/current/brake/RPM/position, motor tests | Управляемый стендовый опыт | Быстрый бортовой контур; не heartbeat 5 s |
|
||||
| Samples, logging, plotting | Синхронная диагностическая запись реакции | Bounded board recorder; сводки через Core |
|
||||
| Firmware/bootloader/recovery | Exact-HW image, версия, progress и recovery | Отдельная поздняя ветка; не «обновить на всякий случай» |
|
||||
| CAN forwarding/multi-controller setup | Явный target и topology | Никаких автоматических detect-all/broadcast writes |
|
||||
| Terminal, Lisp/QML/packages, custom application | Сервисные функции выбранного устройства | Отдельный maintenance scope; чтение кода и его выполнение различаются |
|
||||
| BMS, IMU, power switch, NRF/GPD и расширения | Применимые к конкретному hardware возможности | Capability-driven; отсутствие аппаратуры не маскировать как готовую функцию |
|
||||
|
||||
Перед реализацией широкой сервисной поверхности матрица уточняется по страницам
|
||||
выбранного релиза Tool и реальной HW/FW. Для каждого пункта фиксируются:
|
||||
supported/unsupported/not-implemented, операция, side effects, schema,
|
||||
readback, cancel/recovery и аппаратная приёмка. Старые настройки firmware
|
||||
не переименовываются в поддержанные только ради единого красивого UI.
|
||||
|
||||
## 8. UI brief и Design Guideline
|
||||
|
||||
Задача оператора: выбрать конкретный контроллер, понять состояние, настроить
|
||||
его и проверить итог. Первичная сущность — выбранный controller/channel;
|
||||
мотор и роль — связанный контекст. Вход из существующего списка устройств.
|
||||
|
||||
Выбран **plugin Detail slot** общего `SensorWorkspace`, с текстовой кнопкой
|
||||
«VESC Tool» в карточке. Внутри — обзор/диагностика, параметры и сервисные
|
||||
операции, основанные на capabilities. Нового root или LAB не требуется.
|
||||
Длинный motor workflow остаётся полноценным detail-view; компактный editor
|
||||
может использовать существующий `FeatureSettingsWindow`.
|
||||
|
||||
Альтернатива отдельного глобального workspace создаёт второй вход к тем же
|
||||
устройствам и отрывает инструмент от адресной identity. Модальное окно на весь
|
||||
долгий workflow неудобно для контроля результата и закрытия/recovery.
|
||||
Показ исходного Qt GUI — иной вариант интеграции, описанный выше.
|
||||
|
||||
Уже есть `ResourceRow/List`, `Button/IconButton`, `StatusBadge`,
|
||||
`SettingsCard`, `Window`, `FeatureSettingsWindow`, `SegmentedControl`,
|
||||
`TextField`, `Select`, `RangeControl`, `ConfirmationModal`, `ProgressBar`,
|
||||
`LoadingRegion`, `ToastStack`. Подходящие существующие icons: settings,
|
||||
activity, network, download, upload, refresh, alert, eye, play/stop.
|
||||
Отдельной motor-icon в просмотренном registry нет; новая не нужна для первого
|
||||
slice. Domain graph/plot остаётся кодом плагина с этими controls.
|
||||
|
||||
`RangeControl.min/max` ограничивает drag, но не всякий ручной ввод: для токов
|
||||
и других bounded величин нужны `exactValueBounds` и серверная валидация.
|
||||
Safety check нельзя делегировать только форме.
|
||||
|
||||
Состояния: поиск; не обнаружено; найден кандидат; требуется подготовка;
|
||||
чтение личности; unsupported/ambiguous; готов к чтению; fault; занят;
|
||||
операция выполняется; outcome unknown; связь потеряна. Свежесть контроллера
|
||||
проверяется отдельно от online борта. Браузер не принимает unknown за failed
|
||||
и не предлагает повтор опасного действия как универсальное восстановление.
|
||||
|
||||
Это новое доменное содержимое существующей принятой list/detail композиции.
|
||||
Изменения global navigation и новые общие визуальные сущности не предлагаются.
|
||||
|
||||
## 9. Упаковка с первого бортового опыта
|
||||
|
||||
Versioned пакет/profile устанавливает бинарник, pinned зависимости, отдельного
|
||||
непривилегированного service user, Unix socket для Node, systemd limits,
|
||||
узкие udev rules доступа и ModemManager ignore для квалифицированного профиля.
|
||||
Не добавлять весь Node или пользователя в общий dialout, не делать chmod 666,
|
||||
не отключать ModemManager глобально. Runtime не должен читать произвольные tty.
|
||||
|
||||
Node сохраняет `PrivateDevices=yes`; аппаратные права принадлежат отдельному
|
||||
адаптеру. Verify после установки означает protocol identity/read capability,
|
||||
а не автокалибровку. Package qualification: повторная установка, конфликт
|
||||
портов, rollback бинарника при сохранении data/backup, холодный старт и чистая
|
||||
Ubuntu. Компилятор/Qt dev packages не становятся скрытым требованием runtime.
|
||||
|
||||
## 10. Реализация по проверяемым результатам
|
||||
|
||||
1. **Контракт и build spike.** Изолированный checkout, выбранный upstream
|
||||
release/commit, DG pin, headless engine build, firmware schema closure;
|
||||
synthetic packets, IPC/action contracts. Никакого доступа к моторам.
|
||||
2. **Discovery на борту и две UI.** Поставляемый profile, два кандидата с
|
||||
одинаковым USB serial, FW/UUID handshake, session transitions, одна
|
||||
VESC contribution в Node/Core. Итог — оба контроллера видны независимо.
|
||||
3. **Read-only сервисная поверхность.** Версии/capabilities, telemetry,
|
||||
faults/input, motor/app/custom backups и semantic diff. Это первый полезный
|
||||
завершённый выпуск; статусы неподдержанной FW честные.
|
||||
4. **Диагностика проблемного канала.** Сначала сравнить конфиги, затем на
|
||||
подготовленном стенде записать плавный/резкий старт по конкретному сценарию.
|
||||
Сопоставить command/input, ERPM, currents, voltage, fault и timeout. Выбрать
|
||||
измеренную гипотезу; рабочий конфиг не копировать целиком.
|
||||
5. **Адресная настройка/калибровка.** Backup, effect preview, необходимые
|
||||
ограничения hardware, исключение конкурирующего управления, конкретная
|
||||
операция, cancel/recovery, readback и повтор исходного теста.
|
||||
6. **Остальная матрица Tool.** Дополнять функции вместе с соответствующей
|
||||
упаковкой и аппаратными критериями; FW/terminal/scripts выделены по эффекту.
|
||||
|
||||
До физического теста требуются аппаратные факты о моторах/датчиках/питании,
|
||||
проводке RC/CAN и доступном аварийном останове. Выбор «левый/правый» владельцем
|
||||
выполняется позже; он не блокирует initial inventory.
|
||||
|
||||
## 11. Приёмка и тесты
|
||||
|
||||
- Parser: CRC, partial/multiple packets, неверные длины/концы, timeout,
|
||||
несовместимая FW/config signature, отсутствующие optional fields.
|
||||
- Identity: одинаковые USB serial, пустой/дублированный UUID, два независимых
|
||||
контроллера, unplug/replug/reorder, замена платы, USB/CAN duplicate alias.
|
||||
- Operations: общий local/remote journal, stale session, conflict/lease,
|
||||
crash до/после dispatch, unknown outcome, readback mismatch, отсутствие
|
||||
повторного исполнения после reconnect и reboot.
|
||||
- Packaging: clean Ubuntu, narrow permissions, targeted ModemManager rule,
|
||||
занятый port, idempotency/rollback, pinned binaries/firmware schemas/DG.
|
||||
- UI: обе поверхности на одном экземпляре, одинаковые capabilities/results,
|
||||
empty/offline/fault/unknown, сохранение draft, keyboard/Escape/expand, без
|
||||
новых local controls или моторной логики в App.tsx.
|
||||
- Hardware: версии и backup обеих плат; измеренный fault/поведение;
|
||||
отдельная проверка stop/timeout на стенде до ручного управления.
|
||||
- Совместная работа: вернуть реальные D455/X4/K1 в согласованный сценарий и
|
||||
измерить ресурсы/USB/latency вместе с VESC. Их текущий offline не считается
|
||||
успешной regression-проверкой.
|
||||
|
||||
Проверки кода выполнять последовательно с учётом памяти операторского Mac.
|
||||
Текущая задача не меняла runtime-код, поэтому тесты/сборки приложения не
|
||||
запускались. HTTP/SSH/API чтения и анализ исходников не являются калибровкой.
|
||||
|
||||
## 12. Следующее конкретное действие
|
||||
|
||||
Реализовать и упаковать **двухэкземплярное VESC discovery + read-only identity,
|
||||
config backup и общую detail-поверхность**. Первый бортовой запуск обязан
|
||||
учесть уже обнаруженный duplicate USB serial. Это снимает неизвестность
|
||||
HW/FW и даёт основание выбирать реальную настройку проблемного мотора.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
# VESC Tool as the onboard engine
|
||||
|
||||
Owner decision, 2026-09-23: Mission Core supplies the local and remote product
|
||||
interface; upstream VESC Tool supplies firmware compatibility, configuration
|
||||
schemas/codecs and motor calibration. Do not continue a separate Python
|
||||
implementation of Tool algorithms. Node 0.8.29-1's separate Hall/speed experiment
|
||||
was built but withheld before installation. Installed hardware remains on
|
||||
0.8.28-1. Calibration and sustained-rotation acceptance are still outstanding.
|
||||
|
||||
## Upstream boundary
|
||||
|
||||
Use the stable [Tool 7.00 source](https://github.com/vedderb/vesc_tool/tree/01d5f10901116c311e3fb84d5a1541f663d3ce20)
|
||||
unchanged. This is a Qt application, not an existing HTTP service or a documented
|
||||
standalone SDK. A small C++ process adapter is necessary; it calls the actual
|
||||
`VescInterface`, `Commands`, `ConfigParams` and `Utility` implementations. It
|
||||
must not replace them with translations of their algorithms. Keep upstream
|
||||
license and corresponding source/build provenance with the combined payload.
|
||||
|
||||
Mission Core owns device identity/position, exclusive access, operation and
|
||||
configuration history, session authority, local/paired transport and UI composed
|
||||
from Design Guideline components. Device parameter definitions, groups, labels,
|
||||
units, limits and enum options come from native `ConfigParams`; they must not
|
||||
be copied into a second permanent hand-maintained schema.
|
||||
|
||||
Updates change a pinned upstream source plus its matching resources and rerun
|
||||
compatibility/replay acceptance. Neither a Tool update nor connecting to a board
|
||||
automatically authorizes firmware flashing or replacing controller settings.
|
||||
|
||||
## Verified native execution
|
||||
|
||||
`plugins/vesc/native/offline_main.cpp` links the existing unmodified Tool object
|
||||
files, substituting only the application entry point. It has no admitted serial,
|
||||
TCP, Bluetooth or powered-operation entry point and does not start an event loop.
|
||||
The versioned `packaging/build_native_probe.py` / `native_probe.py` artifact
|
||||
compiles under an unprivileged 3 GiB user scope; it neither installs dependencies
|
||||
nor touches running services. Separate private Qt settings prevent inheriting an
|
||||
operator's saved connection.
|
||||
|
||||
The adapter replays archived identity through native firmware negotiation,
|
||||
checks archive hash/command/length against the native firmware schema, passes
|
||||
the original configuration packets to `Commands::processPacket`, and exports
|
||||
the resulting native parameter groups and XML. Native serialization must
|
||||
reproduce the original binary configuration exactly. Unsupported firmware,
|
||||
corrupt hashes, wrong signatures and truncated payloads must fail closed.
|
||||
|
||||
An offline `Commands::detectAllFoc` serialization probe also demonstrates the
|
||||
real compatibility behavior: Tool automatically halves a 100 W example to
|
||||
50 W on the wire for FW 5.02. No bytes are delivered to hardware. This correction
|
||||
comes from `VescInterface::fwVersionReceived` and `Commands::detectAllFoc`;
|
||||
implementing only the documented-looking command packet would miss it.
|
||||
|
||||
Tool's own XML writer rounds floating point text (`QString::number` default
|
||||
precision). XML is suitable for native import/export but is not a byte-exact
|
||||
archive. Preserve the original binary snapshots alongside native XML. Native
|
||||
`ConfigParams::checkDifference` supplies the comparison tolerance; do not call
|
||||
XML conversion a lossless binary backup.
|
||||
|
||||
This proves engine reuse and offline compatibility, not a shipped hardware
|
||||
backend. Production dependency closure, installer integration, USB ownership
|
||||
handoff, operation API and physical calibration remain separate acceptance work.
|
||||
|
||||
## Canonical calibration for this 1×1 rover
|
||||
|
||||
The [upstream motor wizard](https://vesc-project.com/node/180) distinguishes
|
||||
motor setup from the [input wizard](https://vesc-project.com/node/181).
|
||||
The current desktop implementation is
|
||||
[`DetectAllFocDialog::runDetect`](https://github.com/vedderb/vesc_tool/blob/01d5f10901116c311e3fb84d5a1541f663d3ce20/widgets/detectallfocdialog.cpp),
|
||||
calling the actual
|
||||
[`Utility::detectAllFoc`](https://github.com/vedderb/vesc_tool/blob/01d5f10901116c311e3fb84d5a1541f663d3ce20/utility.cpp).
|
||||
|
||||
1. Confirm each controller's own identity, take motor and application backups,
|
||||
inspect faults and retain existing battery protections. The problematic motor
|
||||
is LEFT; RIGHT works normally from RC. Do not copy right-side Hall calibration
|
||||
to the left or use software-induced right-side stops as a hardware diagnosis.
|
||||
2. Determine actual connection topology. Two motors, or a Mission Core 1×1
|
||||
layout, do not imply CAN master/slave. There are two USB connections and two
|
||||
receiver inputs (CH3 presumed left, CH2 right). Previous native CAN discovery
|
||||
returned no peers. Calibrate each directly connected device separately until
|
||||
another topology is evidenced; do not enable CAN forwarding merely because
|
||||
the rover has two motors.
|
||||
3. Choose the appropriate native motor procedure. The full auto-FOC wizard
|
||||
prepares parameters, temporarily adjusts battery cutoffs, detects R/L, flux
|
||||
and sensors, writes resulting settings, restores cutoffs and checks direction.
|
||||
It is not equivalent to invoking a Hall command or only starting a motor.
|
||||
Its `maxPowerLoss` is motor heating allowance, not rated shaft power; a
|
||||
remembered 500 W nameplate is not an instruction to pass 500 W here.
|
||||
4. For diagnosis without changing battery settings, use upstream's individual
|
||||
`measureRLBlocking`, `measureLinkageOpenloopBlocking` and
|
||||
`measureHallFocBlocking` procedures. The necessary current/start parameters
|
||||
and actual effects must be explicit. Use upstream calculation/application
|
||||
functions when measurements are accepted; never silently transplant a new
|
||||
Hall table or overwrite unmeasured settings.
|
||||
5. Inspect the resulting sensor mode and measurement status. Firmware 5.02
|
||||
autodetection can report success with a sensorless fallback when Hall/encoder
|
||||
detection fails. That is not proof that the broken Hall pin was repaired or
|
||||
that loaded low-speed startup is acceptable. Firmware Hall measurement locks
|
||||
ordinary motor controls during the cycle; do not promise an RC or USB stop
|
||||
that the firmware cannot perform.
|
||||
6. Read back and archive the applied result, then coordinate a visible direction
|
||||
and startup test. Assign the physical position in the existing Mission Core
|
||||
profile only from observation. Finish motor setup before changing receiver
|
||||
endpoints, neutral, deadband or direction. The receiver remains connected.
|
||||
7. Verify sustained rotation using native motor control with current limits and
|
||||
a speed setpoint. Requested duration means measured rotation after settling;
|
||||
startup, stalled motion and an early guard stop do not count as completed
|
||||
time. A torque/current command alone cannot promise 30 seconds of rotation.
|
||||
|
||||
The full auto wizard includes configuration writes beyond measurements. In
|
||||
particular, blindly executing its battery stage from stale configuration metadata
|
||||
is unsuitable here: saved battery metadata says 3S / 6 Ah while observed input is
|
||||
about 50 V. Existing cutoffs are approximately 44.2 / 39 V. Retaining these
|
||||
settings for motor diagnosis is distinct from validating their suitability.
|
||||
|
||||
Battery brand/capacity are not prerequisites for motor identification. Chemistry
|
||||
and series count are needed when recalculating voltage protection; pack/BMS
|
||||
charge and discharge ratings are needed when changing battery current limits.
|
||||
Do not block native engine preparation or motor-only diagnosis on unknown Ah.
|
||||
|
||||
## Hardware evidence and uncertainty
|
||||
|
||||
Owner photos show UNITE branding and model family BM1418HQF on one motor; the
|
||||
other marking is worn. Owner recalls approximately 0.5 kW per motor, tentatively.
|
||||
The [manufacturer catalog](https://m.unitemotorco.com/brushless-motor/) lists
|
||||
350/500/650/750 W variants with several voltage options. This confirms the
|
||||
family, not the exact rating of this unit. Do not select a direct-drive hub
|
||||
profile based solely on the saved 46-pole / gear-ratio fields.
|
||||
|
||||
Owner reports a nominal 48 V CATL-cell battery and a verbally ambiguous capacity
|
||||
around 120 Ah. The photo shows 13 visible cell bodies, but labels and the complete
|
||||
electrical topology are not visible. Chemistry, exact S/P count and BMS ratings
|
||||
remain unconfirmed. Raw photos, controller identities and native replay outputs
|
||||
stay in private evidence, outside normal Git/Ops.
|
||||
|
||||
## Remaining implementation acceptance
|
||||
|
||||
- Carry the native engine and its qualified runtime dependencies through the
|
||||
existing versioned Node installer; no ad-hoc board package/library repair.
|
||||
- Admit one hardware owner at a time. Existing Python serial descriptors must
|
||||
not compete with VescInterface, and background polling must not interleave
|
||||
another session's calibration commands.
|
||||
- Expose explicit operation requests/results and native parameter metadata on
|
||||
the private onboard boundary; reuse the existing Node and paired Core path.
|
||||
- Keep backups, compatibility checks, timeouts, durable unknown-operation state,
|
||||
readback and observed sensor mode in the receipt. No automatic retry of motion.
|
||||
- Before each powered agent test obtain a fresh observing reply and announce
|
||||
target/parameters. Existing authorization does not establish that the owner is
|
||||
still watching after a build.
|
||||
- Qualify native device reads before calibration, then verify left and right
|
||||
startup/direction and actual sustained rotation separately. Do not mark the
|
||||
rover calibrated from offline tests.
|
||||
|
||||
## Native runtime candidate 0.4.0
|
||||
|
||||
The per-device `native/engine_main.cpp` now implements bounded JSON requests
|
||||
on private inherited pipes. Native VescInterface owns and exclusively locks the
|
||||
actual serial descriptor; the service never opens a competing descriptor.
|
||||
Configuration reads verify upstream serialization against the original bytes.
|
||||
The admitted native methods cover identity/telemetry/configuration/CAN/PPM reads,
|
||||
short app-output leases, current release, bounded current and speed, volatile
|
||||
current scales, native parameter/XML export and upstream blocking Hall detection.
|
||||
No arbitrary packet, firmware flash or general command execution API is exposed.
|
||||
|
||||
`runtime/native_link.py` owns subprocess lifetime and attachment checks. An
|
||||
unmatched/lost response closes the stream; no powered command is retried.
|
||||
Reconnection creates a fresh device session. Pending measurements and current
|
||||
limit restoration remain durable across process failures. During Hall detection
|
||||
only its status, telemetry/PPM reads and release/leases are allowed; none is
|
||||
represented as cancelling firmware's non-interruptible measurement.
|
||||
|
||||
The versioned runtime carries private Qt/offscreen dependencies and source/license
|
||||
provenance. `native_check.py` verifies the installed files and runs the disconnected
|
||||
engine as the service account before USB discovery. Qualification and actual
|
||||
installation/measurement outcomes are recorded in the installation ledger.
|
||||
Full auto-FOC, application of measured calibration, general parameter editing
|
||||
and physical motor acceptance remain outstanding; do not equate this API with
|
||||
complete VESC Tool UI parity.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Калибровка VESC через Mission Core
|
||||
|
||||
Проверено на Node 0.8.35-1 / VESC plugin 0.6.3, VESC Tool 7.00 и прошивке
|
||||
контроллеров 5.02. Это описание доступного процесса; журнал конкретных
|
||||
испытаний находится в `17_VESC_INSTALLATION_LEDGER.md`.
|
||||
|
||||
## Действия оператора
|
||||
|
||||
Открыть «Аппараты», нужный аппарат, устройства его бортового компьютера,
|
||||
нужный VESC и «Настройка VESC». В блоке «Калибровка мотора» проверить выбранный
|
||||
контроллер, параметр допустимых потерь, подтвердить наблюдение и нажать
|
||||
«Откалибровать мотор». Дождаться результата записи, проверки конфигурации
|
||||
и снятия тока. Для второго контроллера открыть его карточку и выполнить
|
||||
отдельную калибровку. Профиль 1×1 сам по себе не запускает групповую калибровку.
|
||||
|
||||
Моторы должны свободно вращаться, пульт — быть выключен. В прошивке 5.02
|
||||
нативное измерение нельзя прервать обычной кнопкой остановки или пультом;
|
||||
оператор должен иметь возможность отключить силовое питание.
|
||||
|
||||
Перед процедурой сохраняются конфигурации подключённых контроллеров.
|
||||
Мастер измеряет электрические параметры выбранного мотора, определяет
|
||||
датчики, рассчитывает настройки FOC и записывает результат. Mission Core
|
||||
проверяет прочитанную обратно конфигурацию и сохраняет версии до/после.
|
||||
Настройки другого мотора, аккумулятора и приёмника защищены от незапрошенного
|
||||
изменения. Полная процедура повторно не нужна перед обычным запуском мотора.
|
||||
|
||||
## Что означает 50 Вт
|
||||
|
||||
«Допустимые потери в моторе» — входной параметр штатного мастера VESC Tool.
|
||||
Он задаёт расчётные резистивные потери на нагрев при предельном токе. По нему
|
||||
и измеренному сопротивлению мастер выбирает токи измерения и рассчитывает
|
||||
сохраняемый предел тока мотора. Это не напряжение аккумулятора, не полезная
|
||||
механическая мощность и не паспортная мощность мотора. Увеличение значения
|
||||
может увеличить ток и нагрев; вводить сюда номинальные 500 Вт автоматически
|
||||
неправильно. Параметр не заменяет измерение температуры и тепловую проверку.
|
||||
|
||||
В принятой калибровке двух моторов при 50 Вт мастер установил примерно
|
||||
34,21 и 34,41 А. Это результат конкретных измерений, а не универсальный
|
||||
допустимый ток всех моторов. Поправку совместимости с прошивкой 5.02
|
||||
выполняет сам VESC Tool. Она не воспроизводится отдельной формулой Mission Core.
|
||||
|
||||
Источник: [объяснение автора VESC](https://www.vesc-project.com/node/1029),
|
||||
[влияние параметра на токи измерения](https://vesc-project.com/node/1640),
|
||||
`Commands::detectAllFoc` и `Utility::detectAllFoc` в закреплённом исходнике Tool.
|
||||
|
||||
## Датчики Холла и отдельное измерение
|
||||
|
||||
Датчики сообщают контроллеру положение ротора. При автоопределении FOC мастер
|
||||
уже проверяет датчики и может выбрать работу без них. Успешная калибровка в
|
||||
режиме без датчиков не доказывает исправность проводки Холлов.
|
||||
|
||||
Кнопка «Измерить датчики Холла» выполняет отдельную диагностику выбранного
|
||||
мотора: штатный цикл при 5 А с медленными смещениями получает таблицу
|
||||
состояний. Она не применяется автоматически, рабочая конфигурация сохраняется.
|
||||
Это проверка для поиска неисправности, а не обязательный второй этап каждой
|
||||
калибровки. Неполная таблица требует различать проблемы датчиков/соединений
|
||||
и недостаточное движение при измерении. Номер физического сломанного контакта
|
||||
не определяется по таблице без проверки распиновки и проводки.
|
||||
|
||||
## Назначение и проверка вращения
|
||||
|
||||
Назначение «левый/правый» связывает постоянный UUID VESC с местом мотора на
|
||||
аппарате. Оно нужно для адресного и общего управления, но не влияет на
|
||||
измеряемое сопротивление или параметр потерь. После смены USB-порта назначение
|
||||
сохраняется. Общий список назначений сейчас отображается в каждой карточке
|
||||
VESC; это обзор профиля аппарата, а не перечень моторов внутри одного VESC.
|
||||
|
||||
«Проверка вращения» запускается отдельно от калибровки. Скорость задаётся
|
||||
в ERPM, ток задаёт верхний предел, длительность считается после разгона
|
||||
и удержания скорости. Выбор всех моторов профиля запускает совместную
|
||||
проверку; для 1×1 это два мотора. Успешная проверка на вывешенном приводе
|
||||
не заменяет проверку под нагрузкой или надёжности связи.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Rover 006: инженерный SSH и административные действия
|
||||
|
||||
Проверено 23.09.2026 около 11:02 UTC. Источник: живой paired inventory Core,
|
||||
Tailscale, сравнение SSH host key с ранее доверенной записью и успешный SSH.
|
||||
|
||||
## Проверенный путь
|
||||
|
||||
Текущий Ubuntu Mission Core Node и исторический Device Edge — разные записи
|
||||
доступа. Для текущего борта подтверждён пользователь **`dcsudo`** и основной
|
||||
персональный ключ оператора `~/.ssh/id_ed25519`. Приватный ключ не копируется.
|
||||
Обычный вход работает с `BatchMode=yes`, без ввода пароля и без `sudo`.
|
||||
|
||||
Сохранённый в операторском `~/.ssh/config` alias `nodedc-edge` относится к
|
||||
прежней записи с пользователем `ndcsudo` и старым LAN-адресом. Он не является
|
||||
источником адреса/пользователя нынешнего Rover 006. Не менять его вслепую:
|
||||
другие задачи могут использовать историческую запись.
|
||||
|
||||
На текущем операторском Mac создан и проверен отдельный приватный профиль:
|
||||
|
||||
`/Users/dcconstructions/Downloads/mnt/NODEDC/outputs/rover-006-vesc-context-20260923/ssh-config`
|
||||
|
||||
Он выбирает `rover-006`, актуальное MagicDNS-имя, `dcsudo`, персональный ключ,
|
||||
`IdentitiesOnly=yes`, `BatchMode=yes`, `StrictHostKeyChecking=yes` и прежнюю
|
||||
доверенную запись через `HostKeyAlias`. Команда проверки:
|
||||
|
||||
```sh
|
||||
ssh -F /Users/dcconstructions/Downloads/mnt/NODEDC/outputs/rover-006-vesc-context-20260923/ssh-config rover-006 'id -un; hostname'
|
||||
```
|
||||
|
||||
Адреса, полный host-key fingerprint и USB-идентификаторы не включаются в
|
||||
переносимую документацию. Приватный профиль не содержит пароля или содержимого
|
||||
ключа. Его отсутствие на другом Mac не означает отказ борта.
|
||||
|
||||
## Как восстанавливать контекст
|
||||
|
||||
1. Прочитать MISSIONCOR-76 и последнее дополнение об инженерном доступе.
|
||||
2. Проверить `GET http://127.0.0.1:8000/api/v1/fleet`: выбрать именно сопряжённый
|
||||
Rover 006, проверить свежесть inventory, hostname, node identity и адреса.
|
||||
3. Сопоставить эту машину с текущим Tailscale peer. Worker 006 и старый Device
|
||||
Edge не являются бортом. Не сканировать подсеть.
|
||||
4. Использовать проверенный профиль. При новом адресе сначала сравнить ключ с
|
||||
известной доверенной записью. `ssh-keyscan` сам по себе не устанавливает
|
||||
доверие; 23.09 ключ совпал побайтово с ранее сохранённым ключом Ubuntu Mini.
|
||||
5. Если получен `Permission denied`, проверить **пользователя и выбранный ключ**
|
||||
до обсуждения пароля/sudo. Не подбирать аккаунты, не сбрасывать ключи и не
|
||||
выключать host-key checking. Если доказанного пути нет, использовать
|
||||
существующий GUI Node «Система → SSH · доверенные устройства».
|
||||
|
||||
Sandbox `Operation not permitted` и отказ запуска локального Tailscale CLI
|
||||
не доказывают сетевой отказ. Повторить конкретное read-only действие с
|
||||
разрешением инструмента, не менять маршруты и VPN на основании такой ошибки.
|
||||
|
||||
## SSH не равен sudo
|
||||
|
||||
Подтверждение владельцем пароля на экране Mini относится к административному
|
||||
действию Ubuntu/установщика. Оно не требуется для обычного инженерного чтения.
|
||||
Успешный SSH и членство в группе sudo не доказывают беспарольное повышение прав.
|
||||
|
||||
Установка и изменения runtime выполняются штатным versioned installer/profile.
|
||||
Если такой шаг требует системного подтверждения, сначала подготовить точный
|
||||
артефакт и объяснить действие, затем использовать существующий системный диалог.
|
||||
Не просить пароль в чате; не добавлять NOPASSWD, глобальный dialout/chmod или
|
||||
новый канал обхода ради диагностики. На этапе первоначального аудита sudo не вызывался. Позднее 23.09 владелец
|
||||
ввёл пароль локально в versioned установщике Node0.8.22-1; установка
|
||||
подтверждена report.json и фактической версией пакета.
|
||||
|
||||
Продуктовое управление устройствами проходит Core → mTLS → Node → plugin.
|
||||
SSH остаётся инженерным инструментом, а не транспортом моторных команд.
|
||||
|
||||
## Результат 23.09
|
||||
|
||||
Подтверждены Ubuntu 24.04.4 LTS, kernel 7.0.0-31-generic, Node 0.8.21-3,
|
||||
K1 0.1.14, X4 0.1.3-9. Повторный вход через отдельный профиль вернул
|
||||
правильного пользователя и hostname. SSH/sshd, учётные записи, доверие,
|
||||
Tailscale, VPN и sudo policy не изменялись.
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useEffect,useSyncExternalStore,type ReactNode} from 'react';
|
||||
import {Button,Icon,Inspector,LoadingRegion} from '@nodedc/ui-react';
|
||||
import type {BoardLayoutStore} from './boardLayout';
|
||||
|
||||
export interface BoardSectionsProps {
|
||||
layout:BoardLayoutStore;
|
||||
computer:ReactNode;
|
||||
description?:string;
|
||||
}
|
||||
export function BoardSections({layout,computer,description,settings,devices}:BoardSectionsProps&{settings:ReactNode;devices:ReactNode}){
|
||||
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
|
||||
useEffect(()=>{void layout.load();},[layout]);
|
||||
return <div className="sensor-content">
|
||||
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
||||
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
||||
<Inspector variant="panel" openSections={state.value.open_sections} onOpenSectionsChange={layout.change} sections={[
|
||||
{id:'computer',label:'Бортовой компьютер',description,icon:<Icon name="apps"/>,disabled:!state.ready,content:computer},
|
||||
{id:'settings',label:'Настройки борта',icon:<Icon name="sliders"/>,disabled:!state.ready,content:settings},
|
||||
{id:'devices',label:'Устройства аппарата',icon:<Icon name="camera"/>,disabled:!state.ready,content:devices},
|
||||
]}/>
|
||||
</LoadingRegion>
|
||||
</div>;
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {sensorStatus} from './sensorStatus';
|
||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
import './sensors.css';
|
||||
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps}){
|
||||
const [adding,setAdding]=useState(false);
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
@@ -44,7 +45,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
||||
const inventoryView=<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||
@@ -52,9 +53,11 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=devicePreparation(inventory,item);
|
||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>}
|
||||
<SensorPreparations inventory={inventory}/>
|
||||
</>}
|
||||
</>;
|
||||
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(board?<BoardSections {...board} settings={<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>} devices={inventoryView}/>:inventoryView)}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export const boardSections = ['computer', 'settings', 'devices'] as const;
|
||||
export type BoardSection = typeof boardSections[number];
|
||||
export interface BoardLayout {schema:'missioncore.board-layout/v1';revision:number;open_sections:BoardSection[]}
|
||||
export interface BoardLayoutTransport {
|
||||
read:()=>Promise<BoardLayout>;
|
||||
patch:(section:BoardSection,open:boolean)=>Promise<BoardLayout>;
|
||||
}
|
||||
export const defaultBoardLayout:BoardLayout={schema:'missioncore.board-layout/v1',revision:0,open_sections:[...boardSections]};
|
||||
type Change={section:BoardSection;open:boolean};
|
||||
type Snapshot={value:BoardLayout;ready:boolean;error:string|null;saving:boolean};
|
||||
function apply(value:BoardLayout,change:Change):BoardLayout {
|
||||
const open=new Set(value.open_sections);
|
||||
if(change.open)open.add(change.section);else open.delete(change.section);
|
||||
return {...value,open_sections:boardSections.filter(section=>open.has(section))};
|
||||
}
|
||||
function validate(value:BoardLayout):BoardLayout {
|
||||
if(value.schema!==defaultBoardLayout.schema||!Number.isSafeInteger(value.revision)||value.revision<0||
|
||||
!Array.isArray(value.open_sections)||new Set(value.open_sections).size!==value.open_sections.length||
|
||||
value.open_sections.some(id=>!boardSections.includes(id)))throw new Error('Не удалось прочитать раскладку блоков.');
|
||||
return value;
|
||||
}
|
||||
// The queue belongs to the application resource, not a mounted accordion.
|
||||
// Navigation cannot discard a pending save or let an older reply win a toggle.
|
||||
export function createBoardLayoutStore(transport:BoardLayoutTransport){
|
||||
let saved=defaultBoardLayout;
|
||||
let snapshot:Snapshot={value:saved,ready:false,error:null,saving:false};
|
||||
let pending:Change[]=[];
|
||||
let reading:Promise<void>|null=null;
|
||||
let writing=false;
|
||||
const listeners=new Set<()=>void>();
|
||||
const emit=(patch:Partial<Snapshot>={})=>{
|
||||
snapshot={...snapshot,...patch,value:pending.reduce(apply,saved),saving:writing||pending.length>0};
|
||||
listeners.forEach(listener=>listener());
|
||||
};
|
||||
const load=():Promise<void>=>{
|
||||
if(reading)return reading;
|
||||
if(writing)return Promise.resolve();
|
||||
reading=transport.read().then(value=>{saved=validate(value);emit({ready:true,error:null});})
|
||||
.catch(()=>emit({error:'Раскладка блоков не загружена. Повторите подключение.'}))
|
||||
.finally(()=>{reading=null;});
|
||||
return reading;
|
||||
};
|
||||
const flush=async()=>{
|
||||
if(writing)return;
|
||||
writing=true;emit({error:null});
|
||||
while(pending.length){
|
||||
const change=pending[0];
|
||||
try{saved=validate(await transport.patch(change.section,change.open));pending.shift();emit();}
|
||||
catch{pending=[];emit({error:'Не удалось сохранить раскладку блоков. Изменение отменено.'});break;}
|
||||
}
|
||||
writing=false;emit();
|
||||
};
|
||||
return {
|
||||
getSnapshot:()=>snapshot,
|
||||
subscribe:(listener:()=>void)=>{listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||
load,
|
||||
change:(open:string[])=>{
|
||||
if(!snapshot.ready||reading)return;
|
||||
for(const section of boardSections){
|
||||
if(open.includes(section)!==snapshot.value.open_sections.includes(section))pending.push({section,open:open.includes(section)});
|
||||
}
|
||||
emit();void flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
export type BoardLayoutStore=ReturnType<typeof createBoardLayoutStore>;
|
||||
@@ -4,6 +4,7 @@ export interface Sensor {
|
||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
||||
live_settings?: Record<string,unknown>;
|
||||
camera_status?: Record<string,unknown>;
|
||||
vesc_status?: Record<string,unknown>;
|
||||
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||
@@ -33,6 +34,10 @@ export interface SensorCommand {
|
||||
}
|
||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||
export interface SensorTransport {
|
||||
configurationArchive?: {
|
||||
list:(deviceId:string,before?:string)=>Promise<{items:ConfigurationVersion[];next:string|null}>;
|
||||
read:(deviceId:string,versionId:string)=>Promise<Record<string,unknown>>;
|
||||
};
|
||||
localPreview?: {
|
||||
open: (command:SensorCommand, signal:AbortSignal) => Promise<Response>;
|
||||
read: (peer:string, after:number, signal:AbortSignal) => Promise<Response>;
|
||||
@@ -43,6 +48,7 @@ export interface SensorTransport {
|
||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||
operation: (id:string) => Promise<SensorOperation>;
|
||||
}
|
||||
export interface ConfigurationVersion {id:string;sequence:number;device_id:string;observed_at:string;firmware:string;configs:Record<string,{sha256:string;bytes:number}>}
|
||||
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
|
||||
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
||||
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {ComponentType,ReactNode} from 'react';
|
||||
import type {IconName} from '@nodedc/ui-react';
|
||||
import type {Sensor, SensorTransport} from './contracts';
|
||||
import type {Sensor, SensorInventory, SensorTransport} from './contracts';
|
||||
import type {EnrollmentTransport} from './enrollment';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
BoardSettings?:ComponentType<SensorBoardSettingsProps>;
|
||||
observation?:import('./observation').SensorObservationContribution;
|
||||
kind:string;
|
||||
Detail:ComponentType<SensorDetailProps>;
|
||||
@@ -22,11 +23,17 @@ export interface SensorUiContribution {
|
||||
retainOffline:boolean;
|
||||
supportsPreparation:boolean;
|
||||
supportsRenaming?:boolean;
|
||||
detailLabel?:string;
|
||||
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
|
||||
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||
}
|
||||
|
||||
export interface SensorBoardSettingsProps {
|
||||
inventory:SensorInventory|null;transport:SensorTransport;enabled:boolean;
|
||||
refresh:()=>Promise<void>;failure:(error:unknown)=>void;openDevice:(id:string)=>void;
|
||||
}
|
||||
|
||||
export interface SensorRowAction {
|
||||
actionId:string; label:string; description?:string; disabled?:boolean;
|
||||
parameters?:Record<string,unknown>;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# VESC onboard plugin — profile 0.4.0
|
||||
|
||||
**Native release in preparation:** 0.4.0 / Node 0.8.30-1 uses the actual VESC Tool 7.00 C++ engine for USB ownership, commands, configuration codecs and Hall measurement. Installation and physical qualification are still pending.
|
||||
|
||||
**Previous release status, 2026-09-23:** 0.3.0 / Node 0.8.29-1 is an uninstalled,
|
||||
withheld experiment. The owner requires the actual upstream VESC Tool as the
|
||||
onboard engine behind Mission Core controls. Do not deploy the separate Python
|
||||
Hall workflow below as the intended calibration backend. Hardware currently
|
||||
runs 0.2.5 / Node 0.8.28-1. The verified native adapter and canonical procedure
|
||||
are documented in [the native engine plan](../../docs/node/18_VESC_TOOL_NATIVE_BACKEND.md).
|
||||
|
||||
This increment discovers USB candidates, reads protocol identity and telemetry,
|
||||
saves opaque motor/application configuration backups and provides bounded
|
||||
identification pulses and user-assigned drive positions. It is not complete
|
||||
VESC Tool functional parity and does not implement continuous driving,
|
||||
configuration writes, calibration, CAN forwarding, custom configuration,
|
||||
firmware update or script execution.
|
||||
|
||||
## Placement and ownership
|
||||
|
||||
One `mission-core-vesc` service owns the serial descriptors. The Node model
|
||||
registry admits only its Unix socket and explicit domain operations. Local Node UI
|
||||
and paired Core use the same `SensorUiContribution` and operation journal.
|
||||
`VESC Tool` is a text action in the existing device row, opening the existing
|
||||
Detail slot. No workspace, root, visual control or motor icon was added.
|
||||
|
||||
Candidates must match `0483:5740` and `ChibiOS/RT Virtual COM Port`. USB serial
|
||||
is never their durable identity: every candidate starts with a transport-local
|
||||
attachment including USB devnum. Only a valid firmware reply with a nonzero
|
||||
12-byte UUID establishes a stable ID. Duplicate protocol UUIDs remain separate
|
||||
provisional rows and cannot receive read operations. Replug renews the session.
|
||||
Names and physical Left/Right roles must not be inferred from tty numbering.
|
||||
|
||||
The read-only serial path admits command bytes 0, 4, 14, 17, 31 and 62, with no
|
||||
arguments: identity, values, motor config, application config, decoded PPM and
|
||||
CAN discovery. The separate fixed test commands are described below. No
|
||||
keepalive, detect, config write, terminal or arbitrary packet API exists. Firmware identity is checked again before each operation;
|
||||
backup verifies it at the end as well. CRC/framing/length and response limits
|
||||
are enforced. Every port is held with flock/TIOCEXCL; an already running external
|
||||
tool must release it before this service is prepared.
|
||||
|
||||
## Compatibility and backup
|
||||
|
||||
Wire reference is the official [VESC Tool source](https://github.com/vedderb/vesc_tool/tree/dc53c658cbb89a947246034f7a00149cf79abdfc),
|
||||
specifically packet.cpp, commands.cpp and datatypes.h. The reference identifies
|
||||
itself as test version 7.01; it is not installed by this profile. The first
|
||||
reader implements the common values prefix for motor firmware major 5–7 and
|
||||
hardware type 0 (or an older reply without hardware type). Exact board/firmware
|
||||
qualification remains a hardware acceptance result, not a claim from a version
|
||||
number alone. Unknown layouts may identify themselves but are not decoded.
|
||||
|
||||
Config replies are preserved as received, including command byte and signature,
|
||||
with SHA-256 and identity/version metadata. Their fields are not decoded without
|
||||
the exact firmware schema. The backups are not XML files importable into Tool,
|
||||
and this profile provides no restore action. Motor and application reads are
|
||||
sequential, not an atomic snapshot against other controller interfaces.
|
||||
|
||||
Read receipts and backups live privately in `/var/lib/mission-core-vesc`.
|
||||
Operation IDs bind the exact request; retries retrieve receipts. An interrupted
|
||||
request remains unknown rather than being silently replayed. This service's
|
||||
host storage is bounded and does not delete backups to make space. GUI telemetry
|
||||
is an explicitly requested timestamped snapshot, not a control loop or a
|
||||
waveform recorder. ERPM is not mechanical shaft RPM.
|
||||
|
||||
## Packaging and first preparation
|
||||
|
||||
Node 0.8.22-1 carries this plugin from `packaging/payload.py`; no ad-hoc files,
|
||||
global Python packages or separate Qt installation are required. The source
|
||||
build pin is Design Guideline `8dd9190573d6616024ef01b9b34bf90b72960f44`.
|
||||
The existing versioned owner installer installs Node. Its local Ubuntu sudo
|
||||
prompt belongs to the owner; no password is handled by Core or an agent.
|
||||
|
||||
The device's Prepare action starts only
|
||||
`mission-core-node-vesc-prepare.service`, via the existing Node polkit rule.
|
||||
The shipped profile creates the dedicated account, installs the exact-candidate
|
||||
udev rule, applies it to matching attached ttys and starts the read service.
|
||||
It does not add the operator or Node to dialout or disable ModemManager globally.
|
||||
Node retains PrivateDevices. The adapter has no capabilities, private network,
|
||||
read-only system files, bounded memory/tasks and a cdc_acm device cgroup rule.
|
||||
`modprobe@cdc_acm` ensures the named ttyACM group exists before cgroup resolution
|
||||
on cold boot; [systemd DeviceAllow](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#DeviceAllow=)
|
||||
uses group names from `/proc/devices`, not `char-<major-number>`.
|
||||
|
||||
Before package replacement, the installer refuses an active preparation and
|
||||
stops the reader. A prepared profile restarts through its shipped job afterward.
|
||||
Removal stops/disables the service and removes only a byte-matching owned udev
|
||||
rule. Private backups and account identity are retained. Rolling Node back to
|
||||
0.8.21-3 disables VESC support; do not claim an older package can restore the new
|
||||
UI or firmware configuration. No VESC firmware was changed by this profile.
|
||||
|
||||
## Validation
|
||||
|
||||
Synthetic tests cover every denied transmit byte, fragmented/coalesced/corrupt
|
||||
frames, missing identities, scales, duplicate IDs, two independent attachments,
|
||||
replug sessions, receipts, config hashes and denied write actions. Node tests
|
||||
cover UUID promotion and preservation of camera guards. Full Node and Core
|
||||
checks are required alongside Linux package qualification and actual hardware
|
||||
reads. Test success is not clean-Ubuntu or motor calibration acceptance.
|
||||
|
||||
On the Mini, `qmake`, `qmake6` and `cmake` were absent in the read-only build
|
||||
inventory. This first increment therefore uses the bounded reader fallback from
|
||||
the implementation plan. Headless extraction of the full upstream engine has
|
||||
not been demonstrated; it remains a separate build/compatibility task for the
|
||||
remaining Tool feature matrix.
|
||||
|
||||
|
||||
## 0.2.1 — per-controller identification pulse and immutable archive
|
||||
|
||||
Node 0.8.24-1 adds a fixed 2 A pulse on one selected VESC, with a duration
|
||||
chosen from 1.5, 5 or 10 seconds. Discovery and session validation accept up
|
||||
to 128 directly attached controllers per board; there is no two-motor role enum.
|
||||
Names are the existing UUID-bound device names, scoped to their board.
|
||||
Synthetic 1/6/10-controller tests do not establish physical USB capacity.
|
||||
This is not a vehicle drive controller, completed RC arbiter, or calibration.
|
||||
All attached UUID sessions, firmware 5.02 / 75_300_R2, FOC, PPM Duty Cycle,
|
||||
neutral input for one second and a zero-current failsafe are mandatory.
|
||||
Official 5.02 schemas are included unchanged with their upstream license.
|
||||
Before any torque, motor and application configurations of every attached
|
||||
controller are archived durably, and each CAN segment is checked for unmanaged
|
||||
peers. Attachments are rechecked throughout the pulse. Each
|
||||
controller receives its own 250 ms volatile app-output lease (CAN-forward
|
||||
flag false). Other controllers receive zero current; the target receives only 2 A.
|
||||
A receiver command, serial fault, telemetry limit or local Stop ends the test.
|
||||
No config/firmware write, arbitrary current, arbitrary packet or CAN broadcast
|
||||
is exposed. Replaying an operation ID never repeats physical work.
|
||||
|
||||
Firmware PPM pulses reset the global timeout even during app-output pause;
|
||||
therefore the design relies on the expiring local app-output lease returning
|
||||
to the existing PPM neutral/missing-pulse behavior, not solely on USB timeout.
|
||||
RC activity latches further test requests until an explicit neutral release.
|
||||
This is a test-session guard, not continuous production RC takeover monitoring.
|
||||
Neutral PPM alone cannot prove transmitter/link availability. Host-independent
|
||||
lease behavior follows the pinned firmware source; real stop/failsafe
|
||||
qualification is still required and must not be claimed from synthetic tests.
|
||||
|
||||
Versions live in a private SQLite archive, are replicated via existing pairing
|
||||
with ACK after durable Core storage, and remain downloadable when a controller
|
||||
is offline. The Node and Core use the same detail component. The native VESC
|
||||
Tool 7.00 engineering build is separate: it has not acquired serial ownership
|
||||
or been integrated for calibration.
|
||||
|
||||
Package upgrades remove only generated bytecode below the installed VESC
|
||||
payload before preparation starts. Deterministic source mtimes can otherwise
|
||||
validate stale same-size `.pyc` files even with `python -B`; a regression test
|
||||
reproduces the failed 0.8.23-1 upgrade and verifies this installer-owned fix.
|
||||
|
||||
## 0.2.2 — entered test values and drive positions
|
||||
|
||||
Node 0.8.25-1 carries numeric current and duration fields. The board advertises
|
||||
and independently enforces 0.5–5 A and 0.5–10 seconds for this identification
|
||||
mode. These are software bounds, not controller or motor nameplate
|
||||
ratings. Existing 60 A motor / 55 A battery configuration is not evidence that
|
||||
the rig can safely sustain those currents. Above 2 A the test coasts at
|
||||
400 electrical RPM and resumes current below 250, with a separate 800 ERPM
|
||||
abort threshold. Requested current is also bounded by the read configuration.
|
||||
Receipts distinguish successfully sent current commands from sampled cycles
|
||||
that ended before transmission. These bounds do not admit maximum-power tests.
|
||||
|
||||
The existing VESC detail offers one board-wide drive profile: 1×1 means left
|
||||
and right (two motors); 2×2 means left front, left rear, right front and right
|
||||
rear (four motors). Directions are relative to forward vehicle motion. Position
|
||||
is manually assigned after physical identification and persists by controller
|
||||
UUID, not USB address. The same component is used on Node and paired Core.
|
||||
Revision checks prevent stale updates; an occupied position cannot be stolen,
|
||||
and rear assignments must be explicitly removed before shrinking to 1×1.
|
||||
Changing profile/position is local metadata and sends no VESC command.
|
||||
Discovery itself remains independent of these two admitted layout presets.
|
||||
|
||||
## 0.2.3 — explain blocked tests and retain stop evidence
|
||||
|
||||
Numeric fields show validation errors beside invalid values. The action area
|
||||
explains why Start is unavailable, including confirmation reset after a test.
|
||||
A telemetry-bound stop retains the triggering sample and its field, measured
|
||||
value and unchanged bounds. A completed current pulse never proves physical
|
||||
rotation; the owner must observe the motor before assigning its position.
|
||||
|
||||
## 0.2.4 — gradual current in identification mode
|
||||
|
||||
The confirmed right motor crossed800ERPM within0.245s of a5A step. The next
|
||||
profile starts and resumes at0.5A, increasing by approximately1A/s toward the
|
||||
entered ceiling. Soft coasting triggers at200ERPM or4%PWM and only resumes
|
||||
below100ERPM and2%PWM. It applies to every allowed current, while the hard
|
||||
abort thresholds remain unchanged. This is an identification pulse governor,
|
||||
not a vehicle speed controller. Command receipts record actual requested
|
||||
current per sample, including ramp/coasting;5A input is a ceiling.
|
||||
|
||||
|
||||
## 0.2.5 — entered current up to 30 A and time up to 30 seconds
|
||||
|
||||
At the owner's request the raised-rig test accepts 0.5–30 A and 0.5–30 s.
|
||||
These are software admission bounds, not motor/controller nameplate ratings.
|
||||
The selected controller's configured motor and input current limits still bind.
|
||||
Positive current starts at0.5A and ramps by2A/s; the entered value is a current
|
||||
ceiling, not a speed request. No configuration or firmware write is performed.
|
||||
|
||||
The former200ERPM/4%PWM coast/restart loop was causing the observed right-motor
|
||||
steps and pauses. This increment removes that automatic cycling. Current is
|
||||
maintained until time expires, Stop/RC/link interruption, or a telemetry limit.
|
||||
The test ends at6000ERPM or25%PWM (or a lower configured speed/duty limit),
|
||||
without automatic re-acceleration. A no-load motor can reach a speed limit
|
||||
before the entered time:30seconds is the maximum duration, not a promise of
|
||||
constant-speed rotation. Current feedback has bounded overshoot tolerance,
|
||||
capped by the configured motor current limit. Fault, voltage, temperature,
|
||||
identity/topology, neutral RC, expiring per-device leases and release checks
|
||||
remain active.
|
||||
|
||||
Above5A, a2-second interval without at least three net electrical tachometer
|
||||
steps at60ERPM ends the test. This uses the existing Hall/FOC estimate; it is
|
||||
not independent mechanical feedback or certified thermal protection, especially
|
||||
with a damaged sensor connection. It prevents continuing to raise commanded
|
||||
current while the reported rotor remains stationary. Sustained vehicle control,
|
||||
calibration and native VESC Tool parity remain separate unfinished work.
|
||||
|
||||
The owner clarified the diagnostic roles: LEFT is the problematic motor;
|
||||
RIGHT works normally from RC and is physically assigned right.1. Short steps
|
||||
in the earlier Core test must not be recorded as a right-motor defect. Every
|
||||
powered engineering experiment is coordinated with the owner at launch time.
|
||||
|
||||
|
||||
## 0.3.0 — measured speed hold and native Hall measurement
|
||||
|
||||
`vesc.motor.run` calls firmware speed PID (`COMM_SET_RPM`), ramps the setpoint
|
||||
at 600 ERPM/s and counts time only after one second within 15% of the requested
|
||||
speed with a changing VESC tachometer. The UI exposes speed 300–3000 ERPM,
|
||||
motor-current ceiling 0.5–30 A and **rotation time** 0.5–30 s. Startup is bounded
|
||||
by 15 s; losing speed for 2 s ends the run. No automatic restart. FOC telemetry
|
||||
is not an independent physical encoder; the operator compares visible motion.
|
||||
The old `vesc.motor.pulse` action remains compatible but is not used by this UI.
|
||||
|
||||
Before a speed command, `COMM_SET_MCCONF_TEMP` applies current scaling to both
|
||||
positive and braking motor current. Store/CAN/divide flags are false. ACK and
|
||||
full configuration readback precede torque. A durable UUID-bound journal exists
|
||||
before the first write. Cleanup restores the exact original configuration,
|
||||
verified byte-for-byte. A lost ACK, disconnection or process interruption leaves
|
||||
the journal pending; discovery retries restoration only with a matching identity,
|
||||
zero current, neutral receiver and unchanged unrelated configuration. An external
|
||||
configuration change is never overwritten. No flash or application write is used.
|
||||
The operation transport permits 90 s, including preflight and acceleration.
|
||||
|
||||
`vesc.hall.measure` is the native FW 5.02 `COMM_DETECT_HALL_FOC` procedure also
|
||||
used by VESC Tool. It uses fixed 5 A, sweeps three electrical turns forward and
|
||||
three backwards, returns the observed table and restores its prior configuration.
|
||||
The table is **not automatically applied**. The firmware locks `mc_interface`
|
||||
during this approximately 12 s cycle; USB current-zero and receiver input cannot
|
||||
interrupt it. The UI requires a separate observed-rig/physical-power-cut
|
||||
acknowledgement and explicitly describes this limitation. Unknown completion
|
||||
latches authority and blocks another powered operation. Measurement, samples and
|
||||
backup references remain in the receipt.
|
||||
|
||||
These operations do not constitute the full VESC Tool desktop UI. The product
|
||||
entry is labelled “Настройка VESC” until complete native application session
|
||||
integration is shipped. Full R/L/flux calibration, table application and
|
||||
configuration restore remain separate unimplemented work.
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {ConfigurationVersion,SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function VescBackups({deviceId,archive,revision}: {deviceId:string;archive:SensorTransport['configurationArchive'];revision:string|null}) {
|
||||
const [reload,setReload]=useState(0);
|
||||
const [items,setItems]=useState<ConfigurationVersion[]>([]);
|
||||
const [next,setNext]=useState<string|null>(null);
|
||||
const [loading,setLoading]=useState(false);
|
||||
const [error,setError]=useState<string|null>(null);
|
||||
const [download,setDownload]=useState<string|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
setItems([]);setNext(null);setError(null);
|
||||
if(!archive)return;
|
||||
setLoading(true);
|
||||
archive.list(deviceId).then(result=>{if(active){setItems(result.items);setNext(result.next);}})
|
||||
.catch(()=>{if(active)setError('Не удалось загрузить историю конфигураций.');})
|
||||
.finally(()=>{if(active)setLoading(false);});
|
||||
return()=>{active=false;};
|
||||
},[deviceId,archive,revision,reload]);
|
||||
async function more(){
|
||||
if(!archive||!next||loading)return;
|
||||
setLoading(true);setError(null);
|
||||
try{const result=await archive.list(deviceId,next);setItems(current=>[...current,...result.items.filter(item=>!current.some(old=>old.id===item.id))]);setNext(result.next);}
|
||||
catch{setError('Не удалось загрузить следующие версии.');}
|
||||
finally{setLoading(false);}
|
||||
}
|
||||
async function save(id:string){
|
||||
if(!archive||download)return;
|
||||
setDownload(id);setError(null);
|
||||
try{
|
||||
const value=await archive.read(deviceId,id);
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}));
|
||||
const link=document.createElement('a');link.href=url;link.download=`${deviceId}-${id}.json`;link.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}catch{setError('Не удалось скачать выбранную версию.');}
|
||||
finally{setDownload(null);}
|
||||
}
|
||||
return <SettingsCard title="История конфигураций" description="Сохранённые версии остаются на борту и передаются в Core при подключении." actions={<Button disabled={loading||!archive} onClick={()=>setReload(value=>value+1)}>Обновить историю</Button>}>
|
||||
<LoadingRegion loading={loading&&!items.length} label="Загрузка версий конфигурации">
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{!loading&&!items.length&&!error&&<p>Сохранённых версий пока нет.</p>}
|
||||
<ResourceList aria-label="Версии конфигурации VESC">{items.map(item=><li key={item.id}>
|
||||
<ResourceRow title={new Date(item.observed_at).toLocaleString()} description={`Прошивка ${item.firmware} · мотор и входы`}
|
||||
actions={<Button disabled={download!==null} loading={download===item.id} onClick={()=>void save(item.id)}>Скачать</Button>}/>
|
||||
</li>)}</ResourceList>
|
||||
</LoadingRegion>
|
||||
{next&&<Button loading={loading} disabled={loading} onClick={()=>void more()}>Ещё версии</Button>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,InspectorSelectField,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorBoardSettingsProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform,type Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {drivePositions,vescStatus,type DriveProfile} from './model';
|
||||
import {VescLimits} from './VescLimits';
|
||||
|
||||
export function VescBoardSettings({inventory,transport,enabled,refresh,failure,openDevice}:SensorBoardSettingsProps){
|
||||
const controllers=inventory?.items.filter(device=>device.kind==='vesc.controller')??[];
|
||||
const profile=controllers.map(device=>vescStatus(device).drive_profile).filter((value):value is DriveProfile=>!!value).sort((a,b)=>b.revision-a.revision)[0];
|
||||
const anchor=controllers.find(device=>device.online&&device.verified&&vescStatus(device).board_settings_supported);
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const active=inventory?.operations?.some(value=>['queued','running'].includes(value.state)&&controllers.some(device=>device.id===value.device_id));
|
||||
const blocked=!enabled||!anchor||busy||active||!profile;
|
||||
const positions=drivePositions(profile?.layout??null);
|
||||
async function change(device:Sensor,action:string,parameters:Record<string,unknown>){
|
||||
if(blocked||running.current||!profile)return;
|
||||
running.current=true;setBusy(true);failure(null);
|
||||
try{await perform(transport,device,action,{revision:profile.revision,...parameters});await refresh();}
|
||||
catch(error){failure(error);await refresh();}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <div className="sensor-content">
|
||||
<SettingsCard title="Привод" description="Профиль аппарата и расположение его моторов.">
|
||||
<InspectorSelectField label="Профиль привода" value={profile?.layout??''} disabled={blocked} options={[
|
||||
{value:'',label:'Выберите профиль',disabled:true},
|
||||
{value:'1x1',label:'1×1 · 2 мотора',disabled:!!profile?.bindings['left.2']||!!profile?.bindings['right.2']},
|
||||
{value:'2x2',label:'2×2 · 4 мотора'},
|
||||
]} onChange={layout=>{if(anchor)void change(anchor,'vesc.drive.layout',{layout});}}/>
|
||||
{!profile&&<p>{inventory?'Подключите VESC, чтобы получить профиль привода с борта.':'Получение профиля привода…'}</p>}
|
||||
{profile&&!anchor&&<p>Профиль показан по последним сведениям с борта. Для изменения нужна связь с VESC и актуальное бортовое приложение.</p>}
|
||||
{profile?.layout&&<>
|
||||
<p>Стороны — по направлению движения вперёд. Назначения сохраняются автоматически и остаются с контроллером при смене USB-порта.</p>
|
||||
{Object.entries(positions).map(([slot,label])=>{
|
||||
const bound=profile.bindings[slot];
|
||||
const missing=bound&&!controllers.some(device=>device.id===bound.device_id);
|
||||
return <InspectorSelectField key={slot} label={label} value={bound?.device_id??''} disabled={blocked} options={[
|
||||
{value:'',label:'Не назначен'},
|
||||
...controllers.map(device=>({value:device.id,label:device.name+(!device.online?' · нет связи':''),disabled:!device.online||!device.verified||Object.entries(profile.bindings).some(([other,binding])=>other!==slot&&binding.device_id===device.id)})),
|
||||
...(missing?[{value:bound.device_id,label:`VESC ${bound.uuid.slice(0,6).toUpperCase()} · нет связи`,disabled:true}]:[]),
|
||||
]} onChange={id=>{
|
||||
const target=id?controllers.find(device=>device.id===id):anchor;
|
||||
if(!target)return;
|
||||
void change(target,id?'vesc.drive.assign':'vesc.drive.unassign',id?{layout:profile.layout,slot}:{slot});
|
||||
}}/>;
|
||||
})}
|
||||
<ResourceList aria-label="Настройка назначенных моторов">{Object.entries(profile.bindings).map(([slot,binding])=>{
|
||||
const target=controllers.find(device=>device.id===binding.device_id);
|
||||
return <li key={slot}><ResourceRow title={positions[slot]??slot} description={target?.name??`VESC ${binding.uuid.slice(0,6).toUpperCase()}`} actions={<Button disabled={!target?.prepared||busy||active} onClick={()=>openDevice(binding.device_id)}>Настройка мотора</Button>}/></li>;
|
||||
})}</ResourceList>
|
||||
</>}
|
||||
</SettingsCard>
|
||||
<VescLimits controllers={controllers} transport={transport} enabled={enabled&&!busy&&!active} failure={failure}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,ResourceList,ResourceRow,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface CalibrationResult {
|
||||
completed:boolean;success:boolean;configuration_verified:boolean;release_confirmed:boolean;
|
||||
native:{success?:boolean;code?:number;sensor_mode?:number;parameters?:Record<string,number>};
|
||||
}
|
||||
|
||||
export function VescCalibration({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [loss,setLoss]=useState('50');
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<CalibrationResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const limits=vescStatus(device).foc_calibration;
|
||||
const power=Number(loss);
|
||||
const valid=!!limits&&loss.trim()!==''&&Number.isFinite(power)&&power>=limits.min_power_loss_w&&power<=limits.max_power_loss_w;
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!limits;
|
||||
async function calibrate(){
|
||||
if(running.current||blocked||!available||!confirmed||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<CalibrationResult>(transport,target,'vesc.foc.calibrate',{
|
||||
rig_clear:true,native_cycle_confirmed:true,max_power_loss_w:power,
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},300000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
const parameters=result?.native.parameters;
|
||||
const calibrated=result?.native.success===true&&result.configuration_verified;
|
||||
return <SettingsCard title="Калибровка мотора" description={`${device.name} · штатное автоопределение FOC в VESC Tool.`}>
|
||||
<p>Мастер измеряет сопротивление, индуктивность и магнитный поток, определяет датчики и записывает параметры выбранного мотора. Версии до и после сохраняются в истории; настройки аккумулятора и пульта сохраняются.</p>
|
||||
<TextField type="number" label="Допустимые потери в моторе, Вт" value={loss} onChange={event=>setLoss(event.target.value)} disabled={busy||blocked||!limits} min={limits?.min_power_loss_w} max={limits?.max_power_loss_w} step="5" aria-invalid={!valid} description="Параметр нагрева для мастера VESC Tool, не номинальная мощность мотора. По нему мастер выбирает токи измерения; предел тока проверки вращения здесь не применяется."/>
|
||||
<p>Мотор будет двигаться и разгоняться. На прошивке 5.02 процедуру нельзя прервать кнопкой или пультом — только отключением силового питания. Оставьте пульт выключенным и приводы вывешенными до завершения; цикл может занять до трёх минут.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Наблюдаю мотор, питание могу отключить"/>
|
||||
<Button disabled={!available||blocked||!confirmed||!valid||busy} loading={busy} onClick={()=>void calibrate()}>Откалибровать мотор</Button>
|
||||
{busy&&<p role="status">Подготовка и калибровка VESC Tool. Дождитесь результата и снятия тока.</p>}
|
||||
{result&&<>
|
||||
<p role="status">{calibrated?'Параметры мотора измерены, записаны и проверены.':result.completed?`Калибровка не принята. Код VESC: ${result.native.code??'не получен'}.`:'Завершение калибровки не подтверждено. Проверьте состояние мотора и питание.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока пока не подтверждено. Перед следующей проверкой верните управление после нейтрали.'} {!result.configuration_verified&&' Проверка конфигурации не завершена; новое движение заблокировано.'}</p>
|
||||
{calibrated&&<>
|
||||
<p>{result.native.sensor_mode===0?'Выбран режим без датчиков. Холлы не определились; качество запуска нужно проверить вращением.':result.native.sensor_mode===2?'Определены датчики Холла.':'Определён энкодер.'}</p>
|
||||
{parameters&&<ResourceList aria-label="Результат калибровки">
|
||||
{([['foc_motor_r','Сопротивление',1000,'мОм'],['foc_motor_l','Индуктивность',1e6,'мкГн'],['foc_motor_flux_linkage','Магнитный поток',1000,'мВб'],['l_current_max','Предел тока мотора',1,'А']] as const).map(([key,title,scale,unit])=><li key={key}><ResourceRow title={title} description={`${(parameters[key]*scale).toLocaleString('ru-RU',{maximumFractionDigits:3})} ${unit}`}/></li>)}
|
||||
</ResourceList>}
|
||||
</>}
|
||||
</>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescLabel,vescStatus,type VescTelemetry} from './model';
|
||||
import {VescBackups} from './VescBackups';
|
||||
import {VescMotor} from './VescMotor';
|
||||
import {VescHall} from './VescHall';
|
||||
import {VescCalibration} from './VescCalibration';
|
||||
import {VescLink} from './VescLink';
|
||||
|
||||
const fields=[['input_voltage_v','Напряжение питания','В'],['input_current_a','Ток питания','А'],
|
||||
['motor_current_a','Ток мотора','А'],['erpm','Электрические обороты','ERPM'],['duty','Заполнение PWM',''],
|
||||
['mos_temperature_c','Температура контроллера','°C'],['motor_temperature_c','Температура мотора','°C'],
|
||||
['fault_code','Код ошибки',''],['can_id','CAN ID',''],['timeout','Тайм-аут управления',''],
|
||||
['kill_switch','Вход аварийного останова','']] as const;
|
||||
|
||||
export function VescDetail(props:SensorDetailProps){
|
||||
const {device,transport,enabled,back,refresh,failure}=props;
|
||||
const status=vescStatus(device);const label=vescLabel(device,enabled);
|
||||
const [telemetry,setTelemetry]=useState<VescTelemetry|null>(status.telemetry);
|
||||
const [pending,setPending]=useState<string|null>(null);
|
||||
const [saved,setSaved]=useState<string|null>(null);
|
||||
const [motorBusy,setMotorBusy]=useState(false);
|
||||
const [hallBusy,setHallBusy]=useState(false);
|
||||
const [calibrationBusy,setCalibrationBusy]=useState(false);
|
||||
const [linkBusy,setLinkBusy]=useState(false);
|
||||
const poweredBusy=motorBusy||hallBusy||calibrationBusy||linkBusy;
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
const available=enabled&&device.online&&device.verified&&status.readable;
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
async function read(action:'vesc.telemetry.read'|'vesc.config.backup'){
|
||||
if(running.current||poweredBusy||!available)return;
|
||||
running.current=true;setPending(action);failure(null);
|
||||
try{
|
||||
const result=await perform<Record<string,unknown>>(transport,device,action);
|
||||
if(!mounted.current)return;
|
||||
if(action==='vesc.telemetry.read')setTelemetry(result as unknown as VescTelemetry);
|
||||
else {
|
||||
setSaved(String(result.observed_at));
|
||||
}
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current)setPending(null);}
|
||||
}
|
||||
const backupAt=saved??status.backup?.observed_at;
|
||||
return <div className="sensor-content">
|
||||
<div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={`${device.name} · Настройка VESC`} description={`${device.model} · ${device.connection_label??'USB'}`}
|
||||
actions={<StatusBadge tone={label.tone}>{label.label}</StatusBadge>}>
|
||||
{!enabled||!device.online?<p>Нет свежей связи с контроллером.</p>:status.message?<p>{status.message}</p>:null}
|
||||
{status.identity?<ResourceList aria-label="Контроллер VESC">
|
||||
<li><ResourceRow title="Прошивка" description={status.identity.version}/></li>
|
||||
<li><ResourceRow title="Аппаратная версия" description={status.identity.hardware}/></li>
|
||||
<li><ResourceRow title="UUID" description={status.identity.uuid.toUpperCase()}/></li>
|
||||
{status.identity.test_firmware!==null&&status.identity.test_firmware>0&&<li><ResourceRow title="Тестовая прошивка" description={String(status.identity.test_firmware)}/></li>}
|
||||
</ResourceList>:<p>Аппаратный идентификатор ещё не подтверждён.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Показания контроллера" description={telemetry?`Снимок: ${new Date(telemetry.observed_at).toLocaleString()}`:'Получите текущие значения и код ошибки.'}
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.telemetry.read'} onClick={()=>void read('vesc.telemetry.read')}>Обновить показания</Button>}>
|
||||
<LoadingRegion loading={pending==='vesc.telemetry.read'&&!telemetry} label="Чтение показаний VESC">
|
||||
{telemetry?<ResourceList aria-label="Показания VESC">{fields.map(([key,title,unit])=>{
|
||||
const value=telemetry.values[key];if(value===undefined)return null;
|
||||
return <li key={key}><ResourceRow title={title} description={typeof value==='boolean'?(value?'Активен':'Не активен'):`${Number(value).toLocaleString(undefined,{maximumFractionDigits:3})}${unit?' '+unit:''}`}/></li>;
|
||||
})}</ResourceList>:<p>Показания ещё не прочитаны.</p>}
|
||||
</LoadingRegion>
|
||||
<p>ERPM — электрические обороты. Обороты вала зависят от числа пар полюсов мотора.</p>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Резервная копия конфигурации" description="Сохраните текущие параметры мотора и входов новой версией."
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.config.backup'} onClick={()=>void read('vesc.config.backup')}>Сохранить версию</Button>}>
|
||||
{backupAt&&<p>Последняя копия: {new Date(backupAt).toLocaleString()}</p>}
|
||||
<p>Копия привязана к UUID и прошивке. Версии до и после калибровки остаются в истории.</p>
|
||||
</SettingsCard>
|
||||
<VescLink {...props} blocked={motorBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setLinkBusy}/>
|
||||
<VescCalibration {...props} blocked={linkBusy||motorBusy||hallBusy||pending!==null} onBusyChange={setCalibrationBusy}/>
|
||||
<VescHall {...props} blocked={linkBusy||motorBusy||calibrationBusy||pending!==null} onBusyChange={setHallBusy}/>
|
||||
<VescMotor {...props} blocked={linkBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setMotorBusy}/>
|
||||
<VescBackups deviceId={device.id} archive={transport.configurationArchive} revision={backupAt??null}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface HallResult {
|
||||
completed:boolean;configuration_restored:boolean;release_confirmed:boolean;
|
||||
measurement:null|{valid_six_states:boolean;observed_states:number[];hall_table:number[]};
|
||||
}
|
||||
|
||||
export function VescHall({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<HallResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!vescStatus(device).hall_measurement;
|
||||
async function measure(){
|
||||
if(running.current||blocked||!available||!confirmed)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<HallResult>(transport,target,'vesc.hall.measure',{
|
||||
rig_clear:true,native_cycle_confirmed:true,
|
||||
...(vescStatus(target).hall_measurement?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},60000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
return <SettingsCard title="Датчики Холла" description={`${device.name} · штатное измерение VESC Tool, 5 А.`}>
|
||||
<p>Мотор медленно смещается в обе стороны около 12 секунд. Измерение проверяет состояния датчиков и получает таблицу их положения; новая таблица автоматически не записывается.</p>
|
||||
<p>На прошивке 5.02 этот цикл нельзя прервать кнопкой или пультом. Для немедленной остановки нужно отключить силовое питание. Пульт должен оставаться выключенным, все приводы — вывешенными. Перед запуском убедитесь, что все моторы полностью остановились: в бессенсорном режиме показание оборотов на остановленном моторе может быть ненулевым.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Все моторы остановлены, наблюдаю"/>
|
||||
<Button disabled={!available||blocked||!confirmed||busy} loading={busy} onClick={()=>void measure()}>Измерить датчики Холла</Button>
|
||||
{busy&&<p role="status">Подготовка и штатное измерение датчиков. Дождитесь результата.</p>}
|
||||
{result&&<p role="status">{!result.completed?'Завершение измерения не подтверждено. Проверьте мотор и питание.':result.measurement?.valid_six_states?'Измерение получило шесть состояний Холла.':'Не удалось получить полную таблицу Холла. Возможны отсутствие движения или неисправность датчиков/соединения.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'} {result.configuration_restored?'Исходная конфигурация сохранена.':'Возврат исходной конфигурации не подтверждён.'}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface Limits {observed_at:string;identity?:{uuid:string};parameters:Record<string,number>}
|
||||
const number=(value:number|undefined)=>value===undefined?'—':value.toLocaleString('ru-RU',{maximumFractionDigits:2});
|
||||
export function VescLimits({controllers,transport,enabled,failure}:{controllers:Sensor[];transport:SensorTransport;enabled:boolean;failure:(error:unknown)=>void}){
|
||||
const [values,setValues]=useState<Record<string,Limits>>({});
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const readable=controllers.filter(device=>device.online&&device.verified&&vescStatus(device).readable&&vescStatus(device).board_settings_supported);
|
||||
async function read(){
|
||||
if(!enabled||running.current||!readable.length)return;
|
||||
running.current=true;setBusy(true);failure(null);setValues({});
|
||||
try{for(const device of readable){const value=await perform<Limits>(transport,device,'vesc.limits.read');setValues(current=>({...current,[device.id]:value}));}}
|
||||
catch(error){failure(error);}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <SettingsCard title="Ограничения контроллеров" description="Текущие настройки VESC. Чтение не запускает моторы и не меняет конфигурацию." actions={<Button disabled={!enabled||busy||!readable.length} loading={busy} onClick={()=>void read()}>Прочитать ограничения</Button>}>
|
||||
<p>Ток мотора задаёт тягу; ток батареи ограничивает потребление. Эти настройки действуют и при управлении с пульта. Паспортные пределы моторов, контроллеров и батареи проверяются отдельно.</p>
|
||||
{controllers.map(device=>{
|
||||
const value=values[device.id];if(!value)return null;
|
||||
const p=value.parameters;
|
||||
const fields=[
|
||||
['Ток мотора · разгон / торможение',`${number(p.l_current_max)} / ${number(p.l_current_min)} А`],
|
||||
['Масштаб тока · разгон / торможение',`${number(p.l_current_max_scale*100)} / ${number(p.l_current_min_scale*100)} %`],
|
||||
['Ток батареи · потребление / рекуперация',`${number(p.l_in_current_max)} / ${number(p.l_in_current_min)} А`],
|
||||
['Диапазон электрических оборотов',`${number(p.l_min_erpm)} … ${number(p.l_max_erpm)} ERPM`],
|
||||
['Максимальная мощность',p.l_watt_max>=1500000?'Отдельный предел не задан':`${number(p.l_watt_max)} Вт`],
|
||||
['Максимальный duty cycle',`${number(p.l_max_duty*100)} %`],
|
||||
];
|
||||
return <SettingsCard key={device.id} title={device.name} description={`Прочитано ${new Date(value.observed_at).toLocaleString('ru-RU')}`}><ResourceList aria-label={`Ограничения ${device.name}`}>{fields.map(([title,description])=><li key={title}><ResourceRow title={title} description={description}/></li>)}</ResourceList></SettingsCard>;
|
||||
})}
|
||||
{!Object.keys(values).length&&<p>Прочитайте значения для подключённых контроллеров.</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface LinkResult {
|
||||
outcome:string;duration_s:number;
|
||||
devices:Record<string,{name:string;summary:{replies:number;p95_ms?:number;max_ms?:number;over_60_ms?:number}}>;
|
||||
}
|
||||
export function VescLink({device,transport,enabled,failure,refresh,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [busy,setBusy]=useState(false);const [result,setResult]=useState<LinkResult|null>(null);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const available=enabled&&device.online&&device.verified&&vescStatus(device).link_check_supported;
|
||||
async function check(){
|
||||
if(running.current||blocked||!available)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);failure(null);setResult(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const peers=inventory.items.filter(item=>item.kind==='vesc.controller');
|
||||
const selected=peers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!selected||peers.some(item=>!item.online||!item.verified))throw new Error('Обновите связь со всеми VESC борта.');
|
||||
const sessions=Object.fromEntries(peers.map(item=>[item.id,item.snapshot.context.session_id]));
|
||||
const value=await perform<LinkResult>(transport,selected,'vesc.link.check',{sessions},45000);
|
||||
if(mounted.current)setResult(value);
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current){setBusy(false);onBusyChange(false);}}
|
||||
}
|
||||
if(!vescStatus(device).link_check_supported)return null;
|
||||
const text=result?.outcome==='complete'?'Все ответы получены. Эта проверка не подтверждает связь во время вращения.':
|
||||
result?.outcome==='not_idle'?'Измерение прекращено: есть команда с пульта или движение мотора.':
|
||||
result?.outcome==='read_failed'?'Ответ контроллера не получен. Проверка прервана.':
|
||||
'Измерение не завершено.';
|
||||
return <SettingsCard title="Связь с контроллерами" description="Проверяет ответы всех VESC борта около 10 секунд. Моторы должны стоять; команды вращения и изменения настроек не отправляются."
|
||||
actions={<Button disabled={!available||blocked||busy} loading={busy} onClick={()=>void check()}>Проверить связь</Button>}>
|
||||
{busy&&<p role="status">Измеряется время ответа контроллеров.</p>}
|
||||
{result&&<><p role="status">{text}</p><ResourceList aria-label="Связь VESC">{Object.entries(result.devices).map(([id,item])=><li key={id}><ResourceRow title={item.name}
|
||||
description={`${item.summary.replies} ответов${item.summary.max_ms===undefined?'':` · 95% не дольше ${Math.ceil(item.summary.p95_ms??0)} мс · максимум ${Math.ceil(item.summary.max_ms)} мс`}`}/></li>)}</ResourceList></>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,Checker,Select,TextField,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {testInput,speedInput,rotationResult,vescStatus,driveTestIds,drivePositions} from './model';
|
||||
|
||||
export function VescMotor({device,transport,enabled,refresh,failure,blocked=false,onBusyChange}:SensorDetailProps&{blocked?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||
const [duration,setDuration]=useState('30');
|
||||
const [current,setCurrent]=useState('30');
|
||||
const [speed,setSpeed]=useState('2000');
|
||||
const [direction,setDirection]=useState('forward');
|
||||
const [scope,setScope]=useState('single');
|
||||
const status=vescStatus(device),profile=status.drive_profile;
|
||||
const driveIds=driveTestIds(profile,device.id);
|
||||
const group=scope==='profile';
|
||||
const groupAvailable=status.group_test_supported===true&&driveIds.length>1;
|
||||
const [clear,setClear]=useState(false);const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<string|null>(null);const [stopping,setStopping]=useState(false);
|
||||
const [rc,setRc]=useState(device.vesc_status?.rc_latched===true);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const limits=vescStatus(device).test_limits;
|
||||
const speedLimits=vescStatus(device).speed_limits;
|
||||
const speedValue=speedInput(speed,speedLimits);
|
||||
const input=testInput(current,duration,limits);
|
||||
const available=!!limits&&!!speedLimits&&enabled&&!blocked&&device.online&&device.verified&&device.vesc_status?.test_supported===true;
|
||||
const valid=input.valid&&!speedValue.error&&(direction==='forward'||speedLimits?.reverse_supported===true)&&(!group||groupAvailable);
|
||||
const blockedReason=busy?'Подготовка и проверка выполняются. Дождитесь результата или остановите проверку.':
|
||||
!enabled||!device.online?'Для запуска нужна свежая связь с бортом и VESC.':
|
||||
!device.verified?'Контроллер ещё не определён. Обновите устройства.':
|
||||
!available?'Проверка вращения для этого VESC сейчас недоступна на борту.':
|
||||
!valid?'Исправьте значения в отмеченных полях.':
|
||||
!clear?'Для запуска подтвердите, что все приводы остановлены, вывешены и вращение свободно.':null;
|
||||
async function execute(release=false){
|
||||
if(running.current||!available||!clear||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange?.(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми подключёнными VESC этого борта.');
|
||||
if(group&&!release&&(vescStatus(target).drive_profile?.revision!==profile?.revision||driveIds.some(id=>!controllers.some(item=>item.id===id))))throw new Error('Профиль или состав моторов изменился. Обновите карточку.');
|
||||
const value=await perform<{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}>(transport,target,release?'vesc.control.release':group?'vesc.drive.run':'vesc.motor.run',{
|
||||
rig_clear:true,duration_s:input.durationS,current_a:input.currentA,
|
||||
...(release?{}:{erpm:speedValue.erpm*(direction==='reverse'?-1:1)}),
|
||||
...(vescStatus(target).speed_limits?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
...(!release&&group?{profile_revision:profile?.revision,device_ids:driveIds}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},group&&!release?120000:90000);
|
||||
if(mounted.current){
|
||||
setResult(release?'Управление возвращено для проверки.':rotationResult(value));
|
||||
if(release)setRc(false);
|
||||
}
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{
|
||||
running.current=false;
|
||||
onBusyChange?.(false);
|
||||
if(mounted.current){setBusy(false);setClear(false);await refresh();}
|
||||
}
|
||||
}
|
||||
async function stop(){
|
||||
if(stopping)return;setStopping(true);
|
||||
try{const value=await perform<{interruptible?:boolean}>(transport,device,'vesc.motor.stop',{},15000);if(mounted.current)setResult(value.interruptible===false?'Идёт штатный цикл измерения VESC. Немедленная остановка возможна отключением питания.':'Остановка запрошена. Ожидаем результат проверки.');}
|
||||
catch(error){if(mounted.current)failure(error);}
|
||||
finally{if(mounted.current)setStopping(false);}
|
||||
}
|
||||
useEffect(()=>setRc(device.vesc_status?.rc_latched===true),[device.vesc_status?.rc_latched]);
|
||||
return <SettingsCard title="Проверка вращения" description={`${device.name} · ${device.connection_label??'USB'}. Конфигурации подключённых контроллеров сохраняются автоматически.`}>
|
||||
<p>Для вывешенных колёс без нагрузки. При команде с приёмника проверка прекращается; повторный запуск требует явного возврата управления.</p>
|
||||
<Select label="Проверяемые моторы" value={scope} disabled={busy||blocked} options={[{value:'single',label:`Только ${device.name}`},{value:'profile',label:'Все моторы профиля одновременно',disabled:!groupAvailable}]} onChange={setScope}/>
|
||||
{group&&profile&&<p>{Object.keys(drivePositions(profile.layout)).map(slot=>`${drivePositions(profile.layout)[slot]} · VESC ${profile.bindings[slot]?.uuid.slice(0,6).toUpperCase()??'не назначен'}`).join('; ')}. Общий отсчёт начинается, когда все моторы удерживают скорость. Остановка любого завершает всю проверку. Предел тока применяется к каждому мотору.</p>}
|
||||
<Select label="Направление вращения" value={direction} disabled={busy||blocked} options={[{value:'forward',label:'Прямое'},{value:'reverse',label:'Обратное',disabled:speedLimits?.reverse_supported!==true}]} onChange={value=>{setDirection(value);setClear(false);}}/>
|
||||
<p>Направление относительно настроек VESC. Перед обратным запуском дождитесь полной остановки всех моторов и подтвердите её.</p>
|
||||
<TextField type="number" inputMode="decimal" label="Скорость, ERPM" value={speed} onChange={event=>setSpeed(event.target.value)} disabled={busy||!speedLimits} min={speedLimits?.min_erpm} max={speedLimits?.max_erpm} step="100" aria-invalid={!!speedValue.error} description={speedValue.error??'Электрические обороты в минуту. VESC плавно разгоняет мотор и удерживает заданную скорость.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Предел тока мотора, А" value={current} onChange={event=>setCurrent(event.target.value)} disabled={busy||!limits} min={limits?.min_current_a} max={limits?.max_current_a} step="0.1" aria-invalid={!!input.currentError} hint={limits?`${limits.min_current_a}–${limits.max_current_a} А`:undefined} description={input.currentError??'Максимальный ток разгона и удержания скорости. Прежние пределы сохраняются перед тестом и восстанавливаются после него.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Длительность вращения, с" value={duration} onChange={event=>setDuration(event.target.value)} disabled={busy||!limits} min={limits?.min_duration_s} max={limits?.max_duration_s} step="0.1" aria-invalid={!!input.durationError} hint={limits?`${limits.min_duration_s}–${limits.max_duration_s} с`:undefined} description={input.durationError??'Отсчёт начинается после разгона и стабилизации скорости. Подготовка, разгон и остановки не входят в это время.'}/>
|
||||
<p>Время считается по скорости и тахометру VESC. Если мотор не разгонится за 15 секунд, потеряет скорость или сработает ограничение, результат покажет фактическое время и причину остановки. Показания контроллера нужно сопоставить с видимым вращением.</p>
|
||||
{limits?.stall_timeout_s&&<p>При токе выше {limits.stall_current_a} А и отсутствии подтверждённого движения в течение {limits.stall_timeout_s} секунд проверка остановится.</p>}
|
||||
<p>Все приводы должны быть вывешены, вращение свободно. Подтверждение требуется перед каждым запуском.</p>
|
||||
<Checker checked={clear} onChange={setClear} disabled={busy} label="Все моторы остановлены, наблюдаю"/>
|
||||
{blockedReason&&<p role="status">{blockedReason}</p>}
|
||||
<div className="sensor-actions">
|
||||
{rc?<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute(true)}>Вернуть управление после нейтрали</Button>:
|
||||
<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute()}>Проверить вращение</Button>}
|
||||
<Button disabled={!enabled||!device.online||blocked} loading={stopping} onClick={()=>void stop()}>Остановить проверку</Button>
|
||||
</div>
|
||||
{result&&<p role="status">{result}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export interface VescIdentity {uuid:string;hardware:string;version:string;test_firmware:number|null;hardware_type:number|null}
|
||||
export interface VescTelemetry {observed_at:string;values:Record<string,number|boolean>}
|
||||
export interface DriveProfile {layout:null|'1x1'|'2x2';revision:number;bindings:Record<string,{device_id:string;uuid:string}>}
|
||||
export function drivePositions(layout:DriveProfile['layout']):Record<string,string> {
|
||||
return layout==='2x2'
|
||||
? {'left.1':'Левый передний','left.2':'Левый задний','right.1':'Правый передний','right.2':'Правый задний'}
|
||||
: {'left.1':'Левый','right.1':'Правый'};
|
||||
}
|
||||
export interface VescStatus {
|
||||
board_settings_supported?:boolean;
|
||||
group_test_supported?:boolean;
|
||||
link_check_supported?:boolean;
|
||||
foc_calibration?:{min_power_loss_w:number;max_power_loss_w:number;interruptible:boolean};
|
||||
speed_limits?:{min_erpm:number;max_erpm:number;duration_basis:string;reverse_supported?:boolean;standstill_confirmation_required?:boolean};
|
||||
hall_measurement?:{current_a:number;interruptible:boolean;standstill_confirmation_required?:boolean};
|
||||
drive_profile?:DriveProfile;
|
||||
test_limits?:{min_current_a:number;max_current_a:number;min_duration_s:number;max_duration_s:number;current_ramp_a_per_s?:number;continuous_current?:boolean;max_erpm?:number;max_duty?:number;stall_current_a?:number;stall_timeout_s?:number};
|
||||
identity:VescIdentity|null; readable:boolean; message:string|null; telemetry:VescTelemetry|null;
|
||||
backup:{observed_at:string;operation_id:string;configs:Record<string,{bytes:number;sha256:string}>}|null;
|
||||
}
|
||||
|
||||
export function driveTestIds(profile:DriveProfile|undefined,selected:string):string[] {
|
||||
if(!profile?.layout)return [];
|
||||
const slots=Object.keys(drivePositions(profile.layout));
|
||||
if(Object.keys(profile.bindings).length!==slots.length||slots.some(slot=>!profile.bindings[slot]))return [];
|
||||
const ids=slots.map(slot=>profile.bindings[slot].device_id);
|
||||
return new Set(ids).size===ids.length&&ids.includes(selected)?ids:[];
|
||||
}
|
||||
|
||||
export function speedInput(speed:string,limits:VescStatus['speed_limits']) {
|
||||
const erpm=Number(speed);
|
||||
const error=!limits?'Для удержания скорости требуется обновление профиля VESC на борту.':
|
||||
speed.trim()===''||!Number.isFinite(erpm)?'Введите скорость.':
|
||||
erpm<limits.min_erpm||erpm>limits.max_erpm?`Скорость должна быть от ${limits.min_erpm} до ${limits.max_erpm} ERPM.`:null;
|
||||
return {erpm,error};
|
||||
}
|
||||
|
||||
export function rotationResult(value:{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}) {
|
||||
const time=(value.rotation_s??0).toLocaleString('ru-RU',{maximumFractionDigits:1});
|
||||
const outcome=value.outcome==='duration'?'Заданное время вращения набрано.':value.outcome==='stopped'?'Проверка остановлена.':String(value.outcome);
|
||||
return `${outcome} Вращение на заданной скорости по данным VESC: ${time} с. ${value.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'}${value.limits_restored?' Исходные токовые пределы восстановлены.':''}`;
|
||||
}
|
||||
export function vescStatus(device:Sensor):VescStatus {
|
||||
return {identity:null,readable:false,message:null,telemetry:null,backup:null,...device.vesc_status} as VescStatus;
|
||||
}
|
||||
export function vescLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||
const status=vescStatus(device);
|
||||
if(!fresh||!device.online)return {label:'Нет связи',tone:'neutral'};
|
||||
if(!device.prepared)return {label:'Требуется подготовка',tone:'neutral'};
|
||||
if(!status.identity)return {label:'Не определён',tone:'warning'};
|
||||
if(!status.readable)return {label:'Прошивка не поддерживается',tone:'warning'};
|
||||
return {label:'Готов к чтению',tone:'success'};
|
||||
}
|
||||
|
||||
export function testInput(current:string,duration:string,limits:VescStatus['test_limits']) {
|
||||
const currentA=Number(current),durationS=Number(duration);
|
||||
const number=(value:number)=>value.toLocaleString('ru-RU');
|
||||
const currentError=!limits?null:current.trim()===''||!Number.isFinite(currentA)
|
||||
? 'Введите ток мотора.'
|
||||
: currentA<limits.min_current_a||currentA>limits.max_current_a
|
||||
? `Ток должен быть от ${number(limits.min_current_a)} до ${number(limits.max_current_a)} А.`:null;
|
||||
const durationError=!limits?null:duration.trim()===''||!Number.isFinite(durationS)
|
||||
? 'Введите длительность проверки.'
|
||||
: durationS<limits.min_duration_s||durationS>limits.max_duration_s
|
||||
? `Длительность должна быть от ${number(limits.min_duration_s)} до ${number(limits.max_duration_s)} с.`:null;
|
||||
return {currentA,durationS,currentError,durationError,valid:!!limits&&!currentError&&!durationError};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {VescDetail} from './VescDetail';
|
||||
import {VescBoardSettings} from './VescBoardSettings';
|
||||
import {vescLabel} from './model';
|
||||
|
||||
export const vescSensorUi:SensorUiContribution={
|
||||
kind:'vesc.controller',Detail:VescDetail,BoardSettings:VescBoardSettings,icon:'activity',retainOffline:true,
|
||||
supportsPreparation:true,supportsRenaming:true,detailLabel:'Настройка VESC',status:vescLabel,
|
||||
};
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <QApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QXmlStreamWriter>
|
||||
#include <stdexcept>
|
||||
#include "vescinterface.h"
|
||||
#include "utility.h"
|
||||
|
||||
static QString sha256(const QByteArray &data) {
|
||||
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static QJsonObject exportConfig(ConfigParams *config, const QByteArray &packet,
|
||||
const QString &xmlName) {
|
||||
VByteArray roundTrip;
|
||||
config->serialize(roundTrip);
|
||||
require(roundTrip == packet.mid(1), "Native configuration round-trip differs from archive");
|
||||
QJsonArray parameters;
|
||||
for (const auto &name : config->getParamOrder()) {
|
||||
const auto p = config->getParamCopy(name);
|
||||
QJsonObject item{{"name", name}, {"title", p.longName},
|
||||
{"description", p.description}, {"suffix", p.suffix},
|
||||
{"transmittable", p.transmittable}, {"editor_scale", p.editorScale}};
|
||||
switch (p.type) {
|
||||
case CFG_T_DOUBLE:
|
||||
item.insert("type", "number"); item.insert("value", p.valDouble);
|
||||
item.insert("min", p.minDouble); item.insert("max", p.maxDouble);
|
||||
item.insert("step", p.stepDouble); item.insert("decimals", p.editorDecimalsDouble);
|
||||
break;
|
||||
case CFG_T_INT: case CFG_T_BITFIELD:
|
||||
item.insert("type", p.type == CFG_T_INT ? "integer" : "bitfield");
|
||||
item.insert("value", p.valInt); item.insert("min", p.minInt);
|
||||
item.insert("max", p.maxInt); item.insert("step", p.stepInt);
|
||||
break;
|
||||
case CFG_T_ENUM:
|
||||
item.insert("type", "enum"); item.insert("value", p.valInt);
|
||||
item.insert("options", QJsonArray::fromStringList(p.enumNames)); break;
|
||||
case CFG_T_BOOL:
|
||||
item.insert("type", "boolean"); item.insert("value", bool(p.valInt)); break;
|
||||
case CFG_T_QSTRING:
|
||||
item.insert("type", "string"); item.insert("value", p.valString);
|
||||
item.insert("max_length", p.maxLen); break;
|
||||
default: item.insert("type", "undefined");
|
||||
}
|
||||
parameters.append(item);
|
||||
}
|
||||
QJsonArray groups;
|
||||
for (const auto &group : config->getParamGroups()) {
|
||||
QJsonArray subgroups;
|
||||
for (const auto &subgroup : config->getParamSubgroups(group)) {
|
||||
subgroups.append(QJsonObject{{"name", subgroup}, {"parameters",
|
||||
QJsonArray::fromStringList(config->getParamsFromSubgroup(group, subgroup))}});
|
||||
}
|
||||
groups.append(QJsonObject{{"name", group}, {"subgroups", subgroups}});
|
||||
}
|
||||
QString xml;
|
||||
QXmlStreamWriter writer(&xml);
|
||||
writer.setAutoFormatting(true);
|
||||
config->getXML(writer, xmlName);
|
||||
// Validate Tool's own XML load as well as its binary codec. No output file,
|
||||
// motor configuration write, custom schema, or custom parameter parser.
|
||||
ConfigParams loaded;
|
||||
loaded = *config;
|
||||
QXmlStreamReader reader(xml);
|
||||
require(loaded.setXML(reader, xmlName), "Native XML round-trip failed");
|
||||
VByteArray xmlRoundTrip; loaded.serialize(xmlRoundTrip);
|
||||
const auto differences = config->checkDifference(&loaded);
|
||||
require(differences.isEmpty(), "Native XML differs beyond upstream comparison tolerance");
|
||||
return QJsonObject{{"parameters", parameters}, {"groups", groups}, {"xml", xml},
|
||||
{"packet_sha256", sha256(packet)}, {"round_trip_exact", true},
|
||||
{"xml_round_trip_exact", xmlRoundTrip == roundTrip},
|
||||
{"xml_equivalent_by_upstream_comparison", true},
|
||||
{"signature", double(config->getSignature())}};
|
||||
}
|
||||
|
||||
static QByteArray archivePacket(const QJsonObject &archive, const QString &key, int command) {
|
||||
auto entry = archive.value("configs").toObject().value(key).toObject();
|
||||
auto encoded = entry.value("payload").toString().toLatin1();
|
||||
auto packet = QByteArray::fromBase64(encoded, QByteArray::AbortOnBase64DecodingErrors);
|
||||
require(entry.value("encoding") == "base64" && !packet.isEmpty() && packet.size() <= 16384,
|
||||
"Invalid archived configuration encoding");
|
||||
require(packet.toBase64() == encoded && packet.size() == entry.value("bytes").toInt(),
|
||||
"Invalid archived configuration length");
|
||||
require(quint8(packet.at(0)) == command && sha256(packet) == entry.value("sha256").toString(),
|
||||
"Archived configuration hash or command mismatch");
|
||||
return packet;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Private per-device JSON process adapter. Every wire operation is upstream.
|
||||
#include "config_export.h"
|
||||
#include <QEventLoop>
|
||||
#include <QElapsedTimer>
|
||||
#include <QSocketNotifier>
|
||||
#include <QSerialPort>
|
||||
#include <QTimer>
|
||||
#include <QFileInfo>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
class ExchangeFailure : public std::runtime_error {
|
||||
public:
|
||||
QJsonObject diagnostics;
|
||||
explicit ExchangeFailure(const QJsonObject &value)
|
||||
: std::runtime_error("Native query timed out or disconnected"), diagnostics(value) {}
|
||||
};
|
||||
|
||||
class Engine {
|
||||
public:
|
||||
VescInterface vesc;
|
||||
Packet *packet;
|
||||
FW_RX_PARAMS identity;
|
||||
bool procedureRunning = false;
|
||||
bool procedureUncertain = false;
|
||||
QJsonObject procedure;
|
||||
bool queryRunning = false;
|
||||
bool allowHardware = false;
|
||||
QByteArray lastMotor, lastApplication, lastHall;
|
||||
|
||||
Engine() {
|
||||
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
|
||||
packet = vesc.findChild<Packet *>();
|
||||
require(packet, "Upstream packet transport missing");
|
||||
QObject::connect(vesc.commands(), &Commands::fwVersionReceived,
|
||||
[&](FW_RX_PARAMS value) { identity = value; });
|
||||
QObject::connect(packet, &Packet::packetReceived, [&](QByteArray &raw) {
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_MCCONF) lastMotor = raw;
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_APPCONF) lastApplication = raw;
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_DETECT_HALL_FOC) lastHall = raw;
|
||||
});
|
||||
}
|
||||
|
||||
void open(const QString &port) {
|
||||
require(QRegularExpression("^/dev/ttyACM[0-9]+$").match(port).hasMatch(), "Unsupported USB path");
|
||||
struct stat st;
|
||||
require(lstat(port.toLocal8Bit(), &st) == 0 && S_ISCHR(st.st_mode) && major(st.st_rdev) == 166,
|
||||
"Not a CDC ACM device");
|
||||
require(vesc.connectSerial(port, 115200), "Native serial connection failed");
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
require(serial && serial->isOpen() && "/dev/" + serial->portName() == port,
|
||||
"Native serial path mismatch");
|
||||
require(flock(serial->handle(), LOCK_EX | LOCK_NB) == 0 && ioctl(serial->handle(), TIOCEXCL) == 0,
|
||||
"Serial port is already owned");
|
||||
allowHardware = true;
|
||||
query(COMM_FW_VERSION, 3000);
|
||||
require(identity.major == 5 && identity.minor == 2 && identity.hwType == HW_TYPE_VESC
|
||||
&& identity.isTestFw == 0 && identity.customConfigNum == 0,
|
||||
"Native hardware acceptance currently admits stable FW 5.02 only");
|
||||
}
|
||||
|
||||
QByteArray exchange(int command, int timeoutMs, const std::function<void()> &send) {
|
||||
require(allowHardware && vesc.isPortConnected(), "Device disconnected");
|
||||
require(!queryRunning, "A native query is already pending");
|
||||
queryRunning = true;
|
||||
struct PendingReset { bool &pending; ~PendingReset() { pending = false; } } reset{queryRunning};
|
||||
QEventLoop loop;
|
||||
QTimer timeout; timeout.setSingleShot(true);
|
||||
QObject observer;
|
||||
QElapsedTimer elapsed; elapsed.start();
|
||||
QJsonArray events;
|
||||
bool emitted = false;
|
||||
int packetsSent = 0, packetsReceived = 0;
|
||||
qint64 bytesWritten = 0;
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
auto record = [&](const QString &kind, int code, qint64 bytes) {
|
||||
if (events.size() == 24) events.removeFirst();
|
||||
events.append(QJsonObject{{"event", kind}, {"command", code},
|
||||
{"bytes", double(bytes)}, {"at_ms", elapsed.nsecsElapsed() / 1e6}});
|
||||
};
|
||||
QObject::connect(vesc.commands(), &Commands::dataToSend, &observer, [&](QByteArray &raw) {
|
||||
const int code = raw.isEmpty() ? -1 : quint8(raw[0]);
|
||||
if (code == command) emitted = true;
|
||||
record("command_emitted", code, raw.size());
|
||||
});
|
||||
QObject::connect(packet, &Packet::dataToSend, &observer, [&](QByteArray &raw) {
|
||||
++packetsSent; record("packet_sent", -1, raw.size());
|
||||
});
|
||||
if (serial) {
|
||||
QObject::connect(serial, &QSerialPort::bytesWritten, &observer, [&](qint64 bytes) {
|
||||
bytesWritten += bytes; record("serial_written", -1, bytes);
|
||||
});
|
||||
QObject::connect(serial, &QSerialPort::errorOccurred, &observer, [&](QSerialPort::SerialPortError error) {
|
||||
if (error != QSerialPort::NoError) record("serial_error", int(error), 0);
|
||||
});
|
||||
}
|
||||
QByteArray answer;
|
||||
QObject::connect(packet, &Packet::packetReceived, &observer, [&](QByteArray &raw) {
|
||||
++packetsReceived;
|
||||
record("packet_received", raw.isEmpty() ? -1 : quint8(raw[0]), raw.size());
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == command) { answer = raw; loop.quit(); }
|
||||
});
|
||||
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timeout.start(timeoutMs);
|
||||
send();
|
||||
if (answer.isEmpty()) loop.exec();
|
||||
if (answer.isEmpty() || !vesc.isPortConnected()) {
|
||||
throw ExchangeFailure({{"requested_command", command}, {"timeout_ms", timeoutMs},
|
||||
{"elapsed_ms", elapsed.nsecsElapsed() / 1e6}, {"request_emitted", emitted},
|
||||
{"packets_sent", packetsSent}, {"packets_received", packetsReceived},
|
||||
{"serial_bytes_written", double(bytesWritten)}, {"port_connected", vesc.isPortConnected()},
|
||||
{"serial_open", serial && serial->isOpen()}, {"serial_error", serial ? int(serial->error()) : -1},
|
||||
{"serial_bytes_pending", serial ? double(serial->bytesToWrite()) : -1}, {"events", events}});
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
QByteArray query(int code, int timeoutMs, bool internal = false) {
|
||||
auto cmd = vesc.commands();
|
||||
require(internal || !procedureRunning || code == COMM_GET_VALUES || code == COMM_GET_DECODED_PPM,
|
||||
"Configuration reads are unavailable during native measurement");
|
||||
std::function<void()> send;
|
||||
switch (code) {
|
||||
case COMM_FW_VERSION: send = [=] { cmd->getFwVersion(); }; break;
|
||||
case COMM_GET_VALUES: send = [=] { cmd->getValues(); }; break;
|
||||
case COMM_GET_MCCONF: send = [=] { cmd->getMcconf(); }; break;
|
||||
case COMM_GET_APPCONF: send = [=] { cmd->getAppConf(); }; break;
|
||||
case COMM_GET_DECODED_PPM: send = [=] { cmd->getDecodedPpm(); }; break;
|
||||
case COMM_PING_CAN: send = [=] { cmd->pingCan(); }; break;
|
||||
default: throw std::runtime_error("Native read command is not admitted");
|
||||
}
|
||||
auto raw = exchange(code, timeoutMs, send);
|
||||
if (code == COMM_GET_MCCONF || code == COMM_GET_APPCONF) {
|
||||
VByteArray serialized;
|
||||
auto config = code == COMM_GET_MCCONF ? vesc.mcConfig() : vesc.appConfig();
|
||||
config->serialize(serialized);
|
||||
require(serialized == raw.mid(1), "Native configuration decode is not byte-exact");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
QByteArray configurationPacket(ConfigParams *config, int code) {
|
||||
VByteArray raw; raw.vbAppendInt8(code); config->serialize(raw); return raw;
|
||||
}
|
||||
|
||||
void calibrate(double loss) {
|
||||
ConfigParams beforeMotor, beforeApp;
|
||||
beforeMotor = *vesc.mcConfig(); beforeApp = *vesc.appConfig();
|
||||
bool received = false, validated = false;
|
||||
int code = -1000;
|
||||
QString report, error;
|
||||
QJsonArray changed;
|
||||
auto connection = QObject::connect(vesc.commands(), &Commands::detectAllFocReceived,
|
||||
[&](int result) { received = true; code = result; });
|
||||
try {
|
||||
// The actual upstream wizard motor procedure, including its FW 5.02
|
||||
// power-loss correction. Do not infer a battery profile from Ah/voltage.
|
||||
report = Utility::detectAllFoc(&vesc, false, loss,
|
||||
beforeMotor.getParamDouble("l_in_current_min"), beforeMotor.getParamDouble("l_in_current_max"),
|
||||
beforeMotor.getParamDouble("foc_openloop_rpm"), beforeMotor.getParamDouble("foc_sl_erpm"));
|
||||
require(received, "Native calibration completion was not received");
|
||||
query(COMM_GET_MCCONF, 3000, true);
|
||||
query(COMM_GET_APPCONF, 3000, true);
|
||||
auto mc = vesc.mcConfig(); auto app = vesc.appConfig();
|
||||
const QStringList admitted = {"l_current_max", "l_current_min", "motor_type", "foc_motor_r",
|
||||
"foc_motor_l", "foc_motor_flux_linkage", "foc_current_kp", "foc_current_ki", "foc_observer_gain",
|
||||
"foc_sensor_mode", "m_sensor_port_mode", "foc_encoder_offset", "foc_encoder_ratio", "foc_encoder_inverted",
|
||||
"foc_hall_table__0", "foc_hall_table__1", "foc_hall_table__2", "foc_hall_table__3",
|
||||
"foc_hall_table__4", "foc_hall_table__5", "foc_hall_table__6", "foc_hall_table__7"};
|
||||
for (const auto &key : beforeMotor.checkDifference(mc)) {
|
||||
require(admitted.contains(key), "Calibration changed a protected motor parameter");
|
||||
changed.append(key);
|
||||
}
|
||||
for (const auto &key : beforeApp.checkDifference(app))
|
||||
require(key == "send_can_status", "Calibration changed a protected receiver parameter");
|
||||
// Native FW 5.02 enables CAN status as a side effect. Restore the
|
||||
// original application exactly; retain existing PPM and CAN identity.
|
||||
if (!beforeApp.checkDifference(app).isEmpty()) {
|
||||
*app = beforeApp;
|
||||
const auto expected = configurationPacket(app, COMM_GET_APPCONF);
|
||||
require(exchange(COMM_SET_APPCONF, 3000, [&] { vesc.commands()->setAppConf(); }).size() == 1,
|
||||
"Application write ACK invalid");
|
||||
require(query(COMM_GET_APPCONF, 3000, true) == expected, "Application restore not byte-exact");
|
||||
}
|
||||
if (code < 0) {
|
||||
// A completed failed detection can leave partial RAM changes.
|
||||
// Only the known calibration fields passed the guard above.
|
||||
*mc = beforeMotor;
|
||||
} else {
|
||||
for (const auto &key : {"foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||
require(std::isfinite(mc->getParamDouble(key)) && mc->getParamDouble(key) > 0,
|
||||
"Invalid detected motor parameter");
|
||||
require(mc->getParamDouble("l_current_max") > 0 && mc->getParamDouble("l_current_min") < 0,
|
||||
"Invalid detected current limits");
|
||||
// Calibration must not silently increase the owner's existing
|
||||
// current limits. The separate spin test applies its own 30 A cap.
|
||||
mc->updateParamDouble("l_current_max", qMin(mc->getParamDouble("l_current_max"), beforeMotor.getParamDouble("l_current_max")));
|
||||
mc->updateParamDouble("l_current_min", qMax(mc->getParamDouble("l_current_min"), beforeMotor.getParamDouble("l_current_min")));
|
||||
}
|
||||
const auto expected = configurationPacket(mc, COMM_GET_MCCONF);
|
||||
if (expected != lastMotor) {
|
||||
require(exchange(COMM_SET_MCCONF, 3000, [&] { vesc.commands()->setMcconf(false); }).size() == 1,
|
||||
"Motor write ACK invalid");
|
||||
require(query(COMM_GET_MCCONF, 3000, true) == expected, "Motor write not byte-exact");
|
||||
}
|
||||
validated = true;
|
||||
} catch (const std::exception &e) { error = e.what(); }
|
||||
QObject::disconnect(connection);
|
||||
QJsonObject parameters;
|
||||
for (const auto &key : {"l_current_max", "l_current_min", "foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||
parameters.insert(key, vesc.mcConfig()->getParamDouble(key));
|
||||
procedure = {{"kind", "foc"}, {"completed", received}, {"success", received && code >= 0 && validated},
|
||||
{"validated", validated}, {"code", code}, {"report", report}, {"error", error},
|
||||
{"sensor_mode", vesc.mcConfig()->getParamEnum("foc_sensor_mode")}, {"parameters", parameters},
|
||||
{"changed", changed}, {"upstream", "Utility::detectAllFoc"}};
|
||||
procedureUncertain = !validated;
|
||||
procedureRunning = false;
|
||||
}
|
||||
|
||||
double number(const QJsonObject &request, const QString &name, double min, double max) {
|
||||
auto v = request.value(name);
|
||||
require(v.isDouble() && std::isfinite(v.toDouble()) && v.toDouble() >= min && v.toDouble() <= max,
|
||||
"Numeric argument is outside operation bounds");
|
||||
return v.toDouble();
|
||||
}
|
||||
|
||||
void flush() {
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
require(serial && serial->isOpen(), "Serial transport closed");
|
||||
serial->flush();
|
||||
if (serial->bytesToWrite() > 0) require(serial->waitForBytesWritten(40), "Serial write not confirmed");
|
||||
require(serial->bytesToWrite() == 0, "Serial write is incomplete");
|
||||
}
|
||||
|
||||
QJsonObject dispatch(const QJsonObject &request) {
|
||||
const auto method = request.value("method").toString();
|
||||
if (method == "engine") return {{"version", "7.00"}, {"commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||
{"connected", vesc.isPortConnected()}, {"hardware_enabled", allowHardware},
|
||||
{"legacy_power_loss_correction", vesc.commands()->getMaxPowerLossBug()}};
|
||||
require(allowHardware && vesc.isPortConnected(), "Native device is not connected");
|
||||
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
||||
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
|
||||
if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}};
|
||||
if (method == "lease") { vesc.commands()->disableAppOutput(250, false); flush(); return {}; }
|
||||
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
|
||||
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
||||
if (method == "current") {
|
||||
auto current = number(request, "current_a", 0, 30);
|
||||
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
||||
vesc.commands()->setCurrent(current); flush(); return {};
|
||||
}
|
||||
if (method == "rpm") { vesc.commands()->setRpm(int(number(request, "erpm", 0, 3000))); flush(); return {}; }
|
||||
if (method == "limits") {
|
||||
auto p = request.value("parameters").toObject();
|
||||
MCCONF_TEMP conf;
|
||||
conf.current_min_scale = number(p, "l_current_min_scale", 0, 1);
|
||||
conf.current_max_scale = number(p, "l_current_max_scale", 0, 1);
|
||||
// Restore/application may only change current scales. All other
|
||||
// values must equal the last native read, including battery limits.
|
||||
auto mc = vesc.mcConfig();
|
||||
for (const auto &key : {"l_min_erpm", "l_max_erpm", "l_min_duty", "l_max_duty", "l_watt_min", "l_watt_max", "l_in_current_min", "l_in_current_max"})
|
||||
require(p.value(key).isDouble() && p.value(key).toDouble() == mc->getParamDouble(key), "Only volatile current scales may change");
|
||||
conf.erpm_or_speed_min = mc->getParamDouble("l_min_erpm");
|
||||
conf.erpm_or_speed_max = mc->getParamDouble("l_max_erpm");
|
||||
conf.duty_min = mc->getParamDouble("l_min_duty"); conf.duty_max = mc->getParamDouble("l_max_duty");
|
||||
conf.watt_min = mc->getParamDouble("l_watt_min"); conf.watt_max = mc->getParamDouble("l_watt_max");
|
||||
auto ack = exchange(COMM_SET_MCCONF_TEMP, 2000, [&] {
|
||||
vesc.commands()->setMcconfTemp(conf, false, false, false, false, true);
|
||||
});
|
||||
require(ack.size() == 1, "Invalid native limits ACK"); return {};
|
||||
}
|
||||
if (method == "configuration") {
|
||||
auto motor = query(COMM_GET_MCCONF, 2000);
|
||||
auto application = query(COMM_GET_APPCONF, 2000);
|
||||
return {{"motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration")},
|
||||
{"application", exportConfig(vesc.appConfig(), application, "APPConfiguration")}};
|
||||
}
|
||||
if (method == "foc_start") {
|
||||
const auto loss = number(request, "max_power_loss_w", 10, 150);
|
||||
require(!lastMotor.isEmpty() && !lastApplication.isEmpty(), "Read configurations before calibration");
|
||||
for (const auto &key : {"l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm"})
|
||||
require(std::isfinite(vesc.mcConfig()->getParamDouble(key)) && std::abs(vesc.mcConfig()->getParamDouble(key)) > 0.001,
|
||||
"Zero-valued detection inputs require an explicit equipment profile");
|
||||
procedureRunning = true; procedure = {{"kind", "foc"}};
|
||||
QTimer::singleShot(0, [this, loss] { calibrate(loss); });
|
||||
return {{"started", true}, {"interruptible", false}};
|
||||
}
|
||||
if (method == "hall_start") {
|
||||
require(request.value("current_a") == 5, "This Hall profile uses 5 A");
|
||||
procedureRunning = true; procedure = {}; lastHall.clear();
|
||||
QTimer::singleShot(0, [&] {
|
||||
auto measured = Utility::measureHallFocBlocking(&vesc, 5.0);
|
||||
QJsonArray table;
|
||||
for (int i = 1; i < measured.size(); ++i) table.append(measured[i]);
|
||||
const bool completed = measured.size() == 9 && measured.first() != -10;
|
||||
procedure = {{"kind", "hall"}, {"completed", completed},
|
||||
{"status", measured.isEmpty() ? -10 : measured.first()}, {"table", table},
|
||||
{"payload", QString::fromLatin1(lastHall.toBase64())},
|
||||
{"upstream", "Utility::measureHallFocBlocking"}};
|
||||
procedureUncertain = !completed;
|
||||
procedureRunning = false;
|
||||
});
|
||||
return {{"started", true}, {"interruptible", false}};
|
||||
}
|
||||
throw std::runtime_error("Native operation is not admitted");
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication application(argc, argv);
|
||||
QCoreApplication::setOrganizationName("MissionCore");
|
||||
QCoreApplication::setApplicationName("VescToolEngine");
|
||||
try {
|
||||
require(argc == 2, "One exact serial port or --offline argument is required");
|
||||
Engine engine;
|
||||
const QString port = QString::fromLocal8Bit(argv[1]);
|
||||
if (port != "--offline") engine.open(port);
|
||||
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||
auto write = [&](const QJsonObject &value) {
|
||||
output.write(QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'); output.flush();
|
||||
};
|
||||
write({{"ready", true}, {"engine", engine.dispatch({{"method", "engine"}})}});
|
||||
QByteArray buffer;
|
||||
fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
|
||||
QSocketNotifier input(STDIN_FILENO, QSocketNotifier::Read);
|
||||
QObject::connect(&input, &QSocketNotifier::activated, [&] {
|
||||
char chunk[4096]; const auto size = ::read(STDIN_FILENO, chunk, sizeof(chunk));
|
||||
if (size == 0) { application.quit(); return; }
|
||||
if (size < 0) return;
|
||||
buffer.append(chunk, int(size));
|
||||
if (buffer.size() > 65536) { application.exit(2); return; }
|
||||
int end;
|
||||
while ((end = buffer.indexOf('\n')) >= 0) {
|
||||
auto raw = buffer.left(end); buffer.remove(0, end + 1);
|
||||
QJsonParseError error;
|
||||
auto document = QJsonDocument::fromJson(raw, &error);
|
||||
auto request = document.object();
|
||||
QJsonObject response{{"id", request.value("id")}};
|
||||
try {
|
||||
require(error.error == QJsonParseError::NoError && document.isObject(), "Invalid JSON request");
|
||||
response.insert("result", engine.dispatch(request)); response.insert("ok", true);
|
||||
} catch (const ExchangeFailure &e) {
|
||||
response.insert("ok", false); response.insert("error", e.what());
|
||||
response.insert("diagnostics", e.diagnostics);
|
||||
} catch (const std::exception &e) { response.insert("ok", false); response.insert("error", e.what()); }
|
||||
write(response);
|
||||
}
|
||||
});
|
||||
return application.exec();
|
||||
} catch (const std::exception &error) {
|
||||
fprintf(stderr, "Native engine startup failed: %s\n", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Mission Core adapter to the unmodified VESC Tool engine. No hardware transport
|
||||
// is admitted by this executable. Input is one archived snapshot on stdin.
|
||||
#include "config_export.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// This application never calls connectSerial/connectTcp/connectBle or starts
|
||||
// a Qt event loop. Upstream timers therefore cannot reconnect or poll.
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication app(argc, argv);
|
||||
QCoreApplication::setOrganizationName("MissionCore");
|
||||
QCoreApplication::setApplicationName("VescToolOfflineAdapter");
|
||||
QJsonObject result{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||
{"upstream_commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||
{"upstream_version", QString::number(VT_VERSION, 'f', 2)},
|
||||
{"hardware_access", false}};
|
||||
try {
|
||||
require(argc == 1, "No command-line transport or operation arguments are accepted");
|
||||
QFile input; require(input.open(stdin, QIODevice::ReadOnly), "Cannot read input");
|
||||
const auto raw = input.read(1024 * 1024 + 1);
|
||||
require(!raw.isEmpty() && raw.size() <= 1024 * 1024, "Archive input is empty or too large");
|
||||
QJsonParseError parse;
|
||||
auto document = QJsonDocument::fromJson(raw, &parse);
|
||||
require(parse.error == QJsonParseError::NoError && document.isObject(), "Invalid archive JSON");
|
||||
const auto archive = document.object();
|
||||
const auto identity = archive.value("identity").toObject();
|
||||
FW_RX_PARAMS fw;
|
||||
fw.major = identity.value("major").toInt(-1);
|
||||
fw.minor = identity.value("minor").toInt(-1);
|
||||
fw.hw = identity.value("hardware").toString();
|
||||
fw.isTestFw = identity.value("test_firmware").toInt(-1);
|
||||
fw.customConfigNum = identity.value("custom_configs").toInt(-1);
|
||||
const auto uuid = identity.value("uuid").toString();
|
||||
require(QRegularExpression("^[0-9a-fA-F]{24}$").match(uuid).hasMatch(), "Invalid archived UUID");
|
||||
fw.uuid = QByteArray::fromHex(uuid.toLatin1());
|
||||
require(fw.major == 5 && fw.minor == 2 && fw.isTestFw == 0 && fw.customConfigNum == 0
|
||||
&& identity.value("hardware_type").toInt(-1) == 0,
|
||||
"Offline acceptance currently admits stable firmware 5.02 only");
|
||||
VescInterface vesc;
|
||||
require(Utility::configLoadLatest(&vesc), "Bundled upstream configuration resources missing");
|
||||
auto *commands = vesc.commands();
|
||||
// Replay the archived identity through the real firmware negotiation
|
||||
// signal. The real engine selects bundled schemas and compatibility.
|
||||
commands->fwVersionReceived(fw);
|
||||
require(!vesc.isPortConnected(), "Offline adapter unexpectedly connected");
|
||||
require(vesc.mcConfig()->getSerializeOrder().size() > 0, "Native schema not selected");
|
||||
bool failed = false;
|
||||
QObject::connect(commands, &Commands::deserializeConfigFailed,
|
||||
[&](bool, bool) { failed = true; });
|
||||
auto motor = archivePacket(archive, "motor", COMM_GET_MCCONF);
|
||||
auto application = archivePacket(archive, "application", COMM_GET_APPCONF);
|
||||
// FW 5.02 has fixed-size configurations. Obtain their size through the
|
||||
// public native serializer; do not reproduce the private schema walker.
|
||||
VByteArray motorShape, applicationShape;
|
||||
vesc.mcConfig()->serialize(motorShape);
|
||||
vesc.appConfig()->serialize(applicationShape);
|
||||
require(motor.size() == motorShape.size() + 1 && application.size() == applicationShape.size() + 1,
|
||||
"Archive length differs from the native firmware schema");
|
||||
commands->processPacket(motor);
|
||||
commands->processPacket(application);
|
||||
require(!failed, "Upstream rejected archived configuration");
|
||||
result.insert("motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration"));
|
||||
result.insert("application", exportConfig(vesc.appConfig(), application, "APPConfiguration"));
|
||||
// A disconnected serializer-only probe demonstrates that FW-specific
|
||||
// detect corrections execute upstream. This byte array goes nowhere.
|
||||
QByteArray encodedDetect;
|
||||
QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) {
|
||||
if (!data.isEmpty() && quint8(data.at(0)) == COMM_DETECT_APPLY_ALL_FOC) encodedDetect = data;
|
||||
});
|
||||
commands->detectAllFoc(false, 100.0, 0.0, 0.0, 0.0, 0.0);
|
||||
require(!encodedDetect.isEmpty() && !vesc.isPortConnected(), "Offline serialization failed");
|
||||
result.insert("compatibility", QJsonObject{
|
||||
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
|
||||
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
|
||||
{"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}});
|
||||
result.insert("archive_sha256", sha256(raw));
|
||||
result.insert("ok", true);
|
||||
} catch (const std::exception &error) {
|
||||
result = QJsonObject{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||
{"ok", false}, {"hardware_access", false}, {"error", error.what()}};
|
||||
}
|
||||
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||
output.write(QJsonDocument(result).toJson(QJsonDocument::Compact) + '\n');
|
||||
return result.value("ok").toBool() ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Exact observed candidate descriptor. Firmware identity is verified by the reader.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ATTRS{product}=="ChibiOS/RT Virtual COM Port", GROUP="mission-core-vesc", MODE="0660", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Create the deterministic, self-contained native-engine qualification job."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
|
||||
def build(output):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
files = {"__main__.py": (root / "packaging/native_probe.py").read_bytes(),
|
||||
"offline_main.cpp": (root / "native/offline_main.cpp").read_bytes(),
|
||||
"config_export.h": (root / "native/config_export.h").read_bytes(),
|
||||
"engine_main.cpp": (root / "native/engine_main.cpp").read_bytes(),
|
||||
"native_bundle.py": (root / "packaging/native_bundle.py").read_bytes()}
|
||||
identity = hashlib.sha256(b"".join(k.encode() + v for k, v in sorted(files.items()))).hexdigest()[:24]
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
path = output / ("mission-core-vesc-native-probe-" + identity + ".pyz")
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||
for name, data in files.items():
|
||||
entry = zipfile.ZipInfo(name, (2026, 9, 23, 0, 0, 0))
|
||||
entry.external_attr = 0o600 << 16
|
||||
bundle.writestr(entry, data)
|
||||
result = {"id": identity, "artifact": str(path.resolve()),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size}
|
||||
(output / "current-artifact.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
print(json.dumps(build(parser.parse_args().output)))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Discard only generated VESC bytecode before starting an upgraded profile.
|
||||
|
||||
Deterministic packages reuse file mtimes; a same-size Python source update
|
||||
can otherwise validate an old timestamp-based pyc, even with python -B.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def clear(root):
|
||||
for cache in root.rglob("__pycache__"):
|
||||
if cache.is_symlink():
|
||||
cache.unlink()
|
||||
elif cache.is_dir():
|
||||
shutil.rmtree(cache)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:]:
|
||||
raise ValueError("This installer step accepts no paths or arguments")
|
||||
clear(Path("/usr/lib/mission-core-vesc"))
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Prepare the versioned VESC read profile
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-vesc/prepare.py
|
||||
TimeoutStartSec=90
|
||||
UMask=0022
|
||||
@@ -0,0 +1,37 @@
|
||||
[Unit]
|
||||
Description=Mission Core VESC Tool service
|
||||
Wants=modprobe@cdc_acm.service
|
||||
After=systemd-udev-settle.service modprobe@cdc_acm.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-vesc
|
||||
Group=mission-core-node
|
||||
SupplementaryGroups=mission-core-vesc
|
||||
WorkingDirectory=/usr/lib/mission-core-vesc
|
||||
ExecStart=/usr/bin/python3 -B -m runtime.server
|
||||
RuntimeDirectory=mission-core-vesc
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=mission-core-vesc
|
||||
StateDirectoryMode=0700
|
||||
UMask=0007
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateNetwork=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=char-ttyACM rw
|
||||
MemoryMax=512M
|
||||
TasksMax=64
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,676 @@
|
||||
{
|
||||
"schema": "missioncore.vesc.native-runtime/v1",
|
||||
"upstream_version": "7.00",
|
||||
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
|
||||
"os": "ubuntu-24.04-amd64",
|
||||
"file": "mission-core-vesc-native-runtime.tar.gz",
|
||||
"bytes": 50352726,
|
||||
"sha256": "cc78c273e025f0738ed79f94d4bc6a70e504f2e7204b1ef694119e6a230b913a",
|
||||
"engine_sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
||||
"files": {
|
||||
"bin/mission-core-vesc-engine": {
|
||||
"sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
||||
"bytes": 24801544
|
||||
},
|
||||
"lib/libGL.so.1": {
|
||||
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
|
||||
"bytes": 547136
|
||||
},
|
||||
"lib/libGLX.so.0": {
|
||||
"sha256": "16fc8a37eea9210dc83c57eeff5aedc10ab4c6673f2f97e8bb6ee103df657b40",
|
||||
"bytes": 137792
|
||||
},
|
||||
"lib/libGLdispatch.so.0": {
|
||||
"sha256": "ca01a91104c8887b3d8e59499b58cbb8f604cc285666b50d9ec888eb0c915182",
|
||||
"bytes": 719304
|
||||
},
|
||||
"lib/libQt5Bluetooth.so.5": {
|
||||
"sha256": "7e409c8ba1153801671343a454a3aee4943504f2d7a9d1077c67ff46dc7a9831",
|
||||
"bytes": 915320
|
||||
},
|
||||
"lib/libQt5Core.so.5": {
|
||||
"sha256": "f3a46b3517fdcd82d70b9a2c01ba707499860e2eab328bb98445d8afce59ca37",
|
||||
"bytes": 5699976
|
||||
},
|
||||
"lib/libQt5DBus.so.5": {
|
||||
"sha256": "1122d2c92c03fca880e2e8e7de4eaf15fc577f75012362e8f3a76a38a3ce07ab",
|
||||
"bytes": 595528
|
||||
},
|
||||
"lib/libQt5Gamepad.so.5": {
|
||||
"sha256": "364e27bb1a7dd64ff974ab6442a844beb729006da53912581674c478ac5dc4d7",
|
||||
"bytes": 130144
|
||||
},
|
||||
"lib/libQt5Gui.so.5": {
|
||||
"sha256": "75b66a3edcbc7f3013b4180b76919b49c22ce652372eba45ead8aac9121625e0",
|
||||
"bytes": 7256408
|
||||
},
|
||||
"lib/libQt5Network.so.5": {
|
||||
"sha256": "8b3062321c31ddb2c0d96f2c7a7761e086fc4ef2d0d52dbf1119a03d0079ec64",
|
||||
"bytes": 1749216
|
||||
},
|
||||
"lib/libQt5Positioning.so.5": {
|
||||
"sha256": "267929ff49f2ecfbfed7951f3a2cd50985dc2d96f1d8f5ca689615bb6ee5f2b6",
|
||||
"bytes": 590448
|
||||
},
|
||||
"lib/libQt5PrintSupport.so.5": {
|
||||
"sha256": "818b07d6b46ede7e935f97c523231c97b79b44004723b8c1a17e40739337767d",
|
||||
"bytes": 486696
|
||||
},
|
||||
"lib/libQt5Qml.so.5": {
|
||||
"sha256": "e57364d9d0d366824064539a49f4cb3d8fdd284a15db9e3d164ca1ac9a1f64f3",
|
||||
"bytes": 4729856
|
||||
},
|
||||
"lib/libQt5QmlModels.so.5": {
|
||||
"sha256": "68bebbb6db95909aa2262aa2fa6cfd23547565faaa5bb72c49cfe027ac9caf0b",
|
||||
"bytes": 567600
|
||||
},
|
||||
"lib/libQt5Quick.so.5": {
|
||||
"sha256": "a2d2e45f415baa2c9a251844dd45f67d1d10cf6f15c6270dd49c0d348a431260",
|
||||
"bytes": 5737928
|
||||
},
|
||||
"lib/libQt5QuickWidgets.so.5": {
|
||||
"sha256": "8e3947729ac17448ed59a0a0a74960d9f09244a2d084c976b1c4af342f87d82e",
|
||||
"bytes": 92384
|
||||
},
|
||||
"lib/libQt5SerialPort.so.5": {
|
||||
"sha256": "67305251921c0a1d737b90d72b365cc937ca665c6ee218575e2555e85c63dcdb",
|
||||
"bytes": 97680
|
||||
},
|
||||
"lib/libQt5Widgets.so.5": {
|
||||
"sha256": "8e03815f581781fc8b0ef931335493f8f2fcb48ee855f0be2258bbfbd06e62ee",
|
||||
"bytes": 7057536
|
||||
},
|
||||
"lib/libX11.so.6": {
|
||||
"sha256": "c5b5d782bd9cab3420a62df88f5c991507edf3331a89f98464ddbc538c37b879",
|
||||
"bytes": 1298088
|
||||
},
|
||||
"lib/libXau.so.6": {
|
||||
"sha256": "8040da3f8516c1acfe39f04ff022480cdb273705ba5e7a4ca70bbdb1527cd67a",
|
||||
"bytes": 18696
|
||||
},
|
||||
"lib/libXdmcp.so.6": {
|
||||
"sha256": "667d97d6da16016400ab10de9f83ef4ab209ceb6adf68ff9650225ec13e723b0",
|
||||
"bytes": 26776
|
||||
},
|
||||
"lib/libbrotlicommon.so.1": {
|
||||
"sha256": "a91ead095d2c80520c55a89057bbe10b031a075340442e63f44b310f93883a1b",
|
||||
"bytes": 141640
|
||||
},
|
||||
"lib/libbrotlidec.so.1": {
|
||||
"sha256": "64d8a5019d4c294b89fde1193343ea324bbd8603652554e5545f0a01595fa2c5",
|
||||
"bytes": 51512
|
||||
},
|
||||
"lib/libbsd.so.0": {
|
||||
"sha256": "e86cd4f0019f42c2ba5e60602e0edc8694d2948377d98c3654d0f6bddb5254bb",
|
||||
"bytes": 80888
|
||||
},
|
||||
"lib/libbz2.so.1.0": {
|
||||
"sha256": "cc08c9f50a8009ffd6391e0116a100369b11ca9238fa52392c019e6645b122a1",
|
||||
"bytes": 78944
|
||||
},
|
||||
"lib/libcap.so.2": {
|
||||
"sha256": "6ac6abc86ac891c6e13486470e26f1d939f47fda9e6b5d5508a7f5ec881adc84",
|
||||
"bytes": 51536
|
||||
},
|
||||
"lib/libcom_err.so.2": {
|
||||
"sha256": "022943b3b11c860b049bce41342f1c2594941b7b401d95dfdf235521099fee08",
|
||||
"bytes": 18504
|
||||
},
|
||||
"lib/libdbus-1.so.3": {
|
||||
"sha256": "a6ae7b4ef48562b40d7b9ba8efd2e49f6528b7cc6364bea382dcee4b473b5413",
|
||||
"bytes": 317752
|
||||
},
|
||||
"lib/libdouble-conversion.so.3": {
|
||||
"sha256": "d1c9583dc7c1fce6f0a0701dd4356448425e45afe15c0946c23edeaf93d9397c",
|
||||
"bytes": 79952
|
||||
},
|
||||
"lib/libexpat.so.1": {
|
||||
"sha256": "ec6c12d33bb8f9d0e90804121adf19930f36b1b2a4aeb6e1a454b89c7a50c801",
|
||||
"bytes": 186624
|
||||
},
|
||||
"lib/libfontconfig.so.1": {
|
||||
"sha256": "a94b4059b27766f563894c8f7e61762b6f6b2e25c59ef36b730164d4b75c6c98",
|
||||
"bytes": 325712
|
||||
},
|
||||
"lib/libfreetype.so.6": {
|
||||
"sha256": "c14c53c5baff12afafb610c6312fb879e9bb77e80dd42e27504d52d6d8bcd059",
|
||||
"bytes": 833608
|
||||
},
|
||||
"lib/libgcc_s.so.1": {
|
||||
"sha256": "d93224d2b0dab4247598be683adca02f5cf00586f99c187579cd7e92058fb7cb",
|
||||
"bytes": 183024
|
||||
},
|
||||
"lib/libgcrypt.so.20": {
|
||||
"sha256": "6ad6d7007ee1ad8319eb18ba9a512cf09dda49396a303774c68b47477020bad4",
|
||||
"bytes": 1345072
|
||||
},
|
||||
"lib/libglib-2.0.so.0": {
|
||||
"sha256": "96ef9163aee942bdc09e6f4a1acd2fd6b178c03af824c569741440d63ac9f4f4",
|
||||
"bytes": 1343056
|
||||
},
|
||||
"lib/libgpg-error.so.0": {
|
||||
"sha256": "6cb18a007bfcb623029f4528a36b46578fea7dc34c2b505b8e6e0d99e6348cd1",
|
||||
"bytes": 149760
|
||||
},
|
||||
"lib/libgraphite2.so.3": {
|
||||
"sha256": "fcfaf843b25b58b88319ced52f826fc8213a661f3693c916f88641eddffe05b8",
|
||||
"bytes": 149776
|
||||
},
|
||||
"lib/libgssapi_krb5.so.2": {
|
||||
"sha256": "6c1b81696044d79a47d6f0f494ee60aaf641c525c46a11da0c6e4a041a782d3c",
|
||||
"bytes": 338696
|
||||
},
|
||||
"lib/libharfbuzz.so.0": {
|
||||
"sha256": "4562cfcfd18935324ba3ac74a944898b867ab87a56e925ab3e351ba773f1513e",
|
||||
"bytes": 1101752
|
||||
},
|
||||
"lib/libicudata.so.74": {
|
||||
"sha256": "ddbb3718b8bd9cbd780e5ab08b4503c30a6c4fa0706ebe5d074ed6b596c1714e",
|
||||
"bytes": 30795392
|
||||
},
|
||||
"lib/libicui18n.so.74": {
|
||||
"sha256": "3550b194eb2cf2e6f798f033eb9ca279d498c21296b4a18790ce158d2023e47b",
|
||||
"bytes": 3455304
|
||||
},
|
||||
"lib/libicuuc.so.74": {
|
||||
"sha256": "7560aadde38e5f4237a47a1ddd5891f9b36768a77a60faae30beee003ac01901",
|
||||
"bytes": 2140336
|
||||
},
|
||||
"lib/libk5crypto.so.3": {
|
||||
"sha256": "73bc9d72c0c684d6149a3c38f96aab891d178375415c05954fdda587685936f5",
|
||||
"bytes": 178648
|
||||
},
|
||||
"lib/libkeyutils.so.1": {
|
||||
"sha256": "f48214417757f18793ed6e180cc14ee1d6f04252a518fc7270e8ca1d0b4260fe",
|
||||
"bytes": 22600
|
||||
},
|
||||
"lib/libkrb5.so.3": {
|
||||
"sha256": "9615a2841f0783c410eec7fae005a951282551cfedd377737ad8431c8c8cde64",
|
||||
"bytes": 823488
|
||||
},
|
||||
"lib/libkrb5support.so.0": {
|
||||
"sha256": "0aa43578471faecbd642ed2ee6ab92b6682a2b676644193e956fc59d18ca24bb",
|
||||
"bytes": 47904
|
||||
},
|
||||
"lib/liblz4.so.1": {
|
||||
"sha256": "40bffd0a098387368b16b992abd5f7cf43c0fa2f05cabe5a6d483719554adfda",
|
||||
"bytes": 137440
|
||||
},
|
||||
"lib/liblzma.so.5": {
|
||||
"sha256": "696e868dd0700a19a6d65fc01608ec2d70d3cb91f65710e89180cd2e688f30cb",
|
||||
"bytes": 202904
|
||||
},
|
||||
"lib/libmd.so.0": {
|
||||
"sha256": "423e18586b6ea740f4465afd64f7a9a4cb7264ed979c8e7256f9085af5345fef",
|
||||
"bytes": 55536
|
||||
},
|
||||
"lib/libmd4c.so.0": {
|
||||
"sha256": "d5f418d0ea9aec6b41efbb924580a1fb61e9113bcae36d5ad3f322269822d1b3",
|
||||
"bytes": 67656
|
||||
},
|
||||
"lib/libpcre2-16.so.0": {
|
||||
"sha256": "4dfa8a4023270763b8ca1654dc4f59f835393b1ae78085a19369228f8109a671",
|
||||
"bytes": 572064
|
||||
},
|
||||
"lib/libpcre2-8.so.0": {
|
||||
"sha256": "e00576d71d81d3ba0cfa4903c835a44a8723aac96f72f79ff75200b4cff9071b",
|
||||
"bytes": 625344
|
||||
},
|
||||
"lib/libpng16.so.16": {
|
||||
"sha256": "eac265b3506df0d9110dd9143e1d0503e2daabafe1d98b23fac8ca17b71d1f6b",
|
||||
"bytes": 223304
|
||||
},
|
||||
"lib/libstdc++.so.6": {
|
||||
"sha256": "1fd75fe70354a416d75aef22bcae68c47bd25d20e2d0568c30b1a9838cf62f11",
|
||||
"bytes": 2592224
|
||||
},
|
||||
"lib/libsystemd.so.0": {
|
||||
"sha256": "bdf59c828b547bcdbe7b3576c0810d9d9d2e982d8b61b365812542da6a4c4a99",
|
||||
"bytes": 910592
|
||||
},
|
||||
"lib/libudev.so.1": {
|
||||
"sha256": "4298228175fa62a36af88b1afd4406cdd8b1bf166621f957d0dbbde45a698fe5",
|
||||
"bytes": 207288
|
||||
},
|
||||
"lib/libxcb.so.1": {
|
||||
"sha256": "7958a0136b121bdc4c708968569ad152a9ed208ab026e2537b1005dde64ca440",
|
||||
"bytes": 162392
|
||||
},
|
||||
"lib/libz.so.1": {
|
||||
"sha256": "86200da370f20476a2507e9097a789b5ef97269b4ca8d5e164ad82dab9d99892",
|
||||
"bytes": 113000
|
||||
},
|
||||
"lib/libzstd.so.1": {
|
||||
"sha256": "0a2128bc10841fb29e76d08d945864dfb0b6a66da5df6df5d8299197439e54bb",
|
||||
"bytes": 755864
|
||||
},
|
||||
"licenses/UPSTREAM-SOURCE.txt": {
|
||||
"sha256": "ff2fbc975e47bffd1e3c488e14f1fbe417c83db82fb5ab41e16a9c1da96edb2a",
|
||||
"bytes": 293
|
||||
},
|
||||
"licenses/VESC-Tool-LICENSE": {
|
||||
"sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986",
|
||||
"bytes": 35149
|
||||
},
|
||||
"licenses/config_export.h": {
|
||||
"sha256": "f0c483aa27b9910bab1bff4f7baf66fe4077461e65a1d3e11169c4a722212756",
|
||||
"bytes": 4724
|
||||
},
|
||||
"licenses/engine_main.cpp": {
|
||||
"sha256": "e789004d14185140bda21c10a9d7d8f0b48174a9b5053ec1d70755842b1888de",
|
||||
"bytes": 20913
|
||||
},
|
||||
"licenses/gir1.2-glib-2.0.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/gir1.2-gudev-1.0.copyright": {
|
||||
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||
"bytes": 1056
|
||||
},
|
||||
"licenses/libblkid-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/libbrotli-dev.copyright": {
|
||||
"sha256": "24a64e5bb83d0960d1835696a1e23c0896ad6055b0ca47c66ab0eb9a766324b1",
|
||||
"bytes": 1354
|
||||
},
|
||||
"licenses/libdouble-conversion3.copyright": {
|
||||
"sha256": "1cc0b36cdfe5a674e11cb9907a88291c7602d3805bddd334cc07d57231a0cd00",
|
||||
"bytes": 1999
|
||||
},
|
||||
"licenses/libegl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libevdev-dev.copyright": {
|
||||
"sha256": "01d0da4919f92dd06e7ec73fee6cf5dccac3c6e475917b9dc57cb22800b732de",
|
||||
"bytes": 5460
|
||||
},
|
||||
"licenses/libexpat1-dev.copyright": {
|
||||
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||
"bytes": 1756
|
||||
},
|
||||
"licenses/libexpat1.copyright": {
|
||||
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||
"bytes": 1756
|
||||
},
|
||||
"licenses/libfontconfig-dev.copyright": {
|
||||
"sha256": "b215a61cdd3e62b5b17cc28b1852c78acb3dd38be0fb30706f7efc050dba91db",
|
||||
"bytes": 1301
|
||||
},
|
||||
"licenses/libfreetype-dev.copyright": {
|
||||
"sha256": "ce6d766883ea111e7f47acc09e9d49be8827daa6a03f2c2707243a425d41f0e9",
|
||||
"bytes": 31209
|
||||
},
|
||||
"licenses/libgirepository-2.0-0.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libgl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libglib2.0-0t64.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-bin.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-data.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-dev-bin.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-dev.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglu1-mesa-dev.copyright": {
|
||||
"sha256": "7802232600641c113e2948fbc2feae6de45f26af59a43c28836e0ef846f8dd94",
|
||||
"bytes": 4055
|
||||
},
|
||||
"licenses/libglx-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libgudev-1.0-dev.copyright": {
|
||||
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||
"bytes": 1056
|
||||
},
|
||||
"licenses/libinput-bin.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libinput-dev.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libinput10.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libmd4c0.copyright": {
|
||||
"sha256": "68e5ce452a6fc2bee44279ca61a7064950d146481e325eeaece6c9bea095fd2f",
|
||||
"bytes": 23273
|
||||
},
|
||||
"licenses/libmount-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/libmtdev-dev.copyright": {
|
||||
"sha256": "7ca89f7e6e0ab15b9941aa80def7c50b22a7bd6356dd1eacf28ca4382f72a6de",
|
||||
"bytes": 1628
|
||||
},
|
||||
"licenses/libopengl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libpcre2-16-0.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpcre2-dev.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpcre2-posix3.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpkgconf3.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/libpng-dev.copyright": {
|
||||
"sha256": "4620d402b97601a910946acccbbe2e15bdffac11615bf9046520990d3b00f2b9",
|
||||
"bytes": 13051
|
||||
},
|
||||
"licenses/libpthread-stubs0-dev.copyright": {
|
||||
"sha256": "e45b85577d0f6883300ccfb004ab79e1a4f2cf3777b64eb989525115af400b5a",
|
||||
"bytes": 1849
|
||||
},
|
||||
"licenses/libqt5bluetooth5-bin.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5bluetooth5.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5concurrent5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5core5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5dbus5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5gamepad5-dev.copyright": {
|
||||
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||
"bytes": 3446
|
||||
},
|
||||
"licenses/libqt5gamepad5.copyright": {
|
||||
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||
"bytes": 3446
|
||||
},
|
||||
"licenses/libqt5gui5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5network5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5nfc5.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5positioning5-plugins.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5positioning5.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5positioningquick5.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5printsupport5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5qml5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5qmlmodels5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5qmlworkerscript5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quick5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickcontrols2-5.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/libqt5quickparticles5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickshapes5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quicktemplates2-5.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/libqt5quicktest5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickwidgets5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5serialport5-dev.copyright": {
|
||||
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||
"bytes": 6220
|
||||
},
|
||||
"licenses/libqt5serialport5.copyright": {
|
||||
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||
"bytes": 6220
|
||||
},
|
||||
"licenses/libqt5sql5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5svg5-dev.copyright": {
|
||||
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||
"bytes": 8476
|
||||
},
|
||||
"licenses/libqt5svg5.copyright": {
|
||||
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||
"bytes": 8476
|
||||
},
|
||||
"licenses/libqt5test5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5widgets5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5xml5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libselinux1-dev.copyright": {
|
||||
"sha256": "864f1bb189f609075d580b8c4aada16d85a44546884f1687989e1cacc8c56751",
|
||||
"bytes": 1957
|
||||
},
|
||||
"licenses/libsepol-dev.copyright": {
|
||||
"sha256": "78d2a34606a0302057ec499a3b3c07bcbc43ca334064ce4c80891a0f69c8f6c6",
|
||||
"bytes": 3750
|
||||
},
|
||||
"licenses/libudev-dev.copyright": {
|
||||
"sha256": "a7d06854714a1ca99f6dbd1a1641dde5bcf28635be149f6554449618f8f427f3",
|
||||
"bytes": 12776
|
||||
},
|
||||
"licenses/libvulkan-dev.copyright": {
|
||||
"sha256": "c579213e28f67944a7e407816b8a8e1d2d2406b3d820e420aa923807c450dc07",
|
||||
"bytes": 1964
|
||||
},
|
||||
"licenses/libwacom-dev.copyright": {
|
||||
"sha256": "5026eb61394922e821cfea069fe9740141b1d6abd3ea900e79655ad45ea1cb8a",
|
||||
"bytes": 1624
|
||||
},
|
||||
"licenses/libx11-dev.copyright": {
|
||||
"sha256": "0b380a7fd5b2228f26e9585e56f14812efd3350f3df307507d2bc055dfd8de3e",
|
||||
"bytes": 47102
|
||||
},
|
||||
"licenses/libxau-dev.copyright": {
|
||||
"sha256": "118dd263a7b91c8f21c489f949bf13281dff9e766deea92b829dac4dce66601a",
|
||||
"bytes": 1224
|
||||
},
|
||||
"licenses/libxcb-xinerama0.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxcb-xinput0.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxcb1-dev.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxdmcp-dev.copyright": {
|
||||
"sha256": "1bcbb50f8603fe8b86d330bfa460b772ea04fbd3c70e4499f0768b5340b9fd6e",
|
||||
"bytes": 1265
|
||||
},
|
||||
"licenses/libxext-dev.copyright": {
|
||||
"sha256": "bc57e445ca1d9fe082c8d54189dd411ff26caa8552c9c63d44ea06a982f32124",
|
||||
"bytes": 10421
|
||||
},
|
||||
"licenses/libxkbcommon-dev.copyright": {
|
||||
"sha256": "5eeaeb1b6e029a0274e1573765bb0bae2926ef96a3679203faa4fd00fdaeaa88",
|
||||
"bytes": 3566
|
||||
},
|
||||
"licenses/pkgconf-bin.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/pkgconf.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/python3-packaging.copyright": {
|
||||
"sha256": "51fe4bbadf841c4e4d02ad97ba375bcde0ae11a51da62ae16fdcbe723d3cdad2",
|
||||
"bytes": 2444
|
||||
},
|
||||
"licenses/qt5-qmake-bin.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qt5-qmake.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qt5-qmltooling-plugins.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtbase5-dev-tools.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtbase5-dev.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtbase5-private-dev.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtchooser.copyright": {
|
||||
"sha256": "0b3fa692b33acfbb5b9539335c66938c957350b2a24d2fec35de89e09198738e",
|
||||
"bytes": 5193
|
||||
},
|
||||
"licenses/qtconnectivity5-dev.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/qtdeclarative5-dev-tools.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtdeclarative5-dev.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtpositioning5-dev.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/qtquickcontrols2-5-dev.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/uuid-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/x11proto-dev.copyright": {
|
||||
"sha256": "7b40446cf2035abc6836c7a7f411ec79153dbf4530bce209e26aa2fd7c4dd55a",
|
||||
"bytes": 3963
|
||||
},
|
||||
"licenses/xorg-sgml-doctools.copyright": {
|
||||
"sha256": "f8f02d5cfd7d4ed0eb6c46deacb3a64c1fa5bc60e06db10ecbf202e6fe1d5a89",
|
||||
"bytes": 2271
|
||||
},
|
||||
"licenses/xtrans-dev.copyright": {
|
||||
"sha256": "29e6f06b1dcd85f1bc4b3e9374b92967cb2a274abde00169784f1dd1c7c95431",
|
||||
"bytes": 6364
|
||||
},
|
||||
"licenses/zlib1g-dev.copyright": {
|
||||
"sha256": "9e5b96d63773a5d177ba264254390f792be07e41748ebd94730981c6cac31cc6",
|
||||
"bytes": 2927
|
||||
},
|
||||
"plugins/platforms/libqoffscreen.so": {
|
||||
"sha256": "f2f19a29e816c7e5c60fd52b9d3c1a214634c38e06cd2ffdf9fc88e9f0c49ffd",
|
||||
"bytes": 193880
|
||||
}
|
||||
},
|
||||
"host_libraries": [
|
||||
"ld-linux-x86-64.so.2",
|
||||
"libc.so.6",
|
||||
"libdl.so.2",
|
||||
"libm.so.6",
|
||||
"libpthread.so.0",
|
||||
"libresolv.so.2",
|
||||
"librt.so.1"
|
||||
],
|
||||
"offline_verified": true,
|
||||
"hardware_qualified": false,
|
||||
"clean_os_qualified": false
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Bundle an admitted native engine and the actual ELF dependency closure.
|
||||
|
||||
Private, installer-owned Qt runtime; never installs packages on the build host.
|
||||
The target is Ubuntu 24.04 amd64. Only its glibc family stays a host prerequisite.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
SYSTEM = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1", "libresolv.so.2", "ld-linux-x86-64.so.2"}
|
||||
|
||||
|
||||
def build(engine, sysroot, source, output):
|
||||
output.mkdir(mode=0o700, exist_ok=False)
|
||||
staging = output / "payload"
|
||||
(staging / "bin").mkdir(parents=True)
|
||||
(staging / "lib").mkdir()
|
||||
(staging / "plugins/platforms").mkdir(parents=True)
|
||||
binary = staging / "bin/mission-core-vesc-engine"
|
||||
shutil.copyfile(engine, binary); binary.chmod(0o755)
|
||||
qtlib = sysroot / "usr/lib/x86_64-linux-gnu"
|
||||
plugin = qtlib / "qt5/plugins/platforms/libqoffscreen.so"
|
||||
shutil.copyfile(plugin, staging / "plugins/platforms/libqoffscreen.so")
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=str(qtlib), LC_ALL="C")
|
||||
sources = {}
|
||||
for executable in (engine, plugin):
|
||||
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||
if "not found" in result: raise RuntimeError("Native runtime dependency missing")
|
||||
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||
if name in SYSTEM: continue
|
||||
library = Path(path)
|
||||
if name in sources and sources[name] != library: raise RuntimeError("Conflicting dependency")
|
||||
sources[name] = library
|
||||
for name, library in sources.items(): shutil.copyfile(library, staging / "lib" / name)
|
||||
env.update(LD_LIBRARY_PATH=str(staging / "lib"), QT_PLUGIN_PATH=str(staging / "plugins"),
|
||||
QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(output / "config"), XDG_CACHE_HOME=str(output / "cache"))
|
||||
# All non-glibc ELF dependencies must now resolve inside the shipped payload.
|
||||
for executable in (binary, staging / "plugins/platforms/libqoffscreen.so"):
|
||||
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||
if "not found" in result: raise RuntimeError("Bundled native closure incomplete")
|
||||
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||
if name not in SYSTEM and not Path(path).resolve().is_relative_to(staging):
|
||||
raise RuntimeError("Undeclared host dependency: " + name)
|
||||
proc = subprocess.run([str(binary), "--offline"], env=env, input=b'{"id":1,"method":"engine"}\n',
|
||||
capture_output=True, timeout=15, check=True)
|
||||
responses = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
if len(responses) != 2 or not responses[0]["ready"] or responses[1]["result"]["connected"]:
|
||||
raise RuntimeError("Bundled engine acceptance failed")
|
||||
(output / "offline.stdout").write_bytes(proc.stdout)
|
||||
(output / "offline.stderr").write_bytes(proc.stderr)
|
||||
(staging / "licenses").mkdir()
|
||||
shutil.copyfile(source / "LICENSE", staging / "licenses/VESC-Tool-LICENSE")
|
||||
(staging / "licenses/UPSTREAM-SOURCE.txt").write_text(
|
||||
"VESC Tool 7.00, unmodified upstream sources and resources:\n"
|
||||
"https://github.com/vedderb/vesc_tool/tree/01d5f10901116c311e3fb84d5a1541f663d3ce20\n"
|
||||
"Source archive SHA-256: 4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189\n"
|
||||
"The process adapter source is included alongside this notice.\n")
|
||||
for name in ("engine_main.cpp", "config_export.h"):
|
||||
shutil.copyfile(engine.parent / name, staging / "licenses" / name)
|
||||
# Preserve dependency notices available in the private signed-package sysroot.
|
||||
for index, path in enumerate(sorted((sysroot / "usr/share/doc").glob("*/copyright"))):
|
||||
shutil.copyfile(path, staging / "licenses" / (path.parent.name + ".copyright"))
|
||||
# Host libraries copied into the closure retain their distribution notices.
|
||||
for library in sources.values():
|
||||
if library.resolve().is_relative_to(sysroot): continue
|
||||
owner = subprocess.run(["dpkg-query", "-S", str(library)], capture_output=True, text=True)
|
||||
if owner.returncode: continue
|
||||
package = owner.stdout.split(": ", 1)[0].split(":", 1)[0]
|
||||
notice = Path("/usr/share/doc") / package / "copyright"
|
||||
if notice.is_file(): shutil.copyfile(notice, staging / "licenses" / (package + ".copyright"))
|
||||
metadata = {str(p.relative_to(staging)): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "bytes":p.stat().st_size}
|
||||
for p in sorted(staging.rglob("*")) if p.is_file()}
|
||||
archive = output / "mission-core-vesc-native-runtime.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as stream:
|
||||
for path in sorted(staging.rglob("*")):
|
||||
if path.is_file(): stream.add(path, arcname=str(path.relative_to(staging)))
|
||||
report = {"schema":"missioncore.vesc.native-runtime/v1", "upstream_version":"7.00",
|
||||
"upstream_commit":"01d5f10901116c311e3fb84d5a1541f663d3ce20", "os":"ubuntu-24.04-amd64",
|
||||
"file":archive.name, "bytes":archive.stat().st_size, "sha256":hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||
"engine_sha256":hashlib.sha256(binary.read_bytes()).hexdigest(), "files":metadata,
|
||||
"host_libraries":sorted(SYSTEM), "offline_verified":True, "hardware_qualified":False,
|
||||
"clean_os_qualified":False}
|
||||
(output / "bundle.json").write_text(json.dumps(report,indent=2)+"\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser=argparse.ArgumentParser()
|
||||
for name in ("engine","sysroot","source","output"):parser.add_argument("--"+name,type=Path,required=True)
|
||||
args=parser.parse_args();build(args.engine,args.sysroot,args.source,args.output)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Installer acceptance under the service account; never opens a USB device."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def check(root):
|
||||
manifest = json.loads((root / "manifest.json").read_text())
|
||||
for name, expected in manifest["files"].items():
|
||||
path = root / name
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise RuntimeError("Untrusted native runtime path")
|
||||
if path.stat().st_size != expected["bytes"] or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
|
||||
raise RuntimeError("Native runtime integrity check failed")
|
||||
with tempfile.TemporaryDirectory(prefix="mission-core-vesc-check-") as temporary:
|
||||
env = {"PATH":"/usr/bin:/bin", "LANG":"C.UTF-8", "QT_QPA_PLATFORM":"offscreen",
|
||||
"LD_LIBRARY_PATH":str(root / "lib"), "QT_PLUGIN_PATH":str(root / "plugins"),
|
||||
"XDG_CONFIG_HOME":temporary, "XDG_CACHE_HOME":temporary}
|
||||
result = subprocess.run([str(root / "bin/mission-core-vesc-engine"), "--offline"],
|
||||
env=env, input=b'{"id":1,"method":"engine"}\n', capture_output=True, timeout=15, check=True)
|
||||
replies = [json.loads(line) for line in result.stdout.splitlines()]
|
||||
if (len(replies) != 2 or not replies[0]["ready"] or not replies[1]["ok"]
|
||||
or replies[1]["result"]["hardware_enabled"] or replies[1]["result"]["connected"]
|
||||
or replies[1]["result"]["commit"] != manifest["upstream_commit"]):
|
||||
raise RuntimeError("Native engine offline check failed")
|
||||
print(json.dumps({"ok":True, "upstream_commit":manifest["upstream_commit"], "hardware_access":False}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() == 0: raise RuntimeError("Run as the VESC service account")
|
||||
check(Path(__file__).resolve().parent / "native")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Versioned offline qualification artifact, not a runtime installer.
|
||||
|
||||
Reuse an attested upstream Tool build's object files unchanged. Only the adapter
|
||||
entry point is compiled. The previous staging, packages and services are untouched.
|
||||
All inputs, link objects, outputs and checks are hashed in the private report.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||
UPSTREAM_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run(args):
|
||||
release = platform.freedesktop_os_release()
|
||||
if os.geteuid() == 0 or (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Unprivileged Ubuntu 24.04 amd64 required")
|
||||
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||
limit = (group / "memory.max").read_text().strip()
|
||||
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||
raise RuntimeError("A bounded user scope with MemoryMax <= 3G is required")
|
||||
previous = json.loads(args.upstream_report.read_text())
|
||||
if previous.get("state") != "complete" or previous.get("source_sha256") != UPSTREAM_SHA256:
|
||||
raise RuntimeError("Unqualified upstream build")
|
||||
upstream = Path(previous["binary"])
|
||||
if digest(upstream) != previous["binary_sha256"]:
|
||||
raise RuntimeError("Upstream binary changed")
|
||||
source = upstream.parents[2]
|
||||
if source.name != "vesc_tool-" + COMMIT:
|
||||
raise RuntimeError("Upstream source path mismatch")
|
||||
os.umask(0o077)
|
||||
root = args.output.resolve()
|
||||
root.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||
# Paths enter a generated makefile, never a shell command assembled from JSON.
|
||||
if any(not re.fullmatch(r"[A-Za-z0-9_./-]+", str(p)) for p in (root, source)):
|
||||
raise RuntimeError("Build paths must be make-safe")
|
||||
with zipfile.ZipFile(args.artifact) as bundle:
|
||||
for name in ("offline_main.cpp", "config_export.h", "engine_main.cpp", "native_bundle.py"):
|
||||
(root / name).write_bytes(bundle.read(name))
|
||||
report = {"schema": "missioncore.vesc.native-probe/v1", "state": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||
"source_commit": COMMIT, "artifact_sha256": digest(args.artifact),
|
||||
"upstream_report_sha256": digest(args.upstream_report),
|
||||
"adapter_sha256": digest(root / "offline_main.cpp"),
|
||||
"hardware_access": False, "runtime_installed": False, "system_packages_installed": False,
|
||||
"jobs": [], "checks": []}
|
||||
|
||||
def publish():
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
staging = source.parents[1]
|
||||
qtbase = staging / "sysroot/usr"
|
||||
env = dict(os.environ, LC_ALL="C", QT_QPA_PLATFORM="offscreen",
|
||||
LD_LIBRARY_PATH=str(qtbase / "lib/x86_64-linux-gnu"),
|
||||
QT_PLUGIN_PATH=str(qtbase / "lib/x86_64-linux-gnu/qt5/plugins"),
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"))
|
||||
|
||||
def execute(name, command, data=None, expected=0, timeout=60):
|
||||
start = time.monotonic()
|
||||
proc = subprocess.run(command, cwd=source, env=env, input=data,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
(root / (name + ".stdout")).write_bytes(proc.stdout)
|
||||
(root / (name + ".stderr")).write_bytes(proc.stderr)
|
||||
report["jobs"].append({"id": name, "exit_code": proc.returncode,
|
||||
"duration_seconds": time.monotonic() - start,
|
||||
"stdout_sha256": hashlib.sha256(proc.stdout).hexdigest(),
|
||||
"stderr_sha256": hashlib.sha256(proc.stderr).hexdigest()})
|
||||
publish()
|
||||
if proc.returncode != expected:
|
||||
raise RuntimeError("Native probe step failed: " + name)
|
||||
return proc.stdout
|
||||
|
||||
try:
|
||||
makefile = (source / "Makefile").read_text().replace("\\\n", " ")
|
||||
match = re.search(r"^OBJECTS\s*=\s*(.+)$", makefile, re.MULTILINE)
|
||||
if not match:
|
||||
raise RuntimeError("Upstream link objects missing")
|
||||
objects = [source / name for name in match.group(1).split() if name != "build/lin/obj/main.o"]
|
||||
if not 100 < len(objects) < 1000 or any(not p.is_file() for p in objects):
|
||||
raise RuntimeError("Upstream object inventory incomplete")
|
||||
report["link_objects"] = [{"file": str(p.relative_to(source)), "sha256": digest(p)} for p in objects]
|
||||
report["upstream_makefile_sha256"] = digest(source / "Makefile")
|
||||
target = root / "mission-core-vesc-offline"
|
||||
wrapper = root / "Makefile.native"
|
||||
wrapper.write_text(
|
||||
"include " + str(source / "Makefile") + "\n"
|
||||
".PHONY: mission-core-native-probe\n"
|
||||
"mission-core-native-probe:\n"
|
||||
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "offline_main.o") + " " + str(root / "offline_main.cpp") + "\n"
|
||||
"\t$(LINK) $(LFLAGS) -o " + str(target) + " " + str(root / "offline_main.o") +
|
||||
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||
execute("compile-link", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-probe"], timeout=180)
|
||||
report["binary_sha256"] = digest(target)
|
||||
for index, archive in enumerate(args.archives):
|
||||
raw = archive.read_bytes()
|
||||
native = json.loads(execute("archive-%d" % index, [str(target)], raw))
|
||||
if not native["ok"] or not native["compatibility"]["legacy_power_loss_correction"]:
|
||||
raise RuntimeError("Upstream compatibility check failed")
|
||||
import base64
|
||||
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
||||
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
|
||||
raise RuntimeError("Expected native 5.02 detect correction was not applied")
|
||||
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
|
||||
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"motor_parameter_count": len(native["motor"]["parameters"]),
|
||||
"application_parameter_count": len(native["application"]["parameters"]),
|
||||
"binary_round_trip_exact": True,
|
||||
"xml_equivalent_by_upstream_comparison": True,
|
||||
"xml_binary_exact": all(native[k]["xml_round_trip_exact"] for k in ("motor", "application")),
|
||||
"legacy_power_loss_correction": True})
|
||||
# Fail closed on archive corruption and unsupported firmware instead
|
||||
# of silently presenting the bundled defaults as actual settings.
|
||||
original = json.loads(raw)
|
||||
damaged = json.loads(raw); damaged["configs"]["motor"]["sha256"] = "0" * 64
|
||||
unknown = json.loads(raw); unknown["identity"]["major"] = 99
|
||||
wrong_signature = json.loads(raw)
|
||||
payload = bytearray(base64.b64decode(original["configs"]["motor"]["payload"])); payload[1] ^= 1
|
||||
wrong_signature["configs"]["motor"]["payload"] = base64.b64encode(payload).decode()
|
||||
wrong_signature["configs"]["motor"]["sha256"] = hashlib.sha256(payload).hexdigest()
|
||||
truncated = json.loads(raw)
|
||||
payload = base64.b64decode(original["configs"]["motor"]["payload"])[:-1]
|
||||
truncated["configs"]["motor"].update(payload=base64.b64encode(payload).decode(),
|
||||
sha256=hashlib.sha256(payload).hexdigest(), bytes=len(payload))
|
||||
for name, value in (("corrupt", damaged), ("unsupported", unknown), ("signature", wrong_signature), ("truncated", truncated)):
|
||||
rejected = json.loads(execute("%s-%d" % (name, index), [str(target)], json.dumps(value).encode(), expected=1))
|
||||
if rejected["ok"] or rejected["hardware_access"]:
|
||||
raise RuntimeError("Invalid archive was not rejected")
|
||||
report["checks"].append({"id": "%s-%d" % (name, index), "ok": True})
|
||||
for item in report["link_objects"]:
|
||||
if digest(source / item["file"]) != item["sha256"]:
|
||||
raise RuntimeError("Upstream objects were modified")
|
||||
engine = root / "mission-core-vesc-engine"
|
||||
wrapper.write_text(wrapper.read_text() +
|
||||
"\n.PHONY: mission-core-native-engine\nmission-core-native-engine:\n"
|
||||
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "engine_main.o") + " " + str(root / "engine_main.cpp") + "\n"
|
||||
"\t$(LINK) $(LFLAGS) -o " + str(engine) + " " + str(root / "engine_main.o") +
|
||||
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||
execute("engine-compile", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-engine"], timeout=180)
|
||||
requests = [{"id": 1, "method": "engine"}, {"id": 2, "method": "current", "current_a": 30},
|
||||
{"id": 3, "method": "hall_start", "current_a": 5}, {"id": 4, "method": "arbitrary_packet"}]
|
||||
responses = [json.loads(line) for line in execute("engine-offline", [str(engine), "--offline"],
|
||||
b"".join(json.dumps(r).encode()+b"\n" for r in requests)).splitlines()]
|
||||
if (len(responses) != 5 or not responses[0]["ready"] or not responses[1]["ok"]
|
||||
or any(r["ok"] for r in responses[2:]) or responses[1]["result"]["hardware_enabled"]):
|
||||
raise RuntimeError("Native engine offline boundary failed")
|
||||
report["checks"].append({"id": "engine-offline-denies-hardware", "ok": True})
|
||||
execute("runtime-bundle", ["/usr/bin/python3", str(root / "native_bundle.py"),
|
||||
"--engine", str(engine), "--sysroot", str(staging / "sysroot"), "--source", str(source), "--output", str(root / "runtime")], timeout=180)
|
||||
report["native_runtime"] = json.loads((root / "runtime/bundle.json").read_text())
|
||||
report.update(state="complete", binary=str(target), upstream_objects_unchanged=True)
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
parser.add_argument("--upstream-report", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--archives", type=Path, nargs="+", required=True)
|
||||
run(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The Node release carries this model profile from the first hardware use."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import hashlib
|
||||
import json
|
||||
import tarfile
|
||||
|
||||
|
||||
def payload():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
files = [("usr/lib/mission-core-vesc/runtime/" + p.name, p.read_bytes(), 0o644)
|
||||
for p in sorted((root / "runtime").glob("*.py"))]
|
||||
files.extend(("usr/lib/mission-core-vesc/runtime/" + str(p.relative_to(root / "runtime")), p.read_bytes(), 0o644)
|
||||
for p in sorted((root / "runtime/schemas").rglob("*")) if p.is_file())
|
||||
files.append(("usr/lib/mission-core-vesc/runtime/archive.py",
|
||||
(root.parents[1] / "src/k1link/device_plugins/vesc/archive.py").read_bytes(), 0o644))
|
||||
for name in ("mission-core-vesc.service", "mission-core-node-vesc-prepare.service"):
|
||||
files.append(("usr/lib/systemd/system/" + name, (root / "packaging" / name).read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/prepare.py", (root / "packaging/prepare.py").read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/clear_runtime_cache.py", (root / "packaging/clear_runtime_cache.py").read_bytes(), 0o644))
|
||||
files.append(("usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules",
|
||||
(root / "packaging/70-mission-core-vesc.rules").read_bytes(), 0o644))
|
||||
manifest = json.loads((root / "packaging/native-runtime.json").read_text())
|
||||
bundle = root / "build/native-runtime" / manifest["file"]
|
||||
data = bundle.read_bytes()
|
||||
if len(data) != manifest["bytes"] or hashlib.sha256(data).hexdigest() != manifest["sha256"]:
|
||||
raise ValueError("Native VESC Tool bundle changed")
|
||||
seen = set()
|
||||
with tarfile.open(bundle, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
path = PurePosixPath(member.name)
|
||||
if (not member.isfile() or path.is_absolute() or ".." in path.parts
|
||||
or path.as_posix() != member.name or member.name in seen
|
||||
or member.name not in manifest["files"]):
|
||||
raise ValueError("Unexpected native payload member")
|
||||
seen.add(member.name)
|
||||
expected = manifest["files"][member.name]
|
||||
if member.size != expected["bytes"] or member.size > 128 * 1024**2:
|
||||
raise ValueError("Native member size mismatch")
|
||||
content = archive.extractfile(member).read()
|
||||
if hashlib.sha256(content).hexdigest() != expected["sha256"]:
|
||||
raise ValueError("Native member hash mismatch")
|
||||
mode = 0o755 if member.name.startswith("bin/") else 0o644
|
||||
files.append(("usr/lib/mission-core-vesc/native/" + member.name, content, mode))
|
||||
if seen != set(manifest["files"]): raise ValueError("Incomplete native payload")
|
||||
files.append(("usr/lib/mission-core-vesc/native/manifest.json",
|
||||
(root / "packaging/native-runtime.json").read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/native_check.py",
|
||||
(root / "packaging/native_check.py").read_bytes(), 0o644))
|
||||
return files
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Versioned, idempotent VESC profile; no packages downloaded and no motor I/O."""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from runtime.serial import discover
|
||||
from runtime.service import atomic
|
||||
|
||||
STATE = Path("/var/lib/mission-core-node-profiles/vesc")
|
||||
|
||||
|
||||
def prepare():
|
||||
if os.geteuid() != 0 or sys.argv[1:]:
|
||||
raise RuntimeError("Fixed system profile only")
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||
STATE.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
for directory in (STATE.parent, STATE):
|
||||
info = directory.lstat()
|
||||
if directory.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Untrusted profile state")
|
||||
lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller",
|
||||
"version": "0.6.3", "run_id": uuid.uuid4().hex, "started_at": time.time(),
|
||||
"monotonic_started": time.monotonic(), "state": "running", "steps": []}
|
||||
|
||||
def publish():
|
||||
atomic(STATE / "preparation.json", report)
|
||||
os.chmod(STATE / "preparation.json", 0o644)
|
||||
|
||||
def run(name, label, args):
|
||||
step = {"id": name, "label": label, "state": "running"}
|
||||
report["steps"].append(step)
|
||||
publish()
|
||||
result = subprocess.run(args, capture_output=True, timeout=45,
|
||||
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"})
|
||||
step["state"] = "complete" if result.returncode == 0 else "error"
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError("Не завершён этап: " + label)
|
||||
|
||||
try:
|
||||
import pwd
|
||||
try:
|
||||
pwd.getpwnam("mission-core-vesc")
|
||||
except KeyError:
|
||||
run("account", "Подготовка доступа", ["/usr/sbin/adduser", "--system", "--group", "--home",
|
||||
"/var/lib/mission-core-vesc", "--no-create-home", "--disabled-login", "mission-core-vesc"])
|
||||
run("native", "Проверка VESC Tool", ["/usr/sbin/runuser", "-u", "mission-core-vesc", "--",
|
||||
"/usr/bin/python3", "-I", "/usr/lib/mission-core-vesc/native_check.py"])
|
||||
source = Path("/usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules")
|
||||
rules = source.read_bytes()
|
||||
report["udev_sha256"] = hashlib.sha256(rules).hexdigest()
|
||||
target = Path("/etc/udev/rules.d/70-mission-core-vesc.rules")
|
||||
if target.is_symlink():
|
||||
raise RuntimeError("Untrusted udev destination")
|
||||
target.write_bytes(rules)
|
||||
target.chmod(0o644)
|
||||
run("rules", "Настройка USB-доступа", ["/usr/bin/udevadm", "control", "--reload-rules"])
|
||||
for i, device in enumerate(discover()):
|
||||
run("usb" + str(i), "Применение USB-доступа", ["/usr/bin/udevadm", "trigger", "--action=change",
|
||||
"/sys/class/tty/" + device.tty])
|
||||
run("settle", "Проверка USB-доступа", ["/usr/bin/udevadm", "settle", "--timeout=10"])
|
||||
run("enable", "Подготовка службы", ["/usr/bin/systemctl", "enable", "mission-core-vesc.service"])
|
||||
run("runtime", "Запуск чтения контроллеров", ["/usr/bin/systemctl", "restart", "mission-core-vesc.service"])
|
||||
report["state"] = "complete"
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
report.update(state="error", message=str(error)[:300])
|
||||
finally:
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish()
|
||||
os.close(lock)
|
||||
return report["state"] == "complete"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if prepare() else 1)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Unprivileged VESC Tool build spike; never installs packages or opens a device.
|
||||
|
||||
Run in a bounded user systemd scope on Ubuntu 24.04 amd64. APT resolves and
|
||||
downloads signed Ubuntu packages into this job; dpkg-deb only extracts them.
|
||||
The output is an engineering build, not an installed or qualified runtime.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||
ARCHIVE_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||
DEPS = (
|
||||
"qtbase5-dev", "qtbase5-private-dev", "qtdeclarative5-dev",
|
||||
"qtquickcontrols2-5-dev", "libqt5serialport5-dev", "qtconnectivity5-dev",
|
||||
"qtpositioning5-dev", "libqt5gamepad5-dev", "libqt5svg5-dev",
|
||||
)
|
||||
|
||||
|
||||
def build(archive, root, dependency_cache=None, resume=None):
|
||||
if os.geteuid() == 0:
|
||||
raise RuntimeError("This build must not run as root")
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == ARCHIVE_SHA256
|
||||
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||
limit = (group / "memory.max").read_text().strip()
|
||||
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||
raise RuntimeError("Run in a user scope with MemoryMax=3G")
|
||||
os.umask(0o077)
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
root = root.resolve()
|
||||
report = {"schema": "missioncore.vesc.tool-build/v1", "source_commit": COMMIT,
|
||||
"source_sha256": ARCHIVE_SHA256, "state": "running", "jobs": [],
|
||||
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||
"hardware_access": False, "system_packages_installed": False, "packages": []}
|
||||
env = dict(os.environ, LC_ALL="C", DEBIAN_FRONTEND="noninteractive", QT_QPA_PLATFORM="offscreen")
|
||||
|
||||
def publish():
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def run(name, args, cwd=None, timeout=600):
|
||||
job = {"id": name, "state": "running"}; report["jobs"].append(job); publish()
|
||||
started = time.monotonic()
|
||||
with (root / (name + ".stdout")).open("wb") as out, (root / (name + ".stderr")).open("wb") as err:
|
||||
result = subprocess.run(args, cwd=cwd or root, env=env, stdout=out, stderr=err, timeout=timeout)
|
||||
job.update(state="complete" if result.returncode == 0 else "error", exit_code=result.returncode,
|
||||
duration_seconds=time.monotonic() - started)
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError("Build step failed: " + name)
|
||||
return (root / (name + ".stdout")).read_text()
|
||||
|
||||
try:
|
||||
staging = root
|
||||
if resume is not None:
|
||||
previous_raw = (resume / "report.json").read_bytes()
|
||||
previous = json.loads(previous_raw)
|
||||
if previous.get("source_sha256") != ARCHIVE_SHA256 or previous.get("error") != "Build step failed: compile":
|
||||
raise RuntimeError("Only this source's failed compile may resume")
|
||||
staging = resume.resolve()
|
||||
report["resume_report_sha256"] = hashlib.sha256(previous_raw).hexdigest()
|
||||
report["resumed_staging"] = str(staging)
|
||||
downloads = staging / "packages"
|
||||
if resume is None: downloads.mkdir()
|
||||
sysroot = staging / "sysroot"
|
||||
if resume is None: sysroot.mkdir()
|
||||
if resume is not None:
|
||||
report["packages"] = previous["packages"]
|
||||
for item in report["packages"]:
|
||||
if hashlib.sha256((downloads / item["file"]).read_bytes()).hexdigest() != item["sha256"]:
|
||||
raise RuntimeError("Resumed dependency changed")
|
||||
elif dependency_cache is not None:
|
||||
previous = json.loads((dependency_cache / "report.json").read_text())
|
||||
if previous.get("source_sha256") != ARCHIVE_SHA256 or not previous.get("packages"):
|
||||
raise RuntimeError("Unqualified dependency cache")
|
||||
for item in previous["packages"]:
|
||||
name = item["file"]
|
||||
if Path(name).name != name or not name.endswith(".deb"):
|
||||
raise RuntimeError("Invalid cached package name")
|
||||
package = dependency_cache / "packages" / name
|
||||
if hashlib.sha256(package.read_bytes()).hexdigest() != item["sha256"]:
|
||||
raise RuntimeError("Cached dependency changed")
|
||||
shutil.copyfile(package, downloads / name)
|
||||
report["dependency_cache_report_sha256"] = hashlib.sha256((dependency_cache / "report.json").read_bytes()).hexdigest()
|
||||
else:
|
||||
# Host lists may refer to superseded security packages. Refresh only
|
||||
# this job's signed Ubuntu indexes; never update /var/lib/apt or invoke
|
||||
# the host's update hooks (Timescale and other sources are irrelevant).
|
||||
aptdir = root / "apt"; aptdir.mkdir()
|
||||
for name in ("lists", "lists/partial", "archives", "archives/partial"):
|
||||
(aptdir / name).mkdir(exist_ok=True)
|
||||
sources = aptdir / "sources.list"
|
||||
sources.write_text("".join(
|
||||
"deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] " + url + " " + suite + " main universe\n"
|
||||
for url, suite in (("https://archive.ubuntu.com/ubuntu", "noble"),
|
||||
("https://archive.ubuntu.com/ubuntu", "noble-updates"),
|
||||
("https://security.ubuntu.com/ubuntu", "noble-security"))))
|
||||
config = aptdir / "apt.conf"
|
||||
config.write_text(
|
||||
'Dir::Etc::Parts "-";\nDir::Etc::main "-";\n'
|
||||
'Dir::Etc::sourceparts "-";\nDir::Etc::sourcelist "' + str(sources) + '";\n'
|
||||
'Dir::State::lists "' + str(aptdir / "lists") + '";\n'
|
||||
'Dir::Cache::archives "' + str(aptdir / "archives") + '";\n'
|
||||
'Dir::Cache::pkgcache "";\nDir::Cache::srcpkgcache "";\n'
|
||||
'Acquire::Languages "none";\nDebug::NoLocking "true";\n'
|
||||
'#clear APT::Update::Post-Invoke;\n#clear APT::Update::Post-Invoke-Success;\n')
|
||||
env["APT_CONFIG"] = str(config)
|
||||
apt = ["/usr/bin/apt-get"]
|
||||
run("private-indexes", [*apt, "update"])
|
||||
plan = run("dependencies-plan", [*apt, "--simulate", "--no-install-recommends", "--no-remove", "install", *DEPS])
|
||||
packages = re.findall(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+)", plan, re.MULTILINE)
|
||||
if not packages or len(packages) > 150:
|
||||
raise RuntimeError("Unexpected dependency plan; inspect before changing profile")
|
||||
for index, (name, version) in enumerate(packages):
|
||||
# apt-get download verifies the archive against the host's trusted
|
||||
# repository metadata. No maintainer script or package install runs.
|
||||
run("download-%03d" % index, [*apt, "download", name + "=" + version], downloads)
|
||||
for index, package in enumerate(sorted(downloads.glob("*.deb")) if resume is None else []):
|
||||
report["packages"].append({"file": package.name, "sha256": hashlib.sha256(package.read_bytes()).hexdigest()})
|
||||
run("extract-%03d" % index, ["/usr/bin/dpkg-deb", "--extract", str(package), str(sysroot)])
|
||||
source = staging / "source"
|
||||
if resume is None:
|
||||
source.mkdir()
|
||||
with tarfile.open(archive) as stream:
|
||||
stream.extractall(source, filter="data")
|
||||
source = source / ("vesc_tool-" + COMMIT)
|
||||
qtbase = sysroot / "usr"
|
||||
# APT omits already-installed runtime packages. Complete the private
|
||||
# development symlinks from declared host libraries, recording provenance.
|
||||
report["host_libraries"] = []
|
||||
for name in ("libGL.so.1", "libGLX.so.0", "libGLU.so.1"):
|
||||
target = qtbase / "lib/x86_64-linux-gnu" / name
|
||||
host = Path("/usr/lib/x86_64-linux-gnu") / name
|
||||
if not target.exists() and host.exists():
|
||||
shutil.copyfile(host, target)
|
||||
report["host_libraries"].append({"source": str(host.resolve()), "sha256": hashlib.sha256(host.read_bytes()).hexdigest()})
|
||||
qtarch = qtbase / "lib/x86_64-linux-gnu/qt5"
|
||||
qtbin = qtbase / "lib/qt5/bin"
|
||||
qtconfig = "[Paths]\nPrefix=" + str(qtbase) + "\n" + "\n".join(
|
||||
name + "=" + str(path) for name, path in {
|
||||
"Headers": qtbase / "include/x86_64-linux-gnu/qt5",
|
||||
"Libraries": qtbase / "lib/x86_64-linux-gnu", "ArchData": qtarch,
|
||||
"HostData": qtarch, "Binaries": qtbin, "HostBinaries": qtbin,
|
||||
"Plugins": qtarch / "plugins", "Qml2Imports": qtarch / "qml",
|
||||
"Data": qtbase / "share/qt5",
|
||||
}.items()) + "\n"
|
||||
(qtbin / "qt.conf").write_text(qtconfig)
|
||||
env["LD_LIBRARY_PATH"] = str(qtbase / "lib/x86_64-linux-gnu")
|
||||
env["QT_PLUGIN_PATH"] = str(qtarch / "plugins")
|
||||
env["PKG_CONFIG_LIBDIR"] = str(qtbase / "lib/x86_64-linux-gnu/pkgconfig")
|
||||
env["PKG_CONFIG_SYSROOT_DIR"] = str(sysroot)
|
||||
run("qmake", [str(qtbin / "qmake"), "-config", "release", "CONFIG += release_lin build_original exclude_fw",
|
||||
"VT_GIT_COMMIT=" + COMMIT[:8], "INCLUDEPATH += " + str(qtbase / "include") + " " + str(qtbase / "include/x86_64-linux-gnu"),
|
||||
"QMAKE_LIBDIR += " + str(qtbase / "lib/x86_64-linux-gnu")], source)
|
||||
run("compile", ["/usr/bin/make", "-j2"], source, timeout=1800)
|
||||
binary = source / "build/lin/vesc_tool_7.00"
|
||||
result = run("version", [str(binary), "--version"], source, timeout=30)
|
||||
report.update(state="complete", binary=str(binary), binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
version_output=result, runtime_installed=False, hardware_qualified=False)
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
publish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--dependencies", type=Path)
|
||||
parser.add_argument("--resume", type=Path)
|
||||
args = parser.parse_args()
|
||||
build(args.source.resolve(), args.output, args.dependencies, args.resume)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Onboard VESC discovery, archive and bounded motor identification service."""
|
||||
|
||||
VERSION = "0.6.3"
|
||||
MODEL = "vesc.controller"
|
||||
SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Exact, read-only firmware configuration decoder. Never serializes a write."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def crc32c(data):
|
||||
value = 0xffffffff
|
||||
for byte in data:
|
||||
value ^= byte
|
||||
for _ in range(8):
|
||||
value = (value >> 1) ^ (0x82f63b78 if value & 1 else 0)
|
||||
return value ^ 0xffffffff
|
||||
|
||||
|
||||
def decode(data, kind):
|
||||
code, name = {"motor": (14, "mcconf"), "application": (17, "appconf")}[kind]
|
||||
xml = ET.parse(Path(__file__).parent / "schemas/5.02" / ("parameters_" + name + ".xml")).getroot()
|
||||
params = {p.tag: p for p in xml.find("Params")}
|
||||
order = [p.text for p in xml.find("SerOrder")]
|
||||
signature = "".join(n + params[n].findtext("type", "0") + params[n].findtext("vTx", "0")
|
||||
+ "".join(x.text or "" for x in params[n].findall("enumNames")) for n in order)
|
||||
if len(data) < 5 or data[0] != code or int.from_bytes(data[1:5], "big") != crc32c(signature.encode()):
|
||||
raise ValueError("Configuration signature differs from firmware 5.02 schema")
|
||||
offset, result = 5, {}
|
||||
for name in order:
|
||||
p = params[name]; kind = int(p.findtext("type")); tx = int(p.findtext("vTx", "0"))
|
||||
if kind in (4, 5): fmt = "b"
|
||||
elif kind == 6: fmt = "B"
|
||||
elif kind == 2: fmt = {1: "B", 2: "b", 3: "H", 4: "h", 5: "I", 6: "i"}[tx]
|
||||
elif kind == 1: fmt = {7: "h", 8: "i", 9: "I"}[tx]
|
||||
else: raise ValueError("Unsupported configuration type")
|
||||
size = struct.calcsize(">" + fmt)
|
||||
if offset + size > len(data): raise ValueError("Truncated configuration")
|
||||
value = struct.unpack_from(">" + fmt, data, offset)[0]; offset += size
|
||||
if kind == 1:
|
||||
if tx == 9:
|
||||
exponent, fraction = (value >> 23) & 255, value & 0x7fffff
|
||||
part = fraction / 16777216.0 + 0.5 if exponent or fraction else 0.0
|
||||
value = math.ldexp(-part if value & 0x80000000 else part, exponent - 126)
|
||||
else: value /= float(p.findtext("vTxDoubleScale", "1"))
|
||||
if not math.isfinite(value): raise ValueError("Non-finite parameter")
|
||||
result[name] = value
|
||||
if offset != len(data): raise ValueError("Unexpected configuration tail")
|
||||
return result
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Owner-assigned drive slots, independent of USB addresses and motor commands."""
|
||||
import json
|
||||
|
||||
LAYOUTS = {"1x1": ("left.1", "right.1"), "2x2": ("left.1", "left.2", "right.1", "right.2")}
|
||||
|
||||
def slot_label(layout, slot):
|
||||
side = "Левый" if slot.startswith("left.") else "Правый"
|
||||
return side + ((" передний" if slot.endswith(".1") else " задний") if layout == "2x2" else "")
|
||||
|
||||
|
||||
class DriveProfile:
|
||||
def __init__(self, root, atomic):
|
||||
self.path, self.atomic = root / "drive-profile.json", atomic
|
||||
self.value = json.loads(self.path.read_text()) if self.path.exists() else {"layout": None, "revision": 0, "bindings": {}}
|
||||
|
||||
def update(self, action, params, device):
|
||||
current = self.value
|
||||
if params["revision"] != current["revision"]:
|
||||
raise ValueError("Профиль привода изменился. Обновите карточку.")
|
||||
bindings = dict(current["bindings"])
|
||||
layout = current["layout"]
|
||||
if action == "vesc.drive.assign":
|
||||
layout, slot = params["layout"], params["slot"]
|
||||
if set(bindings) - set(LAYOUTS[layout]):
|
||||
raise ValueError("Сначала снимите назначения задних моторов.")
|
||||
if slot and slot in bindings and bindings[slot]["device_id"] != device.id:
|
||||
raise ValueError("Это место уже занято. Сначала снимите прежнее назначение.")
|
||||
bindings = {key: value for key, value in bindings.items() if value["device_id"] != device.id}
|
||||
if slot:
|
||||
bindings[slot] = {"device_id": device.id, "uuid": device.identity["uuid"]}
|
||||
else:
|
||||
bindings.pop(params["slot"], None)
|
||||
value = {"layout": layout, "revision": current["revision"] + 1, "bindings": bindings}
|
||||
self.atomic(self.path, value)
|
||||
self.value = value
|
||||
return value
|
||||
|
||||
|
||||
def validate(action, params):
|
||||
keys = {"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"}
|
||||
if set(params) != keys or type(params["revision"]) is not int or params["revision"] < 0:
|
||||
raise ValueError("Invalid drive profile revision")
|
||||
if action == "vesc.drive.assign":
|
||||
if params["layout"] not in LAYOUTS or params["slot"] not in ("", *LAYOUTS[params["layout"]]):
|
||||
raise ValueError("Invalid drive layout or slot")
|
||||
elif params["slot"] not in LAYOUTS["2x2"]:
|
||||
raise ValueError("Invalid drive slot")
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Transaction lifecycle around unchanged upstream Utility::detectAllFoc.
|
||||
|
||||
No FOC algorithm or wire writes here. Native firmware owns this non-interruptible
|
||||
cycle; a pending marker survives any uncertain completion and prevents replay.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from .protocol import firmware, values
|
||||
|
||||
|
||||
def fresh_values(owner, devices):
|
||||
# FW 5.02 GET_VALUES reads AND resets accumulated average current. The
|
||||
# first reply after a quiet 30 s calibration includes the entire cycle.
|
||||
# Drain it, then measure a new bounded interval after the release command.
|
||||
for device in devices:
|
||||
values(device.link.query(4, timeout=0.2))
|
||||
owner.sleep(0.25)
|
||||
return {device.id: values(device.link.query(4, timeout=0.2)) for device in devices}
|
||||
|
||||
|
||||
def remove_pending(pending):
|
||||
pending.unlink()
|
||||
fd = os.open(pending.parent, os.O_RDONLY)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def reconcile(owner, devices):
|
||||
"""Explicit neutral-return recovery, never calibration or config replay.
|
||||
|
||||
Admits only a previously completed/verified native receipt, exact archived
|
||||
post-configs for every peer, stable identity, neutral PPM and fresh idle
|
||||
telemetry. Unknown native completion always remains blocked.
|
||||
"""
|
||||
if any(device.link is None for device in devices): raise ValueError("Controller disconnected")
|
||||
pending = owner.service.root / "calibration-pending.json"
|
||||
record = json.loads(pending.read_text())
|
||||
operation = record.get("operation_id", "")
|
||||
if not re.fullmatch(r"op_[0-9a-f]{32}", operation): raise ValueError("Invalid pending operation")
|
||||
receipt = json.loads((owner.service.root / (operation + ".json")).read_text())["receipt"]
|
||||
result = receipt.get("result", {})
|
||||
native = result.get("native", {})
|
||||
if not (receipt.get("state") == "complete" and result.get("completed") is True
|
||||
and result.get("configuration_verified") is True and native.get("validated") is True):
|
||||
raise ValueError("Native completion/configuration is unconfirmed")
|
||||
expected = {}
|
||||
for identifier in result.get("after_backups", []):
|
||||
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier): raise ValueError("Invalid backup identity")
|
||||
backup = json.loads((owner.service.root / ("backup_" + identifier + ".json")).read_text())
|
||||
if backup.get("parent_operation_id") != operation or backup["device_id"] in expected:
|
||||
raise ValueError("Calibration backup ownership differs")
|
||||
expected[backup["device_id"]] = backup
|
||||
if set(expected) != {d.id for d in devices}: raise ValueError("Controller set changed")
|
||||
for device in devices:
|
||||
backup = expected[device.id]
|
||||
if firmware(device.link.query(0)) != backup["identity"]: raise ValueError("Controller identity changed")
|
||||
for kind, code in (("motor",14),("application",17)):
|
||||
actual = device.link.query(code)
|
||||
if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True):
|
||||
raise ValueError("Post-calibration configuration changed")
|
||||
owner.neutral(devices)
|
||||
after = fresh_values(owner, devices)
|
||||
if any(abs(v["motor_current_a"]) > 1 or abs(v["erpm"]) > 30 or abs(v["duty"]) > .01 or v["fault_code"] != 0 for v in after.values()):
|
||||
raise ValueError("Fresh idle state unconfirmed")
|
||||
evidence = {"operation_id": operation, "observed_at": owner.utc(), "after": after,
|
||||
"configuration_verified": True, "release_confirmed": True, "calibration_replayed": False}
|
||||
owner.atomic(owner.service.root / ("calibration_recovered_" + operation + ".json"), evidence)
|
||||
remove_pending(pending)
|
||||
return evidence
|
||||
|
||||
|
||||
def archive_after(owner, command, device, configs):
|
||||
identifier = "op_" + uuid.uuid4().hex
|
||||
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
||||
"identity": device.identity, "operation_id": identifier,
|
||||
"parent_operation_id": command["operation_id"], "observed_at": owner.utc(),
|
||||
"monotonic_at": owner.monotonic(), "decoded": False,
|
||||
"configs": {kind: {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "signature_hex": raw[1:5].hex()}
|
||||
for kind, raw in configs.items()}}
|
||||
owner.atomic(owner.service.root / ("backup_" + identifier + ".json"), backup)
|
||||
owner.service.archive.add("local", backup)
|
||||
device.backup = {"observed_at": backup["observed_at"], "operation_id": identifier,
|
||||
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in backup["configs"].items()}}
|
||||
return identifier
|
||||
|
||||
|
||||
def calibrate(owner, command, devices, target, originals, backups, unchanged):
|
||||
pending = owner.service.root / "calibration-pending.json"
|
||||
owner.atomic(pending, {"device_id": target.id, "identity": target.identity,
|
||||
"started_at": owner.utc(), "operation_id": command["operation_id"], "backups": backups})
|
||||
owner.state("calibrating")
|
||||
owner.active, owner.mode = True, "foc"
|
||||
started = owner.monotonic()
|
||||
native, after, issues, saved = {}, {}, [], []
|
||||
verified, attempted = False, False
|
||||
try:
|
||||
unchanged()
|
||||
owner.neutral(devices)
|
||||
if owner.stop.is_set(): raise ValueError("Cancelled before calibration")
|
||||
# Do not renew a 250 ms lease over the upstream 180 s calibration lease.
|
||||
# No host current/RPM or configuration command is sent while it runs.
|
||||
for device in devices: device.link.test_command("release")
|
||||
attempted = True
|
||||
target.link.calibrate_foc(command["parameters"]["max_power_loss_w"])
|
||||
while owner.monotonic() - started < 225:
|
||||
unchanged()
|
||||
state = target.link.procedure_result()
|
||||
if not state.get("running"):
|
||||
native = state.get("result", {})
|
||||
if state.get("uncertain"): issues.append("native_postcondition_unconfirmed")
|
||||
break
|
||||
if owner.stop.is_set() and "stop_requested_during_native_cycle" not in issues:
|
||||
issues.append("stop_requested_during_native_cycle")
|
||||
owner.sleep(0.5)
|
||||
if not native.get("completed"): issues.append("native_completion_unconfirmed")
|
||||
except (OSError, ValueError, TimeoutError):
|
||||
issues.append("communication_unconfirmed")
|
||||
finally:
|
||||
for device in devices:
|
||||
try: device.link.test_command("release")
|
||||
except (OSError, ValueError, TimeoutError): issues.append("release_unconfirmed")
|
||||
owner.sleep(0.5)
|
||||
try: after = fresh_values(owner, devices)
|
||||
except (OSError, ValueError, TimeoutError): after = {device.id: None for device in devices}
|
||||
released = all(v is not None and abs(v["motor_current_a"]) <= 1 and abs(v["duty"]) <= .01 for v in after.values())
|
||||
if not attempted or native.get("completed"):
|
||||
try:
|
||||
unchanged()
|
||||
for device in devices:
|
||||
if firmware(device.link.query(0)) != device.identity: raise ValueError("Identity changed")
|
||||
configs = {kind: device.link.query(code) for kind, code in (("motor",14),("application",17))}
|
||||
saved.append(archive_after(owner, command, device, configs))
|
||||
if configs["application"] != originals[device.id]["application"]: raise ValueError("Receiver config changed")
|
||||
if (device is not target or not attempted or not native.get("success")) and configs["motor"] != originals[device.id]["motor"]:
|
||||
raise ValueError("Unchanged/restored motor config differs")
|
||||
verified = not attempted or native.get("validated") is True
|
||||
except (OSError, ValueError, TimeoutError): issues.append("configuration_unconfirmed")
|
||||
if verified and released:
|
||||
remove_pending(pending)
|
||||
if not owner.latched: owner.state("ready")
|
||||
else: owner.state("rc")
|
||||
owner.active, owner.mode = False, None
|
||||
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_calibration",
|
||||
"elapsed_s": owner.monotonic() - started, "completed": native.get("completed", False),
|
||||
"success": bool(native.get("success") and verified and released), "native": native,
|
||||
"configuration_verified": verified, "release_confirmed": released, "after": after,
|
||||
"issues": issues, "backups": backups, "after_backups": saved}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""One local, bounded speed test for the complete owner-assigned drive profile."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .drive_profile import LAYOUTS
|
||||
from .protocol import ppm, values, TEST_LIMITS
|
||||
from .speed_hold import SpeedHold
|
||||
|
||||
|
||||
def targets(service, params, devices, selected):
|
||||
profile = service.drive.value
|
||||
bindings = profile["bindings"]
|
||||
slots = LAYOUTS.get(profile["layout"], ())
|
||||
if (not slots or params["profile_revision"] != profile["revision"]
|
||||
or set(bindings) != set(slots)):
|
||||
raise ValueError("Профиль изменился или не все моторы назначены.")
|
||||
ids = [bindings[slot]["device_id"] for slot in slots]
|
||||
if len(set(ids)) != len(ids) or set(params["device_ids"]) != set(ids) or selected.id not in ids:
|
||||
raise ValueError("Состав проверяемого привода не совпадает с профилем.")
|
||||
available = {device.id: device for device in devices}
|
||||
if any(identifier not in available for identifier in ids):
|
||||
raise ValueError("Один из назначенных моторов отключён.")
|
||||
if any(available[b["device_id"]].identity["uuid"] != b["uuid"] for b in bindings.values()):
|
||||
raise ValueError("Идентичность назначенного VESC изменилась.")
|
||||
return [available[identifier] for identifier in ids]
|
||||
|
||||
|
||||
def batch(pool, devices, function):
|
||||
# Join every submitted call before propagating an error: no late worker may
|
||||
# issue torque after the caller has already released the other controllers.
|
||||
futures = [(device.id, pool.submit(function, device)) for device in devices]
|
||||
result, errors = {}, []
|
||||
for identifier, future in futures:
|
||||
try: result[identifier] = future.result()
|
||||
except (OSError, ValueError, TimeoutError) as error:
|
||||
error.device_id = identifier
|
||||
errors.append(error)
|
||||
if errors: raise errors[0]
|
||||
return result
|
||||
|
||||
|
||||
def run(owner, command, devices, moving, originals, backups, unchanged):
|
||||
from .motor_test import Rejected, check_values
|
||||
params = command["parameters"]
|
||||
duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a"))
|
||||
ids = {device.id for device in moving}
|
||||
motors, samples, after, cleanup = {}, [], {}, {}
|
||||
restored, failure, rotation, previous_good = True, None, 0.0, False
|
||||
outcome, claimed, started, previous = "duration", False, owner.monotonic(), owner.monotonic()
|
||||
stalls = {}
|
||||
owner.state("testing")
|
||||
owner.active, owner.mode = True, "group_speed"
|
||||
with ThreadPoolExecutor(max_workers=min(16, len(devices)), thread_name_prefix="vesc-drive") as pool:
|
||||
try:
|
||||
for device in moving:
|
||||
motors[device.id] = owner.limits.apply(device, originals[device.id]["motor"], current_a)
|
||||
started = previous = owner.monotonic()
|
||||
holds = {device.id: SpeedHold(erpm, duration, started) for device in moving}
|
||||
while owner.monotonic() - started < duration + 20:
|
||||
cycle = owner.monotonic()
|
||||
if owner.stop.is_set(): outcome = "stopped"; break
|
||||
unchanged()
|
||||
def read(device):
|
||||
level = ppm(device.link.query(31, timeout=.06))["level"]
|
||||
value = values(device.link.query(4, timeout=.06)) if device.id in ids else None
|
||||
return level, value
|
||||
observed = batch(pool, devices, read)
|
||||
if any(abs(level) > .02 for level, _ in observed.values()):
|
||||
owner.state("rc")
|
||||
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
|
||||
now = owner.monotonic()
|
||||
readings = {identifier: observed[identifier][1] for identifier in ids}
|
||||
setpoint = None
|
||||
for identifier, value in readings.items():
|
||||
check_values(value, moving=True, current_a=current_a, motor=motors[identifier])
|
||||
setpoint, _, error = holds[identifier].update(now, value)
|
||||
if error: raise Rejected(error)
|
||||
if abs(value["motor_current_a"]) > TEST_LIMITS["stall_current_a"]:
|
||||
at, tacho = stalls.setdefault(identifier, (now, value["tachometer"]))
|
||||
if abs(value["erpm"]) >= 60 and abs(value["tachometer"] - tacho) >= 3:
|
||||
stalls[identifier] = (now, value["tachometer"])
|
||||
elif now - at >= TEST_LIMITS["stall_timeout_s"]:
|
||||
raise Rejected("Один из моторов не движется при токе выше 5 А. Общая проверка остановлена.")
|
||||
else: stalls.pop(identifier, None)
|
||||
good = all(h.hold_started is not None and h.previous_good for h in holds.values())
|
||||
delta = now - previous
|
||||
if good and previous_good and delta <= .25: rotation += delta
|
||||
previous, previous_good = now, good
|
||||
sample = {"at": now-started, "devices": readings, "rotation_s": rotation,
|
||||
"phase": "holding" if good else "accelerating", "commanded_erpm": None}
|
||||
samples.append(sample)
|
||||
if rotation >= duration: break
|
||||
if owner.monotonic()-cycle > .12: raise Rejected("Связь слишком медленная для общей проверки.")
|
||||
claimed = True
|
||||
batch(pool, devices, lambda d: d.link.test_command("claim"))
|
||||
if owner.stop.is_set(): outcome = "stopped"; break
|
||||
if owner.monotonic()-cycle > .16: raise Rejected("Связь слишком медленная для общей проверки.")
|
||||
def send(device):
|
||||
if owner.stop.is_set(): return
|
||||
if device.id in ids: device.link.test_speed(setpoint)
|
||||
else: device.link.test_command("release")
|
||||
sent_at = owner.monotonic()
|
||||
batch(pool, devices, send)
|
||||
sample.update(commanded_erpm=setpoint, command_batch_s=owner.monotonic()-sent_at)
|
||||
owner.sleep(max(0, .1-(owner.monotonic()-cycle)))
|
||||
if outcome == "duration" and rotation < duration:
|
||||
outcome = "Общий срок проверки истёк; заданное время совместного вращения не набрано."
|
||||
except (OSError, ValueError, TimeoutError) as error:
|
||||
failure = {"type": type(error).__name__, "message": str(error), "elapsed_s": owner.monotonic()-started}
|
||||
failure["device_id"] = getattr(error, "device_id", None)
|
||||
failure["native_rpc"] = getattr(error, "native_rpc", None)
|
||||
failure["native_history"] = getattr(error, "native_history", [])
|
||||
outcome = str(error) if isinstance(error, Rejected) else "Ответ одного из VESC не получен вовремя. Общая проверка остановлена."
|
||||
finally:
|
||||
def release(device):
|
||||
try:
|
||||
device.link.test_command("release")
|
||||
return "zero_current_sent"
|
||||
except (OSError, ValueError, TimeoutError): return "unconfirmed"
|
||||
if claimed: cleanup = batch(pool, devices, release)
|
||||
owner.sleep(.3)
|
||||
for device in devices:
|
||||
try: after[device.id] = values(device.link.query(4, timeout=.2))
|
||||
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||
confirmed = all(v is not None and abs(v["motor_current_a"]) <= 1 for v in after.values())
|
||||
for device in moving:
|
||||
try: owner.limits.restore(device)
|
||||
except (OSError, ValueError, TimeoutError): restored = False
|
||||
if not confirmed or not restored: owner.state("rc")
|
||||
elif not owner.latched: owner.state("ready")
|
||||
owner.active, owner.mode = False, None
|
||||
return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed",
|
||||
"profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm,
|
||||
"duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure,
|
||||
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
|
||||
"limits_restored": restored, "after": after, "backups": backups}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Native FW 5.02 Hall measurement used by VESC Tool (COMM_DETECT_HALL_FOC).
|
||||
|
||||
This is a non-interruptible ~12 s firmware procedure: it locks mc_interface,
|
||||
overrides phase, sweeps three electrical turns in each direction and restores
|
||||
its prior RAM configuration. A host stop/current-zero cannot cancel the sweep.
|
||||
No table is applied here; measurements are archived in the operation receipt.
|
||||
"""
|
||||
from .configuration import decode
|
||||
from .protocol import values, ppm
|
||||
|
||||
|
||||
def parse_result(raw):
|
||||
if len(raw) != 10 or raw[0] != 28 or raw[9] not in (0, 1):
|
||||
raise ValueError("Invalid Hall result")
|
||||
table = list(raw[1:9])
|
||||
if any(v > 200 and v != 255 for v in table): raise ValueError("Invalid Hall angle")
|
||||
observed = [i for i, v in enumerate(table) if v != 255]
|
||||
return {"firmware_success": raw[9] == 0, "hall_table": table,
|
||||
"observed_states": observed, "valid_six_states": raw[9] == 0 and len(observed) == 6}
|
||||
|
||||
|
||||
def measure(owner, devices, target, original, backups, unchanged):
|
||||
motor = decode(original, "motor")
|
||||
if motor["m_sensor_port_mode"] != 0:
|
||||
from .motor_test import Rejected
|
||||
raise Rejected("Вход датчиков выбран не в режиме Холла.")
|
||||
pending = owner.service.root / "hall-pending.json"
|
||||
owner.atomic(pending, {"device_id": target.id, "identity": target.identity,
|
||||
"started_at": owner.utc(), "backups": backups})
|
||||
owner.state("calibrating")
|
||||
owner.active, owner.mode = True, "hall"
|
||||
started = owner.monotonic()
|
||||
samples, issues, result, after = [], [], None, {}
|
||||
completed, restored, attempted = False, False, False
|
||||
try:
|
||||
unchanged()
|
||||
owner.neutral(devices)
|
||||
# Do not enter the native procedure if Stop arrived during preflight.
|
||||
if owner.stop.is_set(): raise ValueError("Measurement cancelled before start")
|
||||
for device in devices: device.link.test_command("claim")
|
||||
attempted = True
|
||||
target.link.detect_hall()
|
||||
while owner.monotonic() - started < 30:
|
||||
cycle = owner.monotonic()
|
||||
try:
|
||||
unchanged()
|
||||
for device in devices:
|
||||
incoming = ppm(device.link.query(31, timeout=0.06))
|
||||
if abs(incoming["level"]) > 0.02:
|
||||
owner.state("rc")
|
||||
if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle")
|
||||
sample = values(target.link.query(4, timeout=0.06))
|
||||
samples.append({"at": owner.monotonic() - started, "values": sample})
|
||||
for device in devices: device.link.test_command("claim")
|
||||
for device in devices:
|
||||
if device is not target: device.link.test_command("release")
|
||||
except (OSError, ValueError, TimeoutError):
|
||||
if "communication_unconfirmed" not in issues: issues.append("communication_unconfirmed")
|
||||
if owner.stop.is_set() and "stop_requested_during_native_cycle" not in issues:
|
||||
issues.append("stop_requested_during_native_cycle")
|
||||
reply = target.link.hall_result
|
||||
if reply is not None:
|
||||
result = parse_result(reply)
|
||||
completed = True
|
||||
break
|
||||
owner.sleep(max(0, 0.1 - (owner.monotonic() - cycle)))
|
||||
except (OSError, ValueError, TimeoutError):
|
||||
issues.append("native_completion_unconfirmed")
|
||||
finally:
|
||||
# Zero is a release AFTER completion, never a claim that native detect
|
||||
# is interruptible. Peers also receive release if the target disappears.
|
||||
for device in devices:
|
||||
try: device.link.test_command("release")
|
||||
except (OSError, ValueError, TimeoutError): issues.append("release_unconfirmed")
|
||||
owner.sleep(0.3)
|
||||
for device in devices:
|
||||
try: after[device.id] = values(device.link.query(4, timeout=0.1))
|
||||
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||
if completed or not attempted:
|
||||
target.link.hall_pending = False
|
||||
try: restored = target.link.query(14) == original
|
||||
except (OSError, ValueError, TimeoutError): pass
|
||||
released = all(v is not None and abs(v["motor_current_a"]) <= 1 for v in after.values())
|
||||
if (completed or not attempted) and restored and released:
|
||||
pending.unlink()
|
||||
import os
|
||||
fd = os.open(pending.parent, os.O_RDONLY)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
if not owner.latched: owner.state("ready")
|
||||
else:
|
||||
owner.state("rc")
|
||||
owner.active, owner.mode = False, None
|
||||
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall",
|
||||
"current_a": 5, "elapsed_s": owner.monotonic() - started,
|
||||
"completed": completed, "measurement": result, "issues": issues,
|
||||
"configuration_restored": restored, "configuration_written": False,
|
||||
"release_confirmed": released, "after": after, "samples": samples, "backups": backups}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Bounded idle transport measurement through the installed native owners.
|
||||
|
||||
Only PPM/telemetry reads, no leases, motor commands or configuration writes.
|
||||
The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget;
|
||||
it never relaxes the motor-control deadline or authorizes powered operation.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
import time
|
||||
|
||||
from .protocol import ppm, values
|
||||
|
||||
|
||||
def summary(samples):
|
||||
times = sorted(s["elapsed_ms"] for s in samples)
|
||||
if not times: return {"replies": 0}
|
||||
def percentile(p): return times[max(0, math.ceil(len(times)*p)-1)]
|
||||
return {"replies": len(times), "p50_ms": percentile(.5), "p95_ms": percentile(.95),
|
||||
"p99_ms": percentile(.99), "max_ms": times[-1],
|
||||
"over_60_ms": sum(t > 60 for t in times)}
|
||||
|
||||
|
||||
def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monotonic):
|
||||
started = monotonic()
|
||||
observed = datetime.now(timezone.utc).isoformat()
|
||||
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||
ids = {d.id: d.session for d in devices}
|
||||
if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]:
|
||||
raise ValueError("Controller sessions changed")
|
||||
samples = {d.id: [] for d in devices}
|
||||
failure = None
|
||||
stop_reason = "complete"
|
||||
def read(device):
|
||||
for code in (31, 4):
|
||||
before = monotonic()
|
||||
try:
|
||||
raw = device.link.query(code, timeout=.5)
|
||||
reading = ppm(raw) if code == 31 else values(raw)
|
||||
sample = {"command": code, "at_s": before-started, "elapsed_ms": (monotonic()-before)*1000,
|
||||
"native_rpc": getattr(device.link, "last_rpc", None)}
|
||||
if code == 31:
|
||||
sample["ppm_level"] = reading["level"]
|
||||
idle = abs(reading["level"]) <= .02
|
||||
else:
|
||||
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
|
||||
duty=reading["duty"], fault_code=reading["fault_code"])
|
||||
idle = abs(reading["erpm"]) <= 30 and abs(reading["motor_current_a"]) <= 1 and abs(reading["duty"]) <= .01
|
||||
samples[device.id].append(sample)
|
||||
if not idle: return {"device_id": device.id, "reason": "not_idle"}
|
||||
except (OSError, ValueError, TimeoutError) as error:
|
||||
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
|
||||
"elapsed_ms": (monotonic()-before)*1000,
|
||||
"native_rpc": getattr(error, "native_rpc", None),
|
||||
"native_history": getattr(error, "native_history", [])}
|
||||
return None
|
||||
with ExitStack() as locks:
|
||||
for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock)
|
||||
if any(d.link is None for d in devices): raise ValueError("Controller unavailable")
|
||||
# Same cadence and per-controller query order as group rotation, with a
|
||||
# larger read-only deadline to expose latency instead of destroying it.
|
||||
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
|
||||
for _ in range(100):
|
||||
cycle = monotonic()
|
||||
cancelled = service.motor.cancelled_at
|
||||
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
|
||||
stop_reason = "stopped"; break
|
||||
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
|
||||
stop_reason = "deadline"; break
|
||||
futures = [pool.submit(read, d) for d in devices]
|
||||
errors = [error for future in futures if (error := future.result()) is not None]
|
||||
if errors:
|
||||
failure = errors; stop_reason = errors[0]["reason"]; break
|
||||
sleep(max(0, .1-(monotonic()-cycle)))
|
||||
return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started,
|
||||
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
|
||||
"failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(),
|
||||
"summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""A bounded 0.5–30 A raised-rig test for firmware 5.02, never a drive API.
|
||||
|
||||
Firmware reference: vedderb/bldc 3f670137e27e6e383fa79c50cc6b1fa85aab1554,
|
||||
commands.c, app_ppm.c, app.c and chvt.h. The admitted PPM applications are paused
|
||||
with separate 250 ms leases, never broadcast. Firmware resumes PPM on expiry,
|
||||
including its missing-pulse timeout. USB commands alone cannot rely on the
|
||||
global timeout: receiver pulses reset it even while app output is disabled.
|
||||
"""
|
||||
import base64
|
||||
from contextlib import ExitStack
|
||||
import hashlib
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .configuration import decode
|
||||
from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS
|
||||
from .speed_hold import SpeedHold
|
||||
from .temporary_limits import TemporaryLimits
|
||||
|
||||
|
||||
class Rejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class LimitExceeded(Rejected):
|
||||
def __init__(self, field, value, low, high, label, unit, scale=1):
|
||||
self.violation = {"field": field, "value": value, "minimum": low, "maximum": high}
|
||||
number = lambda v: format(v * scale, ".3g").replace(".", ",")
|
||||
super().__init__(f"Тест остановлен: {label} {number(value)} {unit}; диапазон проверки {number(low)}…{number(high)} {unit}.")
|
||||
|
||||
|
||||
def check_configuration(identity, motor, app):
|
||||
if (identity["version"], identity["hardware"], identity["test_firmware"], identity["hardware_type"]) != ("5.02", "75_300_R2", 0, 0):
|
||||
raise Rejected("Тест поддерживает только проверенный профиль VESC 75_300_R2 / 5.02.")
|
||||
if (motor["motor_type"] != 2 or app["app_to_use"] not in (1, 4)
|
||||
or app["timeout_msec"] > 1000 or app["timeout_msec"] < 100
|
||||
or app["timeout_brake_current"] != 0 or app["app_ppm_conf.ctrl_type"] != 4
|
||||
or not 0.01 <= app["app_ppm_conf.hyst"] <= 0.3):
|
||||
raise Rejected("Настройки FOC, PPM или тайм-аута не подходят для короткой проверки.")
|
||||
if not 2 <= motor["l_current_max"] <= 100 or not 2 <= motor["l_in_current_max"] <= 100:
|
||||
raise Rejected("Нужна проверка токовых ограничений.")
|
||||
|
||||
|
||||
def check_values(value, moving=False, current_a=2, motor=None):
|
||||
current_limit = max(5, current_a * 1.2 + 2) if moving else 1
|
||||
speed_limit = TEST_LIMITS["max_erpm"] if moving else 30
|
||||
duty_limit = TEST_LIMITS["max_duty"] if moving else 0.01
|
||||
if moving and motor is not None:
|
||||
current_limit = min(current_limit, motor["l_current_max"])
|
||||
speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"])
|
||||
duty_limit = min(duty_limit, motor["l_max_duty"])
|
||||
bounds = (
|
||||
("fault_code", 0, 0, "код ошибки VESC", "", 1),
|
||||
("input_voltage_v", 20, 60, "напряжение питания", "В", 1),
|
||||
("mos_temperature_c", 0, 65, "температура контроллера", "°C", 1),
|
||||
("motor_current_a", -current_limit, current_limit, "ток мотора", "А", 1),
|
||||
("erpm", -speed_limit, speed_limit, "электрические обороты", "ERPM", 1),
|
||||
("duty", -duty_limit, duty_limit, "заполнение PWM", "%", 100),
|
||||
)
|
||||
for field, low, high, label, unit, scale in bounds:
|
||||
actual = value[field]
|
||||
if not math.isfinite(actual) or not low <= actual <= high:
|
||||
raise LimitExceeded(field, actual, low, high, label, unit, scale)
|
||||
|
||||
|
||||
class MotorTest:
|
||||
def __init__(self, service, atomic, utc, sleep=time.sleep, monotonic=time.monotonic):
|
||||
self.service, self.atomic, self.utc = service, atomic, utc
|
||||
self.sleep, self.monotonic = sleep, monotonic
|
||||
self.stop = threading.Event()
|
||||
self.stop_lock = threading.Lock()
|
||||
self.cancelled_at = None
|
||||
self.active = False
|
||||
self.mode = None
|
||||
self.limits = TemporaryLimits(service, atomic)
|
||||
self.authority = service.root / "motor-authority.json"
|
||||
# A process restart during a test cannot silently grant another pulse.
|
||||
if self.authority.exists():
|
||||
import json
|
||||
self.latched = json.loads(self.authority.read_text()).get("state") != "ready"
|
||||
else:
|
||||
self.latched = False
|
||||
|
||||
def state(self, value):
|
||||
self.atomic(self.authority, {"state": value, "observed_at": self.utc()})
|
||||
self.latched = value == "rc"
|
||||
|
||||
def cancel(self):
|
||||
with self.stop_lock:
|
||||
self.cancelled_at = datetime.now(timezone.utc)
|
||||
self.stop.set()
|
||||
|
||||
def neutral(self, devices):
|
||||
for device in devices:
|
||||
value = ppm(device.link.query(31, timeout=0.06))
|
||||
if abs(value["level"]) > 0.02:
|
||||
self.state("rc")
|
||||
raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.")
|
||||
|
||||
def run(self, command, devices, target, release=False):
|
||||
if not 1 <= len(devices) <= 128 or len({d.id for d in devices}) != len(devices):
|
||||
raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.")
|
||||
duration = command["parameters"]["duration_s"]
|
||||
current_a = command["parameters"]["current_a"]
|
||||
group_mode = command["action_id"] == "vesc.drive.run"
|
||||
speed_mode = command["action_id"] in ("vesc.motor.run", "vesc.drive.run")
|
||||
moving = [target]
|
||||
if group_mode:
|
||||
from .group_test import targets
|
||||
try: moving = targets(self.service, command["parameters"], devices, target)
|
||||
except ValueError as error: raise Rejected(str(error)) from error
|
||||
hall_mode = command["action_id"] == "vesc.hall.measure"
|
||||
foc_mode = command["action_id"] == "vesc.foc.calibrate"
|
||||
budget = duration + (20 if speed_mode else 0)
|
||||
if self.latched and not release:
|
||||
raise Rejected("Управление удерживается за пультом. Верните его явно после нейтрали.")
|
||||
requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
|
||||
if (datetime.now(timezone.utc) - requested).total_seconds() > 10:
|
||||
raise Rejected("Команда устарела до начала проверки. Повторите запрос.")
|
||||
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||
def preflight():
|
||||
if self.stop.is_set(): raise Rejected("Проверка отменена.")
|
||||
if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5:
|
||||
raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.")
|
||||
attachments = {d.attachment for d in devices}
|
||||
def unchanged():
|
||||
if set(self.service.discover_fn()) != attachments:
|
||||
raise Rejected("Подключение VESC изменилось. Обновите устройства.")
|
||||
with self.stop_lock:
|
||||
if self.cancelled_at is not None and requested <= self.cancelled_at:
|
||||
raise Rejected("Проверка отменена до начала исполнения.")
|
||||
self.stop.clear()
|
||||
with ExitStack() as stack:
|
||||
for device in sorted(devices, key=lambda d: d.id):
|
||||
stack.enter_context(device.lock)
|
||||
recovered = None
|
||||
if release and (self.service.root / "calibration-pending.json").exists():
|
||||
from .foc_calibration import reconcile
|
||||
unchanged()
|
||||
try: recovered = reconcile(self, devices)
|
||||
except (OSError, ValueError, TimeoutError, KeyError) as error:
|
||||
raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error
|
||||
identities, applications, backups, originals = {}, {}, [], {}
|
||||
target_motor, target_raw = None, None
|
||||
for device in devices:
|
||||
preflight()
|
||||
if device.link is None: raise Rejected("Один из контроллеров отключён.")
|
||||
if self.limits.pending(device):
|
||||
raise Rejected("Восстановление временных токовых пределов ещё не подтверждено.")
|
||||
if (self.service.root / "hall-pending.json").exists():
|
||||
raise Rejected("Завершение предыдущего измерения Холла не подтверждено. Нужна проверка состояния VESC.")
|
||||
if (self.service.root / "calibration-pending.json").exists():
|
||||
raise Rejected("Завершение предыдущей калибровки не подтверждено. Новое движение заблокировано.")
|
||||
identity = firmware(device.link.query(0))
|
||||
if identity != device.identity: raise Rejected("Идентичность контроллера изменилась.")
|
||||
configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))}
|
||||
originals[device.id] = configs
|
||||
motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application")
|
||||
check_configuration(identity, motor, app)
|
||||
if device in moving:
|
||||
if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]):
|
||||
raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.")
|
||||
if not (motor["l_max_erpm"] > 0 and motor["l_min_erpm"] < 0 and 0 < motor["l_max_duty"] <= 1):
|
||||
raise Rejected("Нужна проверка настроенных пределов оборотов и PWM.")
|
||||
target_motor = motor
|
||||
target_raw = configs["motor"]
|
||||
if foc_mode and any(abs(motor[key]) <= 0.001 for key in ("l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm")):
|
||||
raise Rejected("Для калибровки нужны ненулевые сохранённые пределы питания и настройки запуска FOC.")
|
||||
if speed_mode and command["parameters"]["erpm"] > min(SPEED_LIMITS["max_erpm"], motor["l_max_erpm"] * 0.8):
|
||||
raise Rejected("Заданная скорость превышает настроенный диапазон контроллера.")
|
||||
if speed_mode and command["parameters"]["erpm"] < motor["s_pid_min_erpm"]:
|
||||
raise Rejected(f"Минимальная скорость регулятора этого VESC: {motor['s_pid_min_erpm']:g} ERPM.")
|
||||
check_values(values(device.link.query(4)))
|
||||
identities[device.id], applications[device.id] = identity, app
|
||||
identifier = "op_" + uuid.uuid4().hex
|
||||
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
||||
"identity": identity, "operation_id": identifier, "parent_operation_id": command["operation_id"],
|
||||
"observed_at": self.utc(), "monotonic_at": self.monotonic(), "decoded": False,
|
||||
"configs": {kind: {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "signature_hex": raw[1:5].hex()}
|
||||
for kind, raw in configs.items()}}
|
||||
self.atomic(self.service.root / ("backup_" + identifier + ".json"), backup)
|
||||
self.service.archive.add("local", backup)
|
||||
device.backup = {"observed_at": backup["observed_at"], "operation_id": identifier,
|
||||
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in backup["configs"].items()}}
|
||||
backups.append(identifier)
|
||||
ids = {int(app["controller_id"]) for app in applications.values()}
|
||||
if len(ids) != len(devices): raise Rejected("У контроллеров совпали CAN ID. Нужна проверка схемы.")
|
||||
for device in devices:
|
||||
preflight()
|
||||
# CAN ping includes up to 5 ms transmit wait plus 10 ms reply
|
||||
# wait for every ID; scheduling can exceed the former 4 s cap.
|
||||
try:
|
||||
peers = device.link.query(62, timeout=8)
|
||||
except TimeoutError as error:
|
||||
raise Rejected("Проверка CAN не завершилась вовремя. Ток не подавался.") from error
|
||||
if not peers or peers[0] != 62 or not set(peers[1:]).issubset(ids):
|
||||
raise Rejected("На CAN обнаружено другое устройство. Нужна проверка схемы.")
|
||||
if foc_mode and len(peers) != 1:
|
||||
raise Rejected("Этот профиль калибрует VESC по отдельным USB без CAN-соседей. Требуется профиль связанного CAN-борта.")
|
||||
for _ in range(10):
|
||||
preflight()
|
||||
unchanged()
|
||||
self.neutral(devices)
|
||||
self.sleep(0.1)
|
||||
if release:
|
||||
self.state("ready")
|
||||
return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered}
|
||||
if hall_mode:
|
||||
from .hall_detection import measure
|
||||
return measure(self, devices, target, target_raw, backups, unchanged)
|
||||
if foc_mode:
|
||||
from .foc_calibration import calibrate
|
||||
return calibrate(self, command, devices, target, originals, backups, unchanged)
|
||||
now = datetime.now(timezone.utc)
|
||||
if (deadline - now).total_seconds() < budget + 1:
|
||||
raise Rejected("Команда устарела до запуска. Повторите проверку.")
|
||||
if group_mode:
|
||||
from .group_test import run
|
||||
return run(self, command, devices, moving, originals, backups, unchanged)
|
||||
self.state("testing")
|
||||
self.active = True
|
||||
self.mode = "speed" if speed_mode else "current"
|
||||
started = self.monotonic()
|
||||
samples, outcome, cleanup = [], "duration", {}
|
||||
limit_violation = None
|
||||
failure = None
|
||||
claimed = False
|
||||
sent_current = 0.0
|
||||
last_command_at = started
|
||||
stalled_since = None
|
||||
stall_tachometer = None
|
||||
hold = SpeedHold(command["parameters"]["erpm"], duration, started) if speed_mode else None
|
||||
restored = not speed_mode
|
||||
try:
|
||||
if speed_mode:
|
||||
target_motor = self.limits.apply(target, target_raw, current_a)
|
||||
started = self.monotonic()
|
||||
hold = SpeedHold(command["parameters"]["erpm"], duration, started)
|
||||
while self.monotonic() - started < budget:
|
||||
cycle = self.monotonic()
|
||||
if self.stop.is_set():
|
||||
outcome = "stopped"; break
|
||||
unchanged()
|
||||
self.neutral(devices)
|
||||
sample = {"at": self.monotonic() - started, "devices": {}}
|
||||
current = values(target.link.query(4, timeout=0.06))
|
||||
sample["commanded_current_a"] = None
|
||||
sample["devices"][target.id] = current
|
||||
samples.append(sample)
|
||||
check_values(current, moving=True, current_a=current_a, motor=target_motor)
|
||||
if hold:
|
||||
setpoint, done, error = hold.update(self.monotonic(), current)
|
||||
sample.update(phase=hold.phase, rotation_s=hold.rotation_s, commanded_erpm=None)
|
||||
if error: raise Rejected(error)
|
||||
if done: break
|
||||
# A current command is torque, not a speed setpoint. Do not
|
||||
# repeatedly coast/restart at each Hall edge. A hard limit
|
||||
# ends this operation and cannot automatically re-arm it.
|
||||
# Above 5 A require continuing measured movement. Hall/FOC
|
||||
# telemetry is not an independent physical motion sensor.
|
||||
if max(abs(current["motor_current_a"]), sent_current) > TEST_LIMITS["stall_current_a"]:
|
||||
if stalled_since is None:
|
||||
stalled_since = self.monotonic()
|
||||
stall_tachometer = current["tachometer"]
|
||||
elif abs(current["erpm"]) >= 60 and abs(current["tachometer"] - stall_tachometer) >= 3:
|
||||
stalled_since = self.monotonic()
|
||||
stall_tachometer = current["tachometer"]
|
||||
elif self.monotonic() - stalled_since >= TEST_LIMITS["stall_timeout_s"]:
|
||||
raise Rejected("Тест остановлен: при токе выше 5 А движение не подтверждается 2 секунды. Проверьте мотор и датчики.")
|
||||
else:
|
||||
stalled_since = None
|
||||
if self.monotonic() - cycle > 0.12:
|
||||
raise Rejected("Связь слишком медленная для короткой проверки.")
|
||||
# Refresh local leases only after fresh neutral/telemetry. A
|
||||
# delayed process never sends current after an expired lease.
|
||||
claimed = True
|
||||
for device in devices: device.link.test_command("claim")
|
||||
for device in devices:
|
||||
if device is not target: device.link.test_command("release")
|
||||
if self.stop.is_set():
|
||||
outcome = "stopped"; break
|
||||
if self.monotonic() - cycle > 0.16:
|
||||
raise Rejected("Связь слишком медленная для короткой проверки.")
|
||||
if self.monotonic() - started >= budget:
|
||||
if hold: raise Rejected("Истёк общий срок проверки; время вращения не набрано.")
|
||||
break
|
||||
if hold:
|
||||
target.link.test_speed(setpoint)
|
||||
sample["commanded_erpm"] = setpoint
|
||||
else:
|
||||
elapsed = self.monotonic() - last_command_at
|
||||
sent_current = round(min(current_a, max(0.5, sent_current + elapsed * TEST_LIMITS["current_ramp_a_per_s"])), 3) if sent_current else 0.5
|
||||
target.link.test_current(sent_current)
|
||||
last_command_at = self.monotonic()
|
||||
sample["commanded_current_a"] = None if hold else sent_current
|
||||
self.sleep(max(0, 0.05 - (self.monotonic() - cycle)))
|
||||
except (OSError, ValueError, TimeoutError) as error:
|
||||
failure = {"type": type(error).__name__, "message": str(error), "elapsed_s": self.monotonic()-started}
|
||||
failure["native_rpc"] = getattr(error, "native_rpc", None)
|
||||
failure["native_history"] = getattr(error, "native_history", [])
|
||||
outcome = str(error) if isinstance(error, Rejected) else "Обмен с VESC прерван. Проверка остановлена."
|
||||
if isinstance(error, LimitExceeded): limit_violation = error.violation
|
||||
finally:
|
||||
# Never reconnect to send a stop to a replacement device. Leases
|
||||
# expire in firmware even if USB or this process is lost.
|
||||
if claimed:
|
||||
for device in devices:
|
||||
try:
|
||||
device.link.test_command("release")
|
||||
cleanup[device.id] = "zero_current_sent"
|
||||
except (OSError, ValueError, TimeoutError):
|
||||
cleanup[device.id] = "unconfirmed"
|
||||
self.active = False
|
||||
self.mode = None
|
||||
if not self.latched: self.state("ready")
|
||||
self.sleep(0.3)
|
||||
after = {}
|
||||
for device in devices:
|
||||
try: after[device.id] = values(device.link.query(4, timeout=0.1))
|
||||
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||
confirmed = all(value is not None and abs(value["motor_current_a"]) <= 1 for value in after.values())
|
||||
if claimed and not confirmed:
|
||||
self.state("rc")
|
||||
outcome = "Снятие тока не подтверждено. Проверьте фактическое состояние моторов."
|
||||
if speed_mode:
|
||||
try: restored = self.limits.restore(target)
|
||||
except (OSError, ValueError, TimeoutError):
|
||||
self.state("rc")
|
||||
outcome += " Восстановление прежних токовых пределов не подтверждено; новые запуски заблокированы."
|
||||
if outcome == "duration" and hold.rotation_s < duration:
|
||||
outcome = "Проверка закончилась до набора заданного времени вращения."
|
||||
return {"observed_at": self.utc(), "device_id": target.id, "current_a": current_a,
|
||||
"mode": "speed" if speed_mode else "current", "rotation_s": hold.rotation_s if hold else None,
|
||||
"erpm_target": hold.erpm if hold else None, "limits_restored": restored,
|
||||
"current_ramp_a_per_s": TEST_LIMITS["current_ramp_a_per_s"],
|
||||
"test_limits": TEST_LIMITS,
|
||||
"duration_limit_s": duration, "outcome": outcome, "samples": samples,
|
||||
"limit_violation": limit_violation, "failure": failure,
|
||||
"release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Private lifecycle/RPC boundary to unmodified upstream VESC Tool C++.
|
||||
|
||||
No wire encoding or calibration algorithm lives here. The native process owns
|
||||
one exact USB attachment and sends all commands through upstream Commands.
|
||||
"""
|
||||
import base64
|
||||
from collections import deque
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .serial import check_attachment
|
||||
|
||||
|
||||
class NativeLink:
|
||||
def __init__(self, attachment):
|
||||
self.attachment = attachment
|
||||
self.process = None
|
||||
self.buffer = b""
|
||||
self.sequence = 0
|
||||
self.hall_pending = False
|
||||
self.history = deque(maxlen=32)
|
||||
self.check()
|
||||
root = Path("/usr/lib/mission-core-vesc/native")
|
||||
config = Path("/run/mission-core-vesc/native") / hashlib.sha256(attachment.binding.encode()).hexdigest()[:24]
|
||||
config.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
env = dict(os.environ, QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(config),
|
||||
XDG_CACHE_HOME=str(config / "cache"), LD_LIBRARY_PATH=str(root / "lib"),
|
||||
QT_PLUGIN_PATH=str(root / "plugins"))
|
||||
# Preserve native startup diagnostics in the private runtime directory.
|
||||
with (config / "engine.log").open("wb") as diagnostic:
|
||||
self.process = subprocess.Popen([str(root / "bin/mission-core-vesc-engine"), "/dev/" + attachment.tty],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostic,
|
||||
env=env, bufsize=0, close_fds=True)
|
||||
try:
|
||||
hello = self._receive(time.monotonic() + 6)
|
||||
if hello.get("ready") is not True or not hello.get("engine", {}).get("connected"):
|
||||
raise OSError("Native VESC Tool did not connect")
|
||||
self.engine = hello["engine"]
|
||||
self.check()
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
def check(self):
|
||||
check_attachment(self.attachment)
|
||||
|
||||
def _receive(self, deadline):
|
||||
while b"\n" not in self.buffer:
|
||||
if not self.alive or not select.select([self.process.stdout], [], [], max(0, deadline-time.monotonic()))[0]:
|
||||
raise TimeoutError("Native VESC Tool response timed out")
|
||||
data = os.read(self.process.stdout.fileno(), 65536)
|
||||
if not data: raise OSError("Native VESC Tool exited")
|
||||
self.buffer += data
|
||||
if len(self.buffer) > 2 * 1024 * 1024: raise ValueError("Native response exceeds bound")
|
||||
line, self.buffer = self.buffer.split(b"\n", 1)
|
||||
return json.loads(line)
|
||||
|
||||
def rpc(self, method, timeout=2, **parameters):
|
||||
self.sequence += 1
|
||||
request = json.dumps({"id": self.sequence, "method": method, **parameters}, allow_nan=False).encode()+b"\n"
|
||||
if len(request) > 65536: raise ValueError("Native request exceeds bound")
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
trace = {"method": method, "command": parameters.get("command"),
|
||||
"timeout_ms": parameters.get("timeout_ms", timeout*1000)}
|
||||
try:
|
||||
trace["stage"] = "attachment_before"
|
||||
self.check()
|
||||
trace["attachment_before_ms"] = (time.monotonic()-started)*1000
|
||||
trace["stage"] = "request_write"
|
||||
if not self.alive: raise OSError("Native VESC Tool is not running")
|
||||
if not select.select([], [self.process.stdin], [], max(0, deadline-time.monotonic()))[1]:
|
||||
raise TimeoutError("Native VESC Tool request timed out")
|
||||
if os.write(self.process.stdin.fileno(), request) != len(request):
|
||||
raise OSError("Native request write incomplete")
|
||||
sent_at = time.monotonic()
|
||||
trace["request_write_ms"] = (sent_at-started)*1000-trace["attachment_before_ms"]
|
||||
trace["stage"] = "native_response"
|
||||
response = self._receive(deadline)
|
||||
received_at = time.monotonic()
|
||||
trace["native_response_ms"] = (received_at-sent_at)*1000
|
||||
if not isinstance(response, dict): raise ValueError("Invalid native response")
|
||||
if response.get("id") != self.sequence: raise ValueError("Native response identity mismatch")
|
||||
if response.get("ok") is not True:
|
||||
if isinstance(response.get("diagnostics"), dict):
|
||||
trace["transport"] = response["diagnostics"]
|
||||
raise OSError(response.get("error", "Native operation failed"))
|
||||
trace["stage"] = "attachment_after"
|
||||
self.check()
|
||||
trace["attachment_after_ms"] = (time.monotonic()-received_at)*1000
|
||||
trace["stage"] = "complete"
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict): raise ValueError("Invalid native result")
|
||||
trace.update(ok=True, elapsed_ms=(time.monotonic()-started)*1000)
|
||||
self.last_rpc = trace
|
||||
if hasattr(self, "history"): self.history.append(trace)
|
||||
return result
|
||||
except (OSError, ValueError, TimeoutError) as error:
|
||||
# An unconfirmed reply is never silently retried on the same stream.
|
||||
trace.update(ok=False, elapsed_ms=(time.monotonic()-started)*1000,
|
||||
process_alive=self.alive, error=str(error))
|
||||
try: self.check(); trace["attachment_present"] = True
|
||||
except OSError: trace["attachment_present"] = False
|
||||
self.last_rpc = trace
|
||||
if hasattr(self, "history"): self.history.append(trace)
|
||||
error.native_rpc = trace
|
||||
error.native_history = list(getattr(self, "history", []))
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def query(self, command, timeout=2):
|
||||
result = self.rpc("query", timeout=timeout+0.1, command=command, timeout_ms=max(20, int(timeout*1000)))
|
||||
return base64.b64decode(result["payload"], validate=True)
|
||||
|
||||
def test_command(self, action):
|
||||
if action not in ("claim", "release"): raise ValueError("Unknown control action")
|
||||
self.rpc("lease" if action == "claim" else "release", timeout=0.1)
|
||||
|
||||
def test_current(self, current_a):
|
||||
self.rpc("current", timeout=0.1, current_a=current_a)
|
||||
|
||||
def test_speed(self, erpm):
|
||||
self.rpc("rpm", timeout=0.1, erpm=erpm)
|
||||
|
||||
def set_temporary_limits(self, config):
|
||||
from .temporary_limits import FIELDS
|
||||
self.rpc("limits", timeout=2.2, parameters={k: config[k] for k in FIELDS})
|
||||
|
||||
def configuration(self):
|
||||
return self.rpc("configuration", timeout=5)
|
||||
|
||||
def detect_hall(self):
|
||||
if self.hall_pending: raise ValueError("Hall measurement already pending")
|
||||
self.hall_pending = True
|
||||
self.rpc("hall_start", timeout=0.2, current_a=5)
|
||||
|
||||
def calibrate_foc(self, max_power_loss_w):
|
||||
self.rpc("foc_start", timeout=0.2, max_power_loss_w=max_power_loss_w)
|
||||
|
||||
def procedure_result(self):
|
||||
return self.rpc("procedure_result", timeout=0.2)
|
||||
|
||||
@property
|
||||
def hall_result(self):
|
||||
if not self.hall_pending: return None
|
||||
state = self.rpc("procedure_result", timeout=0.1)
|
||||
result = state.get("result", {})
|
||||
if state.get("running") or not result.get("completed"): return None
|
||||
return base64.b64decode(result["payload"], validate=True)
|
||||
|
||||
def close(self):
|
||||
process, self.process = self.process, None
|
||||
if process is None: return
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill(); process.wait(timeout=1)
|
||||
for stream in (process.stdin, process.stdout):
|
||||
if stream: stream.close()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Bounded VESC serial reader.
|
||||
|
||||
Wire reference: vedderb/vesc_tool dc53c658cbb89a947246034f7a00149cf79abdfc,
|
||||
packet.cpp, commands.cpp and datatypes.h. No arbitrary packet transmit API.
|
||||
Config payloads remain opaque until their exact firmware schema is admitted.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import struct
|
||||
|
||||
READ_COMMANDS = frozenset({0, 4, 14, 17, 31, 62})
|
||||
MAX_PACKET = 10000
|
||||
|
||||
|
||||
def request(command):
|
||||
if type(command) is not int or command not in READ_COMMANDS:
|
||||
raise ValueError("Unsupported read command")
|
||||
data = bytes([command])
|
||||
return b"\x02\x01" + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
TEST_LIMITS = {"min_current_a": 0.5, "max_current_a": 30, "min_duration_s": 0.5,
|
||||
"max_duration_s": 30, "current_ramp_a_per_s": 2.0,
|
||||
"continuous_current": True, "max_erpm": 6000, "max_duty": 0.25,
|
||||
"stall_current_a": 5, "stall_timeout_s": 2.0}
|
||||
|
||||
# A separate action/capability keeps old clients from silently changing modes.
|
||||
SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600,
|
||||
"startup_timeout_s": 15, "settle_s": 1, "speed_tolerance": 0.15,
|
||||
"lost_speed_timeout_s": 2, "duration_basis": "measured_speed"}
|
||||
|
||||
|
||||
def frame(data):
|
||||
if not 1 <= len(data) <= 255: raise ValueError("Invalid bounded command size")
|
||||
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
def speed_packet(erpm):
|
||||
if type(erpm) not in (int, float) or not 0 <= erpm <= SPEED_LIMITS["max_erpm"]:
|
||||
raise ValueError("Invalid test speed")
|
||||
return frame(bytes([8]) + struct.pack(">i", round(erpm)))
|
||||
|
||||
|
||||
def hall_packet():
|
||||
# FW 5.02 native FOC Hall sweep, fixed 5 A; no store and no CAN forwarding.
|
||||
return frame(bytes([28]) + struct.pack(">i", 5000))
|
||||
|
||||
|
||||
def current_packet(current_a):
|
||||
if type(current_a) not in (int, float) or not TEST_LIMITS["min_current_a"] <= current_a <= TEST_LIMITS["max_current_a"]:
|
||||
raise ValueError("Test current must be between 0.5 and 30 A")
|
||||
data = b"\x06" + struct.pack(">i", round(current_a * 1000))
|
||||
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
def test_packet(action):
|
||||
"""Only fixed volatile commands; no arbitrary current/lease/packet."""
|
||||
data = {"current": b"\x06" + struct.pack(">i", 2000),
|
||||
"release": b"\x06" + bytes(4),
|
||||
"claim": b"\x3f\x00" + struct.pack(">i", 250)}[action]
|
||||
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
def ppm(packet):
|
||||
if len(packet) != 9 or packet[0] != 31:
|
||||
raise ValueError("Incomplete PPM reply")
|
||||
level, pulse = struct.unpack(">ii", packet[1:])
|
||||
if not -1100000 <= level <= 1100000 or not 0 <= pulse <= 3000000:
|
||||
raise ValueError("Invalid PPM values")
|
||||
return {"level": level / 1e6, "pulse_ms": pulse / 1e6}
|
||||
|
||||
|
||||
class Decoder:
|
||||
def __init__(self):
|
||||
self.buffer = bytearray()
|
||||
|
||||
def feed(self, data):
|
||||
if len(self.buffer) + len(data) > MAX_PACKET * 2 + 16:
|
||||
self.buffer.clear()
|
||||
raise ValueError("Serial buffer overflow")
|
||||
self.buffer.extend(data)
|
||||
packets = []
|
||||
while self.buffer:
|
||||
start = self.buffer[0]
|
||||
if start not in (2, 3, 4):
|
||||
del self.buffer[0]
|
||||
continue
|
||||
width = start - 1
|
||||
if len(self.buffer) < width + 1:
|
||||
break
|
||||
size = int.from_bytes(self.buffer[1:width + 1], "big")
|
||||
if not 1 <= size <= MAX_PACKET:
|
||||
del self.buffer[0]
|
||||
continue
|
||||
end = width + 1 + size
|
||||
if len(self.buffer) < end + 3:
|
||||
break
|
||||
payload = bytes(self.buffer[width + 1:end])
|
||||
if self.buffer[end + 2] != 3 or int.from_bytes(self.buffer[end:end + 2], "big") != binascii.crc_hqx(payload, 0):
|
||||
del self.buffer[0]
|
||||
continue
|
||||
del self.buffer[:end + 3]
|
||||
packets.append(payload)
|
||||
return packets
|
||||
|
||||
|
||||
def firmware(packet):
|
||||
if len(packet) < 4 or packet[0] != 0:
|
||||
raise ValueError("Incomplete firmware reply")
|
||||
end = packet.find(b"\0", 3, 132)
|
||||
if end <= 3 or len(packet) < end + 13:
|
||||
raise ValueError("Firmware has no complete hardware identity")
|
||||
name = packet[3:end].decode("ascii")
|
||||
if not all(32 <= ord(c) < 127 for c in name):
|
||||
raise ValueError("Invalid hardware name")
|
||||
uuid = packet[end + 1:end + 13]
|
||||
if uuid in (bytes(12), b"\xff" * 12):
|
||||
raise ValueError("Invalid hardware UUID")
|
||||
optional = packet[end + 13:]
|
||||
return {"major": packet[1], "minor": packet[2], "version": f"{packet[1]}.{packet[2]:02d}",
|
||||
"hardware": name, "uuid": uuid.hex(),
|
||||
"test_firmware": optional[1] if len(optional) > 1 else None,
|
||||
"hardware_type": optional[2] if len(optional) > 2 else None,
|
||||
"custom_configs": optional[3] if len(optional) > 3 else None}
|
||||
|
||||
|
||||
def values(packet):
|
||||
if len(packet) < 54 or packet[0] != 4:
|
||||
raise ValueError("Incomplete telemetry reply")
|
||||
fields = (("mos_temperature_c", "h", 10), ("motor_temperature_c", "h", 10),
|
||||
("motor_current_a", "i", 100), ("input_current_a", "i", 100),
|
||||
("id_current_a", "i", 100), ("iq_current_a", "i", 100),
|
||||
("duty", "h", 1000), ("erpm", "i", 1), ("input_voltage_v", "h", 10),
|
||||
("amp_hours", "i", 10000), ("amp_hours_charged", "i", 10000),
|
||||
("watt_hours", "i", 10000), ("watt_hours_charged", "i", 10000),
|
||||
("tachometer", "i", 1), ("tachometer_abs", "i", 1), ("fault_code", "B", 1))
|
||||
result, offset = {}, 1
|
||||
for key, fmt, scale in fields:
|
||||
result[key] = struct.unpack_from(">" + fmt, packet, offset)[0] / scale
|
||||
offset += struct.calcsize(fmt)
|
||||
# Optional tail is ordered, not an independent set of guessed offsets.
|
||||
for key, fmt, scale in (("position_deg", "i", 1e6), ("can_id", "B", 1),
|
||||
("mos1_c", "h", 10), ("mos2_c", "h", 10), ("mos3_c", "h", 10),
|
||||
("vd_v", "i", 1000), ("vq_v", "i", 1000), ("status", "B", 1)):
|
||||
size = struct.calcsize(fmt)
|
||||
if len(packet) < offset + size:
|
||||
break
|
||||
result[key] = struct.unpack_from(">" + fmt, packet, offset)[0] / scale
|
||||
offset += size
|
||||
if "status" in result:
|
||||
flags = int(result.pop("status"))
|
||||
result.update(timeout=bool(flags & 1), kill_switch=bool(flags & 2))
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
Firmware 5.02 configuration definitions from official vedderb/vesc_tool commit 01d5f10901116c311e3fb84d5a1541f663d3ce20, res/config/5.02. Copyright Benjamin Vedder and contributors; see VESC_TOOL_LICENSE. Definitions are unchanged.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user