merge: integrate final rover control with operator simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:48:42 +03:00
88 changed files with 6963 additions and 97 deletions
+18
View File
@@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code.
- Synthetic or explicitly redacted fixtures may be committed under
`tests/fixtures/`.
## Onboard environment ownership — owner requirement, 2026-09-24
- Every onboard OS change belongs to the shipped, versioned installer or the
application's environment/device preparation workflow from the first test.
The operator installs the build and configures it in the application; never
require hand-written service files, USB rules, permission fixes or commands.
- This applies to all Node features, including GUI/service autostart and USB
recovery, not just individual camera drivers. Read-only SSH inspection and
bounded artifact-owned build staging remain allowed.
- Detect the distribution, architecture and required capabilities. Maintain
explicit supported environment profiles; do not claim arbitrary Linux
compatibility from a qualified Ubuntu build. Preserve foreign configuration,
report unsupported features, make preparation repeatable, and ship rollback.
- Do not add USB administration controls or port resets to inventory refresh.
Automatic startup recovery may retry terminal enumeration failures only;
preserve enumerated devices and active companion ports. Device identity and
assignments must survive changes of USB port and tty number.
## Insta360 and clean-host installation — owner requirement, 2026-09-08
- The current X4 starting point is USB enumeration only. SDK installation,
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
async function moduleAt(path) {
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
}
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
const profile={...base,deadband:0};
const arcade={...profile,mode:'arcade'};
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
const motors=['a','b','c','d'];
function frame(now,values=axes(),extra={}) {
return {now,link:{state:'live',at:now},
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
}
function ready(p=base) {
const model=new AuthorityModel(p,motors,policy,'boot-A');
for(let t=0;t<=200;t+=50)model.step(frame(t));
return model;
}
function core() {
const model=ready();
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.ok(token);return {model,token};
}
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
test('tank keeps the two motor sides independent, including reversal',()=>{
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
});
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
});
test('selected stick defines the axes, not the number or position of motors',()=>{
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
});
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
});
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
}
});
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
});
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
for(const n of [2,4,10]){
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
const result=motorDemands({left:.3,right:-.6},bindings);
assert.equal(Object.keys(result).length,n);
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
}
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
assert.throws(()=>motorDemands({left:1,right:1},[b]));
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
});
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
assert.equal(model.step(frame(1200)).state,'rc-ready');
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
});
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
const {model,token}=core();
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
const second=model.step(frame(1750,axes(.5)));
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
assert.equal(model.step(frame(1800)).state,'rc-manual');
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
});
test('neutral of one channel or a single moving member never completes rearming',()=>{
for(const kind of ['axis','drive']){
const {model}=core();model.step(frame(300,axes(1)));
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
}
});
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
});
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
});
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
}
});
test('one repeated neutral sample cannot qualify stable neutral',()=>{
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
assert.equal(model.step(f).state,'hold');
});
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
});
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
}
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
});
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
});
test('premature or replayed Core request does not acquire authority after neutral',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
});
test('reboot cannot restore authority from serialized profile or old grant',()=>{
const {token}=core(), model=ready();
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
const next=new AuthorityModel(base,motors,policy,'boot-B');
for(let t=0;t<=200;t+=50)next.step(frame(t));
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.notEqual(grant.token,token);
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
});
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
});
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
const model=ready({...base,mode:'arcade',stick:'right'});
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
test('arcade mixes directions and opposite keys cancel',()=>{
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
});
test('tank sides and backwards remain independent',()=>{
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
});
test('invalid stored settings never grant motion or invalid limits',()=>{
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
});
@@ -0,0 +1,18 @@
import {build} from 'esbuild';
import {mkdir,writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {resolve,join} from 'node:path';
const root=fileURLToPath(new URL('.',import.meta.url));
const destination=process.argv[2];
if(!destination)throw Error('Pass an artifact output directory; no server is started.');
const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true,
format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'},
alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')},
nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'});
const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('</script','<\\/script');
const css=result.outputFiles.find(f=>f.path.endsWith('.css')).text.replaceAll('</style','<\\/style');
await mkdir(destination,{recursive:true});
const html=`<!doctype html><html lang="ru" data-nodedc-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"><title>Mission Core · Профили управления · Прототип</title><style>${css}</style></head><body data-nodedc-ui><div id="root"></div><script>${js}</script></body></html>`;
await writeFile(join(destination,'rover-control-preview.html'),html);
console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'}));
@@ -0,0 +1,69 @@
import {useState} from 'react';
import {createRoot} from 'react-dom/client';
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import '@nodedc/ui-core/styles.css';
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
import {trace} from './trace';
import './preview.css';
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
const percent=(n:number)=>`${Math.round(n*100)}%`;
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
const storageKey='missioncore.rover-control-preview.v1';
function load():ControlProfile {
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
catch{return {...defaultProfile};}
}
function App(){
const [profile,setProfile]=useState(load);
const [axes,setAxes]=useState<Axes>(emptyAxes);
const [open,setOpen]=useState(['settings','preview']);
const [saved,setSaved]=useState('');
const [light,setLight]=useState(false);
const change=(patch:Partial<ControlProfile>)=>{
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
};
const result=mix(profile,axes);
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
function save(){
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
}
function download(){
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
return <main data-nodedc-ui>
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
{saved&&<p role="status">{saved}</p>}
</SettingsCard>},
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
</SettingsCard>},
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
</SettingsCard>},
]}/>
</main>;
}
document.documentElement.dataset.nodedcTheme='dark';
createRoot(document.getElementById('root')!).render(<App/>);
@@ -0,0 +1,16 @@
/* Domain composition only; all controls come from the Design Guideline. */
body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); }
main { max-width:960px; margin:0 auto; padding:32px 24px 64px; }
main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; }
p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); }
main>header p { font-size:var(--nodedc-font-size-xs); }
.notice { margin-bottom:24px; }
.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; }
.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; }
.demands>div { display:flex; flex-direction:column; gap:8px; }
.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; }
.demands span { font-size:var(--nodedc-font-size-sm); }
.trace { display:grid; gap:20px; }
.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); }
@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} }
@@ -0,0 +1,38 @@
import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority';
import {type ControlProfile} from '../../../../packages/rover-control/src/profile';
/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */
export function trace(profile: ControlProfile): {label: string; decision: Decision}[] {
const model = new AuthorityModel(profile, ['left', 'right'], {
maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'],
}, 'preview');
const rows: {label:string;decision:Decision}[] = [];
let now = -50, token = '';
function step(value=0, link: 'live'|'lost'='live', core=false, request=false) {
now+=50;
const axes = {leftY:0,rightY:0,leftX:0,rightX:0};
axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value;
return model.step({now, link:{state:link,at:now},
axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])),
drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}},
requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined,
command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined,
});
}
const add=(label:string,decision:Decision)=>rows.push({label,decision});
for(let i=0;i<5;i++)step();
const grant=step(0,'live',false,true);token=grant.token??'';
add('Mission Core получил явный допуск',grant);
add('Автономная команда движения',step(0,'live',true));
add('Первое отклонение рычага',step(.8,'live',true));
for(let i=0;i<20;i++)step(.8);
add('Тот же рычаг удерживается',step(.8));
add('Остановка подтверждена, оба рычага отпущены',step());
for(let i=0;i<3;i++)step();
add('Непрерывная нейтраль подтверждена',step());
add('Второе отклонение — ручное движение',step(.8));
add('Запоздавшая команда прежней задачи',step(.8,'live',true));
add('Радиосвязь потеряна',step(0,'lost'));
add('Связь вернулась с отклонённым рычагом',step(.8));
return rows;
}
@@ -0,0 +1,5 @@
{
"extends":"../../tsconfig.app.json",
"compilerOptions":{"incremental":false,"tsBuildInfoFile":null},
"include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"]
}
@@ -0,0 +1,151 @@
package node
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
)
const boardLayoutSchema = "missioncore.board-layout/v1"
var boardSectionIDs = []string{"computer", "settings", "devices"}
type BoardLayout struct {
Schema string `json:"schema"`
Revision int64 `json:"revision"`
OpenSections []string `json:"open_sections"`
}
func validBoardSection(id string) bool {
for _, section := range boardSectionIDs {
if id == section {
return true
}
}
return false
}
func (p *PresentationStore) readBoardLayout() (BoardLayout, error) {
value := BoardLayout{Schema: boardLayoutSchema, OpenSections: append([]string{}, boardSectionIDs...)}
file, err := os.Open(filepath.Join(p.dir, "board-layout.json"))
if errors.Is(err, os.ErrNotExist) {
return value, nil
}
if err != nil {
return BoardLayout{}, err
}
defer file.Close()
d := json.NewDecoder(io.LimitReader(file, 4097))
d.DisallowUnknownFields()
value = BoardLayout{}
if d.Decode(&value) != nil || d.Decode(new(any)) != io.EOF || value.Schema != boardLayoutSchema || value.Revision < 0 || value.Revision >= 1<<53-1 || value.OpenSections == nil {
return BoardLayout{}, errors.New("invalid board layout")
}
seen := map[string]bool{}
for _, id := range value.OpenSections {
if !validBoardSection(id) || seen[id] {
return BoardLayout{}, errors.New("invalid board section")
}
seen[id] = true
}
return value, nil
}
func (p *PresentationStore) saveBoardLayout(value BoardLayout) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
file, err := os.CreateTemp(p.dir, ".board-layout-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(append(data, '\n')); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Rename(file.Name(), filepath.Join(p.dir, "board-layout.json")); err != nil {
return err
}
dir, err := os.Open(p.dir)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Server) boardLayoutRoutes(mux *http.ServeMux) {
p := s.Presentation
mux.HandleFunc("GET /api/presentation/board-layout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.readBoardLayout()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать раскладку борта"})
return
}
reply(w, 200, value)
})
mux.HandleFunc("PATCH /api/presentation/board-layout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return
}
var change struct {
Section string `json:"section"`
Open *bool `json:"open"`
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
d.DisallowUnknownFields()
if d.Decode(&change) != nil || d.Decode(new(any)) != io.EOF || change.Open == nil || !validBoardSection(change.Section) {
reply(w, 400, map[string]string{"error": "Некорректная раскладка борта"})
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.readBoardLayout()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать раскладку борта"})
return
}
next := []string{}
for _, id := range boardSectionIDs {
open := false
for _, saved := range value.OpenSections {
if saved == id {
open = true
}
}
if id == change.Section {
open = *change.Open
}
if open {
next = append(next, id)
}
}
value.OpenSections = next
value.Revision++
if p.saveBoardLayout(value) != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить раскладку борта"})
return
}
reply(w, 200, value)
})
}
@@ -0,0 +1,56 @@
package node
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestBoardLayoutPersistsEmptyAndConcurrentSectionChanges(t *testing.T) {
s, cookie := presentationServer(t)
var wg sync.WaitGroup
for _, id := range boardSectionIDs {
wg.Add(1)
go func(id string) {
defer wg.Done()
w := call(s, "PATCH", "/api/presentation/board-layout", `{"section":"`+id+`","open":false}`, cookie)
if w.Code != 200 {
t.Error(w.Code, w.Body.String())
}
}(id)
}
wg.Wait()
value, err := NewPresentationStore(s.Presentation.dir).readBoardLayout()
if err != nil || value.Revision != 3 || len(value.OpenSections) != 0 || value.OpenSections == nil {
t.Fatal(value, err)
}
info, err := os.Stat(filepath.Join(s.Presentation.dir, "board-layout.json"))
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal(info, err)
}
if w := call(s, "GET", "/api/presentation/board-layout", "", nil); w.Code != 401 {
t.Fatal(w.Code)
}
}
func TestBoardLayoutRejectsInvalidAndPreservesCorruptFile(t *testing.T) {
s, cookie := presentationServer(t)
for _, body := range []string{`{"section":"motor","open":true}`, `{"section":"computer"}`, `{"section":"computer","open":"true"}`, `{"section":"computer","open":true,"extra":0}`} {
if w := call(s, "PATCH", "/api/presentation/board-layout", body, cookie); w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
}
path := filepath.Join(s.Presentation.dir, "board-layout.json")
raw := []byte(`{"schema":"missioncore.board-layout/v1","revision":0,"open_sections":["motor"]}`)
if err := os.WriteFile(path, raw, 0600); err != nil {
t.Fatal(err)
}
if w := call(s, "PATCH", "/api/presentation/board-layout", `{"section":"computer","open":false}`, cookie); w.Code != 500 {
t.Fatal(w.Code)
}
got, _ := os.ReadFile(path)
if string(got) != string(raw) {
t.Fatal("Corrupt file replaced")
}
}
@@ -1,10 +1,12 @@
{
"schema": "missioncore.node.environment/v1",
"revision": "ubuntu-24.04-amd64/2",
"revision": "ubuntu-24.04-amd64/3",
"steps": [
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
{"id":"desktop-autostart","label":"Автозапуск приложения","description":"Открытие окна при входе в рабочий стол","requires":["node-service"]},
{"id":"usb-startup","label":"Обнаружение устройств при загрузке","description":"Автоматическое восстановление незавершённого подключения USB","requires":["platform"]},
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
@@ -152,6 +152,7 @@ func (p *Pairing) remoteHandler() http.Handler {
func (p *Pairing) Run(ctx context.Context) {
go p.channel(ctx)
go p.roverChannel(ctx)
var server *http.Server
endpoint := ""
serverIdentity := ""
@@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error {
}
func (s *Server) presentationRoutes(mux *http.ServeMux) {
s.boardLayoutRoutes(mux)
p := s.Presentation
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
@@ -0,0 +1,249 @@
package node
// Commands stream independently of telemetry. Only the local 20 Hz loop may
// deliver the latest unexpired intent to the single VESC owner.
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"sync"
"time"
)
func (p *Pairing) roverBinding() *CoreBinding {
p.mu.Lock()
defer p.mu.Unlock()
if p.state.Phase != "paired" || p.state.Binding == nil {
return nil
}
b := *p.state.Binding
return &b
}
func sameRoverBinding(a, b *CoreBinding) bool {
return a != nil && b != nil && a.BindingID == b.BindingID && a.Endpoint == b.Endpoint && a.ClientPEM == b.ClientPEM && a.EndpointRevision == b.EndpointRevision
}
func roverWait(ctx context.Context, delay time.Duration) bool {
if delay < 0 {
delay = 0
}
select {
case <-ctx.Done():
return false
case <-time.After(delay):
return true
}
}
func (p *Pairing) roverChannel(ctx context.Context) {
for ctx.Err() == nil {
binding := p.roverBinding()
if binding != nil && p.Sensors != nil {
p.runRoverChannel(ctx, *binding)
}
if !roverWait(ctx, time.Second) {
return
}
}
}
func (p *Pairing) runRoverChannel(parent context.Context, b CoreBinding) {
config, err := bindingTLS(b, p.store.pairingKey())
if err != nil {
return
}
transport := func() *http.Transport {
return &http.Transport{TLSClientConfig: config, Proxy: nil,
MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 10 * time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: 2 * time.Second,
DialContext: (&net.Dialer{Timeout: time.Second}).DialContext}
}
telemetry := &http.Client{Transport: transport(), Timeout: 300 * time.Millisecond, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect forbidden") }}
stream := &http.Client{Transport: transport(), CheckRedirect: telemetry.CheckRedirect}
defer telemetry.CloseIdleConnections()
defer stream.CloseIdleConnections()
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
return
}
relay := hex.EncodeToString(random)
id, _ := p.store.Public()
base := map[string]any{"schema": PairSchema, "node_id": id, "binding_id": b.BindingID,
"core_endpoint": b.Endpoint, "endpoint_revision": b.EndpointRevision, "relay_id": relay}
state := newRoverStreamState(time.Now())
ctx, cancel := context.WithCancel(parent)
var workers sync.WaitGroup
defer func() { cancel(); workers.Wait() }()
workers.Add(2)
go func() { defer workers.Done(); roverTelemetry(ctx, telemetry, b.Endpoint, base, state) }()
go func() { defer workers.Done(); roverCommands(ctx, stream, b.Endpoint, base, state) }()
var model *sensorModel
for i := range sensorModels {
if sensorModels[i].ID == "vesc.controller" {
model = &sensorModels[i]
break
}
}
if model == nil {
return
}
var diagnostic roverDiagnostics
for ctx.Err() == nil && sameRoverBinding(&b, p.roverBinding()) {
select {
case <-state.wake:
default:
}
started := time.Now()
watch, command, stop := state.delivery(started)
frame := roverFrame{}
if command != nil {
frame.Session, _ = command["id"].(string)
frame.Sequence, _ = command["sequence"].(float64)
frame.TTLMS, _ = command["ttl_ms"].(float64)
}
call, done := context.WithTimeout(ctx, 25*time.Millisecond)
result, driverErr := p.Sensors.modelDriver(call, model, "/remote", map[string]any{"watch": watch, "command": command, "relay_id": relay})
done()
frame.DriverMS = time.Since(started).Milliseconds()
if driverErr != nil {
state.stopStream()
frame.Stage = "driver_transport"
state.delivered(map[string]any{}, 0)
} else {
state.delivered(result, stop)
frame.State, _ = result["state"].(string)
}
diagnostic.record(started, frame)
// New intent and stop signals bypass the periodic watchdog tick. The
// buffered wake channel coalesces arrivals; it never queues commands.
timer := time.NewTimer(max(0, 50*time.Millisecond-time.Since(started)))
select {
case <-ctx.Done():
case <-state.wake:
case <-timer.C:
}
timer.Stop()
}
// Binding changes and channel shutdown explicitly retire any current intent.
call, done := context.WithTimeout(context.Background(), 25*time.Millisecond)
_, _ = p.Sensors.modelDriver(call, model, "/remote", map[string]any{"watch": false, "command": nil, "relay_id": relay})
done()
}
func roverTelemetry(ctx context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) {
var diagnostic roverDiagnostics
for ctx.Err() == nil {
started := time.Now()
snapshot, _ := state.view()
payload := make(map[string]any, len(base)+1)
for k, v := range base {
payload[k] = v
}
payload["rover"] = snapshot
raw, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint+"/v1/node/rover", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
frame := roverFrame{}
frame.State, _ = snapshot["state"].(string)
if frame.State == "preparing" || frame.State == "ready" || frame.State == "driving" || frame.State == "stopping" {
frame.Session, _ = snapshot["session_id"].(string)
}
response, err := client.Do(req)
if err != nil {
frame.Stage = "telemetry_transport"
} else {
frame.Status = response.StatusCode
var out roverStreamReply
readErr := json.NewDecoder(io.LimitReader(response.Body, 32768)).Decode(&out)
response.Body.Close()
if readErr != nil || response.StatusCode != 200 {
frame.Stage = "telemetry_response"
} else if !state.sampleClock(out.Clock, started, time.Now()) {
frame.Stage = "clock_invalid"
state.stopStream()
} else {
state.setWatch(out.Watch)
}
}
frame.CoreMS = time.Since(started).Milliseconds()
diagnostic.record(started, frame)
_, watch := state.view()
delay := 500 * time.Millisecond
if watch {
delay = 100 * time.Millisecond
}
if !roverWait(ctx, delay-time.Since(started)) {
return
}
}
}
func roverCommands(ctx context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) {
for ctx.Err() == nil {
_, watch := state.view()
if !watch {
if !roverWait(ctx, 50*time.Millisecond) {
return
}
continue
}
err := roverReadStream(ctx, client, endpoint, base, state)
state.stopStream()
if err != nil && ctx.Err() == nil {
log.Printf("{\"event\":\"rover-command-stream\",\"state\":\"disconnected\"}")
}
if !roverWait(ctx, 250*time.Millisecond) {
return
}
}
}
func roverReadStream(parent context.Context, client *http.Client, endpoint string, base map[string]any, state *roverStreamState) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
raw, _ := json.Marshal(base)
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint+"/v1/node/rover-stream", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
sent := time.Now()
response, err := client.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return errors.New("stream rejected")
}
// The native 200 ms output watchdog is unchanged. A silent stream also
// cancels its network read and retires intent; reconnect never rearms it.
guard := time.AfterFunc(350*time.Millisecond, cancel)
defer guard.Stop()
reader := bufio.NewReaderSize(response.Body, 32768)
first := true
for {
line, err := reader.ReadSlice('\n')
if err != nil {
return err
}
var out roverStreamReply
if json.Unmarshal(line, &out) != nil {
return errors.New("invalid command frame")
}
if first {
if !state.sampleClock(out.Clock, sent, time.Now()) {
return errors.New("invalid stream clock")
}
first = false
}
if !state.accept(out) {
return errors.New("invalid stream intent")
}
if !out.Watch {
return nil
}
if !guard.Reset(350 * time.Millisecond) {
return errors.New("stream deadline")
}
}
}
@@ -0,0 +1,61 @@
package node
// A bounded memory flight recorder. It never stores endpoints, certificates,
// payloads or raw HTTP errors, and never changes admission or lease deadlines.
import (
"encoding/json"
"log"
"time"
)
type roverFrame struct {
AtMS int64 `json:"at_ms"`
GapMS int64 `json:"gap_ms"`
BindingMS int64 `json:"binding_ms"`
CoreMS int64 `json:"core_ms"`
DriverMS int64 `json:"driver_ms"`
Status int `json:"http_status"`
Stage string `json:"error_stage,omitempty"`
Session string `json:"session,omitempty"`
Sequence float64 `json:"sequence"`
TTLMS float64 `json:"ttl_ms"`
State string `json:"state"`
}
type roverDiagnostics struct {
frames []roverFrame
last time.Time
started time.Time
state string
session string
emit func([]byte)
}
func (d *roverDiagnostics) record(at time.Time, frame roverFrame) {
if d.started.IsZero() {
d.started = at
}
frame.AtMS = at.Sub(d.started).Milliseconds()
if !d.last.IsZero() {
frame.GapMS = at.Sub(d.last).Milliseconds()
}
d.last = at
d.frames = append(d.frames, frame)
if len(d.frames) > 128 {
d.frames = d.frames[len(d.frames)-128:]
}
active := frame.Session != "" || d.session != ""
changed := frame.State != d.state || frame.Session != d.session
if active && (changed || frame.Stage != "" || frame.GapMS >= 200) {
raw, _ := json.Marshal(struct {
Event string `json:"event"`
Frames []roverFrame `json:"frames"`
}{"rover-channel", d.frames})
if d.emit != nil {
d.emit(raw)
} else {
log.Printf("%s", raw)
}
}
d.state, d.session = frame.State, frame.Session
}
@@ -0,0 +1,63 @@
package node
import (
"encoding/json"
"testing"
"time"
)
func TestRoverDiagnosticsRetainsCancellationCauseWithoutIdleLogSpam(t *testing.T) {
var events [][]byte
d := roverDiagnostics{emit: func(b []byte) { events = append(events, b) }}
at := time.Unix(100, 0)
for i := 0; i < 200; i++ {
d.record(at.Add(time.Duration(i)*50*time.Millisecond), roverFrame{State: "observing"})
}
if len(events) != 0 || len(d.frames) != 128 {
t.Fatal("idle must remain bounded and quiet")
}
at = at.Add(11 * time.Second)
d.record(at, roverFrame{Session: "session", Sequence: 1, TTLMS: 350, State: "preparing"})
d.record(at.Add(100*time.Millisecond), roverFrame{Session: "session", Sequence: 2, TTLMS: 320, State: "preparing"})
if len(events) != 1 {
t.Fatal("unchanged healthy frames should remain in memory")
}
d.record(at.Add(450*time.Millisecond), roverFrame{CoreMS: 300, Stage: "core_transport", State: "preparing"})
if len(events) != 2 {
t.Fatal("lost command needs diagnostic evidence")
}
var event struct {
Frames []roverFrame `json:"frames"`
}
if err := json.Unmarshal(events[1], &event); err != nil {
t.Fatal(err)
}
last := event.Frames[len(event.Frames)-1]
if last.Stage != "core_transport" || last.GapMS != 350 || last.CoreMS != 300 || last.Session != "" {
t.Fatal(last)
}
if event.Frames[len(event.Frames)-2].Sequence != 2 {
t.Fatal("last accepted frame lost")
}
}
func TestRoverDiagnosticsRecordsBindingWaitAndDriverTerminalState(t *testing.T) {
var events [][]byte
d := roverDiagnostics{emit: func(b []byte) { events = append(events, b) }}
at := time.Unix(100, 0)
d.record(at, roverFrame{Session: "session", State: "preparing"})
d.record(at.Add(500*time.Millisecond), roverFrame{Session: "session", State: "stopped", BindingMS: 400, DriverMS: 2})
if len(events) != 2 {
t.Fatal("terminal state must flush preceding transport history")
}
var event struct {
Frames []roverFrame `json:"frames"`
}
if err := json.Unmarshal(events[1], &event); err != nil {
t.Fatal(err)
}
last := event.Frames[len(event.Frames)-1]
if last.BindingMS != 400 || last.DriverMS != 2 || last.State != "stopped" {
t.Fatal(last)
}
}
@@ -0,0 +1,199 @@
package node
import (
"encoding/hex"
"math"
"sync"
"time"
)
type roverClock struct {
Instance string `json:"instance"`
MonotonicMS float64 `json:"monotonic_ms"`
}
type roverStreamReply struct {
Watch bool `json:"watch"`
Command map[string]any `json:"command"`
Clock roverClock `json:"control_clock"`
}
// Clock bounds use the request-send time, never RTT/2 or synchronized wall
// clocks. Server time was sampled after that send, so it gives an upper bound
// on server-minus-local monotonic offset even on an asymmetric network.
type roverStreamState struct {
mu sync.Mutex
origin time.Time
clockID string
upperMS float64
anchored time.Time
clockSeen time.Time
command map[string]any
watch bool
stopPending bool
stopRevision uint64
snapshot map[string]any
retired map[string]bool
inhibited bool
wake chan struct{}
}
func newRoverStreamState(now time.Time) *roverStreamState {
return &roverStreamState{origin: now, stopPending: true, stopRevision: 1, snapshot: map[string]any{}, retired: map[string]bool{}, wake: make(chan struct{}, 1)}
}
func finiteNumber(v any) (float64, bool) {
n, ok := v.(float64)
return n, ok && !math.IsNaN(n) && !math.IsInf(n, 0)
}
func (s *roverStreamState) sampleClock(c roverClock, sent, received time.Time) bool {
if len(c.Instance) != 32 {
return false
}
if _, err := hex.DecodeString(c.Instance); err != nil {
return false
}
if math.IsNaN(c.MonotonicMS) || math.IsInf(c.MonotonicMS, 0) || c.MonotonicMS < 0 || received.Before(sent) {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
upper := c.MonotonicMS - float64(sent.Sub(s.origin).Microseconds())/1000 + 1
if c.Instance != s.clockID {
s.invalidate()
s.clockID = c.Instance
s.upperMS = upper
s.anchored = received
} else if s.clockSeen.IsZero() || received.Sub(s.clockSeen) > 2*time.Second || upper < s.upperMS+float64(received.Sub(s.anchored).Microseconds())/1e6 {
s.upperMS = upper
s.anchored = received
}
s.clockSeen = received
return true
}
func (s *roverStreamState) accept(reply roverStreamReply) bool {
s.mu.Lock()
defer s.mu.Unlock()
if reply.Clock.Instance != s.clockID || s.clockID == "" {
return false
}
if reply.Command == nil {
if s.command != nil {
s.invalidate()
}
return true
}
command := reply.Command
if len(command) != 6 {
return false
}
for _, k := range []string{"id", "sequence", "left", "right", "settings", "expires_mono_ms"} {
if _, ok := command[k]; !ok {
return false
}
}
expires, ok := finiteNumber(command["expires_mono_ms"])
if !ok || expires > reply.Clock.MonotonicMS+400.001 {
return false
}
id, ok := command["id"].(string)
if !ok || len(id) != 32 {
return false
}
if _, err := hex.DecodeString(id); err != nil {
return false
}
sequence, ok := finiteNumber(command["sequence"])
if !ok || sequence < 0 || sequence >= 1<<53 || math.Trunc(sequence) != sequence {
return false
}
if s.inhibited || s.retired[id] {
return true
}
// The native driver independently validates identity, settings and sequence.
if s.stopPending {
s.retire(id)
return true
}
changed := s.command == nil || s.command["id"] != id || s.command["sequence"] != sequence
s.command = command
if changed {
s.notify()
}
return true
}
func (s *roverStreamState) notify() {
select {
case s.wake <- struct{}{}:
default:
}
}
func (s *roverStreamState) stopStream() {
s.mu.Lock()
defer s.mu.Unlock()
s.invalidate()
}
func (s *roverStreamState) retire(id string) {
if len(s.retired) >= 1024 {
s.inhibited = true // Fail closed instead of forgetting interrupted sessions.
return
}
s.retired[id] = true
}
func (s *roverStreamState) invalidate() {
wake := !s.stopPending || s.command != nil
if s.command != nil {
if id, ok := s.command["id"].(string); ok {
s.retire(id)
}
}
s.command = nil
s.stopPending = true
s.stopRevision++
if wake {
s.notify()
}
}
func (s *roverStreamState) delivery(now time.Time) (bool, map[string]any, uint64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stopPending {
return s.watch, nil, s.stopRevision
}
if s.command == nil {
return s.watch, nil, 0
}
if now.Sub(s.clockSeen) > 2*time.Second {
s.invalidate()
return s.watch, nil, s.stopRevision
}
expires, _ := finiteNumber(s.command["expires_mono_ms"])
// Reserve 25 ms for the private driver RPC and 5 ms plus 1000 ppm for clock
// quantization/rate uncertainty. Expiry is never extended by receipt time.
serverUpper := float64(now.Sub(s.origin).Microseconds())/1000 + s.upperMS + 5 + float64(now.Sub(s.anchored).Microseconds())/1e6
ttl := math.Min(400, expires-serverUpper-25)
if ttl <= 0 {
s.invalidate()
return s.watch, nil, s.stopRevision
}
out := make(map[string]any, 6)
for k, v := range s.command {
if k != "expires_mono_ms" {
out[k] = v
}
}
out["ttl_ms"] = ttl
return s.watch, out, 0
}
func (s *roverStreamState) delivered(snapshot map[string]any, stop uint64) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot = snapshot
if stop != 0 && stop == s.stopRevision {
s.stopPending = false
}
}
func (s *roverStreamState) view() (map[string]any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshot, s.watch
}
func (s *roverStreamState) setWatch(watch bool) { s.mu.Lock(); s.watch = watch; s.mu.Unlock() }
@@ -0,0 +1,235 @@
package node
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
const testClockID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func streamIntent(sequence int, serverNow, expires float64) roverStreamReply {
return roverStreamReply{Watch: true, Clock: roverClock{testClockID, serverNow}, Command: map[string]any{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sequence": float64(sequence), "left": float64(1), "right": float64(1),
"settings": map[string]any{"standstill_confirmed": true, "current_a": float64(30), "max_erpm": float64(2000)}, "expires_mono_ms": expires}}
}
func acknowledgeStop(s *roverStreamState, at time.Time) {
_, _, revision := s.delivery(at)
s.delivered(map[string]any{"state": "observing"}, revision)
}
func TestRoverStreamAsymmetricDelayRetainsOriginalExpiry(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
// Core's clock is +10 seconds. Request took 70 ms outbound and 130 ms
// inbound: dividing RTT in half would be incorrect on this connection.
if !s.sampleClock(roverClock{testClockID, 10070}, origin, origin.Add(200*time.Millisecond)) {
t.Fatal("clock")
}
acknowledgeStop(s, origin.Add(200*time.Millisecond))
frame := streamIntent(1, 10200, 10600)
if !s.accept(frame) {
t.Fatal("intent")
}
_, command, _ := s.delivery(origin.Add(280 * time.Millisecond))
if command == nil {
t.Fatal("fresh streamed intent rejected")
}
ttl := command["ttl_ms"].(float64)
if ttl <= 0 || 280+ttl > 600 {
t.Fatal("delay extended original browser deadline", ttl)
}
// Replaying the same frame does not receive a new deadline.
if !s.accept(frame) {
t.Fatal("repeat")
}
_, late, _ := s.delivery(origin.Add(650 * time.Millisecond))
if late != nil {
t.Fatal("expired frame revived motor intent")
}
}
func TestRoverStreamContinuousInputSurvivesMeasuredRelayJitter(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10050}, origin, origin.Add(150*time.Millisecond))
acknowledgeStop(s, origin.Add(150*time.Millisecond))
priorUntil := float64(0)
for i, delay := range []int{60, 90, 45, 110, 80, 50, 100, 60, 90, 50} {
sent := 200 + i*100
arrival := sent + delay
s.accept(streamIntent(i+1, float64(10000+sent), float64(10400+sent)))
if i > 0 && float64(arrival) >= priorUntil {
t.Fatal("lease gap under measured jitter", i, arrival, priorUntil)
}
_, command, _ := s.delivery(origin.Add(time.Duration(arrival) * time.Millisecond))
if command == nil {
t.Fatal("fresh command lost", i)
}
priorUntil = float64(arrival) + command["ttl_ms"].(float64)
if priorUntil > float64(sent+400) {
t.Fatal("end-to-end deadline expanded")
}
}
}
func TestRoverStreamDisconnectNeedsAcknowledgedStopBeforeNewFrames(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
s.accept(streamIntent(1, 10000, 10400))
s.stopStream()
_, _, oldStop := s.delivery(origin)
s.stopStream() // Another disconnect races the local driver's response.
s.delivered(map[string]any{}, oldStop)
s.accept(streamIntent(2, 10000, 10400))
_, command, newStop := s.delivery(origin)
if command != nil || newStop == 0 || newStop == oldStop {
t.Fatal("stale ACK removed stop barrier")
}
s.delivered(map[string]any{}, newStop)
_, command, _ = s.delivery(origin)
if command != nil {
t.Fatal("frame received before acknowledged stop was queued")
}
}
func TestRoverStreamClockChangeAndStaleClockDisarm(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
s.accept(streamIntent(1, 10000, 10400))
s.sampleClock(roverClock{"cccccccccccccccccccccccccccccccc", 2}, origin, origin)
_, command, stop := s.delivery(origin)
if command != nil || stop == 0 {
t.Fatal("Core restart retained intent")
}
if s.accept(streamIntent(2, 10000, 10400)) {
t.Fatal("old clock accepted")
}
acknowledgeStop(s, origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
// A fresh-looking frame alone cannot renew the clock calibration.
fresh := streamIntent(3, 13000, 13400)
fresh.Command["id"] = "dddddddddddddddddddddddddddddddd"
s.accept(fresh)
_, command, stop = s.delivery(origin.Add(3 * time.Second))
if command != nil || stop == 0 {
t.Fatal("stale clock allowed output")
}
}
func TestRoverStreamUnsentIntentCannotStartAfterReconnect(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
frame := streamIntent(1, 10000, 10400)
s.accept(frame)
// The network fails before the local driver ever sees this session.
s.stopStream()
acknowledgeStop(s, origin)
s.accept(streamIntent(2, 10010, 10410))
_, command, _ := s.delivery(origin)
if command != nil {
t.Fatal("undelivered old session started after reconnect")
}
frame.Command["id"] = "dddddddddddddddddddddddddddddddd"
s.accept(frame)
_, command, _ = s.delivery(origin)
if command == nil {
t.Fatal("new explicit session rejected after stop")
}
}
func TestRoverStreamIntentWakesImmediatelyAndCoalescesWithoutReplayWake(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
for i := 1; i <= 3; i++ {
s.accept(streamIntent(i, 10000, 10400))
}
if len(s.wake) != 1 {
t.Fatal("intent notifications must coalesce")
}
<-s.wake
_, command, _ := s.delivery(origin)
if command["sequence"] != float64(3) {
t.Fatal("queued an intermediate command")
}
s.accept(streamIntent(3, 10000, 10400))
if len(s.wake) != 0 {
t.Fatal("replayed frame woke driver again")
}
s.stopStream()
if len(s.wake) != 1 {
t.Fatal("stop did not wake driver")
}
<-s.wake
s.stopStream()
if len(s.wake) != 0 {
t.Fatal("repeated failure would spin the local loop")
}
}
func TestRoverStreamRejectsExtendedDeadlineAndUnknownCommandFields(t *testing.T) {
origin := time.Unix(100, 0)
s := newRoverStreamState(origin)
s.sampleClock(roverClock{testClockID, 10000}, origin, origin)
acknowledgeStop(s, origin)
if s.accept(streamIntent(1, 10000, 10401)) {
t.Fatal("extended deadline accepted")
}
frame := streamIntent(1, 10000, 10400)
frame.Command["queued"] = true
if s.accept(frame) {
t.Fatal("unknown field accepted")
}
for _, sequence := range []any{map[string]any{}, []any{1}, "1", 1.5, float64(1 << 53)} {
bad := streamIntent(1, 10000, 10400)
bad.Command["sequence"] = sequence
if s.accept(bad) {
t.Fatal("malformed sequence accepted")
}
}
}
func TestRoverStreamSilenceCancelsReadWithoutWaitingForTelemetry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
json.NewEncoder(w).Encode(roverStreamReply{Watch: true, Clock: roverClock{testClockID, 10000}})
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
state := newRoverStreamState(time.Now())
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if roverReadStream(ctx, server.Client(), server.URL, map[string]any{}, state) == nil {
t.Fatal("silent stream accepted")
}
if time.Since(started) > time.Second {
t.Fatal("silent stream did not cancel its read")
}
}
func TestRoverStreamReadsMultipleFramesOnOneResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/node/rover-stream" {
t.Error("wrong path")
}
w.Header().Set("Content-Type", "application/x-ndjson")
for i := 0; i < 3; i++ {
raw, _ := json.Marshal(roverStreamReply{Watch: i < 2, Clock: roverClock{testClockID, 10000 + float64(i)}})
fmt.Fprintln(w, string(raw))
w.(http.Flusher).Flush()
}
}))
defer server.Close()
state := newRoverStreamState(time.Now())
if err := roverReadStream(context.Background(), server.Client(), server.URL, map[string]any{}, state); err != nil {
t.Fatal(err)
}
}
@@ -46,11 +46,11 @@ var sensorModels = []sensorModel{
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second},
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")},
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.6.3",
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.7.4",
Vendor: "0483", Product: "5740", USBName: "ChibiOS/RT Virtual COM Port", Socket: "/run/mission-core-vesc/driver.sock",
PrepareUnit: "mission-core-node-vesc-prepare.service", Report: "/var/lib/mission-core-node-profiles/vesc/preparation.json",
ActionTimeouts: map[string]time.Duration{"vesc.link.check": 45 * time.Second, "vesc.motor.run": 90 * time.Second, "vesc.drive.run": 120 * time.Second, "vesc.hall.measure": 60 * time.Second, "vesc.foc.calibrate": 300 * time.Second, "vesc.motor.pulse": 60 * time.Second, "vesc.control.release": 60 * time.Second},
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")},
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.layout", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")},
}
func modelForDevice(id string) *sensorModel {
@@ -6,10 +6,29 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
)
func TestVESCDeclaredVersionMatchesBundledDriver(t *testing.T) {
model := modelForDevice("vesc_00000000000000000000000000000000")
root := filepath.Join("..", "..", "..", "..", "plugins", "vesc")
driver, err := os.ReadFile(filepath.Join(root, "runtime", "__init__.py"))
if err != nil {
t.Fatal(err)
}
match := regexp.MustCompile(`(?m)^VERSION = "([^"]+)"`).FindSubmatch(driver)
if model == nil || len(match) != 2 || model.Version != string(match[1]) {
t.Fatal("Node must admit the exact driver shipped in the same release", model, string(driver))
}
preparation, err := os.ReadFile(filepath.Join(root, "packaging", "prepare.py"))
if err != nil || !strings.Contains(string(preparation), `"version": "`+model.Version+`"`) {
t.Fatal("VESC preparation must declare the bundled driver version", err)
}
}
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
t.Helper()
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
+1 -1
View File
@@ -354,7 +354,7 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
}
func sensorViewAction(action string) bool {
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.config.backup"
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.limits.read" || action == "vesc.config.backup"
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
+8 -3
View File
@@ -11,8 +11,8 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
BINARY_VERSION = "0.8.35"
VERSION = "0.8.35-1"
BINARY_VERSION = "0.8.45"
VERSION = "0.8.45-1"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
@@ -44,7 +44,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, usbutils, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
Description: Mission Core onboard computer configuration
Local graphical setup, host inventory, SSH access and persistent node identity.
""".encode()
@@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
("desktop_startup.py", "usr/lib/mission-core-node/desktop_startup.py", 0o644),
("mission-core-node-autostart.desktop", "usr/share/mission-core-node/mission-core-node-autostart.desktop", 0o644),
("usb-startup.json", "usr/share/mission-core-node/usb-startup.json", 0o644),
("usb_startup_recovery.py", "usr/lib/mission-core-node/usb_startup_recovery.py", 0o644),
("mission-core-node-usb-startup.service", "usr/lib/systemd/system/mission-core-node-usb-startup.service", 0o644),
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755),
@@ -0,0 +1,30 @@
"""Unprivileged XDG startup: wait for Node before opening its ordinary UI."""
import http.client
import os
import time
def wait_for_service(now=time.monotonic, sleep=time.sleep, connection=http.client.HTTPConnection):
deadline = now() + 180
while now() < deadline:
client = connection('127.0.0.1', 8780, timeout=2)
try:
client.request('GET', '/')
response = client.getresponse()
if response.status == 200:
return True
except (OSError, http.client.HTTPException):
pass
finally:
client.close()
sleep(1)
return False
if __name__ == '__main__':
if os.geteuid() == 0:
raise SystemExit('Run in the normal graphical user session')
wait_for_service()
# Gtk.Application retains one window per desktop session. Its ordinary
# polkit authorization and error handling are unchanged.
os.execv('/usr/bin/mission-core-node', ['/usr/bin/mission-core-node'])
@@ -219,7 +219,33 @@ def tailscale_install():
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
def desktop_autostart():
owned_config(Path('/usr/share/mission-core-node/mission-core-node-autostart.desktop'),
Path('/etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop'))
return "Окно приложения открывается при входе в рабочий стол. Служба БК работает и без входа."
def usb_startup():
# Enabling the next boot is distinct from executing recovery now. This
# workflow never resets devices in a running operator session.
owned_config(Path('/usr/share/mission-core-node/usb-startup.json'),
Path('/etc/mission-core-node/usb-startup.json'))
command(['/usr/bin/systemctl', 'daemon-reload'])
command(['/usr/bin/systemctl', 'enable', 'mission-core-node-usb-startup.service'])
command(['/usr/bin/systemctl', 'is-enabled', 'mission-core-node-usb-startup.service'])
report = json.loads(command(['/usr/bin/python3', '-I', '-B', '/usr/lib/mission-core-node/usb_startup_recovery.py', '--inspect'], timeout=75))
if report.get('state') != 'inspected':
raise SetupError("Не удалось проверить поддержку восстановления USB. Повторите настройку.")
supported = sum(h.get('individual_power') is True for h in report.get('hubs', []))
if not supported:
return "Проверка при загрузке включена. Поддержка отдельного переключения USB-портов не подтверждена; они будут пропущены."
return "Восстановление при загрузке включено для поддерживаемых USB-портов. Обнаруженные устройства сохраняют подключение."
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service,
"desktop-autostart": desktop_autostart, "usb-startup": usb_startup,
"network-inventory": network_inventory, "usb-inventory": usb_inventory,
"ssh-service": ssh_service, "tailscale-install": tailscale_install}
def run_steps(profile, operations, save):
@@ -243,6 +243,9 @@ def main():
sys.path.insert(0, str(node / "packaging"))
from build_deb import BINARY_VERSION, VERSION, build
run("node-environment-tests", ["/usr/bin/python3", "-m", "unittest", "test_environment_helper", "test_usb_startup_recovery", "test_desktop_startup", "-v"], cwd=node / "packaging")
run("owner-release-staging-tests", ["/usr/bin/python3", "plugins/insta360-x4/packaging/test_owner_release_entry.py"], cwd=repo)
run("node-usb-unit-validation", ["/usr/bin/systemd-analyze", "verify", str(node / "packaging/mission-core-node-usb-startup.service")], cwd=node)
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
binary = node / "build/node-agent-linux-amd64"
run(
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Mission Core Node
Comment=Настройка и диагностика бортового компьютера
TryExec=/usr/bin/mission-core-node
Exec=/usr/bin/python3 -I -B /usr/lib/mission-core-node/desktop_startup.py
Icon=org.nodedc.MissionCoreNode
Terminal=false
StartupNotify=false
@@ -0,0 +1,35 @@
[Unit]
Description=Mission Core bounded USB startup recovery
Wants=systemd-udev-settle.service
After=systemd-udev-settle.service
Before=mission-core-node.service mission-core-vesc.service
ConditionPathExists=/etc/mission-core-node/usb-startup.json
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py
ExecStopPost=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py --restore
TimeoutStartSec=130
TimeoutStopSec=15
RuntimeDirectory=mission-core-usb-startup
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
UMask=0077
NoNewPrivileges=yes
PrivateNetwork=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ReadWritePaths=/sys/devices /run/mission-core-usb-startup
DevicePolicy=closed
DeviceAllow=char-usb_device rw
TasksMax=16
MemoryMax=64M
[Install]
WantedBy=multi-user.target
+8
View File
@@ -14,6 +14,14 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
exit 1
fi
fi
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
case "$mc_node_usb_job" in
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
esac
if [ -f /run/mission-core-usb-startup/pending.json ]; then
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
exit 1
fi
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
case "$mc_node_x4_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
+28
View File
@@ -13,6 +13,14 @@ if [ -d /run/systemd/system ]; then
exit 1
fi
fi
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
case "$mc_node_usb_job" in
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
esac
if [ -f /run/mission-core-usb-startup/pending.json ]; then
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
exit 1
fi
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
case "$mc_node_x4_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
@@ -34,6 +42,25 @@ if [ -d /run/systemd/system ]; then
;;
esac
fi
retire_startup_settings() {
for mc_node_owned in /etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop /etc/mission-core-node/usb-startup.json; do
case "$mc_node_owned" in
*.desktop) mc_node_template=/usr/share/mission-core-node/mission-core-node-autostart.desktop ;;
*.json) mc_node_template=/usr/share/mission-core-node/usb-startup.json ;;
esac
if [ ! -L "$mc_node_owned" ] && cmp -s "$mc_node_template" "$mc_node_owned"; then
rm "$mc_node_owned"
fi
done
if [ -d /run/systemd/system ]; then
systemctl disable --now mission-core-node-usb-startup.service
fi
}
# A downgrade removes helpers introduced in 0.8.37. Retire their owned
# configuration first; do not leave an enabled unit or XDG entry dangling.
if [ "$1" = upgrade ] && [ -n "${2:-}" ] && dpkg --compare-versions "$2" lt 0.8.37-1; then
retire_startup_settings
fi
case "$1" in
upgrade|remove|deconfigure)
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
@@ -47,6 +74,7 @@ case "$1" in
rm /etc/udev/rules.d/70-mission-core-vesc.rules
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
fi
retire_startup_settings
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
if [ -e "$mc_node_ssh_snippet" ]; then
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
@@ -0,0 +1,25 @@
import unittest
from unittest.mock import Mock
import desktop_startup as startup
class DesktopStartupTests(unittest.TestCase):
def test_waits_for_service_without_credentials_and_closes_each_connection(self):
clock = [0]
def sleep(seconds): clock[0] += seconds
client = Mock()
client.getresponse.side_effect = [OSError('not started'), Mock(status=503), Mock(status=200)]
factory = Mock(return_value=client)
self.assertTrue(startup.wait_for_service(lambda: clock[0], sleep, factory))
self.assertEqual(clock[0], 2)
self.assertEqual(client.close.call_count, 3)
self.assertEqual(client.request.call_args.args, ('GET', '/'))
def test_stopped_service_does_not_hold_startup_forever(self):
clock = [0]
def sleep(seconds): clock[0] += seconds
client = Mock()
client.request.side_effect = OSError('offline')
self.assertFalse(startup.wait_for_service(lambda: clock[0], sleep, lambda *a, **kw: client))
self.assertEqual(clock[0], 180)
self.assertEqual(client.close.call_count, 180)
@@ -15,6 +15,24 @@ PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-pro
class EnvironmentWorkflowTests(unittest.TestCase):
def test_profile_and_shipped_operations_match(self):
self.assertEqual({s['id'] for s in PROFILE['steps']}, set(helper.OPERATIONS))
def test_usb_setup_enables_next_boot_without_resetting_live_devices(self):
def command(argv, **_):
return json.dumps({'state': 'inspected', 'hubs': [{'individual_power': True}]}) if '--inspect' in argv else ''
with patch.object(helper, 'owned_config') as config, patch.object(helper, 'command', side_effect=command) as run:
self.assertIn('включено', helper.usb_startup())
config.assert_called_once()
for call in run.call_args_list:
self.assertFalse({'--now', 'start', 'restart'} & set(call.args[0]))
self.assertTrue(any('--inspect' in call.args[0] for call in run.call_args_list))
def test_autostart_preserves_foreign_configuration_and_does_not_start_a_gui_as_root(self):
with patch.object(helper, 'owned_config', side_effect=helper.SetupError('conflict')), patch.object(helper, 'command') as run:
with self.assertRaises(helper.SetupError): helper.desktop_autostart()
run.assert_not_called()
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
saved = []
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
@@ -0,0 +1,220 @@
"""Synthetic sysfs/journal only: these tests never open a physical USB port."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import usb_startup_recovery as recovery
def event(second, message, transport='kernel'):
return {'__MONOTONIC_TIMESTAMP': str(int(second * 1000000)),
'_TRANSPORT': transport, 'MESSAGE': message}
class JournalTests(unittest.TestCase):
def test_only_terminal_boot_failures_with_no_later_connection_are_candidates(self):
records = [event(20, 'usb usb1-port2: unable to enumerate USB device'),
event(18, 'usb usb1-port3: unable to enumerate USB device'),
event(23, 'usb 1-3: new full-speed USB device number 8 using xhci_hcd'),
event(12, 'usb 1-1-port2: unable to enumerate USB device'),
event(65, 'usb usb2-port1: unable to enumerate USB device'),
event(15, 'usb usb2-port3: unable to enumerate USB device', 'stdout'),
event(18, 'usb 1-4: device descriptor read/64, error -71')]
self.assertEqual(recovery.failed_ports(list(reversed(records))), ['1-1-port2', 'usb1-port2'])
def test_success_or_user_disconnect_invalidates_old_failure(self):
for message in ['New USB device found, idVendor=0000', 'USB disconnect, device number 8']:
self.assertEqual(recovery.failed_ports([
event(20, 'usb usb1-port2: unable to enumerate USB device'), event(21, 'usb 1-2: ' + message)]), [])
def test_invalid_port_names_cannot_be_paths(self):
for name in ['../../etc/passwd', 'usb0-port0', 'usb1-port1/disable']:
with self.assertRaises(ValueError): recovery.child_name(name)
self.assertEqual(recovery.child_name('1-2.3-port4'), '1-2.3.4')
class SysfsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
base = Path(self.temp.name)
self.root, self.physical = base/'bus', base/'devices'
self.root.mkdir(); self.physical.mkdir()
self.usb = recovery.USB(self.root, self.physical)
self.ports = []
for n in (1, 2):
hub = self.physical/f'usb{n}'; hub.mkdir()
for key, value in {'bDeviceClass':'09', 'busnum':str(n), 'devnum':'1'}.items():
(hub/key).write_text(value)
(self.root/hub.name).symlink_to(hub, target_is_directory=True)
interface = hub/f'{n}-0:1.0'; interface.mkdir()
(self.root/interface.name).symlink_to(interface, target_is_directory=True)
port = interface/f'usb{n}-port2'; port.mkdir()
for key, value in {'state':'not attached', 'disable':'0\n', 'over_current_count':'0', 'connect_type':'unknown'}.items():
(port/key).write_text(value)
self.ports.append(port)
(self.ports[0]/'peer').symlink_to(self.ports[1], target_is_directory=True)
(self.ports[1]/'peer').symlink_to(self.ports[0], target_is_directory=True)
def plan(self):
with patch.object(recovery, 'run', return_value=' wHubCharacteristic 0x0009\n'):
return self.usb.plan('usb1-port2')
def test_individual_power_pair_can_be_disabled_and_restored(self):
entries = self.plan()
self.assertEqual([e['name'] for e in entries], ['usb1-port2', 'usb2-port2'])
for entry in entries: self.usb.write(entry, True)
self.assertTrue(all((p/'disable').read_text() == '1\n' for p in self.ports))
for entry in reversed(entries): self.usb.write(entry, False)
self.assertTrue(all((p/'disable').read_text() == '0\n' for p in self.ports))
def test_connected_companion_blocks_both_ports_before_descriptor_query(self):
(self.ports[1]/'device').symlink_to(self.physical/'camera')
with patch.object(recovery, 'run') as run:
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
run.assert_not_called()
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
def test_non_individual_power_or_missing_descriptor_cannot_reset(self):
for output in ['wHubCharacteristics 0x0000', 'wHubCharacteristics 0x0002', '']:
with self.subTest(output=output), patch.object(recovery, 'run', return_value=output):
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
def test_internal_disabled_overcurrent_and_mid_enumeration_ports_skipped(self):
for attribute, value in [('connect_type', 'hardwired'), ('disable', '1'), ('over_current_count', '1'), ('state', 'powered')]:
p = self.ports[0]/attribute; original = p.read_text(); p.write_text(value)
with self.subTest(attribute=attribute), self.assertRaises(OSError): self.plan()
p.write_text(original)
def test_attachment_after_planning_prevents_write(self):
entries = self.plan()
(self.ports[0]/'device').symlink_to(self.physical/'new-device')
with self.assertRaises(OSError): self.usb.write(entries[0], True)
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
def test_hub_replacement_after_planning_prevents_write(self):
entries = self.plan()
(self.physical/'usb1'/'devnum').write_text('4')
with self.assertRaises(OSError): self.usb.write(entries[0], True)
def test_first_hub_replaced_while_inspecting_companion_invalidates_plan(self):
def inspect(name):
if name == 'usb2-port2': (self.physical/'usb1'/'devnum').write_text('4')
return True
with patch.object(self.usb, 'individual_power', side_effect=inspect):
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
def test_enabled_port_is_not_written_during_cleanup(self):
entries = self.plan()
with patch.object(recovery.os, 'open', side_effect=AssertionError('No write expected')):
self.usb.write(entries[0], False)
def test_foreign_companion_path_is_rejected(self):
(self.ports[0]/'peer').unlink()
(self.ports[0]/'peer').symlink_to(self.root)
with self.assertRaises(OSError): self.usb.companions('usb1-port2')
class RecoveryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
self.state = Path(self.temp.name)
self.clock = 30
self.usb = Mock()
self.entries = [{'name': 'usb1-port2', 'generation': {'address': 1}},
{'name': 'usb2-port2', 'generation': {'address': 1}}]
self.usb.plan.return_value = self.entries
self.usb.empty.return_value = True
self.usb.generation.return_value = {'address': 1}
self.usb.outcome.return_value = {'idVendor': '0000', 'idProduct': '0001', 'product': 'Synthetic'}
def sleep(self, seconds): self.clock += seconds
def run_recovery(self, ports=None):
return recovery.recover(self.usb, self.state, 'boot', ports or ['usb1-port2'], lambda: self.clock, self.sleep)
def test_pair_is_attempted_only_once_and_restored_before_enumeration_check(self):
result = self.run_recovery(['usb1-port2', 'usb2-port2'])
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['state'], 'enumerated')
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
self.assertFalse((self.state/'pending.json').exists())
def test_second_disable_failure_restores_both_and_keeps_failure_detail(self):
def write(entry, disabled):
if disabled and entry['name'] == 'usb2-port2': raise OSError('failed second write')
self.usb.write.side_effect = write
result = self.run_recovery()
self.assertIn('failed second write', result[0]['reason'])
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
self.assertFalse((self.state/'pending.json').exists())
def test_termination_during_off_period_restores_ports(self):
def interrupted(_): raise InterruptedError('stop')
result = recovery.recover(self.usb, self.state, 'boot', ['usb1-port2'], lambda: self.clock, interrupted)
self.assertIn('stop', result[0]['reason'])
self.assertFalse((self.state/'pending.json').exists())
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
def test_restore_failure_preserves_pending_and_stops_other_ports(self):
def write(_, disabled):
if not disabled: raise OSError('restore failed')
self.usb.write.side_effect = write
result = self.run_recovery(['usb1-port2', 'usb1-port3'])
self.assertEqual(result[0]['state'], 'restore_failed')
self.usb.plan.assert_called_once()
self.assertTrue((self.state/'pending.json').exists())
self.usb.write.side_effect = None
recovery.restore(self.usb, self.state, 'boot')
self.assertFalse((self.state/'pending.json').exists())
def test_slow_planning_cannot_start_a_late_reset(self):
def plan(_): self.clock = 179; return self.entries
self.usb.plan.side_effect = plan
result = self.run_recovery()
self.assertIn('deadline', result[0]['reason'])
self.usb.write.assert_not_called()
def test_failed_enumeration_has_no_repeated_power_loop(self):
self.usb.outcome.return_value = None
result = self.run_recovery()
self.assertEqual(result[0]['state'], 'retried')
self.assertEqual(self.clock, 39)
self.assertEqual(self.usb.write.call_count, 4)
def test_previous_boot_pending_state_cannot_address_current_ports(self):
recovery.atomic(self.state/'pending.json', {'boot_id': 'old', 'ports': self.entries})
with self.assertRaises(OSError): recovery.restore(self.usb, self.state, 'new')
self.usb.write.assert_not_called()
def test_boot_wait_occurs_before_journal_snapshot_and_second_run_preserves_report(self):
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
self.clock = 5
def journal():
self.assertGreaterEqual(self.clock, recovery.MIN_AGE)
return []
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
patch.object(recovery, 'state_directory'), patch.object(recovery, 'enabled'), \
patch.object(recovery.os, 'geteuid', return_value=0), patch.object(recovery.sys, 'argv', ['helper']), \
patch.object(recovery.time, 'monotonic', side_effect=lambda: self.clock), \
patch.object(recovery.time, 'sleep', side_effect=self.sleep), \
patch.object(recovery, 'journal', side_effect=journal) as read, patch('builtins.print'):
self.assertEqual(recovery.main(), 0)
first = (self.state/'result.json').read_bytes()
self.assertEqual(recovery.main(), 0)
self.assertEqual((self.state/'result.json').read_bytes(), first)
read.assert_called_once()
def test_late_service_start_cannot_read_or_reset_ports(self):
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
patch.object(recovery, 'state_directory'), patch.object(recovery.os, 'geteuid', return_value=0), \
patch.object(recovery.sys, 'argv', ['helper']), patch.object(recovery.time, 'monotonic', return_value=200), \
patch.object(recovery, 'journal') as read, patch.object(recovery, 'recover') as reset, patch('builtins.print'):
self.assertEqual(recovery.main(), 0)
read.assert_not_called(); reset.assert_not_called()
self.assertEqual(json.loads((self.state/'result.json').read_text())['reason'], 'Outside startup window')
if __name__ == '__main__': unittest.main()
@@ -0,0 +1 @@
{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"}
@@ -0,0 +1,332 @@
"""One bounded boot-time retry of failed USB enumeration; no device commands.
Only root's fixed systemd job may apply. No request supplies a path or command.
Healthy ports (including USB3 companions) and non-individual-power hubs are
excluded. The application still identifies controllers by firmware UUID.
"""
import fcntl
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import time
STATE = Path('/run/mission-core-usb-startup')
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
POLICY = Path('/etc/mission-core-node/usb-startup.json')
EXPECTED_POLICY = {'schema': 'missioncore.node.usb-startup-policy/v1', 'enabled': True,
'mode': 'terminal-enumeration-failures'}
BOOT_WINDOW = 180
FAILURE_WINDOW = 60
MIN_AGE = 30
BUDGET = 90
PORT = re.compile(r'(usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port([1-9][0-9]*)')
FAILURE = re.compile(r'usb ((?:usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port[1-9][0-9]*): unable to enumerate USB device')
def child_name(port):
match = PORT.fullmatch(port)
if not match:
raise ValueError('Invalid USB port')
hub, number = match.groups()
return hub[3:] + '-' + number if hub.startswith('usb') else hub + '.' + number
def failed_ports(records):
"""Ignore failures superseded by a later connection/disconnection event."""
failures, changes = {}, {}
for entry in sorted(records, key=lambda r: int(r.get('__MONOTONIC_TIMESTAMP', 0))):
if entry.get('_TRANSPORT') != 'kernel':
continue
stamp = int(entry.get('__MONOTONIC_TIMESTAMP', 0)) / 1000000
message = entry.get('MESSAGE', '')
if not isinstance(message, str):
continue
match = FAILURE.fullmatch(message)
if match and 0 < stamp <= FAILURE_WINDOW:
failures[match[1]] = stamp
match = re.match(r'usb ([0-9]+-[0-9]+(?:\.[0-9]+)*): (?:new |New USB device found|USB disconnect)', message)
if match:
changes[match[1]] = stamp
return [p for p, stamp in sorted(failures.items()) if changes.get(child_name(p), 0) <= stamp]
def run(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
except subprocess.SubprocessError as error:
raise OSError('USB inspection command timed out or failed') from error
if result.returncode or len(result.stdout) > 2 * 1024**2:
raise OSError('USB inspection command failed')
return result.stdout
def journal():
raw = run(['/usr/bin/journalctl', '-k', '-b', '0', '--no-pager', '-o', 'json', '-n', '2000',
'--grep=unable to enumerate USB device|new .* USB device|New USB device found|USB disconnect'])
return [json.loads(line) for line in raw.splitlines()]
def atomic(path, value):
temporary = path.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(value, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
class USB:
def __init__(self, root=Path('/sys/bus/usb/devices'), physical=Path('/sys/devices')):
self.root, self.physical = root, physical.resolve()
def resolve(self, name):
match = PORT.fullmatch(name)
if not match:
raise ValueError('Invalid port name')
hub = match[1]
interface = hub[3:] + '-0' if hub.startswith('usb') else hub
paths = list(self.root.glob(interface + ':*/' + name))
if len(paths) != 1:
raise OSError('USB port topology unavailable')
path = paths[0].resolve(strict=True)
if not path.is_relative_to(self.physical) or path.name != name:
raise OSError('USB port outside sysfs devices')
return path
def generation(self, name):
hub = self.root / PORT.fullmatch(name)[1]
if (hub / 'bDeviceClass').read_text().strip() != '09':
raise OSError('Parent is not a USB hub')
return {'bus': int((hub / 'busnum').read_text()), 'address': int((hub / 'devnum').read_text()),
'path': str(hub.resolve(strict=True)), 'inode': hub.stat().st_ino}
def empty(self, name):
p = self.resolve(name)
return (not os.path.lexists(p / 'device') and
(p / 'state').read_text().strip() == 'not attached' and
(p / 'disable').read_text().strip() == '0' and
(p / 'over_current_count').read_text().strip() == '0' and
(p / 'connect_type').read_text().strip() in ('hotplug', 'unknown'))
def companions(self, name):
p = self.resolve(name)
names = [name]
if os.path.lexists(p / 'peer'):
peer = (p / 'peer').resolve(strict=True)
if not peer.is_relative_to(self.physical) or self.resolve(peer.name) != peer:
raise OSError('USB companion topology changed')
if (peer / 'peer').resolve(strict=True) != p:
raise OSError('USB companion is not reciprocal')
names.append(peer.name)
return names
def individual_power(self, name):
before = self.generation(name)
raw = run(['/usr/bin/lsusb', '-v', '-s', f"{before['bus']:03}:{before['address']:03}"])
characteristics = re.findall(r'^\s*wHubCharacteristic[s]?\s+0x([0-9a-fA-F]+)\s*$', raw, re.M)
return (before == self.generation(name) and len(characteristics) == 1 and
int(characteristics[0], 16) & 3 == 1)
def plan(self, name):
names = self.companions(name)
entries = [{'name': n, 'generation': self.generation(n)} for n in names]
# Test every companion before querying hub descriptors or opening any port.
if not all(self.empty(n) for n in names):
raise OSError('Port or USB companion already attached, disabled, internal or over-current')
if not all(self.individual_power(n) for n in names):
raise OSError('Individual USB port power switching is not confirmed')
if any(self.generation(e['name']) != e['generation'] for e in entries):
raise OSError('USB hub changed during companion inspection')
return entries
def write(self, entry, disabled):
name = entry['name']
if self.generation(name) != entry['generation']:
raise OSError('USB hub generation changed')
if disabled and not self.empty(name):
raise OSError('USB port became occupied')
p = self.resolve(name) / 'disable'
# Pending state is durable before the first write. An interrupted or
# rejected first write must not cause a redundant enable on a port
# which has since successfully enumerated.
if not disabled and p.read_text().strip() == '0':
return
fd = os.open(p, os.O_WRONLY | os.O_NOFOLLOW)
try:
if self.generation(name) != entry['generation'] or (disabled and not self.empty(name)):
raise OSError('USB topology changed before write')
if os.write(fd, b'1\n' if disabled else b'0\n') != 2:
raise OSError('Incomplete USB port write')
finally:
os.close(fd)
def outcome(self, name):
p = self.resolve(name)
if not (p / 'device').exists():
return None
child = (p / 'device').resolve(strict=True)
if not child.is_relative_to(self.physical):
raise OSError('Unexpected USB child')
return {key: (child / key).read_text().strip() for key in ('idVendor', 'idProduct', 'product')}
def restore(usb, state, boot):
p = state / 'pending.json'
if not p.exists():
return
pending = json.loads(p.read_text())
if pending['boot_id'] != boot:
raise OSError('Pending recovery belongs to another boot')
errors = []
for entry in reversed(pending['ports']):
try:
usb.write(entry, False)
except OSError as error:
errors.append(str(error))
if errors:
raise OSError('; '.join(errors))
p.unlink()
def recover(usb, state, boot, candidates, now=time.monotonic, sleep=time.sleep):
deadline = min(now() + BUDGET, BOOT_WINDOW)
results, processed = [], set()
for name in candidates[:32]:
if name in processed:
continue
item = {'port': name, 'state': 'skipped'}
results.append(item)
if now() + 12 >= deadline:
item['reason'] = 'Boot recovery deadline reached'
break
try:
entries = usb.plan(name)
if now() + 12 >= deadline:
raise OSError('Boot recovery deadline reached during planning')
# Recheck all companions after descriptor reads and before any write.
if not all(usb.empty(e['name']) and usb.generation(e['name']) == e['generation'] for e in entries):
raise OSError('USB port changed during planning')
processed.update(e['name'] for e in entries)
item['ports'] = [e['name'] for e in entries]
atomic(state / 'pending.json', {'boot_id': boot, 'ports': entries})
try:
for entry in entries:
usb.write(entry, True)
sleep(1)
finally:
restore(usb, state, boot)
item['state'] = 'retried'
until = min(now() + 8, deadline)
while now() < until:
result = usb.outcome(name)
if result:
item.update(state='enumerated', descriptor=result)
break
sleep(0.25)
except (OSError, ValueError) as error:
item['reason'] = str(error)
if (state / 'pending.json').exists():
item['state'] = 'restore_failed'
break
return results
def state_directory():
STATE.mkdir(mode=0o755, exist_ok=True)
info = STATE.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise OSError('Untrusted recovery state directory')
def enabled():
info = POLICY.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 1024:
raise OSError('Untrusted startup recovery policy')
if json.loads(POLICY.read_text()) != EXPECTED_POLICY:
raise OSError('Startup recovery policy is not supported')
def inspect(usb):
hubs = []
deadline = time.monotonic() + 60
for p in sorted(usb.root.glob('*')):
if time.monotonic() >= deadline or len(hubs) >= 16:
break
if not re.fullmatch(r'usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*', p.name):
continue
try:
if (p / 'bDeviceClass').read_text().strip() != '09':
continue
hubs.append({'hub': p.name, 'individual_power': usb.individual_power(p.name + '-port1')})
except OSError as error:
hubs.append({'hub': p.name, 'error': str(error)})
return hubs
def main():
if os.geteuid() != 0 or sys.argv[1:] not in ([], ['--inspect'], ['--restore']):
raise ValueError('Fixed root-owned startup job only')
mode = sys.argv[1:] or ['apply']
state_directory()
lock = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
boot = BOOT_ID.read_text().strip()
usb = USB()
if mode == ['--restore']:
try:
restore(usb, STATE, boot)
finally:
os.close(lock)
return 0
if mode == ['apply'] and (STATE / 'attempted').exists():
# Preserve the first result, including restore failures, across a later
# daemon/package restart. Recovery is never retriggered by refresh.
try:
restore(usb, STATE, boot)
finally:
os.close(lock)
return 0
report = {'schema': 'missioncore.node.usb-startup-recovery/v1', 'boot_id': boot,
'observed_at_unix': time.time(), 'boot_seconds': time.monotonic(),
'mode': mode[0], 'state': 'skipped', 'results': []}
destination = STATE / ('inspection.json' if mode == ['--inspect'] else 'result.json')
try:
if mode == ['--inspect']:
report['hubs'] = inspect(usb)
report['state'] = 'inspected'
elif time.monotonic() >= BOOT_WINDOW:
report['reason'] = 'Outside startup window'
else:
enabled()
atomic(STATE / 'attempted', {'boot_id': boot})
restore(usb, STATE, boot)
# udev-settle can return before the hub driver's delayed retries
# give up. Wait before taking the first snapshot, including when
# the journal currently contains no terminal failure yet.
time.sleep(max(0, MIN_AGE - time.monotonic()))
candidates = failed_ports(journal())
if candidates:
report['results'] = recover(usb, STATE, boot, candidates)
report['state'] = 'complete'
except (OSError, ValueError, subprocess.SubprocessError) as error:
report.update(state='error', reason=str(error))
finally:
atomic(destination, report)
destination.chmod(0o644)
os.close(lock)
print(json.dumps(report, sort_keys=True))
return 1 if report['state'] == 'error' or any(r['state'] == 'restore_failed' for r in report['results']) else 0
if __name__ == '__main__':
def interrupted(*_):
raise InterruptedError('Startup recovery interrupted')
signal.signal(signal.SIGTERM, interrupted)
sys.exit(main())
+5 -2
View File
@@ -4,7 +4,10 @@ import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/p
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
import {request} from './api';
import {request,type Status} from './api';
import {Button,Icon,SettingsCard} from '@nodedc/ui-react';
import {createBoardLayoutStore} from '../../../../packages/sensor-ui/src/boardLayout';
const layout=createBoardLayoutStore({read:()=>request('/api/presentation/board-layout'),patch:(section,open)=>request('/api/presentation/board-layout','PATCH',{section,open})});
const configurationArchive:NonNullable<SensorTransport['configurationArchive']>={list:(device,before)=>request(`/api/device-configurations/${encodeURIComponent(device)}${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>request(`/api/device-configurations/${encodeURIComponent(device)}/${encodeURIComponent(version)}`)};
const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
export function NodeSensors({value,openSettings}:{value:Status;openSettings:()=>void}){return <SensorWorkspace board={{layout,description:value.name,computer:<SettingsCard title={value.name} description={value.host.hostname}><dl className="node-facts"><div><dt>Бортовой компьютер</dt><dd>{value.node_id}</dd></div><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{value.host.cpus}</dd></div><div><dt>Память</dt><dd>{value.host.memory_kib?`${(value.host.memory_kib/1048576).toFixed(1)} ГиБ`:"Нет сведений"}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl><Button onClick={openSettings}><Icon name="settings"/>Подготовка среды</Button></SettingsCard>}} contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
+1 -1
View File
@@ -38,7 +38,7 @@ function App() {
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "sensors" ? <NodeSensors />
: workspace.activeView === "sensors" ? <NodeSensors value={value} openSettings={()=>openView("environment")} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -57,16 +57,43 @@
и недостаточное движение при измерении. Номер физического сломанного контакта
не определяется по таблице без проверки распиновки и проводки.
Для подготовки к измерению в Node 0.8.39-1 / plugin 0.6.6 оператор отдельно
подтверждает неподвижность всех моторов. Бессенсорная прошивка может сообщать
ненулевые ERPM и изменение тахометра при физической остановке: оба значения
вычисляются из оценки положения ротора. Приложение сохраняет эти показания,
а перед измерением повторно проверяет нейтраль приёмника, отсутствие PWM,
малый ток и отсутствие ошибок. Это исключение действует только для
наблюдаемого измерения Холлов в бессенсорном режиме, не для управления
движением. Установка и реальные результаты новой версии фиксируются отдельно
в журнале испытаний.
## Назначение и проверка вращения
Назначение «левый/правый» связывает постоянный UUID VESC с местом мотора на
аппарате. Оно нужно для адресного и общего управления, но не влияет на
измеряемое сопротивление или параметр потерь. После смены USB-порта назначение
сохраняется. Общий список назначений сейчас отображается в каждой карточке
VESC; это обзор профиля аппарата, а не перечень моторов внутри одного VESC.
сохраняется. Общий список назначений находится в разделе «Настройки борта»;
это профиль аппарата, а не перечень моторов внутри одного VESC.
«Проверка вращения» запускается отдельно от калибровки. Скорость задаётся
в ERPM, ток задаёт верхний предел, длительность считается после разгона
и удержания скорости. Выбор всех моторов профиля запускает совместную
проверку; для 1×1 это два мотора. Успешная проверка на вывешенном приводе
не заменяет проверку под нагрузкой или надёжности связи.
### Visible forward/reverse bench rotation
After Hall measurement, use **Проверка вращения** for sustained visible motion.
Select one controller or the complete assigned profile, then **Направление
вращения → Прямое / Обратное**, speed magnitude, motor-current ceiling and hold
duration. Direction is relative to each VESC's existing settings, not a certified
vehicle heading. Current is an upper torque limit, not a speed setting.
Observe all motors fully stopped, raised and free before confirming each start.
Wait for physical stop before changing direction. There is no automatic forward/
reverse sequence. On admitted sensorless firmware, idle ERPM alone cannot prove
standstill; the preflight records that estimate alongside electrical checks.
Timing begins after speed settles; preparation/ramp/gaps are excluded. Compare
VESC observations with actual movement. This is an unloaded bench test, not
acceptance of loaded starts or the unresolved left Hall hardware fault.
+145
View File
@@ -0,0 +1,145 @@
# VESC: план привода и ограничения мощности
Актуализация 2026-09-24 после установки: Node 0.8.38-2 установлен, Core
принял оба VESC; владелец подтвердил появление устройств после перезапуска.
Ниже сохранён исходный аудит сбоя загрузки и состояние ранних сборок, а не
текущий статус установки. Последний журнал — `17_VESC_INSTALLATION_LEDGER.md`;
текущая очерёдность диагностики, RC-перехвата и профилей —
`23_ROVER_CONTROL_PROFILES.md`, раздел «Приёмка и порядок».
Статус на 2026-09-24: исследование и план. Пользователь попросил разобраться
в штатном механизме VESC Tool; управление пределами пока не реализуется и
настройки контроллеров не меняются.
## Принятый результат и незавершённая работа
- Оба мотора откалиброваны штатным нативным VESC Tool; калибровка, история
конфигураций, назначение и одиночная/совместная проверка доступны в UI.
- Принят совместный тест примерно 30 секунд и хороший ход обоих моторов с
пульта по наблюдению владельца. Это проверка вывешенного привода.
- Левый работает без датчиков, правый с Холлами. Отдельная повторная
диагностика Холлов не завершена; повреждённый контакт не локализован.
- Надёжность USB не принята: после успешного теста повторялись ошибки чтения.
- Node 0.8.36-1 с исправлением проверки нейтрали PPM подготовлен и проверен,
но не установлен. Это исправление не объявляется решением USB-сбоев.
## Отсутствие устройств после загрузки
Сегодня Node 0.8.35-1 и VESC-служба активны, без автоматических перезапусков.
Linux не перечисляет VESC среди USB-устройств; ttyACM и serial/by-id отсутствуют.
Во время загрузки есть ошибки чтения USB-дескрипторов и адресации (-71) на
двух портах. Дескрипторы этих устройств не получены: приписывать им личность
VESC или конкретную причину ошибки пока нельзя. Изменение портов не должно
менять идентичность: назначения привязаны к UUID контроллеров.
Владелец затем подтвердил подключённые USB-кабели и успешное управление обоими
моторами с пульта сейчас. Это подтверждает работу силового питания и моторного
управления, но не USB-канала. Первые ошибки USB зарегистрированы около 9,124 с
от старта, процесс VESC-службы запущен на 11,111 с, Node — на 13,430 с. Значит,
первый сбой перечисления возник до запуска нашего прикладного драйвера в этой
загрузке. Это не устанавливает, неисправен кабель, устройство, питание USB или
контроллер USB Mini. Никакого ручного сброса портов в аудите не выполнялось.
Отдельный недостаток продукта подтверждён исходниками: drive-profile.json
сохраняется на борту, но Service.inventory публикует drive_profile только
внутри обнаруженных устройств. Node сохраняет отсутствующие устройства через
реестр initialized, который обновляется при prepare; автоматически обнаруженная
личность сама в него не добавляется. В текущем Core один VESC остаётся offline,
второго нет в inventory, хотя архивы обоих доступны. Это не потеря калибровки,
но модель отображения известных устройств и общего профиля недостаточна.
Нужны независимая доступность профиля аппарата и сохранённый список назначенных
контроллеров со статусом «нет связи». Нельзя выдавать сохранённые сведения за
свежую телеметрию или разрешать команды без нового подтверждения UUID/сеанса.
Сохранность самого файла профиля в этом аудите напрямую не проверена: у SSH
пользователя нет права чтения. Факт сохранения установлен по реализации и
вчерашним квитанциям; текущие архивы обоих контроллеров прочитаны через Core.
## Последние подтверждённые пределы
Данные из архивов 2026-09-23 21:05 UTC, а не новое чтение недоступных VESC.
| Параметр | Левый | Правый |
| --- | ---: | ---: |
| Максимальный ток мотора | 34,2135 А | 34,4078 А |
| Масштаб тока разгона | 100% | 100% |
| Предел тока батареи | 55 А | 55 А |
| Предел рекуперации в батарею | −55 А | −55 А |
| Отдельное ограничение мощности | выключено | выключено |
Значение watt max 1500000 соответствует выключенному ограничению в профилях
Tool. Оно не является мощностью оборудования. Значение absolute current
160 А — отдельный порог защиты, а не паспортный рабочий ток. Настройки батареи
унаследованы и не подтверждают возможности BMS. Максимумы мотора около 34 А
получены мастером при допустимых потерях 50 Вт. Этот параметр калибровки не
является выходной мощностью. Временные 30 А и 2000 ERPM теста не являются
постоянным ограничением ручного управления; квитанции подтверждают восстановление
временных токовых масштабов после принятого теста.
## Штатный механизм
Базовые Motor Current Max, Battery Current Max, рекуперация и другие пределы
хранятся в конфигурации каждого VESC. Профиль Tool задаёт масштаб тока разгона
и торможения, скорость, duty и мощность. Процент тока не равен проценту ватт:
ток мотора в первую очередь задаёт момент, а батарейный ток относится к
потреблению от общей батареи.
В закреплённом Tool ProfileDisplay вызывает Commands::setMcconfTemp и предлагает
«Use until reboot» и постоянное применение. FW 5.02 поддерживает сохранение,
пересылку по CAN и деление ватт между обнаруженными CAN-контроллерами. Поэтому
общий профиль возможен, но исполняют ограничения отдельные контроллеры. Их
применение влияет и на PPM-пульт; для этого не нужна повторная калибровка.
Временное применение не требует записи каждого движения ползунка во flash.
В штатной пересылке подтверждение ведущего не является подтверждением каждого
ведомого: FW подавляет ack для пересланных команд. В интеграции нужны проверка
состава назначенных UUID и чтение результата у каждого участника. Делить бюджет
по случайному числу отвечающих устройств нельзя: пропавший контроллер может
снова появиться, и общий бюджет батареи будет превышен. Конкретную политику
частичного применения и восстановления следует определить до реализации.
## Предлагаемая модель Mission Core
Общий профиль привода размещается у аппарата. Он содержит уже существующую
схему 1×1/2×2 и назначения, подтверждённый бюджет общей батареи и режим работы.
Карточка каждого VESC хранит калибровку, паспортные основания пределов и его
индивидуальные ограничения. Общий режим применяет согласованные значения к
назначенным контроллерам через нативный Tool; подтверждённые ограничения
исполняются на VESC независимо от связи с Core.
Запас 20% рассчитывается от подтверждённых допустимых характеристик при
реальном охлаждении и длительности нагрузки. Для каждой пары мотор–контроллер
нужен допустимый фазный ток; для общей батареи — суммарный ток разряда и
отдельный ток заряда/рекуперации. Пиковый ток нельзя считать непрерывным.
Маркер прошивки 75_300_R2 не доказывает производителя и реальные характеристики
платы, а приблизительные 500 Вт мотора не определяют допустимый фазный ток.
Поэтому численный новый потолок пока не назначен. Нужны точные модели или
подтверждённые характеристики от изготовителя и параметры BMS.
## Очерёдность
1. Установить состояние питания/подключений и восстановить USB-обнаружение,
затем проверить связь без вращения. Смена портов не должна требовать
повторного назначения, перезагрузка не должна скрывать сохранённый профиль.
2. Исправить доступ к общему профилю и отображение известных offline-устройств;
применить и проверить подготовленное исправление нейтрали пульта через
версионный установщик с согласованным прерыванием служб.
3. Сверить паспорта моторов, контроллеров и BMS; сформировать индивидуальные
пределы и общий бюджет с согласованным запасом. Затем реализовать профили
ограничений штатными средствами Tool, с резервированием и чтением результата.
4. Завершить отдельную диагностику Холлов, сохранив принятую калибровку.
5. Принять работу под нагрузкой, ограничения температуры/рекуперации и
остановку при потере связи; далее полноценное ручное удалённое управление,
приоритет пульта и автономный режим. Общий тест пока не означает готовность
всей системы управления к эксплуатации.
## Первичные источники
- [VESC: назначение пределов тока](https://vesc-project.com/node/180).
- [Tool: штатные профили и применение](https://github.com/vedderb/vesc_tool/blob/01d5f10901116c311e3fb84d5a1541f663d3ce20/mobile/ProfileDisplay.qml).
- [Tool: поля профиля](https://github.com/vedderb/vesc_tool/blob/01d5f10901116c311e3fb84d5a1541f663d3ce20/mobile/ProfileEditor.qml).
- [FW 5.02: COMM_SET_MCCONF_TEMP](https://github.com/vedderb/bldc/blob/5.02/commands.c).
Ops MCP на момент обновления возвращает HTTP transport error; план и свежая
диагностика в Ops не опубликованы. Частные журналы и UUID находятся вне Git
в outputs/rover-006-vesc-context-20260923/native-probe/boot-20260924-*.
+95
View File
@@ -0,0 +1,95 @@
# Подготовка окружения, автозапуск и USB при загрузке
Требование владельца от 24.09.2026: пользователь получает сборку Node,
открывает приложение и выполняет его настройку. Все необходимые изменения ОС
выполняет версионированный продукт. Ручные правки Linux не являются частью
установки, диагностики с исправлением или первого испытания.
## Владение настройкой
Существующая страница «Настройка окружения → Сконфигурировать» использует
профиль `ubuntu-24.04-amd64/3`. Добавлены два этапа в существующий список:
- «Автозапуск приложения»: root-owned XDG entry открывает обычное окно Node
после входа в графическую сессию. Перед запуском оно до 180 секунд ожидает
HTTP-службу. Gtk.Application сохраняет одно окно на пользовательскую сессию.
Авторизация локального интерфейса остаётся обычной polkit-авторизацией;
автозапуск не выдаёт новые права и не настраивает автоматический вход в ОС.
- «Обнаружение устройств при загрузке»: устанавливает фиксированную политику,
включает отдельную службу на следующую загрузку и читает возможности текущих
USB-хабов. Подготовка окружения не перезапускает USB-порты в текущей сессии.
Системная служба Node уже включалась установщиком и работает до входа в рабочий
стол. Открытие окна и работа борта — разные жизненные циклы. Постоянного
интерфейса управления портами нет; обновление устройств только обновляет список.
Политика и XDG entry создаются из шаблонов пакета. Повторное выполнение
идемпотентно; чужие файлы/ссылки не перезаписываются. При удалении пакета
удаляются только неизменённые принадлежащие продукту файлы, служба отключается.
Пакет запрещает замену файлов во время переключения USB или незавершённого
обратного включения. При откате до версии раньше 0.8.37 prerm также отключает эту службу и удаляет
только совпадающие с шаблонами настройки, прежде чем dpkg удалит новые helper.
Физическая проверка downgrade ещё не выполнена.
Подтверждаемый профиль пока Ubuntu 24.04 amd64. Новые дистрибутивы и архитектуры
должны получить собственные поддерживаемые профили и проверку dependency closure;
этот пакет не является доказательством работы на любом Linux.
## Ограниченная процедура USB
1. Служба запускается до Node и VESC, только если подготовка среды включила
политику. После udev-settle выдерживается возраст загрузки не менее 30 секунд:
ядро может ещё повторять обнаружение после завершения udev-settle.
2. Из журнала **текущей загрузки**, только transport=kernel, выбираются порты с
окончательным `unable to enumerate USB device` в первые 60 секунд. Более
позднее подключение/отключение отменяет устаревший кандидат. Ошибка чтения
дескриптора сама по себе не разрешает сброс.
3. На порту и его USB 2/3 companion не должно быть дочернего устройства,
состояния незавершённого обнаружения, отключения или зарегистрированного
overcurrent. Встроенные hardwired-порты исключены. Проверяются взаимные peer
ссылки, поколение/адрес хаба и нахождение атрибутов в sysfs.
4. Дескриптор каждого хаба должен подтвердить individual port power switching.
При ganged/no switching/нечитаемом дескрипторе порт пропускается. Поддержка
физического отключения VBUS всё равно зависит от железа; успешный sysfs write
не доказывает восстановление устройства.
5. До первого изменения сохраняется root-owned журнал обратного включения.
После повторной проверки свободных портов пара отключается на одну секунду,
включается в `finally`; до восьми секунд ожидается новое обнаружение.
ExecStopPost повторяет обратное включение при остановке процесса. Уже включённый
порт не переключается повторно. Ошибка восстановления сохраняет pending и
останавливает обработку других портов.
6. Не более одной попытки на физическую пару за загрузку, общий бюджет 90 секунд,
начало только в первые 180 секунд. Поздний запуск службы/перезапуск приложения
не сбрасывает USB. Повторный запуск сохраняет исходный результат.
До чтения дескриптора тип проблемного устройства неизвестен: это ограниченное
восстановление ошибки Linux USB, а не угадывание VESC по пустому разъёму.
Конкурентное физическое подключение полностью не атомарно относительно sysfs;
проверки выполняются непосредственно перед записью. Процедура не меняет драйвер
хост-контроллера и не сбрасывает целый USB-контроллер/хаб. Она не отправляет
команды моторам. После обнаружения обычный драйвер сопоставляет VESC по firmware
UUID, а не по tty, разъёму или неуникальному USB serial.
Результаты остаются в `/run/mission-core-usb-startup/`: inspection.json,
result.json, attempted, при незавершённом восстановлении pending.json.
Синтетические тесты используют временный sysfs; реальное переключение в них
не выполняется.
## Приёмка
- Локально: 36 тестов подготовки/автозапуска/USB, shell syntax и diff check прошли.
- Ubuntu qualification: 17 этапов прошли; пакет 0.8.37-1 и штатный установщик
сформированы, APT-план проверен. Пакет установлен штатным установщиком.
Хеши и результаты находятся в ledger.
- Подготовка через поставляемый UI: все девять этапов завершены, XDG entry и
USB-политика созданы, служба восстановления включена для следующей загрузки.
Сводная проверка нашла поддержку у USB-хабов; возможности именно корневых
портов ещё нельзя вывести из сводного статуса. Оба VESC доступны.
- Холодная загрузка с подключёнными VESC, сохранение UUID/назначений и отсутствие
вмешательства в камеры: ещё не приняты. Программный перезапуск службы не
заменяет этот тест. Без владельца перезагрузка борта не выполняется.
Основания: Linux [USB sysfs ABI](https://github.com/torvalds/linux/blob/master/Documentation/ABI/testing/sysfs-bus-usb),
[port.c](https://github.com/torvalds/linux/blob/master/drivers/usb/core/port.c),
[USB error codes](https://docs.kernel.org/driver-api/usb/error-codes.html),
[uhubctl: switching and USB 2/3 peers](https://github.com/mvp/uhubctl).
+37
View File
@@ -0,0 +1,37 @@
# Карточка аппарата: борт, настройки и устройства
Согласовано владельцем 2026-09-24: существующую карточку аппарата разделить
на три независимо сворачиваемых блока; повторить композицию в Node.
- «Бортовой компьютер»: идентичность, связь, характеристики и существующие действия.
- «Настройки борта»: общий профиль привода, назначения UUID и чтение ограничений VESC.
- «Устройства аппарата»: существующий инвентарь и переход к конкретному устройству.
Используется канонический DG Inspector variant=panel. Новая навигация и новые
визуальные сущности не вводятся. Альтернатива — дополнительные окна настроек —
отклонена владельцем в пользу блоков на существующей карточке.
Открытые секции сохраняются автоматически в JSON на стороне приложения:
в Core отдельно для каждого аппарата, в Node локально для его борта. Это
представление оператора, не конфигурация контроллера. Изменение одного блока
не перезаписывает состояние остальных. Пустой список означает «всё свёрнуто».
Инвентарь и выполняющиеся команды живут выше Inspector: сворачивание не
останавливает соединения и не скрывает ошибки сохранения.
Общий профиль и назначения используют прежние проверенные команды VESC.
Калибровка, Холлы, тест вращения и версии конфигурации остаются в карточке
конкретного контроллера. Чтение ограничений идёт через нативный VESC Tool,
не меняет моторную конфигурацию и не запускает мотор. Значения являются
настройками контроллера, а не паспортными пределами оборудования.
Изменение рабочих пределов мощности остаётся отдельным этапом по плану 20:
нужны подтверждённые пределы оборудования и политика согласованного применения.
Никаких искусственных «80% мощности» или новых токовых пределов этот этап
не записывает. Проверки: сохранение после навигации/перезагрузки, разделение
аппаратов, конкурентные изменения секций, ошибки JSON/связи и обе поверхности.
Последующее требование владельца: добавить здесь два режима ручного управления
ровером. Исследование источника команд, пульта и независимого RC-пути находится
в [плане 23](23_ROVER_CONTROL_PROFILES.md). Этот режим не совпадает со схемой
1×1/2×2; переключатель не объявляется действующим до реализации и проверки
реального пути команд.
+595
View File
@@ -0,0 +1,595 @@
# Профили ручного управления ровером
Актуальное решение владельца 25.09.2026: оставить работающее управление двумя
стиками; Tank/Arcade и управление одним стиком отложить до отдельного возврата
к задаче. Сейчас Mini получает CH2/CH3, но не горизонтальную ось выбранного
стика; новую проводку/адаптеры владелец исключил. Прототип не включать в
production и не показывать переключение RC-профиля как применённое.
Требование stop → neutral → manual сохранено незавершённым.
Полный контрольный паспорт, конфигурации, история экспериментов и чекеры
опубликованы в [MISSIONCOR-85 — Гусеничный ровер Node 006](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-85).
Карточка прочитана обратно через прямой Ops MCP: все 35 структурированных
блоков совпали с подготовленным содержимым, включая 600 параметров VESC.
Статус 2026-09-24: исследование и автономный программный прототип. Рабочие
настройки пульта, приёмника и VESC не изменены; новый режим движения на
оборудовании пока не реализован.
## Требование владельца
В существующих «Настройках борта» нужны два профиля: независимое управление
левым и правым бортом двумя рычагами и управление движением/поворотом одним
двухосевым рычагом. Тот же профиль должен быть доступен локально на Node и
через Core. Пульт должен сохранять возможность управления при отказе Mini.
Профиль управления не заменяет схему привода 1×1/2×2 и назначения моторов.
Последние определяют физические исполнительные устройства; профиль определяет
смысл входных команд. Новые режимы не требуют повторной калибровки моторов.
Уточнение владельца: при автономном движении или удалённом ручном управлении
пульт может оставаться включённым и готовым к немедленному перехвату. Движение
рычага за пределами проверенной нейтрали должно отбирать управление у всех
источников Mission Core без переключения режима в интерфейсе. После перехвата
нейтраль пульта не возобновляет прерванную задачу. Локальный оператор может
освободить застрявший ровер даже при недоступном операторском Core; отказ самого
Mini также не должен лишать его независимого RC-пути. Уведомления об аномалии
и обнаружение застревания пока описывают будущий сценарий, не реализованный код.
Следующее уточнение владельца: отдельный интерфейс переключения источника
управления не нужен. Владелец наблюдает, что пульт работает только когда четыре
верхних переключателя подняты, и предлагает использовать это как сигнал
ручного перехвата. Наблюдение ещё не разделено на блокировку при включении и
поведение уже работающего передатчика. По руководству FS-i6S, раздел 4.1,
верхнее положение требуется при включении; раздел 2.2.3 описывает SwA–SwD как
назначаемые переключатели. Документ не подтверждает отдельный передаваемый
признак «все четыре подняты» или отключение радиолинка любым из них.
Владелец затем исправил наблюдение: управление моторами сохраняется и со
всеми четырьмя тумблерами в нижнем положении. Гипотеза «любая нижняя позиция
выключает управление/радиосвязь» отозвана. Начальный пассивный замер был
помечен заявленным положением SWA, но его нельзя использовать как доказательство
влияния тумблера: синхронное сравнение состояний не завершено. Предложенное
переключение SWA для следующего замера отменено. Проверка положений при
включении остаётся вероятным объяснением, не подтверждённой настройкой пульта.
Это требование к автоматическому выбору источника не отменяет ранее заказанную
настройку Tank/Arcade: раскладка органов управления и право выдавать команду
моторам — разные свойства. Перехват не должен требовать клика в Core.
## Последнее уточнение: первый ввод останавливает, следующий управляет
Владелец изменил непосредственную реакцию на RC: первый ввод с рычага должен
только прервать автономное/удалённое движение; последующий ввод может управлять
моторами. Отменяются задачи автономной езды и их вычислительные исполнители,
включая связанные с ней inference/планирование/обработку облаков. Запрет на
выходные команды должен вступать в силу раньше и независимо от завершения
таких задач. Нельзя ждать остановки тяжёлого вычисления, прежде чем запретить
ему движение; запоздавшие результаты и команды теряют допуск.
Один удерживаемый рычаг создаёт непрерывную последовательность пакетов, поэтому
«следующий пакет» не равен второму осознанному действию. Подтверждённая владельцем
последовательность — первый выход из нейтрали: остановка; затем оба моторных
канала в устойчивой нейтрали; затем новое отклонение: ручное движение.
Ответ владельца 2026-09-24: «Да: остановка → нейтраль → управление».
Требование принято; изменение рабочего моторного runtime ещё не выполнено.
Для этой трактовки нужны состояния прекращения команд, подтверждения остановки,
ожидания нейтрали и ручного управления. Нельзя пропускать первое удерживаемое
отклонение после фиксированной паузы или считать перезапуск процесса разрешением
движения. Проверяются свежесть входа, все назначенные стороны и отзыв старых
допусков. Длительность нейтрали и допустимое время остановки задаются после
выбора и проверки исполнительного механизма, не случайной константой.
Текущая прямая PWM-проводка приёмник→VESC и истечение 250 мс аренды сами по себе
не реализуют это требование: отпустив аренду при удерживаемом RC, программа
передаст в мотор уже первое отклонение. Нельзя выдавать существующий test latch
за stop-first. Также удержание блокировки только на Mini не гарантирует эту
семантику при отказе Mini. Для независимой гарантии проверка перехода должна
жить у исполнителя либо в отдельном независимом тракте; stock FW 5.02 такой
квалифицированной функции в нынешней схеме не имеет. Это архитектурная граница,
не разрешение автоматически прошивать VESC или покупать новый контроллер.
Остановка вычислительных задач не означает остановку служб приёма RC,
контроля привода и наблюдения его состояния. Нулевая команда тока не доказывает
физическую остановку: выбег/торможение и допустимая рекуперация требуют отдельной
проверки. Автоматический возврат к прерванной задаче после нейтрали запрещён.
### Подтверждённая последовательность
| Состояние | Событие | Требуемая реакция |
| --- | --- | --- |
| Движением управляет Mission Core | Первое допустимое отклонение любого назначенного RC-канала | Отозвать допуск всех остальных источников, очистить их очередь, начать согласованную остановку всех приводов и отменить задачи автономного движения. Первое отклонение не передавать как команду езды. |
| Остановка / ожидание нейтрали | Рычаг остаётся отклонённым, приходят новые пакеты | Сохранять запрет движения; количество пакетов и прошедшее время сами по себе его не снимают. |
| Остановка / ожидание нейтрали | Остановка подтверждена, все назначенные рычаги вернулись в подтверждённую нейтраль | Разрешить следующий осознанный RC-жест; Mission Core остаётся без допуска к движению. |
| Пульт готов к движению | Новое отклонение рычага | Передать ручную команду назначенным приводам. |
| Пульт управляет | Последующие отклонения и возвраты в нейтраль | Обычное ручное управление. Каждый новый жест не повторяет процедуру перехвата. |
| Любое состояние после перехвата | Пришла запоздавшая команда отменённой задачи / восстановилась связь Core | Отклонить команду. Восстановление связи не возобновляет автономное движение. |
Это контракт поведения, не таблица состояний установленной реализации.
Условия свежести и нейтрали относятся ко всем назначенным каналам, независимо
от схемы 1×1/2×2 и числа моторов. Пропавший канал не считается нейтральным.
Ответ на USB-запрос с последним декодированным значением сам по себе не
доказывает свежий импульс приёмника: FW 5.02 не возвращает его возраст в
`COMM_GET_DECODED_PPM`. Нейтральный failsafe также не доказывает отпускание
стика оператором. Эти ограничения должны быть учтены до допуска автономии.
### Проверка штатной FW 5.02
Аудит выполнен по сохранённому upstream commit
`3f670137e27e6e383fa79c50cc6b1fa85aab1554`; Git blob-хэши `app_ppm.c`,
`app.c`, `commands.c`, `datatypes.h` совпадают с сохранённым деревом Git.
Это проверка исходников upstream, не доказательство побайтового совпадения
прошивки производителя в имеющихся контроллерах.
- `applications/app_ppm.c`: вход декодируется при отключённом выходе;
Safe Start использует счётчик нейтральных импульсов после конфигурации,
тайм-аута приёмника или ошибки. Ветка отключённого выхода не сбрасывает
этот счётчик. Завершение USB-аренды не создаёт новую проверку нейтрали.
- `applications/app.c::app_disable_output`: положительное время отключает
выход до истечения таймера; его callback просто разрешает выход. Значение
−1 отключает бессрочно, что не подходит для независимого RC при отказе Mini.
- `commands.c::COMM_SET_APPCONF`: перезапускает приложения и сохраняет
конфигурацию во flash. Это не команда ручного перехвата на каждый жест.
- `COMM_SET_CAN_MODE` может вызвать тот же перезапуск без сохранения, но
перезапуск PPM/UART и других приложений через настройку CAN не является
проверенным механизмом передачи управления. Такой обход не применяется.
- Наш native `release` отправляет `setCurrent(0)`: это снятие тяги, а не
подтверждённое торможение и не повторное включение Safe Start.
Для исправного Mini возможен программный цикл удержания выхода до нейтрали.
Он не обеспечивает одинаковое поведение при потере Mini/USB: таймер в VESC
всё равно истечёт. Штатной проверенной функции для полного требования в
текущем тракте не найдено. Нужна поддержка перехвата на исполнительном уровне
либо другой независимый тракт. Простого изменения в одном VESC недостаточно:
отклонение одного канала должно согласованно остановить весь борт, а топология
межконтроллерной связи пока не подтверждена.
Следующий этап реализации — выбрать и проверить такой механизм по имеющемуся
железу, включая отзыв прямых USB-команд, согласование всех приводов, остановку
и нейтраль при отказе Mini. Обновление/доработка прошивки требует отдельного
плана совместимости и восстановления. Пока проверяются эти условия,
не активировать автономное движение и не выдавать действующие стендовые
тесты за принятый постоянный арбитр. Прошивки и рабочая RC-конфигурация в этом
аудите не изменялись.
Кандидат для следующей проверки без отдельного бортового контроллера —
штатная LispBM-поддержка современных VESC: upstream документирует для FW 6.00+
`get-ppm`, `get-ppm-age`, `app-ppm-detach`, `app-ppm-override`, а также доступ
к PPM других VESC через настроенный CAN. Код исполняется в контроллере,
не на Mini. Это возможный исполнитель подтверждённой последовательности,
не готовая встроенная функция перехвата и не доказанная гарантия приоритета
над прямыми USB-командами. Нужны проверка совместимости платы, согласованный
обмен между приводами, исключение обхода арбитра, реакция на остановку скрипта
и квалификация поведения при потере каждого соединения. Возраст PPM отличает
отсутствие импульсов от их наличия, но не живой радиолинк от импульсов failsafe.
Текущая версия 5.02 этой документированной LispBM-возможностью не подтверждена.
Маркер 75_300_R2 недостаточен для выбора прошивки; у владельца запрошены
производитель и точная модель, если известны. Обновление не запускалось.
[Документация LispBM VESC](https://github.com/vedderb/bldc/blob/master/lispBM/README.md),
[описание поддержки в FW 6.00 автором VESC](https://vesc-project.com/node/3385).
## Что подтверждено
Приёмник на прежней фотографии — FlySky FS-iA6B. По сообщению владельца,
левый VESC подключён к CH3, правый к CH2. Принятое вращением назначение
контроллеров хранится по UUID, независимо от USB-порта.
Новые фотографии пульта показывают маркировку Robcom Venom Drone. Корпус,
две кнопки питания, сенсорный экран, расположение переключателей, задних
кнопок и PS/2/USB совпадают со схемой FlySky FS-i6S. Это обоснованная
идентификация семейства по внешности. Владелец сообщает о втором внешне таком
же пульте без маркировки Robcom. Последующий осмотр About 2026-09-25 подтвердил
сообщаемые устройством Flysky FS-i6S, прошивку 2.00 от 04-Apr-2020 и Hardware
V_3.0; подробности видеоподтверждения приведены ниже. Это не аудит возможных
OEM-изменений электроники.
По руководству семейства FS-i6S, разделы 2.2.3 и 6.8, SwA/SwB/SwC/SwD —
назначаемые переключатели: их можно связать с дополнительным каналом или
функцией передатчика. У каждого нет постоянного назначения «камера»,
«ручной режим» или «аварийный стоп». На дополнительном канале передаётся
значение, соответствующее положению; смысл ему задаёт принимающая система.
Фактические назначения конкретного пульта ещё не прочитаны. Меню Aux. Channels
показывает назначения дополнительных каналов; функции самого передатчика
могут использовать переключатели отдельно (например Trainer Mode, раздел 7.3).
Текущее подключение CH2/CH3 к VESC не даёт Mini доступ ко всем этим каналам.
В архиве конфигураций после принятого общего теста оба входа VESC настроены
как PPM Duty Cycle. Левый использует приложение PPM, правый — PPM and UART.
Настройки отклика различаются: разгон/сброс слева 0,4/0,2 с, справа 0,5/0,5 с;
параметр polynomial curve слева −1, справа −1,5; deadband у обоих 0,15.
Это сохранённые значения, не новое чтение после текущей загрузки и не
основание автоматически уравнивать параметры. Ровное вращение на общей
команде ERPM не означает одинаковую реакцию на одинаковое положение стиков.
У обоих сохранён multi_esc=true. Наличие и топология физического CAN ещё
не подтверждены. Перед изменением схемы команд надо исключить взаимную
пересылку между левым и правым бортом; одну настройку нельзя считать схемой
проводки. Никакого CAN broadcast или изменения multi_esc сейчас не сделано.
## Канонические механизмы
В терминологии WPILib первый режим — Tank Drive, второй — Arcade Drive.
Arcade преобразует движение и поворот в согласованную пару команд левой и
правой стороны. Curvature Drive — отдельный вариант поведения поворота;
его нельзя незаметно подменять под тот же профиль. Стороны могут включать
несколько назначенных моторов. [Официальное описание WPILib](https://docs.wpilib.org/en/stable/docs/software/hardware-apis/motors/wpi-drive-classes.html).
Микширование не должно трактовать ток как заданный радиус поворота: ток
связан с моментом, а скорость зависит от нагрузки. Нормализация, насыщение,
знаки, нейтраль, движение назад и разворот на месте требуют явной модели и
приёмки. Микшер и ограничения мощности — разные части системы.
Штатное приложение PPM прошивки VESC 5.02 читает один импульсный вход.
multi_esc пересылает ту же команду другим контроллерам, а не вычисляет
дифференциальное управление из двух осей.
[Исходник FW 5.02](https://github.com/vedderb/bldc/blob/5.02/applications/app_ppm.c).
В официальном руководстве FS-i6S есть Mix (master/slave, положительный и
отрицательный коэффициенты), сохранённые модели и просмотр каналов. Число
доступных независимых миксов, назначение осей и поведение их композиции в
имеющейся прошивке ещё не проверены. Наличие пункта Mix не доказывает
возможность получить два требуемых выхода на нынешних CH2/CH3. Меню About
показывает модель и версии. Failsafe=Off в руководстве означает удержание
последнего значения; выключенный передатчик нельзя приравнивать к нейтрали
без измерения выхода приёмника. [Руководство производителя](https://www.flysky-cn.com/s/FS-i6S-User-manual-20200628-al4y.pdf), разделы 6.9, 6.10, 7.2, 7.13.
## Где может исполняться профиль
1. В пульте. Сначала проверить штатное микширование на экране каналов без
команд моторам. Такой путь сохраняет независимость от Mini. Через два
имеющихся PWM-провода Mission Core не может читать или менять модель
передатчика: нельзя показывать локально сохранённый выбор как применённый.
2. На Node. Нужен полный вход с осями и явным выбором управления, например
через приёмник iBUS и совместимый интерфейс. Разъём iBUS есть у семейства
FS-iA6B, но соответствующего подключения к Mini сейчас не подтверждено.
Сначала проверить уровни, адаптер, формат и режим реального приёмника.
Текущих двух вертикальных осей недостаточно для правого двухосевого стика.
3. Во внешнем контроллере/микшере. Это технический вариант, а не принятое
решение: владелец хочет обойтись существующим бортовым компьютером.
Выбор пути зависит от наблюдаемой аппаратуры. Реализацию универсального
переключателя нельзя завершить, скрыв эту зависимость. Не прошивать пульт или
VESC и не переподключать каналы ради проверки гипотезы без отдельного плана.
## Наблюдение входа и приоритет пульта: существующая граница
Текущий native backend уже читает через VESC Tool `COMM_GET_DECODED_PPM`:
декодированный уровень и длительность последнего импульса одного входа VESC.
Два USB-соединения дают два подключённых канала приёмника. Это не полный
радиообмен передатчика с приёмником, не все оси/переключатели и не подтверждение
радиолинка. Штатный PPM-код продолжает декодировать вход при временно отключённом
выходе приложения. Прямые команды тока/ERPM через USB уже применялись в тестах;
воспроизведение радиопакетов или подмена PWM-проводов для этого не нужны.
По двум нейтральным значениям CH2/CH3 нельзя различить включённый передатчик
с отпущенными стиками и выключенный передатчик при нейтральном failsafe.
Положения SwA–SwD тоже не следует выводить из этих значений. Для перехвата по
переключателю нужен проверенный соответствующий канал, доступный на борту;
для перехвата по наличию радиолинка — подтверждённый признак валидности связи.
Подключение полного потока приёмника к Mini пока не подтверждено. Если сама
готовность включённого пульта означает ручной режим, автономия с включённым
готовым пультом блокируется; это отличается от предыдущего сценария перехвата
движением рычага. До выяснения реального сигнала не менять рабочую схему.
В `motor_test.py` и `group_test.py` проверка входа выполняется до следующей
подачи команды. Активный канал записывает состояние `rc`, прерывает тест и
запрещает новый запуск до явного `vesc.control.release` после нейтрали.
`receiver.py` использует свежую конфигурацию deadband каждого VESC, а не
считает любой ненулевой шум командой. Это действующий код коротких стендовых
тестов, не принятый постоянный арбитр автономного движения.
Тесты временно приостанавливают выход PPM отдельными продлеваемыми арендами
по 250 мс. Таймер исполняется в VESC: при прекращении продления PPM возвращается
без участия Mini. Это защита от исчезновения процесса/USB, а не гарантия
приоритета RC при ошибочной программе, продолжающей продлевать аренду. Также
250 мс не являются измеренной максимальной задержкой перехвата всего ровера.
Надёжный постоянный тракт требует проверки таймаутов, возврата RC, отсутствия
поздних USB-команд и поведения всех назначенных сторон. Гарантия при ошибочной
программе на живом Mini потребует независимого решения у исполнительного
уровня; наличия такой гарантии в stock FW 5.02 не установлено.
Штатные FOC/Hall-процедуры FW 5.02 не прерываются обычной командой пульта.
Они остаются отдельным сервисным режимом вывешенного привода и не могут
запускаться в автономном движении под обещание постоянного RC-перехвата.
## Контракт реализации в Mission Core
- Один версионный профиль борта: режим, источник входа, проверенное назначение
осей, стороны/UUID, параметры отклика и ссылка на отдельные пределы привода.
Хранить желаемое и подтверждённое применённое состояние раздельно.
- Общий интерфейс в существующем блоке через компоненты Design Guideline;
калибровка конкретного двигателя остаётся в карточке VESC.
- Смена профиля только после нейтрали всех назначенных сторон. При частичной
потере связи запрещено объявлять общий профиль применённым.
- Перехват пультом фиксируется до явного возврата управления. Нулевое значение
PWM само по себе не определяет, выключен передатчик или стоит в нейтрали.
- На Node должен быть один владелец выхода: все команды автоматики, клавиатуры,
удалённого джойстика и другого управления проходят через него. Перехват RC
отзывает их допуск и отменяет очередь; опоздавшая команда старого допуска
не может вновь включить движение. Core отображает состояние, но не является
звеном, необходимым для локального перехвата.
- Потеря Core, Node, входного потока или одного VESC должна иметь проверенное
поведение. Существующая аренда отключения PPM для коротких тестов не является
приёмкой постоянного удалённого управления. Без Mini должен сохраняться
понятный оператору независимый путь RC, а не внезапная смена смысла стиков.
- Нативный VESC Tool остаётся владельцем протокола и конфигурации контроллеров;
калибровочные алгоритмы не копируются в новый модуль профилей.
## Приёмка и порядок
Node 0.8.38-2 установлен; Core принял оба VESC после исправления версии драйвера.
1. Завершить диагностику существующего привода до установки гусениц:
подтвердить связь и сохранить свежие конфигурации; отдельно сравнить Холлы
левого и правого мотора без замены принятой калибровки. Перед процедурой
заново синхронизироваться с наблюдающим владельцем. Если сигнал отсутствует,
отделить неисправность проводки/датчика от недостаточности измерения.
Повторная калибровка не восстанавливает физический контакт.
2. После результата проверить оба направления, старт и остановку, затем
поведение пульта при потере связи на вывешенном приводе. При необходимости
уточнить нейтраль и failsafe через штатные средства. Дать отдельный вывод
о готовности к ограниченной нагрузочной проверке; существующий ровный
тест без нагрузки и исправный правый Hall не доказывают исправность левого.
3. Для нового управления прочитать About, меню Mix, карту каналов и failsafe
пульта без изменения рабочей модели. Проверить точную модель VESC и
межконтроллерную связь. Недостающая модель не блокирует диагностику текущей
версии, но блокирует обоснованный выбор новой прошивки.
4. Выбрать штатный исполнитель перехвата с независимостью от Mini, подготовить
версионную интеграцию и сначала проверить переходы без движения. LispBM
остаётся кандидатом; обновление не является обязательным следующим шагом
и не выполняется по одному имени 75_300_R2. Приёмка включает удержание
первого жеста, нейтраль всех входов, второй жест, запоздалые команды,
потерю Core/Mini/USB/связи между контроллерами и отсутствие самовозврата.
5. Реализовать Tank/Arcade и общий профиль ограничений в существующих
«Настройках борта», с индивидуальными пределами каждого привода. Выбрать
место микширования по реально доступным каналам. Новые численные максимумы
и запас 20% требуют характеристик моторов, VESC и общей батареи/BMS;
приблизительные 500 Вт и имя аппаратной прошивки их не подтверждают.
Испытание на гусеницах зависит от результата пунктов 1–2 и допустимых рабочих
пределов, а не от завершения будущей автономии. Новый RC-перехват и Arcade
проверяются на вывешенном приводе до проверки под нагрузкой.
Уточнение владельца: идентифицировать контроллеры программно; разборка ровера
ради чтения маркировки нежелательна и сейчас не требуется. В выполненном
аудите через установленный native Tool оба контроллера подтвердили уникальные
UUID, FW 5.02 / 75_300_R2, штатный тип VESC, test_firmware=0 и custom_configs=0.
С каждого считаны полные motor/app-конфигурации (151/149 параметров), вход PPM
и телеметрия. Оба штатных CAN ping дали пустой список: межконтроллерный обмен
не подтверждён, но отсутствие физических проводов из этого не следует.
USB-дескрипторы и USB-серийники одинаковы; идентичность по-прежнему задаёт
UUID VESC. Новые резервные копии есть в истории Core и совпадают с принятыми
архивами после калибровки. Движение и изменения настроек не выполнялись.
Аппаратная сборка не определяет коммерческую модель: сам производитель
[Flipsky описывает разные платы серии 75 на основе 75_300_R2](https://flipsky.net/blogs/vesc-tool/tips-of-75-serise-esc).
Это подтверждение неоднозначности, не доказательство бренда имеющихся плат.
Ни новый образ прошивки, ни паспортные пределы не выбираются по одному этому
имени. Продолжить неразрушающую программную диагностику и использовать
существующую комплектацию/документацию изготовителя ровера для свойств,
которые текущий протокол не сообщает.
Для выбранной реализации проверить нейтраль, прямое/обратное движение,
повороты и крайние диагонали, одинаковую ограниченную реакцию сторон, переход
между режимами в нейтрали, RC-перехват и отказ каждого канала связи. Сначала
без движения (преобразование входов), затем на вывешенном приводе; нагрузочные
испытания и настройка поворота на гусеницах — отдельный этап. Успешный тест
без нагрузки не подтверждает старт под нагрузкой с повреждённым Холлом.
## Comparative Hall result, 2026-09-24 12:13 UTC
Node 0.8.39-1 / plugin 0.6.6 completed both separate canonical measurements.
LEFT again yields only [1,3,5,7] while the owner observes movement both ways;
RIGHT yields [1,2,3,4,5,6] and firmware success. Both configurations are unchanged
and current release is confirmed. Hall diagnostic localization is complete:
LEFT circuit incomplete, RIGHT six-state signal accepted. Exact broken physical
contact is not identified and no hardware repair is claimed. Sensorless LEFT
versus Hall RIGHT operation remains. The owner next requests visible sustained
rotation in both directions; this is a bench-test feature, separate from RC
Tank/Arcade profiles and the stop → neutral → manual authority contract.
## Прототип во время отсутствия владельца
По прямому запросу владельца подготовлены `packages/rover-control/src/profile.ts`
и `authority.ts`: расчёт Tank/Arcade, UUID-назначения произвольного числа моторов,
версионный JSON желаемого профиля и модель stop → neutral → manual. В production
runtime они не подключены. 21 направленный тест проверяет знак и пределы
смешивания, 2/4/10 моторов, первый/второй жест, непрерывную нейтраль, потерю
любого привода/приёмника, устаревшие данные, часы, команды и допуски после reboot.
Оси для перехвата объявляются отдельно от осей микшера: в макете даже второй
рычаг останавливает Core в Arcade, а пропавшая ось не считается нейтралью.
Отдельный макет `apps/control-station/tools/rover-control-preview` использует
канонические контролы Design Guideline, показывает расчёт и сохраняет/выгружает
черновик. Сеть запрещена CSP; никакого обращения к роверу или новой продуктовой
вкладки нет. Пороговые времена в моделировании — синтетические, не приёмка
времени остановки Rover006. Алгоритмы калибровки VESC Tool не копировались.
Контракт и ограничения: [rover-control README](../../packages/rover-control/README.md).
## Текущий пульт: приёмка потери сигнала завершена
2026-09-24 владелец прочитал Failsafe: CH1…CH10 показывают 0%. После отдельной
проверки каждого мотора с удерживаемым рычагом и извлечением батарейки пульта
владелец подтвердил остановку обоих. Возврат питания/радиосвязи с рычагами
в центре не вызвал движения. Журналы, ограничения измерений и отличие от
непринятого первого опыта записаны в [протоколе 24](24_RC_FAILSAFE_ACCEPTANCE.md).
Рабочие конфигурации не менялись; это приёмка существующего прямого RC-пути,
не новой логики перехвата. Можно продолжать чтение Functions → Mix и карты
каналов. Подтверждение аппаратного исполнителя профилей в Core остаётся
обязательным до их активации: один переключатель в UI не меняет проводку.
## Сохранённые настройки пульта: осмотр 2026-09-25
Владелец последовательно прочитал и затем явно подтвердил весь список: всего
четыре правила Mix, номера 1/3/4 выключены, только Mix 2 включён. Параметры
Mix 2: Master C3, Slave C4, Offset 0%, NEG −100%, POS −100%. Это перечень
правил внутри текущей модели, не четыре взаимоисключающих профиля ровера.
Настройки не меняли. Назначение C4 в конструкции пока не установлено; по
сообщённой проводке моторные входы подключены к CH2/CH3. Нельзя приписывать
этому миксу управление вторым мотором без проверки всей карты каналов.
Владелец прислал видеозапись меню продолжительностью около 74 секунд.
Выполнен локальный визуальный разбор кадров; исходное видео не изменено,
аудиодорожка не транскрибировалась. Закрытый manifest с SHA-256 исходника,
методом разбора и кадрами хранится вне репозитория в
`outputs/rover-006-tx-menu-review-20260925/manifest.json` корня рабочего пространства.
Время ниже приблизительное, это не измерение задержки управления.
| Экран | Наблюдение |
| --- | --- |
| Монитор каналов, 8–13 с | Полосы CH1…CH6, затем прокрутка. Отдельных движений осей с одновременным наблюдением каналов нет. |
| Reverse, 21–23 с | CH9 — Rev; остальные показанные каналы CH1…CH10 — Nor. |
| End points, 28 с | CH2: 100/110%; CH3: 110/100%, в порядке двух столбцов экрана. Это диапазон сигнала пульта, не проценты мощности или тока VESC. |
| Subtrim, 33–36 с | Видимые значения 0%. |
| Trims, 40–42 с | Off. |
| Rate/Exp., 45–47 с | Только показанный CH1 Normal: Rate 100, Exp. 0. Значения других каналов не проверены. |
| Rate/Exp. switch, 50–51 с | Assign SW: Null. |
| Throt curve, 56–57 с | График визуально прямой; численные значения всех точек не открывались. |
| Aux. channels, 60–62 с | Channel 5 назначен SwA. Назначения SwB/SwC/SwD ещё не прочитаны. |
| Failsafe, 66–67 с | CH1…CH10 показывают 0%, согласуется с предыдущим чтением и принятой проверкой потери связи. |
SWA → CH5 подтверждает назначаемую функцию переключателя в текущей модели,
но не доступ Mini к этому каналу и не аварийную остановку. Монитор каналов
найден; повторно искать его или перебирать Mix не требуется. Следующее чтение
— системные Sticks Mode, Output Mode и About: установить раскладку, режим
выхода и версию устройства без изменения модели. Фактическое соответствие
осей выходам затем проверяется отдельно; этот ролик его не доказывает.
Никаких команд аппаратуре или изменений приложения при разборе видео нет.
## Системные меню и идентификация пульта, 2026-09-25
Вторая запись владельца, около 77 секунд, содержит экран About на 72–74 с:
Flysky FS-i6S, версия 2.00, дата 04-Apr-2020, Hardware V_3.0. Идентификация
пульта теперь опирается на его собственный экран, а не только на форму корпуса.
Это не идентификация VESC и не повод обновлять прошивку пульта или моторов.
Trainer Mode показан OFF, Switch Null; Student Mode показан OFF. Попытки
открыть Output Mode (около 22 с) и Sticks Mode (около 25 с) останавливаются
сообщением `Turn off RX!`. Значения режима выхода и раскладки рычагов не
открылись, поэтому нельзя объявлять Mode 2 либо конкретный PWM/iBUS режим
прочитанными. RX здесь означает приёмник; повторное нажатие OK при работающем
приёмнике не раскрывает заблокированные настройки. Для чтения нужен штатно
обесточенный приёмник при оставленном включённым передатчике, без изменения
значений или обхода блокировки. Никакого отключения во время разбора не было.
Закрытое подтверждение с SHA-256 исходника и пятью кадрами:
`outputs/rover-006-tx-system-review-20260925/manifest.json` корня рабочего
пространства. Видеофайл неизменён, аудио не транскрибировалось; просмотр кадров
не является проверкой движения или записью конфигурации. Не запускать
Sticks Adjust, Factory Reset, RX Bind или Firmware Update ради чтения профиля.
## Ограничение комплектации: без нового подключения приёмника, 2026-09-25
Владелец явно исключил подключение FS-iA6B к Mini дополнительными проводами,
адаптером или пайкой: таких средств нет, этот вариант сейчас неприемлем.
Прямой вход iBUS/PPM в Mini не включать в обязательный текущий план. Он не
нужен для уже доказанного управления VESC по USB: оба существующих тракта
сохраняются — пульт → приёмник → моторный вход VESC и Mini → USB → VESC.
Через VESC доступны два подключённых входных канала; полный поток остальных
осей и переключателей от этого не появляется.
Наличие двух рабочих путей не означает приёмку их одновременных конкурирующих
команд. Нынешний код ограниченных тестов временно удерживает выход PPM,
продолжает читать вход и прекращает тест при RC-команде. Это не готовая
постоянная логика «первый жест — остановка, нейтраль, второй жест — ручное
управление» с гарантией при отказе Mini. Требование владельца сохраняется;
нельзя объявлять его выполненным либо обещать независимую гарантию только
потому, что USB-команды и прямой пульт по отдельности проверены.
Продолжать аудит штатного микшера пульта и исполнительных возможностей VESC
в имеющейся комплектации. Реализуемость Arcade на существующих моторных
выходах, переключение его из Mission Core и независимый перехват — отдельные
вопросы. Чтение меню пульта не доказывает дистанционную запись его профиля.
Если точное требование недостижимо в этой комплектации, описать конкретное
ограничение владельцу, не подменяя задачу профилем только для команд Core.
## Разблокированные меню после отключения батареи, 2026-09-25
Владелец прислал ещё две записи, около 66 и 32 секунд, с отключённой, по его
сообщению, батареей ровера. Системные страницы теперь открываются. Прочитаны:
- Models: Model 1.
- Output Mode: выбран PWM; отдельный Serial: выбран i-BUS. Наличие выбранного
i-BUS не означает подключение этой линии к Mini; существующие VESC получают
отдельные каналы PWM. Проводка остаётся прежней.
- Sticks Mode: первоначально M2; на 37-й секунде первого ролика видно
переключение в M1, затем на 38–39-й — возврат в M2 до выхода со страницы.
- Базовые назначения M2 до миксов: правый горизонтальный CH1, правый
вертикальный CH2, левый вертикальный CH3, левый горизонтальный CH4.
Включённый Mix 2 добавляет зависимость итогового CH4 от CH3; нельзя называть
его выход независимым сырым значением левой горизонтальной оси.
- Throt Mode: Self centering.
Это подтверждает, что для правого Arcade требуются две оси CH1/CH2, тогда как
нынешние моторные подключения CH2/CH3 дают два вертикальных канала. Прямые
команды Mini по USB остаются доступным независимым путём. Штатное микширование
нужно проверять по фактическим выходам CH2/CH3, включая подавление ненужной оси,
знаки, нейтраль, насыщение и сохранение рабочего Tank. Руководство описывает
парный Master/Slave, но не доказывает конкретный порядок каскадирования миксов
и кривых; схему нельзя объявлять рабочей только по формуле или числу слотов.
### Новая проверка перед возвратом к движению
На 48-й секунде первого ролика открыт Sticks Adjust. До конца записи нет
полного прохода всех осей/крутилок по пределам; второй ролик начинается уже
в обычном меню. Завершение процедуры между роликами не видно. По штатному
руководству §7.8 это калибровка аналоговых органов пульта, с сохранением через
выход после центрирования, а не обычный монитор каналов. Нельзя гарантировать
неизменность калибровки или объявлять её испорченной по этим кадрам.
Перед повторным включением силового питания нужен обычный монитор CH1…CH6
с проверкой нейтрали и отдельных полных ходов осей при выключенных приводах.
Это проверка пульта, не повторная FOC/Hall-калибровка VESC. Не использовать
Sticks Adjust для чтения показаний и не запускать автоматический sweep.
В первом ролике также кратко открыт RX Bind при выключенном, по сообщению
владельца, приёмнике; результат привязки не проверялся. Во втором открыт
диалог Factory Reset: нажата правая кнопка и выполнен возврат в меню; нет
подтверждения выполненного сброса. Firmware Update просмотрен до страницы
Continue, затем закрыт; обновление на записи не запускалось. Не превращать
обход меню в утверждение, что все действия были только чтением.
Закрытые оригинальные хэши, кадры и метод разбора:
`outputs/rover-006-tx-offline-review-20260925/manifest.json` корня рабочего
пространства. Оригиналы не изменены, аудио не транскрибировалось. Со стороны
ассистента операций с пультом, Node или VESC при разборе не выполнялось.
## Проверка монитора каналов после Sticks Adjust, 2026-09-25
Следующая запись владельца, `IMG_2491.MOV`, около 41 секунды, показывает
обычный монитор CH1…CH6 и движения сначала левого, затем правого рычага.
Оба используемых моторных канала проходят в положительную и отрицательную
сторону: CH3 при вертикальном движении левого рычага, CH2 — правого. После
возврата рычагов значения визуально близки к центру, с небольшими остаточными
смещениями. Признаков застрявшего крайнего значения или отсутствующей половины
хода этих двух каналов в записи нет. По полоскам нельзя измерить точные
проценты, ширину PWM-импульса или подтвердить допустимую нейтраль на VESC.
Горизонтальные движения изменяют CH4 слева и CH1 справа; часть правого
горизонтального прохода затемнена/перекрыта рукой. Зависимость CH4 от
вертикального CH3 имеет противоположный знак и соответствует ранее прочитанному
Mix 2. Неподвижное пятно на поверхности экрана возле CH4 не является показанием
канала. Это качественная проверка раскладки M2 и моторных выходов пульта,
не приёмка Arcade, нового перехвата, радиосвязи или failsafe после изменения
меню. Повторное обследование меню для установления этих назначений не требуется.
Следующий шаг после подтверждённого владельцем возврата питания на вывешенном
ровере с рычагами в центре — прочитать фактические уровни обоих входов через
VESC и сравнить их с сохранённой мёртвой зоной, без команд вращения. Во время
разбора этого видео силовое питание не включалось средствами ассистента,
команды приводам не отправлялись, калибровка VESC не менялась.
Закрытые хэш исходника, 11 кадров и метод разбора:
`outputs/rover-006-tx-axis-review-20260925/manifest.json` корня рабочего
пространства. Оригинал не изменён; аудио не транскрибировалось.
Продолжение: владелец вернул питание, проверил движение от стиков, затем
подтвердил нейтраль и физическую остановку обоих моторов. Чтение через VESC
подтвердило уровни справа −0.06, слева +0.068…+0.07 внутри deadband ±0.15,
нулевые duty/ток батареи и отсутствие ошибок. Motor/application конфигурации
обоих контроллеров побайтно совпадают с сохранёнными 2026-09-24. Проверка
нейтрали после просмотра меню закрыта; подробности и границы результата —
в `24_RC_FAILSAFE_ACCEPTANCE.md`, раздел «Нейтраль после возврата питания».
Профиль одного стика и постоянный перехват этим чтением не приняты.
+247
View File
@@ -0,0 +1,247 @@
# Приёмка пульта и потери сигнала
Состояние: оба моторных канала прошли наблюдаемый вывешенный тест остановки
при потере питания передатчика. Восстановление связи с рычагами в нейтрали
не вызвало движения. Точное время реакции и поведение под нагрузкой не измерены.
Node 0.8.40-1, VESC plugin 0.6.7, FW 5.02.
Калибровка и рабочие настройки не изменяются этим исследованием.
Обновление 2026-09-25: после просмотра системных меню передатчика и захода
в Sticks Adjust повторно проверены монитор каналов, физическая остановка по
сообщению владельца и фактическая нейтраль на обоих VESC. Конфигурации VESC
побайтно совпадают с исходными от 2026-09-24. Это не повторный тест потери
радиосвязи после действий с меню пульта; его прежняя приёмка относится к
описанным ниже испытаниям 2026-09-24.
## Начальное состояние, 2026-09-24
Владелец подтвердил: пульт выключен, оба мотора неподвижны, гусеницы сняты,
ровер вывешен. Свежая инвентаризация Core подтверждает два прежних UUID,
нет активного теста или RC-защёлки. Через штатные операции сохранены обе
конфигурации и прочитаны входы/телеметрия. Никаких команд движения, аренды
выхода, alive, записи конфигурации или прямого доступа к serial не выполнялось.
Оба контроллера: вход PPM около +0.010…+0.012 при deadband 0.15, PWM 0,
ток батареи 0, fault 0. Правый показывает 0 ERPM. Левый при физическом
покое показывает -153…-165 ERPM: ранее установленный дрейф бессенсорной
оценки, не доказательство движения.
Сохранённые настройки: duty-cycle PPM control; Safe Start включён; timeout
1000 мс, timeout brake current 0 A; pulse 1.0/1.5/2.0 мс; multi_esc включён.
Слева PPM, справа PPM+UART. Плавность слева 0.4 с набор / 0.2 с сброс,
справа 0.5 / 0.5 с. Экспонента -1.0 слева и -1.5 справа. Из этого нельзя
делать вывод о дефекте мотора или автоматически унифицировать настройки.
## Граница измерения
Декодированный PPM в FW 5.02 сообщает последнее значение и длину импульса,
но не возраст импульса и не состояние радиолинка. Нейтраль при выключенном
передатчике сама по себе не доказывает failsafe: передатчик мог быть выключен
после нейтрали, а приёмник мог удержать последнее значение.
По исходникам pinned upstream 3f670137: при утрате импульсов PPM обработчик
проверяет их возраст и тайм-аут, снимая тягу при timeout brake current 0.
Это не активное торможение с заданным временем остановки. Если приёмник
продолжает выдавать ненулевое последнее значение, тайм-аут VESC не решает
потерю радио. Руководство FS-i6S 2020-06-28, раздел 6.10, описывает Off как
удержание последнего значения. Точная модель/настройка имеющегося передатчика
ещё требует проверки его экрана; внешний вид не достаточен.
## Порядок продолжения
1. Включение пульта с обоими рычагами в нейтрали; чтение входов и наблюдение
отсутствия самопроизвольного движения.
2. Чтение About, Failsafe для CH2/CH3 и карты каналов на пульте; без изменения
текущей модели. Если выявлено удержание последнего положения, сначала
подготовить и проверить нейтральный failsafe через штатные средства.
3. Под наблюдением владельца проверить отклонение/возврат каждого рычага,
соответствие стороне и исчезновение команды в нейтрали.
4. Отдельно согласовать выключение передатчика во время ограниченного
движения, прочитать вход и состояние обоих VESC, получить физическое
наблюдение остановки. Возвращать радиосвязь с рычагами в нейтрали.
5. Проверить восстановление связи и отсутствие самопроизвольного движения.
Чтение через отдельные удалённые операции даёт около 4.8 с на полный цикл
двух входов и двух телеметрий в начальном замере. Такой журнал фиксирует
состояния, но не позволяет подтвердить миллисекундную задержку остановки.
Не выдавать USB-время ответа за время реакции радиоканала. При необходимости
точного времени добавить продуктовую бортовую запись, не обходить драйвер.
Исходный приватный протокол rc-failsafe-d83b84fb311245e5b8a3fea31716416b.json,
SHA-256 8098fe14a754622cb027472fa4f48f96fa9ffcb0548b16af557ac90a56ea6fb1.
Два полных цикла чтения, оба успешны. Наблюдатель rc_failsafe_observe.py
разрешает только input.read, telemetry.read и config.backup.
## Уточнение наблюдения после включения пульта
Протокол rc-failsafe-34b93f7184024d87a8e66327f2f60649.json был изначально
помечен `tx-on-neutral-confirmed`, но во время чтения зарегистрированы
отклонения входов и вращение правого. Владелец уточнил: «Двигал рычаги;
сейчас оба стоят». Поэтому этот замер содержит ручное управление и **не**
является ни доказательством самопроизвольного запуска, ни чистой проверкой
включения в нейтрали. Исходная метка и сырой протокол сохранены неизменными.
SHA-256: 26dd21ffae833b4ac13aa1ceb004b97a3b58de8ea4ded9856ec616a335744d4b.
Следующее чтение rc-failsafe-ea478fad45c047db919f892c23a34dc2.json подтвердило:
справа вход −0.064 / 1.468 мс, слева +0.074 / 1.537 мс; оба в текущей зоне
нейтрали ±0.15. На обоих PWM 0, ток батареи 0, fault 0. Справа ERPM 0,
слева −156 при подтверждённом физическом покое — известная бессенсорная
оценка. Это не подтверждает исправность левого Холла и не измеряет failsafe.
SHA-256: 6fe3cceeae8d1afc6116048df9079001a46a1f08a8813de4782ac7433d33dab8.
Владелец отошёл на 15 минут и разрешил независимую разработку/прототипирование.
Моторные испытания приостановлены до его возвращения и новой синхронизации.
Вопрос о модели в About и текущем Failsafe CH2/CH3 остаётся без ответа;
повторно спрашивать или самостоятельно менять настройки по догадке не нужно.
## Возвращение владельца
Владелец сообщил: гусеница снята, пульт включён, он рядом и готов выполнять
действия. Запрошено оставить рычаги в нейтрали и прочитать Failsafe CH2/CH3.
До ответа выполнены только два цикла input.read/telemetry.read: справа вход
−0.064…−0.066, слева +0.072…+0.074; оба в текущей зоне нейтрали. PWM и ток
батареи 0, fault 0 на обоих; справа ERPM 0, слева −159…−151 (прежняя
бессенсорная оценка, не отдельное подтверждение физического покоя).
Протокол rc-failsafe-b05818b1cf8a426f8dccd5e6f80b19a5.json,
SHA-256 511257aafc9d2f000d65073438399b9f7fa52c86515f4a7e994fd28b610737bb.
Команд движения, alive, аренды выхода или записи конфигурации не было.
## Экран Failsafe и первый ручной тест потери сигнала
Владелец открыл сенсорное меню Functions после разблокировки экрана и затем
Failsafe. Сообщил: каналы CH1…CH10, везде 0%. По официальному руководству
семейства FS-i6S числовая позиция означает заданное положение при потере
радиосигнала, в отличие от Off/удержания последней команды. Отдельные
настройки не изменялись. Модель/версия из About ещё не прочитана.
Для проверки выдано задание: на вывешенном приводе с демонтированной
гусеницей запустить правый мотор небольшим отклонением, выключить пульт до
возврата рычага, затем отпустить его; сообщить наблюдение остановки. Если
вращение продолжается — вернуть рычаги в нейтраль и восстановить пульт.
Программа при этом только читала входы и телеметрию, 13 циклов за примерно
60 секунд. Зафиксированы правый вход +0.254, 798 ERPM и PWM 0.051; затем
PWM 0, 72 ERPM, и в последнем цикле вход −0.064, PWM 0, ERPM 0. Fault 0
на обоих VESC. Пары input/telemetry снимаются последовательно, не синхронно.
Протокол rc-failsafe-fe6aea514466484aad007568c9f90cc0.json,
SHA-256 d1c471f855f80c1715578df0bfc0e69a8c517a7cc9c2cf1aeaa09ef61e37c9a9.
Физический результат и факт выключения при удерживаемом ненулевом рычаге
пока ожидаются от владельца. До этого переход нельзя объявлять принятым
failsafe; USB-наблюдение не отличает потерю радиосвязи от отпускания стика.
Никакого испытания левого канала или восстановления радиосвязи ещё не принято.
Уточнение владельца к первому тесту: он **сначала отпустил рычаг**, затем
вынул батарейку пульта. Поэтому этот опыт не подтверждает остановку из-за
потери радиосвязи. После снятия питания передатчика отдельное чтение показало
правый вход +0.010 / 1.504 мс, левый +0.012 / 1.506 мс; PWM и ток батареи 0,
fault 0 на обоих, ERPM правого 0. Протокол
rc-failsafe-f99b5bb25bff471dbe017ed0b8f4632b.json,
SHA-256 4ade3d8a2061048c67b3e21a83304fd6a9cb832942189f6a08c29792a6984adc.
Это подтверждение нейтрального выхода при выключенном пульте после нейтрали,
а не проверка прекращения ненулевой команды. Запущена отдельная запись для
повтора с явным удержанием рычага до физического отключения передатчика.
## Правый канал: остановка при потере радио подтверждена
Повтор rc-failsafe-b208d87fa223496ca5fffebddf4ad9be.json, 25 полных циклов,
2026-09-24T13:39:31.281029Z…13:41:33.274243Z,
SHA-256 00e64109018c8b815b96b5db1e69fbb32ca7ab80bd8eff672f3ac86b69caffb1.
После восстановления пульта вход правого вышел из нейтрали, зарегистрировано
вращение. На цикле 11 вход +0.232 / 1.616 мс, 282 ERPM, PWM 0.022;
на цикле 12 вход +0.010 / 1.505 мс, ERPM 0, PWM 0. Левый вход также
вернулся к прежнему значению выключенного передатчика, PWM 0. Fault 0 везде.
Владелец подтвердил: он вращал правым рычагом, вынул батарейку, и мотор
остановился непосредственно при её извлечении. Это исправленный повтор
предыдущего опыта с отпусканием рычага до отключения. Для правого канала
поведение прекращения ручной тяги при потере радиосвязи принято в рамках
этого вывешенного теста. Точная задержка в миллисекундах не измерена;
нагрузочный тормозной путь и будущий stop-first перехват из Core не проверены.
Следующий запуск записи для левого был случайно прерван владельцем. Проверено:
нового протокола не создано, процессов rc_failsafe_observe.py не осталось.
Команд движения не было. После возобновления запущен отдельный read-only
протокол левого и выдана та же последовательность удержания рычага до
отключения. Его результат пока ожидается; приёмка левого не следует из правого.
## Левый канал: физическая остановка подтверждена владельцем
Протокол rc-failsafe-cc289f6fce514a21af66bf7c17117e59.json завершён,
24 полных цикла; SHA-256
64164b2083a1b28172ebe1471a7f59df88c1d10b085db6e204f7c60f81608d11.
На цикле 8 левый вход +0.280 / 1.640 мс, ток мотора 2.39 A; на цикле 9
вход +0.012 / 1.506 мс, ток 0.04 A. После отключения оба PWM нулевые,
fault 0. Сама скорость вращения левого не попала в редкие последовательные
USB-замеры; его отрицательный ERPM при PWM 0 нельзя трактовать как движение.
На отдельный прямой вопрос «Левый мотор действительно крутился до извлечения
батарейки и остановился именно после него, пока рычаг оставался отклонённым?»
владелец ответил «Да, крутился и остановился после извлечения». На основании
этого физического наблюдения и записи возврата входа в нейтраль поведение
левого канала при потере радио принято для данного вывешенного теста.
Точное время остановки, нагрузка и исправность Холла этим не подтверждаются.
Далее запущена отдельная запись восстановления пульта в нейтрали обоих рычагов.
## Восстановление связи и итог
Протокол rc-failsafe-6811a6865139462cb233566fb2bead05.json завершён,
13 полных циклов; SHA-256
c9d36bf24ca7ae43bb51bd7777c936e58ca44f3dd3d3b3b14126f6cf8717516f.
В ходе записи входы перешли от значений выключенного пульта около +0.01
к его включённой нейтрали: справа −0.062…−0.064, слева +0.076. PWM 0,
ток батареи 0 и fault 0 на обоих во всех записанных циклах. Правый ERPM 0;
слева прежняя ненулевая бессенсорная оценка при снятой тяге.
Владелец подтвердил: после возврата батарейки, включения и ожидания около
пяти секунд с рычагами по центру оба мотора остались полностью неподвижны.
Приёмка текущего RC-пути в объёме стендового сценария завершена:
ненулевая ручная команда → полное отключение передатчика → остановка отдельно
справа и слева; затем восстановление связи в нейтрали без самопроизвольного
движения. Источник физического результата — наблюдение владельца; журналы
фиксируют входы/телеметрию с последовательным USB-опросом, а не точную задержку.
Конфигурации и калибровка не изменялись, команды движения от Core не выдавались.
Этот результат не принимает новый stop → neutral → manual перехват из
автономии, отказ Mini/USB, поведение восстановления с отклонённым рычагом,
нагрузочный тормозной путь или исправность повреждённой левой цепи Холла.
Следующий этап — чтение текущих Mix/карты каналов пульта для Tank/Arcade,
без изменения рабочего радиопрофиля по догадке.
## Нейтраль после возврата питания, 2026-09-25
После видеопроверки монитора передатчика владелец вернул питание ровера и
сообщил, что моторы вращаются от стиков. Перед чтением отдельно подтвердил:
пульт включён, оба стика по центру, оба мотора полностью неподвижны. Через
канонический Core выполнены config.backup обоих VESC и два последовательных
цикла input.read / telemetry.read. Тестов вращения, аренды выхода, alive и
записи конфигураций не было. Оба прежних UUID в свежей инвентаризации доступны,
активных моторных тестов нет.
| Измерение | Правый | Левый |
| --- | --- | --- |
| Уровень декодированного входа | −0.059999 | +0.068…+0.069999 |
| Импульс | 1.470 мс | 1.534…1.535 мс |
| Настроенная зона нейтрали | ±0.15 | ±0.15 |
| Duty / ток батареи / fault | 0 / 0 / 0 | 0 / 0 / 0 |
| ERPM | 0 | −162…−147 |
Оба входа внутри мёртвой зоны. Ненулевая бессенсорная оценка ERPM слева при
нулевом выходе и подтверждённом физическом покое не является вращением.
Сырые показания тока мотора справа −0.55…−0.79 A, слева +0.09…+0.11 A;
не подменять их утверждением, что все датчики показывали математический ноль.
Проверка нейтрали завершена; изменение deadband или повторная калибровка VESC
по этим результатам не требуется. Полный цикл чтения занимает около 6 секунд
и не измеряет задержку перехвата.
Прочитанные motor/application payload каждого контроллера побайтно равны
исходным из протокола `rc-failsafe-d83b84fb311245e5b8a3fea31716416b.json`.
Рабочая FOC-калибровка, режимы sensorless/Hall, направление, токовые пределы
и настройки PPM сохранены. Это сравнение конфигураций VESC, не всей модели
или внутренней калибровки передатчика.
Приватный протокол `rc-failsafe-b79d96f465f94fcb881e48a536ea0cc7.json`,
SHA-256 `c71b9c4b805f9b7a0909e8a26355297a831224997f7cb0fd908ce08bf88960eb`.
Отдельный результат сравнения:
`rc-neutral-comparison-b79d96f465f94fcb881e48a536ea0cc7.json` в той же закрытой
папке `outputs/rover-006-vesc-context-20260923/native-probe`. Наблюдатель
завершился штатно; постоянный процесс опроса не оставлен.
@@ -0,0 +1,545 @@
# Observation and remote control — implementation record
Owner request: 2026-09-25. Extend the existing per-vehicle observation center.
The operator sees cameras, map, a vehicle model and per-controller telemetry,
then explicitly takes keyboard/pointer control. Return goes to the vehicle list.
## Product surface decision
Selected: two new layers in the admitted observation composition, with the model
below the map and telemetry alongside it. Rejected: a separate driving workspace,
which would separate the operator from cameras and duplicate vehicle navigation.
No primary navigation or product root changes. Layer visibility/order/splits stay
in the existing versioned per-vehicle layout. The header host owns the back action.
States: offline, observing, preparing, ready, driving, receiver takeover, stopped,
fault. Showing a model, pressing a key while disarmed, or reconnecting never arms
motion. Stale telemetry is unavailable, not zero. No fabricated position/distance
or model motion is inferred from keyboard intent.
Design Guideline: existing Button/IconButton/Window/Select/Switch/LoadingRegion,
SplitPane, GlassSurface and StatusBadge. Owner explicitly approved outlined
momentary key buttons; shared KeyButton is added to DG first. Arcade W/S+A/D;
tank Q/A left forward/reverse, E/D right forward/reverse. Space stops/disarms.
## Sources
NodeDC source: `NODEDC_ENGINE_INFRA/nodedc-source/src/viewer/playcanvas/`.
Copy the rendering implementation, DEFAULT_POSTFX and environment atlas with
source hashes; keep lighting/postprocessing/grid shader settings unchanged.
Vehicle export preserves immutable Blender originals. v021 is a two-scheme
kinematic study, not the full rover; full reconstruction is v020. Owner explicitly selected v020. The export contains all 1,426 renderable
objects, joined into four donor material groups without geometric decimation.
## Control boundary
Existing five-second inventory/operation heartbeat is unsuitable for held keys.
Implement a separate authenticated, ephemeral command channel on the already
paired mTLS transport. Core does not access USB. The Node relays to its single
VESC owner. Browser, relay and native output each have bounded leases; commands
are identity/session/sequence bound and never persist or resume after restart.
Motion command transport is distinct from configuration/calibration operations.
Per-wheel current is measured motor current; battery input current is separate.
Stock FW 5.02 resumes direct PPM when its output-disable lease expires. A healthy
board can stop on RC intent and require neutral; a failed board cannot guarantee
the full first-gesture-stop protocol against held RC input. This limitation must
remain explicit in acceptance and cannot be fixed by UI promises.
## Validation — 2026-09-25
Core lease tests: 13 passed. VESC driver: 127 tests passed, including eight
remote-control tests (expired/duplicate frames, terminal stop, invalid bounds,
RC first-gesture zero hold, reversal neutral interval, one-port failure and
unconfirmed release). Native Tool adapter compiled and passed offline archive
and output-denial checks on Mini; no hardware access during qualification.
Control Station: architecture checks, typecheck, 939 unit tests and production
build passed. The active operator checkout retains its unrelated simulation and
AI polygon changes. Canonical 8000 was restarted through its existing launchd
service, preserving configuration and operator data.
Browser acceptance: back returns to the fleet list; full v020 visible; model
selection persists across reload; Tank changes the key pad to Q/E/A/D; Arcade
restores W/A/S/D; both layers appear in Available layers; hidden telemetry stays
hidden after leaving/re-entering, then was restored. Pane maximize/restore keeps
the scene and canvas sizes correctly. No arm or motion command sent.
Export correction: initial exporter unintentionally included the original
Blender scenes; visual QA found the studio floor obscuring the rover. Export is
now limited to the active export scene and selected joined object, verified as
one scene, one mesh, four donor materials (54,960,576-byte GLB). Immutable v020
source is unchanged. All donor lighting/postprocess defaults remain unchanged;
only asset URLs, responsive hosting and model framing adapt to Mission Core.
Node 0.8.41-1 / VESC integration 0.7.0 qualification passed and the versioned
owner installer completed on Mini. dpkg confirms 0.8.41-1; Node/VESC services
are active. The paired fast channel supplies fresh RIGHT telemetry with no
control session. LEFT is absent from Linux USB enumeration, while its UUID
assignment remains intact; kernel descriptor errors -71 occurred at 11:18–11:19
MSK, before the installer stopped the driver at 11:20:28. The owner confirms
USB/power was reconnected during the work. This does not establish the physical
cause; no USB reset or ad-hoc OS change was performed. The telemetry layer now
retains missing assigned motors, explicitly shows no connection, and never
substitutes another UUID or zero measurements.
Hardware motion, actual channel latency and physical stopping require a fresh
attended acceptance test. Offline tests do not qualify loaded driving or the
Mini-independent stop-first RC behavior described above.
Ship every Node runtime change in the versioned installer; no ad-hoc board edits.
### Owner power-cycle and first admission attempt
At 11:35 MSK the owner disconnected/reconnected the battery. Both USB devices
then enumerated and the existing runtime recovered both assigned UUIDs without
an application restart or agent-issued port reset. Twenty read-only samples
(10 seconds) all contained both fresh devices, no active control session.
After fresh owner confirmation (tracks off, raised stationary rig, TX off,
observing), one Core API trial was armed at 11:38:46 MSK. Initial preflight
rejected sensorless duty=0.001; no forward command, output claim or temporary
limit application was reached. This is not a keyboard or motion acceptance.
Source investigation: pinned FW 5.02 mcpwm_foc.c, commit
3f670137e27e6e383fa79c50cc6b1fa85aab1554, undriven branch lines 2550–2693,
computes duty_now from measured phase voltages/back EMF even with no driven
output. commands.c encodes this field with scale 1000. The earlier strict
zero-duty assumption for observed sensorless standstill was therefore incorrect.
The new bounded tolerance is one wire quantum, abs(duty)<=0.001, only for that
already explicitly observed sensorless case. The other current, voltage,
temperature, fault, input-current and stable-neutral checks remain in force.
This is an admission bound, not proof of physical stopping or a motor rating.
New regression verifies both signs of one quantum, rejection of two quanta and
continued rejection of unobserved speed drift. All 128 VESC tests passed.
Node 0.8.41-2 / VESC integration 0.7.1 passed qualification and was installed
through the owner-authorized versioned installer at 12:08:23 MSK; no ad-hoc
runtime patch and no automatic repeat of movement.
The same follow-up separates terminal input-lease completion from hardware
faults: normal session end reports stopped only after cleanup; unconfirmed
release or restoration warnings remain fault. Regression drives the actual
output loop through the session wrapper and verifies release of both outputs.
The preceding qualification completed, but its package was superseded before
installation to include this correction in one owner update. Final VESC suite: 130 tests passed.
Post-install verification: both services active, installed runtime 0.7.1, both
assigned controller UUIDs fresh after observation refresh (14–205 ms), zero
currents/duty/fault codes and no control session. Fresh owner observation is
required for the next attended API trial; physical keyboard/RC acceptance remains pending.
### Attended retry and channel diagnostics
At 12:18:51 MSK, the helper rejected cached device samples before sending arm.
Read-only refresh restored both fresh UUIDs. The one armed trial at 12:19:28
remained in preparing for about five seconds, then ended stopped before the
helper ever sent forward demand. The browser-to-Core zero-demand heartbeat
continued every approximately 100 ms. No service restart occurred. The current
evidence does not distinguish a Core transport failure from a Node scheduling
gap; it does not establish a new USB failure.
Node 0.8.41-3 adds a bounded 32-frame in-memory channel diagnostic recorder,
flushed to the private service journal on session transitions, transport errors
or long gaps during a session. It records monotonic intervals, binding wait,
Core/driver elapsed times, sequence/remaining TTL and state. It excludes trust
material, endpoints, request bodies and raw HTTP error text. Admission, timeout,
lease and motor-output behavior are unchanged. Qualification/installation is
pending; no automatic physical retry.
Node 0.8.41-3 installation completed successfully at 09:36:34 UTC in 18 seconds
after owner-local Ubuntu authorization. Package version and active Node/VESC
services verified. Both assigned UUIDs produce fresh readings (190/199 ms),
zero motor current and fault codes; no control session. Owner observation is
requested again before one bounded repeat; no motor output sent after the
12:19 preparation abort.
### Recorded channel timing and operator TCP correction
The attended 12:38:43 MSK retry again ended in preparation without forward
demand. The new journal localizes the failure to the Core/Node transport budget:
Core round trips commonly took 100–270 ms, driver calls 1–3 ms and initial
binding-lock waits zero. One response explicitly expired in transit; another
response body exceeded the 300 ms HTTP deadline. The first inferred consecutive
command arrival gap was already greater than its remaining lease. No service
restart occurred. This does not establish an electrical or USB fault.
Read-only Tailscale checks confirmed DERP(hel) relay rather than a direct path.
Five Tailscale probes: 42–120 ms; five ICMP probes: 42.8–157.5 ms, zero loss.
No VPN, route, firewall or onboard OS settings were changed. Private network
inventory is retained only in the private evidence directory.
The paired Core HTTP handler used the standard TCP Nagle default while writing
small response headers and JSON separately. Enabled its supported
disable_nagle_algorithm option (TCP_NODELAY) to remove an avoidable buffering
source; this does not change authentication, TTLs or output limits. All 52
Fleet tests passed. Promoted the same narrow change to the active operator
checkout and restarted its existing launchd service only after verifying no
active motor session. Canonical 8000 and both fresh controller readings recovered.
Whether this is sufficient for the current relay path still requires attended
acceptance; no automatic motion retry.
The 12:46:39 MSK attended retry disproved sufficiency: responses improved to
47–70 ms at times but still reached 166 ms, and preparation ended before any
forward command. Four armed remote-channel attempts have now failed in
preparation; none is a motion acceptance. Subsequent motor trials are paused
while the command delivery mechanism is corrected.
### Node 0.8.42: independent command stream (qualification pending)
The paired Core endpoint now offers an authenticated NDJSON response containing
the latest command every 50 ms. Telemetry remains a separate request/response
channel. Certificate, binding and endpoint revision are checked on every frame.
No command queue is introduced. Legacy Node clients retain their existing
exchange response; new clients consume commands only from the stream.
Core attaches a random process clock epoch and an absolute monotonic expiry to
each command. Node estimates a conservative upper bound on Core clock offset
using request-send time and the timestamp taken afterward at Core. It never
assumes symmetric network latency or synchronized wall clocks. A 5 ms margin,
1000 ppm clock-rate allowance and 25 ms private driver-call reserve reduce the
remaining lease. The original 400 ms deadline is not extended on receipt or
repeated frames. Clock calibration expires after two seconds; loss of return
telemetry retires the Core session after one second. These are software timing
bounds, not hard-real-time or field-safety certification.
A separate local 20 Hz loop feeds only the latest unexpired intent to the
single VESC owner. Stream silence cancels its network read after 350 ms;
disconnect, epoch change, binding change or failed local RPC requires an
acknowledged null command before accepting further frames. An interrupted
session remains retired in the existing native-driver lease and cannot resume
on reconnection. The C++ output watchdog (200 ms), PPM suppression lease
(250 ms), firmware, calibration and output limits are unchanged.
Qualification covers asymmetric delay, measured relay jitter, original expiry,
replayed frames, stop-ack races, old clock epochs, stale calibration, silent
HTTP streams, multiple frames per response and binding revocation midstream.
Physical API motion, keyboard release/blur and RC takeover acceptance remain
pending and require fresh owner observation after installation.
Node 0.8.42-1 installed successfully at 10:22:27 UTC. Forty read-only samples
over ten seconds showed observing/no active session; the last twenty contained
both assigned controllers with ages at most 217 ms, zero current and fault.
The freshly authorized 13:25:53 MSK API trial still ended during preparation,
without forward demand. Unlike the previous transport, the local driver loop
held 50–51 ms intervals and 1–3 ms responses; no stream disconnect was logged.
In the retained timeline sequence 26 had about 29 ms remaining at local time
218304 ms; sequence 28 reached the driver at 218355 ms. This is consistent
with an expired previous lease despite delivery of a subsequently fresh frame.
The driver correctly does not revive such a session. It is not evidence of a
new USB fault or accepted movement.
Follow-up 0.8.42-2 removes periodic-tick waiting for new intent at both ends:
Core condition notification wakes the stream immediately on accepted input or
stop; Node uses a one-slot wake signal and always reads the latest intent.
The 50 ms periodic tick remains a fallback, not an input queue. Tests cover
updates before/during wait, immediate stop, coalescing and unchanged replay.
Original 400 ms expiry, output watchdog and current limits remain unchanged.
The private diagnostic history expands to 128 frames, and terminal telemetry
no longer triggers idle journal spam solely because it retains a session ID.
Core tests: 59 passed. This follow-up requires qualification and installation
before a separately synchronized physical retry.
Owner OS authorization completed: final 0.8.42-2 installed at 10:47:29 UTC
in 18.67 seconds, all installer steps exit 0; Node and VESC services active.
Forty read-only samples over ten seconds confirmed observing/no active session.
The final twenty contained both assigned controllers, at most 216 ms old,
with zero motor/input current, duty and fault. Fresh owner observation has
been requested before any new motion; the five previous failed preparation
attempts remain failures, not movement acceptance.
Sixth attended Core API attempt, 10:52:51 UTC, stopped before forward demand:
"another VESC operation is running". Recorded command TTL remained 323 ms,
so this particular failure is different from the preceding lease expiry.
The remote observer shares operation_lock; _prepare previously attempted
nonblocking acquisition and treated any overlapping read as a fatal conflict.
The trace does not identify which operation held the lock; code and a
concurrent regression reproduce this admission race without hardware.
Candidate Node 0.8.43-1 / VESC 0.7.2 waits up to 500 ms for exclusive access,
checks the existing input lease every 20 ms and after acquisition, and skips
new observer cycles while the control worker is alive. It does not queue a
future drive or extend the input lease. Tests cover completing an in-flight
read, Stop while waiting, expiry before acquisition, bounded rejection of a
long operation, and observer yielding. Canonical unittest discovery passes
135 tests. A first full pytest invocation incorrectly collected the imported
protocol helper test_packet as a fixture-based test; no product failure was
reported, and the suite was rerun with its canonical unittest runner.
Installation and a separately synchronized physical retry are pending.
Node 0.8.43-1 / VESC 0.7.2 installed at 11:06:32 UTC in 18.49 s,
all installer steps exit 0. Both services active; final 20/40 read-only
samples contained both assigned UUIDs, age <=219 ms, zero currents/faults,
observing/no control session. A fresh attended trial is requested separately.
Seventh observed Core API attempt at 11:10:39 UTC reached preparing without
the ownership fault, then stopped before forward demand. Sequence 19 arrived
with ~313 ms remaining; sequence 20 followed ~315 ms later, consistent with
expiry at this boundary. The old trial sent a command, synchronously fetched
telemetry and only then scheduled its next input. Telemetry reads introduced
periodic delays up to ~319 ms in its sample cadence. This differs from the UI,
where the command heartbeat and telemetry poll are independent.
The corrected attended_stream_test.py separates bounded telemetry polling
from 100 ms command renewal, preserves the same 400 ms lease and all existing
preflight/stop checks, and records request timing for every command. Synthetic
checks prove blocked reads do not hold the input path, failures abort and
reader threads terminate. This is a test-harness correction, not movement
acceptance or proof that all transport jitter is solved. A fresh observed
trial is requested; there is no automatic motion retry.
Core-only timing logs were added to distinguish registry wait, archive and
save delay. All 59 Fleet tests pass; loopback HTTP test needed sandbox network
permission. The active Core received only this reviewed registry.py diff and
was restarted without an active session. Read-only samples: max local GET
179.8 ms, three of forty above 100 ms; retained registry waits 25.9–58.5 ms.
No archive/save delay above 25 ms was reported in the initial capture. Thus a
registry persistence bottleneck is not yet established by these measurements.
Eighth observed trial at 11:22:27 UTC still stopped before forward demand.
Independent input recording showed local Core command POST outliers 103.4,
166.5, 216.1 and 127.4 ms (normally 2–10 ms). Sequence 17 reached Node with
312.7 ms remaining; sequence 19 followed after 335 ms. Separating trial
telemetry therefore did not by itself solve the delivery problem.
A bounded read-only macOS sample of the operator Core found JSON encoding and
zlib work on its ASGI main thread. The periodically polled completed planning
report /api/v1/mission-planner/live-tests/active is 2,403,591 bytes and took
218.5 ms for one local GET. Its endpoint fetched the dict in a worker, but
FastAPI recursively encoded it and the middleware compressed it on the event
loop. This shared process also admits rover commands.
planning_live_api.py now constructs the JSON response and optional gzip body
in the existing thread pool, preserving the full report contract and bypassing
second compression through Content-Encoding. No polling is disabled, evidence
is not removed, and Node, calibration, current limits and leases are unchanged.
13 focused tests passed against development and active Core: JSON/gzip execute
off-loop, exact content survives decompression, empty/failure contracts and
existing planning presentation/compression behavior remain valid. Canonical
8000 was restarted without active control. Sixty ordinary read-only rover
samples over 15 seconds then had max 86.1 ms, p95 64.5 ms and zero over 100 ms;
both assigned UUIDs fresh, currents/faults zero. This improves measured delay,
but does not yet establish motion or field acceptance. A fresh observed retry
is requested separately. Private process samples and device traces stay out
of normal Git.
Ninth attended Core API trial at 11:31:08 UTC passed preparation and completed
8 seconds of forward demand at up to 2000 ERPM, with a 30 A ceiling per motor.
The owner confirmed both motors physically rotated forward and subsequently
confirmed both stopped. Final state stopped, release_confirmed=true, zero
motor/input current and fault. Recorded peaks: left 2001 ERPM / 2.88 A motor,
right 2028 ERPM / 2.81 A motor; these are sampled peaks, not current ceilings.
During driving device ages stayed <=104 ms, all recorded fault codes zero.
Preparation retained old motor samples up to 10.1 s while exclusive setup ran;
those samples are not treated as live motion telemetry. Command POST max
81.28 ms, p95 10.05 ms across 201 requests. The normal stop command was accepted.
This accepts the observed API forward/release path on Node 0.8.43-1 / VESC
0.7.2 and the corrected operator Core. It does not accept physical keyboard
input, Stop/blur, channel loss, RC takeover, loaded or field operation. Those
remain separate checks. No new calibration, firmware, USB reset or OS change
was performed for this trial. Private raw evidence and owner notes are hashed
in the experiment manifest; the previous eight preparation failures remain
recorded as failures.
At 11:38–11:39 UTC the owner physically held W in the 3D View after UI arming,
then released it. Owner reports both motors forward, immediate perceived stop
on release and approximately 0.5–1 s before initial motion. Exact key-event
latency was not instrumented and is not claimed resolved. Read-only capture:
393 samples, no request errors; driving observed for about 10 s (the requested
hold was approximately 8 s). Sampled peaks left 2004 ERPM / 3.51 A, right
2032 ERPM / 2.85 A; driving sample age <=113 ms, all faults zero. Release
returned to ready with motors stopped; the UI Stop action then reached stopped,
release_confirmed=true. Final UI explicitly says control disabled. The read-only
recorder exited and no motion input remained active.
This accepts actual W forward/release, separately from the prior API trial.
Reverse/turns/Tank, Stop while moving/blur, channel-loss and RC takeover remain
unaccepted on the new UI path. Owner startup-delay observation is retained
for a separately instrumented check. No hardware limits or calibration changed.
MISSIONCOR-85 now records both successful trials, their limits and the previous
operator event-loop diagnosis while preserving all hardware/Hall history.
Observed S/A/D session, 11:45–11:47 UTC: the owner confirmed reverse on S
and opposite sides on A (left reverse, right forward); also reports D worked
before the agent disabled control. The telemetry records both opposite-side
patterns with neutral intervals. Final state stopped, release_confirmed=true.
The owner reports a repeatable approximately two-second start delay, with
immediate perceived release. This blocks completion of drive-response acceptance.
Root cause in the remote output loop: it ramps the speed setpoint from zero
at 600 ERPM/s. Both saved configurations have s_pid_min_erpm=900. Pinned
upstream bldc mcpwm_foc.c (3f670137e27e6e383fa79c50cc6b1fa85aab1554) forces
zero duty and resets speed-PID state for targets below that threshold. The
result is 1.5 s of ineffective commands on each start, plus the retained
0.5 s undriven interval when changing direction. Recorded telemetry already
says driving while the rotor remains stopped, consistent with this mechanism.
This delay is downstream of command admission; the exact key-to-node timing
was not captured and no claim of zero network delay is made.
Candidate Node 0.8.44-1 / VESC 0.7.3 starts the remote speed request at each
controller's own read-back minimum PID speed (rounded up for native integer
setRpm), then ramps at the existing rate above it. It does not change the
firmware threshold, motor configuration or current ceiling. Subthreshold
analogue requests release instead of being amplified above the request;
invalid/unreachable thresholds reject before output claim. Zero input still
releases immediately; expiry, RC takeover and the reversal dwell remain.
Synthetic tests cover distinct thresholds including fractional serialization,
first-cycle output, ramp above the threshold, turn signs, subthreshold input,
immediate release and invalid thresholds. Physical response after installation
requires a new observed trial; this candidate is not yet installed.
0.8.44-1 qualification completed: all 18 Ubuntu stages succeeded in 268.87 s,
including 140 VESC tests, Go race checks and Node UI. Source 1b1be6ef5913fd3740a11c69;
package SHA-256 46cae05efa621a2636251f69ab8071ff4e18db67f23203eab396a71f0ef0972b.
Owner installer bf270c06ae6d1e598e963756 launched in the local Ubuntu session.
APT simulation changes only mission-core-node 0.8.43-1 -> 0.8.44-1, with no
added or removed packages. Before launch Core showed stopped, release confirmed,
both currents/ERPM/faults zero. Local OS authorization is pending; launch is
not evidence of installation or an accepted new motor response.
Owner authorization completed: 0.8.44-1 installed at 12:01:17 UTC in 18.56 s,
all installer steps exit 0; Node and VESC services active. Twenty read-only
samples captured after installation, final sample both assigned controllers
fresh (53/63 ms), ERPM/current/fault zero and no active control. Fresh owner
observation requested for W start/release; improved physical response is not
yet accepted.
Post-0.8.44-1 owner keyboard series at 12:05–12:06 UTC included repeated
forward/reverse and both turn patterns. Owner reports delay approximately
halved, forward-to-reverse works, but one side sometimes starts sooner.
Read-only recording: 947 samples, no errors, all faults zero; 23 driving
segments. First side above 300 ERPM in the same sample or 0.25–0.51 s later;
both sides by 0–0.76 s. These are state-relative samples, not key-event timing.
The four-minute recorder ended at ready; a separately saved final snapshot
confirms stopped/release_confirmed, both ERPM and currents zero after UI Stop.
Sampled peaks left 2007 ERPM / 5.04 A, right 2025 ERPM / 3.32 A.
Asymmetry diagnosis: after a turn, the remote code held only the reversing
motor for its 0.5 s neutral interval, while the other side immediately drove
the new command. Trace 12:05:39 (turn -> forward): right above 300 ERPM in the
first driving sample, left +0.763 s. The mirror transition at 12:05:33 had
left first, right +0.511 s. This is a software coordination defect, not evidence
that all smaller differences arise from the broken left Hall circuit.
Candidate Node 0.8.45-1 / VESC 0.7.4 uses one reversal barrier for the entire
assigned drive group. If any side reverses, all outputs release until all
motors have been observed quiet and undriven for 0.5 s, then the current
latest targets start in the same output cycle. Already observed neutral time
counts; no extra dwell is added after a sufficiently long released pause.
Cancelled/replaced direction requests are not queued. The per-controller PID
threshold correction, speed ramp, immediate zero, current caps, expiry and RC
priority remain. Synthetic regressions cover turn->straight synchronization,
a slower coasting companion, counting an existing neutral pause and cancelling
a pending reversal. Installed software remains 0.8.44-1 pending qualification
and owner installation of this separate candidate.
0.8.45-1 / VESC 0.7.4 passed all 18 Ubuntu qualification stages in 267.11 s,
including 144 VESC tests. Source d31a8b586b9a600a2a8e61b3; package SHA-256
af412c607fec9b34aba0af0e8e67614cd6f4d373fb641d7202341fea30be9ba1.
Installer f1a8a72c4a1a7efc4aeebedd launched in Ubuntu. Its APT plan upgrades
only mission-core-node 0.8.44-1 -> 0.8.45-1; no added/removed packages.
Before launch both controllers had zero ERPM/current/fault, control stopped,
release confirmed. OS authorization and new physical acceptance are pending.
0.8.45-1 installed at 12:23:15 UTC after owner OS authorization, 18.56 s,
all steps exit 0. Node and VESC services active. Twenty read-only samples;
final both assigned UUIDs fresh (115/124 ms), zero ERPM/current/fault and
no control session. Fresh owner observation requested for turn->forward
transitions; physical synchrony and remaining control/RC acceptance pending.
Observed 0.8.45-1 keyboard trial, 12:32–12:34 UTC: 545 read-only samples,
no read errors, fault codes zero, driving sample age <=105 ms. Twelve driving
segments; both sides above 300 ERPM in the same sample or within one 0.25 s
sample. Peaks left/right 2014/2043 ERPM and 4.35/3.24 A. Owner reports responsive
forward/reverse/turns. This is coarse telemetry, not exact key-to-output timing.
CRITICAL physical acceptance failure: owner reports D continued after leaving
the browser and releasing the physical key; later clicks recovered it. The
trace contains prolonged D segments (22.82 and 20.56 s), but browser focus/key
source events were not recorded, so exact focus-loss latency is unknown.
Agent ended control through UI; stopped/release_confirmed=true and both motors
zero ERPM/current/fault. Remote-control acceptance remains blocked by this defect.
The UI already subscribed to blur/pagehide/visibility events. Its 100 ms
command sender nevertheless renewed a remembered nonzero demand without a
focus or input-freshness check; a missed browser-host event could hold it
indefinitely. Fix uses a core-owned held-input binding, capture listeners,
50 ms focus polling, and a guard checked at every command heartbeat. A key
requires a fresh trusted press, then OS-repeat evidence: <=1000 ms initially,
<=300 ms after a repeat. This fallback bounds a lost keyup even if the host
also misses focus events. Focus loss/expiry clears all held states and disarms;
returning focus or delivering a late repeat cannot resume movement. Continuous
keyboard control therefore requires OS repeat within those bounds; this is
not a global-background keyboard implementation. Pointer up/cancel/capture-loss
and component disposal release held state. No onboard install or firmware/config
change belongs to this UI fix. Physical focus-loss re-test remains pending.
Owner follow-up after the first focus fix: movement now stops on focus loss,
but terminating the control session is explicitly rejected. New required
behavior is neutral hold with the current healthy control session preserved;
returning focus requires a new physical press, not another arm/preparation.
The implementation now separates input pause (clear held input, send zero,
keep session) from explicit Stop/pagehide/unmount/fault (end session). Guards
still run before every send; old demand is never restored on focus return.
The periodic neutral messages maintain the session only while communication
remains healthy. Actual background suspension/channel expiry is still a stop,
not permission to extend the motion watchdog. Continuous keyboard holds still
require OS repeat evidence within the bounded input lease.
Preparation now hides key controls and renders the existing canonical warning
StatusBadge as Подготовка управления; readiness alone admits the green state
and key controls. Initial focus-fix physical evidence confirms stopping but
not the requested session-preserving behavior. Revised physical test pending.
Final revised UI qualification: 934 unit tests, architecture checks, TypeScript
and production build pass. Canonical Core serves the exact new index/assets;
no Node/OS/firmware change. Browser verified amber Подготовка управления with
keys absent until ready. Owner observed the revised trial and confirmed:
focus loss stops motors, returning allows a fresh press without another arm
or preparation. One active session persisted throughout all six short driving
segments. Explicit UI Stop after completion ended the session; final fresh
telemetry confirms stopped/release_confirmed=true, both ERPM/current/fault zero.
The private result includes UTC/monotonic traces, owner notes and SHA-256.
This accepts focus loss/resume on the observed host. Tank, explicit Stop while
moving, command-channel loss, RC takeover and loaded/field behavior remain
separate outstanding physical checks. No exact key-to-stop timing is claimed.
Startup-entry follow-up, 2026-09-25: owner reports silently disabled Manage
and intermittent first-arm failure on a fresh Core page. The outer button
previously depended on fresh/supported status without explaining the wait.
A reproduced hook race allowed a poll begun during POST /arm to return the
old controlling=false state after the arm acknowledgement and revoke the new
session. The error path had the same missing generation guard; repeated arm
calls before acknowledgement also escaped the session-only check. Three new
regressions failed before the fix; actual current-generation authority loss
already passed and must continue to end control.
The acknowledgement now retires pre-acknowledgement reads; poll success and
failure require the current generation. An in-flight arm guard prevents
duplicate submission. Status reads may wait 1000 ms; command/arm 350 ms
deadlines and all board motion watchdogs remain unchanged. Manage always
opens settings, while actual arm waits for fresh supported idle authority.
The dialog remains open on failed arm. Canonical amber status distinguishes
synchronization, preparation, unavailable data and previous-session cleanup;
ready alone shows green and motion keys. Title is now exactly
«Центр наблюдения и управления». No board install/configuration change.
Validation: 938 unit tests, architecture check, TypeScript and production
build passed; final copy/color adjustment rechecked with 22 focused tests
and production build. Fresh browser entry acquired control on its first click.
13:20:28–13:20:38 UTC preparation was visible, then ready; no motion input
was sent. Explicit UI Stop completed at 13:21:09 UTC, release confirmed.
307 read-only samples, no read errors, all ERPM and fault codes zero.
A subsequent page reload and settings reopen also passed. These are bounded
UI/lifecycle checks, not new acceptance of driving or RC takeover. Private
entry-startup-result.json contains UTC/monotonic evidence and SHA-256 hashes.
+107
View File
@@ -0,0 +1,107 @@
# Rover control behaviour prototype
Status: **offline behavioural reference; no production actuation adapter**.
Requested by the owner while unavailable for physical testing, 2026-09-24.
Neither Core composition nor Node/VESC runtime imports these modules. No new
driver, service, firmware, motor settings or authority capability is installed.
`src/profile.ts` contains the versioned desired-profile parser, Tank and Arcade
mixers, continuous deadband, linear/squared response, relative output scaling,
and fan-out to any number of UUID-bound motors on both sides. UUIDs must be
unique; a missing side is rejected. `forwardSign` is the verified sign at the
future actuator adapter, not a copy of `m_invert_direction` and not a second
automatic inversion of the current VESC configuration.
The normalized result has no electrical units. It cannot be sent as amperes,
watts, duty, ERPM or speed without a separately qualified adapter and individual
motor, battery/BMS and braking limits. A profile output scale of 80% is **not**
the owner's proposed 20% safety margin against verified equipment ratings.
The current real RC path is PPM Duty Cycle; this prototype does not change it.
Arcade uses continuous diamond desaturation, with forward positive and right
yaw positive. For shaped inputs `v` and `r`, the pair is `(v+r, v-r)` multiplied
by `max(abs(v),abs(r))/(abs(v)+abs(r))`, or zero at the origin. This follows the
WPILib ArcadeDriveIK geometry with the steering sign adapted to the UI.
Reverse plus right still requests right yaw; it is not a car steering-wheel
convention, curvature drive, a turn-radius controller or omnidirectional motion.
No VESC Tool calibration algorithm is reproduced.
References inspected 2026-09-24:
- [WPILib drive classes](https://docs.wpilib.org/en/stable/docs/software/hardware-apis/motors/wpi-drive-classes.html)
- [ArcadeDriveIK source](https://github.com/wpilibsuite/allwpilib/blob/main/wpilibc/src/main/native/cpp/drive/DifferentialDrive.cpp)
## Authority model
`src/authority.ts` is a pure deterministic model. Its output contains **intent**
to stop all drives, revoke the Core epoch, flush queued motion and cancel
autonomous motion tasks; it does not actually stop a motor or cancel a process.
The future actuator must enforce the gate synchronously before asynchronous
task cancellation. RC reception, drive supervision and telemetry must survive.
Boot, input loss, controller loss, stale/invalid data, a timing gap or Core
command loss enter hold. First RC deflection while Core owns motion consumes
the gesture and revokes the old token. Every assigned input must then be neutral
and every motor must have trusted physical-stop evidence for the full configured
interval. A held first gesture or repeated packet does not qualify. The next
gesture starts RC manual operation. Neutral never resumes the previous Core
task. Reacquisition requires a new explicit, nonreplayed request in neutral.
Old Core commands cannot cross an epoch or process boot identity.
Policy values are mandatory constructor arguments. The demo uses 100 ms
freshness / 200 ms neutral solely as synthetic fixtures, **not accepted rover
reaction limits**. Use one monotonic clock domain from an authenticated local
producer and a fresh unpredictable boot identity per instance. A token is a
stale-command fence, not authentication or a replacement for access control.
Calls and input types belong to a trusted model harness; this is not a public
network request parser. Run a supervisory tick even when no new input arrives.
`link.live`, sample acquisition timestamps/sequences and `drive.stopped` must
come from qualified evidence. A fresh USB response with old decoded PPM does
not meet this contract. Zero motor current/duty alone is not physical stop.
The current FW 5.02 input API does not supply all required evidence. In
particular, stop-first independent of Mini needs an enforcement point outside
Mini and coordination of **all** motor controllers. No such qualified mechanism
is claimed by these tests. Receiver failsafe is also still awaiting acceptance.
Input axes are semantic controls, not invented receiver channels. The mixer for
Tank requires two Y axes; Arcade requires both axes of the selected stick. The
authority policy separately declares all `monitoredAxes`: the demo watches all
four axes, so the other stick also stops Core in Arcade. Missing monitored axes
are rejected, not synthesized as zero; all must return to neutral. The current two
separate receiver-to-VESC PWM outputs do not establish access to those Arcade
axes. The channel map, radio-link semantics and independent mixed RC path remain
hardware integration work. Profile revision or binding changes require a fresh
model initialized in hold, not an in-place live change.
## Preview and validation
Use the already installed Control Station toolchain; no new dependencies:
```sh
cd apps/control-station
node --test test/roverControl.test.mjs
./node_modules/.bin/tsc --project tools/rover-control-preview/tsconfig.json
node tools/rover-control-preview/build.mjs /absolute/artifact/directory
```
The self-contained HTML uses canonical Design Guideline components. Its CSP
disables all network connections, and no transport/serial code exists in the
bundle. It is an owner-review artifact, outside production navigation. Browser
storage and JSON export contain a **draft**, never confirmed applied state.
It does not add USB controls, device-specific phantom entities or a runtime
source-selection switch to the product. The synthetic takeover trace is
engineering review content, not a proposed operator control panel.
Intended product placement remains the existing shared «Настройки борта» section
on Core and Node, between computer details and devices. Alternatives (a new
workspace or separate per-VESC control-mode selectors) would fragment a single
vehicle-wide profile and are not used. Existing `Inspector`, `SettingsCard`,
`InspectorSelectField`, `RangeControl`, `Button`, `StatusBadge` cover the review;
no Design Guideline extensions or new visual primitives were needed.
Before production integration: verified input mapping and radio failsafe,
independent stop-first actuator mechanism, desired/applied profile storage on
Node with optimistic revision checking, all-member application receipts and
failure recovery, then shared UI and supervised unloaded tests. Do not expose
an enabled Apply button based only on successful model tests.
+134
View File
@@ -0,0 +1,134 @@
/** Behavioural reference ONLY. Not connected to the current VESC PPM path. */
import {axisKeys, bounded, mix, parseProfile, type Axes, type ControlProfile, type Sides} from './profile';
export interface Sample { value: number; at: number; sequence: number }
export interface DriveEvidence { at: number; healthy: boolean; stopped: boolean }
export interface CoreCommand {
token: string; sequence: number; at: number; expires: number; demand: Sides;
}
export interface Observation {
now: number;
// These are trusted receiver timestamps and link status, NOT USB read times.
link: { state: 'live' | 'lost' | 'unknown'; at: number };
axes: Partial<Record<keyof Axes, Sample>>;
drives: Record<string, DriveEvidence>;
command?: CoreCommand;
requestCore?: {owner: 'remote' | 'autonomy'; sequence: number; at: number};
}
export interface Policy {
maxAgeMs: number; neutralMs: number; maxCommandMs: number;
/** Verified RC controls that can take over, including non-driving stick axes. */
monitoredAxes: readonly (keyof Axes)[];
}
export type State = 'hold' | 'rc-ready' | 'rc-manual' | 'core';
export interface Decision {
state: State; reason: string; demand: Sides; token: string | null;
owner: 'remote' | 'autonomy' | 'rc' | null;
stopAll: boolean; flushMotionQueue: boolean; cancelMotionTasks: boolean;
}
const zero = (): Sides => ({left: 0, right: 0});
export class AuthorityModel {
private state: State = 'hold';
private epoch = 0;
private token: string | null = null;
private owner: Decision['owner'] = null;
private neutralSince: number | null = null;
private lastNow = -Infinity;
private lastCommand = -1;
private lastRequest = -1;
private samples: Partial<Record<keyof Axes, Sample>> = {};
private readonly profile: ControlProfile;
private readonly motors: string[];
private readonly policy: Policy;
constructor(profile: ControlProfile, motors: readonly string[], policy: Policy, private readonly bootId: string) {
this.profile = parseProfile(profile);
this.motors = [...motors]; this.policy = {...policy, monitoredAxes:[...policy.monitoredAxes]};
if (!bootId || !motors.length || new Set(motors).size !== motors.length || motors.some(id => !id)
|| !bounded(policy.maxAgeMs, 1, 10000) || !bounded(policy.neutralMs, 1, 10000)
|| !bounded(policy.maxCommandMs, 1, 10000)
|| new Set(policy.monitoredAxes).size !== policy.monitoredAxes.length
|| policy.monitoredAxes.some(key => !['leftY','rightY','leftX','rightX'].includes(key))
|| axisKeys(this.profile).some(key => !policy.monitoredAxes.includes(key))) throw Error('Invalid authority policy');
}
private result(reason: string, demand = zero(), revoke = false): Decision {
return {state: this.state, reason, demand, token: this.token, owner: this.owner,
stopAll: this.state === 'hold', flushMotionQueue: revoke, cancelMotionTasks: revoke};
}
private hold(reason: string): Decision {
const revoke = this.state === 'core';
if (this.state !== 'hold') this.epoch++;
this.state = 'hold'; this.token = null; this.owner = null;
this.neutralSince = null; this.lastCommand = -1;
return this.result(reason, zero(), revoke);
}
step(input: Observation): Decision {
const now = input.now;
if (!Number.isFinite(now) || now < 0 || now < this.lastNow) return this.hold('clock-invalid');
const gap = now - this.lastNow; this.lastNow = now;
const fresh = (at: number) => Number.isFinite(at) && at <= now && now - at <= this.policy.maxAgeMs;
const request = input.requestCore;
const newRequest = !!request && Number.isSafeInteger(request.sequence) && request.sequence > this.lastRequest;
// Consume even a premature request: it must never become effective later.
if (newRequest) this.lastRequest = request.sequence;
// A gap cannot count towards continuous observed neutral.
if (gap > this.policy.maxAgeMs) {
const first = gap === Infinity;
if (!first) return this.hold('observation-gap');
this.neutralSince = null;
}
if (input.link?.state !== 'live' || !fresh(input.link.at)) return this.hold('receiver-unverified');
const axes = {leftY: 0, rightY: 0, leftX: 0, rightX: 0};
let neutral = true;
let observedThrough = input.link.at;
const candidates: Partial<Record<keyof Axes, Sample>> = {};
for (const key of this.policy.monitoredAxes) {
const sample = input.axes[key], prev = this.samples[key];
if (!sample || !bounded(sample.value, -1, 1) || !fresh(sample.at)
|| !Number.isSafeInteger(sample.sequence) || sample.sequence < 0
|| (prev && (sample.sequence < prev.sequence || sample.at < prev.at
|| (sample.sequence === prev.sequence && (sample.value !== prev.value || sample.at !== prev.at)))))
return this.hold('axis-invalid');
candidates[key] = {...sample}; axes[key] = sample.value;
observedThrough = Math.min(observedThrough, sample.at);
neutral &&= Math.abs(sample.value) <= this.profile.deadband;
}
this.samples = candidates;
let stopped = true;
for (const id of this.motors) {
const drive = input.drives[id];
if (!drive || drive.healthy !== true || !fresh(drive.at)) return this.hold('drive-unverified');
observedThrough = Math.min(observedThrough, drive.at);
stopped &&= drive.stopped === true;
}
// RC intent is evaluated before any Core command or reacquisition request.
if (this.state === 'core' && !neutral) return this.hold('rc-takeover');
if (neutral && stopped) this.neutralSince ??= now;
else this.neutralSince = null;
const stableNeutral = this.neutralSince !== null && observedThrough - this.neutralSince >= this.policy.neutralMs;
if (this.state === 'hold') {
if (!stableNeutral) return this.result(stopped ? 'await-neutral' : 'await-stop');
this.state = 'rc-ready'; this.owner = 'rc';
// A request queued before reaching neutral cannot acquire Core authority.
return this.result('rc-ready');
}
if (newRequest && request && fresh(request.at) && this.state !== 'core' && stableNeutral) {
if (!['remote', 'autonomy'].includes(request.owner)) return this.hold('source-invalid');
this.epoch++; this.token = `${this.bootId}:${this.epoch}:${this.profile.revision}`;
this.owner = request.owner; this.state = 'core'; this.lastCommand = -1;
return this.result('core-granted');
}
if (this.state === 'core') {
const c = input.command;
if (!c || c.token !== this.token || !Number.isSafeInteger(c.sequence) || c.sequence <= this.lastCommand
|| !fresh(c.at) || !Number.isFinite(c.expires) || c.expires <= now
|| c.expires - c.at > this.policy.maxCommandMs || c.expires < c.at
|| !bounded(c.demand.left, -1, 1) || !bounded(c.demand.right, -1, 1)) return this.hold('core-command-invalid');
this.lastCommand = c.sequence;
return this.result('core-command', {left: c.demand.left * this.profile.outputScale, right: c.demand.right * this.profile.outputScale});
}
if (this.state === 'rc-ready' && !neutral) this.state = 'rc-manual';
return this.result(neutral ? 'rc-neutral' : 'rc-command', mix(this.profile, axes));
}
}
+72
View File
@@ -0,0 +1,72 @@
/** Executable profile proposal. Pure calculations; no hardware or transport. */
export interface ControlProfile {
schema: 'missioncore.rover-control/v1';
revision: number;
mode: 'tank' | 'arcade';
stick: 'left' | 'right';
deadband: number;
response: 'linear' | 'squared';
outputScale: number;
}
export interface Axes { leftY: number; rightY: number; leftX: number; rightX: number }
export interface Sides { left: number; right: number }
export interface MotorBinding { uuid: string; side: 'left' | 'right'; forwardSign: 1 | -1 }
export const defaultProfile: Readonly<ControlProfile> = Object.freeze({
schema: 'missioncore.rover-control/v1', revision: 0, mode: 'tank', stick: 'right',
deadband: 0.15, response: 'linear', outputScale: 1,
});
export function bounded(value: unknown, min: number, max: number): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
}
export function parseProfile(value: unknown): ControlProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('Invalid profile');
const p = value as ControlProfile;
if (Object.keys(p).sort().join() !== Object.keys(defaultProfile).sort().join()
|| p.schema !== defaultProfile.schema || !Number.isSafeInteger(p.revision) || p.revision < 0
|| !['tank', 'arcade'].includes(p.mode) || !['left', 'right'].includes(p.stick)
|| !['linear', 'squared'].includes(p.response) || !bounded(p.deadband, 0, 0.5)
|| !bounded(p.outputScale, 0, 1)) throw Error('Invalid profile');
return {...p};
}
export function axisKeys(profile: ControlProfile): (keyof Axes)[] {
return profile.mode === 'tank' ? ['leftY', 'rightY']
: profile.stick === 'right' ? ['rightY', 'rightX'] : ['leftY', 'leftX'];
}
export function shapeAxis(value: number, profile: ControlProfile): number {
if (!bounded(value, -1, 1)) throw Error('Invalid axis');
const magnitude = Math.max(0, (Math.abs(value) - profile.deadband) / (1 - profile.deadband));
return Math.sign(value) * (profile.response === 'squared' ? magnitude * magnitude : magnitude);
}
/** +Y = forward, +X = turn right, including while reversing (yaw convention).
* Arcade diamond desaturation follows the documented WPILib ArcadeDriveIK
* convention, with clockwise steering sign adapted here. Output is normalized
* demand, NOT amps, watts, ERPM, physical velocity, or a guaranteed turn radius.
*/
export function mix(profile: ControlProfile, axes: Axes): Sides {
parseProfile(profile);
for (const key of axisKeys(profile)) if (!bounded(axes[key], -1, 1)) throw Error('Missing or invalid axis');
let left: number, right: number;
if (profile.mode === 'tank') {
left = shapeAxis(axes.leftY, profile); right = shapeAxis(axes.rightY, profile);
} else {
const throttle = shapeAxis(profile.stick === 'right' ? axes.rightY : axes.leftY, profile);
const turn = shapeAxis(profile.stick === 'right' ? axes.rightX : axes.leftX, profile);
const peak = Math.max(Math.abs(throttle), Math.abs(turn));
const scale = peak === 0 ? 0 : peak / (Math.abs(throttle) + Math.abs(turn));
left = (throttle + turn) * scale; right = (throttle - turn) * scale;
}
return {left: left * profile.outputScale || 0, right: right * profile.outputScale || 0};
}
/** All members of both sides are required, irrespective of 1x1/2x2/6x6. */
export function motorDemands(sides: Sides, bindings: readonly MotorBinding[]): Record<string, number> {
if (!bounded(sides.left, -1, 1) || !bounded(sides.right, -1, 1)
|| !bindings.some(b => b.side === 'left') || !bindings.some(b => b.side === 'right')) throw Error('Incomplete drive');
const values: Record<string, number> = Object.create(null);
for (const b of bindings) {
if (!/^[0-9a-f]{24}$/.test(b.uuid) || b.uuid in values || !['left', 'right'].includes(b.side)
|| ![1, -1].includes(b.forwardSign)) throw Error('Invalid motor binding');
values[b.uuid] = sides[b.side] * b.forwardSign || 0;
}
return values;
}
@@ -7,6 +7,7 @@ import platform
import re
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
@@ -24,11 +25,44 @@ PROFILES = {
}
def sync_directory(path):
directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
def private(path):
path.mkdir(mode=0o700, exist_ok=True)
info = path.lstat()
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise RuntimeError("Release directory is not private and owned")
sync_directory(path.parent)
def stage_file(path, data, mode):
"""Publish complete, durable installer files before opening the sudo UI."""
if path.is_symlink():
raise ValueError("Unexpected release symlink")
if path.exists():
with path.open("rb") as stream:
if stream.read() != data:
raise ValueError("Existing release was modified")
os.fsync(stream.fileno())
else:
descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
# Do not overwrite another launcher or a modified staged file.
os.link(temporary, path)
finally:
os.unlink(temporary)
sync_directory(path.parent)
def main():
@@ -74,16 +108,7 @@ def main():
raise ValueError("Release payload changed")
files["release.json"] = raw
for name, data in files.items():
path = folder / name
if path.is_symlink():
raise ValueError("Unexpected release symlink")
if path.exists():
if path.read_bytes() != data:
raise ValueError("Existing release was modified")
else:
with path.open("xb") as stream:
stream.write(data)
path.chmod(0o700 if name == "install" else 0o600)
stage_file(folder / name, data, 0o700 if name == "install" else 0o600)
print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True)
if sys.argv[1] == "--plan":
result = subprocess.run(
@@ -0,0 +1,57 @@
"""Installer staging survives interrupted writes without admitting corrupt files."""
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"owner_release_entry", Path(__file__).with_name("owner_release_entry.py")
)
entry = importlib.util.module_from_spec(spec)
spec.loader.exec_module(entry)
class ReleaseStagingTests(unittest.TestCase):
def test_sync_failure_never_publishes_partial_payload(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "package.deb"
with patch.object(entry.os, "fsync", side_effect=OSError("disk failure")):
with self.assertRaises(OSError):
entry.stage_file(target, b"complete package", 0o600)
self.assertEqual(list(Path(directory).iterdir()), [])
entry.stage_file(target, b"complete package", 0o600)
self.assertEqual(target.read_bytes(), b"complete package")
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
def test_valid_staging_can_be_repeated_without_replacing_inode(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "install"
entry.stage_file(target, b"installer", 0o700)
inode = target.stat().st_ino
entry.stage_file(target, b"installer", 0o700)
self.assertEqual(target.stat().st_ino, inode)
self.assertEqual(target.stat().st_mode & 0o777, 0o700)
def test_truncated_existing_file_is_preserved_and_rejected(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "package.deb"
target.write_bytes(b"partial")
with self.assertRaisesRegex(ValueError, "modified"):
entry.stage_file(target, b"complete package", 0o600)
self.assertEqual(target.read_bytes(), b"partial")
def test_symlink_never_changes_its_target(self):
with tempfile.TemporaryDirectory() as directory:
real = Path(directory) / "real"
real.write_bytes(b"keep")
target = Path(directory) / "package.deb"
target.symlink_to(real)
with self.assertRaisesRegex(ValueError, "symlink"):
entry.stage_file(target, b"replacement", 0o600)
self.assertEqual(real.read_bytes(), b"keep")
if __name__ == "__main__":
unittest.main()
+14 -2
View File
@@ -35,8 +35,16 @@ public:
bool queryRunning = false;
bool allowHardware = false;
QByteArray lastMotor, lastApplication, lastHall;
QTimer outputWatchdog;
Engine() {
outputWatchdog.setSingleShot(true);
QObject::connect(&outputWatchdog, &QTimer::timeout, [&] {
if (allowHardware && vesc.isPortConnected() && !procedureRunning) {
vesc.commands()->setCurrent(0);
try { flush(); } catch (...) {}
}
});
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
packet = vesc.findChild<Packet *>();
require(packet, "Upstream packet transport missing");
@@ -249,15 +257,19 @@ public:
if (method == "query") return {{"payload", QString::fromLatin1(query(
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}};
if (method == "lease") { vesc.commands()->disableAppOutput(250, false); flush(); return {}; }
if (method == "lease") {
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
vesc.commands()->disableAppOutput(250, false); flush(); outputWatchdog.start(200); return {};
}
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
if (method == "current") {
require(outputWatchdog.isActive(), "Output lease expired");
auto current = number(request, "current_a", 0, 30);
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
vesc.commands()->setCurrent(current); flush(); return {};
}
if (method == "rpm") { vesc.commands()->setRpm(int(number(request, "erpm", 0, 3000))); flush(); return {}; }
if (method == "rpm") { require(outputWatchdog.isActive(), "Output lease expired"); vesc.commands()->setRpm(int(number(request, "erpm", -3000, 3000))); flush(); return {}; }
if (method == "limits") {
auto p = request.value("parameters").toObject();
MCCONF_TEMP conf;
+9
View File
@@ -73,6 +73,15 @@ int main(int argc, char **argv) {
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
{"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}});
QJsonArray rpmExamples;
QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) {
if (!data.isEmpty() && quint8(data.at(0)) == COMM_SET_RPM)
rpmExamples.append(QString::fromLatin1(data.toBase64()));
});
commands->setRpm(3000);
commands->setRpm(-3000);
require(rpmExamples.size() == 2 && !vesc.isPortConnected(), "Offline signed RPM serialization failed");
result.insert("offline_signed_rpm_examples_base64", rpmExamples);
result.insert("archive_sha256", sha256(raw));
result.insert("ok", true);
} catch (const std::exception &error) {
+7 -7
View File
@@ -4,13 +4,13 @@
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
"os": "ubuntu-24.04-amd64",
"file": "mission-core-vesc-native-runtime.tar.gz",
"bytes": 50352726,
"sha256": "cc78c273e025f0738ed79f94d4bc6a70e504f2e7204b1ef694119e6a230b913a",
"engine_sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
"bytes": 50354000,
"sha256": "4473819387682089a814a6c558065e3e6a4c2fe67266c0ed82ad49cea1444aad",
"engine_sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
"files": {
"bin/mission-core-vesc-engine": {
"sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
"bytes": 24801544
"sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
"bytes": 24801840
},
"lib/libGL.so.1": {
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
@@ -257,8 +257,8 @@
"bytes": 4724
},
"licenses/engine_main.cpp": {
"sha256": "e789004d14185140bda21c10a9d7d8f0b48174a9b5053ec1d70755842b1888de",
"bytes": 20913
"sha256": "e4b32dc5c9def71208d8daad830d5c47852f39f85a321713abc45dc15173b4fc",
"bytes": 21539
},
"licenses/gir1.2-glib-2.0.copyright": {
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
+5
View File
@@ -113,6 +113,11 @@ def run(args):
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
raise RuntimeError("Expected native 5.02 detect correction was not applied")
rpm_packets = [base64.b64decode(p) for p in native["offline_signed_rpm_examples_base64"]]
if (len(rpm_packets) != 2 or any(len(p) != 5 or p[0] != 8 for p in rpm_packets)
or [int.from_bytes(p[1:], "big", signed=True) for p in rpm_packets] != [3000, -3000]):
raise RuntimeError("Upstream signed RPM serialization mismatch")
report["checks"].append({"id": "signed-rpm-serialization-%d" % index, "ok": True})
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
"archive_sha256": hashlib.sha256(raw).hexdigest(),
"motor_parameter_count": len(native["motor"]["parameters"]),
+1 -1
View File
@@ -32,7 +32,7 @@ def prepare():
lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller",
"version": "0.6.3", "run_id": uuid.uuid4().hex, "started_at": time.time(),
"version": "0.7.4", "run_id": uuid.uuid4().hex, "started_at": time.time(),
"monotonic_started": time.monotonic(), "state": "running", "steps": []}
def publish():
+1 -1
View File
@@ -1,5 +1,5 @@
"""Onboard VESC discovery, archive and bounded motor identification service."""
VERSION = "0.6.3"
VERSION = "0.7.4"
MODEL = "vesc.controller"
SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2"
+10 -3
View File
@@ -19,7 +19,11 @@ class DriveProfile:
raise ValueError("Профиль привода изменился. Обновите карточку.")
bindings = dict(current["bindings"])
layout = current["layout"]
if action == "vesc.drive.assign":
if action == "vesc.drive.layout":
layout = params["layout"]
if set(bindings) - set(LAYOUTS[layout]):
raise ValueError("Сначала снимите назначения задних моторов.")
elif action == "vesc.drive.assign":
layout, slot = params["layout"], params["slot"]
if set(bindings) - set(LAYOUTS[layout]):
raise ValueError("Сначала снимите назначения задних моторов.")
@@ -37,10 +41,13 @@ class DriveProfile:
def validate(action, params):
keys = {"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"}
keys = {"revision", "layout"} if action == "vesc.drive.layout" else ({"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"})
if set(params) != keys or type(params["revision"]) is not int or params["revision"] < 0:
raise ValueError("Invalid drive profile revision")
if action == "vesc.drive.assign":
if action == "vesc.drive.layout":
if params["layout"] not in LAYOUTS:
raise ValueError("Invalid drive layout")
elif action == "vesc.drive.assign":
if params["layout"] not in LAYOUTS or params["slot"] not in ("", *LAYOUTS[params["layout"]]):
raise ValueError("Invalid drive layout or slot")
elif params["slot"] not in LAYOUTS["2x2"]:
+4
View File
@@ -11,6 +11,8 @@ import re
import uuid
from .protocol import firmware, values
from .configuration import decode
from .receiver import neutral_band
def fresh_values(owner, devices):
@@ -63,6 +65,8 @@ def reconcile(owner, devices):
actual = device.link.query(code)
if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True):
raise ValueError("Post-calibration configuration changed")
if kind == "application":
owner.receiver_bands[device.id] = neutral_band(decode(actual, kind))
owner.neutral(devices)
after = fresh_values(owner, devices)
if any(abs(v["motor_current_a"]) > 1 or abs(v["erpm"]) > 30 or abs(v["duty"]) > .01 or v["fault_code"] != 0 for v in after.values()):
+3 -3
View File
@@ -38,7 +38,7 @@ def batch(pool, devices, function):
return result
def run(owner, command, devices, moving, originals, backups, unchanged):
def run(owner, command, devices, moving, originals, backups, unchanged, preflight):
from .motor_test import Rejected, check_values
params = command["parameters"]
duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a"))
@@ -64,7 +64,7 @@ def run(owner, command, devices, moving, originals, backups, unchanged):
value = values(device.link.query(4, timeout=.06)) if device.id in ids else None
return level, value
observed = batch(pool, devices, read)
if any(abs(level) > .02 for level, _ in observed.values()):
if any(owner.receiver_active(identifier, level) for identifier, (level, _) in observed.items()):
owner.state("rc")
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
now = owner.monotonic()
@@ -129,7 +129,7 @@ def run(owner, command, devices, moving, originals, backups, unchanged):
elif not owner.latched: owner.state("ready")
owner.active, owner.mode = False, None
return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed",
"profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm,
"preflight": preflight, "profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm,
"duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure,
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
"limits_restored": restored, "after": after, "backups": backups}
+3 -2
View File
@@ -19,7 +19,7 @@ def parse_result(raw):
"observed_states": observed, "valid_six_states": raw[9] == 0 and len(observed) == 6}
def measure(owner, devices, target, original, backups, unchanged):
def measure(owner, devices, target, original, backups, unchanged, preflight):
motor = decode(original, "motor")
if motor["m_sensor_port_mode"] != 0:
from .motor_test import Rejected
@@ -46,7 +46,7 @@ def measure(owner, devices, target, original, backups, unchanged):
unchanged()
for device in devices:
incoming = ppm(device.link.query(31, timeout=0.06))
if abs(incoming["level"]) > 0.02:
if owner.receiver_active(device.id, incoming["level"]):
owner.state("rc")
if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle")
sample = values(target.link.query(4, timeout=0.06))
@@ -92,6 +92,7 @@ def measure(owner, devices, target, original, backups, unchanged):
owner.state("rc")
owner.active, owner.mode = False, None
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall",
"preflight": preflight,
"current_a": 5, "elapsed_s": owner.monotonic() - started,
"completed": completed, "measurement": result, "issues": issues,
"configuration_restored": restored, "configuration_written": False,
+18
View File
@@ -0,0 +1,18 @@
"""Read controller settings using the bundled upstream VESC Tool decoder."""
import math
FIELDS = frozenset({"l_current_max", "l_current_min", "l_current_max_scale", "l_current_min_scale",
"l_in_current_max", "l_in_current_min", "l_min_erpm", "l_max_erpm", "l_max_duty",
"l_watt_max", "l_watt_min", "si_motor_poles", "si_gear_ratio", "si_wheel_diameter"})
def read_limits(link):
configuration = link.configuration()
result = {}
for parameter in configuration["motor"]["parameters"]:
name, value = parameter["name"], parameter.get("value")
if name in FIELDS and type(value) in (int, float) and math.isfinite(value):
result[name] = value
if set(result) != FIELDS:
raise ValueError("Native configuration is missing limit fields")
return result
+32 -12
View File
@@ -1,6 +1,6 @@
"""Bounded idle transport measurement through the installed native owners.
Only PPM/telemetry reads, no leases, motor commands or configuration writes.
Only application/PPM/telemetry reads, no leases, motor commands or configuration writes.
The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget;
it never relaxes the motor-control deadline or authorizes powered operation.
"""
@@ -11,6 +11,8 @@ import math
import time
from .protocol import ppm, values
from .configuration import decode
from .receiver import active, neutral_band
def summary(samples):
@@ -30,8 +32,21 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot
if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]:
raise ValueError("Controller sessions changed")
samples = {d.id: [] for d in devices}
bands = {}
failure = None
stop_reason = "complete"
def interrupted():
cancelled = service.motor.cancelled_at
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
return "stopped"
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
return "deadline"
return None
def failed(device, code, before, error):
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
"elapsed_ms": (monotonic()-before)*1000,
"native_rpc": getattr(error, "native_rpc", None),
"native_history": getattr(error, "native_history", [])}
def read(device):
for code in (31, 4):
before = monotonic()
@@ -42,7 +57,7 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot
"native_rpc": getattr(device.link, "last_rpc", None)}
if code == 31:
sample["ppm_level"] = reading["level"]
idle = abs(reading["level"]) <= .02
idle = not active(reading["level"], bands[device.id])
else:
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
duty=reading["duty"], fault_code=reading["fault_code"])
@@ -50,24 +65,28 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot
samples[device.id].append(sample)
if not idle: return {"device_id": device.id, "reason": "not_idle"}
except (OSError, ValueError, TimeoutError) as error:
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
"elapsed_ms": (monotonic()-before)*1000,
"native_rpc": getattr(error, "native_rpc", None),
"native_history": getattr(error, "native_history", [])}
return failed(device, code, before, error)
return None
with ExitStack() as locks:
for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock)
if any(d.link is None for d in devices): raise ValueError("Controller unavailable")
for device in devices:
if reason := interrupted():
stop_reason = reason; break
before = monotonic()
try:
bands[device.id] = neutral_band(decode(device.link.query(17), "application"))
except (OSError, ValueError, TimeoutError) as error:
failure = [failed(device, 17, before, error)]
stop_reason = "read_failed"; break
# Same cadence and per-controller query order as group rotation, with a
# larger read-only deadline to expose latency instead of destroying it.
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
for _ in range(100):
if stop_reason != "complete": break
cycle = monotonic()
cancelled = service.motor.cancelled_at
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
stop_reason = "stopped"; break
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
stop_reason = "deadline"; break
if reason := interrupted():
stop_reason = reason; break
futures = [pool.submit(read, d) for d in devices]
errors = [error for future in futures if (error := future.result()) is not None]
if errors:
@@ -76,4 +95,5 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot
return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started,
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
"failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(),
"summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}
"neutral_band": bands.get(d.id), "summary": summary(samples[d.id]),
"samples": samples[d.id]} for d in devices}}
+59 -12
View File
@@ -19,6 +19,7 @@ from .configuration import decode
from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS
from .speed_hold import SpeedHold
from .temporary_limits import TemporaryLimits
from .receiver import active as receiver_active, neutral_band
class Rejected(ValueError):
@@ -44,7 +45,7 @@ def check_configuration(identity, motor, app):
raise Rejected("Нужна проверка токовых ограничений.")
def check_values(value, moving=False, current_a=2, motor=None):
def check_values(value, moving=False, current_a=2, motor=None, *, standstill_confirmed=False):
current_limit = max(5, current_a * 1.2 + 2) if moving else 1
speed_limit = TEST_LIMITS["max_erpm"] if moving else 30
duty_limit = TEST_LIMITS["max_duty"] if moving else 0.01
@@ -52,6 +53,20 @@ def check_values(value, moving=False, current_a=2, motor=None):
current_limit = min(current_limit, motor["l_current_max"])
speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"])
duty_limit = min(duty_limit, motor["l_max_duty"])
# FW 5.02 continues its observer/PLL while undriven. Sensorless ERPM and
# tachometer share that estimate, so neither proves physical standstill.
# Only explicitly attended Hall/speed/release operations may substitute
# observation. Current, modulation bounds and sensored ERPM still apply.
observed_sensorless = (standstill_confirmed is True and not moving
and motor is not None and motor["motor_type"] == 2
and motor["foc_sensor_mode"] == 0)
if observed_sensorless:
# FW 5.02 mcpwm_foc.c calculates duty_now from measured phase voltages
# even in the undriven branch. COMM_GET_VALUES quantizes it to 1/1000;
# one idle quantum is not proof that PWM is enabled. Permit at most
# that quantum only with fresh operator-confirmed physical standstill.
# Current/voltage/temperature/fault and neutral-window guards remain.
duty_limit = 0.001
bounds = (
("fault_code", 0, 0, "код ошибки VESC", "", 1),
("input_voltage_v", 20, 60, "напряжение питания", "В", 1),
@@ -62,8 +77,13 @@ def check_values(value, moving=False, current_a=2, motor=None):
)
for field, low, high, label, unit, scale in bounds:
actual = value[field]
if not math.isfinite(actual) or not low <= actual <= high:
estimated_speed = field == "erpm" and observed_sensorless
if not math.isfinite(actual) or (not estimated_speed and not low <= actual <= high):
raise LimitExceeded(field, actual, low, high, label, unit, scale)
if standstill_confirmed:
actual = value["input_current_a"]
if not math.isfinite(actual) or abs(actual) > 1:
raise LimitExceeded("input_current_a", actual, -1, 1, "ток батареи", "А")
class MotorTest:
@@ -75,6 +95,7 @@ class MotorTest:
self.cancelled_at = None
self.active = False
self.mode = None
self.receiver_bands = {}
self.limits = TemporaryLimits(service, atomic)
self.authority = service.root / "motor-authority.json"
# A process restart during a test cannot silently grant another pulse.
@@ -96,11 +117,16 @@ class MotorTest:
def neutral(self, devices):
for device in devices:
value = ppm(device.link.query(31, timeout=0.06))
if abs(value["level"]) > 0.02:
if self.receiver_active(device.id, value["level"]):
self.state("rc")
raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.")
def run(self, command, devices, target, release=False):
def receiver_active(self, identifier, level):
if identifier not in self.receiver_bands:
raise Rejected("Нейтраль приёмника ещё не проверена по конфигурации VESC.")
return receiver_active(level, self.receiver_bands[identifier])
def run(self, command, devices, target, release=False, remote=None):
if not 1 <= len(devices) <= 128 or len({d.id for d in devices}) != len(devices):
raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.")
duration = command["parameters"]["duration_s"]
@@ -115,13 +141,14 @@ class MotorTest:
hall_mode = command["action_id"] == "vesc.hall.measure"
foc_mode = command["action_id"] == "vesc.foc.calibrate"
budget = duration + (20 if speed_mode else 0)
if self.latched and not release:
if self.latched and not release and remote is None:
raise Rejected("Управление удерживается за пультом. Верните его явно после нейтрали.")
requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - requested).total_seconds() > 10:
raise Rejected("Команда устарела до начала проверки. Повторите запрос.")
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
def preflight():
if remote is not None: remote.ensure_live()
if self.stop.is_set(): raise Rejected("Проверка отменена.")
if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5:
raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.")
@@ -134,6 +161,8 @@ class MotorTest:
raise Rejected("Проверка отменена до начала исполнения.")
self.stop.clear()
with ExitStack() as stack:
# Never reuse an earlier operation's neutral configuration.
self.receiver_bands = {}
for device in sorted(devices, key=lambda d: d.id):
stack.enter_context(device.lock)
recovered = None
@@ -144,6 +173,8 @@ class MotorTest:
except (OSError, ValueError, TimeoutError, KeyError) as error:
raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error
identities, applications, backups, originals = {}, {}, [], {}
motors, idle_samples = {}, []
observed_standstill = (hall_mode or speed_mode or release) and command["parameters"].get("standstill_confirmed") is True
target_motor, target_raw = None, None
for device in devices:
preflight()
@@ -159,7 +190,9 @@ class MotorTest:
configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))}
originals[device.id] = configs
motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application")
motors[device.id] = motor
check_configuration(identity, motor, app)
self.receiver_bands[device.id] = neutral_band(app)
if device in moving:
if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]):
raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.")
@@ -169,11 +202,13 @@ class MotorTest:
target_raw = configs["motor"]
if foc_mode and any(abs(motor[key]) <= 0.001 for key in ("l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm")):
raise Rejected("Для калибровки нужны ненулевые сохранённые пределы питания и настройки запуска FOC.")
if speed_mode and command["parameters"]["erpm"] > min(SPEED_LIMITS["max_erpm"], motor["l_max_erpm"] * 0.8):
if speed_mode and abs(command["parameters"]["erpm"]) > min(SPEED_LIMITS["max_erpm"],
(motor["l_max_erpm"] if command["parameters"]["erpm"] > 0 else -motor["l_min_erpm"]) * 0.8):
raise Rejected("Заданная скорость превышает настроенный диапазон контроллера.")
if speed_mode and command["parameters"]["erpm"] < motor["s_pid_min_erpm"]:
if speed_mode and abs(command["parameters"]["erpm"]) < motor["s_pid_min_erpm"]:
raise Rejected(f"Минимальная скорость регулятора этого VESC: {motor['s_pid_min_erpm']:g} ERPM.")
check_values(values(device.link.query(4)))
check_values(values(device.link.query(4)), motor=motor,
standstill_confirmed=observed_standstill)
identities[device.id], applications[device.id] = identity, app
identifier = "op_" + uuid.uuid4().hex
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
@@ -205,13 +240,22 @@ class MotorTest:
preflight()
unchanged()
self.neutral(devices)
if observed_standstill:
for device in devices:
sample = values(device.link.query(4, timeout=0.06))
check_values(sample, motor=motors[device.id],
standstill_confirmed=observed_standstill)
idle_samples.append({"device_id": device.id, "at": self.monotonic(),
"sensor_mode": motors[device.id]["foc_sensor_mode"], "values": sample})
self.sleep(0.1)
preflight_record = {"standstill_confirmed": observed_standstill, "samples": idle_samples}
if release:
self.state("ready")
return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered}
return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered, "preflight": preflight_record}
if hall_mode:
from .hall_detection import measure
return measure(self, devices, target, target_raw, backups, unchanged)
return measure(self, devices, target, target_raw, backups, unchanged,
preflight_record)
if foc_mode:
from .foc_calibration import calibrate
return calibrate(self, command, devices, target, originals, backups, unchanged)
@@ -219,8 +263,11 @@ class MotorTest:
if (deadline - now).total_seconds() < budget + 1:
raise Rejected("Команда устарела до запуска. Повторите проверку.")
if group_mode:
if remote is not None:
self.state("testing")
return remote.drive(self, command, devices, moving, originals, unchanged)
from .group_test import run
return run(self, command, devices, moving, originals, backups, unchanged)
return run(self, command, devices, moving, originals, backups, unchanged, preflight_record)
self.state("testing")
self.active = True
self.mode = "speed" if speed_mode else "current"
@@ -337,7 +384,7 @@ class MotorTest:
"mode": "speed" if speed_mode else "current", "rotation_s": hold.rotation_s if hold else None,
"erpm_target": hold.erpm if hold else None, "limits_restored": restored,
"current_ramp_a_per_s": TEST_LIMITS["current_ramp_a_per_s"],
"test_limits": TEST_LIMITS,
"test_limits": TEST_LIMITS, "preflight": preflight_record,
"duration_limit_s": duration, "outcome": outcome, "samples": samples,
"limit_violation": limit_violation, "failure": failure,
"release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups}
+3 -1
View File
@@ -71,7 +71,9 @@ class NativeLink:
started = time.monotonic()
deadline = started + timeout
trace = {"method": method, "command": parameters.get("command"),
"timeout_ms": parameters.get("timeout_ms", timeout*1000)}
"timeout_ms": parameters.get("timeout_ms", timeout*1000),
"attachment": {"usb": self.attachment.usb, "address": self.attachment.address,
"tty": self.attachment.tty}}
try:
trace["stage"] = "attachment_before"
self.check()
+3 -2
View File
@@ -27,7 +27,8 @@ TEST_LIMITS = {"min_current_a": 0.5, "max_current_a": 30, "min_duration_s": 0.5,
# A separate action/capability keeps old clients from silently changing modes.
SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600,
"startup_timeout_s": 15, "settle_s": 1, "speed_tolerance": 0.15,
"lost_speed_timeout_s": 2, "duration_basis": "measured_speed"}
"lost_speed_timeout_s": 2, "duration_basis": "measured_speed",
"reverse_supported": True, "standstill_confirmation_required": True}
def frame(data):
@@ -36,7 +37,7 @@ def frame(data):
def speed_packet(erpm):
if type(erpm) not in (int, float) or not 0 <= erpm <= SPEED_LIMITS["max_erpm"]:
if type(erpm) not in (int, float) or not -SPEED_LIMITS["max_erpm"] <= erpm <= SPEED_LIMITS["max_erpm"]:
raise ValueError("Invalid test speed")
return frame(bytes([8]) + struct.pack(">i", round(erpm)))
+22
View File
@@ -0,0 +1,22 @@
"""Interpret the admitted FW 5.02 PPM input before its firmware deadband.
app_ppm.c publishes input_val before utils_deadband. A nonzero decoded value
inside app_ppm_conf.hyst is therefore not a motor command. Do not infer radio
link presence from this value: the receiver can keep emitting failsafe pulses.
"""
import math
def neutral_band(application):
band = application["app_ppm_conf.hyst"]
if (application["app_to_use"] not in (1, 4)
or application["app_ppm_conf.ctrl_type"] != 4
or not math.isfinite(band) or not .01 <= band <= .3):
raise ValueError("Unsupported PPM neutral configuration")
return band
def active(level, band):
if not math.isfinite(level) or not math.isfinite(band) or not .01 <= band <= .3:
raise ValueError("Invalid PPM level or neutral band")
return abs(level) > band
+343
View File
@@ -0,0 +1,343 @@
"""Single Node-owned, volatile keyboard control session and live read projection.
Configuration/calibration keeps the same exclusive hardware owner. Browser input
is a short lease, not a queue. Native Tool still owns all serial commands.
"""
import copy
import math
import re
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from .group_test import batch
from .protocol import ppm, values
from .drive_profile import LAYOUTS, slot_label
class ControlEnded(ValueError):
"""A terminal input lease is normal stop; cleanup still verifies release."""
class InputLease:
def __init__(self, clock=time.monotonic):
self.clock = clock
self.id = None
self.sequence = -1
self.until = 0
self.demand = (0., 0.)
self.retired = set()
def accept(self, command):
if not isinstance(command, dict) or set(command) != {"id", "sequence", "ttl_ms", "left", "right", "settings"}:
raise ValueError("Invalid control envelope")
if (not re.fullmatch(r"[0-9a-f]{32}", str(command["id"]))
or type(command["sequence"]) is not int or not 0 <= command["sequence"] < 2**53
or any(type(command[k]) not in (int,float) or not math.isfinite(command[k]) for k in ("left","right","ttl_ms"))
or max(abs(command["left"]), abs(command["right"])) > 1
or not 0 < command["ttl_ms"] <= 400):
raise ValueError("Invalid control bounds")
s = command["settings"]
if (not isinstance(s, dict) or set(s) != {"standstill_confirmed","current_a","max_erpm"}
or s["standstill_confirmed"] is not True
or type(s["current_a"]) not in (int,float) or not .5 <= s["current_a"] <= 30
or type(s["max_erpm"]) not in (int,float) or not 300 <= s["max_erpm"] <= 3000):
raise ValueError("Invalid control settings")
if command["id"] in self.retired:
return False
if self.id is not None and (self.id != command["id"] or self.clock() >= self.until):
self.stop()
return False
if self.id == command["id"] and command["sequence"] <= self.sequence:
return False # Repeated frames cannot extend a lease.
self.id, self.sequence = command["id"], command["sequence"]
self.until = self.clock()+command["ttl_ms"]/1000
self.demand = (command["left"], command["right"])
return True
def stop(self):
if self.id:
self.retired.add(self.id)
# Bound tombstones; Core never reuses a cryptographic session id.
if len(self.retired)>1024:
raise ValueError("Restart control service after excessive sessions")
self.until = 0
self.demand = (0.,0.)
def live(self):
return self.id is not None and self.id not in self.retired and self.clock() < self.until
class RemoteControl:
def __init__(self, service):
self.service = service
self.lock = threading.RLock()
self.lease = InputLease()
self.instance = uuid.uuid4().hex
self.relay = None
self.primed = False
self.thread = None
self.watch_until = 0
self.state = "observing"
self.message = None
self.readings = {}
self.release_confirmed = None
def feed(self, body):
if (set(body) != {"watch","command","relay_id"} or type(body["watch"]) is not bool
or not re.fullmatch(r"[0-9a-f]{32}", str(body["relay_id"]))):
raise ValueError("Invalid relay")
with self.lock:
if self.relay != body["relay_id"]:
self.lease.stop()
self.relay = body["relay_id"]
self.primed = False
if body["watch"]:
self.watch_until = time.monotonic()+2
command = body["command"]
if command is None:
self.primed = True
self.lease.stop()
elif not self.primed:
self.lease.retired.add(command.get("id"))
else:
running = self.thread is not None and self.thread.is_alive()
if not running and command.get("id") != self.lease.id:
self.lease.id = None
self.lease.sequence = -1
accepted = self.lease.accept(command)
if accepted and not running:
self.state, self.message = "preparing", None
self.thread = threading.Thread(target=self._prepare, args=(copy.deepcopy(command),), daemon=True,
name="vesc-remote-control")
self.thread.start()
return self.snapshot()
def snapshot(self):
with self.lock:
readings = [{**copy.deepcopy(v), "age_ms": int((time.monotonic()-v["sampled_at"])*1000)} for v in self.readings.values()]
for v in readings: v.pop("sampled_at", None)
return {"supported": True, "instance": self.instance, "state": self.state,
"session_id": self.lease.id, "message": self.message, "devices": readings,
"release_confirmed": self.release_confirmed,
"profile": copy.deepcopy(self.service.drive.value)}
def observe(self):
with self.lock:
# Let an in-flight read finish, but do not start another one while
# the control worker is waiting to become the exclusive owner.
if self.thread is not None and self.thread.is_alive():
return
if time.monotonic() >= self.watch_until or not self.service.operation_lock.acquire(False):
return
try:
with self.service.lock: devices = [d for d in self.service.devices.values() if d.link and d.readable]
# Reading is explicit window interest, never discovery-port reset.
for d in devices:
if not d.lock.acquire(False): continue
try:
self._publish(d, values(d.link.query(4, timeout=.06)), ppm(d.link.query(31, timeout=.06)))
except (OSError, ValueError, TimeoutError):
pass # Old values retain age; a read failure is never zero.
finally: d.lock.release()
finally: self.service.operation_lock.release()
def _publish(self, device, value, receiver):
profile = self.service.drive.value
slot = next((k for k,b in profile["bindings"].items() if b["device_id"]==device.id), None)
with self.lock:
self.readings[device.id] = {"id": device.id,"uuid":device.identity["uuid"],
"slot":slot,"label":slot_label(profile["layout"],slot) if slot else "VESC "+device.identity["uuid"][:6].upper(),
"values":value,"receiver":receiver,"sampled_at":time.monotonic()}
def _prepare(self, envelope):
owner = self.service.motor
acquired = False
try:
# Observation also owns this lock. A nonblocking attempt made
# arming depend on which thread happened to read first. Wait only
# briefly, with a live input lease, never enqueue a future drive.
deadline = time.monotonic() + .5
while not acquired:
self.ensure_live()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ValueError("Другая операция с VESC ещё выполняется.")
acquired = self.service.operation_lock.acquire(timeout=min(.02, remaining))
self.ensure_live()
with self.service.lock: devices = list(self.service.devices.values())
profile = self.service.drive.value
slots = LAYOUTS.get(profile["layout"], ())
if not slots or set(profile["bindings"]) != set(slots):
raise ValueError("Назначьте все моторы в настройках борта.")
if not devices or any(not d.link for d in devices):
raise ValueError("Один из VESC недоступен.")
now = datetime.now(timezone.utc)
settings = envelope["settings"]
command = {"action_id":"vesc.drive.run","operation_id":"op_"+uuid.uuid4().hex,
"requested_at":now.isoformat(),"deadline_at":(now+timedelta(seconds=300)).isoformat(),
"parameters":{"current_a":settings["current_a"],"duration_s":30,"erpm":settings["max_erpm"],
"standstill_confirmed":True,"profile_revision":profile["revision"],
"device_ids":[profile["bindings"][s]["device_id"] for s in slots]}}
owner.run(command, devices, devices[0], remote=self)
except ControlEnded:
with self.lock:
self.state = "fault" if self.message or self.release_confirmed is False else "stopped"
except (OSError, ValueError, TimeoutError, KeyError) as error:
with self.lock:
self.state = "fault"
self.message = str(error) if self.message is None else self.message + " " + str(error)
finally:
with self.lock: self.lease.stop()
if acquired: self.service.operation_lock.release()
def ensure_live(self):
with self.lock:
if not self.lease.live(): raise ControlEnded("Управление остановлено: команда больше не подтверждается.")
def drive(self, owner, command, devices, moving, originals, unchanged):
from .motor_test import check_values
limit = command["parameters"]["current_a"]
maximum = command["parameters"]["erpm"]
profile = copy.deepcopy(self.service.drive.value)
sides = {b["device_id"]:0 if slot.startswith("left.") else 1 for slot,b in profile["bindings"].items()}
configs, minimum_speeds, claimed, neutral_since = {}, {}, False, None
previous, speeds = time.monotonic(), {d.id:0. for d in moving}
last_sign, undriven_since = {}, None
self.release_confirmed = None
owner.active, owner.mode = True, "remote"
receiver = False
zero_seen = False
stalls = {}
with ThreadPoolExecutor(max_workers=min(16,len(devices)),thread_name_prefix="vesc-remote") as pool:
try:
for d in moving:
self.ensure_live()
configs[d.id] = owner.limits.apply(d, originals[d.id]["motor"], limit)
minimum = configs[d.id]["s_pid_min_erpm"]
if not math.isfinite(minimum) or minimum < 0:
raise ValueError("Некорректный минимальный порог регулятора VESC.")
# Native Tool serializes setRpm as an integer. Round up so
# the first command actually reaches the firmware threshold.
minimum_speeds[d.id] = max(1, math.ceil(minimum))
if maximum < minimum_speeds[d.id]:
raise ValueError(f"Минимальная скорость регулятора этого VESC: {minimum_speeds[d.id]} ERPM.")
self.state = "ready"
while True:
cycle = time.monotonic()
if owner.stop.is_set(): break
if not receiver: self.ensure_live()
unchanged()
if self.service.drive.value != profile: raise ValueError("Назначения моторов изменились.")
def read(d): return values(d.link.query(4,timeout=.06)),ppm(d.link.query(31,timeout=.06))
readouts = batch(pool, devices, read)
for d in devices: self._publish(d,*readouts[d.id])
active = any(owner.receiver_active(d.id,readouts[d.id][1]["level"]) for d in devices)
if active:
receiver = True
self.state = "receiver"
owner.state("rc")
with self.lock: self.lease.stop()
if receiver:
# Hold zero locally through the first gesture. Neutral
# releases PPM, so only a subsequent gesture drives it.
stopped = all(abs(v[0]["motor_current_a"])<=1 and abs(v[0]["duty"])<.01 for v in readouts.values())
neutral_since = (neutral_since or cycle) if not active and stopped else None
if neutral_since is not None and cycle-neutral_since>=.5: break
demand=(0.,0.)
else:
with self.lock: demand=self.lease.demand
if demand==(0.,0.): zero_seen=True
if not zero_seen: demand=(0.,0.)
now=time.monotonic()
dt=min(.15,now-previous);previous=now
targets = {}
for d in moving:
value=readouts[d.id][0]
check_values(value,moving=True,current_a=limit,motor=configs[d.id])
target=demand[sides[d.id]]*maximum
minimum = minimum_speeds[d.id]
# A small analogue request must never be rounded UP to
# a faster requested speed. Release below the operable
# range; firmware would otherwise enter zero-duty mode.
if abs(target) < minimum: target=0
targets[d.id] = target
signs = {key: 1 if target>0 else -1 if target<0 else 0 for key,target in targets.items()}
quiet = all(abs(readouts[d.id][0]["erpm"])<300
and abs(readouts[d.id][0]["motor_current_a"])<=1
and abs(readouts[d.id][0]["duty"])<.01 for d in moving)
# Count only observed neutral while ALL previous outputs
# were released. A long operator pause already satisfies it.
if quiet and not any(speeds.values()):
if undriven_since is None: undriven_since = now
else: undriven_since = None
reversing = any(sign and last_sign.get(key,sign)!=sign for key,sign in signs.items())
if reversing and (undriven_since is None or now-undriven_since<.5):
# One shared barrier: after a turn, the side keeping its
# direction must not drive while the other waits to reverse.
targets = dict.fromkeys(targets, 0.)
else:
last_sign.update({key:sign for key,sign in signs.items() if sign})
for d in moving:
value=readouts[d.id][0]
target=targets[d.id]
minimum=minimum_speeds[d.id]
# Zero is immediate release, never an RPM hold/brake.
if target==0:
speeds[d.id]=0
else:
step=600*dt
speeds[d.id]+=max(-step,min(step,target-speeds[d.id]))
# FW 5.02 disables its speed PID below s_pid_min_erpm.
# Ramping from zero spent 900/600 = 1.5 s sending
# ineffective commands. Enter the configured range
# immediately, then keep the existing ramp above it.
if abs(speeds[d.id]) < minimum:
speeds[d.id] = math.copysign(minimum, target)
if abs(value["motor_current_a"])>5:
at,tacho=stalls.setdefault(d.id,(now,value["tachometer"]))
if abs(value["erpm"])>=60 and abs(value["tachometer"]-tacho)>=3: stalls[d.id]=(now,value["tachometer"])
elif now-at>=2: raise ValueError("Мотор не движется при токе выше 5 А.")
else: stalls.pop(d.id,None)
if now-cycle>.12: raise ValueError("Связь с VESC слишком медленная.")
if not receiver: self.ensure_live()
claimed=True
batch(pool,devices,lambda d:d.link.test_command("claim"))
if time.monotonic()-cycle>.16: raise ValueError("Связь с VESC слишком медленная.")
if not receiver: self.ensure_live()
def send(d):
if not receiver: self.ensure_live()
if owner.stop.is_set() or receiver or abs(speeds.get(d.id,0))<1: d.link.test_command("release")
else: d.link.test_speed(speeds[d.id])
batch(pool,devices,send)
if not receiver: self.state="driving" if any(speeds.values()) else "ready"
owner.sleep(max(0,.1-(time.monotonic()-cycle)))
finally:
self.state="stopping"
def release(d):
try: d.link.test_command("release")
except (OSError,ValueError,TimeoutError): pass
if claimed: batch(pool,devices,release)
if claimed:
owner.sleep(.3)
def released(d):
try:
value = values(d.link.query(4, timeout=.1))
return all(math.isfinite(value[k]) and abs(value[k]) <= 1
for k in ("motor_current_a", "input_current_a")) and abs(value["duty"]) < .01
except (OSError, ValueError, TimeoutError): return False
self.release_confirmed = all(batch(pool, devices, released).values())
if not self.release_confirmed:
self.message = "Снятие тока не подтверждено. Проверьте фактическое состояние моторов."
owner.state("rc")
for d in moving:
try: owner.limits.restore(d)
except (OSError,ValueError,TimeoutError):
notice="Восстановление пределов ожидает нейтрали."
self.message=(self.message+" " if self.message else "")+notice
owner.state("rc")
owner.active,owner.mode=False,None
self.state="fault" if self.release_confirmed is False else "receiver" if receiver else "stopped"
if not receiver and not owner.latched: owner.state("ready")
+11 -2
View File
@@ -13,6 +13,7 @@ from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit, parse_qs
from .service import Service
from .remote_control import RemoteControl
class Handler(BaseHTTPRequestHandler):
@@ -48,14 +49,15 @@ class Handler(BaseHTTPRequestHandler):
elif self.command == "GET" and self.path.startswith("/archive-export?"):
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
result = self.server.service.archive.export("local", after)
elif self.command == "POST" and self.path == "/operation":
elif self.command == "POST" and self.path in ("/operation", "/remote"):
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= 65536 or self.headers.get("Content-Type") != "application/json":
raise ValueError("Invalid command")
raw = self.rfile.read(size)
if len(raw) != size:
raise ValueError("Truncated command")
result = self.server.service.execute(json.loads(raw))
result = (self.server.service.execute(json.loads(raw)) if self.path == "/operation"
else self.server.service.remote.feed(json.loads(raw)))
else:
raise ValueError("Unknown route")
except (ValueError, KeyError, TypeError, OSError):
@@ -102,6 +104,7 @@ def main():
raise RuntimeError("VESC must run as its own unprivileged user")
os.umask(0o007)
service = Service("/var/lib/mission-core-vesc")
service.remote = RemoteControl(service)
stop = threading.Event()
def scan():
@@ -113,6 +116,11 @@ def main():
pass
stop.wait(2)
def observe():
while not stop.is_set():
service.remote.observe()
stop.wait(.2)
path = Path("/run/mission-core-vesc/driver.sock")
path.unlink(missing_ok=True)
with Server(str(path), Handler) as server:
@@ -120,6 +128,7 @@ def main():
server.service = service
thread = threading.Thread(target=scan, daemon=True)
thread.start()
threading.Thread(target=observe, daemon=True, name="vesc-observer").start()
try:
server.serve_forever()
finally:
+26 -9
View File
@@ -23,7 +23,7 @@ try:
except ImportError: # Source checkout; packaging copies this exact shared file.
from k1link.device_plugins.vesc.archive import Archive
ACTIONS = frozenset({"verify", "details", "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"})
ACTIONS = frozenset({"verify", "details", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"})
def utc():
@@ -164,7 +164,7 @@ class Service:
"preparation_safe": True, "online": True, "usb": attachment.speed,
"connection_label": "USB " + attachment.usb + next((" · " + slot_label(self.drive.value["layout"], slot) for slot, binding in self.drive.value["bindings"].items() if binding["device_id"] == identifier), ""), "layers": [],
"vesc_status": {"identity": identity if unique else None, "readable": unique and Device.readable_identity(identity),
"engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched},
"engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "board_settings_supported": True, "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False, "standstill_confirmation_required": True}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched},
"snapshot": {"context": {"session_id": d["session"],
"device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc",
"plugin_version": VERSION, "model_id": MODEL},
@@ -186,13 +186,15 @@ class Service:
if action not in ACTIONS or not isinstance(params, dict):
raise ValueError("Unsupported operation")
if action in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.control.release"):
keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set())
keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"standstill_confirmed"} if action != "vesc.motor.pulse" else set()) | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set())
if (set(params) != keys or params["rig_clear"] is not True
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128
or type(params["current_a"]) not in (int, float) or not TEST_LIMITS["min_current_a"] <= params["current_a"] <= TEST_LIMITS["max_current_a"]
or type(params["duration_s"]) not in (int, float) or not TEST_LIMITS["min_duration_s"] <= params["duration_s"] <= TEST_LIMITS["max_duration_s"]):
raise ValueError("Explicit raised-rig confirmation, duration and controller sessions required")
if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= params["erpm"] <= SPEED_LIMITS["max_erpm"]):
if action != "vesc.motor.pulse" and params["standstill_confirmed"] is not True:
raise ValueError("Explicit observation of all motors at standstill required")
if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= abs(params["erpm"]) <= SPEED_LIMITS["max_erpm"]):
raise ValueError("Speed is outside the supported range")
if action == "vesc.drive.run" and (type(params["profile_revision"]) is not int or params["profile_revision"] < 0
or not isinstance(params["device_ids"], list) or not 2 <= len(params["device_ids"]) <= 128
@@ -200,17 +202,19 @@ class Service:
or len(set(params["device_ids"])) != len(params["device_ids"])):
raise ValueError("Explicit complete drive profile required")
elif action in ("vesc.hall.measure", "vesc.foc.calibrate"):
extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else set()
extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else {"standstill_confirmed"}
if (set(params) != ({"sessions", "rig_clear", "native_cycle_confirmed"} | extra)
or params["rig_clear"] is not True or params["native_cycle_confirmed"] is not True
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128):
raise ValueError("Native procedure and rig confirmation required")
if extra and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150):
if action == "vesc.hall.measure" and params["standstill_confirmed"] is not True:
raise ValueError("Explicit observation of all motors at standstill required")
if action == "vesc.foc.calibrate" and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150):
raise ValueError("Heating budget must be between 10 and 150 W")
elif action == "vesc.link.check":
if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128:
raise ValueError("Controller sessions required")
elif action in ("vesc.drive.assign", "vesc.drive.unassign"):
elif action in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
validate_drive(action, params)
elif params != {}:
raise ValueError("This operation has no parameters")
@@ -265,7 +269,7 @@ class Service:
record["receipt"] = {"state": "complete", "result": result}
atomic(path, record)
return record["receipt"]
if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign"):
if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
if device.identity is None: raise Rejected("Сначала подтвердите личность VESC.")
try:
result = self.drive.update(command["action_id"], command["parameters"], device)
@@ -304,11 +308,16 @@ class Service:
raise ValueError("Controller identity changed")
result = {"identity": actual, "device_id": device.id, "observed_at": utc()}
action = command["action_id"]
if action in {"vesc.telemetry.read", "vesc.config.backup"} and not device.readable:
if action in {"vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup"} and not device.readable:
raise ValueError("Firmware read layout is unsupported")
if action == "vesc.telemetry.read":
result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic())
device.telemetry = result
elif action == "vesc.limits.read":
from .limits_view import read_limits
result.update(parameters=read_limits(device.link))
if firmware(device.link.query(0)) != actual:
raise ValueError("Controller changed during configuration read")
elif action == "vesc.input.read":
if actual["version"] != "5.02": raise ValueError("Input layout unsupported")
result["input"] = ppm(device.link.query(31))
@@ -340,6 +349,14 @@ class Service:
record["receipt"] = {"state": "complete", "result": result}
except (OSError, ValueError, TimeoutError) as error:
record["receipt"] = {"state": "error", "error": str(error) if isinstance(error, Rejected) else "Операция не выполнена. Проверьте связь и совместимость контроллера."}
if getattr(error, "native_rpc", None) is not None:
# Keep transport evidence in the receipt, not product copy.
# This covers ordinary reads and preflight before a motor
# procedure has its own result/failure envelope.
record["receipt"]["result"] = {"failure": {
"type": type(error).__name__, "native_rpc": error.native_rpc,
"native_history": getattr(error, "native_history", []),
}}
atomic(path, record)
return record["receipt"]
+2 -2
View File
@@ -21,7 +21,7 @@ class SpeedHold:
if self.tachometer is not None and value["tachometer"] != self.tachometer:
self.motion_at = now
self.tachometer = value["tachometer"]
good = (abs(value["erpm"] - self.erpm) <= self.erpm * SPEED_LIMITS["speed_tolerance"]
good = (abs(value["erpm"] - self.erpm) <= abs(self.erpm) * SPEED_LIMITS["speed_tolerance"]
and self.motion_at is not None and now - self.motion_at <= 0.25)
error = None
if self.hold_started is None:
@@ -48,5 +48,5 @@ class SpeedHold:
if now - self.started > SPEED_LIMITS["startup_timeout_s"] + self.duration + 5:
error = "Истёк общий срок проверки; заданное время вращения не набрано."
done = self.rotation_s >= self.duration
setpoint = min(self.erpm, max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"]))
setpoint = (1 if self.erpm > 0 else -1) * min(abs(self.erpm), max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"]))
return setpoint, done, error
+3 -1
View File
@@ -73,7 +73,9 @@ class TemporaryLimits:
raise ValueError("Limits changed outside this operation; restoration pending")
if actual_raw != raw:
state = values(device.link.query(4))
if abs(state["motor_current_a"]) > 1 or abs(ppm(device.link.query(31))["level"]) > 0.02:
from .receiver import active, neutral_band
application = decode(device.link.query(17), "application")
if abs(state["motor_current_a"]) > 1 or active(ppm(device.link.query(31))["level"], neutral_band(application)):
raise ValueError("Wait for zero current and neutral before restoring limits")
device.link.set_temporary_limits(old)
if device.link.query(14) != raw: raise ValueError("Original configuration readback failed")
+13
View File
@@ -6,6 +6,19 @@ import test_reader
class DriveTests(test_reader.ServiceTests):
def test_change_layout_keeps_assignments_without_motor_commands(self):
left, right = list(self.service.devices.values())
self.assign(left, 'left.1')
self.assign(right, 'right.1')
command = self.command(action='vesc.drive.layout')
command['parameters'] = {'layout': '2x2', 'revision': 2}
result = self.service.execute(command)
self.assertEqual(result['state'], 'complete')
self.assertEqual(result['result']['layout'], '2x2')
self.assertEqual(result['result']['bindings']['left.1']['device_id'], left.id)
self.assertEqual(len(result['result']['bindings']), 2)
self.assertTrue(all(link.commands == [0] for link in test_reader.FakeLink.instances))
def assign(self, device, slot, layout='1x1', revision=None):
item=next(i for i in self.service.inventory('node_synthetic')['items'] if i['id']==device.id)
command=self.command(item,action='vesc.drive.assign')
+3 -3
View File
@@ -149,7 +149,7 @@ class CalibrationTests(unittest.TestCase):
self.assertFalse(receipt['result']['release_confirmed'])
self.assertTrue(receipt['result']['configuration_verified'])
self.case.devices[0].link.query = query
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
result = self.case.service.execute(release)['result']
self.assertEqual(result['authority'], 'ready')
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
@@ -165,7 +165,7 @@ class CalibrationTests(unittest.TestCase):
key = self.case.devices[0].id
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
before = self.configs[key]
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
receipt = self.case.service.execute(release)
self.assertEqual(receipt['state'], 'error')
self.assertEqual(self.configs[key], before)
@@ -175,6 +175,6 @@ class CalibrationTests(unittest.TestCase):
def test_unknown_completion_cannot_be_cleared_by_neutral_return(self):
self.case.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
self.case.service.execute(self.command())
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
self.assertEqual(self.case.service.execute(release)['state'], 'error')
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
+1 -1
View File
@@ -56,7 +56,7 @@ class GroupTests(unittest.TestCase):
def command(self):
command = self.case.command_for_pulse()
command['action_id'] = 'vesc.drive.run'
command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4,
command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4, standstill_confirmed=True,
device_ids=[d.id for d in self.case.devices])
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
return command
+108
View File
@@ -0,0 +1,108 @@
"""Sensorless idle drift must not become a general motion-check bypass."""
import math
import struct
import unittest
import test_speed_and_hall
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.motor_test import check_values, LimitExceeded
from runtime.protocol import values
class HallStandstillTests(unittest.TestCase):
def setUp(self):
self.rig = test_speed_and_hall.RunTests('test_native_hall_returns_measured_table_without_applying_it')
self.rig.setUp()
self.addCleanup(self.rig.doCleanups)
self.case = self.rig.case
def command(self):
command = self.case.command(action='vesc.hall.measure')
command['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True,
'standstill_confirmed': True, 'sessions': {d.id:d.session for d in self.case.devices}}
return command
def drift(self, device, mode=0, late_duty=False):
config = decode(self.rig.configs[device.id], 'motor')
self.rig.configs[device.id] = configuration('motor', {**config, 'foc_sensor_mode':mode})
query = device.link.query
reads = 0
def read(code, timeout=2):
nonlocal reads
raw = query(code, timeout)
if code == 4:
reads += 1
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
if late_duty and reads >= 6:
raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
return raw
device.link.query = read
def test_sensorless_peer_can_drift_but_raw_evidence_is_retained(self):
self.drift(self.case.devices[1])
result = self.case.service.execute(self.command())['result']
self.assertTrue(result['completed'])
self.assertTrue(result['configuration_restored'])
self.assertTrue(result['preflight']['standstill_confirmed'])
samples = result['preflight']['samples']
self.assertEqual(len(samples), 20)
peer = [s for s in samples if s['device_id'] == self.case.devices[1].id]
self.assertTrue(all(s['values']['erpm'] == -160 for s in peer))
self.assertGreaterEqual(peer[-1]['at'] - peer[0]['at'], 0.89)
def test_sensored_speed_still_blocks_before_any_write(self):
self.drift(self.case.devices[1], mode=2)
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertIsNone(self.rig.hall_started)
self.assertEqual(self.case.sent, [])
def test_modulation_above_one_wire_quantum_blocks_before_any_write(self):
self.drift(self.case.devices[1], late_duty=True)
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertIsNone(self.rig.hall_started)
self.assertEqual(self.case.sent, [])
def test_missing_false_and_nonboolean_observation_rejected(self):
for confirmation in (None, False, 1, 'true'):
command = self.command()
if confirmation is None: del command['parameters']['standstill_confirmed']
else: command['parameters']['standstill_confirmed'] = confirmation
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.case.sent, [])
def test_sensorless_drift_does_not_bypass_ordinary_motor_test(self):
self.drift(self.case.devices[1])
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
self.assertEqual(self.case.sent, [])
def test_rc_still_blocks_hall_before_any_write(self):
self.drift(self.case.devices[1])
self.case.changed = True
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertTrue(self.case.service.motor.latched)
self.assertEqual(self.case.sent, [])
def test_electrical_and_finite_guards_cannot_be_replaced_by_observation(self):
device = self.case.devices[0]
sample = values(device.link.query(4))
motor = decode(self.rig.configs[device.id], 'motor')
for field, value in (('erpm', math.nan), ('motor_current_a', 1.01),
('input_current_a', -1.01), ('duty', .002),
('fault_code', 1), ('mos_temperature_c', 66),
('input_voltage_v', 61)):
with self.subTest(field=field), self.assertRaises(LimitExceeded):
check_values({**sample, 'erpm':-160, field:value}, motor=motor,
standstill_confirmed=True)
def test_one_quantized_idle_modulation_step_requires_observed_standstill(self):
device = self.case.devices[0]
sample = values(device.link.query(4))
motor = {**decode(self.rig.configs[device.id], 'motor'), 'foc_sensor_mode':0}
for duty in (-.001, 0, .001):
idle = {**sample, 'erpm':-160, 'duty':duty}
check_values(idle, motor=motor, standstill_confirmed=True)
with self.assertRaises(LimitExceeded):
check_values(idle, motor=motor)
with self.assertRaises(LimitExceeded):
check_values({**sample, 'duty':-.002}, motor=motor, standstill_confirmed=True)
+34
View File
@@ -0,0 +1,34 @@
"""Native configuration reads never acquire motor control or change settings."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_reader
from runtime.limits_view import FIELDS, read_limits
class LimitsTests(unittest.TestCase):
def test_native_export_only_and_missing_or_nonfinite_values_rejected(self):
class Link:
def configuration(self):
return {"motor": {"parameters": [{"name": key, "value": 1.0} for key in FIELDS]}}
self.assertEqual(set(read_limits(Link())), FIELDS)
for bad in (float('nan'), True, '300'):
with patch.object(Link, 'configuration', return_value={"motor":{"parameters":[{"name":key,"value":bad} for key in FIELDS]}}):
with self.assertRaises(ValueError): read_limits(Link())
def test_service_read_is_bound_to_uuid_and_cannot_write(self):
fixture = test_reader.ServiceTests()
fixture.setUp()
try:
service = fixture.service
with patch.object(test_reader.FakeLink, 'configuration', create=True, return_value={"motor":{"parameters":[{"name":key,"value":1.0} for key in FIELDS]}}) as native:
response = service.execute(fixture.command(action='vesc.limits.read'))
self.assertEqual(response['state'], 'complete')
self.assertEqual(set(response['result']['parameters']), FIELDS)
native.assert_called_once()
self.assertTrue(all(set(link.commands) == {0} for link in test_reader.FakeLink.instances))
self.assertEqual(service.drive.value['revision'], 0)
finally:
fixture.tearDown()
+25 -5
View File
@@ -1,6 +1,7 @@
"""No-power read measurements and failure attribution; no USB hardware."""
from unittest.mock import patch
import unittest
import struct
import test_motor_test
from runtime.link_check import measure, summary
@@ -30,9 +31,9 @@ class LinkCheckTests(unittest.TestCase):
result=self.run_check()
self.assertEqual(result['outcome'],'complete')
self.assertFalse(result['motor_commands_sent'])
self.assertEqual(len(self.reads),400)
self.assertEqual({r[1] for r in self.reads},{31,4})
self.assertTrue(all(r[2]==.5 for r in self.reads))
self.assertEqual(len(self.reads),402)
self.assertEqual({r[1] for r in self.reads},{17,31,4})
self.assertTrue(all(r[2]==.5 for r in self.reads if r[1]!=17))
self.assertEqual(self.case.sent,[])
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
self.assertGreaterEqual(result['duration_s'],9.9)
@@ -45,7 +46,9 @@ class LinkCheckTests(unittest.TestCase):
def test_no_response_keeps_peer_command_and_native_cause(self):
device=self.case.devices[1]
original=device.link.query
def query(code,timeout=2):
if code==17:return original(code,timeout)
error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True}
raise error
device.link.query=query
@@ -61,7 +64,24 @@ class LinkCheckTests(unittest.TestCase):
result=self.run_check()
self.assertEqual(result['outcome'],'not_idle')
self.assertEqual(self.case.sent,[])
self.assertEqual({r[1] for r in self.reads},{31})
self.assertEqual({r[1] for r in self.reads},{17,31})
def test_receiver_offset_inside_configured_deadband_is_idle(self):
for device in self.case.devices:
original=device.link.query
device.link.query=lambda code,timeout=2,original=original: bytes([31])+struct.pack('>ii',-66000,1466000) if code==31 else original(code,timeout)
result=self.run_check()
self.assertEqual(result['outcome'],'complete')
self.assertTrue(all(abs(d['neutral_band']-.15)<1e-6 for d in result['devices'].values()))
self.assertEqual(self.case.sent,[])
def test_config_failure_is_reported_before_ppm_or_motor_commands(self):
self.case.devices[0].link.query=lambda *args,**kwargs: b'bad config'
result=self.run_check()
self.assertEqual(result['outcome'],'read_failed')
self.assertEqual(result['failure'][0]['command'],17)
self.assertEqual(self.case.sent,[])
self.assertFalse(any(r[1] in (31,4) for r in self.reads))
def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self):
command=self.command()
@@ -71,7 +91,7 @@ class LinkCheckTests(unittest.TestCase):
result=self.case.service.execute(command)
self.assertEqual(result['state'],'complete')
self.assertEqual(self.case.service.execute(command),result)
self.assertEqual(len(self.reads),400)
self.assertEqual(len(self.reads),402)
self.case.service.operation_lock.acquire()
try:
with self.assertRaises(ValueError):self.case.service.execute(self.command())
+3
View File
@@ -8,11 +8,13 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runtime.native_link import NativeLink
from runtime.serial import Attachment
class NativeBoundaryTests(unittest.TestCase):
def link(self, handler):
value = object.__new__(NativeLink)
value.attachment = Attachment('2-1', '11', 'ttyACM0', '12')
value.buffer = b""; value.sequence = 0; value.hall_pending = False
value.check = lambda:None
script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n'
@@ -73,6 +75,7 @@ class NativeBoundaryTests(unittest.TestCase):
with self.assertRaises(OSError) as caught: link.query(31,timeout=.06)
trace=caught.exception.native_rpc
self.assertEqual(trace['command'],31)
self.assertEqual(trace['attachment'], {'usb':'2-1','address':'11','tty':'ttyACM0'})
self.assertEqual(trace['timeout_ms'],60)
self.assertTrue(trace['process_alive'])
self.assertTrue(trace['attachment_present'])
+75
View File
@@ -0,0 +1,75 @@
"""Firmware neutral-band regression and retained native failure evidence."""
import json
import struct
import unittest
import test_motor_test
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.receiver import active, neutral_band
class ReceiverTests(unittest.TestCase):
def setUp(self):
self.case=test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
self.case.setUp(); self.addCleanup(self.case.tearDown)
def offset(self, device, level, band=None):
original=device.link.query
def query(code,timeout=2):
if code==31:return bytes([31])+struct.pack('>ii',int(level*1e6),1466000)
raw=original(code,timeout)
if code==17 and band is not None:
return configuration('application',{**decode(raw,'application'),'app_ppm_conf.hyst':band})
return raw
device.link.query=query
def test_inside_deadband_allows_test_without_changing_ppm_configuration(self):
c=self.case
for device in c.devices:self.offset(device,-.066)
before=[d.link.query(17) for d in c.devices]
result=c.service.execute(c.command_for_pulse())
self.assertEqual(result['state'],'complete',result)
self.assertEqual(result['result']['outcome'],'duration')
self.assertFalse(c.service.motor.latched)
self.assertEqual(before,[d.link.query(17) for d in c.devices])
def test_each_peer_uses_its_own_fresh_band_and_active_input_blocks_motion(self):
c=self.case
for device in c.devices:self.offset(device,-.066)
self.assertEqual(c.service.execute(c.command_for_pulse())['state'],'complete')
c.sent.clear()
self.offset(c.devices[1],-.066,.05)
result=c.service.execute(c.command_for_pulse())
self.assertEqual(result['state'],'error')
self.assertTrue(c.service.motor.latched)
self.assertEqual(c.sent,[])
def test_unknown_band_and_invalid_input_do_not_assume_neutral(self):
with self.assertRaises(ValueError):self.case.service.motor.receiver_active('missing',0)
for level,band in [(float('nan'),.15),(0,float('nan')),(0,0),(0,.8)]:
with self.assertRaises(ValueError):active(level,band)
self.assertFalse(active(.15,.15))
self.assertTrue(active(-.150001,.15))
def test_native_read_and_preflight_failures_keep_transport_diagnostics(self):
c=self.case
for action,code in [('vesc.input.read',31),('vesc.motor.pulse',17)]:
with self.subTest(action=action):
device=c.devices[0]; original=device.link.query
trace={'command':code,'stage':'native_response','transport':{'request_emitted':True,'packets_received':0}}
def query(actual,timeout=2):
if actual==code:
error=OSError('native timeout');error.native_rpc=trace;error.native_history=[trace]
raise error
return original(actual,timeout)
device.link.query=query
command=c.command_for_pulse() if action=='vesc.motor.pulse' else c.command(action=action)
result=c.service.execute(command)
self.assertEqual(result['state'],'error')
self.assertEqual(result['result']['failure']['native_rpc'],trace)
saved=json.loads((c.service.root/(command['operation_id']+'.json')).read_text())
self.assertEqual(saved['receipt'],result)
self.assertEqual(c.service.execute(command),result)
self.assertEqual(c.sent,[])
device.link.query=original
+307
View File
@@ -0,0 +1,307 @@
import unittest
from runtime.remote_control import InputLease
def envelope(identifier='a'*32,seq=1,**kw):
return dict(id=identifier,sequence=seq,ttl_ms=300,left=1,right=-1,
settings=dict(standstill_confirmed=True,current_a=30,max_erpm=2000),**kw)
class LeaseTests(unittest.TestCase):
def setUp(self):
self.now=0
self.lease=InputLease(lambda:self.now)
def test_no_duplicate_renewal_no_late_resume(self):
self.assertTrue(self.lease.accept(envelope()))
self.now=.2;self.assertFalse(self.lease.accept(envelope()))
self.now=.301;self.assertFalse(self.lease.live())
self.assertFalse(self.lease.accept(envelope(seq=2)))
self.assertFalse(self.lease.live())
def test_stop_is_terminal_even_with_newer_frames(self):
self.lease.accept(envelope());self.lease.stop()
self.assertFalse(self.lease.accept(envelope(seq=20)))
self.assertEqual(self.lease.demand,(0,0))
def test_identity_change_stops_instead_of_stealing(self):
self.lease.accept(envelope())
self.assertFalse(self.lease.accept(envelope('b'*32)))
self.assertFalse(self.lease.live())
def test_invalid_limits_and_nan(self):
for key,value in [('ttl_ms',401),('ttl_ms',0),('left',float('nan')),('right',2),('sequence',True)]:
item=envelope();item[key]=value
with self.subTest(key=key,value=value),self.assertRaises(ValueError):self.lease.accept(item)
from types import SimpleNamespace
from unittest.mock import patch
import threading
from runtime.remote_control import ControlEnded, RemoteControl
class PreparationHandoffTests(unittest.TestCase):
"""The observer and control worker share one hardware owner, not a queue."""
def setUp(self):
self.now = [0.]
self.attempted = threading.Event()
self.operation = threading.Lock()
self.operation.acquire()
case = self
class ObservedLock:
def acquire(self, *args, **kwargs):
case.attempted.set()
return case.operation.acquire(*args, **kwargs)
def release(self):
case.operation.release()
self.calls = []
def run(*args, **kwargs):
self.calls.append('prepare')
raise ControlEnded('synthetic completion')
profile = {'layout': '1x1', 'revision': 1, 'bindings': {
'left.1': {'device_id': 'left'}, 'right.1': {'device_id': 'right'}}}
service = SimpleNamespace(operation_lock=ObservedLock(), lock=threading.Lock(),
motor=SimpleNamespace(run=run), drive=SimpleNamespace(value=profile),
devices={'left': SimpleNamespace(link=object()), 'right': SimpleNamespace(link=object())})
self.remote = RemoteControl(service)
self.remote.lease = InputLease(lambda: self.now[0])
self.relay = 'b' * 32
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
def start(self):
self.remote.feed({'watch': True, 'command': envelope(), 'relay_id': self.relay})
self.assertTrue(self.attempted.wait(1))
def finish(self):
self.operation.release()
self.remote.thread.join(1)
self.assertFalse(self.remote.thread.is_alive())
def tearDown(self):
self.remote.lease.stop()
if self.operation.locked():
self.operation.release()
if self.remote.thread:
self.remote.thread.join(1)
def test_existing_read_finishes_before_preparation(self):
self.start()
self.assertEqual(self.remote.state, 'preparing')
self.assertEqual(self.calls, [])
self.finish()
self.assertEqual(self.calls, ['prepare'])
self.assertEqual(self.remote.state, 'stopped')
self.assertFalse(self.operation.locked())
def test_stop_while_waiting_never_starts_preparation(self):
self.start()
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
self.finish()
self.assertEqual(self.calls, [])
self.assertEqual(self.remote.state, 'stopped')
def test_expired_input_does_not_start_when_lock_is_released(self):
self.start()
self.now[0] = .301
self.finish()
self.assertEqual(self.calls, [])
self.assertFalse(self.remote.lease.live())
def test_long_operation_does_not_queue_preparation(self):
self.start()
self.remote.thread.join(1)
self.assertFalse(self.remote.thread.is_alive())
self.assertEqual(self.calls, [])
self.assertEqual(self.remote.state, 'fault')
self.assertTrue(self.operation.locked())
def test_observer_yields_to_waiting_control_worker(self):
self.start()
self.attempted.clear()
self.remote.observe()
# The observer does not even try to reacquire ownership.
# Its test devices intentionally have no serial read methods.
self.assertEqual(self.calls, [])
self.finish()
self.assertEqual(self.calls, ['prepare'])
class DriveBoundaryTests(unittest.TestCase):
"""Synthetic two-controller run: verify the actual output loop and cleanup."""
def run_drive(self, scenario, *, broken_read=False, through_prepare=False, minimum_erpm=900):
now=[0.]; logs=[]; restored=[]
profile={'layout':'1x1','revision':1,'bindings':{
'left.1':{'device_id':'left','uuid':'left'},
'right.1':{'device_id':'right','uuid':'right'}}}
service=SimpleNamespace(drive=SimpleNamespace(value=profile))
remote=RemoteControl(service)
remote._publish=lambda *args:None
remote.lease=InputLease(lambda:now[0]); remote.lease.accept(envelope())
remote.lease.demand=(0,0)
def value():return dict(fault_code=0,input_voltage_v=48,mos_temperature_c=25,
motor_current_a=0,input_current_a=0,erpm=0,duty=0,tachometer=0)
class Link:
def __init__(self,name):self.name=name
def query(self,code,timeout):
if broken_read and self.name=='right' and now[0]>=.2:raise TimeoutError('lost right controller')
if code==4:
result=value()
if scenario=='coasting' and self.name=='right' and .4<=now[0]<.8:result['erpm']=1000
return result
return {'level':.8 if scenario=='receiver' and .2<=now[0]<.5 else 0}
def test_command(self,name):logs.append((now[0],self.name,name,0))
def test_speed(self,rpm):logs.append((now[0],self.name,'rpm',rpm))
devices=[SimpleNamespace(id=name,link=Link(name)) for name in ('left','right')]
owner=SimpleNamespace(stop=threading.Event(),latched=False,active=False,mode=None)
def state(s):owner.latched=s=='rc'
owner.state=state
owner.receiver_active=lambda _,level:abs(level)>.15
owner.limits=SimpleNamespace(apply=lambda d,*a:dict(l_current_max=35,l_max_erpm=100000,l_min_erpm=-100000,l_max_duty=.95,
s_pid_min_erpm=minimum_erpm[d.id] if isinstance(minimum_erpm,dict) else minimum_erpm),restore=lambda d:restored.append(d.id))
def sleep(delta):
now[0]+=delta
if now[0]>=2:owner.stop.set()
if remote.lease.live():
if scenario!='expire': remote.lease.until=now[0]+.4
remote.lease.demand=((-1,-1) if scenario=='reverse' and now[0]>=.4 else (1,1))
if scenario=='small': remote.lease.demand=(.2,-.2)
if scenario=='turn': remote.lease.demand=(-1,1)
if scenario=='release' and now[0]>=.4: remote.lease.demand=(0,0)
if scenario in ('turn-forward','coasting'):
remote.lease.demand=(-1,1) if now[0]<.4 else (1,1)
if scenario=='paused-reverse':
remote.lease.demand=(1,1) if now[0]<.4 else (0,0) if now[0]<1.2 else (-1,-1)
if scenario=='cancel-reverse':
remote.lease.demand=(-1,-1) if .4<=now[0]<.6 else (1,1)
owner.sleep=sleep
command={'parameters':{'current_a':30,'erpm':2000}}
originals={d.id:{'motor':b'config'} for d in devices}
service.motor=owner;service.operation_lock=threading.Lock();service.lock=threading.Lock()
service.devices={d.id:d for d in devices}
owner.run=lambda command,devices,target,remote:remote.drive(owner,command,devices,devices,originals,lambda:None)
error=None
with patch('runtime.remote_control.time.monotonic',lambda:now[0]),patch('runtime.remote_control.values',lambda v:v),patch('runtime.remote_control.ppm',lambda v:v):
try:
if through_prepare: remote._prepare(envelope())
else: remote.drive(owner,command,devices,devices,originals,lambda:None)
except (ValueError,TimeoutError) as e:error=e
return logs,restored,remote,owner,error
def test_start_enters_each_configured_pid_range_without_dead_ramp(self):
log,_,_,_,error=self.run_drive('forward',minimum_erpm={'left':900,'right':1100.1})
self.assertIsNone(error)
for name,minimum in [('left',900),('right',1101)]:
commands=[v for v in log if v[1]==name and v[2]=='rpm']
self.assertLessEqual(commands[0][0],.2)
self.assertEqual(commands[0][3],minimum)
self.assertTrue(all(minimum<=v[3]<=2000 for v in commands))
for before,after in zip(commands,commands[1:]):
self.assertLessEqual(after[3]-before[3],600*(after[0]-before[0])+1e-6)
def test_turn_starts_with_opposite_signs_at_pid_threshold(self):
log,_,_,_,error=self.run_drive('turn')
self.assertIsNone(error)
for name,sign in [('left',-1),('right',1)]:
first=next(v for v in log if v[1]==name and v[2]=='rpm')
self.assertEqual(first[3],sign*900)
def test_subthreshold_request_is_released_never_amplified(self):
log,_,_,_,error=self.run_drive('small')
self.assertIsNone(error)
self.assertFalse(any(v[2]=='rpm' for v in log))
def test_release_still_has_no_ramp_or_minimum_speed(self):
log,_,_,_,error=self.run_drive('release')
self.assertIsNone(error)
self.assertTrue(any(v[2]=='rpm' for v in log))
self.assertFalse(any(v[2]=='rpm' and v[0]>=.4 for v in log))
def test_invalid_or_unreachable_pid_threshold_never_claims_output(self):
for minimum in [-1,float('nan'),float('inf'),2000.1]:
with self.subTest(minimum=minimum):
log,_,_,_,error=self.run_drive('forward',minimum_erpm=minimum)
self.assertIsInstance(error,ValueError)
self.assertFalse(any(v[2] in ('rpm','claim') for v in log))
def test_turn_to_forward_releases_both_and_restarts_in_same_cycle(self):
log,_,_,_,error=self.run_drive('turn-forward')
self.assertIsNone(error)
restarts={name:next(v[0] for v in log if v[1]==name and v[2]=='rpm' and v[0]>=.4)
for name in ('left','right')}
self.assertEqual(restarts['left'],restarts['right'])
self.assertGreaterEqual(restarts['left'],.9)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<restarts['left'] for v in log))
self.assertTrue(all(v[3]>0 for v in log if v[2]=='rpm' and v[0]>=restarts['left']))
def test_shared_reversal_waits_for_every_motor_to_be_quiet(self):
log,_,_,_,error=self.run_drive('coasting')
self.assertIsNone(error)
first=next(v[0] for v in log if v[2]=='rpm' and v[0]>=.4)
self.assertGreaterEqual(first,1.3)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<1.3 for v in log))
def test_neutral_pause_counts_before_reversal_command(self):
log,_,_,_,error=self.run_drive('paused-reverse')
self.assertIsNone(error)
first=next(v[0] for v in log if v[2]=='rpm' and v[3]<0)
self.assertLess(first,1.4)
def test_cancelled_reversal_never_emits_old_direction(self):
log,_,_,_,error=self.run_drive('cancel-reverse')
self.assertIsNone(error)
self.assertFalse(any(v[2]=='rpm' and v[3]<0 for v in log))
self.assertTrue(any(v[2]=='rpm' and v[0]>=.6 for v in log))
def test_expired_browser_lease_releases_every_controller(self):
log,restored,remote,owner,error=self.run_drive('expire')
self.assertIsNotNone(error)
self.assertFalse(any(row[2]=='rpm' and row[0]>=.3 for row in log))
self.assertEqual(set(restored),{'left','right'})
self.assertTrue(remote.release_confirmed)
self.assertTrue(any(row[2]=='rpm' for row in log))
for name in ('left','right'):self.assertEqual([v[2] for v in log if v[1]==name][-1],'release')
def test_terminal_lease_reports_stopped_after_verified_cleanup(self):
log,restored,remote,owner,error=self.run_drive('expire',through_prepare=True)
self.assertIsNone(error)
self.assertEqual(remote.state,'stopped')
self.assertIsNone(remote.message)
self.assertTrue(remote.release_confirmed)
self.assertTrue(any(row[2]=='rpm' for row in log))
self.assertEqual(set(restored),{'left','right'})
self.assertFalse(remote.service.operation_lock.locked())
def test_session_wrapper_preserves_unconfirmed_release_fault(self):
_,_,remote,owner,error=self.run_drive('lost',broken_read=True,through_prepare=True)
self.assertEqual(remote.state,'fault')
self.assertFalse(remote.release_confirmed)
self.assertIn('не подтверждено',remote.message)
self.assertFalse(remote.service.operation_lock.locked())
def test_receiver_first_gesture_holds_zero_until_neutral(self):
log,restored,remote,owner,error=self.run_drive('receiver')
self.assertIsNone(error)
self.assertTrue(any(v[2]=='rpm' for v in log))
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
self.assertTrue(owner.latched)
self.assertEqual(remote.state,'receiver')
self.assertGreaterEqual(max(v[0] for v in log),.9)
def test_reversal_has_zero_interval(self):
log,_,_,_,error=self.run_drive('reverse')
self.assertIsNone(error)
first_negative=min(v[0] for v in log if v[2]=='rpm' and v[3]<0)
self.assertGreaterEqual(first_negative,.9)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<.9 for v in log))
def test_one_port_failure_stops_both_and_does_not_claim_confirmed_release(self):
log,restored,remote,owner,error=self.run_drive('lost',broken_read=True)
self.assertIsInstance(error,TimeoutError)
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
self.assertFalse(remote.release_confirmed)
self.assertTrue(owner.latched)
self.assertEqual(remote.state,'fault')
self.assertIn('не подтверждено',remote.message)
self.assertEqual(set(restored),{'left','right'})
+136
View File
@@ -0,0 +1,136 @@
"""Signed upstream speed commands retain timing, preflight and RC interlocks."""
import struct
import unittest
import test_speed_and_hall
import test_group_test
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.protocol import Decoder, speed_packet
from runtime.speed_hold import SpeedHold
class ReverseTests(unittest.TestCase):
def setUp(self):
self.rig = test_speed_and_hall.RunTests('test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits')
self.rig.setUp()
self.addCleanup(self.rig.doCleanups)
self.case = self.rig.case
def command(self):
command = self.rig.run_command()
command['parameters']['erpm'] = -3000
return command
def test_reverse_holds_thirty_seconds_with_signed_ramp_and_restores(self):
result = self.case.service.execute(self.command())['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertGreaterEqual(result['rotation_s'], 30)
self.assertGreater(result['samples'][-1]['at'], 35)
speeds = [v for _, v in self.rig.speeds]
self.assertEqual(speeds[0], -1)
self.assertEqual(speeds[-1], -3000)
self.assertTrue(all(-3000 <= v < 0 for v in speeds))
self.assertTrue(all(-31 <= b-a <= 0 for a, b in zip(speeds, speeds[1:])))
self.assertTrue(result['release_confirmed'])
self.assertTrue(result['limits_restored'])
self.assertEqual(self.rig.configs, self.rig.originals)
self.assertEqual(len(result['preflight']['samples']), 20)
def test_missing_false_or_nonboolean_stop_confirmation_never_writes(self):
for confirmation in (None, False, 1, 'true'):
command = self.command()
if confirmation is None: del command['parameters']['standstill_confirmed']
else: command['parameters']['standstill_confirmed'] = confirmation
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.rig.speeds, [])
self.assertEqual(self.case.sent, [])
def test_unsigned_size_bounds_are_checked_for_both_directions(self):
for erpm in (0, 299, -299, 3001, -3001, True, float('nan'), float('inf')):
command = self.command(); command['parameters']['erpm'] = erpm
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.rig.speeds, [])
def test_reverse_respects_configured_negative_limit(self):
target = self.case.devices[0]
motor = decode(self.rig.configs[target.id], 'motor')
self.rig.configs[target.id] = configuration('motor', {**motor, 'l_min_erpm': -1000})
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertEqual(self.rig.speeds, [])
def test_sensorless_idle_observation_rejects_modulation_above_one_quantum(self):
peer = self.case.devices[1]
motor = decode(self.rig.configs[peer.id], 'motor')
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
original = peer.link.query
reads = 0
def query(code, timeout=2):
nonlocal reads
raw = original(code, timeout)
if code == 4:
reads += 1
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
if reads >= 6: raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
return raw
peer.link.query = query
result = self.case.service.execute(self.command())
self.assertEqual(result['state'], 'error')
self.assertEqual(self.rig.speeds, [])
self.assertEqual(self.rig.sets, [])
def test_attended_sensorless_peer_drift_allows_test_and_explicit_release(self):
peer = self.case.devices[1]
motor = decode(self.rig.configs[peer.id], 'motor')
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
query = peer.link.query
def read(code, timeout=2):
raw = query(code, timeout)
return raw[:23] + struct.pack('>i', -160) + raw[27:] if code == 4 else raw
peer.link.query = read
self.case.service.motor.state('rc')
release = self.case.command_for_pulse()
release['action_id'] = 'vesc.control.release'
release['parameters']['standstill_confirmed'] = True
receipt = self.case.service.execute(release)
self.assertEqual(receipt['state'], 'complete', receipt)
self.assertFalse(self.case.service.motor.latched)
self.assertEqual(self.rig.speeds, [])
result = self.case.service.execute(self.command())['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertTrue(any(s['values']['erpm'] == -160 for s in result['preflight']['samples']))
def test_reverse_rc_preemption_latches_without_restart(self):
speed = self.case.devices[0].link.test_speed
def send(value):
speed(value)
self.case.changed = True
self.case.devices[0].link.test_speed = send
result = self.case.service.execute(self.command())['result']
self.assertEqual(len(self.rig.speeds), 1)
self.assertTrue(self.case.service.motor.latched)
self.assertEqual(result['rotation_s'], 0)
self.assertTrue(result['release_confirmed'])
class ReverseClockTests(unittest.TestCase):
def test_wrong_direction_never_counts_even_with_changing_tachometer(self):
hold = SpeedHold(-3000, 30, 0)
for i in range(151): _, _, error = hold.update(i/10, {'erpm':3000, 'tachometer':i})
self.assertEqual(hold.rotation_s, 0)
self.assertIsNone(hold.hold_started)
self.assertIn('не вышел', error)
def test_signed_wire_is_int32_not_absolute_value(self):
self.assertEqual(Decoder().feed(speed_packet(-3000)), [bytes([8])+struct.pack('>i', -3000)])
def test_common_reverse_holds_all_motors_and_restores(self):
rig = test_group_test.GroupTests('test_pair_and_four_motors_run_one_common_interval_and_restore')
rig.setUp(); self.addCleanup(rig.doCleanups)
command = rig.command(); command['parameters']['erpm'] = -3000
result = rig.case.service.execute(command)['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertGreaterEqual(result['rotation_s'], 3)
self.assertTrue(all(-3000 <= v < 0 for _, v in rig.commands))
self.assertTrue(result['limits_restored'])
self.assertEqual(rig.configs, rig.originals)
+5 -5
View File
@@ -57,7 +57,7 @@ class RunTests(unittest.TestCase):
def run_command(self):
command = self.case.command_for_pulse()
command['action_id'] = 'vesc.motor.run'
command['parameters'].update(erpm=1200,current_a=30,duration_s=30)
command['parameters'].update(erpm=1200,current_a=30,duration_s=30,standstill_confirmed=True)
from datetime import datetime,timedelta
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
return command
@@ -119,7 +119,7 @@ class RunTests(unittest.TestCase):
def test_native_hall_returns_measured_table_without_applying_it(self):
command=self.case.command(action='vesc.hall.measure')
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True,
'sessions':{d.id:d.session for d in self.case.devices}}
receipt=self.case.service.execute(command)
self.assertEqual(receipt['state'],'complete',receipt)
@@ -138,7 +138,7 @@ class RunTests(unittest.TestCase):
def test_native_hall_requires_its_own_explicit_confirmation(self):
command=self.case.command(action='vesc.hall.measure')
command['parameters']={'rig_clear':True,'native_cycle_confirmed':False,
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':False,
'sessions':{d.id:d.session for d in self.case.devices}}
with self.assertRaises(ValueError):self.case.service.execute(command)
self.assertIsNone(self.hall_started)
@@ -165,7 +165,7 @@ class RunTests(unittest.TestCase):
def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self):
self.case.devices[0].link.detect_hall=lambda:None
command=self.case.command(action='vesc.hall.measure')
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True,
'sessions':{d.id:d.session for d in self.case.devices}}
result=self.case.service.execute(command)['result']
self.assertFalse(result['completed'])
@@ -202,5 +202,5 @@ class HoldClockTests(unittest.TestCase):
failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1]))
self.assertFalse(failure['valid_six_states'])
self.assertEqual(failure['observed_states'],[3])
for value in (float('nan'),float('inf'),-1,3001,True):
for value in (float('nan'),float('inf'),-3001,3001,True):
with self.assertRaises(ValueError):speed_packet(value)
+42
View File
@@ -0,0 +1,42 @@
import json
import threading
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from k1link.fleet import board_layout
from k1link.fleet.trust import PairingError
from k1link.web.fleet_api import router, local_operator
def test_layout_api_persists_empty_and_isolates_vehicles(tmp_path):
app = FastAPI()
app.include_router(router)
def find(identifier):
if identifier not in ("rover-a", "rover-b"):
raise PairingError("Unknown")
fleet = SimpleNamespace(root=tmp_path, lock=threading.RLock(), find=find)
app.dependency_overrides[local_operator] = lambda: fleet
client = TestClient(app)
for section in board_layout.SECTIONS:
response = client.patch('/api/v1/fleet/rover-a/board-layout', json={"section": section, "open": False})
assert response.status_code == 200
assert client.get('/api/v1/fleet/rover-a/board-layout').json()['open_sections'] == []
assert client.get('/api/v1/fleet/rover-b/board-layout').json()['open_sections'] == list(board_layout.SECTIONS)
assert board_layout.read(tmp_path, 'rover-a')['revision'] == 3
assert board_layout.path(tmp_path, 'rover-a').stat().st_mode & 0o777 == 0o600
for body in ({"section":"motor","open":True},{"section":"computer","open":"true"},{"section":"computer","open":True,"extra":0}):
assert client.patch('/api/v1/fleet/rover-a/board-layout', json=body).status_code == 422
assert client.get('/api/v1/fleet/missing/board-layout').status_code == 404
def test_corrupt_layout_is_preserved(tmp_path):
board_layout.update(tmp_path, "a", "computer", False)
target = board_layout.path(tmp_path, "a")
raw = json.dumps({"schema":board_layout.SCHEMA,"revision":3,"open_sections":["motor"]})
target.write_text(raw)
with pytest.raises(ValueError):
board_layout.update(tmp_path, "a", "devices", False)
assert target.read_text() == raw
+19
View File
@@ -84,6 +84,25 @@ def cert(row):
serialization.Encoding.DER
)
def test_rover_stream_requires_current_pairing_and_binding(setup):
fleet, _, _, _ = setup
public, _ = create(setup)
fleet.advance(public["id"])
row = fleet.find(public["id"])
body = {"schema": SCHEMA, "node_id": row["node_id"],
"binding_id": row["binding"]["binding_id"], "relay_id": "a"*32}
certificate = cert(row)
status, result = fleet.receive(certificate, "/v1/node/rover-stream", body)
assert status == 200
assert result['command'] is None
assert result['control_clock']['instance'] == fleet.rover_control.clock_id
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", {**body, "binding_id": "other"})
assert status == 410
row['enrollment'] = 'revoked'
fleet.save(row)
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", body)
assert status == 410
def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup):
fleet, _, _, _ = setup
+115
View File
@@ -0,0 +1,115 @@
import pytest
from k1link.fleet.rover_control import RoverControl
@pytest.fixture
def hub():
now=[10.]
h=RoverControl(lambda:now[0])
h.exchange('node',{'relay_id':'a'*32,'rover':{'supported':True,'instance':'driver','state':'observing'}})
return h,now
def arm(h):
return h.arm('node',{'standstill_confirmed':True,'current_a':30,'max_erpm':2000})['session_id']
def cmd(id,seq=1,**kw):
return {'session_id':id,'sequence':seq,'left':1,'right':1,'stop':False,**kw}
def exchange(h,**kw):
return h.exchange('node',{'relay_id':'a'*32,'rover':{'supported':True,'instance':'driver',**kw}})
def test_expired_browser_cannot_be_revived_by_late_packet(hub):
h,now=hub;id=arm(h);h.command('node',cmd(id));now[0]+=.401
assert exchange(h)['command'] is None
with pytest.raises(ValueError):h.command('node',cmd(id,2))
def test_sequence_stop_and_single_owner(hub):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):arm(h)
h.command('node',cmd(id,2))
with pytest.raises(ValueError):h.command('node',cmd(id,1))
assert exchange(h)['command']['sequence']==2
h.command('node',cmd(id,3,stop=True))
with pytest.raises(ValueError):h.command('node',cmd(id,4))
assert exchange(h)['command'] is None
@pytest.mark.parametrize('snapshot',[{'state':'receiver'},{'state':'fault'},{'instance':'new-driver'}])
def test_takeover_fault_or_driver_restart_revokes(hub,snapshot):
h,_=hub;id=arm(h)
assert exchange(h,session_id=id,**snapshot)['command'] is None
def test_relay_restart_and_cross_board_do_not_inherit_authority(hub):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):h.command('other',cmd(id))
assert h.exchange('node',{'relay_id':'b'*32,'rover':{}})['command'] is None
@pytest.mark.parametrize('value',[True,float('nan'),float('inf'),1.01,-1.01,'1'])
def test_invalid_demand_cannot_refresh(hub,value):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):h.command('node',cmd(id,left=value))
def test_stale_telemetry_is_unavailable_and_arm_requires_current_board(hub):
h,now=hub;now[0]+=1.1
assert h.view('node')['snapshot']=={}
with pytest.raises(ValueError):arm(h)
def test_stream_keeps_absolute_deadline_and_never_renews_held_frame(hub):
h, now = hub
identifier = arm(h)
h.command('node', cmd(identifier, 1))
first = h.stream('node', 'a'*32)
now[0] += .2
repeated = h.stream('node', 'a'*32)
assert repeated['command'] == first['command']
assert repeated['control_clock']['monotonic_ms'] > first['control_clock']['monotonic_ms']
assert first['command']['expires_mono_ms'] == pytest.approx(10400)
now[0] += .201
assert h.stream('node', 'a'*32)['command'] is None
with pytest.raises(ValueError): h.command('node', cmd(identifier, 2))
def test_stream_delivers_new_intent_without_waiting_for_telemetry(hub):
h, now = hub
identifier = arm(h)
h.command('node', cmd(identifier, 1))
h.command('node', cmd(identifier, 2, left=-1))
assert h.stream('node', 'a'*32)['command']['left'] == -1
assert h.stream('node', 'b'*32)['command'] is None
h.command('node', cmd(identifier, 3, stop=True))
assert h.stream('node', 'a'*32)['command'] is None
def test_new_core_has_distinct_clock_epoch(hub):
h, _ = hub
assert h.stream('node', 'a'*32)['control_clock']['instance'] != RoverControl().clock_id
def test_stream_retires_control_if_return_telemetry_is_lost(hub):
h, now = hub
identifier = arm(h)
for seq in range(1, 13):
h.command('node', cmd(identifier, seq))
now[0] += .09
assert h.stream('node', 'a'*32)['command'] is None
with pytest.raises(ValueError): h.command('node', cmd(identifier, 13))
def test_intent_wait_wakes_on_update_and_does_not_miss_prior_update(hub):
import threading
from unittest.mock import patch
h, _ = hub
identifier = arm(h)
previous = h.stream('node', 'a'*32)['command']
entered, done = threading.Event(), threading.Event()
def waiter():
entered.set()
h.wait_for_intent('node', 'a'*32, previous, timeout=2)
done.set()
worker = threading.Thread(target=waiter)
worker.start()
assert entered.wait(1)
h.command('node', cmd(identifier, 1))
assert done.wait(.5), 'new intent waited for the periodic keepalive'
worker.join(2)
# A command arriving after socket write but before wait must not be lost.
with patch.object(h.changed, 'wait', side_effect=AssertionError('missed prior update')):
h.wait_for_intent('node', 'a'*32, previous, timeout=2)
latest = h.stream('node', 'a'*32)['command']
h.command('node', cmd(identifier, 2, stop=True))
with patch.object(h.changed, 'wait', side_effect=AssertionError('missed stop')):
h.wait_for_intent('node', 'a'*32, latest, timeout=2)
@@ -0,0 +1,52 @@
"""Exercise HTTP framing and per-frame binding checks without a real board."""
import http.client
import json
import threading
import time
from http.server import ThreadingHTTPServer
from types import SimpleNamespace
from k1link.fleet.transport import NodeChannelHandler
def test_stream_rechecks_binding_and_closes_when_revoked():
calls = []
def receive(certificate, path, body, *, address):
calls.append((certificate, path, body, address))
if len(calls) == 3:
return 410, {"error": "Binding revoked"}
return 200, {"watch": True, "command": {"sequence": len(calls)}}
class Handler(NodeChannelHandler):
def setup(self):
super().setup()
# Only TLS certificate extraction is substituted. HTTP reads,
# writes, streaming, loop and reauthentication use production code.
self.connection = SimpleNamespace(getpeercert=lambda **_: b"synthetic-cert")
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.address = "127.0.0.1"
server.registry = SimpleNamespace(receive=receive, stop=threading.Event(),
rover_control=SimpleNamespace(wait_for_intent=lambda *_: time.sleep(.01)))
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
client = http.client.HTTPConnection(*server.server_address, timeout=2)
try:
body = {"relay_id": "a" * 32, "node_id": "synthetic-node"}
client.request("POST", "/v1/node/rover-stream", json.dumps(body),
{"Content-Type": "application/json"})
response = client.getresponse()
assert response.status == 200
assert response.getheader("Content-Type") == "application/x-ndjson"
assert response.getheader("Content-Length") is None
frames = [json.loads(line) for line in response.read().splitlines()]
assert [frame["command"]["sequence"] for frame in frames] == [1, 2]
assert len(calls) == 3
assert all(call == (b"synthetic-cert", "/v1/node/rover-stream", body, "127.0.0.1") for call in calls)
finally:
client.close()
server.shutdown()
server.server_close()
worker.join(timeout=2)
assert not worker.is_alive()
+69
View File
@@ -0,0 +1,69 @@
"""Periodic planning reports must not serialize/compress on the ASGI loop."""
import threading
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.web import planning_live_api
from k1link.web.response_compression import ResponseCompressionMiddleware
@pytest.mark.parametrize("encoding", ["gzip", "identity"])
def test_active_report_keeps_content_and_encodes_off_loop(monkeypatch, encoding):
document = {"state": "completed", "evidence": [{"label": "Проверка", "x": 1.25}] * 50}
calls = []
original_json = planning_live_api.JSONResponse
original_compress = planning_live_api.gzip.compress
class RecordedJSON(original_json):
def render(self, content):
calls.append(("json", threading.get_ident()))
return super().render(content)
def compress(*args, **kwargs):
calls.append(("gzip", threading.get_ident()))
return original_compress(*args, **kwargs)
monkeypatch.setattr(planning_live_api, "JSONResponse", RecordedJSON)
monkeypatch.setattr(planning_live_api.gzip, "compress", compress)
app = FastAPI()
app.include_router(planning_live_api.build_planning_live_router(SimpleNamespace(get=lambda: document)))
app.add_middleware(ResponseCompressionMiddleware)
@app.get("/loop-thread")
async def loop_thread():
return threading.get_ident()
with TestClient(app) as client:
loop_id = client.get("/loop-thread").json()
result = client.get("/api/v1/mission-planner/live-tests/active", headers={"Accept-Encoding": encoding})
assert result.status_code == 200
assert result.json() == document # Includes HTTP decompression; no double gzip.
assert result.headers["cache-control"] == "no-store"
assert result.headers["content-type"] == "application/json"
assert [name for name, _ in calls] == (["json", "gzip"] if encoding == "gzip" else ["json"])
assert all(thread != loop_id for _, thread in calls)
if encoding == "gzip":
assert result.headers["content-encoding"] == "gzip"
assert result.headers["vary"] == "Accept-Encoding"
else:
assert "content-encoding" not in result.headers
def test_active_report_retains_empty_and_failure_contract():
service = SimpleNamespace(get=lambda: None)
app = FastAPI()
app.include_router(planning_live_api.build_planning_live_router(service))
with TestClient(app) as client:
result = client.get("/api/v1/mission-planner/live-tests/active")
assert result.status_code == 200 and result.json() is None
def fail():
raise RuntimeError("synthetic unavailable report")
service.get = fail
result = client.get("/api/v1/mission-planner/live-tests/active")
assert result.status_code == 409
assert result.json() == {"detail": "synthetic unavailable report"}
+49
View File
@@ -0,0 +1,49 @@
"""Immutable-source evaluated geometry export, owner-selected complete v020."""
import bpy,json,hashlib,struct
from pathlib import Path
from mathutils import Vector,Matrix
root=Path.cwd();source=Path(bpy.data.filepath)
donor=root.parent/'NODEDC_ENGINE_INFRA/nodedc-source/public/3dassetnode/model.glb'
with donor.open('rb') as f:
f.read(12);n,t=struct.unpack('<II',f.read(8));donor_json=json.loads(f.read(n))
# Same physical PBR parameters as the drone's untextured metal/rubber/steel.
material_rules={'metal':1,'rubber':2,'steel':6,'paint':3}
materials={}
for kind,index in material_rules.items():
spec=donor_json['materials'][index]['pbrMetallicRoughness'];m=bpy.data.materials.new('NodeDC '+kind);m.use_nodes=True
bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=spec.get('baseColorFactor',[1,1,1,1]);bs.inputs['Metallic'].default_value=spec.get('metallicFactor',1);bs.inputs['Roughness'].default_value=spec.get('roughnessFactor',1);materials[kind]=m
source_scene=bpy.context.scene;source_scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get()
instances=[]
for inst in dg.object_instances:
o=inst.object;src=o.original;cols=[c.name for c in src.users_collection]
if o.type not in {'MESH','CURVE'} or src.hide_render:continue
if any(c.hide_render for c in src.users_collection) or any(('STUDIO' in c or 'BOOLEAN' in c or 'DATUM' in c) for c in cols):continue
# Exclude standalone instancing source collections; their placed instances survive.
if not inst.is_instance and not src.visible_get():continue
mesh=bpy.data.meshes.new_from_object(o,preserve_all_data_layers=True,depsgraph=dg)
names=[slot.material.name.lower() if slot.material else '' for slot in o.material_slots]
for idx in range(len(mesh.materials)):
name=names[idx] if idx<len(names) else ''
kind='rubber' if any(k in name for k in ['rubber','gasket','polymer','elastomer','jacket','asa']) else 'steel' if any(k in name for k in ['zinc','steel','brass','spring']) else 'paint' if any(k in name for k in ['paint','coated','black','charcoal']) else 'metal'
mesh.materials[idx]=materials[kind]
if not mesh.materials:mesh.materials.append(materials['metal'])
instances.append((src.name,mesh,inst.matrix_world.copy(),inst.is_instance))
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene
objects=[]
for name,mesh,matrix,_ in instances:
o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.matrix_world=matrix;objects.append(o)
bpy.context.view_layer.update()
lo=Vector((min((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
hi=Vector((max((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
shift=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
for o in objects:o.matrix_world=Matrix.Translation(shift)@o.matrix_world
source_object_count=len(objects)
bpy.ops.object.select_all(action='SELECT')
bpy.context.view_layer.objects.active=objects[0]
bpy.ops.object.join()
joined=bpy.context.object
for uv in list(joined.data.uv_layers):joined.data.uv_layers.remove(uv)
out=root/'apps/control-station/public/rover-scene/dcd006-v020.glb'
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_cameras=False,export_lights=False,export_extras=False,export_yup=True,export_apply=False,export_texcoords=False,export_attributes=False,export_vertex_color='NONE')
manifest={'source':source.name,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model':'DCD-006 v020 complete rover','source_object_count':source_object_count,'render_mesh_count':1,'original_bounds':[list(lo),list(hi)],'dimensions_m':list(hi-lo),'translation_m':list(shift),'glb_bytes':out.stat().st_size,'glb_sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'material_source_sha256':hashlib.sha256(donor.read_bytes()).hexdigest(),'material_rules':material_rules,'source_saved':False}
(out.parent/'dcd006-v020.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n');print(json.dumps(manifest))
+11
View File
@@ -0,0 +1,11 @@
import bpy,json
from mathutils import Vector
scene=bpy.context.scene;scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get()
rows=[]
for inst in dg.object_instances:
o=inst.object;src=o.original
if o.type not in {'MESH','CURVE'} or src.hide_render:continue
cols=[c.name for c in src.users_collection]
if any(c.hide_render for c in src.users_collection) or any(('STUDIO' in c or 'BOOLEAN' in c or 'DATUM' in c) for c in cols):continue
rows.append({'name':src.name,'collections':cols,'verts':len(o.data.vertices) if o.type=='MESH' else 0,'bbox':[[round(x,4) for x in (inst.matrix_world@Vector(v))] for v in o.bound_box]})
print(json.dumps({'count':len(rows),'vertices':sum(x['verts'] for x in rows),'bounds':[[min(v['bbox'][j][k] for v in rows for j in range(8)) for k in range(3)],[max(v['bbox'][j][k] for v in rows for j in range(8)) for k in range(3)]],'sample':[{k:v for k,v in x.items() if k!='bbox'} for x in rows[:15]]},ensure_ascii=False))