feat(fleet): preserve operator VESC integration before final driver merge

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:46 +03:00
parent dad11b47d7
commit 71a648fec8
128 changed files with 22390 additions and 59 deletions
+23
View File
@@ -0,0 +1,23 @@
import {useEffect,useSyncExternalStore,type ReactNode} from 'react';
import {Button,Icon,Inspector,LoadingRegion} from '@nodedc/ui-react';
import type {BoardLayoutStore} from './boardLayout';
export interface BoardSectionsProps {
layout:BoardLayoutStore;
computer:ReactNode;
description?:string;
}
export function BoardSections({layout,computer,description,settings,devices}:BoardSectionsProps&{settings:ReactNode;devices:ReactNode}){
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
useEffect(()=>{void layout.load();},[layout]);
return <div className="sensor-content">
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
<Inspector variant="panel" openSections={state.value.open_sections} onOpenSectionsChange={layout.change} sections={[
{id:'computer',label:'Бортовой компьютер',description,icon:<Icon name="apps"/>,disabled:!state.ready,content:computer},
{id:'settings',label:'Настройки борта',icon:<Icon name="sliders"/>,disabled:!state.ready,content:settings},
{id:'devices',label:'Устройства аппарата',icon:<Icon name="camera"/>,disabled:!state.ready,content:devices},
]}/>
</LoadingRegion>
</div>;
}
+7 -4
View File
@@ -9,8 +9,9 @@ import {sensorStatus} from './sensorStatus';
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
import type {RerunHostFactory} from './rerunHost';
import './sensors.css';
import {BoardSections,type BoardSectionsProps} from './BoardSections';
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps}){
const [adding,setAdding]=useState(false);
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
@@ -44,7 +45,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
const inventoryView=<>
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
@@ -52,9 +53,11 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const configured=item.configured??item.snapshot.enrollment==='enrolled';
const prep=devicePreparation(inventory,item);
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>}
<SensorPreparations inventory={inventory}/>
</>}
</>;
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(board?<BoardSections {...board} settings={<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>} devices={inventoryView}/>:inventoryView)}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
+66
View File
@@ -0,0 +1,66 @@
export const boardSections = ['computer', 'settings', 'devices'] as const;
export type BoardSection = typeof boardSections[number];
export interface BoardLayout {schema:'missioncore.board-layout/v1';revision:number;open_sections:BoardSection[]}
export interface BoardLayoutTransport {
read:()=>Promise<BoardLayout>;
patch:(section:BoardSection,open:boolean)=>Promise<BoardLayout>;
}
export const defaultBoardLayout:BoardLayout={schema:'missioncore.board-layout/v1',revision:0,open_sections:[...boardSections]};
type Change={section:BoardSection;open:boolean};
type Snapshot={value:BoardLayout;ready:boolean;error:string|null;saving:boolean};
function apply(value:BoardLayout,change:Change):BoardLayout {
const open=new Set(value.open_sections);
if(change.open)open.add(change.section);else open.delete(change.section);
return {...value,open_sections:boardSections.filter(section=>open.has(section))};
}
function validate(value:BoardLayout):BoardLayout {
if(value.schema!==defaultBoardLayout.schema||!Number.isSafeInteger(value.revision)||value.revision<0||
!Array.isArray(value.open_sections)||new Set(value.open_sections).size!==value.open_sections.length||
value.open_sections.some(id=>!boardSections.includes(id)))throw new Error('Не удалось прочитать раскладку блоков.');
return value;
}
// The queue belongs to the application resource, not a mounted accordion.
// Navigation cannot discard a pending save or let an older reply win a toggle.
export function createBoardLayoutStore(transport:BoardLayoutTransport){
let saved=defaultBoardLayout;
let snapshot:Snapshot={value:saved,ready:false,error:null,saving:false};
let pending:Change[]=[];
let reading:Promise<void>|null=null;
let writing=false;
const listeners=new Set<()=>void>();
const emit=(patch:Partial<Snapshot>={})=>{
snapshot={...snapshot,...patch,value:pending.reduce(apply,saved),saving:writing||pending.length>0};
listeners.forEach(listener=>listener());
};
const load=():Promise<void>=>{
if(reading)return reading;
if(writing)return Promise.resolve();
reading=transport.read().then(value=>{saved=validate(value);emit({ready:true,error:null});})
.catch(()=>emit({error:'Раскладка блоков не загружена. Повторите подключение.'}))
.finally(()=>{reading=null;});
return reading;
};
const flush=async()=>{
if(writing)return;
writing=true;emit({error:null});
while(pending.length){
const change=pending[0];
try{saved=validate(await transport.patch(change.section,change.open));pending.shift();emit();}
catch{pending=[];emit({error:'Не удалось сохранить раскладку блоков. Изменение отменено.'});break;}
}
writing=false;emit();
};
return {
getSnapshot:()=>snapshot,
subscribe:(listener:()=>void)=>{listeners.add(listener);return()=>{listeners.delete(listener);};},
load,
change:(open:string[])=>{
if(!snapshot.ready||reading)return;
for(const section of boardSections){
if(open.includes(section)!==snapshot.value.open_sections.includes(section))pending.push({section,open:open.includes(section)});
}
emit();void flush();
},
};
}
export type BoardLayoutStore=ReturnType<typeof createBoardLayoutStore>;
+6
View File
@@ -4,6 +4,7 @@ export interface Sensor {
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
live_settings?: Record<string,unknown>;
camera_status?: Record<string,unknown>;
vesc_status?: Record<string,unknown>;
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
@@ -33,6 +34,10 @@ export interface SensorCommand {
}
export interface SensorOperation {state:string;error?:string;result?:unknown}
export interface SensorTransport {
configurationArchive?: {
list:(deviceId:string,before?:string)=>Promise<{items:ConfigurationVersion[];next:string|null}>;
read:(deviceId:string,versionId:string)=>Promise<Record<string,unknown>>;
};
localPreview?: {
open: (command:SensorCommand, signal:AbortSignal) => Promise<Response>;
read: (peer:string, after:number, signal:AbortSignal) => Promise<Response>;
@@ -43,6 +48,7 @@ export interface SensorTransport {
submit: (value:SensorCommand) => Promise<SensorOperation>;
operation: (id:string) => Promise<SensorOperation>;
}
export interface ConfigurationVersion {id:string;sequence:number;device_id:string;observed_at:string;firmware:string;configs:Record<string,{sha256:string;bytes:number}>}
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
+8 -1
View File
@@ -1,6 +1,6 @@
import type {ComponentType,ReactNode} from 'react';
import type {IconName} from '@nodedc/ui-react';
import type {Sensor, SensorTransport} from './contracts';
import type {Sensor, SensorInventory, SensorTransport} from './contracts';
import type {EnrollmentTransport} from './enrollment';
import type {RerunHostFactory} from './rerunHost';
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
}
export interface SensorUiContribution {
BoardSettings?:ComponentType<SensorBoardSettingsProps>;
observation?:import('./observation').SensorObservationContribution;
kind:string;
Detail:ComponentType<SensorDetailProps>;
@@ -22,11 +23,17 @@ export interface SensorUiContribution {
retainOffline:boolean;
supportsPreparation:boolean;
supportsRenaming?:boolean;
detailLabel?:string;
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
}
export interface SensorBoardSettingsProps {
inventory:SensorInventory|null;transport:SensorTransport;enabled:boolean;
refresh:()=>Promise<void>;failure:(error:unknown)=>void;openDevice:(id:string)=>void;
}
export interface SensorRowAction {
actionId:string; label:string; description?:string; disabled?:boolean;
parameters?:Record<string,unknown>;