merge: integrate final rover control with operator simulation workspace
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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())
|
||||
@@ -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}/>;}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user