merge: integrate final rover control with operator simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:48:42 +03:00
88 changed files with 6963 additions and 97 deletions
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
async function moduleAt(path) {
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
}
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
const profile={...base,deadband:0};
const arcade={...profile,mode:'arcade'};
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
const motors=['a','b','c','d'];
function frame(now,values=axes(),extra={}) {
return {now,link:{state:'live',at:now},
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
}
function ready(p=base) {
const model=new AuthorityModel(p,motors,policy,'boot-A');
for(let t=0;t<=200;t+=50)model.step(frame(t));
return model;
}
function core() {
const model=ready();
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.ok(token);return {model,token};
}
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
test('tank keeps the two motor sides independent, including reversal',()=>{
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
});
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
});
test('selected stick defines the axes, not the number or position of motors',()=>{
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
});
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
});
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
}
});
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
});
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
for(const n of [2,4,10]){
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
const result=motorDemands({left:.3,right:-.6},bindings);
assert.equal(Object.keys(result).length,n);
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
}
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
assert.throws(()=>motorDemands({left:1,right:1},[b]));
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
});
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
assert.equal(model.step(frame(1200)).state,'rc-ready');
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
});
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
const {model,token}=core();
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
const second=model.step(frame(1750,axes(.5)));
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
assert.equal(model.step(frame(1800)).state,'rc-manual');
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
});
test('neutral of one channel or a single moving member never completes rearming',()=>{
for(const kind of ['axis','drive']){
const {model}=core();model.step(frame(300,axes(1)));
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
}
});
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
});
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
});
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
}
});
test('one repeated neutral sample cannot qualify stable neutral',()=>{
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
assert.equal(model.step(f).state,'hold');
});
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
});
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
}
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
});
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
});
test('premature or replayed Core request does not acquire authority after neutral',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
});
test('reboot cannot restore authority from serialized profile or old grant',()=>{
const {token}=core(), model=ready();
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
const next=new AuthorityModel(base,motors,policy,'boot-B');
for(let t=0;t<=200;t+=50)next.step(frame(t));
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.notEqual(grant.token,token);
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
});
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
});
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
const model=ready({...base,mode:'arcade',stick:'right'});
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
test('arcade mixes directions and opposite keys cancel',()=>{
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
});
test('tank sides and backwards remain independent',()=>{
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
});
test('invalid stored settings never grant motion or invalid limits',()=>{
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
});
@@ -0,0 +1,18 @@
import {build} from 'esbuild';
import {mkdir,writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {resolve,join} from 'node:path';
const root=fileURLToPath(new URL('.',import.meta.url));
const destination=process.argv[2];
if(!destination)throw Error('Pass an artifact output directory; no server is started.');
const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true,
format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'},
alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')},
nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'});
const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('</script','<\\/script');
const css=result.outputFiles.find(f=>f.path.endsWith('.css')).text.replaceAll('</style','<\\/style');
await mkdir(destination,{recursive:true});
const html=`<!doctype html><html lang="ru" data-nodedc-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"><title>Mission Core · Профили управления · Прототип</title><style>${css}</style></head><body data-nodedc-ui><div id="root"></div><script>${js}</script></body></html>`;
await writeFile(join(destination,'rover-control-preview.html'),html);
console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'}));
@@ -0,0 +1,69 @@
import {useState} from 'react';
import {createRoot} from 'react-dom/client';
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import '@nodedc/ui-core/styles.css';
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
import {trace} from './trace';
import './preview.css';
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
const percent=(n:number)=>`${Math.round(n*100)}%`;
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
const storageKey='missioncore.rover-control-preview.v1';
function load():ControlProfile {
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
catch{return {...defaultProfile};}
}
function App(){
const [profile,setProfile]=useState(load);
const [axes,setAxes]=useState<Axes>(emptyAxes);
const [open,setOpen]=useState(['settings','preview']);
const [saved,setSaved]=useState('');
const [light,setLight]=useState(false);
const change=(patch:Partial<ControlProfile>)=>{
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
};
const result=mix(profile,axes);
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
function save(){
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
}
function download(){
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
return <main data-nodedc-ui>
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
{saved&&<p role="status">{saved}</p>}
</SettingsCard>},
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
</SettingsCard>},
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
</SettingsCard>},
]}/>
</main>;
}
document.documentElement.dataset.nodedcTheme='dark';
createRoot(document.getElementById('root')!).render(<App/>);
@@ -0,0 +1,16 @@
/* Domain composition only; all controls come from the Design Guideline. */
body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); }
main { max-width:960px; margin:0 auto; padding:32px 24px 64px; }
main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; }
p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); }
main>header p { font-size:var(--nodedc-font-size-xs); }
.notice { margin-bottom:24px; }
.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; }
.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; }
.demands>div { display:flex; flex-direction:column; gap:8px; }
.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; }
.demands span { font-size:var(--nodedc-font-size-sm); }
.trace { display:grid; gap:20px; }
.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); }
@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} }
@@ -0,0 +1,38 @@
import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority';
import {type ControlProfile} from '../../../../packages/rover-control/src/profile';
/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */
export function trace(profile: ControlProfile): {label: string; decision: Decision}[] {
const model = new AuthorityModel(profile, ['left', 'right'], {
maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'],
}, 'preview');
const rows: {label:string;decision:Decision}[] = [];
let now = -50, token = '';
function step(value=0, link: 'live'|'lost'='live', core=false, request=false) {
now+=50;
const axes = {leftY:0,rightY:0,leftX:0,rightX:0};
axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value;
return model.step({now, link:{state:link,at:now},
axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])),
drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}},
requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined,
command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined,
});
}
const add=(label:string,decision:Decision)=>rows.push({label,decision});
for(let i=0;i<5;i++)step();
const grant=step(0,'live',false,true);token=grant.token??'';
add('Mission Core получил явный допуск',grant);
add('Автономная команда движения',step(0,'live',true));
add('Первое отклонение рычага',step(.8,'live',true));
for(let i=0;i<20;i++)step(.8);
add('Тот же рычаг удерживается',step(.8));
add('Остановка подтверждена, оба рычага отпущены',step());
for(let i=0;i<3;i++)step();
add('Непрерывная нейтраль подтверждена',step());
add('Второе отклонение — ручное движение',step(.8));
add('Запоздавшая команда прежней задачи',step(.8,'live',true));
add('Радиосвязь потеряна',step(0,'lost'));
add('Связь вернулась с отклонённым рычагом',step(.8));
return rows;
}
@@ -0,0 +1,5 @@
{
"extends":"../../tsconfig.app.json",
"compilerOptions":{"incremental":false,"tsBuildInfoFile":null},
"include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"]
}
@@ -0,0 +1,151 @@
package node
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
)
const boardLayoutSchema = "missioncore.board-layout/v1"
var boardSectionIDs = []string{"computer", "settings", "devices"}
type BoardLayout struct {
Schema string `json:"schema"`
Revision int64 `json:"revision"`
OpenSections []string `json:"open_sections"`
}
func validBoardSection(id string) bool {
for _, section := range boardSectionIDs {
if id == section {
return true
}
}
return false
}
func (p *PresentationStore) readBoardLayout() (BoardLayout, error) {
value := BoardLayout{Schema: boardLayoutSchema, OpenSections: append([]string{}, boardSectionIDs...)}
file, err := os.Open(filepath.Join(p.dir, "board-layout.json"))
if errors.Is(err, os.ErrNotExist) {
return value, nil
}
if err != nil {
return BoardLayout{}, err
}
defer file.Close()
d := json.NewDecoder(io.LimitReader(file, 4097))
d.DisallowUnknownFields()
value = BoardLayout{}
if d.Decode(&value) != nil || d.Decode(new(any)) != io.EOF || value.Schema != boardLayoutSchema || value.Revision < 0 || value.Revision >= 1<<53-1 || value.OpenSections == nil {
return BoardLayout{}, errors.New("invalid board layout")
}
seen := map[string]bool{}
for _, id := range value.OpenSections {
if !validBoardSection(id) || seen[id] {
return BoardLayout{}, errors.New("invalid board section")
}
seen[id] = true
}
return value, nil
}
func (p *PresentationStore) saveBoardLayout(value BoardLayout) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
file, err := os.CreateTemp(p.dir, ".board-layout-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(append(data, '\n')); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Rename(file.Name(), filepath.Join(p.dir, "board-layout.json")); err != nil {
return err
}
dir, err := os.Open(p.dir)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Server) boardLayoutRoutes(mux *http.ServeMux) {
p := s.Presentation
mux.HandleFunc("GET /api/presentation/board-layout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.readBoardLayout()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать раскладку борта"})
return
}
reply(w, 200, value)
})
mux.HandleFunc("PATCH /api/presentation/board-layout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return
}
var change struct {
Section string `json:"section"`
Open *bool `json:"open"`
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
d.DisallowUnknownFields()
if d.Decode(&change) != nil || d.Decode(new(any)) != io.EOF || change.Open == nil || !validBoardSection(change.Section) {
reply(w, 400, map[string]string{"error": "Некорректная раскладка борта"})
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.readBoardLayout()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать раскладку борта"})
return
}
next := []string{}
for _, id := range boardSectionIDs {
open := false
for _, saved := range value.OpenSections {
if saved == id {
open = true
}
}
if id == change.Section {
open = *change.Open
}
if open {
next = append(next, id)
}
}
value.OpenSections = next
value.Revision++
if p.saveBoardLayout(value) != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить раскладку борта"})
return
}
reply(w, 200, value)
})
}
@@ -0,0 +1,56 @@
package node
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestBoardLayoutPersistsEmptyAndConcurrentSectionChanges(t *testing.T) {
s, cookie := presentationServer(t)
var wg sync.WaitGroup
for _, id := range boardSectionIDs {
wg.Add(1)
go func(id string) {
defer wg.Done()
w := call(s, "PATCH", "/api/presentation/board-layout", `{"section":"`+id+`","open":false}`, cookie)
if w.Code != 200 {
t.Error(w.Code, w.Body.String())
}
}(id)
}
wg.Wait()
value, err := NewPresentationStore(s.Presentation.dir).readBoardLayout()
if err != nil || value.Revision != 3 || len(value.OpenSections) != 0 || value.OpenSections == nil {
t.Fatal(value, err)
}
info, err := os.Stat(filepath.Join(s.Presentation.dir, "board-layout.json"))
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal(info, err)
}
if w := call(s, "GET", "/api/presentation/board-layout", "", nil); w.Code != 401 {
t.Fatal(w.Code)
}
}
func TestBoardLayoutRejectsInvalidAndPreservesCorruptFile(t *testing.T) {
s, cookie := presentationServer(t)
for _, body := range []string{`{"section":"motor","open":true}`, `{"section":"computer"}`, `{"section":"computer","open":"true"}`, `{"section":"computer","open":true,"extra":0}`} {
if w := call(s, "PATCH", "/api/presentation/board-layout", body, cookie); w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
}
path := filepath.Join(s.Presentation.dir, "board-layout.json")
raw := []byte(`{"schema":"missioncore.board-layout/v1","revision":0,"open_sections":["motor"]}`)
if err := os.WriteFile(path, raw, 0600); err != nil {
t.Fatal(err)
}
if w := call(s, "PATCH", "/api/presentation/board-layout", `{"section":"computer","open":false}`, cookie); w.Code != 500 {
t.Fatal(w.Code)
}
got, _ := os.ReadFile(path)
if string(got) != string(raw) {
t.Fatal("Corrupt file replaced")
}
}
@@ -1,10 +1,12 @@
{
"schema": "missioncore.node.environment/v1",
"revision": "ubuntu-24.04-amd64/2",
"revision": "ubuntu-24.04-amd64/3",
"steps": [
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
{"id":"desktop-autostart","label":"Автозапуск приложения","description":"Открытие окна при входе в рабочий стол","requires":["node-service"]},
{"id":"usb-startup","label":"Обнаружение устройств при загрузке","description":"Автоматическое восстановление незавершённого подключения USB","requires":["platform"]},
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
@@ -152,6 +152,7 @@ func (p *Pairing) remoteHandler() http.Handler {
func (p *Pairing) Run(ctx context.Context) {
go p.channel(ctx)
go p.roverChannel(ctx)
var server *http.Server
endpoint := ""
serverIdentity := ""
@@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error {
}
func (s *Server) presentationRoutes(mux *http.ServeMux) {
s.boardLayoutRoutes(mux)
p := s.Presentation
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
@@ -0,0 +1,249 @@
package node
// Commands stream independently of telemetry. Only the local 20 Hz loop may
// deliver the latest unexpired intent to the single VESC owner.
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"sync"
"time"
)
func (p *Pairing) roverBinding() *CoreBinding {
p.mu.Lock()
defer p.mu.Unlock()
if p.state.Phase != "paired" || p.state.Binding == nil {
return nil
}
b := *p.state.Binding
return &b
}
func sameRoverBinding(a, b *CoreBinding) bool {
return a != nil && b != nil && a.BindingID == b.BindingID && a.Endpoint == b.Endpoint && a.ClientPEM == b.ClientPEM && a.EndpointRevision == b.EndpointRevision
}
func roverWait(ctx context.Context, delay time.Duration) bool {
if delay < 0 {
delay = 0
}
select {
case <-ctx.Done():
return false
case <-time.After(delay):
return true
}
}
func (p *Pairing) roverChannel(ctx context.Context) {
for ctx.Err() == nil {
binding := p.roverBinding()
if binding != nil && p.Sensors != nil {
p.runRoverChannel(ctx, *binding)
}
if !roverWait(ctx, time.Second) {
return
}
}
}
func (p *Pairing) runRoverChannel(parent context.Context, b CoreBinding) {
config, err := bindingTLS(b, p.store.pairingKey())
if err != nil {
return
}
transport := func() *http.Transport {
return &http.Transport{TLSClientConfig: config, Proxy: nil,
MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 10 * time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: 2 * time.Second,
DialContext: (&net.Dialer{Timeout: time.Second}).DialContext}
}
telemetry := &http.Client{Transport: transport(), Timeout: 300 * time.Millisecond, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect forbidden") }}
stream := &http.Client{Transport: transport(), CheckRedirect: telemetry.CheckRedirect}
defer telemetry.CloseIdleConnections()
defer stream.CloseIdleConnections()
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
return
}
relay := hex.EncodeToString(random)
id, _ := p.store.Public()
base := map[string]any{"schema": PairSchema, "node_id": id, "binding_id": b.BindingID,
"core_endpoint": b.Endpoint, "endpoint_revision": b.EndpointRevision, "relay_id": relay}
state := newRoverStreamState(time.Now())
ctx, cancel := context.WithCancel(parent)
var workers sync.WaitGroup
defer func() { cancel(); workers.Wait() }()
workers.Add(2)
go func() { defer workers.Done(); roverTelemetry(ctx, telemetry, b.Endpoint, base, state) }()
go func() { defer workers.Done(); roverCommands(ctx, stream, b.Endpoint, base, state) }()
var model *sensorModel
for i := range sensorModels {
if sensorModels[i].ID == "vesc.controller" {
model = &sensorModels[i]
break
}
}
if model == nil {
return
}
var diagnostic roverDiagnostics
for ctx.Err() == nil && sameRoverBinding(&b, p.roverBinding()) {
select {
case <-state.wake:
default:
}
started := time.Now()
watch, command, stop := state.delivery(started)
frame := roverFrame{}
if command != nil {
frame.Session, _ = command["id"].(string)
frame.Sequence, _ = command["sequence"].(float64)
frame.TTLMS, _ = command["ttl_ms"].(float64)
}
call, done := context.WithTimeout(ctx, 25*time.Millisecond)
result, driverErr := p.Sensors.modelDriver(call, model, "/remote", map[string]any{"watch": watch, "command": command, "relay_id": relay})
done()
frame.DriverMS = time.Since(started).Milliseconds()
if driverErr != nil {
state.stopStream()
frame.Stage = "driver_transport"
state.delivered(map[string]any{}, 0)
} else {
state.delivered(result, stop)
frame.State, _ = result["state"].(string)
}
diagnostic.record(started, frame)
// New intent and stop signals bypass the periodic watchdog tick. The
// buffered wake channel coalesces arrivals; it never queues commands.
timer := time.NewTimer(max(0, 50*time.Millisecond-time.Since(started)))
select {
case <-ctx.Done():
case <-state.wake:
case <-timer.C:
}
timer.Stop()
}
// Binding changes and channel shutdown explicitly retire any current intent.
call, done := context.WithTimeout(context.Background(), 25*time.Millisecond)
_, _ = p.Sensors.modelDriver(call, model, "/remote", map[string]any{"watch": false, "command": nil, "relay_id": relay})
done()
}
func roverTelemetry(ctx context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) {
var diagnostic roverDiagnostics
for ctx.Err() == nil {
started := time.Now()
snapshot, _ := state.view()
payload := make(map[string]any, len(base)+1)
for k, v := range base {
payload[k] = v
}
payload["rover"] = snapshot
raw, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint+"/v1/node/rover", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
frame := roverFrame{}
frame.State, _ = snapshot["state"].(string)
if frame.State == "preparing" || frame.State == "ready" || frame.State == "driving" || frame.State == "stopping" {
frame.Session, _ = snapshot["session_id"].(string)
}
response, err := client.Do(req)
if err != nil {
frame.Stage = "telemetry_transport"
} else {
frame.Status = response.StatusCode
var out roverStreamReply
readErr := json.NewDecoder(io.LimitReader(response.Body, 32768)).Decode(&out)
response.Body.Close()
if readErr != nil || response.StatusCode != 200 {
frame.Stage = "telemetry_response"
} else if !state.sampleClock(out.Clock, started, time.Now()) {
frame.Stage = "clock_invalid"
state.stopStream()
} else {
state.setWatch(out.Watch)
}
}
frame.CoreMS = time.Since(started).Milliseconds()
diagnostic.record(started, frame)
_, watch := state.view()
delay := 500 * time.Millisecond
if watch {
delay = 100 * time.Millisecond
}
if !roverWait(ctx, delay-time.Since(started)) {
return
}
}
}
func roverCommands(ctx context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) {
for ctx.Err() == nil {
_, watch := state.view()
if !watch {
if !roverWait(ctx, 50*time.Millisecond) {
return
}
continue
}
err := roverReadStream(ctx, client, endpoint, base, state)
state.stopStream()
if err != nil && ctx.Err() == nil {
log.Printf("{\"event\":\"rover-command-stream\",\"state\":\"disconnected\"}")
}
if !roverWait(ctx, 250*time.Millisecond) {
return
}
}
}
func roverReadStream(parent context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
raw, _ := json.Marshal(base)
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint+"/v1/node/rover-stream", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
sent := time.Now()
response, err := client.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return errors.New("stream rejected")
}
// The native 200 ms output watchdog is unchanged. A silent stream also
// cancels its network read and retires intent; reconnect never rearms it.
guard := time.AfterFunc(350*time.Millisecond, cancel)
defer guard.Stop()
reader := bufio.NewReaderSize(response.Body, 32768)
first := true
for {
line, err := reader.ReadSlice('\n')
if err != nil {
return err
}
var out roverStreamReply
if json.Unmarshal(line, &out) != nil {
return errors.New("invalid command frame")
}
if first {
if !state.sampleClock(out.Clock, sent, time.Now()) {
return errors.New("invalid stream clock")
}
first = false
}
if !state.accept(out) {
return errors.New("invalid stream intent")
}
if !out.Watch {
return nil
}
if !guard.Reset(350 * time.Millisecond) {
return errors.New("stream deadline")
}
}
}
@@ -0,0 +1,61 @@
package node
// A bounded memory flight recorder. It never stores endpoints, certificates,
// payloads or raw HTTP errors, and never changes admission or lease deadlines.
import (
"encoding/json"
"log"
"time"
)
type roverFrame struct {
AtMS int64 `json:"at_ms"`
GapMS int64 `json:"gap_ms"`
BindingMS int64 `json:"binding_ms"`
CoreMS int64 `json:"core_ms"`
DriverMS int64 `json:"driver_ms"`
Status int `json:"http_status"`
Stage string `json:"error_stage,omitempty"`
Session string `json:"session,omitempty"`
Sequence float64 `json:"sequence"`
TTLMS float64 `json:"ttl_ms"`
State string `json:"state"`
}
type roverDiagnostics struct {
frames []roverFrame
last time.Time
started time.Time
state string
session string
emit func([]byte)
}
func (d *roverDiagnostics) record(at time.Time, frame roverFrame) {
if d.started.IsZero() {
d.started = at
}
frame.AtMS = at.Sub(d.started).Milliseconds()
if !d.last.IsZero() {
frame.GapMS = at.Sub(d.last).Milliseconds()
}
d.last = at
d.frames = append(d.frames, frame)
if len(d.frames) > 128 {
d.frames = d.frames[len(d.frames)-128:]
}
active := frame.Session != "" || d.session != ""
changed := frame.State != d.state || frame.Session != d.session
if active && (changed || frame.Stage != "" || frame.GapMS >= 200) {
raw, _ := json.Marshal(struct {
Event string `json:"event"`
Frames []roverFrame `json:"frames"`
}{"rover-channel", d.frames})
if d.emit != nil {
d.emit(raw)
} else {
log.Printf("%s", raw)
}
}
d.state, d.session = frame.State, frame.Session
}
@@ -0,0 +1,63 @@
package node
import (
"encoding/json"
"testing"
"time"
)
func TestRoverDiagnosticsRetainsCancellationCauseWithoutIdleLogSpam(t *testing.T) {
var events [][]byte
d := roverDiagnostics{emit: func(b []byte) { events = append(events, b) }}
at := time.Unix(100, 0)
for i := 0; i < 200; i++ {
d.record(at.Add(time.Duration(i)*50*time.Millisecond), roverFrame{State: "observing"})
}
if len(events) != 0 || len(d.frames) != 128 {
t.Fatal("idle must remain bounded and quiet")
}
at = at.Add(11 * time.Second)
d.record(at, roverFrame{Session: "session", Sequence: 1, TTLMS: 350, State: "preparing"})
d.record(at.Add(100*time.Millisecond), roverFrame{Session: "session", Sequence: 2, TTLMS: 320, State: "preparing"})
if len(events) != 1 {
t.Fatal("unchanged healthy frames should remain in memory")
}
d.record(at.Add(450*time.Millisecond), roverFrame{CoreMS: 300, Stage: "core_transport", State: "preparing"})
if len(events) != 2 {
t.Fatal("lost command needs diagnostic evidence")
}
var event struct {
Frames []roverFrame `json:"frames"`
}
if err := json.Unmarshal(events[1], &event); err != nil {
t.Fatal(err)
}
last := event.Frames[len(event.Frames)-1]
if last.Stage != "core_transport" || last.GapMS != 350 || last.CoreMS != 300 || last.Session != "" {
t.Fatal(last)
}
if event.Frames[len(event.Frames)-2].Sequence != 2 {
t.Fatal("last accepted frame lost")
}
}
func TestRoverDiagnosticsRecordsBindingWaitAndDriverTerminalState(t *testing.T) {
var events [][]byte
d := roverDiagnostics{emit: func(b []byte) { events = append(events, b) }}
at := time.Unix(100, 0)
d.record(at, roverFrame{Session: "session", State: "preparing"})
d.record(at.Add(500*time.Millisecond), roverFrame{Session: "session", State: "stopped", BindingMS: 400, DriverMS: 2})
if len(events) != 2 {
t.Fatal("terminal state must flush preceding transport history")
}
var event struct {
Frames []roverFrame `json:"frames"`
}
if err := json.Unmarshal(events[1], &event); err != nil {
t.Fatal(err)
}
last := event.Frames[len(event.Frames)-1]
if last.BindingMS != 400 || last.DriverMS != 2 || last.State != "stopped" {
t.Fatal(last)
}
}
@@ -0,0 +1,199 @@
package node
import (
"encoding/hex"
"math"
"sync"
"time"
)
type roverClock struct {
Instance string `json:"instance"`
MonotonicMS float64 `json:"monotonic_ms"`
}
type roverStreamReply struct {
Watch bool `json:"watch"`
Command map[string]any `json:"command"`
Clock roverClock `json:"control_clock"`
}
// Clock bounds use the request-send time, never RTT/2 or synchronized wall
// clocks. Server time was sampled after that send, so it gives an upper bound
// on server-minus-local monotonic offset even on an asymmetric network.
type roverStreamState struct {
mu sync.Mutex
origin time.Time
clockID string
upperMS float64
anchored time.Time
clockSeen time.Time
command map[string]any
watch bool
stopPending bool
stopRevision uint64
snapshot map[string]any
retired map[string]bool
inhibited bool
wake chan struct{}
}
func newRoverStreamState(now time.Time) *roverStreamState {
return &roverStreamState{origin: now, stopPending: true, stopRevision: 1, snapshot: map[string]any{}, retired: map[string]bool{}, wake: make(chan struct{}, 1)}
}
func finiteNumber(v any) (float64, bool) {
n, ok := v.(float64)
return n, ok && !math.IsNaN(n) && !math.IsInf(n, 0)
}
func (s *roverStreamState) sampleClock(c roverClock, sent, received time.Time) bool {
if len(c.Instance) != 32 {
return false
}
if _, err := hex.DecodeString(c.Instance); err != nil {
return false
}
if math.IsNaN(c.MonotonicMS) || math.IsInf(c.MonotonicMS, 0) || c.MonotonicMS < 0 || received.Before(sent) {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
upper := c.MonotonicMS - float64(sent.Sub(s.origin).Microseconds())/1000 + 1
if c.Instance != s.clockID {
s.invalidate()
s.clockID = c.Instance
s.upperMS = upper
s.anchored = received
} else if s.clockSeen.IsZero() || received.Sub(s.clockSeen) > 2*time.Second || upper < s.upperMS+float64(received.Sub(s.anchored).Microseconds())/1e6 {
s.upperMS = upper
s.anchored = received
}
s.clockSeen = received
return true
}
func (s *roverStreamState) accept(reply roverStreamReply) bool {
s.mu.Lock()
defer s.mu.Unlock()
if reply.Clock.Instance != s.clockID || s.clockID == "" {
return false
}
if reply.Command == nil {
if s.command != nil {
s.invalidate()
}
return true
}
command := reply.Command
if len(command) != 6 {
return false
}
for _, k := range []string{"id", "sequence", "left", "right", "settings", "expires_mono_ms"} {
if _, ok := command[k]; !ok {
return false
}
}
expires, ok := finiteNumber(command["expires_mono_ms"])
if !ok || expires > reply.Clock.MonotonicMS+400.001 {
return false
}
id, ok := command["id"].(string)
if !ok || len(id) != 32 {
return false
}
if _, err := hex.DecodeString(id); err != nil {
return false
}
sequence, ok := finiteNumber(command["sequence"])
if !ok || sequence < 0 || sequence >= 1<<53 || math.Trunc(sequence) != sequence {
return false
}
if s.inhibited || s.retired[id] {
return true
}
// The native driver independently validates identity, settings and sequence.
if s.stopPending {
s.retire(id)
return true
}
changed := s.command == nil || s.command["id"] != id || s.command["sequence"] != sequence
s.command = command
if changed {
s.notify()
}
return true
}
func (s *roverStreamState) notify() {
select {
case s.wake <- struct{}{}:
default:
}
}
func (s *roverStreamState) stopStream() {
s.mu.Lock()
defer s.mu.Unlock()
s.invalidate()
}
func (s *roverStreamState) retire(id string) {
if len(s.retired) >= 1024 {
s.inhibited = true // Fail closed instead of forgetting interrupted sessions.
return
}
s.retired[id] = true
}
func (s *roverStreamState) invalidate() {
wake := !s.stopPending || s.command != nil
if s.command != nil {
if id, ok := s.command["id"].(string); ok {
s.retire(id)
}
}
s.command = nil
s.stopPending = true
s.stopRevision++
if wake {
s.notify()
}
}
func (s *roverStreamState) delivery(now time.Time) (bool, map[string]any, uint64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stopPending {
return s.watch, nil, s.stopRevision
}
if s.command == nil {
return s.watch, nil, 0
}
if now.Sub(s.clockSeen) > 2*time.Second {
s.invalidate()
return s.watch, nil, s.stopRevision
}
expires, _ := finiteNumber(s.command["expires_mono_ms"])
// Reserve 25 ms for the private driver RPC and 5 ms plus 1000 ppm for clock
// quantization/rate uncertainty. Expiry is never extended by receipt time.
serverUpper := float64(now.Sub(s.origin).Microseconds())/1000 + s.upperMS + 5 + float64(now.Sub(s.anchored).Microseconds())/1e6
ttl := math.Min(400, expires-serverUpper-25)
if ttl <= 0 {
s.invalidate()
return s.watch, nil, s.stopRevision
}
out := make(map[string]any, 6)
for k, v := range s.command {
if k != "expires_mono_ms" {
out[k] = v
}
}
out["ttl_ms"] = ttl
return s.watch, out, 0
}
func (s *roverStreamState) delivered(snapshot map[string]any, stop uint64) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot = snapshot
if stop != 0 && stop == s.stopRevision {
s.stopPending = false
}
}
func (s *roverStreamState) view() (map[string]any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshot, s.watch
}
func (s *roverStreamState) setWatch(watch bool) { s.mu.Lock(); s.watch = watch; s.mu.Unlock() }
@@ -0,0 +1,235 @@
package node
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
const testClockID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func streamIntent(sequence int, serverNow, expires float64) roverStreamReply {
return roverStreamReply{Watch: true, Clock: roverClock{testClockID, serverNow}, Command: map[string]any{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sequence": float64(sequence), "left": float64(1), "right": float64(1),
"settings": map[string]any{"standstill_confirmed": true, "current_a": float64(30), "max_erpm": float64(2000)}, "expires_mono_ms": expires}}
}
func acknowledgeStop(s *roverStreamState, at time.Time) {
_, _, revision := s.delivery(at)
s.delivered(map[string]any{"state": "observing"}, revision)
}
func TestRoverStreamAsymmetricDelayRetainsOriginalExpiry(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
// Core's clock is +10 seconds. Request took 70 ms outbound and 130 ms
// inbound: dividing RTT in half would be incorrect on this connection.
if !s.sampleClock(roverClock{testClockID, 10070}, origin, origin.Add(200*time.Millisecond)) {
t.Fatal("clock")
}
acknowledgeStop(s, origin.Add(200*time.Millisecond))
frame := streamIntent(1, 10200, 10600)
if !s.accept(frame) {
t.Fatal("intent")
}
_, command, _ := s.delivery(origin.Add(280 * time.Millisecond))
if command == nil {
t.Fatal("fresh streamed intent rejected")
}
ttl := command["ttl_ms"].(float64)
if ttl <= 0 || 280+ttl > 600 {
t.Fatal("delay extended original browser deadline", ttl)
}
// Replaying the same frame does not receive a new deadline.
if !s.accept(frame) {
t.Fatal("repeat")
}
_, late, _ := s.delivery(origin.Add(650 * time.Millisecond))
if late != nil {
t.Fatal("expired frame revived motor intent")
}
}
func TestRoverStreamContinuousInputSurvivesMeasuredRelayJitter(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10050}, origin, origin.Add(150*time.Millisecond))
acknowledgeStop(s, origin.Add(150*time.Millisecond))
priorUntil := float64(0)
for i, delay := range []int{60, 90, 45, 110, 80, 50, 100, 60, 90, 50} {
sent := 200 + i*100
arrival := sent + delay
s.accept(streamIntent(i+1, float64(10000+sent), float64(10400+sent)))
if i > 0 && float64(arrival) >= priorUntil {
t.Fatal("lease gap under measured jitter", i, arrival, priorUntil)
}
_, command, _ := s.delivery(origin.Add(time.Duration(arrival) * time.Millisecond))
if command == nil {
t.Fatal("fresh command lost", i)
}
priorUntil = float64(arrival) + command["ttl_ms"].(float64)
if priorUntil > float64(sent+400) {
t.Fatal("end-to-end deadline expanded")
}
}
}
func TestRoverStreamDisconnectNeedsAcknowledgedStopBeforeNewFrames(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
s.accept(streamIntent(1, 10000, 10400))
s.stopStream()
_, _, oldStop := s.delivery(origin)
s.stopStream() // Another disconnect races the local driver's response.
s.delivered(map[string]any{}, oldStop)
s.accept(streamIntent(2, 10000, 10400))
_, command, newStop := s.delivery(origin)
if command != nil || newStop == 0 || newStop == oldStop {
t.Fatal("stale ACK removed stop barrier")
}
s.delivered(map[string]any{}, newStop)
_, command, _ = s.delivery(origin)
if command != nil {
t.Fatal("frame received before acknowledged stop was queued")
}
}
func TestRoverStreamClockChangeAndStaleClockDisarm(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
s.accept(streamIntent(1, 10000, 10400))
s.sampleClock(roverClock{"cccccccccccccccccccccccccccccccc", 2}, origin, origin)
_, command, stop := s.delivery(origin)
if command != nil || stop == 0 {
t.Fatal("Core restart retained intent")
}
if s.accept(streamIntent(2, 10000, 10400)) {
t.Fatal("old clock accepted")
}
acknowledgeStop(s, origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
// A fresh-looking frame alone cannot renew the clock calibration.
fresh := streamIntent(3, 13000, 13400)
fresh.Command["id"] = "dddddddddddddddddddddddddddddddd"
s.accept(fresh)
_, command, stop = s.delivery(origin.Add(3 * time.Second))
if command != nil || stop == 0 {
t.Fatal("stale clock allowed output")
}
}
func TestRoverStreamUnsentIntentCannotStartAfterReconnect(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
frame := streamIntent(1, 10000, 10400)
s.accept(frame)
// The network fails before the local driver ever sees this session.
s.stopStream()
acknowledgeStop(s, origin)
s.accept(streamIntent(2, 10010, 10410))
_, command, _ := s.delivery(origin)
if command != nil {
t.Fatal("undelivered old session started after reconnect")
}
frame.Command["id"] = "dddddddddddddddddddddddddddddddd"
s.accept(frame)
_, command, _ = s.delivery(origin)
if command == nil {
t.Fatal("new explicit session rejected after stop")
}
}
func TestRoverStreamIntentWakesImmediatelyAndCoalescesWithoutReplayWake(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
for i := 1; i <= 3; i++ {
s.accept(streamIntent(i, 10000, 10400))
}
if len(s.wake) != 1 {
t.Fatal("intent notifications must coalesce")
}
<-s.wake
_, command, _ := s.delivery(origin)
if command["sequence"] != float64(3) {
t.Fatal("queued an intermediate command")
}
s.accept(streamIntent(3, 10000, 10400))
if len(s.wake) != 0 {
t.Fatal("replayed frame woke driver again")
}
s.stopStream()
if len(s.wake) != 1 {
t.Fatal("stop did not wake driver")
}
<-s.wake
s.stopStream()
if len(s.wake) != 0 {
t.Fatal("repeated failure would spin the local loop")
}
}
func TestRoverStreamRejectsExtendedDeadlineAndUnknownCommandFields(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
if s.accept(streamIntent(1, 10000, 10401)) {
t.Fatal("extended deadline accepted")
}
frame := streamIntent(1, 10000, 10400)
frame.Command["queued"] = true
if s.accept(frame) {
t.Fatal("unknown field accepted")
}
for _, sequence := range []any{map[string]any{}, []any{1}, "1", 1.5, float64(1 << 53)} {
bad := streamIntent(1, 10000, 10400)
bad.Command["sequence"] = sequence
if s.accept(bad) {
t.Fatal("malformed sequence accepted")
}
}
}
func TestRoverStreamSilenceCancelsReadWithoutWaitingForTelemetry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
json.NewEncoder(w).Encode(roverStreamReply{Watch: true, Clock: roverClock{testClockID, 10000}})
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
state := newRoverStreamState(time.Now())
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if roverReadStream(ctx, server.Client(), server.URL, map[string]any{}, state) == nil {
t.Fatal("silent stream accepted")
}
if time.Since(started) > time.Second {
t.Fatal("silent stream did not cancel its read")
}
}
func TestRoverStreamReadsMultipleFramesOnOneResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/node/rover-stream" {
t.Error("wrong path")
}
w.Header().Set("Content-Type", "application/x-ndjson")
for i := 0; i < 3; i++ {
raw, _ := json.Marshal(roverStreamReply{Watch: i < 2, Clock: roverClock{testClockID, 10000 + float64(i)}})
fmt.Fprintln(w, string(raw))
w.(http.Flusher).Flush()
}
}))
defer server.Close()
state := newRoverStreamState(time.Now())
if err := roverReadStream(context.Background(), server.Client(), server.URL, map[string]any{}, state); err != nil {
t.Fatal(err)
}
}
@@ -46,11 +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",
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.7.4",
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")},
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.layout", "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 {
@@ -6,10 +6,29 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
)
func TestVESCDeclaredVersionMatchesBundledDriver(t *testing.T) {
model := modelForDevice("vesc_00000000000000000000000000000000")
root := filepath.Join("..", "..", "..", "..", "plugins", "vesc")
driver, err := os.ReadFile(filepath.Join(root, "runtime", "__init__.py"))
if err != nil {
t.Fatal(err)
}
match := regexp.MustCompile(`(?m)^VERSION = "([^"]+)"`).FindSubmatch(driver)
if model == nil || len(match) != 2 || model.Version != string(match[1]) {
t.Fatal("Node must admit the exact driver shipped in the same release", model, string(driver))
}
preparation, err := os.ReadFile(filepath.Join(root, "packaging", "prepare.py"))
if err != nil || !strings.Contains(string(preparation), `"version": "`+model.Version+`"`) {
t.Fatal("VESC preparation must declare the bundled driver version", err)
}
}
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
t.Helper()
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
+1 -1
View File
@@ -354,7 +354,7 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
}
func sensorViewAction(action string) bool {
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.config.backup"
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.limits.read" || action == "vesc.config.backup"
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
+8 -3
View File
@@ -11,8 +11,8 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
BINARY_VERSION = "0.8.35"
VERSION = "0.8.35-1"
BINARY_VERSION = "0.8.45"
VERSION = "0.8.45-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.39), 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, usbutils, 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()
@@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
("desktop_startup.py", "usr/lib/mission-core-node/desktop_startup.py", 0o644),
("mission-core-node-autostart.desktop", "usr/share/mission-core-node/mission-core-node-autostart.desktop", 0o644),
("usb-startup.json", "usr/share/mission-core-node/usb-startup.json", 0o644),
("usb_startup_recovery.py", "usr/lib/mission-core-node/usb_startup_recovery.py", 0o644),
("mission-core-node-usb-startup.service", "usr/lib/systemd/system/mission-core-node-usb-startup.service", 0o644),
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755),
@@ -0,0 +1,30 @@
"""Unprivileged XDG startup: wait for Node before opening its ordinary UI."""
import http.client
import os
import time
def wait_for_service(now=time.monotonic, sleep=time.sleep, connection=http.client.HTTPConnection):
deadline = now() + 180
while now() < deadline:
client = connection('127.0.0.1', 8780, timeout=2)
try:
client.request('GET', '/')
response = client.getresponse()
if response.status == 200:
return True
except (OSError, http.client.HTTPException):
pass
finally:
client.close()
sleep(1)
return False
if __name__ == '__main__':
if os.geteuid() == 0:
raise SystemExit('Run in the normal graphical user session')
wait_for_service()
# Gtk.Application retains one window per desktop session. Its ordinary
# polkit authorization and error handling are unchanged.
os.execv('/usr/bin/mission-core-node', ['/usr/bin/mission-core-node'])
@@ -219,7 +219,33 @@ def tailscale_install():
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
def desktop_autostart():
owned_config(Path('/usr/share/mission-core-node/mission-core-node-autostart.desktop'),
Path('/etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop'))
return "Окно приложения открывается при входе в рабочий стол. Служба БК работает и без входа."
def usb_startup():
# Enabling the next boot is distinct from executing recovery now. This
# workflow never resets devices in a running operator session.
owned_config(Path('/usr/share/mission-core-node/usb-startup.json'),
Path('/etc/mission-core-node/usb-startup.json'))
command(['/usr/bin/systemctl', 'daemon-reload'])
command(['/usr/bin/systemctl', 'enable', 'mission-core-node-usb-startup.service'])
command(['/usr/bin/systemctl', 'is-enabled', 'mission-core-node-usb-startup.service'])
report = json.loads(command(['/usr/bin/python3', '-I', '-B', '/usr/lib/mission-core-node/usb_startup_recovery.py', '--inspect'], timeout=75))
if report.get('state') != 'inspected':
raise SetupError("Не удалось проверить поддержку восстановления USB. Повторите настройку.")
supported = sum(h.get('individual_power') is True for h in report.get('hubs', []))
if not supported:
return "Проверка при загрузке включена. Поддержка отдельного переключения USB-портов не подтверждена; они будут пропущены."
return "Восстановление при загрузке включено для поддерживаемых USB-портов. Обнаруженные устройства сохраняют подключение."
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service,
"desktop-autostart": desktop_autostart, "usb-startup": usb_startup,
"network-inventory": network_inventory, "usb-inventory": usb_inventory,
"ssh-service": ssh_service, "tailscale-install": tailscale_install}
def run_steps(profile, operations, save):
@@ -243,6 +243,9 @@ def main():
sys.path.insert(0, str(node / "packaging"))
from build_deb import BINARY_VERSION, VERSION, build
run("node-environment-tests", ["/usr/bin/python3", "-m", "unittest", "test_environment_helper", "test_usb_startup_recovery", "test_desktop_startup", "-v"], cwd=node / "packaging")
run("owner-release-staging-tests", ["/usr/bin/python3", "plugins/insta360-x4/packaging/test_owner_release_entry.py"], cwd=repo)
run("node-usb-unit-validation", ["/usr/bin/systemd-analyze", "verify", str(node / "packaging/mission-core-node-usb-startup.service")], cwd=node)
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(
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Mission Core Node
Comment=Настройка и диагностика бортового компьютера
TryExec=/usr/bin/mission-core-node
Exec=/usr/bin/python3 -I -B /usr/lib/mission-core-node/desktop_startup.py
Icon=org.nodedc.MissionCoreNode
Terminal=false
StartupNotify=false
@@ -0,0 +1,35 @@
[Unit]
Description=Mission Core bounded USB startup recovery
Wants=systemd-udev-settle.service
After=systemd-udev-settle.service
Before=mission-core-node.service mission-core-vesc.service
ConditionPathExists=/etc/mission-core-node/usb-startup.json
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py
ExecStopPost=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py --restore
TimeoutStartSec=130
TimeoutStopSec=15
RuntimeDirectory=mission-core-usb-startup
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
UMask=0077
NoNewPrivileges=yes
PrivateNetwork=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ReadWritePaths=/sys/devices /run/mission-core-usb-startup
DevicePolicy=closed
DeviceAllow=char-usb_device rw
TasksMax=16
MemoryMax=64M
[Install]
WantedBy=multi-user.target
+8
View File
@@ -14,6 +14,14 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
exit 1
fi
fi
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
case "$mc_node_usb_job" in
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
esac
if [ -f /run/mission-core-usb-startup/pending.json ]; then
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
exit 1
fi
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
case "$mc_node_x4_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
+28
View File
@@ -13,6 +13,14 @@ if [ -d /run/systemd/system ]; then
exit 1
fi
fi
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
case "$mc_node_usb_job" in
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
esac
if [ -f /run/mission-core-usb-startup/pending.json ]; then
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
exit 1
fi
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
case "$mc_node_x4_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
@@ -34,6 +42,25 @@ if [ -d /run/systemd/system ]; then
;;
esac
fi
retire_startup_settings() {
for mc_node_owned in /etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop /etc/mission-core-node/usb-startup.json; do
case "$mc_node_owned" in
*.desktop) mc_node_template=/usr/share/mission-core-node/mission-core-node-autostart.desktop ;;
*.json) mc_node_template=/usr/share/mission-core-node/usb-startup.json ;;
esac
if [ ! -L "$mc_node_owned" ] && cmp -s "$mc_node_template" "$mc_node_owned"; then
rm "$mc_node_owned"
fi
done
if [ -d /run/systemd/system ]; then
systemctl disable --now mission-core-node-usb-startup.service
fi
}
# A downgrade removes helpers introduced in 0.8.37. Retire their owned
# configuration first; do not leave an enabled unit or XDG entry dangling.
if [ "$1" = upgrade ] && [ -n "${2:-}" ] && dpkg --compare-versions "$2" lt 0.8.37-1; then
retire_startup_settings
fi
case "$1" in
upgrade|remove|deconfigure)
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
@@ -47,6 +74,7 @@ case "$1" in
rm /etc/udev/rules.d/70-mission-core-vesc.rules
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
fi
retire_startup_settings
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
@@ -0,0 +1,25 @@
import unittest
from unittest.mock import Mock
import desktop_startup as startup
class DesktopStartupTests(unittest.TestCase):
def test_waits_for_service_without_credentials_and_closes_each_connection(self):
clock = [0]
def sleep(seconds): clock[0] += seconds
client = Mock()
client.getresponse.side_effect = [OSError('not started'), Mock(status=503), Mock(status=200)]
factory = Mock(return_value=client)
self.assertTrue(startup.wait_for_service(lambda: clock[0], sleep, factory))
self.assertEqual(clock[0], 2)
self.assertEqual(client.close.call_count, 3)
self.assertEqual(client.request.call_args.args, ('GET', '/'))
def test_stopped_service_does_not_hold_startup_forever(self):
clock = [0]
def sleep(seconds): clock[0] += seconds
client = Mock()
client.request.side_effect = OSError('offline')
self.assertFalse(startup.wait_for_service(lambda: clock[0], sleep, lambda *a, **kw: client))
self.assertEqual(clock[0], 180)
self.assertEqual(client.close.call_count, 180)
@@ -15,6 +15,24 @@ PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-pro
class EnvironmentWorkflowTests(unittest.TestCase):
def test_profile_and_shipped_operations_match(self):
self.assertEqual({s['id'] for s in PROFILE['steps']}, set(helper.OPERATIONS))
def test_usb_setup_enables_next_boot_without_resetting_live_devices(self):
def command(argv, **_):
return json.dumps({'state': 'inspected', 'hubs': [{'individual_power': True}]}) if '--inspect' in argv else ''
with patch.object(helper, 'owned_config') as config, patch.object(helper, 'command', side_effect=command) as run:
self.assertIn('включено', helper.usb_startup())
config.assert_called_once()
for call in run.call_args_list:
self.assertFalse({'--now', 'start', 'restart'} & set(call.args[0]))
self.assertTrue(any('--inspect' in call.args[0] for call in run.call_args_list))
def test_autostart_preserves_foreign_configuration_and_does_not_start_a_gui_as_root(self):
with patch.object(helper, 'owned_config', side_effect=helper.SetupError('conflict')), patch.object(helper, 'command') as run:
with self.assertRaises(helper.SetupError): helper.desktop_autostart()
run.assert_not_called()
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
saved = []
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
@@ -0,0 +1,220 @@
"""Synthetic sysfs/journal only: these tests never open a physical USB port."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import usb_startup_recovery as recovery
def event(second, message, transport='kernel'):
return {'__MONOTONIC_TIMESTAMP': str(int(second * 1000000)),
'_TRANSPORT': transport, 'MESSAGE': message}
class JournalTests(unittest.TestCase):
def test_only_terminal_boot_failures_with_no_later_connection_are_candidates(self):
records = [event(20, 'usb usb1-port2: unable to enumerate USB device'),
event(18, 'usb usb1-port3: unable to enumerate USB device'),
event(23, 'usb 1-3: new full-speed USB device number 8 using xhci_hcd'),
event(12, 'usb 1-1-port2: unable to enumerate USB device'),
event(65, 'usb usb2-port1: unable to enumerate USB device'),
event(15, 'usb usb2-port3: unable to enumerate USB device', 'stdout'),
event(18, 'usb 1-4: device descriptor read/64, error -71')]
self.assertEqual(recovery.failed_ports(list(reversed(records))), ['1-1-port2', 'usb1-port2'])
def test_success_or_user_disconnect_invalidates_old_failure(self):
for message in ['New USB device found, idVendor=0000', 'USB disconnect, device number 8']:
self.assertEqual(recovery.failed_ports([
event(20, 'usb usb1-port2: unable to enumerate USB device'), event(21, 'usb 1-2: ' + message)]), [])
def test_invalid_port_names_cannot_be_paths(self):
for name in ['../../etc/passwd', 'usb0-port0', 'usb1-port1/disable']:
with self.assertRaises(ValueError): recovery.child_name(name)
self.assertEqual(recovery.child_name('1-2.3-port4'), '1-2.3.4')
class SysfsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
base = Path(self.temp.name)
self.root, self.physical = base/'bus', base/'devices'
self.root.mkdir(); self.physical.mkdir()
self.usb = recovery.USB(self.root, self.physical)
self.ports = []
for n in (1, 2):
hub = self.physical/f'usb{n}'; hub.mkdir()
for key, value in {'bDeviceClass':'09', 'busnum':str(n), 'devnum':'1'}.items():
(hub/key).write_text(value)
(self.root/hub.name).symlink_to(hub, target_is_directory=True)
interface = hub/f'{n}-0:1.0'; interface.mkdir()
(self.root/interface.name).symlink_to(interface, target_is_directory=True)
port = interface/f'usb{n}-port2'; port.mkdir()
for key, value in {'state':'not attached', 'disable':'0\n', 'over_current_count':'0', 'connect_type':'unknown'}.items():
(port/key).write_text(value)
self.ports.append(port)
(self.ports[0]/'peer').symlink_to(self.ports[1], target_is_directory=True)
(self.ports[1]/'peer').symlink_to(self.ports[0], target_is_directory=True)
def plan(self):
with patch.object(recovery, 'run', return_value=' wHubCharacteristic 0x0009\n'):
return self.usb.plan('usb1-port2')
def test_individual_power_pair_can_be_disabled_and_restored(self):
entries = self.plan()
self.assertEqual([e['name'] for e in entries], ['usb1-port2', 'usb2-port2'])
for entry in entries: self.usb.write(entry, True)
self.assertTrue(all((p/'disable').read_text() == '1\n' for p in self.ports))
for entry in reversed(entries): self.usb.write(entry, False)
self.assertTrue(all((p/'disable').read_text() == '0\n' for p in self.ports))
def test_connected_companion_blocks_both_ports_before_descriptor_query(self):
(self.ports[1]/'device').symlink_to(self.physical/'camera')
with patch.object(recovery, 'run') as run:
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
run.assert_not_called()
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
def test_non_individual_power_or_missing_descriptor_cannot_reset(self):
for output in ['wHubCharacteristics 0x0000', 'wHubCharacteristics 0x0002', '']:
with self.subTest(output=output), patch.object(recovery, 'run', return_value=output):
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
def test_internal_disabled_overcurrent_and_mid_enumeration_ports_skipped(self):
for attribute, value in [('connect_type', 'hardwired'), ('disable', '1'), ('over_current_count', '1'), ('state', 'powered')]:
p = self.ports[0]/attribute; original = p.read_text(); p.write_text(value)
with self.subTest(attribute=attribute), self.assertRaises(OSError): self.plan()
p.write_text(original)
def test_attachment_after_planning_prevents_write(self):
entries = self.plan()
(self.ports[0]/'device').symlink_to(self.physical/'new-device')
with self.assertRaises(OSError): self.usb.write(entries[0], True)
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
def test_hub_replacement_after_planning_prevents_write(self):
entries = self.plan()
(self.physical/'usb1'/'devnum').write_text('4')
with self.assertRaises(OSError): self.usb.write(entries[0], True)
def test_first_hub_replaced_while_inspecting_companion_invalidates_plan(self):
def inspect(name):
if name == 'usb2-port2': (self.physical/'usb1'/'devnum').write_text('4')
return True
with patch.object(self.usb, 'individual_power', side_effect=inspect):
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
def test_enabled_port_is_not_written_during_cleanup(self):
entries = self.plan()
with patch.object(recovery.os, 'open', side_effect=AssertionError('No write expected')):
self.usb.write(entries[0], False)
def test_foreign_companion_path_is_rejected(self):
(self.ports[0]/'peer').unlink()
(self.ports[0]/'peer').symlink_to(self.root)
with self.assertRaises(OSError): self.usb.companions('usb1-port2')
class RecoveryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
self.state = Path(self.temp.name)
self.clock = 30
self.usb = Mock()
self.entries = [{'name': 'usb1-port2', 'generation': {'address': 1}},
{'name': 'usb2-port2', 'generation': {'address': 1}}]
self.usb.plan.return_value = self.entries
self.usb.empty.return_value = True
self.usb.generation.return_value = {'address': 1}
self.usb.outcome.return_value = {'idVendor': '0000', 'idProduct': '0001', 'product': 'Synthetic'}
def sleep(self, seconds): self.clock += seconds
def run_recovery(self, ports=None):
return recovery.recover(self.usb, self.state, 'boot', ports or ['usb1-port2'], lambda: self.clock, self.sleep)
def test_pair_is_attempted_only_once_and_restored_before_enumeration_check(self):
result = self.run_recovery(['usb1-port2', 'usb2-port2'])
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['state'], 'enumerated')
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
self.assertFalse((self.state/'pending.json').exists())
def test_second_disable_failure_restores_both_and_keeps_failure_detail(self):
def write(entry, disabled):
if disabled and entry['name'] == 'usb2-port2': raise OSError('failed second write')
self.usb.write.side_effect = write
result = self.run_recovery()
self.assertIn('failed second write', result[0]['reason'])
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
self.assertFalse((self.state/'pending.json').exists())
def test_termination_during_off_period_restores_ports(self):
def interrupted(_): raise InterruptedError('stop')
result = recovery.recover(self.usb, self.state, 'boot', ['usb1-port2'], lambda: self.clock, interrupted)
self.assertIn('stop', result[0]['reason'])
self.assertFalse((self.state/'pending.json').exists())
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
def test_restore_failure_preserves_pending_and_stops_other_ports(self):
def write(_, disabled):
if not disabled: raise OSError('restore failed')
self.usb.write.side_effect = write
result = self.run_recovery(['usb1-port2', 'usb1-port3'])
self.assertEqual(result[0]['state'], 'restore_failed')
self.usb.plan.assert_called_once()
self.assertTrue((self.state/'pending.json').exists())
self.usb.write.side_effect = None
recovery.restore(self.usb, self.state, 'boot')
self.assertFalse((self.state/'pending.json').exists())
def test_slow_planning_cannot_start_a_late_reset(self):
def plan(_): self.clock = 179; return self.entries
self.usb.plan.side_effect = plan
result = self.run_recovery()
self.assertIn('deadline', result[0]['reason'])
self.usb.write.assert_not_called()
def test_failed_enumeration_has_no_repeated_power_loop(self):
self.usb.outcome.return_value = None
result = self.run_recovery()
self.assertEqual(result[0]['state'], 'retried')
self.assertEqual(self.clock, 39)
self.assertEqual(self.usb.write.call_count, 4)
def test_previous_boot_pending_state_cannot_address_current_ports(self):
recovery.atomic(self.state/'pending.json', {'boot_id': 'old', 'ports': self.entries})
with self.assertRaises(OSError): recovery.restore(self.usb, self.state, 'new')
self.usb.write.assert_not_called()
def test_boot_wait_occurs_before_journal_snapshot_and_second_run_preserves_report(self):
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
self.clock = 5
def journal():
self.assertGreaterEqual(self.clock, recovery.MIN_AGE)
return []
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
patch.object(recovery, 'state_directory'), patch.object(recovery, 'enabled'), \
patch.object(recovery.os, 'geteuid', return_value=0), patch.object(recovery.sys, 'argv', ['helper']), \
patch.object(recovery.time, 'monotonic', side_effect=lambda: self.clock), \
patch.object(recovery.time, 'sleep', side_effect=self.sleep), \
patch.object(recovery, 'journal', side_effect=journal) as read, patch('builtins.print'):
self.assertEqual(recovery.main(), 0)
first = (self.state/'result.json').read_bytes()
self.assertEqual(recovery.main(), 0)
self.assertEqual((self.state/'result.json').read_bytes(), first)
read.assert_called_once()
def test_late_service_start_cannot_read_or_reset_ports(self):
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
patch.object(recovery, 'state_directory'), patch.object(recovery.os, 'geteuid', return_value=0), \
patch.object(recovery.sys, 'argv', ['helper']), patch.object(recovery.time, 'monotonic', return_value=200), \
patch.object(recovery, 'journal') as read, patch.object(recovery, 'recover') as reset, patch('builtins.print'):
self.assertEqual(recovery.main(), 0)
read.assert_not_called(); reset.assert_not_called()
self.assertEqual(json.loads((self.state/'result.json').read_text())['reason'], 'Outside startup window')
if __name__ == '__main__': unittest.main()
@@ -0,0 +1 @@
{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"}
@@ -0,0 +1,332 @@
"""One bounded boot-time retry of failed USB enumeration; no device commands.
Only root's fixed systemd job may apply. No request supplies a path or command.
Healthy ports (including USB3 companions) and non-individual-power hubs are
excluded. The application still identifies controllers by firmware UUID.
"""
import fcntl
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import time
STATE = Path('/run/mission-core-usb-startup')
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
POLICY = Path('/etc/mission-core-node/usb-startup.json')
EXPECTED_POLICY = {'schema': 'missioncore.node.usb-startup-policy/v1', 'enabled': True,
'mode': 'terminal-enumeration-failures'}
BOOT_WINDOW = 180
FAILURE_WINDOW = 60
MIN_AGE = 30
BUDGET = 90
PORT = re.compile(r'(usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port([1-9][0-9]*)')
FAILURE = re.compile(r'usb ((?:usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port[1-9][0-9]*): unable to enumerate USB device')
def child_name(port):
match = PORT.fullmatch(port)
if not match:
raise ValueError('Invalid USB port')
hub, number = match.groups()
return hub[3:] + '-' + number if hub.startswith('usb') else hub + '.' + number
def failed_ports(records):
"""Ignore failures superseded by a later connection/disconnection event."""
failures, changes = {}, {}
for entry in sorted(records, key=lambda r: int(r.get('__MONOTONIC_TIMESTAMP', 0))):
if entry.get('_TRANSPORT') != 'kernel':
continue
stamp = int(entry.get('__MONOTONIC_TIMESTAMP', 0)) / 1000000
message = entry.get('MESSAGE', '')
if not isinstance(message, str):
continue
match = FAILURE.fullmatch(message)
if match and 0 < stamp <= FAILURE_WINDOW:
failures[match[1]] = stamp
match = re.match(r'usb ([0-9]+-[0-9]+(?:\.[0-9]+)*): (?:new |New USB device found|USB disconnect)', message)
if match:
changes[match[1]] = stamp
return [p for p, stamp in sorted(failures.items()) if changes.get(child_name(p), 0) <= stamp]
def run(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
except subprocess.SubprocessError as error:
raise OSError('USB inspection command timed out or failed') from error
if result.returncode or len(result.stdout) > 2 * 1024**2:
raise OSError('USB inspection command failed')
return result.stdout
def journal():
raw = run(['/usr/bin/journalctl', '-k', '-b', '0', '--no-pager', '-o', 'json', '-n', '2000',
'--grep=unable to enumerate USB device|new .* USB device|New USB device found|USB disconnect'])
return [json.loads(line) for line in raw.splitlines()]
def atomic(path, value):
temporary = path.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(value, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
class USB:
def __init__(self, root=Path('/sys/bus/usb/devices'), physical=Path('/sys/devices')):
self.root, self.physical = root, physical.resolve()
def resolve(self, name):
match = PORT.fullmatch(name)
if not match:
raise ValueError('Invalid port name')
hub = match[1]
interface = hub[3:] + '-0' if hub.startswith('usb') else hub
paths = list(self.root.glob(interface + ':*/' + name))
if len(paths) != 1:
raise OSError('USB port topology unavailable')
path = paths[0].resolve(strict=True)
if not path.is_relative_to(self.physical) or path.name != name:
raise OSError('USB port outside sysfs devices')
return path
def generation(self, name):
hub = self.root / PORT.fullmatch(name)[1]
if (hub / 'bDeviceClass').read_text().strip() != '09':
raise OSError('Parent is not a USB hub')
return {'bus': int((hub / 'busnum').read_text()), 'address': int((hub / 'devnum').read_text()),
'path': str(hub.resolve(strict=True)), 'inode': hub.stat().st_ino}
def empty(self, name):
p = self.resolve(name)
return (not os.path.lexists(p / 'device') and
(p / 'state').read_text().strip() == 'not attached' and
(p / 'disable').read_text().strip() == '0' and
(p / 'over_current_count').read_text().strip() == '0' and
(p / 'connect_type').read_text().strip() in ('hotplug', 'unknown'))
def companions(self, name):
p = self.resolve(name)
names = [name]
if os.path.lexists(p / 'peer'):
peer = (p / 'peer').resolve(strict=True)
if not peer.is_relative_to(self.physical) or self.resolve(peer.name) != peer:
raise OSError('USB companion topology changed')
if (peer / 'peer').resolve(strict=True) != p:
raise OSError('USB companion is not reciprocal')
names.append(peer.name)
return names
def individual_power(self, name):
before = self.generation(name)
raw = run(['/usr/bin/lsusb', '-v', '-s', f"{before['bus']:03}:{before['address']:03}"])
characteristics = re.findall(r'^\s*wHubCharacteristic[s]?\s+0x([0-9a-fA-F]+)\s*$', raw, re.M)
return (before == self.generation(name) and len(characteristics) == 1 and
int(characteristics[0], 16) & 3 == 1)
def plan(self, name):
names = self.companions(name)
entries = [{'name': n, 'generation': self.generation(n)} for n in names]
# Test every companion before querying hub descriptors or opening any port.
if not all(self.empty(n) for n in names):
raise OSError('Port or USB companion already attached, disabled, internal or over-current')
if not all(self.individual_power(n) for n in names):
raise OSError('Individual USB port power switching is not confirmed')
if any(self.generation(e['name']) != e['generation'] for e in entries):
raise OSError('USB hub changed during companion inspection')
return entries
def write(self, entry, disabled):
name = entry['name']
if self.generation(name) != entry['generation']:
raise OSError('USB hub generation changed')
if disabled and not self.empty(name):
raise OSError('USB port became occupied')
p = self.resolve(name) / 'disable'
# Pending state is durable before the first write. An interrupted or
# rejected first write must not cause a redundant enable on a port
# which has since successfully enumerated.
if not disabled and p.read_text().strip() == '0':
return
fd = os.open(p, os.O_WRONLY | os.O_NOFOLLOW)
try:
if self.generation(name) != entry['generation'] or (disabled and not self.empty(name)):
raise OSError('USB topology changed before write')
if os.write(fd, b'1\n' if disabled else b'0\n') != 2:
raise OSError('Incomplete USB port write')
finally:
os.close(fd)
def outcome(self, name):
p = self.resolve(name)
if not (p / 'device').exists():
return None
child = (p / 'device').resolve(strict=True)
if not child.is_relative_to(self.physical):
raise OSError('Unexpected USB child')
return {key: (child / key).read_text().strip() for key in ('idVendor', 'idProduct', 'product')}
def restore(usb, state, boot):
p = state / 'pending.json'
if not p.exists():
return
pending = json.loads(p.read_text())
if pending['boot_id'] != boot:
raise OSError('Pending recovery belongs to another boot')
errors = []
for entry in reversed(pending['ports']):
try:
usb.write(entry, False)
except OSError as error:
errors.append(str(error))
if errors:
raise OSError('; '.join(errors))
p.unlink()
def recover(usb, state, boot, candidates, now=time.monotonic, sleep=time.sleep):
deadline = min(now() + BUDGET, BOOT_WINDOW)
results, processed = [], set()
for name in candidates[:32]:
if name in processed:
continue
item = {'port': name, 'state': 'skipped'}
results.append(item)
if now() + 12 >= deadline:
item['reason'] = 'Boot recovery deadline reached'
break
try:
entries = usb.plan(name)
if now() + 12 >= deadline:
raise OSError('Boot recovery deadline reached during planning')
# Recheck all companions after descriptor reads and before any write.
if not all(usb.empty(e['name']) and usb.generation(e['name']) == e['generation'] for e in entries):
raise OSError('USB port changed during planning')
processed.update(e['name'] for e in entries)
item['ports'] = [e['name'] for e in entries]
atomic(state / 'pending.json', {'boot_id': boot, 'ports': entries})
try:
for entry in entries:
usb.write(entry, True)
sleep(1)
finally:
restore(usb, state, boot)
item['state'] = 'retried'
until = min(now() + 8, deadline)
while now() < until:
result = usb.outcome(name)
if result:
item.update(state='enumerated', descriptor=result)
break
sleep(0.25)
except (OSError, ValueError) as error:
item['reason'] = str(error)
if (state / 'pending.json').exists():
item['state'] = 'restore_failed'
break
return results
def state_directory():
STATE.mkdir(mode=0o755, exist_ok=True)
info = STATE.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise OSError('Untrusted recovery state directory')
def enabled():
info = POLICY.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 1024:
raise OSError('Untrusted startup recovery policy')
if json.loads(POLICY.read_text()) != EXPECTED_POLICY:
raise OSError('Startup recovery policy is not supported')
def inspect(usb):
hubs = []
deadline = time.monotonic() + 60
for p in sorted(usb.root.glob('*')):
if time.monotonic() >= deadline or len(hubs) >= 16:
break
if not re.fullmatch(r'usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*', p.name):
continue
try:
if (p / 'bDeviceClass').read_text().strip() != '09':
continue
hubs.append({'hub': p.name, 'individual_power': usb.individual_power(p.name + '-port1')})
except OSError as error:
hubs.append({'hub': p.name, 'error': str(error)})
return hubs
def main():
if os.geteuid() != 0 or sys.argv[1:] not in ([], ['--inspect'], ['--restore']):
raise ValueError('Fixed root-owned startup job only')
mode = sys.argv[1:] or ['apply']
state_directory()
lock = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
boot = BOOT_ID.read_text().strip()
usb = USB()
if mode == ['--restore']:
try:
restore(usb, STATE, boot)
finally:
os.close(lock)
return 0
if mode == ['apply'] and (STATE / 'attempted').exists():
# Preserve the first result, including restore failures, across a later
# daemon/package restart. Recovery is never retriggered by refresh.
try:
restore(usb, STATE, boot)
finally:
os.close(lock)
return 0
report = {'schema': 'missioncore.node.usb-startup-recovery/v1', 'boot_id': boot,
'observed_at_unix': time.time(), 'boot_seconds': time.monotonic(),
'mode': mode[0], 'state': 'skipped', 'results': []}
destination = STATE / ('inspection.json' if mode == ['--inspect'] else 'result.json')
try:
if mode == ['--inspect']:
report['hubs'] = inspect(usb)
report['state'] = 'inspected'
elif time.monotonic() >= BOOT_WINDOW:
report['reason'] = 'Outside startup window'
else:
enabled()
atomic(STATE / 'attempted', {'boot_id': boot})
restore(usb, STATE, boot)
# udev-settle can return before the hub driver's delayed retries
# give up. Wait before taking the first snapshot, including when
# the journal currently contains no terminal failure yet.
time.sleep(max(0, MIN_AGE - time.monotonic()))
candidates = failed_ports(journal())
if candidates:
report['results'] = recover(usb, STATE, boot, candidates)
report['state'] = 'complete'
except (OSError, ValueError, subprocess.SubprocessError) as error:
report.update(state='error', reason=str(error))
finally:
atomic(destination, report)
destination.chmod(0o644)
os.close(lock)
print(json.dumps(report, sort_keys=True))
return 1 if report['state'] == 'error' or any(r['state'] == 'restore_failed' for r in report['results']) else 0
if __name__ == '__main__':
def interrupted(*_):
raise InterruptedError('Startup recovery interrupted')
signal.signal(signal.SIGTERM, interrupted)
sys.exit(main())
+5 -2
View File
@@ -4,7 +4,10 @@ import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/p
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';
import {request,type Status} from './api';
import {Button,Icon,SettingsCard} from '@nodedc/ui-react';
import {createBoardLayoutStore} from '../../../../packages/sensor-ui/src/boardLayout';
const layout=createBoardLayoutStore({read:()=>request('/api/presentation/board-layout'),patch:(section,open)=>request('/api/presentation/board-layout','PATCH',{section,open})});
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}/>;}
export function NodeSensors({value,openSettings}:{value:Status;openSettings:()=>void}){return <SensorWorkspace board={{layout,description:value.name,computer:<SettingsCard title={value.name} description={value.host.hostname}><dl className="node-facts"><div><dt>Бортовой компьютер</dt><dd>{value.node_id}</dd></div><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{value.host.cpus}</dd></div><div><dt>Память</dt><dd>{value.host.memory_kib?`${(value.host.memory_kib/1048576).toFixed(1)} ГиБ`:"Нет сведений"}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl><Button onClick={openSettings}><Icon name="settings"/>Подготовка среды</Button></SettingsCard>}} contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
+1 -1
View File
@@ -38,7 +38,7 @@ function App() {
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "sensors" ? <NodeSensors />
: workspace.activeView === "sensors" ? <NodeSensors value={value} openSettings={()=>openView("environment")} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />