merge: integrate final rover control with operator simulation workspace
This commit is contained in:
@@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code.
|
|||||||
- Synthetic or explicitly redacted fixtures may be committed under
|
- Synthetic or explicitly redacted fixtures may be committed under
|
||||||
`tests/fixtures/`.
|
`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
|
## Insta360 and clean-host installation — owner requirement, 2026-09-08
|
||||||
|
|
||||||
- The current X4 starting point is USB enumeration only. SDK installation,
|
- 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",
|
"schema": "missioncore.node.environment/v1",
|
||||||
"revision": "ubuntu-24.04-amd64/2",
|
"revision": "ubuntu-24.04-amd64/3",
|
||||||
"steps": [
|
"steps": [
|
||||||
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
|
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
|
||||||
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
|
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
|
||||||
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","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":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
|
||||||
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
|
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
|
||||||
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","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) {
|
func (p *Pairing) Run(ctx context.Context) {
|
||||||
go p.channel(ctx)
|
go p.channel(ctx)
|
||||||
|
go p.roverChannel(ctx)
|
||||||
var server *http.Server
|
var server *http.Server
|
||||||
endpoint := ""
|
endpoint := ""
|
||||||
serverIdentity := ""
|
serverIdentity := ""
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) presentationRoutes(mux *http.ServeMux) {
|
func (s *Server) presentationRoutes(mux *http.ServeMux) {
|
||||||
|
s.boardLayoutRoutes(mux)
|
||||||
p := s.Presentation
|
p := s.Presentation
|
||||||
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.authorized(w, r) {
|
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",
|
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},
|
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")},
|
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",
|
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",
|
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},
|
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 {
|
func modelForDevice(id string) *sensorModel {
|
||||||
|
|||||||
@@ -6,10 +6,29 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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) {
|
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
|
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sensorViewAction(action string) bool {
|
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) {
|
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
BINARY_VERSION = "0.8.35"
|
BINARY_VERSION = "0.8.45"
|
||||||
VERSION = "0.8.35-1"
|
VERSION = "0.8.45-1"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
from debian import package
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Architecture: amd64
|
|||||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
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
|
Description: Mission Core onboard computer configuration
|
||||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||||
""".encode()
|
""".encode()
|
||||||
@@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration
|
|||||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||||
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
||||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
("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),
|
("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),
|
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||||
("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755),
|
("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 установлен; системная служба запущена. Вход проверяется отдельно."
|
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):
|
def run_steps(profile, operations, save):
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ def main():
|
|||||||
sys.path.insert(0, str(node / "packaging"))
|
sys.path.insert(0, str(node / "packaging"))
|
||||||
from build_deb import BINARY_VERSION, VERSION, build
|
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)
|
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
|
||||||
binary = node / "build/node-agent-linux-amd64"
|
binary = node / "build/node-agent-linux-amd64"
|
||||||
run(
|
run(
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Mission Core Node
|
||||||
|
Comment=Настройка и диагностика бортового компьютера
|
||||||
|
TryExec=/usr/bin/mission-core-node
|
||||||
|
Exec=/usr/bin/python3 -I -B /usr/lib/mission-core-node/desktop_startup.py
|
||||||
|
Icon=org.nodedc.MissionCoreNode
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=false
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mission Core bounded USB startup recovery
|
||||||
|
Wants=systemd-udev-settle.service
|
||||||
|
After=systemd-udev-settle.service
|
||||||
|
Before=mission-core-node.service mission-core-vesc.service
|
||||||
|
ConditionPathExists=/etc/mission-core-node/usb-startup.json
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py
|
||||||
|
ExecStopPost=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py --restore
|
||||||
|
TimeoutStartSec=130
|
||||||
|
TimeoutStopSec=15
|
||||||
|
RuntimeDirectory=mission-core-usb-startup
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
RuntimeDirectoryPreserve=yes
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateNetwork=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
ReadWritePaths=/sys/devices /run/mission-core-usb-startup
|
||||||
|
DevicePolicy=closed
|
||||||
|
DeviceAllow=char-usb_device rw
|
||||||
|
TasksMax=16
|
||||||
|
MemoryMax=64M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -14,6 +14,14 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
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)
|
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
|
case "$mc_node_x4_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ if [ -d /run/systemd/system ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
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)
|
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
|
case "$mc_node_x4_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||||
@@ -34,6 +42,25 @@ if [ -d /run/systemd/system ]; then
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
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
|
case "$1" in
|
||||||
upgrade|remove|deconfigure)
|
upgrade|remove|deconfigure)
|
||||||
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
|
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
|
rm /etc/udev/rules.d/70-mission-core-vesc.rules
|
||||||
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
||||||
fi
|
fi
|
||||||
|
retire_startup_settings
|
||||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
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
|
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):
|
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):
|
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
|
||||||
saved = []
|
saved = []
|
||||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Synthetic sysfs/journal only: these tests never open a physical USB port."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
import usb_startup_recovery as recovery
|
||||||
|
|
||||||
|
|
||||||
|
def event(second, message, transport='kernel'):
|
||||||
|
return {'__MONOTONIC_TIMESTAMP': str(int(second * 1000000)),
|
||||||
|
'_TRANSPORT': transport, 'MESSAGE': message}
|
||||||
|
|
||||||
|
|
||||||
|
class JournalTests(unittest.TestCase):
|
||||||
|
def test_only_terminal_boot_failures_with_no_later_connection_are_candidates(self):
|
||||||
|
records = [event(20, 'usb usb1-port2: unable to enumerate USB device'),
|
||||||
|
event(18, 'usb usb1-port3: unable to enumerate USB device'),
|
||||||
|
event(23, 'usb 1-3: new full-speed USB device number 8 using xhci_hcd'),
|
||||||
|
event(12, 'usb 1-1-port2: unable to enumerate USB device'),
|
||||||
|
event(65, 'usb usb2-port1: unable to enumerate USB device'),
|
||||||
|
event(15, 'usb usb2-port3: unable to enumerate USB device', 'stdout'),
|
||||||
|
event(18, 'usb 1-4: device descriptor read/64, error -71')]
|
||||||
|
self.assertEqual(recovery.failed_ports(list(reversed(records))), ['1-1-port2', 'usb1-port2'])
|
||||||
|
|
||||||
|
def test_success_or_user_disconnect_invalidates_old_failure(self):
|
||||||
|
for message in ['New USB device found, idVendor=0000', 'USB disconnect, device number 8']:
|
||||||
|
self.assertEqual(recovery.failed_ports([
|
||||||
|
event(20, 'usb usb1-port2: unable to enumerate USB device'), event(21, 'usb 1-2: ' + message)]), [])
|
||||||
|
|
||||||
|
def test_invalid_port_names_cannot_be_paths(self):
|
||||||
|
for name in ['../../etc/passwd', 'usb0-port0', 'usb1-port1/disable']:
|
||||||
|
with self.assertRaises(ValueError): recovery.child_name(name)
|
||||||
|
self.assertEqual(recovery.child_name('1-2.3-port4'), '1-2.3.4')
|
||||||
|
|
||||||
|
|
||||||
|
class SysfsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
base = Path(self.temp.name)
|
||||||
|
self.root, self.physical = base/'bus', base/'devices'
|
||||||
|
self.root.mkdir(); self.physical.mkdir()
|
||||||
|
self.usb = recovery.USB(self.root, self.physical)
|
||||||
|
self.ports = []
|
||||||
|
for n in (1, 2):
|
||||||
|
hub = self.physical/f'usb{n}'; hub.mkdir()
|
||||||
|
for key, value in {'bDeviceClass':'09', 'busnum':str(n), 'devnum':'1'}.items():
|
||||||
|
(hub/key).write_text(value)
|
||||||
|
(self.root/hub.name).symlink_to(hub, target_is_directory=True)
|
||||||
|
interface = hub/f'{n}-0:1.0'; interface.mkdir()
|
||||||
|
(self.root/interface.name).symlink_to(interface, target_is_directory=True)
|
||||||
|
port = interface/f'usb{n}-port2'; port.mkdir()
|
||||||
|
for key, value in {'state':'not attached', 'disable':'0\n', 'over_current_count':'0', 'connect_type':'unknown'}.items():
|
||||||
|
(port/key).write_text(value)
|
||||||
|
self.ports.append(port)
|
||||||
|
(self.ports[0]/'peer').symlink_to(self.ports[1], target_is_directory=True)
|
||||||
|
(self.ports[1]/'peer').symlink_to(self.ports[0], target_is_directory=True)
|
||||||
|
|
||||||
|
def plan(self):
|
||||||
|
with patch.object(recovery, 'run', return_value=' wHubCharacteristic 0x0009\n'):
|
||||||
|
return self.usb.plan('usb1-port2')
|
||||||
|
|
||||||
|
def test_individual_power_pair_can_be_disabled_and_restored(self):
|
||||||
|
entries = self.plan()
|
||||||
|
self.assertEqual([e['name'] for e in entries], ['usb1-port2', 'usb2-port2'])
|
||||||
|
for entry in entries: self.usb.write(entry, True)
|
||||||
|
self.assertTrue(all((p/'disable').read_text() == '1\n' for p in self.ports))
|
||||||
|
for entry in reversed(entries): self.usb.write(entry, False)
|
||||||
|
self.assertTrue(all((p/'disable').read_text() == '0\n' for p in self.ports))
|
||||||
|
|
||||||
|
def test_connected_companion_blocks_both_ports_before_descriptor_query(self):
|
||||||
|
(self.ports[1]/'device').symlink_to(self.physical/'camera')
|
||||||
|
with patch.object(recovery, 'run') as run:
|
||||||
|
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||||
|
run.assert_not_called()
|
||||||
|
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
|
||||||
|
|
||||||
|
def test_non_individual_power_or_missing_descriptor_cannot_reset(self):
|
||||||
|
for output in ['wHubCharacteristics 0x0000', 'wHubCharacteristics 0x0002', '']:
|
||||||
|
with self.subTest(output=output), patch.object(recovery, 'run', return_value=output):
|
||||||
|
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||||
|
|
||||||
|
def test_internal_disabled_overcurrent_and_mid_enumeration_ports_skipped(self):
|
||||||
|
for attribute, value in [('connect_type', 'hardwired'), ('disable', '1'), ('over_current_count', '1'), ('state', 'powered')]:
|
||||||
|
p = self.ports[0]/attribute; original = p.read_text(); p.write_text(value)
|
||||||
|
with self.subTest(attribute=attribute), self.assertRaises(OSError): self.plan()
|
||||||
|
p.write_text(original)
|
||||||
|
|
||||||
|
def test_attachment_after_planning_prevents_write(self):
|
||||||
|
entries = self.plan()
|
||||||
|
(self.ports[0]/'device').symlink_to(self.physical/'new-device')
|
||||||
|
with self.assertRaises(OSError): self.usb.write(entries[0], True)
|
||||||
|
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
|
||||||
|
|
||||||
|
def test_hub_replacement_after_planning_prevents_write(self):
|
||||||
|
entries = self.plan()
|
||||||
|
(self.physical/'usb1'/'devnum').write_text('4')
|
||||||
|
with self.assertRaises(OSError): self.usb.write(entries[0], True)
|
||||||
|
|
||||||
|
def test_first_hub_replaced_while_inspecting_companion_invalidates_plan(self):
|
||||||
|
def inspect(name):
|
||||||
|
if name == 'usb2-port2': (self.physical/'usb1'/'devnum').write_text('4')
|
||||||
|
return True
|
||||||
|
with patch.object(self.usb, 'individual_power', side_effect=inspect):
|
||||||
|
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||||
|
|
||||||
|
def test_enabled_port_is_not_written_during_cleanup(self):
|
||||||
|
entries = self.plan()
|
||||||
|
with patch.object(recovery.os, 'open', side_effect=AssertionError('No write expected')):
|
||||||
|
self.usb.write(entries[0], False)
|
||||||
|
|
||||||
|
def test_foreign_companion_path_is_rejected(self):
|
||||||
|
(self.ports[0]/'peer').unlink()
|
||||||
|
(self.ports[0]/'peer').symlink_to(self.root)
|
||||||
|
with self.assertRaises(OSError): self.usb.companions('usb1-port2')
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
|
||||||
|
self.state = Path(self.temp.name)
|
||||||
|
self.clock = 30
|
||||||
|
self.usb = Mock()
|
||||||
|
self.entries = [{'name': 'usb1-port2', 'generation': {'address': 1}},
|
||||||
|
{'name': 'usb2-port2', 'generation': {'address': 1}}]
|
||||||
|
self.usb.plan.return_value = self.entries
|
||||||
|
self.usb.empty.return_value = True
|
||||||
|
self.usb.generation.return_value = {'address': 1}
|
||||||
|
self.usb.outcome.return_value = {'idVendor': '0000', 'idProduct': '0001', 'product': 'Synthetic'}
|
||||||
|
|
||||||
|
def sleep(self, seconds): self.clock += seconds
|
||||||
|
|
||||||
|
def run_recovery(self, ports=None):
|
||||||
|
return recovery.recover(self.usb, self.state, 'boot', ports or ['usb1-port2'], lambda: self.clock, self.sleep)
|
||||||
|
|
||||||
|
def test_pair_is_attempted_only_once_and_restored_before_enumeration_check(self):
|
||||||
|
result = self.run_recovery(['usb1-port2', 'usb2-port2'])
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]['state'], 'enumerated')
|
||||||
|
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||||
|
self.assertFalse((self.state/'pending.json').exists())
|
||||||
|
|
||||||
|
def test_second_disable_failure_restores_both_and_keeps_failure_detail(self):
|
||||||
|
def write(entry, disabled):
|
||||||
|
if disabled and entry['name'] == 'usb2-port2': raise OSError('failed second write')
|
||||||
|
self.usb.write.side_effect = write
|
||||||
|
result = self.run_recovery()
|
||||||
|
self.assertIn('failed second write', result[0]['reason'])
|
||||||
|
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||||
|
self.assertFalse((self.state/'pending.json').exists())
|
||||||
|
|
||||||
|
def test_termination_during_off_period_restores_ports(self):
|
||||||
|
def interrupted(_): raise InterruptedError('stop')
|
||||||
|
result = recovery.recover(self.usb, self.state, 'boot', ['usb1-port2'], lambda: self.clock, interrupted)
|
||||||
|
self.assertIn('stop', result[0]['reason'])
|
||||||
|
self.assertFalse((self.state/'pending.json').exists())
|
||||||
|
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||||
|
|
||||||
|
def test_restore_failure_preserves_pending_and_stops_other_ports(self):
|
||||||
|
def write(_, disabled):
|
||||||
|
if not disabled: raise OSError('restore failed')
|
||||||
|
self.usb.write.side_effect = write
|
||||||
|
result = self.run_recovery(['usb1-port2', 'usb1-port3'])
|
||||||
|
self.assertEqual(result[0]['state'], 'restore_failed')
|
||||||
|
self.usb.plan.assert_called_once()
|
||||||
|
self.assertTrue((self.state/'pending.json').exists())
|
||||||
|
self.usb.write.side_effect = None
|
||||||
|
recovery.restore(self.usb, self.state, 'boot')
|
||||||
|
self.assertFalse((self.state/'pending.json').exists())
|
||||||
|
|
||||||
|
def test_slow_planning_cannot_start_a_late_reset(self):
|
||||||
|
def plan(_): self.clock = 179; return self.entries
|
||||||
|
self.usb.plan.side_effect = plan
|
||||||
|
result = self.run_recovery()
|
||||||
|
self.assertIn('deadline', result[0]['reason'])
|
||||||
|
self.usb.write.assert_not_called()
|
||||||
|
|
||||||
|
def test_failed_enumeration_has_no_repeated_power_loop(self):
|
||||||
|
self.usb.outcome.return_value = None
|
||||||
|
result = self.run_recovery()
|
||||||
|
self.assertEqual(result[0]['state'], 'retried')
|
||||||
|
self.assertEqual(self.clock, 39)
|
||||||
|
self.assertEqual(self.usb.write.call_count, 4)
|
||||||
|
|
||||||
|
def test_previous_boot_pending_state_cannot_address_current_ports(self):
|
||||||
|
recovery.atomic(self.state/'pending.json', {'boot_id': 'old', 'ports': self.entries})
|
||||||
|
with self.assertRaises(OSError): recovery.restore(self.usb, self.state, 'new')
|
||||||
|
self.usb.write.assert_not_called()
|
||||||
|
|
||||||
|
def test_boot_wait_occurs_before_journal_snapshot_and_second_run_preserves_report(self):
|
||||||
|
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
|
||||||
|
self.clock = 5
|
||||||
|
def journal():
|
||||||
|
self.assertGreaterEqual(self.clock, recovery.MIN_AGE)
|
||||||
|
return []
|
||||||
|
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
|
||||||
|
patch.object(recovery, 'state_directory'), patch.object(recovery, 'enabled'), \
|
||||||
|
patch.object(recovery.os, 'geteuid', return_value=0), patch.object(recovery.sys, 'argv', ['helper']), \
|
||||||
|
patch.object(recovery.time, 'monotonic', side_effect=lambda: self.clock), \
|
||||||
|
patch.object(recovery.time, 'sleep', side_effect=self.sleep), \
|
||||||
|
patch.object(recovery, 'journal', side_effect=journal) as read, patch('builtins.print'):
|
||||||
|
self.assertEqual(recovery.main(), 0)
|
||||||
|
first = (self.state/'result.json').read_bytes()
|
||||||
|
self.assertEqual(recovery.main(), 0)
|
||||||
|
self.assertEqual((self.state/'result.json').read_bytes(), first)
|
||||||
|
read.assert_called_once()
|
||||||
|
|
||||||
|
def test_late_service_start_cannot_read_or_reset_ports(self):
|
||||||
|
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
|
||||||
|
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
|
||||||
|
patch.object(recovery, 'state_directory'), patch.object(recovery.os, 'geteuid', return_value=0), \
|
||||||
|
patch.object(recovery.sys, 'argv', ['helper']), patch.object(recovery.time, 'monotonic', return_value=200), \
|
||||||
|
patch.object(recovery, 'journal') as read, patch.object(recovery, 'recover') as reset, patch('builtins.print'):
|
||||||
|
self.assertEqual(recovery.main(), 0)
|
||||||
|
read.assert_not_called(); reset.assert_not_called()
|
||||||
|
self.assertEqual(json.loads((self.state/'result.json').read_text())['reason'], 'Outside startup window')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""One bounded boot-time retry of failed USB enumeration; no device commands.
|
||||||
|
|
||||||
|
Only root's fixed systemd job may apply. No request supplies a path or command.
|
||||||
|
Healthy ports (including USB3 companions) and non-individual-power hubs are
|
||||||
|
excluded. The application still identifies controllers by firmware UUID.
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
STATE = Path('/run/mission-core-usb-startup')
|
||||||
|
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
|
||||||
|
POLICY = Path('/etc/mission-core-node/usb-startup.json')
|
||||||
|
EXPECTED_POLICY = {'schema': 'missioncore.node.usb-startup-policy/v1', 'enabled': True,
|
||||||
|
'mode': 'terminal-enumeration-failures'}
|
||||||
|
BOOT_WINDOW = 180
|
||||||
|
FAILURE_WINDOW = 60
|
||||||
|
MIN_AGE = 30
|
||||||
|
BUDGET = 90
|
||||||
|
PORT = re.compile(r'(usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port([1-9][0-9]*)')
|
||||||
|
FAILURE = re.compile(r'usb ((?:usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port[1-9][0-9]*): unable to enumerate USB device')
|
||||||
|
|
||||||
|
|
||||||
|
def child_name(port):
|
||||||
|
match = PORT.fullmatch(port)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('Invalid USB port')
|
||||||
|
hub, number = match.groups()
|
||||||
|
return hub[3:] + '-' + number if hub.startswith('usb') else hub + '.' + number
|
||||||
|
|
||||||
|
|
||||||
|
def failed_ports(records):
|
||||||
|
"""Ignore failures superseded by a later connection/disconnection event."""
|
||||||
|
failures, changes = {}, {}
|
||||||
|
for entry in sorted(records, key=lambda r: int(r.get('__MONOTONIC_TIMESTAMP', 0))):
|
||||||
|
if entry.get('_TRANSPORT') != 'kernel':
|
||||||
|
continue
|
||||||
|
stamp = int(entry.get('__MONOTONIC_TIMESTAMP', 0)) / 1000000
|
||||||
|
message = entry.get('MESSAGE', '')
|
||||||
|
if not isinstance(message, str):
|
||||||
|
continue
|
||||||
|
match = FAILURE.fullmatch(message)
|
||||||
|
if match and 0 < stamp <= FAILURE_WINDOW:
|
||||||
|
failures[match[1]] = stamp
|
||||||
|
match = re.match(r'usb ([0-9]+-[0-9]+(?:\.[0-9]+)*): (?:new |New USB device found|USB disconnect)', message)
|
||||||
|
if match:
|
||||||
|
changes[match[1]] = stamp
|
||||||
|
return [p for p, stamp in sorted(failures.items()) if changes.get(child_name(p), 0) <= stamp]
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
|
||||||
|
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
|
||||||
|
except subprocess.SubprocessError as error:
|
||||||
|
raise OSError('USB inspection command timed out or failed') from error
|
||||||
|
if result.returncode or len(result.stdout) > 2 * 1024**2:
|
||||||
|
raise OSError('USB inspection command failed')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def journal():
|
||||||
|
raw = run(['/usr/bin/journalctl', '-k', '-b', '0', '--no-pager', '-o', 'json', '-n', '2000',
|
||||||
|
'--grep=unable to enumerate USB device|new .* USB device|New USB device found|USB disconnect'])
|
||||||
|
return [json.loads(line) for line in raw.splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def atomic(path, value):
|
||||||
|
temporary = path.with_suffix('.tmp')
|
||||||
|
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
||||||
|
with os.fdopen(fd, 'w') as stream:
|
||||||
|
json.dump(value, stream, sort_keys=True)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
class USB:
|
||||||
|
def __init__(self, root=Path('/sys/bus/usb/devices'), physical=Path('/sys/devices')):
|
||||||
|
self.root, self.physical = root, physical.resolve()
|
||||||
|
|
||||||
|
def resolve(self, name):
|
||||||
|
match = PORT.fullmatch(name)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('Invalid port name')
|
||||||
|
hub = match[1]
|
||||||
|
interface = hub[3:] + '-0' if hub.startswith('usb') else hub
|
||||||
|
paths = list(self.root.glob(interface + ':*/' + name))
|
||||||
|
if len(paths) != 1:
|
||||||
|
raise OSError('USB port topology unavailable')
|
||||||
|
path = paths[0].resolve(strict=True)
|
||||||
|
if not path.is_relative_to(self.physical) or path.name != name:
|
||||||
|
raise OSError('USB port outside sysfs devices')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def generation(self, name):
|
||||||
|
hub = self.root / PORT.fullmatch(name)[1]
|
||||||
|
if (hub / 'bDeviceClass').read_text().strip() != '09':
|
||||||
|
raise OSError('Parent is not a USB hub')
|
||||||
|
return {'bus': int((hub / 'busnum').read_text()), 'address': int((hub / 'devnum').read_text()),
|
||||||
|
'path': str(hub.resolve(strict=True)), 'inode': hub.stat().st_ino}
|
||||||
|
|
||||||
|
def empty(self, name):
|
||||||
|
p = self.resolve(name)
|
||||||
|
return (not os.path.lexists(p / 'device') and
|
||||||
|
(p / 'state').read_text().strip() == 'not attached' and
|
||||||
|
(p / 'disable').read_text().strip() == '0' and
|
||||||
|
(p / 'over_current_count').read_text().strip() == '0' and
|
||||||
|
(p / 'connect_type').read_text().strip() in ('hotplug', 'unknown'))
|
||||||
|
|
||||||
|
def companions(self, name):
|
||||||
|
p = self.resolve(name)
|
||||||
|
names = [name]
|
||||||
|
if os.path.lexists(p / 'peer'):
|
||||||
|
peer = (p / 'peer').resolve(strict=True)
|
||||||
|
if not peer.is_relative_to(self.physical) or self.resolve(peer.name) != peer:
|
||||||
|
raise OSError('USB companion topology changed')
|
||||||
|
if (peer / 'peer').resolve(strict=True) != p:
|
||||||
|
raise OSError('USB companion is not reciprocal')
|
||||||
|
names.append(peer.name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def individual_power(self, name):
|
||||||
|
before = self.generation(name)
|
||||||
|
raw = run(['/usr/bin/lsusb', '-v', '-s', f"{before['bus']:03}:{before['address']:03}"])
|
||||||
|
characteristics = re.findall(r'^\s*wHubCharacteristic[s]?\s+0x([0-9a-fA-F]+)\s*$', raw, re.M)
|
||||||
|
return (before == self.generation(name) and len(characteristics) == 1 and
|
||||||
|
int(characteristics[0], 16) & 3 == 1)
|
||||||
|
|
||||||
|
def plan(self, name):
|
||||||
|
names = self.companions(name)
|
||||||
|
entries = [{'name': n, 'generation': self.generation(n)} for n in names]
|
||||||
|
# Test every companion before querying hub descriptors or opening any port.
|
||||||
|
if not all(self.empty(n) for n in names):
|
||||||
|
raise OSError('Port or USB companion already attached, disabled, internal or over-current')
|
||||||
|
if not all(self.individual_power(n) for n in names):
|
||||||
|
raise OSError('Individual USB port power switching is not confirmed')
|
||||||
|
if any(self.generation(e['name']) != e['generation'] for e in entries):
|
||||||
|
raise OSError('USB hub changed during companion inspection')
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def write(self, entry, disabled):
|
||||||
|
name = entry['name']
|
||||||
|
if self.generation(name) != entry['generation']:
|
||||||
|
raise OSError('USB hub generation changed')
|
||||||
|
if disabled and not self.empty(name):
|
||||||
|
raise OSError('USB port became occupied')
|
||||||
|
p = self.resolve(name) / 'disable'
|
||||||
|
# Pending state is durable before the first write. An interrupted or
|
||||||
|
# rejected first write must not cause a redundant enable on a port
|
||||||
|
# which has since successfully enumerated.
|
||||||
|
if not disabled and p.read_text().strip() == '0':
|
||||||
|
return
|
||||||
|
fd = os.open(p, os.O_WRONLY | os.O_NOFOLLOW)
|
||||||
|
try:
|
||||||
|
if self.generation(name) != entry['generation'] or (disabled and not self.empty(name)):
|
||||||
|
raise OSError('USB topology changed before write')
|
||||||
|
if os.write(fd, b'1\n' if disabled else b'0\n') != 2:
|
||||||
|
raise OSError('Incomplete USB port write')
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
def outcome(self, name):
|
||||||
|
p = self.resolve(name)
|
||||||
|
if not (p / 'device').exists():
|
||||||
|
return None
|
||||||
|
child = (p / 'device').resolve(strict=True)
|
||||||
|
if not child.is_relative_to(self.physical):
|
||||||
|
raise OSError('Unexpected USB child')
|
||||||
|
return {key: (child / key).read_text().strip() for key in ('idVendor', 'idProduct', 'product')}
|
||||||
|
|
||||||
|
|
||||||
|
def restore(usb, state, boot):
|
||||||
|
p = state / 'pending.json'
|
||||||
|
if not p.exists():
|
||||||
|
return
|
||||||
|
pending = json.loads(p.read_text())
|
||||||
|
if pending['boot_id'] != boot:
|
||||||
|
raise OSError('Pending recovery belongs to another boot')
|
||||||
|
errors = []
|
||||||
|
for entry in reversed(pending['ports']):
|
||||||
|
try:
|
||||||
|
usb.write(entry, False)
|
||||||
|
except OSError as error:
|
||||||
|
errors.append(str(error))
|
||||||
|
if errors:
|
||||||
|
raise OSError('; '.join(errors))
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def recover(usb, state, boot, candidates, now=time.monotonic, sleep=time.sleep):
|
||||||
|
deadline = min(now() + BUDGET, BOOT_WINDOW)
|
||||||
|
results, processed = [], set()
|
||||||
|
for name in candidates[:32]:
|
||||||
|
if name in processed:
|
||||||
|
continue
|
||||||
|
item = {'port': name, 'state': 'skipped'}
|
||||||
|
results.append(item)
|
||||||
|
if now() + 12 >= deadline:
|
||||||
|
item['reason'] = 'Boot recovery deadline reached'
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
entries = usb.plan(name)
|
||||||
|
if now() + 12 >= deadline:
|
||||||
|
raise OSError('Boot recovery deadline reached during planning')
|
||||||
|
# Recheck all companions after descriptor reads and before any write.
|
||||||
|
if not all(usb.empty(e['name']) and usb.generation(e['name']) == e['generation'] for e in entries):
|
||||||
|
raise OSError('USB port changed during planning')
|
||||||
|
processed.update(e['name'] for e in entries)
|
||||||
|
item['ports'] = [e['name'] for e in entries]
|
||||||
|
atomic(state / 'pending.json', {'boot_id': boot, 'ports': entries})
|
||||||
|
try:
|
||||||
|
for entry in entries:
|
||||||
|
usb.write(entry, True)
|
||||||
|
sleep(1)
|
||||||
|
finally:
|
||||||
|
restore(usb, state, boot)
|
||||||
|
item['state'] = 'retried'
|
||||||
|
until = min(now() + 8, deadline)
|
||||||
|
while now() < until:
|
||||||
|
result = usb.outcome(name)
|
||||||
|
if result:
|
||||||
|
item.update(state='enumerated', descriptor=result)
|
||||||
|
break
|
||||||
|
sleep(0.25)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
item['reason'] = str(error)
|
||||||
|
if (state / 'pending.json').exists():
|
||||||
|
item['state'] = 'restore_failed'
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def state_directory():
|
||||||
|
STATE.mkdir(mode=0o755, exist_ok=True)
|
||||||
|
info = STATE.lstat()
|
||||||
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||||
|
raise OSError('Untrusted recovery state directory')
|
||||||
|
|
||||||
|
|
||||||
|
def enabled():
|
||||||
|
info = POLICY.lstat()
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 1024:
|
||||||
|
raise OSError('Untrusted startup recovery policy')
|
||||||
|
if json.loads(POLICY.read_text()) != EXPECTED_POLICY:
|
||||||
|
raise OSError('Startup recovery policy is not supported')
|
||||||
|
|
||||||
|
|
||||||
|
def inspect(usb):
|
||||||
|
hubs = []
|
||||||
|
deadline = time.monotonic() + 60
|
||||||
|
for p in sorted(usb.root.glob('*')):
|
||||||
|
if time.monotonic() >= deadline or len(hubs) >= 16:
|
||||||
|
break
|
||||||
|
if not re.fullmatch(r'usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*', p.name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if (p / 'bDeviceClass').read_text().strip() != '09':
|
||||||
|
continue
|
||||||
|
hubs.append({'hub': p.name, 'individual_power': usb.individual_power(p.name + '-port1')})
|
||||||
|
except OSError as error:
|
||||||
|
hubs.append({'hub': p.name, 'error': str(error)})
|
||||||
|
return hubs
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.geteuid() != 0 or sys.argv[1:] not in ([], ['--inspect'], ['--restore']):
|
||||||
|
raise ValueError('Fixed root-owned startup job only')
|
||||||
|
mode = sys.argv[1:] or ['apply']
|
||||||
|
state_directory()
|
||||||
|
lock = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
boot = BOOT_ID.read_text().strip()
|
||||||
|
usb = USB()
|
||||||
|
if mode == ['--restore']:
|
||||||
|
try:
|
||||||
|
restore(usb, STATE, boot)
|
||||||
|
finally:
|
||||||
|
os.close(lock)
|
||||||
|
return 0
|
||||||
|
if mode == ['apply'] and (STATE / 'attempted').exists():
|
||||||
|
# Preserve the first result, including restore failures, across a later
|
||||||
|
# daemon/package restart. Recovery is never retriggered by refresh.
|
||||||
|
try:
|
||||||
|
restore(usb, STATE, boot)
|
||||||
|
finally:
|
||||||
|
os.close(lock)
|
||||||
|
return 0
|
||||||
|
report = {'schema': 'missioncore.node.usb-startup-recovery/v1', 'boot_id': boot,
|
||||||
|
'observed_at_unix': time.time(), 'boot_seconds': time.monotonic(),
|
||||||
|
'mode': mode[0], 'state': 'skipped', 'results': []}
|
||||||
|
destination = STATE / ('inspection.json' if mode == ['--inspect'] else 'result.json')
|
||||||
|
try:
|
||||||
|
if mode == ['--inspect']:
|
||||||
|
report['hubs'] = inspect(usb)
|
||||||
|
report['state'] = 'inspected'
|
||||||
|
elif time.monotonic() >= BOOT_WINDOW:
|
||||||
|
report['reason'] = 'Outside startup window'
|
||||||
|
else:
|
||||||
|
enabled()
|
||||||
|
atomic(STATE / 'attempted', {'boot_id': boot})
|
||||||
|
restore(usb, STATE, boot)
|
||||||
|
# udev-settle can return before the hub driver's delayed retries
|
||||||
|
# give up. Wait before taking the first snapshot, including when
|
||||||
|
# the journal currently contains no terminal failure yet.
|
||||||
|
time.sleep(max(0, MIN_AGE - time.monotonic()))
|
||||||
|
candidates = failed_ports(journal())
|
||||||
|
if candidates:
|
||||||
|
report['results'] = recover(usb, STATE, boot, candidates)
|
||||||
|
report['state'] = 'complete'
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
report.update(state='error', reason=str(error))
|
||||||
|
finally:
|
||||||
|
atomic(destination, report)
|
||||||
|
destination.chmod(0o644)
|
||||||
|
os.close(lock)
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return 1 if report['state'] == 'error' or any(r['state'] == 'restore_failed' for r in report['results']) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
def interrupted(*_):
|
||||||
|
raise InterruptedError('Startup recovery interrupted')
|
||||||
|
signal.signal(signal.SIGTERM, interrupted)
|
||||||
|
sys.exit(main())
|
||||||
@@ -4,7 +4,10 @@ import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/p
|
|||||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
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 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))};
|
const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
export function NodeSensors({value,openSettings}:{value:Status;openSettings:()=>void}){return <SensorWorkspace board={{layout,description:value.name,computer:<SettingsCard title={value.name} description={value.host.hostname}><dl className="node-facts"><div><dt>Бортовой компьютер</dt><dd>{value.node_id}</dd></div><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{value.host.cpus}</dd></div><div><dt>Память</dt><dd>{value.host.memory_kib?`${(value.host.memory_kib/1048576).toFixed(1)} ГиБ`:"Нет сведений"}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl><Button onClick={openSettings}><Icon name="settings"/>Подготовка среды</Button></SettingsCard>}} contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function App() {
|
|||||||
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
|
function 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); }
|
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} />
|
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 === "network" ? <NetworkView value={value} />
|
||||||
: workspace.activeView === "usb" ? <USBView value={value} />
|
: workspace.activeView === "usb" ? <USBView value={value} />
|
||||||
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
|
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -57,16 +57,43 @@
|
|||||||
и недостаточное движение при измерении. Номер физического сломанного контакта
|
и недостаточное движение при измерении. Номер физического сломанного контакта
|
||||||
не определяется по таблице без проверки распиновки и проводки.
|
не определяется по таблице без проверки распиновки и проводки.
|
||||||
|
|
||||||
|
Для подготовки к измерению в Node 0.8.39-1 / plugin 0.6.6 оператор отдельно
|
||||||
|
подтверждает неподвижность всех моторов. Бессенсорная прошивка может сообщать
|
||||||
|
ненулевые ERPM и изменение тахометра при физической остановке: оба значения
|
||||||
|
вычисляются из оценки положения ротора. Приложение сохраняет эти показания,
|
||||||
|
а перед измерением повторно проверяет нейтраль приёмника, отсутствие PWM,
|
||||||
|
малый ток и отсутствие ошибок. Это исключение действует только для
|
||||||
|
наблюдаемого измерения Холлов в бессенсорном режиме, не для управления
|
||||||
|
движением. Установка и реальные результаты новой версии фиксируются отдельно
|
||||||
|
в журнале испытаний.
|
||||||
|
|
||||||
## Назначение и проверка вращения
|
## Назначение и проверка вращения
|
||||||
|
|
||||||
Назначение «левый/правый» связывает постоянный UUID VESC с местом мотора на
|
Назначение «левый/правый» связывает постоянный UUID VESC с местом мотора на
|
||||||
аппарате. Оно нужно для адресного и общего управления, но не влияет на
|
аппарате. Оно нужно для адресного и общего управления, но не влияет на
|
||||||
измеряемое сопротивление или параметр потерь. После смены USB-порта назначение
|
измеряемое сопротивление или параметр потерь. После смены USB-порта назначение
|
||||||
сохраняется. Общий список назначений сейчас отображается в каждой карточке
|
сохраняется. Общий список назначений находится в разделе «Настройки борта»;
|
||||||
VESC; это обзор профиля аппарата, а не перечень моторов внутри одного VESC.
|
это профиль аппарата, а не перечень моторов внутри одного VESC.
|
||||||
|
|
||||||
«Проверка вращения» запускается отдельно от калибровки. Скорость задаётся
|
«Проверка вращения» запускается отдельно от калибровки. Скорость задаётся
|
||||||
в ERPM, ток задаёт верхний предел, длительность считается после разгона
|
в ERPM, ток задаёт верхний предел, длительность считается после разгона
|
||||||
и удержания скорости. Выбор всех моторов профиля запускает совместную
|
и удержания скорости. Выбор всех моторов профиля запускает совместную
|
||||||
проверку; для 1×1 это два мотора. Успешная проверка на вывешенном приводе
|
проверку; для 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.
|
||||||
|
|||||||
@@ -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-*.
|
||||||
@@ -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).
|
||||||
@@ -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; переключатель не объявляется действующим до реализации и проверки
|
||||||
|
реального пути команд.
|
||||||
@@ -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`, раздел «Нейтраль после возврата питания».
|
||||||
|
Профиль одного стика и постоянный перехват этим чтением не приняты.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
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):
|
def private(path):
|
||||||
path.mkdir(mode=0o700, exist_ok=True)
|
path.mkdir(mode=0o700, exist_ok=True)
|
||||||
info = path.lstat()
|
info = path.lstat()
|
||||||
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||||
raise RuntimeError("Release directory is not private and owned")
|
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():
|
def main():
|
||||||
@@ -74,16 +108,7 @@ def main():
|
|||||||
raise ValueError("Release payload changed")
|
raise ValueError("Release payload changed")
|
||||||
files["release.json"] = raw
|
files["release.json"] = raw
|
||||||
for name, data in files.items():
|
for name, data in files.items():
|
||||||
path = folder / name
|
stage_file(folder / name, data, 0o700 if name == "install" else 0o600)
|
||||||
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)
|
|
||||||
print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True)
|
print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True)
|
||||||
if sys.argv[1] == "--plan":
|
if sys.argv[1] == "--plan":
|
||||||
result = subprocess.run(
|
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()
|
||||||
@@ -35,8 +35,16 @@ public:
|
|||||||
bool queryRunning = false;
|
bool queryRunning = false;
|
||||||
bool allowHardware = false;
|
bool allowHardware = false;
|
||||||
QByteArray lastMotor, lastApplication, lastHall;
|
QByteArray lastMotor, lastApplication, lastHall;
|
||||||
|
QTimer outputWatchdog;
|
||||||
|
|
||||||
Engine() {
|
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");
|
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
|
||||||
packet = vesc.findChild<Packet *>();
|
packet = vesc.findChild<Packet *>();
|
||||||
require(packet, "Upstream packet transport missing");
|
require(packet, "Upstream packet transport missing");
|
||||||
@@ -249,15 +257,19 @@ public:
|
|||||||
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
||||||
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
|
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 == "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 {}; }
|
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
|
||||||
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
||||||
if (method == "current") {
|
if (method == "current") {
|
||||||
|
require(outputWatchdog.isActive(), "Output lease expired");
|
||||||
auto current = number(request, "current_a", 0, 30);
|
auto current = number(request, "current_a", 0, 30);
|
||||||
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
||||||
vesc.commands()->setCurrent(current); flush(); return {};
|
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") {
|
if (method == "limits") {
|
||||||
auto p = request.value("parameters").toObject();
|
auto p = request.value("parameters").toObject();
|
||||||
MCCONF_TEMP conf;
|
MCCONF_TEMP conf;
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ int main(int argc, char **argv) {
|
|||||||
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
|
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
|
||||||
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
|
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
|
||||||
{"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}});
|
{"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("archive_sha256", sha256(raw));
|
||||||
result.insert("ok", true);
|
result.insert("ok", true);
|
||||||
} catch (const std::exception &error) {
|
} catch (const std::exception &error) {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
|
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
|
||||||
"os": "ubuntu-24.04-amd64",
|
"os": "ubuntu-24.04-amd64",
|
||||||
"file": "mission-core-vesc-native-runtime.tar.gz",
|
"file": "mission-core-vesc-native-runtime.tar.gz",
|
||||||
"bytes": 50352726,
|
"bytes": 50354000,
|
||||||
"sha256": "cc78c273e025f0738ed79f94d4bc6a70e504f2e7204b1ef694119e6a230b913a",
|
"sha256": "4473819387682089a814a6c558065e3e6a4c2fe67266c0ed82ad49cea1444aad",
|
||||||
"engine_sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
"engine_sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
|
||||||
"files": {
|
"files": {
|
||||||
"bin/mission-core-vesc-engine": {
|
"bin/mission-core-vesc-engine": {
|
||||||
"sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
"sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
|
||||||
"bytes": 24801544
|
"bytes": 24801840
|
||||||
},
|
},
|
||||||
"lib/libGL.so.1": {
|
"lib/libGL.so.1": {
|
||||||
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
|
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
|
||||||
@@ -257,8 +257,8 @@
|
|||||||
"bytes": 4724
|
"bytes": 4724
|
||||||
},
|
},
|
||||||
"licenses/engine_main.cpp": {
|
"licenses/engine_main.cpp": {
|
||||||
"sha256": "e789004d14185140bda21c10a9d7d8f0b48174a9b5053ec1d70755842b1888de",
|
"sha256": "e4b32dc5c9def71208d8daad830d5c47852f39f85a321713abc45dc15173b4fc",
|
||||||
"bytes": 20913
|
"bytes": 21539
|
||||||
},
|
},
|
||||||
"licenses/gir1.2-glib-2.0.copyright": {
|
"licenses/gir1.2-glib-2.0.copyright": {
|
||||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ def run(args):
|
|||||||
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
||||||
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
|
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")
|
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,
|
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
|
||||||
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
"motor_parameter_count": len(native["motor"]["parameters"]),
|
"motor_parameter_count": len(native["motor"]["parameters"]),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def prepare():
|
|||||||
lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
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)
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller",
|
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": []}
|
"monotonic_started": time.monotonic(), "state": "running", "steps": []}
|
||||||
|
|
||||||
def publish():
|
def publish():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Onboard VESC discovery, archive and bounded motor identification service."""
|
"""Onboard VESC discovery, archive and bounded motor identification service."""
|
||||||
|
|
||||||
VERSION = "0.6.3"
|
VERSION = "0.7.4"
|
||||||
MODEL = "vesc.controller"
|
MODEL = "vesc.controller"
|
||||||
SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ class DriveProfile:
|
|||||||
raise ValueError("Профиль привода изменился. Обновите карточку.")
|
raise ValueError("Профиль привода изменился. Обновите карточку.")
|
||||||
bindings = dict(current["bindings"])
|
bindings = dict(current["bindings"])
|
||||||
layout = current["layout"]
|
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"]
|
layout, slot = params["layout"], params["slot"]
|
||||||
if set(bindings) - set(LAYOUTS[layout]):
|
if set(bindings) - set(LAYOUTS[layout]):
|
||||||
raise ValueError("Сначала снимите назначения задних моторов.")
|
raise ValueError("Сначала снимите назначения задних моторов.")
|
||||||
@@ -37,10 +41,13 @@ class DriveProfile:
|
|||||||
|
|
||||||
|
|
||||||
def validate(action, params):
|
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:
|
if set(params) != keys or type(params["revision"]) is not int or params["revision"] < 0:
|
||||||
raise ValueError("Invalid drive profile revision")
|
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"]]):
|
if params["layout"] not in LAYOUTS or params["slot"] not in ("", *LAYOUTS[params["layout"]]):
|
||||||
raise ValueError("Invalid drive layout or slot")
|
raise ValueError("Invalid drive layout or slot")
|
||||||
elif params["slot"] not in LAYOUTS["2x2"]:
|
elif params["slot"] not in LAYOUTS["2x2"]:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from .protocol import firmware, values
|
from .protocol import firmware, values
|
||||||
|
from .configuration import decode
|
||||||
|
from .receiver import neutral_band
|
||||||
|
|
||||||
|
|
||||||
def fresh_values(owner, devices):
|
def fresh_values(owner, devices):
|
||||||
@@ -63,6 +65,8 @@ def reconcile(owner, devices):
|
|||||||
actual = device.link.query(code)
|
actual = device.link.query(code)
|
||||||
if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True):
|
if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True):
|
||||||
raise ValueError("Post-calibration configuration changed")
|
raise ValueError("Post-calibration configuration changed")
|
||||||
|
if kind == "application":
|
||||||
|
owner.receiver_bands[device.id] = neutral_band(decode(actual, kind))
|
||||||
owner.neutral(devices)
|
owner.neutral(devices)
|
||||||
after = fresh_values(owner, 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()):
|
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()):
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def batch(pool, devices, function):
|
|||||||
return result
|
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
|
from .motor_test import Rejected, check_values
|
||||||
params = command["parameters"]
|
params = command["parameters"]
|
||||||
duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a"))
|
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
|
value = values(device.link.query(4, timeout=.06)) if device.id in ids else None
|
||||||
return level, value
|
return level, value
|
||||||
observed = batch(pool, devices, read)
|
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")
|
owner.state("rc")
|
||||||
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
|
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
|
||||||
now = owner.monotonic()
|
now = owner.monotonic()
|
||||||
@@ -129,7 +129,7 @@ def run(owner, command, devices, moving, originals, backups, unchanged):
|
|||||||
elif not owner.latched: owner.state("ready")
|
elif not owner.latched: owner.state("ready")
|
||||||
owner.active, owner.mode = False, None
|
owner.active, owner.mode = False, None
|
||||||
return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed",
|
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,
|
"duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure,
|
||||||
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
|
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
|
||||||
"limits_restored": restored, "after": after, "backups": backups}
|
"limits_restored": restored, "after": after, "backups": backups}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def parse_result(raw):
|
|||||||
"observed_states": observed, "valid_six_states": raw[9] == 0 and len(observed) == 6}
|
"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")
|
motor = decode(original, "motor")
|
||||||
if motor["m_sensor_port_mode"] != 0:
|
if motor["m_sensor_port_mode"] != 0:
|
||||||
from .motor_test import Rejected
|
from .motor_test import Rejected
|
||||||
@@ -46,7 +46,7 @@ def measure(owner, devices, target, original, backups, unchanged):
|
|||||||
unchanged()
|
unchanged()
|
||||||
for device in devices:
|
for device in devices:
|
||||||
incoming = ppm(device.link.query(31, timeout=0.06))
|
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")
|
owner.state("rc")
|
||||||
if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle")
|
if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle")
|
||||||
sample = values(target.link.query(4, timeout=0.06))
|
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.state("rc")
|
||||||
owner.active, owner.mode = False, None
|
owner.active, owner.mode = False, None
|
||||||
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall",
|
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall",
|
||||||
|
"preflight": preflight,
|
||||||
"current_a": 5, "elapsed_s": owner.monotonic() - started,
|
"current_a": 5, "elapsed_s": owner.monotonic() - started,
|
||||||
"completed": completed, "measurement": result, "issues": issues,
|
"completed": completed, "measurement": result, "issues": issues,
|
||||||
"configuration_restored": restored, "configuration_written": False,
|
"configuration_restored": restored, "configuration_written": False,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Bounded idle transport measurement through the installed native owners.
|
"""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;
|
The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget;
|
||||||
it never relaxes the motor-control deadline or authorizes powered operation.
|
it never relaxes the motor-control deadline or authorizes powered operation.
|
||||||
"""
|
"""
|
||||||
@@ -11,6 +11,8 @@ import math
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .protocol import ppm, values
|
from .protocol import ppm, values
|
||||||
|
from .configuration import decode
|
||||||
|
from .receiver import active, neutral_band
|
||||||
|
|
||||||
|
|
||||||
def summary(samples):
|
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"]:
|
if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]:
|
||||||
raise ValueError("Controller sessions changed")
|
raise ValueError("Controller sessions changed")
|
||||||
samples = {d.id: [] for d in devices}
|
samples = {d.id: [] for d in devices}
|
||||||
|
bands = {}
|
||||||
failure = None
|
failure = None
|
||||||
stop_reason = "complete"
|
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):
|
def read(device):
|
||||||
for code in (31, 4):
|
for code in (31, 4):
|
||||||
before = monotonic()
|
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)}
|
"native_rpc": getattr(device.link, "last_rpc", None)}
|
||||||
if code == 31:
|
if code == 31:
|
||||||
sample["ppm_level"] = reading["level"]
|
sample["ppm_level"] = reading["level"]
|
||||||
idle = abs(reading["level"]) <= .02
|
idle = not active(reading["level"], bands[device.id])
|
||||||
else:
|
else:
|
||||||
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
|
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
|
||||||
duty=reading["duty"], fault_code=reading["fault_code"])
|
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)
|
samples[device.id].append(sample)
|
||||||
if not idle: return {"device_id": device.id, "reason": "not_idle"}
|
if not idle: return {"device_id": device.id, "reason": "not_idle"}
|
||||||
except (OSError, ValueError, TimeoutError) as error:
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
|
return failed(device, code, before, error)
|
||||||
"elapsed_ms": (monotonic()-before)*1000,
|
|
||||||
"native_rpc": getattr(error, "native_rpc", None),
|
|
||||||
"native_history": getattr(error, "native_history", [])}
|
|
||||||
return None
|
return None
|
||||||
with ExitStack() as locks:
|
with ExitStack() as locks:
|
||||||
for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock)
|
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")
|
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
|
# Same cadence and per-controller query order as group rotation, with a
|
||||||
# larger read-only deadline to expose latency instead of destroying it.
|
# larger read-only deadline to expose latency instead of destroying it.
|
||||||
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
|
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
|
if stop_reason != "complete": break
|
||||||
cycle = monotonic()
|
cycle = monotonic()
|
||||||
cancelled = service.motor.cancelled_at
|
if reason := interrupted():
|
||||||
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
|
stop_reason = reason; break
|
||||||
stop_reason = "stopped"; break
|
|
||||||
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
|
|
||||||
stop_reason = "deadline"; break
|
|
||||||
futures = [pool.submit(read, d) for d in devices]
|
futures = [pool.submit(read, d) for d in devices]
|
||||||
errors = [error for future in futures if (error := future.result()) is not None]
|
errors = [error for future in futures if (error := future.result()) is not None]
|
||||||
if errors:
|
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,
|
return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started,
|
||||||
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
|
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
|
||||||
"failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(),
|
"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}}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .configuration import decode
|
|||||||
from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS
|
from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS
|
||||||
from .speed_hold import SpeedHold
|
from .speed_hold import SpeedHold
|
||||||
from .temporary_limits import TemporaryLimits
|
from .temporary_limits import TemporaryLimits
|
||||||
|
from .receiver import active as receiver_active, neutral_band
|
||||||
|
|
||||||
|
|
||||||
class Rejected(ValueError):
|
class Rejected(ValueError):
|
||||||
@@ -44,7 +45,7 @@ def check_configuration(identity, motor, app):
|
|||||||
raise Rejected("Нужна проверка токовых ограничений.")
|
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
|
current_limit = max(5, current_a * 1.2 + 2) if moving else 1
|
||||||
speed_limit = TEST_LIMITS["max_erpm"] if moving else 30
|
speed_limit = TEST_LIMITS["max_erpm"] if moving else 30
|
||||||
duty_limit = TEST_LIMITS["max_duty"] if moving else 0.01
|
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"])
|
current_limit = min(current_limit, motor["l_current_max"])
|
||||||
speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"])
|
speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"])
|
||||||
duty_limit = min(duty_limit, motor["l_max_duty"])
|
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 = (
|
bounds = (
|
||||||
("fault_code", 0, 0, "код ошибки VESC", "", 1),
|
("fault_code", 0, 0, "код ошибки VESC", "", 1),
|
||||||
("input_voltage_v", 20, 60, "напряжение питания", "В", 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:
|
for field, low, high, label, unit, scale in bounds:
|
||||||
actual = value[field]
|
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)
|
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:
|
class MotorTest:
|
||||||
@@ -75,6 +95,7 @@ class MotorTest:
|
|||||||
self.cancelled_at = None
|
self.cancelled_at = None
|
||||||
self.active = False
|
self.active = False
|
||||||
self.mode = None
|
self.mode = None
|
||||||
|
self.receiver_bands = {}
|
||||||
self.limits = TemporaryLimits(service, atomic)
|
self.limits = TemporaryLimits(service, atomic)
|
||||||
self.authority = service.root / "motor-authority.json"
|
self.authority = service.root / "motor-authority.json"
|
||||||
# A process restart during a test cannot silently grant another pulse.
|
# A process restart during a test cannot silently grant another pulse.
|
||||||
@@ -96,11 +117,16 @@ class MotorTest:
|
|||||||
def neutral(self, devices):
|
def neutral(self, devices):
|
||||||
for device in devices:
|
for device in devices:
|
||||||
value = ppm(device.link.query(31, timeout=0.06))
|
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")
|
self.state("rc")
|
||||||
raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.")
|
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):
|
if not 1 <= len(devices) <= 128 or len({d.id for d in devices}) != len(devices):
|
||||||
raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.")
|
raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.")
|
||||||
duration = command["parameters"]["duration_s"]
|
duration = command["parameters"]["duration_s"]
|
||||||
@@ -115,13 +141,14 @@ class MotorTest:
|
|||||||
hall_mode = command["action_id"] == "vesc.hall.measure"
|
hall_mode = command["action_id"] == "vesc.hall.measure"
|
||||||
foc_mode = command["action_id"] == "vesc.foc.calibrate"
|
foc_mode = command["action_id"] == "vesc.foc.calibrate"
|
||||||
budget = duration + (20 if speed_mode else 0)
|
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("Управление удерживается за пультом. Верните его явно после нейтрали.")
|
raise Rejected("Управление удерживается за пультом. Верните его явно после нейтрали.")
|
||||||
requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
|
requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
|
||||||
if (datetime.now(timezone.utc) - requested).total_seconds() > 10:
|
if (datetime.now(timezone.utc) - requested).total_seconds() > 10:
|
||||||
raise Rejected("Команда устарела до начала проверки. Повторите запрос.")
|
raise Rejected("Команда устарела до начала проверки. Повторите запрос.")
|
||||||
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||||
def preflight():
|
def preflight():
|
||||||
|
if remote is not None: remote.ensure_live()
|
||||||
if self.stop.is_set(): raise Rejected("Проверка отменена.")
|
if self.stop.is_set(): raise Rejected("Проверка отменена.")
|
||||||
if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5:
|
if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5:
|
||||||
raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.")
|
raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.")
|
||||||
@@ -134,6 +161,8 @@ class MotorTest:
|
|||||||
raise Rejected("Проверка отменена до начала исполнения.")
|
raise Rejected("Проверка отменена до начала исполнения.")
|
||||||
self.stop.clear()
|
self.stop.clear()
|
||||||
with ExitStack() as stack:
|
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):
|
for device in sorted(devices, key=lambda d: d.id):
|
||||||
stack.enter_context(device.lock)
|
stack.enter_context(device.lock)
|
||||||
recovered = None
|
recovered = None
|
||||||
@@ -144,6 +173,8 @@ class MotorTest:
|
|||||||
except (OSError, ValueError, TimeoutError, KeyError) as error:
|
except (OSError, ValueError, TimeoutError, KeyError) as error:
|
||||||
raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error
|
raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error
|
||||||
identities, applications, backups, originals = {}, {}, [], {}
|
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
|
target_motor, target_raw = None, None
|
||||||
for device in devices:
|
for device in devices:
|
||||||
preflight()
|
preflight()
|
||||||
@@ -159,7 +190,9 @@ class MotorTest:
|
|||||||
configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))}
|
configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))}
|
||||||
originals[device.id] = configs
|
originals[device.id] = configs
|
||||||
motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application")
|
motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application")
|
||||||
|
motors[device.id] = motor
|
||||||
check_configuration(identity, motor, app)
|
check_configuration(identity, motor, app)
|
||||||
|
self.receiver_bands[device.id] = neutral_band(app)
|
||||||
if device in moving:
|
if device in moving:
|
||||||
if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]):
|
if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]):
|
||||||
raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.")
|
raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.")
|
||||||
@@ -169,11 +202,13 @@ class MotorTest:
|
|||||||
target_raw = configs["motor"]
|
target_raw = configs["motor"]
|
||||||
if foc_mode and any(abs(motor[key]) <= 0.001 for key in ("l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm")):
|
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.")
|
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("Заданная скорость превышает настроенный диапазон контроллера.")
|
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.")
|
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
|
identities[device.id], applications[device.id] = identity, app
|
||||||
identifier = "op_" + uuid.uuid4().hex
|
identifier = "op_" + uuid.uuid4().hex
|
||||||
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
||||||
@@ -205,13 +240,22 @@ class MotorTest:
|
|||||||
preflight()
|
preflight()
|
||||||
unchanged()
|
unchanged()
|
||||||
self.neutral(devices)
|
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)
|
self.sleep(0.1)
|
||||||
|
preflight_record = {"standstill_confirmed": observed_standstill, "samples": idle_samples}
|
||||||
if release:
|
if release:
|
||||||
self.state("ready")
|
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:
|
if hall_mode:
|
||||||
from .hall_detection import measure
|
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:
|
if foc_mode:
|
||||||
from .foc_calibration import calibrate
|
from .foc_calibration import calibrate
|
||||||
return calibrate(self, command, devices, target, originals, backups, unchanged)
|
return calibrate(self, command, devices, target, originals, backups, unchanged)
|
||||||
@@ -219,8 +263,11 @@ class MotorTest:
|
|||||||
if (deadline - now).total_seconds() < budget + 1:
|
if (deadline - now).total_seconds() < budget + 1:
|
||||||
raise Rejected("Команда устарела до запуска. Повторите проверку.")
|
raise Rejected("Команда устарела до запуска. Повторите проверку.")
|
||||||
if group_mode:
|
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
|
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.state("testing")
|
||||||
self.active = True
|
self.active = True
|
||||||
self.mode = "speed" if speed_mode else "current"
|
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,
|
"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,
|
"erpm_target": hold.erpm if hold else None, "limits_restored": restored,
|
||||||
"current_ramp_a_per_s": TEST_LIMITS["current_ramp_a_per_s"],
|
"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,
|
"duration_limit_s": duration, "outcome": outcome, "samples": samples,
|
||||||
"limit_violation": limit_violation, "failure": failure,
|
"limit_violation": limit_violation, "failure": failure,
|
||||||
"release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups}
|
"release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class NativeLink:
|
|||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
deadline = started + timeout
|
deadline = started + timeout
|
||||||
trace = {"method": method, "command": parameters.get("command"),
|
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:
|
try:
|
||||||
trace["stage"] = "attachment_before"
|
trace["stage"] = "attachment_before"
|
||||||
self.check()
|
self.check()
|
||||||
|
|||||||
@@ -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.
|
# A separate action/capability keeps old clients from silently changing modes.
|
||||||
SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600,
|
SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600,
|
||||||
"startup_timeout_s": 15, "settle_s": 1, "speed_tolerance": 0.15,
|
"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):
|
def frame(data):
|
||||||
@@ -36,7 +37,7 @@ def frame(data):
|
|||||||
|
|
||||||
|
|
||||||
def speed_packet(erpm):
|
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")
|
raise ValueError("Invalid test speed")
|
||||||
return frame(bytes([8]) + struct.pack(">i", round(erpm)))
|
return frame(bytes([8]) + struct.pack(">i", round(erpm)))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -13,6 +13,7 @@ from http.server import BaseHTTPRequestHandler
|
|||||||
from urllib.parse import urlsplit, parse_qs
|
from urllib.parse import urlsplit, parse_qs
|
||||||
|
|
||||||
from .service import Service
|
from .service import Service
|
||||||
|
from .remote_control import RemoteControl
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -48,14 +49,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif self.command == "GET" and self.path.startswith("/archive-export?"):
|
elif self.command == "GET" and self.path.startswith("/archive-export?"):
|
||||||
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
|
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
|
||||||
result = self.server.service.archive.export("local", after)
|
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"))
|
size = int(self.headers.get("Content-Length", "0"))
|
||||||
if not 0 < size <= 65536 or self.headers.get("Content-Type") != "application/json":
|
if not 0 < size <= 65536 or self.headers.get("Content-Type") != "application/json":
|
||||||
raise ValueError("Invalid command")
|
raise ValueError("Invalid command")
|
||||||
raw = self.rfile.read(size)
|
raw = self.rfile.read(size)
|
||||||
if len(raw) != size:
|
if len(raw) != size:
|
||||||
raise ValueError("Truncated command")
|
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:
|
else:
|
||||||
raise ValueError("Unknown route")
|
raise ValueError("Unknown route")
|
||||||
except (ValueError, KeyError, TypeError, OSError):
|
except (ValueError, KeyError, TypeError, OSError):
|
||||||
@@ -102,6 +104,7 @@ def main():
|
|||||||
raise RuntimeError("VESC must run as its own unprivileged user")
|
raise RuntimeError("VESC must run as its own unprivileged user")
|
||||||
os.umask(0o007)
|
os.umask(0o007)
|
||||||
service = Service("/var/lib/mission-core-vesc")
|
service = Service("/var/lib/mission-core-vesc")
|
||||||
|
service.remote = RemoteControl(service)
|
||||||
stop = threading.Event()
|
stop = threading.Event()
|
||||||
|
|
||||||
def scan():
|
def scan():
|
||||||
@@ -113,6 +116,11 @@ def main():
|
|||||||
pass
|
pass
|
||||||
stop.wait(2)
|
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 = Path("/run/mission-core-vesc/driver.sock")
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
with Server(str(path), Handler) as server:
|
with Server(str(path), Handler) as server:
|
||||||
@@ -120,6 +128,7 @@ def main():
|
|||||||
server.service = service
|
server.service = service
|
||||||
thread = threading.Thread(target=scan, daemon=True)
|
thread = threading.Thread(target=scan, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
threading.Thread(target=observe, daemon=True, name="vesc-observer").start()
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ try:
|
|||||||
except ImportError: # Source checkout; packaging copies this exact shared file.
|
except ImportError: # Source checkout; packaging copies this exact shared file.
|
||||||
from k1link.device_plugins.vesc.archive import Archive
|
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():
|
def utc():
|
||||||
@@ -164,7 +164,7 @@ class Service:
|
|||||||
"preparation_safe": True, "online": True, "usb": attachment.speed,
|
"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": [],
|
"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),
|
"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"],
|
"snapshot": {"context": {"session_id": d["session"],
|
||||||
"device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc",
|
"device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc",
|
||||||
"plugin_version": VERSION, "model_id": MODEL},
|
"plugin_version": VERSION, "model_id": MODEL},
|
||||||
@@ -186,13 +186,15 @@ class Service:
|
|||||||
if action not in ACTIONS or not isinstance(params, dict):
|
if action not in ACTIONS or not isinstance(params, dict):
|
||||||
raise ValueError("Unsupported operation")
|
raise ValueError("Unsupported operation")
|
||||||
if action in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.control.release"):
|
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
|
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 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["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"]):
|
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")
|
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")
|
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
|
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
|
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"])):
|
or len(set(params["device_ids"])) != len(params["device_ids"])):
|
||||||
raise ValueError("Explicit complete drive profile required")
|
raise ValueError("Explicit complete drive profile required")
|
||||||
elif action in ("vesc.hall.measure", "vesc.foc.calibrate"):
|
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)
|
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 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):
|
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128):
|
||||||
raise ValueError("Native procedure and rig confirmation required")
|
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")
|
raise ValueError("Heating budget must be between 10 and 150 W")
|
||||||
elif action == "vesc.link.check":
|
elif action == "vesc.link.check":
|
||||||
if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128:
|
if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128:
|
||||||
raise ValueError("Controller sessions required")
|
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)
|
validate_drive(action, params)
|
||||||
elif params != {}:
|
elif params != {}:
|
||||||
raise ValueError("This operation has no parameters")
|
raise ValueError("This operation has no parameters")
|
||||||
@@ -265,7 +269,7 @@ class Service:
|
|||||||
record["receipt"] = {"state": "complete", "result": result}
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
atomic(path, record)
|
atomic(path, record)
|
||||||
return record["receipt"]
|
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.")
|
if device.identity is None: raise Rejected("Сначала подтвердите личность VESC.")
|
||||||
try:
|
try:
|
||||||
result = self.drive.update(command["action_id"], command["parameters"], device)
|
result = self.drive.update(command["action_id"], command["parameters"], device)
|
||||||
@@ -304,11 +308,16 @@ class Service:
|
|||||||
raise ValueError("Controller identity changed")
|
raise ValueError("Controller identity changed")
|
||||||
result = {"identity": actual, "device_id": device.id, "observed_at": utc()}
|
result = {"identity": actual, "device_id": device.id, "observed_at": utc()}
|
||||||
action = command["action_id"]
|
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")
|
raise ValueError("Firmware read layout is unsupported")
|
||||||
if action == "vesc.telemetry.read":
|
if action == "vesc.telemetry.read":
|
||||||
result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic())
|
result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic())
|
||||||
device.telemetry = result
|
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":
|
elif action == "vesc.input.read":
|
||||||
if actual["version"] != "5.02": raise ValueError("Input layout unsupported")
|
if actual["version"] != "5.02": raise ValueError("Input layout unsupported")
|
||||||
result["input"] = ppm(device.link.query(31))
|
result["input"] = ppm(device.link.query(31))
|
||||||
@@ -340,6 +349,14 @@ class Service:
|
|||||||
record["receipt"] = {"state": "complete", "result": result}
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
except (OSError, ValueError, TimeoutError) as error:
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
record["receipt"] = {"state": "error", "error": str(error) if isinstance(error, Rejected) else "Операция не выполнена. Проверьте связь и совместимость контроллера."}
|
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)
|
atomic(path, record)
|
||||||
return record["receipt"]
|
return record["receipt"]
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class SpeedHold:
|
|||||||
if self.tachometer is not None and value["tachometer"] != self.tachometer:
|
if self.tachometer is not None and value["tachometer"] != self.tachometer:
|
||||||
self.motion_at = now
|
self.motion_at = now
|
||||||
self.tachometer = value["tachometer"]
|
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)
|
and self.motion_at is not None and now - self.motion_at <= 0.25)
|
||||||
error = None
|
error = None
|
||||||
if self.hold_started is 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:
|
if now - self.started > SPEED_LIMITS["startup_timeout_s"] + self.duration + 5:
|
||||||
error = "Истёк общий срок проверки; заданное время вращения не набрано."
|
error = "Истёк общий срок проверки; заданное время вращения не набрано."
|
||||||
done = self.rotation_s >= self.duration
|
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
|
return setpoint, done, error
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ class TemporaryLimits:
|
|||||||
raise ValueError("Limits changed outside this operation; restoration pending")
|
raise ValueError("Limits changed outside this operation; restoration pending")
|
||||||
if actual_raw != raw:
|
if actual_raw != raw:
|
||||||
state = values(device.link.query(4))
|
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")
|
raise ValueError("Wait for zero current and neutral before restoring limits")
|
||||||
device.link.set_temporary_limits(old)
|
device.link.set_temporary_limits(old)
|
||||||
if device.link.query(14) != raw: raise ValueError("Original configuration readback failed")
|
if device.link.query(14) != raw: raise ValueError("Original configuration readback failed")
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ import test_reader
|
|||||||
|
|
||||||
|
|
||||||
class DriveTests(test_reader.ServiceTests):
|
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):
|
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)
|
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')
|
command=self.command(item,action='vesc.drive.assign')
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class CalibrationTests(unittest.TestCase):
|
|||||||
self.assertFalse(receipt['result']['release_confirmed'])
|
self.assertFalse(receipt['result']['release_confirmed'])
|
||||||
self.assertTrue(receipt['result']['configuration_verified'])
|
self.assertTrue(receipt['result']['configuration_verified'])
|
||||||
self.case.devices[0].link.query = query
|
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']
|
result = self.case.service.execute(release)['result']
|
||||||
self.assertEqual(result['authority'], 'ready')
|
self.assertEqual(result['authority'], 'ready')
|
||||||
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
|
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
|
||||||
@@ -165,7 +165,7 @@ class CalibrationTests(unittest.TestCase):
|
|||||||
key = self.case.devices[0].id
|
key = self.case.devices[0].id
|
||||||
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
|
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
|
||||||
before = self.configs[key]
|
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)
|
receipt = self.case.service.execute(release)
|
||||||
self.assertEqual(receipt['state'], 'error')
|
self.assertEqual(receipt['state'], 'error')
|
||||||
self.assertEqual(self.configs[key], before)
|
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):
|
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.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||||
self.case.service.execute(self.command())
|
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.assertEqual(self.case.service.execute(release)['state'], 'error')
|
||||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class GroupTests(unittest.TestCase):
|
|||||||
def command(self):
|
def command(self):
|
||||||
command = self.case.command_for_pulse()
|
command = self.case.command_for_pulse()
|
||||||
command['action_id'] = 'vesc.drive.run'
|
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])
|
device_ids=[d.id for d in self.case.devices])
|
||||||
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
|
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
|
||||||
return command
|
return command
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""No-power read measurements and failure attribution; no USB hardware."""
|
"""No-power read measurements and failure attribution; no USB hardware."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import unittest
|
import unittest
|
||||||
|
import struct
|
||||||
import test_motor_test
|
import test_motor_test
|
||||||
from runtime.link_check import measure, summary
|
from runtime.link_check import measure, summary
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ class LinkCheckTests(unittest.TestCase):
|
|||||||
result=self.run_check()
|
result=self.run_check()
|
||||||
self.assertEqual(result['outcome'],'complete')
|
self.assertEqual(result['outcome'],'complete')
|
||||||
self.assertFalse(result['motor_commands_sent'])
|
self.assertFalse(result['motor_commands_sent'])
|
||||||
self.assertEqual(len(self.reads),400)
|
self.assertEqual(len(self.reads),402)
|
||||||
self.assertEqual({r[1] for r in self.reads},{31,4})
|
self.assertEqual({r[1] for r in self.reads},{17,31,4})
|
||||||
self.assertTrue(all(r[2]==.5 for r in self.reads))
|
self.assertTrue(all(r[2]==.5 for r in self.reads if r[1]!=17))
|
||||||
self.assertEqual(self.case.sent,[])
|
self.assertEqual(self.case.sent,[])
|
||||||
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
|
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
|
||||||
self.assertGreaterEqual(result['duration_s'],9.9)
|
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):
|
def test_no_response_keeps_peer_command_and_native_cause(self):
|
||||||
device=self.case.devices[1]
|
device=self.case.devices[1]
|
||||||
|
original=device.link.query
|
||||||
def query(code,timeout=2):
|
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}
|
error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True}
|
||||||
raise error
|
raise error
|
||||||
device.link.query=query
|
device.link.query=query
|
||||||
@@ -61,7 +64,24 @@ class LinkCheckTests(unittest.TestCase):
|
|||||||
result=self.run_check()
|
result=self.run_check()
|
||||||
self.assertEqual(result['outcome'],'not_idle')
|
self.assertEqual(result['outcome'],'not_idle')
|
||||||
self.assertEqual(self.case.sent,[])
|
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):
|
def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self):
|
||||||
command=self.command()
|
command=self.command()
|
||||||
@@ -71,7 +91,7 @@ class LinkCheckTests(unittest.TestCase):
|
|||||||
result=self.case.service.execute(command)
|
result=self.case.service.execute(command)
|
||||||
self.assertEqual(result['state'],'complete')
|
self.assertEqual(result['state'],'complete')
|
||||||
self.assertEqual(self.case.service.execute(command),result)
|
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()
|
self.case.service.operation_lock.acquire()
|
||||||
try:
|
try:
|
||||||
with self.assertRaises(ValueError):self.case.service.execute(self.command())
|
with self.assertRaises(ValueError):self.case.service.execute(self.command())
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from runtime.native_link import NativeLink
|
from runtime.native_link import NativeLink
|
||||||
|
from runtime.serial import Attachment
|
||||||
|
|
||||||
|
|
||||||
class NativeBoundaryTests(unittest.TestCase):
|
class NativeBoundaryTests(unittest.TestCase):
|
||||||
def link(self, handler):
|
def link(self, handler):
|
||||||
value = object.__new__(NativeLink)
|
value = object.__new__(NativeLink)
|
||||||
|
value.attachment = Attachment('2-1', '11', 'ttyACM0', '12')
|
||||||
value.buffer = b""; value.sequence = 0; value.hall_pending = False
|
value.buffer = b""; value.sequence = 0; value.hall_pending = False
|
||||||
value.check = lambda:None
|
value.check = lambda:None
|
||||||
script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n'
|
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)
|
with self.assertRaises(OSError) as caught: link.query(31,timeout=.06)
|
||||||
trace=caught.exception.native_rpc
|
trace=caught.exception.native_rpc
|
||||||
self.assertEqual(trace['command'],31)
|
self.assertEqual(trace['command'],31)
|
||||||
|
self.assertEqual(trace['attachment'], {'usb':'2-1','address':'11','tty':'ttyACM0'})
|
||||||
self.assertEqual(trace['timeout_ms'],60)
|
self.assertEqual(trace['timeout_ms'],60)
|
||||||
self.assertTrue(trace['process_alive'])
|
self.assertTrue(trace['process_alive'])
|
||||||
self.assertTrue(trace['attachment_present'])
|
self.assertTrue(trace['attachment_present'])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'})
|
||||||
@@ -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)
|
||||||
@@ -57,7 +57,7 @@ class RunTests(unittest.TestCase):
|
|||||||
def run_command(self):
|
def run_command(self):
|
||||||
command = self.case.command_for_pulse()
|
command = self.case.command_for_pulse()
|
||||||
command['action_id'] = 'vesc.motor.run'
|
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
|
from datetime import datetime,timedelta
|
||||||
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
|
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
|
||||||
return command
|
return command
|
||||||
@@ -119,7 +119,7 @@ class RunTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_native_hall_returns_measured_table_without_applying_it(self):
|
def test_native_hall_returns_measured_table_without_applying_it(self):
|
||||||
command=self.case.command(action='vesc.hall.measure')
|
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}}
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
receipt=self.case.service.execute(command)
|
receipt=self.case.service.execute(command)
|
||||||
self.assertEqual(receipt['state'],'complete',receipt)
|
self.assertEqual(receipt['state'],'complete',receipt)
|
||||||
@@ -138,7 +138,7 @@ class RunTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_native_hall_requires_its_own_explicit_confirmation(self):
|
def test_native_hall_requires_its_own_explicit_confirmation(self):
|
||||||
command=self.case.command(action='vesc.hall.measure')
|
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}}
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
with self.assertRaises(ValueError):self.case.service.execute(command)
|
with self.assertRaises(ValueError):self.case.service.execute(command)
|
||||||
self.assertIsNone(self.hall_started)
|
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):
|
def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self):
|
||||||
self.case.devices[0].link.detect_hall=lambda:None
|
self.case.devices[0].link.detect_hall=lambda:None
|
||||||
command=self.case.command(action='vesc.hall.measure')
|
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}}
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
result=self.case.service.execute(command)['result']
|
result=self.case.service.execute(command)['result']
|
||||||
self.assertFalse(result['completed'])
|
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]))
|
failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1]))
|
||||||
self.assertFalse(failure['valid_six_states'])
|
self.assertFalse(failure['valid_six_states'])
|
||||||
self.assertEqual(failure['observed_states'],[3])
|
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)
|
with self.assertRaises(ValueError):speed_packet(value)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,6 +84,25 @@ def cert(row):
|
|||||||
serialization.Encoding.DER
|
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):
|
def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup):
|
||||||
fleet, _, _, _ = setup
|
fleet, _, _, _ = setup
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user