feat(control-station): add rover scene telemetry and guarded keyboard control
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import {useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import '@nodedc/ui-core/styles.css';
|
||||
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
|
||||
import {trace} from './trace';
|
||||
import './preview.css';
|
||||
|
||||
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
|
||||
const percent=(n:number)=>`${Math.round(n*100)}%`;
|
||||
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
|
||||
const storageKey='missioncore.rover-control-preview.v1';
|
||||
function load():ControlProfile {
|
||||
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
|
||||
catch{return {...defaultProfile};}
|
||||
}
|
||||
function App(){
|
||||
const [profile,setProfile]=useState(load);
|
||||
const [axes,setAxes]=useState<Axes>(emptyAxes);
|
||||
const [open,setOpen]=useState(['settings','preview']);
|
||||
const [saved,setSaved]=useState('');
|
||||
const [light,setLight]=useState(false);
|
||||
const change=(patch:Partial<ControlProfile>)=>{
|
||||
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
|
||||
};
|
||||
const result=mix(profile,axes);
|
||||
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
|
||||
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
|
||||
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
|
||||
function save(){
|
||||
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
|
||||
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
|
||||
}
|
||||
function download(){
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
|
||||
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
return <main data-nodedc-ui>
|
||||
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
|
||||
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
|
||||
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
|
||||
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
|
||||
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
|
||||
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
|
||||
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
|
||||
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
|
||||
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
|
||||
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
|
||||
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
|
||||
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
|
||||
{saved&&<p role="status">{saved}</p>}
|
||||
</SettingsCard>},
|
||||
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
|
||||
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
|
||||
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
|
||||
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
|
||||
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
|
||||
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
|
||||
</SettingsCard>},
|
||||
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
|
||||
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
|
||||
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
|
||||
</SettingsCard>},
|
||||
]}/>
|
||||
</main>;
|
||||
}
|
||||
document.documentElement.dataset.nodedcTheme='dark';
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
Reference in New Issue
Block a user