diff --git a/AGENTS.md b/AGENTS.md index eb97e6d..8d8efec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code. - Synthetic or explicitly redacted fixtures may be committed under `tests/fixtures/`. +## Onboard environment ownership — owner requirement, 2026-09-24 + +- Every onboard OS change belongs to the shipped, versioned installer or the + application's environment/device preparation workflow from the first test. + The operator installs the build and configures it in the application; never + require hand-written service files, USB rules, permission fixes or commands. +- This applies to all Node features, including GUI/service autostart and USB + recovery, not just individual camera drivers. Read-only SSH inspection and + bounded artifact-owned build staging remain allowed. +- Detect the distribution, architecture and required capabilities. Maintain + explicit supported environment profiles; do not claim arbitrary Linux + compatibility from a qualified Ubuntu build. Preserve foreign configuration, + report unsupported features, make preparation repeatable, and ship rollback. +- Do not add USB administration controls or port resets to inventory refresh. + Automatic startup recovery may retry terminal enumeration failures only; + preserve enumerated devices and active companion ports. Device identity and + assignments must survive changes of USB port and tty number. + ## Insta360 and clean-host installation — owner requirement, 2026-09-08 - The current X4 starting point is USB enumeration only. SDK installation, diff --git a/apps/control-station/test/roverControl.test.mjs b/apps/control-station/test/roverControl.test.mjs new file mode 100644 index 0000000..3dcdd28 --- /dev/null +++ b/apps/control-station/test/roverControl.test.mjs @@ -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:imotorDemands({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')); +}); diff --git a/apps/control-station/test/roverInput.test.mjs b/apps/control-station/test/roverInput.test.mjs new file mode 100644 index 0000000..f1d41f0 --- /dev/null +++ b/apps/control-station/test/roverInput.test.mjs @@ -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}); +}); diff --git a/apps/control-station/tools/rover-control-preview/build.mjs b/apps/control-station/tools/rover-control-preview/build.mjs new file mode 100644 index 0000000..aa32ec2 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/build.mjs @@ -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('f.path.endsWith('.css')).text.replaceAll('Mission Core · Профили управления · Прототип
`; +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'})); diff --git a/apps/control-station/tools/rover-control-preview/main.tsx b/apps/control-station/tools/rover-control-preview/main.tsx new file mode 100644 index 0000000..ab36fa7 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/main.tsx @@ -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(emptyAxes); + const [open,setOpen]=useState(['settings','preview']); + const [saved,setSaved]=useState(''); + const [light,setLight]=useState(false); + const change=(patch:Partial)=>{ + 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)=>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
+

MISSION CORE · ПРОТОТИП

Настройки управления ровером

+

Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.

+ + change({mode})}/> + {profile.mode==='arcade'&&change({stick})}/>} +

{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}

+ change({response})}/> + change({deadband})}/> + change({outputScale})}/> +

Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.

+
+ {saved&&

{saved}

} + }, + {id:'preview',label:'Проверка профиля на экране',content: + {field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')} + {field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')} +
+
{(['left','right'] as const).map(side=>
{side==='left'?'Левая сторона':'Правая сторона'}{percent(result[side])}{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}
)}
+

Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.

+
}, + {id:'takeover',label:'Перехват пультом · проверка сценария',content: +
{trace(profile).map(({label,decision},i)=>
{i+1}. {label}{states[decision.state]}{percent(decision.demand.left)} / {percent(decision.demand.right)}
)}
+

Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.

+
}, + ]}/> +
; +} +document.documentElement.dataset.nodedcTheme='dark'; +createRoot(document.getElementById('root')!).render(); diff --git a/apps/control-station/tools/rover-control-preview/preview.css b/apps/control-station/tools/rover-control-preview/preview.css new file mode 100644 index 0000000..6a26ea5 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/preview.css @@ -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;} } diff --git a/apps/control-station/tools/rover-control-preview/trace.ts b/apps/control-station/tools/rover-control-preview/trace.ts new file mode 100644 index 0000000..ee8765b --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/trace.ts @@ -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; +} diff --git a/apps/control-station/tools/rover-control-preview/tsconfig.json b/apps/control-station/tools/rover-control-preview/tsconfig.json new file mode 100644 index 0000000..f19d0c0 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends":"../../tsconfig.app.json", + "compilerOptions":{"incremental":false,"tsBuildInfoFile":null}, + "include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"] +} diff --git a/apps/node-agent/internal/node/board_layout.go b/apps/node-agent/internal/node/board_layout.go new file mode 100644 index 0000000..b2d7aaa --- /dev/null +++ b/apps/node-agent/internal/node/board_layout.go @@ -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) + }) +} diff --git a/apps/node-agent/internal/node/board_layout_test.go b/apps/node-agent/internal/node/board_layout_test.go new file mode 100644 index 0000000..60e5f6d --- /dev/null +++ b/apps/node-agent/internal/node/board_layout_test.go @@ -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") + } +} diff --git a/apps/node-agent/internal/node/environment-profile.json b/apps/node-agent/internal/node/environment-profile.json index 0d0a983..21b8b39 100644 --- a/apps/node-agent/internal/node/environment-profile.json +++ b/apps/node-agent/internal/node/environment-profile.json @@ -1,10 +1,12 @@ { "schema": "missioncore.node.environment/v1", - "revision": "ubuntu-24.04-amd64/2", + "revision": "ubuntu-24.04-amd64/3", "steps": [ {"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]}, {"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]}, {"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]}, + {"id":"desktop-autostart","label":"Автозапуск приложения","description":"Открытие окна при входе в рабочий стол","requires":["node-service"]}, + {"id":"usb-startup","label":"Обнаружение устройств при загрузке","description":"Автоматическое восстановление незавершённого подключения USB","requires":["platform"]}, {"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]}, {"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]}, {"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]}, diff --git a/apps/node-agent/internal/node/pairing_transport.go b/apps/node-agent/internal/node/pairing_transport.go index 6cd0ea7..cc5007d 100644 --- a/apps/node-agent/internal/node/pairing_transport.go +++ b/apps/node-agent/internal/node/pairing_transport.go @@ -152,6 +152,7 @@ func (p *Pairing) remoteHandler() http.Handler { func (p *Pairing) Run(ctx context.Context) { go p.channel(ctx) + go p.roverChannel(ctx) var server *http.Server endpoint := "" serverIdentity := "" diff --git a/apps/node-agent/internal/node/presentation.go b/apps/node-agent/internal/node/presentation.go index d2741d8..2cce8a9 100644 --- a/apps/node-agent/internal/node/presentation.go +++ b/apps/node-agent/internal/node/presentation.go @@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error { } func (s *Server) presentationRoutes(mux *http.ServeMux) { + s.boardLayoutRoutes(mux) p := s.Presentation mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { diff --git a/apps/node-agent/internal/node/rover_channel.go b/apps/node-agent/internal/node/rover_channel.go new file mode 100644 index 0000000..329b6e2 --- /dev/null +++ b/apps/node-agent/internal/node/rover_channel.go @@ -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") + } + } +} diff --git a/apps/node-agent/internal/node/rover_diagnostics.go b/apps/node-agent/internal/node/rover_diagnostics.go new file mode 100644 index 0000000..aa600df --- /dev/null +++ b/apps/node-agent/internal/node/rover_diagnostics.go @@ -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 +} diff --git a/apps/node-agent/internal/node/rover_diagnostics_test.go b/apps/node-agent/internal/node/rover_diagnostics_test.go new file mode 100644 index 0000000..20e23a4 --- /dev/null +++ b/apps/node-agent/internal/node/rover_diagnostics_test.go @@ -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) + } +} diff --git a/apps/node-agent/internal/node/rover_stream_state.go b/apps/node-agent/internal/node/rover_stream_state.go new file mode 100644 index 0000000..692aa86 --- /dev/null +++ b/apps/node-agent/internal/node/rover_stream_state.go @@ -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() } diff --git a/apps/node-agent/internal/node/rover_stream_state_test.go b/apps/node-agent/internal/node/rover_stream_state_test.go new file mode 100644 index 0000000..5948426 --- /dev/null +++ b/apps/node-agent/internal/node/rover_stream_state_test.go @@ -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) + } +} diff --git a/apps/node-agent/internal/node/sensor_models.go b/apps/node-agent/internal/node/sensor_models.go index 032c9d7..251dce0 100644 --- a/apps/node-agent/internal/node/sensor_models.go +++ b/apps/node-agent/internal/node/sensor_models.go @@ -46,11 +46,11 @@ var sensorModels = []sensorModel{ PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json", ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second}, Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")}, - {ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.6.3", + {ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.7.4", Vendor: "0483", Product: "5740", USBName: "ChibiOS/RT Virtual COM Port", Socket: "/run/mission-core-vesc/driver.sock", PrepareUnit: "mission-core-node-vesc-prepare.service", Report: "/var/lib/mission-core-node-profiles/vesc/preparation.json", ActionTimeouts: map[string]time.Duration{"vesc.link.check": 45 * time.Second, "vesc.motor.run": 90 * time.Second, "vesc.drive.run": 120 * time.Second, "vesc.hall.measure": 60 * time.Second, "vesc.foc.calibrate": 300 * time.Second, "vesc.motor.pulse": 60 * time.Second, "vesc.control.release": 60 * time.Second}, - ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")}, + ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.layout", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")}, } func modelForDevice(id string) *sensorModel { diff --git a/apps/node-agent/internal/node/sensor_vesc_test.go b/apps/node-agent/internal/node/sensor_vesc_test.go index 9bbae11..5c3016a 100644 --- a/apps/node-agent/internal/node/sensor_vesc_test.go +++ b/apps/node-agent/internal/node/sensor_vesc_test.go @@ -6,10 +6,29 @@ import ( "net/http" "os" "path/filepath" + "regexp" + "strings" "sync/atomic" "testing" ) +func TestVESCDeclaredVersionMatchesBundledDriver(t *testing.T) { + model := modelForDevice("vesc_00000000000000000000000000000000") + root := filepath.Join("..", "..", "..", "..", "plugins", "vesc") + driver, err := os.ReadFile(filepath.Join(root, "runtime", "__init__.py")) + if err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`(?m)^VERSION = "([^"]+)"`).FindSubmatch(driver) + if model == nil || len(match) != 2 || model.Version != string(match[1]) { + t.Fatal("Node must admit the exact driver shipped in the same release", model, string(driver)) + } + preparation, err := os.ReadFile(filepath.Join(root, "packaging", "prepare.py")) + if err != nil || !strings.Contains(string(preparation), `"version": "`+model.Version+`"`) { + t.Fatal("VESC preparation must declare the bundled driver version", err) + } +} + func fakeVESC(t *testing.T, s *Sensors, port, number string) { t.Helper() fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number) diff --git a/apps/node-agent/internal/node/sensors.go b/apps/node-agent/internal/node/sensors.go index 1cd7677..f2a6c69 100644 --- a/apps/node-agent/internal/node/sensors.go +++ b/apps/node-agent/internal/node/sensors.go @@ -354,7 +354,7 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any { } func sensorViewAction(action string) bool { - return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.config.backup" + return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.limits.read" || action == "vesc.config.backup" } func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) { diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index aadb682..5f53a88 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,8 +11,8 @@ import sys ROOT = Path(__file__).resolve().parents[1] -BINARY_VERSION = "0.8.35" -VERSION = "0.8.35-1" +BINARY_VERSION = "0.8.45" +VERSION = "0.8.45-1" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package @@ -44,7 +44,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g +Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, usbutils, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g Description: Mission Core onboard computer configuration Local graphical setup, host inventory, SSH access and persistent node identity. """.encode() @@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration ("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644), ("configure-system", "usr/lib/mission-core-node/configure-system", 0o755), ("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644), + ("desktop_startup.py", "usr/lib/mission-core-node/desktop_startup.py", 0o644), + ("mission-core-node-autostart.desktop", "usr/share/mission-core-node/mission-core-node-autostart.desktop", 0o644), + ("usb-startup.json", "usr/share/mission-core-node/usb-startup.json", 0o644), + ("usb_startup_recovery.py", "usr/lib/mission-core-node/usb_startup_recovery.py", 0o644), + ("mission-core-node-usb-startup.service", "usr/lib/systemd/system/mission-core-node-usb-startup.service", 0o644), ("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644), ("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644), ("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755), diff --git a/apps/node-agent/packaging/desktop_startup.py b/apps/node-agent/packaging/desktop_startup.py new file mode 100644 index 0000000..5913038 --- /dev/null +++ b/apps/node-agent/packaging/desktop_startup.py @@ -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']) diff --git a/apps/node-agent/packaging/environment_helper.py b/apps/node-agent/packaging/environment_helper.py index 5c34852..f17f2c6 100644 --- a/apps/node-agent/packaging/environment_helper.py +++ b/apps/node-agent/packaging/environment_helper.py @@ -219,7 +219,33 @@ def tailscale_install(): return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно." -OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install} +def desktop_autostart(): + owned_config(Path('/usr/share/mission-core-node/mission-core-node-autostart.desktop'), + Path('/etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop')) + return "Окно приложения открывается при входе в рабочий стол. Служба БК работает и без входа." + + +def usb_startup(): + # Enabling the next boot is distinct from executing recovery now. This + # workflow never resets devices in a running operator session. + owned_config(Path('/usr/share/mission-core-node/usb-startup.json'), + Path('/etc/mission-core-node/usb-startup.json')) + command(['/usr/bin/systemctl', 'daemon-reload']) + command(['/usr/bin/systemctl', 'enable', 'mission-core-node-usb-startup.service']) + command(['/usr/bin/systemctl', 'is-enabled', 'mission-core-node-usb-startup.service']) + report = json.loads(command(['/usr/bin/python3', '-I', '-B', '/usr/lib/mission-core-node/usb_startup_recovery.py', '--inspect'], timeout=75)) + if report.get('state') != 'inspected': + raise SetupError("Не удалось проверить поддержку восстановления USB. Повторите настройку.") + supported = sum(h.get('individual_power') is True for h in report.get('hubs', [])) + if not supported: + return "Проверка при загрузке включена. Поддержка отдельного переключения USB-портов не подтверждена; они будут пропущены." + return "Восстановление при загрузке включено для поддерживаемых USB-портов. Обнаруженные устройства сохраняют подключение." + + +OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, + "desktop-autostart": desktop_autostart, "usb-startup": usb_startup, + "network-inventory": network_inventory, "usb-inventory": usb_inventory, + "ssh-service": ssh_service, "tailscale-install": tailscale_install} def run_steps(profile, operations, save): diff --git a/apps/node-agent/packaging/linux_build_job.py b/apps/node-agent/packaging/linux_build_job.py index fcbe010..b59c107 100644 --- a/apps/node-agent/packaging/linux_build_job.py +++ b/apps/node-agent/packaging/linux_build_job.py @@ -243,6 +243,9 @@ def main(): sys.path.insert(0, str(node / "packaging")) from build_deb import BINARY_VERSION, VERSION, build + run("node-environment-tests", ["/usr/bin/python3", "-m", "unittest", "test_environment_helper", "test_usb_startup_recovery", "test_desktop_startup", "-v"], cwd=node / "packaging") + run("owner-release-staging-tests", ["/usr/bin/python3", "plugins/insta360-x4/packaging/test_owner_release_entry.py"], cwd=repo) + run("node-usb-unit-validation", ["/usr/bin/systemd-analyze", "verify", str(node / "packaging/mission-core-node-usb-startup.service")], cwd=node) run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo) binary = node / "build/node-agent-linux-amd64" run( diff --git a/apps/node-agent/packaging/mission-core-node-autostart.desktop b/apps/node-agent/packaging/mission-core-node-autostart.desktop new file mode 100644 index 0000000..ce1f4d7 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-autostart.desktop @@ -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 diff --git a/apps/node-agent/packaging/mission-core-node-usb-startup.service b/apps/node-agent/packaging/mission-core-node-usb-startup.service new file mode 100644 index 0000000..a2700b4 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-usb-startup.service @@ -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 diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index 4f50291..541aad9 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -14,6 +14,14 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then exit 1 fi fi + mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true) + case "$mc_node_usb_job" in + activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;; + esac + if [ -f /run/mission-core-usb-startup/pending.json ]; then + echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2 + exit 1 + fi mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true) case "$mc_node_x4_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;; diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index 1aacf33..754ea7d 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -13,6 +13,14 @@ if [ -d /run/systemd/system ]; then exit 1 fi fi + mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true) + case "$mc_node_usb_job" in + activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;; + esac + if [ -f /run/mission-core-usb-startup/pending.json ]; then + echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2 + exit 1 + fi mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true) case "$mc_node_x4_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;; @@ -34,6 +42,25 @@ if [ -d /run/systemd/system ]; then ;; esac fi +retire_startup_settings() { + for mc_node_owned in /etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop /etc/mission-core-node/usb-startup.json; do + case "$mc_node_owned" in + *.desktop) mc_node_template=/usr/share/mission-core-node/mission-core-node-autostart.desktop ;; + *.json) mc_node_template=/usr/share/mission-core-node/usb-startup.json ;; + esac + if [ ! -L "$mc_node_owned" ] && cmp -s "$mc_node_template" "$mc_node_owned"; then + rm "$mc_node_owned" + fi + done + if [ -d /run/systemd/system ]; then + systemctl disable --now mission-core-node-usb-startup.service + fi +} +# A downgrade removes helpers introduced in 0.8.37. Retire their owned +# configuration first; do not leave an enabled unit or XDG entry dangling. +if [ "$1" = upgrade ] && [ -n "${2:-}" ] && dpkg --compare-versions "$2" lt 0.8.37-1; then + retire_startup_settings +fi case "$1" in upgrade|remove|deconfigure) if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then @@ -47,6 +74,7 @@ case "$1" in rm /etc/udev/rules.d/70-mission-core-vesc.rules if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi fi + retire_startup_settings mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf if [ -e "$mc_node_ssh_snippet" ]; then if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then diff --git a/apps/node-agent/packaging/test_desktop_startup.py b/apps/node-agent/packaging/test_desktop_startup.py new file mode 100644 index 0000000..55c67f4 --- /dev/null +++ b/apps/node-agent/packaging/test_desktop_startup.py @@ -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) diff --git a/apps/node-agent/packaging/test_environment_helper.py b/apps/node-agent/packaging/test_environment_helper.py index acfd543..2a2e2a0 100644 --- a/apps/node-agent/packaging/test_environment_helper.py +++ b/apps/node-agent/packaging/test_environment_helper.py @@ -15,6 +15,24 @@ PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-pro class EnvironmentWorkflowTests(unittest.TestCase): + def test_profile_and_shipped_operations_match(self): + self.assertEqual({s['id'] for s in PROFILE['steps']}, set(helper.OPERATIONS)) + + def test_usb_setup_enables_next_boot_without_resetting_live_devices(self): + def command(argv, **_): + return json.dumps({'state': 'inspected', 'hubs': [{'individual_power': True}]}) if '--inspect' in argv else '' + with patch.object(helper, 'owned_config') as config, patch.object(helper, 'command', side_effect=command) as run: + self.assertIn('включено', helper.usb_startup()) + config.assert_called_once() + for call in run.call_args_list: + self.assertFalse({'--now', 'start', 'restart'} & set(call.args[0])) + self.assertTrue(any('--inspect' in call.args[0] for call in run.call_args_list)) + + def test_autostart_preserves_foreign_configuration_and_does_not_start_a_gui_as_root(self): + with patch.object(helper, 'owned_config', side_effect=helper.SetupError('conflict')), patch.object(helper, 'command') as run: + with self.assertRaises(helper.SetupError): helper.desktop_autostart() + run.assert_not_called() + def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self): saved = [] operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']} diff --git a/apps/node-agent/packaging/test_usb_startup_recovery.py b/apps/node-agent/packaging/test_usb_startup_recovery.py new file mode 100644 index 0000000..a498373 --- /dev/null +++ b/apps/node-agent/packaging/test_usb_startup_recovery.py @@ -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() diff --git a/apps/node-agent/packaging/usb-startup.json b/apps/node-agent/packaging/usb-startup.json new file mode 100644 index 0000000..da3b167 --- /dev/null +++ b/apps/node-agent/packaging/usb-startup.json @@ -0,0 +1 @@ +{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"} diff --git a/apps/node-agent/packaging/usb_startup_recovery.py b/apps/node-agent/packaging/usb_startup_recovery.py new file mode 100644 index 0000000..542595c --- /dev/null +++ b/apps/node-agent/packaging/usb_startup_recovery.py @@ -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()) diff --git a/apps/node-agent/ui/src/NodeSensors.tsx b/apps/node-agent/ui/src/NodeSensors.tsx index f33b225..97d9667 100644 --- a/apps/node-agent/ui/src/NodeSensors.tsx +++ b/apps/node-agent/ui/src/NodeSensors.tsx @@ -4,7 +4,10 @@ import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/p import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost'; import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace'; import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts'; -import {request} from './api'; +import {request,type Status} from './api'; +import {Button,Icon,SettingsCard} from '@nodedc/ui-react'; +import {createBoardLayoutStore} from '../../../../packages/sensor-ui/src/boardLayout'; +const layout=createBoardLayoutStore({read:()=>request('/api/presentation/board-layout'),patch:(section,open)=>request('/api/presentation/board-layout','PATCH',{section,open})}); const configurationArchive:NonNullable={list:(device,before)=>request(`/api/device-configurations/${encodeURIComponent(device)}${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>request(`/api/device-configurations/${encodeURIComponent(device)}/${encodeURIComponent(version)}`)}; const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))}; -export function NodeSensors(){return ;} +export function NodeSensors({value,openSettings}:{value:Status;openSettings:()=>void}){return
Бортовой компьютер
{value.node_id}
Операционная система
{value.host.os}
Архитектура
{value.host.architecture}
Процессоры
{value.host.cpus}
Память
{value.host.memory_kib?`${(value.host.memory_kib/1048576).toFixed(1)} ГиБ`:"Нет сведений"}
Mission Core Node
{value.version}
}} contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;} diff --git a/apps/node-agent/ui/src/main.tsx b/apps/node-agent/ui/src/main.tsx index b8d9f51..e8c26e0 100644 --- a/apps/node-agent/ui/src/main.tsx +++ b/apps/node-agent/ui/src/main.tsx @@ -38,7 +38,7 @@ function App() { function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); } function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); } const content = !value ? null : workspace.activeView === "environment" ? : workspace.activeView === "overview" ? - : workspace.activeView === "sensors" ? + : workspace.activeView === "sensors" ? openView("environment")} /> : workspace.activeView === "network" ? : workspace.activeView === "usb" ? : workspace.activeView === "core" ? diff --git a/docs/node/17_VESC_INSTALLATION_LEDGER.md b/docs/node/17_VESC_INSTALLATION_LEDGER.md index e144ee3..2324229 100644 --- a/docs/node/17_VESC_INSTALLATION_LEDGER.md +++ b/docs/node/17_VESC_INSTALLATION_LEDGER.md @@ -5,6 +5,15 @@ Private evidence is in the operator's `outputs/rover-006-vesc-context-20260923` directory. Raw configurations, device UUIDs, SSH material and photographs are not repository fixtures. The current Ops record is MISSIONCOR-84. +2026-09-25: the owner-requested consolidated configuration passport is now +[MISSIONCOR-85 — Гусеничный ровер Node 006](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-85). +It contains current hardware, both full decoded controller configurations, +radio settings, experiment outcomes and outstanding checkers. MISSIONCOR-84 +remains the historical integration task. Direct MCP readback verified all 35 +blocks; no controller changes or powered experiments were performed to publish +the passport. Owner deferred the one-stick/Tank–Arcade work and retained the +existing two-stick radio control. + ## 2026-09-23 — first deployed reader Node **0.8.22-1**, VESC plugin **0.1.0**, DG revision @@ -1159,3 +1168,1077 @@ services according to preinst/postinst. It is not a restart-free hot patch and is not described as only a VESC restart. Physical power/USB changes and a Linux reboot are not part of that update. Owner agreement on software service interruption is needed before applying under the current no-restart constraint. + +### 2026-09-24 — post-boot absence and power-profile research + +Owner reports board booted and requests a refreshed plan, investigation of +missing VESCs, and explanation of native power profiles. Power-limit changes +are explicitly deferred. SSH works; installed Node remains 0.8.35-1 and both +Node/VESC services are active with zero automatic restarts in this boot. +Read-only Linux inspection finds no VESC USB enumeration and no ttyACM/by-id +ports. Boot logs show descriptor/address failures (-71) on two USB ports; +unidentified devices cannot yet be attributed to a particular VESC. Owner was +asked to confirm present VESC power/cables and any changed connections, without +requesting disconnection/reset. No service restart or controller command ran. + +Core retains one configured offline VESC and serves both controllers' archived +configurations, but the other controller is absent from inventory. Source review +shows the persisted drive profile is exposed only inside live inventory items; +Node's offline registry is updated by explicit preparation, not every verified +discovery. Known-device persistence and independently available drive profiles +therefore need product work separate from USB recovery. Direct SSH reading of +the protected profile file was denied; no permission workaround was attempted. + +The latest confirmed archived configuration has motor limits 34.2135/34.4078 A, +100% acceleration scales, 55 A battery limits per controller and no separate +watt cap. These are historical readbacks, not fresh hardware measurements or +manufacturer-approved ratings. Native profile temporary/permanent semantics, +CAN propagation and per-device readback requirements are documented in +20_VESC_POWER_LIMITS_PLAN.md. No power UI/runtime changes were implemented. +Private boot-20260924-* evidence remains outside Git. Ops MCP still fails at +the instruction call with an HTTP transport error; this update is not in Ops. + +Owner subsequently confirms both VESC USB cables are connected and both motors +work from RC now. First USB enumeration errors appear at boot monotonic 9.124 s, +before VESC process start at 11.111 s and Node at 13.430 s. This separates the +initial boot enumeration failure from current application-driver activity; it +does not identify the failing hardware or exclude USB firmware/host issues. +There are still no ttyACM devices. No reset, movement or configuration change +was issued by the agent during this audit. + +Owner confirms all USB devices were removed and reinserted in different ports +while starting Mini. Audit of installed runtime and udev rules finds no fixed +port requirement: discovery enumerates all USB device paths matching the model, +permissions match descriptors, identity is derived from firmware UUID, and drive +bindings store device_id/UUID. Installed serial.py and drive_profile.py SHA256s +match source exactly. Port/address checks protect one connection generation and +do not determine persistent motor assignment. Three focused synthetic tests +passed; an additional synthetic move of both controllers to different bus, +port and tty names retained both identities and left/right assignments. + +At the latest read-only inspection all four USB2 root ports are active, each +reports zero over-current events, and no VESC tty exists. These attributes do +not certify cable or electrical signal quality. The owner was offered one +controlled USB reconnection into the same current port to distinguish persistent +enumeration failure from boot-time state, with Mini/main VESC power kept on and +no commanded movement. This remains pending explicit owner response under the +previous no-reconnection constraint. No reset or controller command was issued. + + +### 2026-09-24 — owner reconnect and artifact-owned startup changes + +Owner subsequently reconnected USB. Both controllers reappeared without a +service restart: the right instance on ttyACM0/port 1-3, the left on ttyACM1/1-2. +Firmware-derived device identities and the 1x1 profile revision 2 retained both +assignments. Kernel evidence records two enumerations about ten seconds apart; +this is not evidence that one physical reconnection restored both controllers. +Node/VESC remained active with Node NRestarts=0. No motor command was issued. + +Owner requires GUI autostart through the existing environment configuration, +boot-only recovery of failed USB enumeration, no USB control surface and no reset +behind inventory refresh. Owner then extended the artifact-owned OS configuration +rule to all onboard development and Linux environment profiles. The repository +AGENTS.md now records that invariant. + +Candidate Node 0.8.37-1 adds environment profile revision 3, owned XDG autostart, +service-readiness wait, fixed boot USB recovery and individual-hub capability +inspection. Port recovery checks the current boot's terminal kernel errors, +empty port and companion, hub generation and power switching support; it records +pending re-enable before any change and has bounded retries/cleanup. See +21_STARTUP_AND_USB_RECOVERY.md. Neither the USB policy nor autostart is enabled +by copying files manually or by this source edit; the shipped environment setup +owns activation. No warm USB reset, hardware test or reboot has been performed. + +35 focused Python tests passed locally; shell syntax and diff checks passed. +Ubuntu artifact qualification, installation/preparation and cold-boot acceptance +are pending. The board's actual individual-port power capability is still unknown. +Current services and both ttyACM devices remain available. Ops publication remains +blocked by the previously recorded direct-MCP transport failure. + +Pre-install review added a guard and regression for a hub replaced during its +companion descriptor inspection. All 36 focused tests now pass; the first +source snapshot is superseded before any build/install. No runtime change. + + +Ubuntu qualification completed for source 4b32759c1bc19f3c6b64c983 in 253.855 s: +17 jobs passed, including Node UI build/tests, Go race tests, 36 environment / +USB / desktop tests, systemd unit validation (empty stderr), and 99 VESC tests. +Package 0.8.37-1 SHA256: +2171120488a30530e8603a9f8f1a757a7ad65e02138b57a245d5620a28c01e02. +Owner release dbc5e717ee36ebb521f28fa4 SHA256: +06eeff49bda51662c041b25e675be9d6037a5f5a3acf6d27e014a87bfdebc59b. +APT simulation: one Node upgrade from 0.8.35-1, no new packages or removals. +This is prepared-Ubuntu qualification, not clean-image or cold-boot acceptance. + +The release's own --launch opened its local Ubuntu installer. Owner was asked +for the OS sudo prompt in that local window. Installation and the subsequent +in-application environment setup are pending; no arbitrary root command or +manual OS configuration was used. Both controllers were readable and no motor +test was active immediately before opening the installer. Core on 8000 remained +HTTP 200 with one canonical listener; no listener on 8765. Ops MCP was retried +once after the long build preparation interval and still failed at instructions. + + +Owner entered sudo in the artifact's local installer. Readback confirms +0.8.37-1, dpkg `install ok installed`, Node/VESC active/running with NRestarts=0, +and installed USB helper SHA256 identical to the qualified source. Core sees +both original VESC identities, readable=true, test_active=false, profile 1x1 +revision 2 unchanged. No controller configuration or power limit was written. + +Owner ran «Система → Настройка окружения → Сконфигурировать» in the onboard UI. +All nine revision-3 steps completed. App-owned XDG entry and USB policy are +root-owned 0644; the boot recovery unit is enabled, inactive/dead (not executed +in this warm session). Both ttyACM devices remain present. Capability inspection +reported at least one individually switchable hub; exact per-hub inspection is +root-only before the first unit start (preparation umask makes its /run directory +0700; systemd RuntimeDirectoryMode=0755 applies when the unit starts). Do not +infer root-port compatibility from the aggregate preparation status. No manual +permission repair was made. + +Owner asks for the next acceptance step: an orderly full Mini shutdown and +power-on, retaining USB cables and VESC main power, without manually opening +Node or reconnecting devices. Cold-boot recovery, exact root-port capability, +GUI startup and persistent motor assignments remain to be observed. The agent +has not issued a reboot or shutdown command. Ops publication still unavailable. + +## 2026-09-24 — observed restart and board settings UI, Node 0.8.38-1 + +Owner restarted the Mini. A new boot was observed; Node and VESC became active +with NRestarts=0, both original firmware UUIDs were readable, and profile 1x1 +revision 2 retained its left/right assignments despite new port locations. +GNOME reports the MissionCoreNode application scope launched by +gnome-session-binary. The boot recovery result is complete with an empty retry +list: enumeration succeeded without a port reset. Its boot_seconds field is the +job's initial timestamp, before the mandatory wait; services began after the +30-second window. This does not qualify recovery during a real enumeration +failure, individual root-port switching, or the exact physical cold-power +sequence. No agent-initiated reboot, USB reset or motor movement occurred. + +Owner requested three collapsible blocks in the existing vehicle surface and +the onboard device surface. See 22_BOARD_SETTINGS_SURFACE.md. Shared DG Inspector +composition now holds computer information, board settings, and devices. JSON +layout persistence uses independent section patches, separate vehicle files in +Core and the local presentation store in Node. Navigation cannot discard a +pending save. Empty/all-closed layouts, invalid data and concurrent changes are +covered. Inventory lifecycle remains outside the collapsing content. + +The global drive profile and assignments moved out of individual VESC cards. +Changing 1x1/2x2 preserves existing compatible UUID bindings and never commands +motors. New limits.read exposes the native Tool-decoded motor, battery, speed, +watt and duty settings. It is read-only; no hardware ratings, arbitrary reserve, +power write, motor recalibration, or direct OS configuration was introduced. + +Local checks: two API/storage tests, five layout state tests, seven VESC UI +tests, 93 architecture/plugin checks, both TypeScript checks and active Core +production build passed. All 102 VESC synthetic tests passed. Real Core browser +QA confirmed that closing computer/settings survives a full page reload and +reopening the vehicle; both assigned controllers remain visible. The canonical +8000 LaunchAgent was restarted through its exact process group and new health +and layout API were accepted. + +Qualified Ubuntu source ffed12d0fba14f693d52d329 passed all 17 jobs, including +Node build/UI tests, Go race tests, and environment/USB/VESC regressions. +Package 0.8.38-1 SHA256: +cd6fd8bcc2f31f87cad8b406893084ec9d8441bebf555c424c7db5bc66fc2dd6. +Owner release d22b3c002f38c1d842b35526 SHA256: +3cde842b1f96467930460ff19e716574b7236a308322bdd720400701d558f014. +APT plan: upgrade only mission-core-node from 0.8.37-1, no additions/removals. +The artifact-owned local installer was opened and the owner was asked to enter +the Ubuntu sudo prompt. Installation and real limits-read acceptance pending. +Ops direct instructions endpoint was retried and still failed at HTTP transport; +this ledger is local evidence, not a claim of Ops publication. + +Browser QA also confirmed keyboard Enter toggling and persistence of the empty +all-closed layout after reload. The original all-open layout was restored after +the test. The installation check still reports 0.8.37-1; the prepared 0.8.38-1 +installer awaits the owner's local sudo entry, so new Node UI and live limit +readback are not yet accepted. + +Owner entered sudo and 0.8.38-1 installed successfully. Acceptance caught a +release integration defect: the bundled VESC driver declares 0.6.5, while the +Node model registry still required 0.6.4. Node correctly rejected the mismatched +driver snapshot, leaving provisional USB entries instead of the confirmed +controllers. This is an application admission defect, not evidence of USB +failure. No port reset, environment reconfiguration or controller write was +used as a workaround. + +Corrective package 0.8.38-2 aligns the Node registry with the bundled 0.6.5 +driver. A Go regression now reads both bundled driver and preparation metadata +and compares their versions to the Node declaration. Source snapshot +b0af9cd57565e35f41554380 SHA256: +a92d0bc5695624846afe6ab2056c261ca9df8f247611db5f50b2747552fd2e06. +Ubuntu qualification and corrective installation are in progress. + +Corrective qualification completed in 256.927 s: all 17 jobs passed, including +the new cross-package version regression in the Go race test run. +Package 0.8.38-2 SHA256: +9443c079d4396adc569445ccbe745152c164732b31474ac526cd2b5eb1423a94. +Owner release 851ee3935d7db0de2d6be048 SHA256: +348fa611c9b0a2ed361c144c4a6ec2798c45a0a4952f2bab7123f10f892ad4ba. +APT simulation upgrades only Node 0.8.38-1 to 0.8.38-2, without added or removed +packages. The release opened its local Ubuntu installer; OS sudo authorization +and live read-only acceptance are pending. No direct OS edits were made. + +After the corrective installer launch, the board stopped responding to SSH; +Core's last observed heartbeat was 12:51:14 local time. Bounded Tailscale status +inspection reported the local client Running/online and the target Mini +offline; its existing tailnet route remained on the Tailscale interface. Two +SSH checks timed out. These observations do not establish whether the install +finished, or why the board went offline. Owner was asked for the visible Mini +and installer state; no reboot, USB manipulation, route change or additional +installation attempt was initiated. Post-install identity and limits-read +acceptance remain pending until the board returns. + +Owner subsequently reports a black Mini screen. Fresh bounded checks still +show the Mini offline in Tailscale and SSH timing out. The owner was asked to +try a single Shift/mouse wake and report the power LED / monitor signal state, +without a reboot. Static audit of both immutable source snapshots confirms +identical preinst/postinst/prerm/postrm, installer and launcher scripts. The +normal upgrade path restarts Mission Core services; no suspend, shutdown, +reboot, display-manager stop or network stop was found in that path. VESC +preparation reloads udev rules and triggers change only on discovered VESC tty +devices; startup port recovery is not started by the installer. This audit is +not proof of causality or exclusion: the actual last completed installer step, +system journal and host resource state are unavailable while the Mini is +offline. No new package, OS mutation or hardware command was issued during +this incident investigation. + +Owner identified a loose cable and restored it. The Mini returned on a new +boot; Node 0.8.38-1 is fully configured, `dpkg --audit` is empty, and Node/VESC +services have zero restarts. Startup USB recovery again completed without any +port retry. Root filesystem has about 340.8 GB free. The prior boot journal ends +without an orderly shutdown entry in the inspected tail. The cable report and +recovery support power interruption; no software shutdown was commanded. + +The interrupted release's staged deb was truncated to 109051904 bytes instead +of 218843100. The original installer archive still matches its SHA256; scripts +and release manifest also match. The launcher's checksum guard rejected the +truncated staging, and no 0.8.38-2 installation had been completed. Existing +staging is preserved. The product launcher now writes into a temporary file, +fsyncs it before publication without overwrite, and fsyncs directories before +opening sudo. It continues to reject modified files and symlinks. Four focused +interruption/idempotency/integrity tests passed locally and on Ubuntu; they are +also added to future Linux build qualification. + +An artifact-owned temporary owner-release build repackaged the unchanged, +qualified 0.8.38-2 deb. The first wrapper lacked two installer inputs and failed +before producing a release; the complete second wrapper passed its four tests. +Wrapper SHA256 a4cbc35f1bf5af5fa62f60920c389fe54c0d5f84cbd574b02cb696d400f4df2d. +New owner release 3f418604a94057e97414d71a SHA256: +b4eb8ba129c4ba6c8ea1750be6a85aba5c2dbdcaa98abb92c56a47d22377e337. +APT simulation remains one Node upgrade with no additions/removals. Its local +Ubuntu installer was opened; owner sudo and live acceptance remain pending. +No installed OS file or controller configuration was manually repaired. + +Owner asks whether the tracks can be fitted and repeats that a contact was +torn from wiring labelled Hall, with side/contact unknown and no soldering or +replacement pins available. Archived canonical FOC receipts confirm LEFT +sensorless and RIGHT Hall calibration; independent and simultaneous unloaded +30-second tests and owner RC checks passed. The separate Hall comparison +remains incomplete, loaded startup is unaccepted, and the previous LEFT +four-state observation alone does not identify a physical broken pin. Owner was +advised to finish the Hall investigation while the drivetrain is unloaded; +there is no new motion authorization and no diagnostic motor run in this entry. + +The owner subsequently authorized necessary unloaded diagnostics and confirmed +tracks removed, rover raised, transmitter off and attendance. Asked about the +means of interrupting the noninterruptible FW 5.02 native Hall cycle, the owner +identified an Anderson battery connector as the only disconnect. Its exact +model and load-break rating are unknown; no instruction to unplug under load +was given. No new Hall or other motor procedure has started. The existing +0.8.38-2 installer still waits at local sudo; installed Node remains 0.8.38-1. + +The owner supplied three transmitter photos. Exterior matches the official +FlySky FS-i6S diagram, with Robcom Venom Drone marking; hardware/firmware +identity remains provisional until its About screen is read. The old receiver +photo identifies FS-iA6B. Official FlySky Mix/Models/failsafe documentation and +VESC 5.02 PPM source were reviewed. Existing archived PPM settings decode as +Duty Cycle on both controllers, with different response curves/ramps; no +configuration was changed. The two requested tank/arcade profiles and their +input/fallback requirements are recorded in 23_ROVER_CONTROL_PROFILES.md. +No native radio mixing capability or live profile switch is claimed accepted. + +Owner clarified the field-control requirement: an enabled, neutral transmitter +may accompany autonomous/remote driving; stick input must immediately take +authority without a UI mode switch and latch out all Core sources until an +explicit neutral handback. This is separate from selecting tank/arcade mapping. +Read-only source audit confirms the native decoded-PPM read and existing bounded +test latch/250 ms PPM-output leases. Neither full radio-channel capture nor a +qualified continuous driving arbiter is claimed. Firmware-side lease expiry +does not cover faulty software that keeps renewing it. The permanent authority +contract and maintenance-calibration exception are recorded in plan 23. No +network scan, radio/receiver change or motor command was performed in this audit. + +Corrective owner release 3f418604a94057e97414d71a completed at +2026-09-24T11:04:21.135514Z, duration 17.846 s. dpkg confirms Node 0.8.38-2 +fully installed; Node and VESC services are active with zero automatic restarts. +Core now admits exactly two verified VESC snapshots at plugin version 0.6.5, +both publishing board-settings capability. The 1x1 profile remains revision 2 +with the same UUID assignments; test_active and rc_latched are false. + +A bounded engineering probe through existing Core/Node SDK actions performed +six read-only vesc.input.read calls, three per controller, without motor, +lease or configuration writes. At 11:05:39–11:05:46Z the RIGHT decoded input +was approximately −0.058 to −0.060 and 1.470–1.471 ms; LEFT was +0.074 to +0.076 +and 1.537–1.538 ms. These are raw inputs near the archived neutral bands, not +proof of radio-link state or independent transmitter switch positions. + +The owner then corrected the switch hypothesis: motors remain controllable +with all four transmitter toggles down. The proposed SWA comparison was +cancelled; initial readings carry an operator-reported condition only and do +not establish causality. No new Hall/FOC procedure or driven test was started. +Private installation, fleet and RC receipts remain in native-probe evidence. + +Owner now requests stop-first RC takeover: first stick input cancels autonomous +and remote motion, subsequent input may drive manually. Clarification was sent +to distinguish another packet of a held stick from a deliberate second gesture +after neutral. Existing direct PWM and expiring test leases do not implement +stop-first; simply releasing the lease would pass the original deflection to +the motor. This limitation and the required command-revocation/neutral boundary +were recorded in plan 23. No live control change or driven test was performed. + +The owner explicitly confirmed the sequence: stop → neutral → manual control. +Plan 23 now records this as accepted, including both input channels, rejection +of delayed Core commands and no automatic autonomous resumption. The first +held deflection cannot become a drive command merely because another packet +arrives or a delay expires. Ordinary gestures once already in manual control +do not repeat the takeover procedure. + +A bounded source audit checked the cached FW 5.02 commit +3f670137e27e6e383fa79c50cc6b1fa85aab1554 against its Git tree blob hashes. +Safe Start is not rearmed by expiry of app-disable output. Persistent app +configuration writes, indefinite output disable and restarting applications +through CAN-mode configuration were rejected as takeover mechanisms. The +current native release sends zero current, not a verified braking or neutral +gate command. A healthy-Mini wait loop cannot guarantee the same gesture +semantics after loss of Mini/USB. Coordinated actuator-side support remains +necessary to qualify the full requirement; no firmware or working RC settings +were changed. The FS-i6S manual's assignable-switch semantics were also recorded; +actual switch assignments and transmitter firmware remain unverified. + +Official VESC documentation identifies stock LispBM support from FW 6.00 as +a candidate for controller-resident input arbitration, not an accepted solution. +It documents PPM value/age and PPM override; direct-command bypass, coordinated +multi-controller behavior and script-failure handling still need qualification. +The hardware marker alone is insufficient for firmware selection. The owner was +asked for the controller manufacturer/model if known. No update or script was +uploaded, and no motion was commanded. + +Owner requested software-only controller identification before considering +physical access. A bounded audit used eight existing read actions through +Core/Node/native Tool: backup, telemetry, PPM and CAN ping for each controller. +All completed. Read-only SSH collected Linux sysfs USB descriptors without +opening serial, resetting ports or changing the host. Both distinct VESC UUIDs +and FW 5.02 / 75_300_R2 were confirmed; USB descriptors and USB serial strings +are identical. Each backup decodes into 151 motor and 149 application parameters. +Core history contains both new backups, and their motor/app SHA-256 values +match the accepted 2026-09-23 21:05 UTC versions. Calibration was preserved. + +Both CAN queries returned no peers; physical wiring is not inferred from that +result. No movement, configuration write, restart or firmware operation occurred. +The manufacturer and commercial board model remain unconfirmed: Flipsky's own +75-series documentation explicitly describes multiple boards using 75_300_R2; +this does not identify these boards as Flipsky. Private receipts, decoded +passports, USB evidence, archive acceptance and hashes are saved under the +identity-audit-20260924 artifacts. Current-version diagnostics do not require +disassembly; exact update compatibility remains a separate evidence requirement. + + +## 2026-09-24 — Hall preflight and sensorless standstill, Node 0.8.39-1 + +Before any Hall cycle, read-only link.check stopped with not_idle: LEFT +reported -161 ERPM, zero wire duty and about 0.08 A. Repeated individual reads +reported -140 to -168 ERPM, zero input current/duty and 0.06–0.10 A motor +current. RIGHT reported zero ERPM. Owner explicitly observed the LEFT leading +sprocket fully stationary. No driven command or native procedure was sent. +Private preflight, idle-read and idle-observed receipts retain UTC/monotonic +identity and raw telemetry; their SHA256 values are respectively +229df6a4abbe55d03e0bbfcbc1331647e2bd6768e6d2b7b16ac9be4736f07734, +adab11366c597579259b53bdecc0b9e602a197b5933665cdcb4201b540a13738, +d39185797ddc1b3e99385e922161aabac1121df67c94922d099eae212cc6825a. + +Pinned FW 5.02 mcpwm_foc.c (3f670137e27e6e383fa79c50cc6b1fa85aab1554) +continues observer and PLL updates while undriven; get_rpm and tachometer use +that phase estimate. Changing tachometer is not independent movement evidence. +This explains why the old ±30 ERPM stationary gate cannot reliably admit an +attended sensorless Hall measurement. It does not prove the observer's error +magnitude or motor state without the operator's physical observation. + +The correction is scoped to Hall measurement. New requests must explicitly +confirm all motors physically stationary. For FOC sensorless mode only, that +observation replaces the speed gate while wire duty must be exactly zero; +finite speed is still required. Other idle electrical/fault/temperature bounds +remain, battery current must be within ±1 A, and fresh telemetry plus PPM +neutral are checked ten times before any lease/current write. Sensored Hall +preflight and every ordinary motor/FOC test retain the existing speed gate. +Raw preflight values and the observation flag are archived in the Hall receipt. +The Tool algorithm, current, duration and no-table-write contract are unchanged. + +Core and Node reuse the existing Hall confirmation control; new clients send +the observation field only to a board advertising the new capability. The +0.6.6 driver rejects old clients lacking the explicit observation. No live +runtime bypass or OS edit is introduced. Package qualification, installation, +observer resynchronization and actual Hall comparison are still pending. + + +Local acceptance: all 109 VESC tests passed, including seven Hall standstill +fault-injection cases. The active Core passed four architecture tests, +TypeScript, all 916 unit tests and production build. Its existing port-8000 +process serves the exact new index SHA256 without a server restart. In-app +browser verification shows the Hall observation text and a disabled measurement +button while unconfirmed, in normal and expanded presentation; no measurement +control was activated. Escape was exercised but did not exit the existing +expanded vehicle surface; this unchanged window behavior is not accepted by +that check and was not modified in the Hall fix. + +Immutable source b6097066b4068f72483c4b68, SHA256 +37896e9d6b7df1b6f0170aab073761aaa81de2ba05cd48ace9508c1bc1749c16, +was transferred and verified on the Mini. Its unprivileged, bounded build is +running; installed runtime remains 0.8.38-2 until owner installation. Exact FW +source blob 416eacbadb2696081d5e85e4342cd798f5d08906 was verified against the +pinned Git tree. Ops instructions endpoint again failed at HTTP transport; +no Ops write was attempted or claimed. + + +Ubuntu qualification completed in 255.491 s; every recorded job passed, +including VESC regressions, Node UI build and Go race tests. Node 0.8.39-1 deb +SHA256 e0ceb0116be62962944657723d30bf14498491c5dc34902be00845d716a87b26 +(218843958 bytes). Owner release f196781a2f4ff411ada25090 SHA256 +09eed256684a2c70b52318e33a6df02a01848feb644f2e50fac6c7129ede0f51. +APT simulation upgrades only mission-core-node 0.8.38-2 → 0.8.39-1 with no +added/removed packages. The artifact opened its local Ubuntu installer; owner +sudo entry and live acceptance are pending. Hall comparison has not started. + + +Owner entered sudo. Installation e96d63c6a3364b25bde5bb3a8813a12c completed +at 2026-09-24T12:10:15.346435Z in 17.851 s. dpkg confirms 0.8.39-1; +Node and VESC services are active/running with NRestarts=0. Fresh Core inventory +confirms exactly the two original UUIDs, plugin 0.6.6, the required Hall +standstill capability and no active/latched procedure. No port reset, firmware +flash or calibration write was performed. Observer resynchronization was +requested before the first actual comparative Hall measurement. + + +Both attended comparative measurements completed through Core → Node → native +VESC Tool, after fresh owner confirmation of stopped motors, removed tracks, +raised rover and transmitter off. LEFT operation began 12:12:16.619604Z and +finished 12:12:41.467723Z; its native cycle lasted 12.224 s. Result: +[255,173,255,120,255,21,255,68], states [1,3,5,7], firmware success false. +Owner observed actual slow backward movement followed by forward movement. +RIGHT began 12:13:12.520652Z and finished 12:13:36.369798Z; native cycle +12.225 s. Result [255,167,97,133,37,198,71,255], states [1,2,3,4,5,6], +firmware success true. Both report unchanged configuration, no configuration +write, confirmed release and no issues. Each receipt contains twenty idle +preflight observations and 119 cycle telemetry samples. LEFT/RIGHT private +receipt SHA256: 5f4972011019b53dc9803b47eeffd357de647ac161a2949b49473ecb702877c1, +1faa352ce8eed1483a46d9c92eb10b5305e3dcaf32ffa98c2833e1103720023f. + +Diagnostic conclusion: LEFT has a reproducible incomplete Hall signal during +observed movement; bit zero remains high in all returned states. RIGHT passes +the canonical six-state measurement. This is consistent with the owner's +damaged contact on the LEFT Hall circuit, but USB cannot distinguish exact +wire/contact, sensor or controller input. The LEFT remains sensorless and the +RIGHT remains Hall-controlled; the fault is localized, not repaired. Loaded +startup and field driving remain unqualified. + +Owner then requests plainly visible sustained forward and reverse rotation +instead of further small Hall movements. Hall comparison is complete; no +additional Hall/FOC cycle is needed. The next product increment will add signed +speed testing to the existing rotation card, retaining current limits and +requiring a separate physically stopped confirmation before each direction. +No automatic reversal of a coasting sensorless motor is authorized by this +implementation plan. Existing native adapter currently admits only 0..3000 +ERPM, so reverse requires a versioned adapter/package update, not a bypass. + + +## 2026-09-24 — attended bidirectional rotation, 0.8.40-1 (qualification) + +Owner requested visible sustained forward/reverse rotation after the completed +comparative Hall diagnosis. Extend the existing single/profile speed test with +a direction selector. Native VESC Tool `Commands::setRpm` receives signed ERPM; +no firmware, sensor mode, inversion setting or calibration rewrite. Bound remains +300–3000 ERPM magnitude, current ceiling 30 A, observed hold duration up to 30 s. +Use signed ramp and speed tolerance; wrong-direction motion never earns hold time. +Each operation starts independently from an explicitly observed physical stop. +No automatic reversal on coasting motors. UI clears the confirmation after each +operation and direction change; older driver capability disables reverse. + +Sensorless FW 5.02 idle PLL drift established in the prior Hall investigation +also affects normal bench preflight. Attended speed/profile/release requests now +require a strict standstill confirmation and ten fresh neutral/electrical samples +from every connected controller. Sensorless ERPM is recorded, not used as physical +standstill proof; finite values, zero PWM, low motor/input current, voltage, thermal, +fault, identity, CAN, receiver, cancellation and pending-restoration gates remain. +Hall-mode speed still blocks; legacy current pulse and FOC retain their old gates. + +Version ownership: Node 0.8.40-1, VESC plugin 0.6.7; native adapter delivered through +the versioned installer/preparation payload only. Bounded Ubuntu native build +383ac3eb5d0bdf9a3e3b3584 completed in 37.10 s with 13 checks, including upstream +positive/negative RPM serialization and denial of hardware in offline mode. +Upstream objects unchanged. Runtime SHA-256 +`dab6dc61fe4ff29f734a69e372e20ec66ffcc36d1513f4f89b2e4c29f311f45a`. +119 Python VESC regressions passed, including reverse 30 s hold, current-limit +restoration, per-direction configured speed bound, missing/invalid observation, +late PWM rejection, observed idle drift, RC preemption and complete-profile reverse. +Installation and physical rotation remain pending at this entry. + +Qualification and handoff: the immutable Node source artifact +`4a5e80a91a7a1526795a3f2c` (SHA-256 +`2fb53048d741c1d3da098163b9b74de688289c8f2b1f52e6197f579f474f06a7`) +passed the bounded Ubuntu qualification suite. Owner installer +`86a0dccb179cf082e6548d64`, SHA-256 +`a1360a296b11d65f90288a40c8b6c4fc8e7d67401ccd3ab74237df2b679caf8a`. +APT simulation upgrades only Node 0.8.39-1 → 0.8.40-1, adds/removes no packages. +Local Ubuntu installer was launched for owner sudo entry. No ad-hoc runtime +or OS patching. Transfer reused a disposable staging copy as rsync basis (previous +immutable source preserved); final source SHA verified before execution. + +Operator Core: 4 architecture tests, TypeScript, 916 unit tests and production +build pass. Only VescMotor.tsx and model.ts were promoted into the active checkout. +Canonical 8000 serves the matching new index +`c71c979185d48c90701d7569b947e1192c9f81be72bccab608517b1ec43c43c6`. +Browser verifies direction control, old-driver reverse disabled, initially +unchecked observation and legible 30 A/30 s fields. No motor command during UI QA. + +Physical acceptance: Node 0.8.40-1 installed successfully in 18.52 s, finished +2026-09-24T12:36:40.136111Z. Node and VESC services active, zero restarts; fresh +Core inventory verified both UUIDs with plugin 0.6.7 and signed-speed capability. +After fresh owner confirmation (tracks off, raised rig, TX off, observed physical +stop), complete 1x1 profile ran at +3000 ERPM, 30 A ceiling per motor, with +30.0447 s common hold. Owner saw both rotate evenly and confirmed full stop. +Then separately authorized -3000 ERPM yielded 30.0474 s common hold; owner saw +both rotate backwards at similar speed and stop. Both operations outcome duration, +release confirmed, limits restored, fault codes zero, no transport failures. +Forward steady current was approximately 2.7/3.0 A: 30 A is a ceiling, not a forced +current while holding speed. Original calibration/configuration remained in use. +Raw operation and installation receipts with hashes are retained privately in +native-probe/rotation-040-*; these attest unloaded rotation only. Left Hall hardware +fault remains localized and unrepaired; RC profiles and new takeover protocol are +separate outstanding work, not implied by this bench acceptance. + + +RC/failsafe follow-up began with read-only Core/Node operations and owner-confirmed +TX off / raised stationary rig. Both configuration backups saved; both inputs +within configured neutral, zero PWM/input current and fault 0. Different existing +PPM ramp/expo settings recorded; no configuration write. Details and remaining +acceptance gates: docs/node/24_RC_FAILSAFE_ACCEPTANCE.md. No new package needed +for initial reads. Direct Ops MCP instruction retrieval again failed at HTTP +transport; no Tasker write claimed or alternate card API used. + +2026-09-24, unattended development authorized by the owner for approximately +15 minutes: no additional hardware test or configuration write was performed. +The previously nonneutral TX-on capture was clarified by the owner as manual +stick movement, not spontaneous startup. Follow-up neutral evidence and the +pending TX About/Failsafe questions are recorded in 24_RC_FAILSAFE_ACCEPTANCE.md. + +Added an isolated executable prototype in packages/rover-control and the +Control Station developer-only rover-control-preview tool. Tank/Arcade mixing, +semantic-axis mapping, UUID fan-out (2/4/10 motors), strict desired-profile JSON, +and stop → neutral → manual authority transitions are tested without a hardware +adapter. RC takeover observes its configured full axis set even when Arcade +uses only one stick to drive. Missing axes do not become neutral. Existing +MotorTest authority, firmware, system services and package versions are intact. + +Validation: 21 final focused behavioural tests passed; 4 architecture checks, +Control Station typecheck and a 935-test full UI pass completed before the final +extra monitored-axis test. Preview typecheck and minified self-contained build +passed after the final change. Browser visual QA is not claimed: the Browser +use URL policy rejected navigation to the local HTML artifact; no alternate +browser/server workaround was attempted. Network is disabled in its CSP, and +source has no serial/Core transport. Private artifact manifest/logs/trace live +in outputs/rover-006-control-prototype. The running Core still returns two +verified online VESCs with test_active=false. No installer or password needed. + +This prototype does not solve independent receiver fallback or qualify physical +stopping. Current FW 5.02 does not provide the age/live-link evidence or the +independent stop-first enforcement required by the reference contract. Production +profile activation stays absent pending that integration. Model timing constants +are synthetic fixtures, not measured rover safety limits. Direct Ops MCP remains +unavailable at HTTP transport; local evidence is preserved without claiming an +Ops update. Rollback: remove the unreferenced prototype/tool artifacts only; +there is no installed board change to roll back. + +2026-09-24 RC failsafe bench acceptance: owner found Functions/Failsafe on the +touchscreen and reported 0% on all ten channels. An initial right-side attempt +returned the stick before removing the TX battery, so it was explicitly excluded +as loss-of-radio stopping evidence. The repeated right-side test held the stick +until battery extraction; owner confirmed immediate observed stop. Same procedure +on LEFT also physically stopped. Final TX restoration with both sticks neutral +caused no motion, confirmed by the owner and zero PWM in recorded telemetry. +Completed private captures b208d87f / cc289f6f / 6811a686, with full identifiers +and hashes in docs/node/24_RC_FAILSAFE_ACCEPTANCE.md. No runtime/firmware/settings +write, output lease, alive or Core motor command; only shipped input.read and +telemetry.read. This qualifies the observed unloaded RC loss/neutral restoration +scenario, not exact response latency, loaded braking, Mini-independent new +stop-first arbitration or repaired LEFT Hall hardware. Continuing read-only TX +Mix/channel inspection for the requested profile integration. + + +## 2026-09-25 — observation channel, Node 0.8.41-1 + +Versioned Node 0.8.41-1 / VESC integration 0.7.0 adds the separate paired mTLS +remote-control channel and native watchdog. Native source artifact +ad291aa57e2b828f14619673 and Node source artifact 4f5ded93cde5b3d271fbef5c +were built and qualified in bounded Mini staging. Node qualification peaked at +2,907,676,672 bytes under its 3 GiB ceiling. Native offline checks, 127 VESC tests, +Go race tests, DG packages/catalog/registry, Node UI and environment/USB/startup +qualification passed without driving hardware during the build. + +Package SHA-256 e0d4d43ed026b3f8e841492793942cfd1b7cb761da1da2c2e40951b9e120de04. +Owner installer a4f1163407e76521b6139ab6, SHA-256 +076576893e54b69377910d8e1bcf85596629e9d6644e0895af743be01ca2badf. +Its plan upgraded only mission-core-node 0.8.40-1 to 0.8.41-1; no other package +addition/removal. The owner-authorized local Ubuntu installation completed; +dpkg version and active Node/VESC services were read back over SSH. The package +owns every installed file and service change; no manual board runtime patch. +Rollback uses the preceding versioned owner-release artifact, not hand edits. + +Core 8000 receives fresh RIGHT samples through the new channel, supported=true, +state=observing, session_id=null, controlling=false. LEFT is not enumerated in +Linux; its profile UUID binding survives. USB descriptor errors -71 predate +installation; owner confirms reconnecting USB/power. No motor command or USB +reset was issued in this implementation session. Physical root cause and +both-controller channel acceptance remain open. Attended remote movement, +release/blur/timeout and RC stop-neutral-manual acceptance are still outstanding. +Stock FW 5.02 does not guarantee that protocol independently of a failed Mini. + +Full implementation, UI acceptance and limitations are recorded in +25_OBSERVATION_AND_REMOTE_CONTROL.md; private hashes, qualification and raw +read-only evidence are under outputs/rover-006-observation-control-20260925. + + +### Follow-up 0.8.41-2 — observed sensorless idle and session completion + +Owner restored both USB controllers by disconnecting/reconnecting the battery +at 11:35 MSK, not by restarting Mission Core. Twenty read-only samples contained +both assigned UUIDs. One freshly authorized API trial at 11:38:46 was rejected +before movement at reported sensorless duty=0.001; no positive demand or volatile +limit write was reached. Pinned FW computes that quantity from measured phase +voltages even undriven, so the previous exact-zero admission assumption was +incorrect. Follow-up permits one encoded quantum only in the explicitly +owner-observed sensorless case, while retaining current, fault, temperature, +voltage, RC neutral and physical-observation requirements. + +The same follow-up reports ordinary terminal input leases as stopped after +cleanup. Failed release/restoration still reports fault. Actual loop tests now +exercise expiry after sending speed through the session wrapper, plus a failed +controller during cleanup. All 130 VESC tests pass. + +First qualification stopped on a mismatched declared driver version; the Node +model registry was corrected to 0.7.1. A subsequent passing package was +superseded before installation to include normal-session completion. Final +source artifact b889d129c87de5b811c0ee6c, SHA-256 +74c48e186d8877c92d1501b7dbbd43943e476c4f2b8cf89a136b1d8aea227981, +passed the complete bounded Mini qualification. Package SHA-256 +fb1a484ddaad2e8ff3dc1ca99d7ba6f5fb5d1d78b9075e04643a727eba100f9e, +218,851,386 bytes. Owner installer f1a536e5d42ef73dabf1d7f0, SHA-256 +9b71a345928c198a68fad9af0f8a426ef0cf3ff8c79deb0d33729fbd88d5aa09. +APT simulation upgrades only Node 0.8.41-1 to 0.8.41-2, no additions/removals. +Owner entered the local Ubuntu authorization. Installer completed successfully +at 2026-09-25 09:08:23 UTC (24.9 seconds), all steps exit 0. Installed package +0.8.41-2 and VESC runtime 0.7.1 verified by read-only SSH; both services active. +After observation refresh both assigned UUIDs report fresh values (14–205 ms), +zero currents/duty/faults, no active control session. Evidence: postinstall +receipt and API samples in the private observation-control evidence directory. +Attended movement retry remains pending fresh owner observation. No firmware, +calibration or persistent electrical-limit change is part of this package. + +### Follow-up 0.8.41-3 — diagnose preparation lease cancellation + +Owner authorized one bounded forward trial after installing 0.8.41-2. The first +helper call at 12:18:51 MSK declined stale telemetry before arm. Read-only refresh +restored both fresh UUIDs; the armed trial at 12:19:28 ended stopped about five +seconds into preparation, with no forward demand sent. Browser/Core heartbeats +continued at approximately 100 ms. Neither Node nor VESC restarted. The cause +of the lease cancellation was not preserved by the earlier transport logging. + +The follow-up adds only a bounded private channel diagnostic flight recorder +to Node. VESC runtime remains 0.7.1; native Tool, deadlines, current limits and +output behavior are unchanged. Source artifact a93510d58156a2ec1ed8fa74, SHA-256 +ea46f9e4666523a02cf869565c65ab982763f053fa43987dd019cc97e059ee76. +Slow SCP was stopped before build execution; delta staging from the existing +source artifact produced an identical full SHA-256, verified before launch. +Qualification runs in the artifact-owned 3 GiB / 150% CPU temporary cgroup. +Installation and further physical acceptance remain pending. + +Qualification completed at 09:33:39 UTC in 263.8 seconds: Go race tests including +the bounded diagnostic history cases, Node UI, environment, installer and all +130 VESC tests passed. Package SHA-256 +bb7d23fe7ae134d1249b60e92aebbd5f09ef40a165ff12dec77f821950b1b8e3, +218,855,416 bytes. Installer c400214393c2c5c4c26e6489, SHA-256 +48cfe00d4d54cf3825177deafb097efb66cb8a9d2038657e680502e0ca271a8b. +APT simulation changes only mission-core-node 0.8.41-2 to 0.8.41-3, no other +package additions/removals. Before launch Core reports no active session and +stopped. The local Ubuntu installer was opened; OS authorization is pending. + +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. + +### Follow-up 0.8.42-1 — separate commands from telemetry round trips + +The 12:38 and 12:46 MSK attended attempts ended during preparation without a +forward command. Node diagnostics localized short-lease consumption to serial +request/response timing. TCP_NODELAY reduced some delays but did not resolve +the admission failure. No new USB fault was established by these attempts. + +The new Node streams latest Core intent independently from telemetry and feeds +the local owner at 20 Hz. Commands carry their original Core monotonic expiry; +conservative clock bounds subtract network time rather than starting a new +400 ms lease on receipt. Reconnect must cross an acknowledged null command, +and interrupted IDs are retained even if they never reached the driver. +Native VESC runtime remains 0.7.1, its output watchdog remains 200 ms, and the +firmware, motor calibration and electrical limits are unchanged. + +First source 6a008b44ecdd7d4ba550795b passed bounded Linux qualification but was +superseded before installation to include the undelivered-session tombstone +regression. Final source 4721a12daa5599d3e6cd3e19, SHA-256 +5420a70de810a80234cb586695109c2e38f857dfe1721f103dac2c2ad1cccc4e, +466,985,414 bytes, is undergoing the same qualification. Both staging copies +were transferred as deltas and fully SHA-256 verified before execution. No +installed Mini files or system configuration were patched. + +Core transport.py, registry.py and rover_control.py were promoted after 58 +Fleet regressions passed against both development and active operator sources. +The existing canonical launchd service was restarted only with no active +session; 8000 and fresh telemetry for both assigned UUIDs recovered. Legacy +Node 0.8.41-3 remains installed until the final package passes qualification +and the versioned owner installer completes. New motion acceptance is pending. + +Final qualification completed: all 18 jobs exit 0, including Go race tests and +130 VESC tests. Package 0.8.42-1 is 218,865,950 bytes, SHA-256 +b091b9ced7310e6aacae12c6911170e3acbd4f80138e3fe65863a49a6e2a8bc0. +Installer f80c3eb98fef89e31e93b076 is 218,888,341 bytes, SHA-256 +2ce07d7b266a1ae31640bcdeec9bdd8c8c945099f4ef8368bb5ff502ede07b1a. +Its fresh APT simulation upgrades only mission-core-node 0.8.41-3 to 0.8.42-1, +with no new or removed packages. No active control session was present before +launching its local Ubuntu window. Owner OS authorization is pending; do not +confuse opening the installer with installation or movement acceptance. + +Owner authorization completed; 0.8.42-1 installed at 10:22:27 UTC in 18.1 s, +all steps exit 0. Node and VESC services are active. Ten-second read-only +verification contained both fresh UUIDs, zero currents/faults and no active +session. A fresh owner-observed trial at 10:25:53 UTC nevertheless ended in +preparation without forward demand. Stream/local-loop timing improved, but +one retained update crossed the prior command's deadline by roughly 20 ms. +The session remained retired as designed; no automatic motion retry. + +Candidate 0.8.42-2 removes periodic waiting for new commands on Core and Node, +without increasing the 400 ms expiry or native output watchdog. Core: 59 tests +passed. Node uses a coalescing signal, not a command queue, and retains stopped +session tombstones. Qualification/installation is pending; 0.8.42-1 remains +the installed predecessor. + +Final 0.8.42-2 source: 82ecb65d554eb431616494ee, SHA-256 +3b36ee72e923a44c392706b8fb8c159cc22360ec7e42f740efbd367d8481ff54. +Candidate cb7441a86c04cb2cdd91652c passed qualification but was superseded +before installation to reject malformed/non-integral command sequences before +comparing latest intent. This prevents invalid JSON types from disrupting the +Go receiver. The final candidate is undergoing the same bounded qualification. + +Final qualification passed all 18 jobs. Package 0.8.42-2: 218,867,020 bytes, +SHA-256 0be7052996013522f2240b44e0fca2284e6b357675f83fa8a6210ce61e1027b1. +Installer fd7d351f1882ed2131cd5057: 218,889,411 bytes, SHA-256 +74fdb94bc10cf8a54d82e62e58f1539f6a179c2f9b151f2dd021edde9580f311. +APT plan upgrades only mission-core-node 0.8.42-1 to 0.8.42-2, no additions +or removals. No active session before opening the local Ubuntu installer; +owner OS authorization is pending. No further motion has been attempted. + +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. + +Final source c2d98db61de6722f2a77c4ed qualified: all 18 Linux jobs exit 0. +Source SHA-256 a0854257a11a97dba7f544203a0babde835d144d9ab897f46f28e4f3e6643461. +Node package 0.8.43-1: 218,867,200 bytes, SHA-256 +09fa9bba4595673d1b763cb49d6caee6d5f2519e08ed667e733a07867810ae98. +Installer 38c4764476007a22c2c594a3: 218,889,591 bytes, SHA-256 +cb195603fc5bc490bb2d64d3a7201b71b58479ea5af309963e13b931e05b9379. +The plan upgrades only mission-core-node from 0.8.42-2; no new or removed +packages. No active control session before launching the local installer. +Owner OS authorization is pending. Firmware and calibration remain unchanged; +no further motion has been attempted after the sixth failed preparation. + +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. + + +2026-09-25 13:21 UTC — operator startup UI regression fix only. +Node remains 0.8.45-1 / VESC plugin 0.7.4; no OS, firmware or motor-config +change. Fixed stale-read revocation of a newly acquired session and silent +Manage availability. One first-click neutral-only preparation completed; +explicit Stop confirmed release and both motors zero. 307 samples, no read +errors/faults/motion; 938 unit tests, typecheck, architecture and build pass. +See 25_OBSERVATION_AND_REMOTE_CONTROL.md and private entry-startup-result.json. diff --git a/docs/node/19_VESC_OPERATOR_CALIBRATION.md b/docs/node/19_VESC_OPERATOR_CALIBRATION.md index 28ec7ae..e156759 100644 --- a/docs/node/19_VESC_OPERATOR_CALIBRATION.md +++ b/docs/node/19_VESC_OPERATOR_CALIBRATION.md @@ -57,16 +57,43 @@ и недостаточное движение при измерении. Номер физического сломанного контакта не определяется по таблице без проверки распиновки и проводки. +Для подготовки к измерению в Node 0.8.39-1 / plugin 0.6.6 оператор отдельно +подтверждает неподвижность всех моторов. Бессенсорная прошивка может сообщать +ненулевые ERPM и изменение тахометра при физической остановке: оба значения +вычисляются из оценки положения ротора. Приложение сохраняет эти показания, +а перед измерением повторно проверяет нейтраль приёмника, отсутствие PWM, +малый ток и отсутствие ошибок. Это исключение действует только для +наблюдаемого измерения Холлов в бессенсорном режиме, не для управления +движением. Установка и реальные результаты новой версии фиксируются отдельно +в журнале испытаний. + ## Назначение и проверка вращения Назначение «левый/правый» связывает постоянный UUID VESC с местом мотора на аппарате. Оно нужно для адресного и общего управления, но не влияет на измеряемое сопротивление или параметр потерь. После смены USB-порта назначение -сохраняется. Общий список назначений сейчас отображается в каждой карточке -VESC; это обзор профиля аппарата, а не перечень моторов внутри одного VESC. +сохраняется. Общий список назначений находится в разделе «Настройки борта»; +это профиль аппарата, а не перечень моторов внутри одного VESC. «Проверка вращения» запускается отдельно от калибровки. Скорость задаётся в ERPM, ток задаёт верхний предел, длительность считается после разгона и удержания скорости. Выбор всех моторов профиля запускает совместную проверку; для 1×1 это два мотора. Успешная проверка на вывешенном приводе не заменяет проверку под нагрузкой или надёжности связи. + + +### Visible forward/reverse bench rotation + +After Hall measurement, use **Проверка вращения** for sustained visible motion. +Select one controller or the complete assigned profile, then **Направление +вращения → Прямое / Обратное**, speed magnitude, motor-current ceiling and hold +duration. Direction is relative to each VESC's existing settings, not a certified +vehicle heading. Current is an upper torque limit, not a speed setting. + +Observe all motors fully stopped, raised and free before confirming each start. +Wait for physical stop before changing direction. There is no automatic forward/ +reverse sequence. On admitted sensorless firmware, idle ERPM alone cannot prove +standstill; the preflight records that estimate alongside electrical checks. +Timing begins after speed settles; preparation/ramp/gaps are excluded. Compare +VESC observations with actual movement. This is an unloaded bench test, not +acceptance of loaded starts or the unresolved left Hall hardware fault. diff --git a/docs/node/20_VESC_POWER_LIMITS_PLAN.md b/docs/node/20_VESC_POWER_LIMITS_PLAN.md new file mode 100644 index 0000000..4068754 --- /dev/null +++ b/docs/node/20_VESC_POWER_LIMITS_PLAN.md @@ -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-*. diff --git a/docs/node/21_STARTUP_AND_USB_RECOVERY.md b/docs/node/21_STARTUP_AND_USB_RECOVERY.md new file mode 100644 index 0000000..2a67032 --- /dev/null +++ b/docs/node/21_STARTUP_AND_USB_RECOVERY.md @@ -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). diff --git a/docs/node/22_BOARD_SETTINGS_SURFACE.md b/docs/node/22_BOARD_SETTINGS_SURFACE.md new file mode 100644 index 0000000..61f4789 --- /dev/null +++ b/docs/node/22_BOARD_SETTINGS_SURFACE.md @@ -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; переключатель не объявляется действующим до реализации и проверки +реального пути команд. diff --git a/docs/node/23_ROVER_CONTROL_PROFILES.md b/docs/node/23_ROVER_CONTROL_PROFILES.md new file mode 100644 index 0000000..ea86995 --- /dev/null +++ b/docs/node/23_ROVER_CONTROL_PROFILES.md @@ -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`, раздел «Нейтраль после возврата питания». +Профиль одного стика и постоянный перехват этим чтением не приняты. diff --git a/docs/node/24_RC_FAILSAFE_ACCEPTANCE.md b/docs/node/24_RC_FAILSAFE_ACCEPTANCE.md new file mode 100644 index 0000000..8f67876 --- /dev/null +++ b/docs/node/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`. Наблюдатель +завершился штатно; постоянный процесс опроса не оставлен. diff --git a/docs/node/25_OBSERVATION_AND_REMOTE_CONTROL.md b/docs/node/25_OBSERVATION_AND_REMOTE_CONTROL.md new file mode 100644 index 0000000..7d296db --- /dev/null +++ b/docs/node/25_OBSERVATION_AND_REMOTE_CONTROL.md @@ -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. diff --git a/packages/rover-control/README.md b/packages/rover-control/README.md new file mode 100644 index 0000000..8fa845b --- /dev/null +++ b/packages/rover-control/README.md @@ -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. diff --git a/packages/rover-control/src/authority.ts b/packages/rover-control/src/authority.ts new file mode 100644 index 0000000..59dcc27 --- /dev/null +++ b/packages/rover-control/src/authority.ts @@ -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>; + drives: Record; + 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> = {}; + 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> = {}; + 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)); + } +} diff --git a/packages/rover-control/src/profile.ts b/packages/rover-control/src/profile.ts new file mode 100644 index 0000000..b5ba470 --- /dev/null +++ b/packages/rover-control/src/profile.ts @@ -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 = 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 { + 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 = 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; +} diff --git a/plugins/insta360-x4/packaging/owner_release_entry.py b/plugins/insta360-x4/packaging/owner_release_entry.py index d3a2a1a..548ee61 100644 --- a/plugins/insta360-x4/packaging/owner_release_entry.py +++ b/plugins/insta360-x4/packaging/owner_release_entry.py @@ -7,6 +7,7 @@ import platform import re import subprocess import sys +import tempfile import zipfile from pathlib import Path @@ -24,11 +25,44 @@ PROFILES = { } +def sync_directory(path): + directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + def private(path): path.mkdir(mode=0o700, exist_ok=True) info = path.lstat() if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077: raise RuntimeError("Release directory is not private and owned") + sync_directory(path.parent) + + +def stage_file(path, data, mode): + """Publish complete, durable installer files before opening the sudo UI.""" + if path.is_symlink(): + raise ValueError("Unexpected release symlink") + if path.exists(): + with path.open("rb") as stream: + if stream.read() != data: + raise ValueError("Existing release was modified") + os.fsync(stream.fileno()) + else: + descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + # Do not overwrite another launcher or a modified staged file. + os.link(temporary, path) + finally: + os.unlink(temporary) + sync_directory(path.parent) def main(): @@ -74,16 +108,7 @@ def main(): raise ValueError("Release payload changed") files["release.json"] = raw for name, data in files.items(): - path = folder / name - if path.is_symlink(): - raise ValueError("Unexpected release symlink") - if path.exists(): - if path.read_bytes() != data: - raise ValueError("Existing release was modified") - else: - with path.open("xb") as stream: - stream.write(data) - path.chmod(0o700 if name == "install" else 0o600) + stage_file(folder / name, data, 0o700 if name == "install" else 0o600) print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True) if sys.argv[1] == "--plan": result = subprocess.run( diff --git a/plugins/insta360-x4/packaging/test_owner_release_entry.py b/plugins/insta360-x4/packaging/test_owner_release_entry.py new file mode 100644 index 0000000..c8c98cc --- /dev/null +++ b/plugins/insta360-x4/packaging/test_owner_release_entry.py @@ -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() diff --git a/plugins/vesc/native/engine_main.cpp b/plugins/vesc/native/engine_main.cpp index 568a6fc..9f7d2f5 100644 --- a/plugins/vesc/native/engine_main.cpp +++ b/plugins/vesc/native/engine_main.cpp @@ -35,8 +35,16 @@ public: bool queryRunning = false; bool allowHardware = false; QByteArray lastMotor, lastApplication, lastHall; + QTimer outputWatchdog; Engine() { + outputWatchdog.setSingleShot(true); + QObject::connect(&outputWatchdog, &QTimer::timeout, [&] { + if (allowHardware && vesc.isPortConnected() && !procedureRunning) { + vesc.commands()->setCurrent(0); + try { flush(); } catch (...) {} + } + }); require(Utility::configLoadLatest(&vesc), "Upstream resources missing"); packet = vesc.findChild(); require(packet, "Upstream packet transport missing"); @@ -249,15 +257,19 @@ public: if (method == "query") return {{"payload", QString::fromLatin1(query( int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}}; if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}}; - if (method == "lease") { vesc.commands()->disableAppOutput(250, false); flush(); return {}; } + if (method == "lease") { + require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller"); + vesc.commands()->disableAppOutput(250, false); flush(); outputWatchdog.start(200); return {}; + } if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; } require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller"); if (method == "current") { + require(outputWatchdog.isActive(), "Output lease expired"); auto current = number(request, "current_a", 0, 30); require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded"); vesc.commands()->setCurrent(current); flush(); return {}; } - if (method == "rpm") { vesc.commands()->setRpm(int(number(request, "erpm", 0, 3000))); flush(); return {}; } + if (method == "rpm") { require(outputWatchdog.isActive(), "Output lease expired"); vesc.commands()->setRpm(int(number(request, "erpm", -3000, 3000))); flush(); return {}; } if (method == "limits") { auto p = request.value("parameters").toObject(); MCCONF_TEMP conf; diff --git a/plugins/vesc/native/offline_main.cpp b/plugins/vesc/native/offline_main.cpp index d0679be..91a7cd2 100644 --- a/plugins/vesc/native/offline_main.cpp +++ b/plugins/vesc/native/offline_main.cpp @@ -73,6 +73,15 @@ int main(int argc, char **argv) { {"legacy_power_loss_correction", commands->getMaxPowerLossBug()}, {"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())}, {"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}}); + QJsonArray rpmExamples; + QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) { + if (!data.isEmpty() && quint8(data.at(0)) == COMM_SET_RPM) + rpmExamples.append(QString::fromLatin1(data.toBase64())); + }); + commands->setRpm(3000); + commands->setRpm(-3000); + require(rpmExamples.size() == 2 && !vesc.isPortConnected(), "Offline signed RPM serialization failed"); + result.insert("offline_signed_rpm_examples_base64", rpmExamples); result.insert("archive_sha256", sha256(raw)); result.insert("ok", true); } catch (const std::exception &error) { diff --git a/plugins/vesc/packaging/native-runtime.json b/plugins/vesc/packaging/native-runtime.json index e8618e4..e155f7f 100644 --- a/plugins/vesc/packaging/native-runtime.json +++ b/plugins/vesc/packaging/native-runtime.json @@ -4,13 +4,13 @@ "upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20", "os": "ubuntu-24.04-amd64", "file": "mission-core-vesc-native-runtime.tar.gz", - "bytes": 50352726, - "sha256": "cc78c273e025f0738ed79f94d4bc6a70e504f2e7204b1ef694119e6a230b913a", - "engine_sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325", + "bytes": 50354000, + "sha256": "4473819387682089a814a6c558065e3e6a4c2fe67266c0ed82ad49cea1444aad", + "engine_sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590", "files": { "bin/mission-core-vesc-engine": { - "sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325", - "bytes": 24801544 + "sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590", + "bytes": 24801840 }, "lib/libGL.so.1": { "sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027", @@ -257,8 +257,8 @@ "bytes": 4724 }, "licenses/engine_main.cpp": { - "sha256": "e789004d14185140bda21c10a9d7d8f0b48174a9b5053ec1d70755842b1888de", - "bytes": 20913 + "sha256": "e4b32dc5c9def71208d8daad830d5c47852f39f85a321713abc45dc15173b4fc", + "bytes": 21539 }, "licenses/gir1.2-glib-2.0.copyright": { "sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211", diff --git a/plugins/vesc/packaging/native_probe.py b/plugins/vesc/packaging/native_probe.py index 34f2def..c006fc1 100644 --- a/plugins/vesc/packaging/native_probe.py +++ b/plugins/vesc/packaging/native_probe.py @@ -113,6 +113,11 @@ def run(args): packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"]) if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000: raise RuntimeError("Expected native 5.02 detect correction was not applied") + rpm_packets = [base64.b64decode(p) for p in native["offline_signed_rpm_examples_base64"]] + if (len(rpm_packets) != 2 or any(len(p) != 5 or p[0] != 8 for p in rpm_packets) + or [int.from_bytes(p[1:], "big", signed=True) for p in rpm_packets] != [3000, -3000]): + raise RuntimeError("Upstream signed RPM serialization mismatch") + report["checks"].append({"id": "signed-rpm-serialization-%d" % index, "ok": True}) report["checks"].append({"id": "native-archive-%d" % index, "ok": True, "archive_sha256": hashlib.sha256(raw).hexdigest(), "motor_parameter_count": len(native["motor"]["parameters"]), diff --git a/plugins/vesc/packaging/prepare.py b/plugins/vesc/packaging/prepare.py index 6f5851b..ebae100 100644 --- a/plugins/vesc/packaging/prepare.py +++ b/plugins/vesc/packaging/prepare.py @@ -32,7 +32,7 @@ def prepare(): lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller", - "version": "0.6.3", "run_id": uuid.uuid4().hex, "started_at": time.time(), + "version": "0.7.4", "run_id": uuid.uuid4().hex, "started_at": time.time(), "monotonic_started": time.monotonic(), "state": "running", "steps": []} def publish(): diff --git a/plugins/vesc/runtime/__init__.py b/plugins/vesc/runtime/__init__.py index 89c21a1..5cf8079 100644 --- a/plugins/vesc/runtime/__init__.py +++ b/plugins/vesc/runtime/__init__.py @@ -1,5 +1,5 @@ """Onboard VESC discovery, archive and bounded motor identification service.""" -VERSION = "0.6.3" +VERSION = "0.7.4" MODEL = "vesc.controller" SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2" diff --git a/plugins/vesc/runtime/drive_profile.py b/plugins/vesc/runtime/drive_profile.py index 8c2a8bf..b2b8503 100644 --- a/plugins/vesc/runtime/drive_profile.py +++ b/plugins/vesc/runtime/drive_profile.py @@ -19,7 +19,11 @@ class DriveProfile: raise ValueError("Профиль привода изменился. Обновите карточку.") bindings = dict(current["bindings"]) layout = current["layout"] - if action == "vesc.drive.assign": + if action == "vesc.drive.layout": + layout = params["layout"] + if set(bindings) - set(LAYOUTS[layout]): + raise ValueError("Сначала снимите назначения задних моторов.") + elif action == "vesc.drive.assign": layout, slot = params["layout"], params["slot"] if set(bindings) - set(LAYOUTS[layout]): raise ValueError("Сначала снимите назначения задних моторов.") @@ -37,10 +41,13 @@ class DriveProfile: def validate(action, params): - keys = {"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"} + keys = {"revision", "layout"} if action == "vesc.drive.layout" else ({"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"}) if set(params) != keys or type(params["revision"]) is not int or params["revision"] < 0: raise ValueError("Invalid drive profile revision") - if action == "vesc.drive.assign": + if action == "vesc.drive.layout": + if params["layout"] not in LAYOUTS: + raise ValueError("Invalid drive layout") + elif action == "vesc.drive.assign": if params["layout"] not in LAYOUTS or params["slot"] not in ("", *LAYOUTS[params["layout"]]): raise ValueError("Invalid drive layout or slot") elif params["slot"] not in LAYOUTS["2x2"]: diff --git a/plugins/vesc/runtime/foc_calibration.py b/plugins/vesc/runtime/foc_calibration.py index 7eeb9eb..bd5e16e 100644 --- a/plugins/vesc/runtime/foc_calibration.py +++ b/plugins/vesc/runtime/foc_calibration.py @@ -11,6 +11,8 @@ import re import uuid from .protocol import firmware, values +from .configuration import decode +from .receiver import neutral_band def fresh_values(owner, devices): @@ -63,6 +65,8 @@ def reconcile(owner, devices): actual = device.link.query(code) if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True): raise ValueError("Post-calibration configuration changed") + if kind == "application": + owner.receiver_bands[device.id] = neutral_band(decode(actual, kind)) owner.neutral(devices) after = fresh_values(owner, devices) if any(abs(v["motor_current_a"]) > 1 or abs(v["erpm"]) > 30 or abs(v["duty"]) > .01 or v["fault_code"] != 0 for v in after.values()): diff --git a/plugins/vesc/runtime/group_test.py b/plugins/vesc/runtime/group_test.py index a660744..618a8f6 100644 --- a/plugins/vesc/runtime/group_test.py +++ b/plugins/vesc/runtime/group_test.py @@ -38,7 +38,7 @@ def batch(pool, devices, function): return result -def run(owner, command, devices, moving, originals, backups, unchanged): +def run(owner, command, devices, moving, originals, backups, unchanged, preflight): from .motor_test import Rejected, check_values params = command["parameters"] duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a")) @@ -64,7 +64,7 @@ def run(owner, command, devices, moving, originals, backups, unchanged): value = values(device.link.query(4, timeout=.06)) if device.id in ids else None return level, value observed = batch(pool, devices, read) - if any(abs(level) > .02 for level, _ in observed.values()): + if any(owner.receiver_active(identifier, level) for identifier, (level, _) in observed.items()): owner.state("rc") raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.") now = owner.monotonic() @@ -129,7 +129,7 @@ def run(owner, command, devices, moving, originals, backups, unchanged): elif not owner.latched: owner.state("ready") owner.active, owner.mode = False, None return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed", - "profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm, + "preflight": preflight, "profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm, "duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure, "samples": samples, "release": cleanup, "release_confirmed": confirmed, "limits_restored": restored, "after": after, "backups": backups} diff --git a/plugins/vesc/runtime/hall_detection.py b/plugins/vesc/runtime/hall_detection.py index 31bbbc9..c2b79e4 100644 --- a/plugins/vesc/runtime/hall_detection.py +++ b/plugins/vesc/runtime/hall_detection.py @@ -19,7 +19,7 @@ def parse_result(raw): "observed_states": observed, "valid_six_states": raw[9] == 0 and len(observed) == 6} -def measure(owner, devices, target, original, backups, unchanged): +def measure(owner, devices, target, original, backups, unchanged, preflight): motor = decode(original, "motor") if motor["m_sensor_port_mode"] != 0: from .motor_test import Rejected @@ -46,7 +46,7 @@ def measure(owner, devices, target, original, backups, unchanged): unchanged() for device in devices: incoming = ppm(device.link.query(31, timeout=0.06)) - if abs(incoming["level"]) > 0.02: + if owner.receiver_active(device.id, incoming["level"]): owner.state("rc") if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle") sample = values(target.link.query(4, timeout=0.06)) @@ -92,6 +92,7 @@ def measure(owner, devices, target, original, backups, unchanged): owner.state("rc") owner.active, owner.mode = False, None return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall", + "preflight": preflight, "current_a": 5, "elapsed_s": owner.monotonic() - started, "completed": completed, "measurement": result, "issues": issues, "configuration_restored": restored, "configuration_written": False, diff --git a/plugins/vesc/runtime/limits_view.py b/plugins/vesc/runtime/limits_view.py new file mode 100644 index 0000000..350682a --- /dev/null +++ b/plugins/vesc/runtime/limits_view.py @@ -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 diff --git a/plugins/vesc/runtime/link_check.py b/plugins/vesc/runtime/link_check.py index cd3431c..7ac3cf6 100644 --- a/plugins/vesc/runtime/link_check.py +++ b/plugins/vesc/runtime/link_check.py @@ -1,6 +1,6 @@ """Bounded idle transport measurement through the installed native owners. -Only PPM/telemetry reads, no leases, motor commands or configuration writes. +Only application/PPM/telemetry reads, no leases, motor commands or configuration writes. The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget; it never relaxes the motor-control deadline or authorizes powered operation. """ @@ -11,6 +11,8 @@ import math import time from .protocol import ppm, values +from .configuration import decode +from .receiver import active, neutral_band def summary(samples): @@ -30,8 +32,21 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]: raise ValueError("Controller sessions changed") samples = {d.id: [] for d in devices} + bands = {} failure = None stop_reason = "complete" + def interrupted(): + cancelled = service.motor.cancelled_at + if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")): + return "stopped" + if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2: + return "deadline" + return None + def failed(device, code, before, error): + return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error), + "elapsed_ms": (monotonic()-before)*1000, + "native_rpc": getattr(error, "native_rpc", None), + "native_history": getattr(error, "native_history", [])} def read(device): for code in (31, 4): before = monotonic() @@ -42,7 +57,7 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot "native_rpc": getattr(device.link, "last_rpc", None)} if code == 31: sample["ppm_level"] = reading["level"] - idle = abs(reading["level"]) <= .02 + idle = not active(reading["level"], bands[device.id]) else: sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"], duty=reading["duty"], fault_code=reading["fault_code"]) @@ -50,24 +65,28 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot samples[device.id].append(sample) if not idle: return {"device_id": device.id, "reason": "not_idle"} except (OSError, ValueError, TimeoutError) as error: - return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error), - "elapsed_ms": (monotonic()-before)*1000, - "native_rpc": getattr(error, "native_rpc", None), - "native_history": getattr(error, "native_history", [])} + return failed(device, code, before, error) return None with ExitStack() as locks: for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock) if any(d.link is None for d in devices): raise ValueError("Controller unavailable") + for device in devices: + if reason := interrupted(): + stop_reason = reason; break + before = monotonic() + try: + bands[device.id] = neutral_band(decode(device.link.query(17), "application")) + except (OSError, ValueError, TimeoutError) as error: + failure = [failed(device, 17, before, error)] + stop_reason = "read_failed"; break # Same cadence and per-controller query order as group rotation, with a # larger read-only deadline to expose latency instead of destroying it. with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool: for _ in range(100): + if stop_reason != "complete": break cycle = monotonic() - cancelled = service.motor.cancelled_at - if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")): - stop_reason = "stopped"; break - if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2: - stop_reason = "deadline"; break + if reason := interrupted(): + stop_reason = reason; break futures = [pool.submit(read, d) for d in devices] errors = [error for future in futures if (error := future.result()) is not None] if errors: @@ -76,4 +95,5 @@ def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monot return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started, "outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500, "failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(), - "summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}} + "neutral_band": bands.get(d.id), "summary": summary(samples[d.id]), + "samples": samples[d.id]} for d in devices}} diff --git a/plugins/vesc/runtime/motor_test.py b/plugins/vesc/runtime/motor_test.py index b5a93d6..d6766df 100644 --- a/plugins/vesc/runtime/motor_test.py +++ b/plugins/vesc/runtime/motor_test.py @@ -19,6 +19,7 @@ from .configuration import decode from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS from .speed_hold import SpeedHold from .temporary_limits import TemporaryLimits +from .receiver import active as receiver_active, neutral_band class Rejected(ValueError): @@ -44,7 +45,7 @@ def check_configuration(identity, motor, app): raise Rejected("Нужна проверка токовых ограничений.") -def check_values(value, moving=False, current_a=2, motor=None): +def check_values(value, moving=False, current_a=2, motor=None, *, standstill_confirmed=False): current_limit = max(5, current_a * 1.2 + 2) if moving else 1 speed_limit = TEST_LIMITS["max_erpm"] if moving else 30 duty_limit = TEST_LIMITS["max_duty"] if moving else 0.01 @@ -52,6 +53,20 @@ def check_values(value, moving=False, current_a=2, motor=None): current_limit = min(current_limit, motor["l_current_max"]) speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"]) duty_limit = min(duty_limit, motor["l_max_duty"]) + # FW 5.02 continues its observer/PLL while undriven. Sensorless ERPM and + # tachometer share that estimate, so neither proves physical standstill. + # Only explicitly attended Hall/speed/release operations may substitute + # observation. Current, modulation bounds and sensored ERPM still apply. + observed_sensorless = (standstill_confirmed is True and not moving + and motor is not None and motor["motor_type"] == 2 + and motor["foc_sensor_mode"] == 0) + if observed_sensorless: + # FW 5.02 mcpwm_foc.c calculates duty_now from measured phase voltages + # even in the undriven branch. COMM_GET_VALUES quantizes it to 1/1000; + # one idle quantum is not proof that PWM is enabled. Permit at most + # that quantum only with fresh operator-confirmed physical standstill. + # Current/voltage/temperature/fault and neutral-window guards remain. + duty_limit = 0.001 bounds = ( ("fault_code", 0, 0, "код ошибки VESC", "", 1), ("input_voltage_v", 20, 60, "напряжение питания", "В", 1), @@ -62,8 +77,13 @@ def check_values(value, moving=False, current_a=2, motor=None): ) for field, low, high, label, unit, scale in bounds: actual = value[field] - if not math.isfinite(actual) or not low <= actual <= high: + estimated_speed = field == "erpm" and observed_sensorless + if not math.isfinite(actual) or (not estimated_speed and not low <= actual <= high): raise LimitExceeded(field, actual, low, high, label, unit, scale) + if standstill_confirmed: + actual = value["input_current_a"] + if not math.isfinite(actual) or abs(actual) > 1: + raise LimitExceeded("input_current_a", actual, -1, 1, "ток батареи", "А") class MotorTest: @@ -75,6 +95,7 @@ class MotorTest: self.cancelled_at = None self.active = False self.mode = None + self.receiver_bands = {} self.limits = TemporaryLimits(service, atomic) self.authority = service.root / "motor-authority.json" # A process restart during a test cannot silently grant another pulse. @@ -96,11 +117,16 @@ class MotorTest: def neutral(self, devices): for device in devices: value = ppm(device.link.query(31, timeout=0.06)) - if abs(value["level"]) > 0.02: + if self.receiver_active(device.id, value["level"]): self.state("rc") raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.") - def run(self, command, devices, target, release=False): + def receiver_active(self, identifier, level): + if identifier not in self.receiver_bands: + raise Rejected("Нейтраль приёмника ещё не проверена по конфигурации VESC.") + return receiver_active(level, self.receiver_bands[identifier]) + + def run(self, command, devices, target, release=False, remote=None): if not 1 <= len(devices) <= 128 or len({d.id for d in devices}) != len(devices): raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.") duration = command["parameters"]["duration_s"] @@ -115,13 +141,14 @@ class MotorTest: hall_mode = command["action_id"] == "vesc.hall.measure" foc_mode = command["action_id"] == "vesc.foc.calibrate" budget = duration + (20 if speed_mode else 0) - if self.latched and not release: + if self.latched and not release and remote is None: raise Rejected("Управление удерживается за пультом. Верните его явно после нейтрали.") requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")) if (datetime.now(timezone.utc) - requested).total_seconds() > 10: raise Rejected("Команда устарела до начала проверки. Повторите запрос.") deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00")) def preflight(): + if remote is not None: remote.ensure_live() if self.stop.is_set(): raise Rejected("Проверка отменена.") if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5: raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.") @@ -134,6 +161,8 @@ class MotorTest: raise Rejected("Проверка отменена до начала исполнения.") self.stop.clear() with ExitStack() as stack: + # Never reuse an earlier operation's neutral configuration. + self.receiver_bands = {} for device in sorted(devices, key=lambda d: d.id): stack.enter_context(device.lock) recovered = None @@ -144,6 +173,8 @@ class MotorTest: except (OSError, ValueError, TimeoutError, KeyError) as error: raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error identities, applications, backups, originals = {}, {}, [], {} + motors, idle_samples = {}, [] + observed_standstill = (hall_mode or speed_mode or release) and command["parameters"].get("standstill_confirmed") is True target_motor, target_raw = None, None for device in devices: preflight() @@ -159,7 +190,9 @@ class MotorTest: configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))} originals[device.id] = configs motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application") + motors[device.id] = motor check_configuration(identity, motor, app) + self.receiver_bands[device.id] = neutral_band(app) if device in moving: if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]): raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.") @@ -169,11 +202,13 @@ class MotorTest: target_raw = configs["motor"] if foc_mode and any(abs(motor[key]) <= 0.001 for key in ("l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm")): raise Rejected("Для калибровки нужны ненулевые сохранённые пределы питания и настройки запуска FOC.") - if speed_mode and command["parameters"]["erpm"] > min(SPEED_LIMITS["max_erpm"], motor["l_max_erpm"] * 0.8): + if speed_mode and abs(command["parameters"]["erpm"]) > min(SPEED_LIMITS["max_erpm"], + (motor["l_max_erpm"] if command["parameters"]["erpm"] > 0 else -motor["l_min_erpm"]) * 0.8): raise Rejected("Заданная скорость превышает настроенный диапазон контроллера.") - if speed_mode and command["parameters"]["erpm"] < motor["s_pid_min_erpm"]: + if speed_mode and abs(command["parameters"]["erpm"]) < motor["s_pid_min_erpm"]: raise Rejected(f"Минимальная скорость регулятора этого VESC: {motor['s_pid_min_erpm']:g} ERPM.") - check_values(values(device.link.query(4))) + check_values(values(device.link.query(4)), motor=motor, + standstill_confirmed=observed_standstill) identities[device.id], applications[device.id] = identity, app identifier = "op_" + uuid.uuid4().hex backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id, @@ -205,13 +240,22 @@ class MotorTest: preflight() unchanged() self.neutral(devices) + if observed_standstill: + for device in devices: + sample = values(device.link.query(4, timeout=0.06)) + check_values(sample, motor=motors[device.id], + standstill_confirmed=observed_standstill) + idle_samples.append({"device_id": device.id, "at": self.monotonic(), + "sensor_mode": motors[device.id]["foc_sensor_mode"], "values": sample}) self.sleep(0.1) + preflight_record = {"standstill_confirmed": observed_standstill, "samples": idle_samples} if release: self.state("ready") - return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered} + return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered, "preflight": preflight_record} if hall_mode: from .hall_detection import measure - return measure(self, devices, target, target_raw, backups, unchanged) + return measure(self, devices, target, target_raw, backups, unchanged, + preflight_record) if foc_mode: from .foc_calibration import calibrate return calibrate(self, command, devices, target, originals, backups, unchanged) @@ -219,8 +263,11 @@ class MotorTest: if (deadline - now).total_seconds() < budget + 1: raise Rejected("Команда устарела до запуска. Повторите проверку.") if group_mode: + if remote is not None: + self.state("testing") + return remote.drive(self, command, devices, moving, originals, unchanged) from .group_test import run - return run(self, command, devices, moving, originals, backups, unchanged) + return run(self, command, devices, moving, originals, backups, unchanged, preflight_record) self.state("testing") self.active = True self.mode = "speed" if speed_mode else "current" @@ -337,7 +384,7 @@ class MotorTest: "mode": "speed" if speed_mode else "current", "rotation_s": hold.rotation_s if hold else None, "erpm_target": hold.erpm if hold else None, "limits_restored": restored, "current_ramp_a_per_s": TEST_LIMITS["current_ramp_a_per_s"], - "test_limits": TEST_LIMITS, + "test_limits": TEST_LIMITS, "preflight": preflight_record, "duration_limit_s": duration, "outcome": outcome, "samples": samples, "limit_violation": limit_violation, "failure": failure, "release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups} diff --git a/plugins/vesc/runtime/native_link.py b/plugins/vesc/runtime/native_link.py index 97e0c1b..e15b421 100644 --- a/plugins/vesc/runtime/native_link.py +++ b/plugins/vesc/runtime/native_link.py @@ -71,7 +71,9 @@ class NativeLink: started = time.monotonic() deadline = started + timeout trace = {"method": method, "command": parameters.get("command"), - "timeout_ms": parameters.get("timeout_ms", timeout*1000)} + "timeout_ms": parameters.get("timeout_ms", timeout*1000), + "attachment": {"usb": self.attachment.usb, "address": self.attachment.address, + "tty": self.attachment.tty}} try: trace["stage"] = "attachment_before" self.check() diff --git a/plugins/vesc/runtime/protocol.py b/plugins/vesc/runtime/protocol.py index cc87d76..72bb070 100644 --- a/plugins/vesc/runtime/protocol.py +++ b/plugins/vesc/runtime/protocol.py @@ -27,7 +27,8 @@ TEST_LIMITS = {"min_current_a": 0.5, "max_current_a": 30, "min_duration_s": 0.5, # A separate action/capability keeps old clients from silently changing modes. SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600, "startup_timeout_s": 15, "settle_s": 1, "speed_tolerance": 0.15, - "lost_speed_timeout_s": 2, "duration_basis": "measured_speed"} + "lost_speed_timeout_s": 2, "duration_basis": "measured_speed", + "reverse_supported": True, "standstill_confirmation_required": True} def frame(data): @@ -36,7 +37,7 @@ def frame(data): def speed_packet(erpm): - if type(erpm) not in (int, float) or not 0 <= erpm <= SPEED_LIMITS["max_erpm"]: + if type(erpm) not in (int, float) or not -SPEED_LIMITS["max_erpm"] <= erpm <= SPEED_LIMITS["max_erpm"]: raise ValueError("Invalid test speed") return frame(bytes([8]) + struct.pack(">i", round(erpm))) diff --git a/plugins/vesc/runtime/receiver.py b/plugins/vesc/runtime/receiver.py new file mode 100644 index 0000000..05b8d4c --- /dev/null +++ b/plugins/vesc/runtime/receiver.py @@ -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 diff --git a/plugins/vesc/runtime/remote_control.py b/plugins/vesc/runtime/remote_control.py new file mode 100644 index 0000000..9966af1 --- /dev/null +++ b/plugins/vesc/runtime/remote_control.py @@ -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") diff --git a/plugins/vesc/runtime/server.py b/plugins/vesc/runtime/server.py index ef66839..25bb2e5 100644 --- a/plugins/vesc/runtime/server.py +++ b/plugins/vesc/runtime/server.py @@ -13,6 +13,7 @@ from http.server import BaseHTTPRequestHandler from urllib.parse import urlsplit, parse_qs from .service import Service +from .remote_control import RemoteControl class Handler(BaseHTTPRequestHandler): @@ -48,14 +49,15 @@ class Handler(BaseHTTPRequestHandler): elif self.command == "GET" and self.path.startswith("/archive-export?"): after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0]) result = self.server.service.archive.export("local", after) - elif self.command == "POST" and self.path == "/operation": + elif self.command == "POST" and self.path in ("/operation", "/remote"): size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= 65536 or self.headers.get("Content-Type") != "application/json": raise ValueError("Invalid command") raw = self.rfile.read(size) if len(raw) != size: raise ValueError("Truncated command") - result = self.server.service.execute(json.loads(raw)) + result = (self.server.service.execute(json.loads(raw)) if self.path == "/operation" + else self.server.service.remote.feed(json.loads(raw))) else: raise ValueError("Unknown route") except (ValueError, KeyError, TypeError, OSError): @@ -102,6 +104,7 @@ def main(): raise RuntimeError("VESC must run as its own unprivileged user") os.umask(0o007) service = Service("/var/lib/mission-core-vesc") + service.remote = RemoteControl(service) stop = threading.Event() def scan(): @@ -113,6 +116,11 @@ def main(): pass stop.wait(2) + def observe(): + while not stop.is_set(): + service.remote.observe() + stop.wait(.2) + path = Path("/run/mission-core-vesc/driver.sock") path.unlink(missing_ok=True) with Server(str(path), Handler) as server: @@ -120,6 +128,7 @@ def main(): server.service = service thread = threading.Thread(target=scan, daemon=True) thread.start() + threading.Thread(target=observe, daemon=True, name="vesc-observer").start() try: server.serve_forever() finally: diff --git a/plugins/vesc/runtime/service.py b/plugins/vesc/runtime/service.py index aac6c38..3774ade 100644 --- a/plugins/vesc/runtime/service.py +++ b/plugins/vesc/runtime/service.py @@ -23,7 +23,7 @@ try: except ImportError: # Source checkout; packaging copies this exact shared file. from k1link.device_plugins.vesc.archive import Archive -ACTIONS = frozenset({"verify", "details", "vesc.link.check", "vesc.telemetry.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"}) +ACTIONS = frozenset({"verify", "details", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"}) def utc(): @@ -164,7 +164,7 @@ class Service: "preparation_safe": True, "online": True, "usb": attachment.speed, "connection_label": "USB " + attachment.usb + next((" · " + slot_label(self.drive.value["layout"], slot) for slot, binding in self.drive.value["bindings"].items() if binding["device_id"] == identifier), ""), "layers": [], "vesc_status": {"identity": identity if unique else None, "readable": unique and Device.readable_identity(identity), - "engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched}, + "engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "board_settings_supported": True, "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False, "standstill_confirmation_required": True}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched}, "snapshot": {"context": {"session_id": d["session"], "device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc", "plugin_version": VERSION, "model_id": MODEL}, @@ -186,13 +186,15 @@ class Service: if action not in ACTIONS or not isinstance(params, dict): raise ValueError("Unsupported operation") if action in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.control.release"): - keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set()) + keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"standstill_confirmed"} if action != "vesc.motor.pulse" else set()) | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set()) if (set(params) != keys or params["rig_clear"] is not True or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128 or type(params["current_a"]) not in (int, float) or not TEST_LIMITS["min_current_a"] <= params["current_a"] <= TEST_LIMITS["max_current_a"] or type(params["duration_s"]) not in (int, float) or not TEST_LIMITS["min_duration_s"] <= params["duration_s"] <= TEST_LIMITS["max_duration_s"]): raise ValueError("Explicit raised-rig confirmation, duration and controller sessions required") - if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= params["erpm"] <= SPEED_LIMITS["max_erpm"]): + if action != "vesc.motor.pulse" and params["standstill_confirmed"] is not True: + raise ValueError("Explicit observation of all motors at standstill required") + if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= abs(params["erpm"]) <= SPEED_LIMITS["max_erpm"]): raise ValueError("Speed is outside the supported range") if action == "vesc.drive.run" and (type(params["profile_revision"]) is not int or params["profile_revision"] < 0 or not isinstance(params["device_ids"], list) or not 2 <= len(params["device_ids"]) <= 128 @@ -200,17 +202,19 @@ class Service: or len(set(params["device_ids"])) != len(params["device_ids"])): raise ValueError("Explicit complete drive profile required") elif action in ("vesc.hall.measure", "vesc.foc.calibrate"): - extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else set() + extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else {"standstill_confirmed"} if (set(params) != ({"sessions", "rig_clear", "native_cycle_confirmed"} | extra) or params["rig_clear"] is not True or params["native_cycle_confirmed"] is not True or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128): raise ValueError("Native procedure and rig confirmation required") - if extra and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150): + if action == "vesc.hall.measure" and params["standstill_confirmed"] is not True: + raise ValueError("Explicit observation of all motors at standstill required") + if action == "vesc.foc.calibrate" and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150): raise ValueError("Heating budget must be between 10 and 150 W") elif action == "vesc.link.check": if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128: raise ValueError("Controller sessions required") - elif action in ("vesc.drive.assign", "vesc.drive.unassign"): + elif action in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"): validate_drive(action, params) elif params != {}: raise ValueError("This operation has no parameters") @@ -265,7 +269,7 @@ class Service: record["receipt"] = {"state": "complete", "result": result} atomic(path, record) return record["receipt"] - if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign"): + if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"): if device.identity is None: raise Rejected("Сначала подтвердите личность VESC.") try: result = self.drive.update(command["action_id"], command["parameters"], device) @@ -304,11 +308,16 @@ class Service: raise ValueError("Controller identity changed") result = {"identity": actual, "device_id": device.id, "observed_at": utc()} action = command["action_id"] - if action in {"vesc.telemetry.read", "vesc.config.backup"} and not device.readable: + if action in {"vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup"} and not device.readable: raise ValueError("Firmware read layout is unsupported") if action == "vesc.telemetry.read": result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic()) device.telemetry = result + elif action == "vesc.limits.read": + from .limits_view import read_limits + result.update(parameters=read_limits(device.link)) + if firmware(device.link.query(0)) != actual: + raise ValueError("Controller changed during configuration read") elif action == "vesc.input.read": if actual["version"] != "5.02": raise ValueError("Input layout unsupported") result["input"] = ppm(device.link.query(31)) @@ -340,6 +349,14 @@ class Service: record["receipt"] = {"state": "complete", "result": result} except (OSError, ValueError, TimeoutError) as error: record["receipt"] = {"state": "error", "error": str(error) if isinstance(error, Rejected) else "Операция не выполнена. Проверьте связь и совместимость контроллера."} + if getattr(error, "native_rpc", None) is not None: + # Keep transport evidence in the receipt, not product copy. + # This covers ordinary reads and preflight before a motor + # procedure has its own result/failure envelope. + record["receipt"]["result"] = {"failure": { + "type": type(error).__name__, "native_rpc": error.native_rpc, + "native_history": getattr(error, "native_history", []), + }} atomic(path, record) return record["receipt"] diff --git a/plugins/vesc/runtime/speed_hold.py b/plugins/vesc/runtime/speed_hold.py index 5bc4ac2..9251609 100644 --- a/plugins/vesc/runtime/speed_hold.py +++ b/plugins/vesc/runtime/speed_hold.py @@ -21,7 +21,7 @@ class SpeedHold: if self.tachometer is not None and value["tachometer"] != self.tachometer: self.motion_at = now self.tachometer = value["tachometer"] - good = (abs(value["erpm"] - self.erpm) <= self.erpm * SPEED_LIMITS["speed_tolerance"] + good = (abs(value["erpm"] - self.erpm) <= abs(self.erpm) * SPEED_LIMITS["speed_tolerance"] and self.motion_at is not None and now - self.motion_at <= 0.25) error = None if self.hold_started is None: @@ -48,5 +48,5 @@ class SpeedHold: if now - self.started > SPEED_LIMITS["startup_timeout_s"] + self.duration + 5: error = "Истёк общий срок проверки; заданное время вращения не набрано." done = self.rotation_s >= self.duration - setpoint = min(self.erpm, max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"])) + setpoint = (1 if self.erpm > 0 else -1) * min(abs(self.erpm), max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"])) return setpoint, done, error diff --git a/plugins/vesc/runtime/temporary_limits.py b/plugins/vesc/runtime/temporary_limits.py index 7f95d06..322f190 100644 --- a/plugins/vesc/runtime/temporary_limits.py +++ b/plugins/vesc/runtime/temporary_limits.py @@ -73,7 +73,9 @@ class TemporaryLimits: raise ValueError("Limits changed outside this operation; restoration pending") if actual_raw != raw: state = values(device.link.query(4)) - if abs(state["motor_current_a"]) > 1 or abs(ppm(device.link.query(31))["level"]) > 0.02: + from .receiver import active, neutral_band + application = decode(device.link.query(17), "application") + if abs(state["motor_current_a"]) > 1 or active(ppm(device.link.query(31))["level"], neutral_band(application)): raise ValueError("Wait for zero current and neutral before restoring limits") device.link.set_temporary_limits(old) if device.link.query(14) != raw: raise ValueError("Original configuration readback failed") diff --git a/plugins/vesc/tests/test_drive_profile.py b/plugins/vesc/tests/test_drive_profile.py index f95e280..759bb77 100644 --- a/plugins/vesc/tests/test_drive_profile.py +++ b/plugins/vesc/tests/test_drive_profile.py @@ -6,6 +6,19 @@ import test_reader class DriveTests(test_reader.ServiceTests): + def test_change_layout_keeps_assignments_without_motor_commands(self): + left, right = list(self.service.devices.values()) + self.assign(left, 'left.1') + self.assign(right, 'right.1') + command = self.command(action='vesc.drive.layout') + command['parameters'] = {'layout': '2x2', 'revision': 2} + result = self.service.execute(command) + self.assertEqual(result['state'], 'complete') + self.assertEqual(result['result']['layout'], '2x2') + self.assertEqual(result['result']['bindings']['left.1']['device_id'], left.id) + self.assertEqual(len(result['result']['bindings']), 2) + self.assertTrue(all(link.commands == [0] for link in test_reader.FakeLink.instances)) + def assign(self, device, slot, layout='1x1', revision=None): item=next(i for i in self.service.inventory('node_synthetic')['items'] if i['id']==device.id) command=self.command(item,action='vesc.drive.assign') diff --git a/plugins/vesc/tests/test_foc_calibration.py b/plugins/vesc/tests/test_foc_calibration.py index 3896098..724a7e3 100644 --- a/plugins/vesc/tests/test_foc_calibration.py +++ b/plugins/vesc/tests/test_foc_calibration.py @@ -149,7 +149,7 @@ class CalibrationTests(unittest.TestCase): self.assertFalse(receipt['result']['release_confirmed']) self.assertTrue(receipt['result']['configuration_verified']) self.case.devices[0].link.query = query - release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release' + release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True result = self.case.service.execute(release)['result'] self.assertEqual(result['authority'], 'ready') self.assertFalse(result['calibration_recovered']['calibration_replayed']) @@ -165,7 +165,7 @@ class CalibrationTests(unittest.TestCase): key = self.case.devices[0].id self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5}) before = self.configs[key] - release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release' + release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True receipt = self.case.service.execute(release) self.assertEqual(receipt['state'], 'error') self.assertEqual(self.configs[key], before) @@ -175,6 +175,6 @@ class CalibrationTests(unittest.TestCase): def test_unknown_completion_cannot_be_cleared_by_neutral_return(self): self.case.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}} self.case.service.execute(self.command()) - release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release' + release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True self.assertEqual(self.case.service.execute(release)['state'], 'error') self.assertTrue((self.case.service.root/'calibration-pending.json').exists()) diff --git a/plugins/vesc/tests/test_group_test.py b/plugins/vesc/tests/test_group_test.py index c50ae44..e77ce13 100644 --- a/plugins/vesc/tests/test_group_test.py +++ b/plugins/vesc/tests/test_group_test.py @@ -56,7 +56,7 @@ class GroupTests(unittest.TestCase): def command(self): command = self.case.command_for_pulse() command['action_id'] = 'vesc.drive.run' - command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4, + command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4, standstill_confirmed=True, device_ids=[d.id for d in self.case.devices]) command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat() return command diff --git a/plugins/vesc/tests/test_hall_standstill.py b/plugins/vesc/tests/test_hall_standstill.py new file mode 100644 index 0000000..f261f07 --- /dev/null +++ b/plugins/vesc/tests/test_hall_standstill.py @@ -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) diff --git a/plugins/vesc/tests/test_limits_view.py b/plugins/vesc/tests/test_limits_view.py new file mode 100644 index 0000000..647481e --- /dev/null +++ b/plugins/vesc/tests/test_limits_view.py @@ -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() diff --git a/plugins/vesc/tests/test_link_check.py b/plugins/vesc/tests/test_link_check.py index 806d8d2..2db5919 100644 --- a/plugins/vesc/tests/test_link_check.py +++ b/plugins/vesc/tests/test_link_check.py @@ -1,6 +1,7 @@ """No-power read measurements and failure attribution; no USB hardware.""" from unittest.mock import patch import unittest +import struct import test_motor_test from runtime.link_check import measure, summary @@ -30,9 +31,9 @@ class LinkCheckTests(unittest.TestCase): result=self.run_check() self.assertEqual(result['outcome'],'complete') self.assertFalse(result['motor_commands_sent']) - self.assertEqual(len(self.reads),400) - self.assertEqual({r[1] for r in self.reads},{31,4}) - self.assertTrue(all(r[2]==.5 for r in self.reads)) + self.assertEqual(len(self.reads),402) + self.assertEqual({r[1] for r in self.reads},{17,31,4}) + self.assertTrue(all(r[2]==.5 for r in self.reads if r[1]!=17)) self.assertEqual(self.case.sent,[]) self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values())) self.assertGreaterEqual(result['duration_s'],9.9) @@ -45,7 +46,9 @@ class LinkCheckTests(unittest.TestCase): def test_no_response_keeps_peer_command_and_native_cause(self): device=self.case.devices[1] + original=device.link.query def query(code,timeout=2): + if code==17:return original(code,timeout) error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True} raise error device.link.query=query @@ -61,7 +64,24 @@ class LinkCheckTests(unittest.TestCase): result=self.run_check() self.assertEqual(result['outcome'],'not_idle') self.assertEqual(self.case.sent,[]) - self.assertEqual({r[1] for r in self.reads},{31}) + self.assertEqual({r[1] for r in self.reads},{17,31}) + + def test_receiver_offset_inside_configured_deadband_is_idle(self): + for device in self.case.devices: + original=device.link.query + device.link.query=lambda code,timeout=2,original=original: bytes([31])+struct.pack('>ii',-66000,1466000) if code==31 else original(code,timeout) + result=self.run_check() + self.assertEqual(result['outcome'],'complete') + self.assertTrue(all(abs(d['neutral_band']-.15)<1e-6 for d in result['devices'].values())) + self.assertEqual(self.case.sent,[]) + + def test_config_failure_is_reported_before_ppm_or_motor_commands(self): + self.case.devices[0].link.query=lambda *args,**kwargs: b'bad config' + result=self.run_check() + self.assertEqual(result['outcome'],'read_failed') + self.assertEqual(result['failure'][0]['command'],17) + self.assertEqual(self.case.sent,[]) + self.assertFalse(any(r[1] in (31,4) for r in self.reads)) def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self): command=self.command() @@ -71,7 +91,7 @@ class LinkCheckTests(unittest.TestCase): result=self.case.service.execute(command) self.assertEqual(result['state'],'complete') self.assertEqual(self.case.service.execute(command),result) - self.assertEqual(len(self.reads),400) + self.assertEqual(len(self.reads),402) self.case.service.operation_lock.acquire() try: with self.assertRaises(ValueError):self.case.service.execute(self.command()) diff --git a/plugins/vesc/tests/test_native_link.py b/plugins/vesc/tests/test_native_link.py index 7aca8f4..a5770b9 100644 --- a/plugins/vesc/tests/test_native_link.py +++ b/plugins/vesc/tests/test_native_link.py @@ -8,11 +8,13 @@ from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from runtime.native_link import NativeLink +from runtime.serial import Attachment class NativeBoundaryTests(unittest.TestCase): def link(self, handler): value = object.__new__(NativeLink) + value.attachment = Attachment('2-1', '11', 'ttyACM0', '12') value.buffer = b""; value.sequence = 0; value.hall_pending = False value.check = lambda:None script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n' @@ -73,6 +75,7 @@ class NativeBoundaryTests(unittest.TestCase): with self.assertRaises(OSError) as caught: link.query(31,timeout=.06) trace=caught.exception.native_rpc self.assertEqual(trace['command'],31) + self.assertEqual(trace['attachment'], {'usb':'2-1','address':'11','tty':'ttyACM0'}) self.assertEqual(trace['timeout_ms'],60) self.assertTrue(trace['process_alive']) self.assertTrue(trace['attachment_present']) diff --git a/plugins/vesc/tests/test_receiver.py b/plugins/vesc/tests/test_receiver.py new file mode 100644 index 0000000..c1c657f --- /dev/null +++ b/plugins/vesc/tests/test_receiver.py @@ -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 diff --git a/plugins/vesc/tests/test_remote_control.py b/plugins/vesc/tests/test_remote_control.py new file mode 100644 index 0000000..cb5abe9 --- /dev/null +++ b/plugins/vesc/tests/test_remote_control.py @@ -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]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'}) diff --git a/plugins/vesc/tests/test_reverse_speed.py b/plugins/vesc/tests/test_reverse_speed.py new file mode 100644 index 0000000..1faef0c --- /dev/null +++ b/plugins/vesc/tests/test_reverse_speed.py @@ -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) diff --git a/plugins/vesc/tests/test_speed_and_hall.py b/plugins/vesc/tests/test_speed_and_hall.py index 094a47e..320b832 100644 --- a/plugins/vesc/tests/test_speed_and_hall.py +++ b/plugins/vesc/tests/test_speed_and_hall.py @@ -57,7 +57,7 @@ class RunTests(unittest.TestCase): def run_command(self): command = self.case.command_for_pulse() command['action_id'] = 'vesc.motor.run' - command['parameters'].update(erpm=1200,current_a=30,duration_s=30) + command['parameters'].update(erpm=1200,current_a=30,duration_s=30,standstill_confirmed=True) from datetime import datetime,timedelta command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat() return command @@ -119,7 +119,7 @@ class RunTests(unittest.TestCase): def test_native_hall_returns_measured_table_without_applying_it(self): command=self.case.command(action='vesc.hall.measure') - command['parameters']={'rig_clear':True,'native_cycle_confirmed':True, + command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True, 'sessions':{d.id:d.session for d in self.case.devices}} receipt=self.case.service.execute(command) self.assertEqual(receipt['state'],'complete',receipt) @@ -138,7 +138,7 @@ class RunTests(unittest.TestCase): def test_native_hall_requires_its_own_explicit_confirmation(self): command=self.case.command(action='vesc.hall.measure') - command['parameters']={'rig_clear':True,'native_cycle_confirmed':False, + command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':False, 'sessions':{d.id:d.session for d in self.case.devices}} with self.assertRaises(ValueError):self.case.service.execute(command) self.assertIsNone(self.hall_started) @@ -165,7 +165,7 @@ class RunTests(unittest.TestCase): def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self): self.case.devices[0].link.detect_hall=lambda:None command=self.case.command(action='vesc.hall.measure') - command['parameters']={'rig_clear':True,'native_cycle_confirmed':True, + command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True, 'sessions':{d.id:d.session for d in self.case.devices}} result=self.case.service.execute(command)['result'] self.assertFalse(result['completed']) @@ -202,5 +202,5 @@ class HoldClockTests(unittest.TestCase): failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1])) self.assertFalse(failure['valid_six_states']) self.assertEqual(failure['observed_states'],[3]) - for value in (float('nan'),float('inf'),-1,3001,True): + for value in (float('nan'),float('inf'),-3001,3001,True): with self.assertRaises(ValueError):speed_packet(value) diff --git a/tests/fleet/test_board_layout.py b/tests/fleet/test_board_layout.py new file mode 100644 index 0000000..5e50780 --- /dev/null +++ b/tests/fleet/test_board_layout.py @@ -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 diff --git a/tests/fleet/test_pairing.py b/tests/fleet/test_pairing.py index 5e9ea29..3f833d1 100644 --- a/tests/fleet/test_pairing.py +++ b/tests/fleet/test_pairing.py @@ -84,6 +84,25 @@ def cert(row): serialization.Encoding.DER ) +def test_rover_stream_requires_current_pairing_and_binding(setup): + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + row = fleet.find(public["id"]) + body = {"schema": SCHEMA, "node_id": row["node_id"], + "binding_id": row["binding"]["binding_id"], "relay_id": "a"*32} + certificate = cert(row) + status, result = fleet.receive(certificate, "/v1/node/rover-stream", body) + assert status == 200 + assert result['command'] is None + assert result['control_clock']['instance'] == fleet.rover_control.clock_id + status, _ = fleet.receive(certificate, "/v1/node/rover-stream", {**body, "binding_id": "other"}) + assert status == 410 + row['enrollment'] = 'revoked' + fleet.save(row) + status, _ = fleet.receive(certificate, "/v1/node/rover-stream", body) + assert status == 410 + def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup): fleet, _, _, _ = setup diff --git a/tests/fleet/test_rover_control.py b/tests/fleet/test_rover_control.py new file mode 100644 index 0000000..a535114 --- /dev/null +++ b/tests/fleet/test_rover_control.py @@ -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) diff --git a/tests/fleet/test_rover_stream_transport.py b/tests/fleet/test_rover_stream_transport.py new file mode 100644 index 0000000..56e23a6 --- /dev/null +++ b/tests/fleet/test_rover_stream_transport.py @@ -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() diff --git a/tests/test_planning_active_response.py b/tests/test_planning_active_response.py new file mode 100644 index 0000000..d2d7833 --- /dev/null +++ b/tests/test_planning_active_response.py @@ -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"} diff --git a/tools/rover-scene/export_rover.py b/tools/rover-scene/export_rover.py new file mode 100644 index 0000000..401ef69 --- /dev/null +++ b/tools/rover-scene/export_rover.py @@ -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('