feat(vesc): integrate native calibration diagnostics and configuration archives
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {ConfigurationVersion,SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function VescBackups({deviceId,archive,revision}: {deviceId:string;archive:SensorTransport['configurationArchive'];revision:string|null}) {
|
||||
const [reload,setReload]=useState(0);
|
||||
const [items,setItems]=useState<ConfigurationVersion[]>([]);
|
||||
const [next,setNext]=useState<string|null>(null);
|
||||
const [loading,setLoading]=useState(false);
|
||||
const [error,setError]=useState<string|null>(null);
|
||||
const [download,setDownload]=useState<string|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
setItems([]);setNext(null);setError(null);
|
||||
if(!archive)return;
|
||||
setLoading(true);
|
||||
archive.list(deviceId).then(result=>{if(active){setItems(result.items);setNext(result.next);}})
|
||||
.catch(()=>{if(active)setError('Не удалось загрузить историю конфигураций.');})
|
||||
.finally(()=>{if(active)setLoading(false);});
|
||||
return()=>{active=false;};
|
||||
},[deviceId,archive,revision,reload]);
|
||||
async function more(){
|
||||
if(!archive||!next||loading)return;
|
||||
setLoading(true);setError(null);
|
||||
try{const result=await archive.list(deviceId,next);setItems(current=>[...current,...result.items.filter(item=>!current.some(old=>old.id===item.id))]);setNext(result.next);}
|
||||
catch{setError('Не удалось загрузить следующие версии.');}
|
||||
finally{setLoading(false);}
|
||||
}
|
||||
async function save(id:string){
|
||||
if(!archive||download)return;
|
||||
setDownload(id);setError(null);
|
||||
try{
|
||||
const value=await archive.read(deviceId,id);
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}));
|
||||
const link=document.createElement('a');link.href=url;link.download=`${deviceId}-${id}.json`;link.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}catch{setError('Не удалось скачать выбранную версию.');}
|
||||
finally{setDownload(null);}
|
||||
}
|
||||
return <SettingsCard title="История конфигураций" description="Сохранённые версии остаются на борту и передаются в Core при подключении." actions={<Button disabled={loading||!archive} onClick={()=>setReload(value=>value+1)}>Обновить историю</Button>}>
|
||||
<LoadingRegion loading={loading&&!items.length} label="Загрузка версий конфигурации">
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{!loading&&!items.length&&!error&&<p>Сохранённых версий пока нет.</p>}
|
||||
<ResourceList aria-label="Версии конфигурации VESC">{items.map(item=><li key={item.id}>
|
||||
<ResourceRow title={new Date(item.observed_at).toLocaleString()} description={`Прошивка ${item.firmware} · мотор и входы`}
|
||||
actions={<Button disabled={download!==null} loading={download===item.id} onClick={()=>void save(item.id)}>Скачать</Button>}/>
|
||||
</li>)}</ResourceList>
|
||||
</LoadingRegion>
|
||||
{next&&<Button loading={loading} disabled={loading} onClick={()=>void more()}>Ещё версии</Button>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,InspectorSelectField,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorBoardSettingsProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform,type Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {drivePositions,vescStatus,type DriveProfile} from './model';
|
||||
import {VescLimits} from './VescLimits';
|
||||
|
||||
export function VescBoardSettings({inventory,transport,enabled,refresh,failure,openDevice}:SensorBoardSettingsProps){
|
||||
const controllers=inventory?.items.filter(device=>device.kind==='vesc.controller')??[];
|
||||
const profile=controllers.map(device=>vescStatus(device).drive_profile).filter((value):value is DriveProfile=>!!value).sort((a,b)=>b.revision-a.revision)[0];
|
||||
const anchor=controllers.find(device=>device.online&&device.verified&&vescStatus(device).board_settings_supported);
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const active=inventory?.operations?.some(value=>['queued','running'].includes(value.state)&&controllers.some(device=>device.id===value.device_id));
|
||||
const blocked=!enabled||!anchor||busy||active||!profile;
|
||||
const positions=drivePositions(profile?.layout??null);
|
||||
async function change(device:Sensor,action:string,parameters:Record<string,unknown>){
|
||||
if(blocked||running.current||!profile)return;
|
||||
running.current=true;setBusy(true);failure(null);
|
||||
try{await perform(transport,device,action,{revision:profile.revision,...parameters});await refresh();}
|
||||
catch(error){failure(error);await refresh();}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <div className="sensor-content">
|
||||
<SettingsCard title="Привод" description="Профиль аппарата и расположение его моторов.">
|
||||
<InspectorSelectField label="Профиль привода" value={profile?.layout??''} disabled={blocked} options={[
|
||||
{value:'',label:'Выберите профиль',disabled:true},
|
||||
{value:'1x1',label:'1×1 · 2 мотора',disabled:!!profile?.bindings['left.2']||!!profile?.bindings['right.2']},
|
||||
{value:'2x2',label:'2×2 · 4 мотора'},
|
||||
]} onChange={layout=>{if(anchor)void change(anchor,'vesc.drive.layout',{layout});}}/>
|
||||
{!profile&&<p>{inventory?'Подключите VESC, чтобы получить профиль привода с борта.':'Получение профиля привода…'}</p>}
|
||||
{profile&&!anchor&&<p>Профиль показан по последним сведениям с борта. Для изменения нужна связь с VESC и актуальное бортовое приложение.</p>}
|
||||
{profile?.layout&&<>
|
||||
<p>Стороны — по направлению движения вперёд. Назначения сохраняются автоматически и остаются с контроллером при смене USB-порта.</p>
|
||||
{Object.entries(positions).map(([slot,label])=>{
|
||||
const bound=profile.bindings[slot];
|
||||
const missing=bound&&!controllers.some(device=>device.id===bound.device_id);
|
||||
return <InspectorSelectField key={slot} label={label} value={bound?.device_id??''} disabled={blocked} options={[
|
||||
{value:'',label:'Не назначен'},
|
||||
...controllers.map(device=>({value:device.id,label:device.name+(!device.online?' · нет связи':''),disabled:!device.online||!device.verified||Object.entries(profile.bindings).some(([other,binding])=>other!==slot&&binding.device_id===device.id)})),
|
||||
...(missing?[{value:bound.device_id,label:`VESC ${bound.uuid.slice(0,6).toUpperCase()} · нет связи`,disabled:true}]:[]),
|
||||
]} onChange={id=>{
|
||||
const target=id?controllers.find(device=>device.id===id):anchor;
|
||||
if(!target)return;
|
||||
void change(target,id?'vesc.drive.assign':'vesc.drive.unassign',id?{layout:profile.layout,slot}:{slot});
|
||||
}}/>;
|
||||
})}
|
||||
<ResourceList aria-label="Настройка назначенных моторов">{Object.entries(profile.bindings).map(([slot,binding])=>{
|
||||
const target=controllers.find(device=>device.id===binding.device_id);
|
||||
return <li key={slot}><ResourceRow title={positions[slot]??slot} description={target?.name??`VESC ${binding.uuid.slice(0,6).toUpperCase()}`} actions={<Button disabled={!target?.prepared||busy||active} onClick={()=>openDevice(binding.device_id)}>Настройка мотора</Button>}/></li>;
|
||||
})}</ResourceList>
|
||||
</>}
|
||||
</SettingsCard>
|
||||
<VescLimits controllers={controllers} transport={transport} enabled={enabled&&!busy&&!active} failure={failure}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,ResourceList,ResourceRow,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface CalibrationResult {
|
||||
completed:boolean;success:boolean;configuration_verified:boolean;release_confirmed:boolean;
|
||||
native:{success?:boolean;code?:number;sensor_mode?:number;parameters?:Record<string,number>};
|
||||
}
|
||||
|
||||
export function VescCalibration({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [loss,setLoss]=useState('50');
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<CalibrationResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const limits=vescStatus(device).foc_calibration;
|
||||
const power=Number(loss);
|
||||
const valid=!!limits&&loss.trim()!==''&&Number.isFinite(power)&&power>=limits.min_power_loss_w&&power<=limits.max_power_loss_w;
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!limits;
|
||||
async function calibrate(){
|
||||
if(running.current||blocked||!available||!confirmed||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<CalibrationResult>(transport,target,'vesc.foc.calibrate',{
|
||||
rig_clear:true,native_cycle_confirmed:true,max_power_loss_w:power,
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},300000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
const parameters=result?.native.parameters;
|
||||
const calibrated=result?.native.success===true&&result.configuration_verified;
|
||||
return <SettingsCard title="Калибровка мотора" description={`${device.name} · штатное автоопределение FOC в VESC Tool.`}>
|
||||
<p>Мастер измеряет сопротивление, индуктивность и магнитный поток, определяет датчики и записывает параметры выбранного мотора. Версии до и после сохраняются в истории; настройки аккумулятора и пульта сохраняются.</p>
|
||||
<TextField type="number" label="Допустимые потери в моторе, Вт" value={loss} onChange={event=>setLoss(event.target.value)} disabled={busy||blocked||!limits} min={limits?.min_power_loss_w} max={limits?.max_power_loss_w} step="5" aria-invalid={!valid} description="Параметр нагрева для мастера VESC Tool, не номинальная мощность мотора. По нему мастер выбирает токи измерения; предел тока проверки вращения здесь не применяется."/>
|
||||
<p>Мотор будет двигаться и разгоняться. На прошивке 5.02 процедуру нельзя прервать кнопкой или пультом — только отключением силового питания. Оставьте пульт выключенным и приводы вывешенными до завершения; цикл может занять до трёх минут.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Наблюдаю мотор, питание могу отключить"/>
|
||||
<Button disabled={!available||blocked||!confirmed||!valid||busy} loading={busy} onClick={()=>void calibrate()}>Откалибровать мотор</Button>
|
||||
{busy&&<p role="status">Подготовка и калибровка VESC Tool. Дождитесь результата и снятия тока.</p>}
|
||||
{result&&<>
|
||||
<p role="status">{calibrated?'Параметры мотора измерены, записаны и проверены.':result.completed?`Калибровка не принята. Код VESC: ${result.native.code??'не получен'}.`:'Завершение калибровки не подтверждено. Проверьте состояние мотора и питание.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока пока не подтверждено. Перед следующей проверкой верните управление после нейтрали.'} {!result.configuration_verified&&' Проверка конфигурации не завершена; новое движение заблокировано.'}</p>
|
||||
{calibrated&&<>
|
||||
<p>{result.native.sensor_mode===0?'Выбран режим без датчиков. Холлы не определились; качество запуска нужно проверить вращением.':result.native.sensor_mode===2?'Определены датчики Холла.':'Определён энкодер.'}</p>
|
||||
{parameters&&<ResourceList aria-label="Результат калибровки">
|
||||
{([['foc_motor_r','Сопротивление',1000,'мОм'],['foc_motor_l','Индуктивность',1e6,'мкГн'],['foc_motor_flux_linkage','Магнитный поток',1000,'мВб'],['l_current_max','Предел тока мотора',1,'А']] as const).map(([key,title,scale,unit])=><li key={key}><ResourceRow title={title} description={`${(parameters[key]*scale).toLocaleString('ru-RU',{maximumFractionDigits:3})} ${unit}`}/></li>)}
|
||||
</ResourceList>}
|
||||
</>}
|
||||
</>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescLabel,vescStatus,type VescTelemetry} from './model';
|
||||
import {VescBackups} from './VescBackups';
|
||||
import {VescMotor} from './VescMotor';
|
||||
import {VescHall} from './VescHall';
|
||||
import {VescCalibration} from './VescCalibration';
|
||||
import {VescLink} from './VescLink';
|
||||
|
||||
const fields=[['input_voltage_v','Напряжение питания','В'],['input_current_a','Ток питания','А'],
|
||||
['motor_current_a','Ток мотора','А'],['erpm','Электрические обороты','ERPM'],['duty','Заполнение PWM',''],
|
||||
['mos_temperature_c','Температура контроллера','°C'],['motor_temperature_c','Температура мотора','°C'],
|
||||
['fault_code','Код ошибки',''],['can_id','CAN ID',''],['timeout','Тайм-аут управления',''],
|
||||
['kill_switch','Вход аварийного останова','']] as const;
|
||||
|
||||
export function VescDetail(props:SensorDetailProps){
|
||||
const {device,transport,enabled,back,refresh,failure}=props;
|
||||
const status=vescStatus(device);const label=vescLabel(device,enabled);
|
||||
const [telemetry,setTelemetry]=useState<VescTelemetry|null>(status.telemetry);
|
||||
const [pending,setPending]=useState<string|null>(null);
|
||||
const [saved,setSaved]=useState<string|null>(null);
|
||||
const [motorBusy,setMotorBusy]=useState(false);
|
||||
const [hallBusy,setHallBusy]=useState(false);
|
||||
const [calibrationBusy,setCalibrationBusy]=useState(false);
|
||||
const [linkBusy,setLinkBusy]=useState(false);
|
||||
const poweredBusy=motorBusy||hallBusy||calibrationBusy||linkBusy;
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
const available=enabled&&device.online&&device.verified&&status.readable;
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
async function read(action:'vesc.telemetry.read'|'vesc.config.backup'){
|
||||
if(running.current||poweredBusy||!available)return;
|
||||
running.current=true;setPending(action);failure(null);
|
||||
try{
|
||||
const result=await perform<Record<string,unknown>>(transport,device,action);
|
||||
if(!mounted.current)return;
|
||||
if(action==='vesc.telemetry.read')setTelemetry(result as unknown as VescTelemetry);
|
||||
else {
|
||||
setSaved(String(result.observed_at));
|
||||
}
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current)setPending(null);}
|
||||
}
|
||||
const backupAt=saved??status.backup?.observed_at;
|
||||
return <div className="sensor-content">
|
||||
<div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={`${device.name} · Настройка VESC`} description={`${device.model} · ${device.connection_label??'USB'}`}
|
||||
actions={<StatusBadge tone={label.tone}>{label.label}</StatusBadge>}>
|
||||
{!enabled||!device.online?<p>Нет свежей связи с контроллером.</p>:status.message?<p>{status.message}</p>:null}
|
||||
{status.identity?<ResourceList aria-label="Контроллер VESC">
|
||||
<li><ResourceRow title="Прошивка" description={status.identity.version}/></li>
|
||||
<li><ResourceRow title="Аппаратная версия" description={status.identity.hardware}/></li>
|
||||
<li><ResourceRow title="UUID" description={status.identity.uuid.toUpperCase()}/></li>
|
||||
{status.identity.test_firmware!==null&&status.identity.test_firmware>0&&<li><ResourceRow title="Тестовая прошивка" description={String(status.identity.test_firmware)}/></li>}
|
||||
</ResourceList>:<p>Аппаратный идентификатор ещё не подтверждён.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Показания контроллера" description={telemetry?`Снимок: ${new Date(telemetry.observed_at).toLocaleString()}`:'Получите текущие значения и код ошибки.'}
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.telemetry.read'} onClick={()=>void read('vesc.telemetry.read')}>Обновить показания</Button>}>
|
||||
<LoadingRegion loading={pending==='vesc.telemetry.read'&&!telemetry} label="Чтение показаний VESC">
|
||||
{telemetry?<ResourceList aria-label="Показания VESC">{fields.map(([key,title,unit])=>{
|
||||
const value=telemetry.values[key];if(value===undefined)return null;
|
||||
return <li key={key}><ResourceRow title={title} description={typeof value==='boolean'?(value?'Активен':'Не активен'):`${Number(value).toLocaleString(undefined,{maximumFractionDigits:3})}${unit?' '+unit:''}`}/></li>;
|
||||
})}</ResourceList>:<p>Показания ещё не прочитаны.</p>}
|
||||
</LoadingRegion>
|
||||
<p>ERPM — электрические обороты. Обороты вала зависят от числа пар полюсов мотора.</p>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Резервная копия конфигурации" description="Сохраните текущие параметры мотора и входов новой версией."
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.config.backup'} onClick={()=>void read('vesc.config.backup')}>Сохранить версию</Button>}>
|
||||
{backupAt&&<p>Последняя копия: {new Date(backupAt).toLocaleString()}</p>}
|
||||
<p>Копия привязана к UUID и прошивке. Версии до и после калибровки остаются в истории.</p>
|
||||
</SettingsCard>
|
||||
<VescLink {...props} blocked={motorBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setLinkBusy}/>
|
||||
<VescCalibration {...props} blocked={linkBusy||motorBusy||hallBusy||pending!==null} onBusyChange={setCalibrationBusy}/>
|
||||
<VescHall {...props} blocked={linkBusy||motorBusy||calibrationBusy||pending!==null} onBusyChange={setHallBusy}/>
|
||||
<VescMotor {...props} blocked={linkBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setMotorBusy}/>
|
||||
<VescBackups deviceId={device.id} archive={transport.configurationArchive} revision={backupAt??null}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface HallResult {
|
||||
completed:boolean;configuration_restored:boolean;release_confirmed:boolean;
|
||||
measurement:null|{valid_six_states:boolean;observed_states:number[];hall_table:number[]};
|
||||
}
|
||||
|
||||
export function VescHall({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<HallResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!vescStatus(device).hall_measurement;
|
||||
async function measure(){
|
||||
if(running.current||blocked||!available||!confirmed)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<HallResult>(transport,target,'vesc.hall.measure',{
|
||||
rig_clear:true,native_cycle_confirmed:true,
|
||||
...(vescStatus(target).hall_measurement?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},60000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
return <SettingsCard title="Датчики Холла" description={`${device.name} · штатное измерение VESC Tool, 5 А.`}>
|
||||
<p>Мотор медленно смещается в обе стороны около 12 секунд. Измерение проверяет состояния датчиков и получает таблицу их положения; новая таблица автоматически не записывается.</p>
|
||||
<p>На прошивке 5.02 этот цикл нельзя прервать кнопкой или пультом. Для немедленной остановки нужно отключить силовое питание. Пульт должен оставаться выключенным, все приводы — вывешенными. Перед запуском убедитесь, что все моторы полностью остановились: в бессенсорном режиме показание оборотов на остановленном моторе может быть ненулевым.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Все моторы остановлены, наблюдаю"/>
|
||||
<Button disabled={!available||blocked||!confirmed||busy} loading={busy} onClick={()=>void measure()}>Измерить датчики Холла</Button>
|
||||
{busy&&<p role="status">Подготовка и штатное измерение датчиков. Дождитесь результата.</p>}
|
||||
{result&&<p role="status">{!result.completed?'Завершение измерения не подтверждено. Проверьте мотор и питание.':result.measurement?.valid_six_states?'Измерение получило шесть состояний Холла.':'Не удалось получить полную таблицу Холла. Возможны отсутствие движения или неисправность датчиков/соединения.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'} {result.configuration_restored?'Исходная конфигурация сохранена.':'Возврат исходной конфигурации не подтверждён.'}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface Limits {observed_at:string;identity?:{uuid:string};parameters:Record<string,number>}
|
||||
const number=(value:number|undefined)=>value===undefined?'—':value.toLocaleString('ru-RU',{maximumFractionDigits:2});
|
||||
export function VescLimits({controllers,transport,enabled,failure}:{controllers:Sensor[];transport:SensorTransport;enabled:boolean;failure:(error:unknown)=>void}){
|
||||
const [values,setValues]=useState<Record<string,Limits>>({});
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const readable=controllers.filter(device=>device.online&&device.verified&&vescStatus(device).readable&&vescStatus(device).board_settings_supported);
|
||||
async function read(){
|
||||
if(!enabled||running.current||!readable.length)return;
|
||||
running.current=true;setBusy(true);failure(null);setValues({});
|
||||
try{for(const device of readable){const value=await perform<Limits>(transport,device,'vesc.limits.read');setValues(current=>({...current,[device.id]:value}));}}
|
||||
catch(error){failure(error);}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <SettingsCard title="Ограничения контроллеров" description="Текущие настройки VESC. Чтение не запускает моторы и не меняет конфигурацию." actions={<Button disabled={!enabled||busy||!readable.length} loading={busy} onClick={()=>void read()}>Прочитать ограничения</Button>}>
|
||||
<p>Ток мотора задаёт тягу; ток батареи ограничивает потребление. Эти настройки действуют и при управлении с пульта. Паспортные пределы моторов, контроллеров и батареи проверяются отдельно.</p>
|
||||
{controllers.map(device=>{
|
||||
const value=values[device.id];if(!value)return null;
|
||||
const p=value.parameters;
|
||||
const fields=[
|
||||
['Ток мотора · разгон / торможение',`${number(p.l_current_max)} / ${number(p.l_current_min)} А`],
|
||||
['Масштаб тока · разгон / торможение',`${number(p.l_current_max_scale*100)} / ${number(p.l_current_min_scale*100)} %`],
|
||||
['Ток батареи · потребление / рекуперация',`${number(p.l_in_current_max)} / ${number(p.l_in_current_min)} А`],
|
||||
['Диапазон электрических оборотов',`${number(p.l_min_erpm)} … ${number(p.l_max_erpm)} ERPM`],
|
||||
['Максимальная мощность',p.l_watt_max>=1500000?'Отдельный предел не задан':`${number(p.l_watt_max)} Вт`],
|
||||
['Максимальный duty cycle',`${number(p.l_max_duty*100)} %`],
|
||||
];
|
||||
return <SettingsCard key={device.id} title={device.name} description={`Прочитано ${new Date(value.observed_at).toLocaleString('ru-RU')}`}><ResourceList aria-label={`Ограничения ${device.name}`}>{fields.map(([title,description])=><li key={title}><ResourceRow title={title} description={description}/></li>)}</ResourceList></SettingsCard>;
|
||||
})}
|
||||
{!Object.keys(values).length&&<p>Прочитайте значения для подключённых контроллеров.</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface LinkResult {
|
||||
outcome:string;duration_s:number;
|
||||
devices:Record<string,{name:string;summary:{replies:number;p95_ms?:number;max_ms?:number;over_60_ms?:number}}>;
|
||||
}
|
||||
export function VescLink({device,transport,enabled,failure,refresh,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [busy,setBusy]=useState(false);const [result,setResult]=useState<LinkResult|null>(null);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const available=enabled&&device.online&&device.verified&&vescStatus(device).link_check_supported;
|
||||
async function check(){
|
||||
if(running.current||blocked||!available)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);failure(null);setResult(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const peers=inventory.items.filter(item=>item.kind==='vesc.controller');
|
||||
const selected=peers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!selected||peers.some(item=>!item.online||!item.verified))throw new Error('Обновите связь со всеми VESC борта.');
|
||||
const sessions=Object.fromEntries(peers.map(item=>[item.id,item.snapshot.context.session_id]));
|
||||
const value=await perform<LinkResult>(transport,selected,'vesc.link.check',{sessions},45000);
|
||||
if(mounted.current)setResult(value);
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current){setBusy(false);onBusyChange(false);}}
|
||||
}
|
||||
if(!vescStatus(device).link_check_supported)return null;
|
||||
const text=result?.outcome==='complete'?'Все ответы получены. Эта проверка не подтверждает связь во время вращения.':
|
||||
result?.outcome==='not_idle'?'Измерение прекращено: есть команда с пульта или движение мотора.':
|
||||
result?.outcome==='read_failed'?'Ответ контроллера не получен. Проверка прервана.':
|
||||
'Измерение не завершено.';
|
||||
return <SettingsCard title="Связь с контроллерами" description="Проверяет ответы всех VESC борта около 10 секунд. Моторы должны стоять; команды вращения и изменения настроек не отправляются."
|
||||
actions={<Button disabled={!available||blocked||busy} loading={busy} onClick={()=>void check()}>Проверить связь</Button>}>
|
||||
{busy&&<p role="status">Измеряется время ответа контроллеров.</p>}
|
||||
{result&&<><p role="status">{text}</p><ResourceList aria-label="Связь VESC">{Object.entries(result.devices).map(([id,item])=><li key={id}><ResourceRow title={item.name}
|
||||
description={`${item.summary.replies} ответов${item.summary.max_ms===undefined?'':` · 95% не дольше ${Math.ceil(item.summary.p95_ms??0)} мс · максимум ${Math.ceil(item.summary.max_ms)} мс`}`}/></li>)}</ResourceList></>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,Checker,Select,TextField,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {testInput,speedInput,rotationResult,vescStatus,driveTestIds,drivePositions} from './model';
|
||||
|
||||
export function VescMotor({device,transport,enabled,refresh,failure,blocked=false,onBusyChange}:SensorDetailProps&{blocked?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||
const [duration,setDuration]=useState('30');
|
||||
const [current,setCurrent]=useState('30');
|
||||
const [speed,setSpeed]=useState('2000');
|
||||
const [direction,setDirection]=useState('forward');
|
||||
const [scope,setScope]=useState('single');
|
||||
const status=vescStatus(device),profile=status.drive_profile;
|
||||
const driveIds=driveTestIds(profile,device.id);
|
||||
const group=scope==='profile';
|
||||
const groupAvailable=status.group_test_supported===true&&driveIds.length>1;
|
||||
const [clear,setClear]=useState(false);const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<string|null>(null);const [stopping,setStopping]=useState(false);
|
||||
const [rc,setRc]=useState(device.vesc_status?.rc_latched===true);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const limits=vescStatus(device).test_limits;
|
||||
const speedLimits=vescStatus(device).speed_limits;
|
||||
const speedValue=speedInput(speed,speedLimits);
|
||||
const input=testInput(current,duration,limits);
|
||||
const available=!!limits&&!!speedLimits&&enabled&&!blocked&&device.online&&device.verified&&device.vesc_status?.test_supported===true;
|
||||
const valid=input.valid&&!speedValue.error&&(direction==='forward'||speedLimits?.reverse_supported===true)&&(!group||groupAvailable);
|
||||
const blockedReason=busy?'Подготовка и проверка выполняются. Дождитесь результата или остановите проверку.':
|
||||
!enabled||!device.online?'Для запуска нужна свежая связь с бортом и VESC.':
|
||||
!device.verified?'Контроллер ещё не определён. Обновите устройства.':
|
||||
!available?'Проверка вращения для этого VESC сейчас недоступна на борту.':
|
||||
!valid?'Исправьте значения в отмеченных полях.':
|
||||
!clear?'Для запуска подтвердите, что все приводы остановлены, вывешены и вращение свободно.':null;
|
||||
async function execute(release=false){
|
||||
if(running.current||!available||!clear||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange?.(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми подключёнными VESC этого борта.');
|
||||
if(group&&!release&&(vescStatus(target).drive_profile?.revision!==profile?.revision||driveIds.some(id=>!controllers.some(item=>item.id===id))))throw new Error('Профиль или состав моторов изменился. Обновите карточку.');
|
||||
const value=await perform<{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}>(transport,target,release?'vesc.control.release':group?'vesc.drive.run':'vesc.motor.run',{
|
||||
rig_clear:true,duration_s:input.durationS,current_a:input.currentA,
|
||||
...(release?{}:{erpm:speedValue.erpm*(direction==='reverse'?-1:1)}),
|
||||
...(vescStatus(target).speed_limits?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
...(!release&&group?{profile_revision:profile?.revision,device_ids:driveIds}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},group&&!release?120000:90000);
|
||||
if(mounted.current){
|
||||
setResult(release?'Управление возвращено для проверки.':rotationResult(value));
|
||||
if(release)setRc(false);
|
||||
}
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{
|
||||
running.current=false;
|
||||
onBusyChange?.(false);
|
||||
if(mounted.current){setBusy(false);setClear(false);await refresh();}
|
||||
}
|
||||
}
|
||||
async function stop(){
|
||||
if(stopping)return;setStopping(true);
|
||||
try{const value=await perform<{interruptible?:boolean}>(transport,device,'vesc.motor.stop',{},15000);if(mounted.current)setResult(value.interruptible===false?'Идёт штатный цикл измерения VESC. Немедленная остановка возможна отключением питания.':'Остановка запрошена. Ожидаем результат проверки.');}
|
||||
catch(error){if(mounted.current)failure(error);}
|
||||
finally{if(mounted.current)setStopping(false);}
|
||||
}
|
||||
useEffect(()=>setRc(device.vesc_status?.rc_latched===true),[device.vesc_status?.rc_latched]);
|
||||
return <SettingsCard title="Проверка вращения" description={`${device.name} · ${device.connection_label??'USB'}. Конфигурации подключённых контроллеров сохраняются автоматически.`}>
|
||||
<p>Для вывешенных колёс без нагрузки. При команде с приёмника проверка прекращается; повторный запуск требует явного возврата управления.</p>
|
||||
<Select label="Проверяемые моторы" value={scope} disabled={busy||blocked} options={[{value:'single',label:`Только ${device.name}`},{value:'profile',label:'Все моторы профиля одновременно',disabled:!groupAvailable}]} onChange={setScope}/>
|
||||
{group&&profile&&<p>{Object.keys(drivePositions(profile.layout)).map(slot=>`${drivePositions(profile.layout)[slot]} · VESC ${profile.bindings[slot]?.uuid.slice(0,6).toUpperCase()??'не назначен'}`).join('; ')}. Общий отсчёт начинается, когда все моторы удерживают скорость. Остановка любого завершает всю проверку. Предел тока применяется к каждому мотору.</p>}
|
||||
<Select label="Направление вращения" value={direction} disabled={busy||blocked} options={[{value:'forward',label:'Прямое'},{value:'reverse',label:'Обратное',disabled:speedLimits?.reverse_supported!==true}]} onChange={value=>{setDirection(value);setClear(false);}}/>
|
||||
<p>Направление относительно настроек VESC. Перед обратным запуском дождитесь полной остановки всех моторов и подтвердите её.</p>
|
||||
<TextField type="number" inputMode="decimal" label="Скорость, ERPM" value={speed} onChange={event=>setSpeed(event.target.value)} disabled={busy||!speedLimits} min={speedLimits?.min_erpm} max={speedLimits?.max_erpm} step="100" aria-invalid={!!speedValue.error} description={speedValue.error??'Электрические обороты в минуту. VESC плавно разгоняет мотор и удерживает заданную скорость.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Предел тока мотора, А" value={current} onChange={event=>setCurrent(event.target.value)} disabled={busy||!limits} min={limits?.min_current_a} max={limits?.max_current_a} step="0.1" aria-invalid={!!input.currentError} hint={limits?`${limits.min_current_a}–${limits.max_current_a} А`:undefined} description={input.currentError??'Максимальный ток разгона и удержания скорости. Прежние пределы сохраняются перед тестом и восстанавливаются после него.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Длительность вращения, с" value={duration} onChange={event=>setDuration(event.target.value)} disabled={busy||!limits} min={limits?.min_duration_s} max={limits?.max_duration_s} step="0.1" aria-invalid={!!input.durationError} hint={limits?`${limits.min_duration_s}–${limits.max_duration_s} с`:undefined} description={input.durationError??'Отсчёт начинается после разгона и стабилизации скорости. Подготовка, разгон и остановки не входят в это время.'}/>
|
||||
<p>Время считается по скорости и тахометру VESC. Если мотор не разгонится за 15 секунд, потеряет скорость или сработает ограничение, результат покажет фактическое время и причину остановки. Показания контроллера нужно сопоставить с видимым вращением.</p>
|
||||
{limits?.stall_timeout_s&&<p>При токе выше {limits.stall_current_a} А и отсутствии подтверждённого движения в течение {limits.stall_timeout_s} секунд проверка остановится.</p>}
|
||||
<p>Все приводы должны быть вывешены, вращение свободно. Подтверждение требуется перед каждым запуском.</p>
|
||||
<Checker checked={clear} onChange={setClear} disabled={busy} label="Все моторы остановлены, наблюдаю"/>
|
||||
{blockedReason&&<p role="status">{blockedReason}</p>}
|
||||
<div className="sensor-actions">
|
||||
{rc?<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute(true)}>Вернуть управление после нейтрали</Button>:
|
||||
<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute()}>Проверить вращение</Button>}
|
||||
<Button disabled={!enabled||!device.online||blocked} loading={stopping} onClick={()=>void stop()}>Остановить проверку</Button>
|
||||
</div>
|
||||
{result&&<p role="status">{result}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export interface VescIdentity {uuid:string;hardware:string;version:string;test_firmware:number|null;hardware_type:number|null}
|
||||
export interface VescTelemetry {observed_at:string;values:Record<string,number|boolean>}
|
||||
export interface DriveProfile {layout:null|'1x1'|'2x2';revision:number;bindings:Record<string,{device_id:string;uuid:string}>}
|
||||
export function drivePositions(layout:DriveProfile['layout']):Record<string,string> {
|
||||
return layout==='2x2'
|
||||
? {'left.1':'Левый передний','left.2':'Левый задний','right.1':'Правый передний','right.2':'Правый задний'}
|
||||
: {'left.1':'Левый','right.1':'Правый'};
|
||||
}
|
||||
export interface VescStatus {
|
||||
board_settings_supported?:boolean;
|
||||
group_test_supported?:boolean;
|
||||
link_check_supported?:boolean;
|
||||
foc_calibration?:{min_power_loss_w:number;max_power_loss_w:number;interruptible:boolean};
|
||||
speed_limits?:{min_erpm:number;max_erpm:number;duration_basis:string;reverse_supported?:boolean;standstill_confirmation_required?:boolean};
|
||||
hall_measurement?:{current_a:number;interruptible:boolean;standstill_confirmation_required?:boolean};
|
||||
drive_profile?:DriveProfile;
|
||||
test_limits?:{min_current_a:number;max_current_a:number;min_duration_s:number;max_duration_s:number;current_ramp_a_per_s?:number;continuous_current?:boolean;max_erpm?:number;max_duty?:number;stall_current_a?:number;stall_timeout_s?:number};
|
||||
identity:VescIdentity|null; readable:boolean; message:string|null; telemetry:VescTelemetry|null;
|
||||
backup:{observed_at:string;operation_id:string;configs:Record<string,{bytes:number;sha256:string}>}|null;
|
||||
}
|
||||
|
||||
export function driveTestIds(profile:DriveProfile|undefined,selected:string):string[] {
|
||||
if(!profile?.layout)return [];
|
||||
const slots=Object.keys(drivePositions(profile.layout));
|
||||
if(Object.keys(profile.bindings).length!==slots.length||slots.some(slot=>!profile.bindings[slot]))return [];
|
||||
const ids=slots.map(slot=>profile.bindings[slot].device_id);
|
||||
return new Set(ids).size===ids.length&&ids.includes(selected)?ids:[];
|
||||
}
|
||||
|
||||
export function speedInput(speed:string,limits:VescStatus['speed_limits']) {
|
||||
const erpm=Number(speed);
|
||||
const error=!limits?'Для удержания скорости требуется обновление профиля VESC на борту.':
|
||||
speed.trim()===''||!Number.isFinite(erpm)?'Введите скорость.':
|
||||
erpm<limits.min_erpm||erpm>limits.max_erpm?`Скорость должна быть от ${limits.min_erpm} до ${limits.max_erpm} ERPM.`:null;
|
||||
return {erpm,error};
|
||||
}
|
||||
|
||||
export function rotationResult(value:{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}) {
|
||||
const time=(value.rotation_s??0).toLocaleString('ru-RU',{maximumFractionDigits:1});
|
||||
const outcome=value.outcome==='duration'?'Заданное время вращения набрано.':value.outcome==='stopped'?'Проверка остановлена.':String(value.outcome);
|
||||
return `${outcome} Вращение на заданной скорости по данным VESC: ${time} с. ${value.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'}${value.limits_restored?' Исходные токовые пределы восстановлены.':''}`;
|
||||
}
|
||||
export function vescStatus(device:Sensor):VescStatus {
|
||||
return {identity:null,readable:false,message:null,telemetry:null,backup:null,...device.vesc_status} as VescStatus;
|
||||
}
|
||||
export function vescLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||
const status=vescStatus(device);
|
||||
if(!fresh||!device.online)return {label:'Нет связи',tone:'neutral'};
|
||||
if(!device.prepared)return {label:'Требуется подготовка',tone:'neutral'};
|
||||
if(!status.identity)return {label:'Не определён',tone:'warning'};
|
||||
if(!status.readable)return {label:'Прошивка не поддерживается',tone:'warning'};
|
||||
return {label:'Готов к чтению',tone:'success'};
|
||||
}
|
||||
|
||||
export function testInput(current:string,duration:string,limits:VescStatus['test_limits']) {
|
||||
const currentA=Number(current),durationS=Number(duration);
|
||||
const number=(value:number)=>value.toLocaleString('ru-RU');
|
||||
const currentError=!limits?null:current.trim()===''||!Number.isFinite(currentA)
|
||||
? 'Введите ток мотора.'
|
||||
: currentA<limits.min_current_a||currentA>limits.max_current_a
|
||||
? `Ток должен быть от ${number(limits.min_current_a)} до ${number(limits.max_current_a)} А.`:null;
|
||||
const durationError=!limits?null:duration.trim()===''||!Number.isFinite(durationS)
|
||||
? 'Введите длительность проверки.'
|
||||
: durationS<limits.min_duration_s||durationS>limits.max_duration_s
|
||||
? `Длительность должна быть от ${number(limits.min_duration_s)} до ${number(limits.max_duration_s)} с.`:null;
|
||||
return {currentA,durationS,currentError,durationError,valid:!!limits&&!currentError&&!durationError};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {VescDetail} from './VescDetail';
|
||||
import {VescBoardSettings} from './VescBoardSettings';
|
||||
import {vescLabel} from './model';
|
||||
|
||||
export const vescSensorUi:SensorUiContribution={
|
||||
kind:'vesc.controller',Detail:VescDetail,BoardSettings:VescBoardSettings,icon:'activity',retainOffline:true,
|
||||
supportsPreparation:true,supportsRenaming:true,detailLabel:'Настройка VESC',status:vescLabel,
|
||||
};
|
||||
Reference in New Issue
Block a user