Fix onboard BLE admission and stage wireless device enrollment
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {xgridsK1SensorUi,K1EnrollmentWindow} from './sensors/plugin';
|
||||
import {xgridsK1SensorUi} from './sensors/plugin';
|
||||
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
||||
import { XgridsK1Connection } from "./XgridsK1Connection";
|
||||
import { K1SpatialControls } from "./components/K1SpatialControls";
|
||||
@@ -8,7 +8,7 @@ import "./styles.css";
|
||||
|
||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||
manifest: xgridsK1Manifest,
|
||||
sensorUi: {contributions: [xgridsK1SensorUi], Enrollment: K1EnrollmentWindow},
|
||||
sensorUi: {contributions: [xgridsK1SensorUi]},
|
||||
RuntimeProvider: XgridsK1RuntimeProvider,
|
||||
SpatialControlsView: K1SpatialControls,
|
||||
connectionViews: Object.freeze({
|
||||
|
||||
@@ -1,62 +1,119 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,type EnrollmentState,type EnrollmentTransport} from './enrollment';
|
||||
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||
import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk';
|
||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,type EnrollmentState} from './enrollment';
|
||||
|
||||
export function DeviceEnrollmentWindow({transport,onClose,onChange}:{transport:EnrollmentTransport;onClose:()=>void;onChange:()=>void}){
|
||||
export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorEnrollmentProps){
|
||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||
const [device,setDevice]=useState('');const [ssid,setSSID]=useState('');const [password,setPassword]=useState('');
|
||||
const [device,setDevice]=useState('');
|
||||
const [ssid,setSSID]=useState('');
|
||||
const [password,setPassword]=useState('');
|
||||
const [networks,setNetworks]=useState<NonNullable<EnrollmentState['networks']>>([]);
|
||||
const [network,setNetwork]=useState('manual');const [busy,setBusy]=useState('loading');
|
||||
const [error,setError]=useState('');const [scanned,setScanned]=useState(false);
|
||||
const lifetime=useRef<AbortController|null>(null);const running=useRef(false);
|
||||
const [network,setNetwork]=useState('manual');
|
||||
const [busy,setBusy]=useState('loading');
|
||||
const [error,setError]=useState('');
|
||||
const [scanned,setScanned]=useState(false);
|
||||
const lifetime=useRef<AbortController|null>(null);
|
||||
const running=useRef(false);
|
||||
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();lifetime.current=controller;setState(null);setBusy('loading');
|
||||
const controller=new AbortController();
|
||||
lifetime.current=controller;setState(null);setBusy('loading');
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
async function observe(){
|
||||
try{const value=await transport.state();if(!controller.signal.aborted)setState(current=>mergeEnrollmentState(current,value));}
|
||||
catch{if(!controller.signal.aborted)setState(current=>current?{...current,available:false,fresh:false}:null);}
|
||||
finally{if(!controller.signal.aborted){setBusy(current=>current==='loading'?'':current);timer=setTimeout(()=>void observe(),3000);}}
|
||||
try{
|
||||
const value=await transport.state();
|
||||
if(!controller.signal.aborted)setState(current=>mergeEnrollmentState(current,value));
|
||||
}catch{
|
||||
if(!controller.signal.aborted)setState(current=>current?{...current,available:false,fresh:false}:null);
|
||||
}finally{
|
||||
if(!controller.signal.aborted){
|
||||
setBusy(current=>current==='loading'?'':current);
|
||||
timer=setTimeout(()=>void observe(),3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
void observe();return()=>{controller.abort();if(timer)clearTimeout(timer);};
|
||||
void observe();
|
||||
return()=>{controller.abort();if(timer)clearTimeout(timer);};
|
||||
},[transport]);
|
||||
|
||||
const attempt=connectionAttempt(state);
|
||||
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
|
||||
const notice=state?enrollmentNotice(state):'';
|
||||
useEffect(()=>{setDevice('');setPassword('');},[state?.runtime_id,state?.discovery_generation,state?.mode_revision]);
|
||||
useEffect(()=>{
|
||||
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
|
||||
},[state?.runtime_id,state?.discovery_generation,state?.mode_revision]);
|
||||
useEffect(()=>{setScanned(false);},[state?.runtime_id,state?.mode_revision]);
|
||||
const ready=state?.available&&state.fresh!==false&&!!state.runtime_id;
|
||||
const selected=state?.candidates?.some(v=>v.id===device);
|
||||
const selected=state?.candidates?.some(value=>value.id===device)??false;
|
||||
const pending=!!busy||waiting;
|
||||
|
||||
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
||||
if(!state||busy||running.current)return;running.current=true;const signal=lifetime.current?.signal;setBusy(action);setError('');
|
||||
const parameters=action==='connect'||action==='verify'?{device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,...(action==='connect'?{ssid,password}:{})}:{};
|
||||
if(!state||busy||running.current)return;
|
||||
running.current=true;
|
||||
const signal=lifetime.current?.signal;
|
||||
setBusy(action);setError('');
|
||||
if(action==='scan')setScanned(false);
|
||||
const parameters=action==='connect'||action==='verify'?{
|
||||
device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,
|
||||
...(action==='connect'?{ssid,password}:{}),
|
||||
}:{};
|
||||
if(action==='connect')setPassword('');
|
||||
try {
|
||||
const result=await enroll(transport,state,action,parameters,{signal,onState:value=>{if(!signal?.aborted)setState(current=>mergeEnrollmentState(current,value));}});
|
||||
if(signal?.aborted)return;onChange();
|
||||
try{
|
||||
const result=await enroll(transport,state,action,parameters,{
|
||||
signal,onState:value=>{if(!signal?.aborted)setState(current=>mergeEnrollmentState(current,value));},
|
||||
});
|
||||
if(signal?.aborted)return;
|
||||
onChange();
|
||||
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
|
||||
if(action==='scan'){setDevice('');setScanned(true);}
|
||||
if(action==='networks')setNetworks(result.networks??[]);
|
||||
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
|
||||
}catch(e){if(!signal?.aborted)setError(e instanceof Error?e.message:'Не удалось выполнить действие K1.');}
|
||||
finally{running.current=false;if(!signal?.aborted)setBusy('');}
|
||||
}catch(cause){
|
||||
if(!signal?.aborted)setError(cause instanceof Error?cause.message:'Не удалось выполнить действие K1.');
|
||||
}finally{
|
||||
running.current=false;
|
||||
if(!signal?.aborted)setBusy('');
|
||||
}
|
||||
}
|
||||
const close=()=>{setPassword('');onClose();};
|
||||
return <Window open title="Подключение устройства к БК" onClose={close} footer={<WindowFooterActions>
|
||||
<Button onClick={close}>Закрыть</Button><Button variant="primary" disabled={!ready||!!busy||waiting||!selected||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} onClick={()=>void run('connect')}>Подключить</Button>
|
||||
</WindowFooterActions>}><div className="sensor-content" aria-busy={!!busy}>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'} description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
|
||||
<ResourceRow title="XGRIDS K1 · Bridge" status={<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':busy==='loading'?'Получаем сведения':'Подключение недоступно'}</StatusBadge>}/>
|
||||
</SettingsCard>
|
||||
{busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:!ready?<SettingsCard title="Служба подключения устройств на БК недоступна" description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:<>
|
||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={<Button disabled={!!busy||waiting||!enrollmentAllowed(state,'scan')} onClick={()=>void run('scan')}>Найти K1</Button>}/>
|
||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден" description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||
{!!state?.candidates?.length&&<Select label="Устройство K1" value={device} options={[{value:'',label:'Выберите K1',disabled:true},...state.candidates.map(v=>({value:v.id,label:v.name}))]} onChange={setDevice} disabled={!!busy||waiting}/>}
|
||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите доступную сеть или введите её название. БК должен иметь доступ к этой сети." actions={<Button disabled={!!busy||waiting} onClick={()=>void run('networks')}>Найти сети</Button>}/>
|
||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network} options={[{value:'manual',label:'Ввести название сети'},...networks.map((v,i)=>({value:String(i),label:v.ssid,description:`Сигнал ${v.signal}% · ${v.security||'Без защиты'}`}))]} onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={!!busy||waiting}/>}
|
||||
<TextField label="Название сети Wi-Fi" value={ssid} onChange={e=>{setSSID(e.target.value);setNetwork('manual');}} disabled={!!busy||waiting} autoComplete="off"/>
|
||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={!!busy||waiting} autoComplete="new-password"/>
|
||||
<Button disabled={!!busy||waiting||!selected||!enrollmentAllowed(state,'verify')} onClick={()=>void run('verify')}>Проверить текущее подключение</Button>
|
||||
</>}
|
||||
{!!busy&&busy!=='loading'&&<ActivityIndicator label={busy==='scan'?'Ищем K1 на БК':busy==='networks'?'Ищем сети рядом с БК':'Проверяем подключение K1'}/>}
|
||||
{notice&&<SettingsCard title={notice}/>}<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div></Window>;
|
||||
|
||||
return renderWindow({busy:pending,
|
||||
actions:selected?<Button variant="primary" disabled={!ready||pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)}
|
||||
aria-busy={busy==='connect'} icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void run('connect')}>{busy==='connect'?'Подключаем K1':'Подключить'}</Button>:undefined,
|
||||
content:<>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'}
|
||||
description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
|
||||
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
||||
<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':'Подключение недоступно'}</StatusBadge>}/>
|
||||
</SettingsCard>
|
||||
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
|
||||
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={
|
||||
<Button disabled={pending||!enrollmentAllowed(state,'scan')} aria-busy={busy==='scan'}
|
||||
icon={busy==='scan'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}</Button>}/>
|
||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
|
||||
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||
{!!state?.candidates?.length&&<Select label="Устройство K1" value={device}
|
||||
options={[{value:'',label:'Выберите K1',disabled:true},...state.candidates.map(value=>({value:value.id,label:value.name}))]}
|
||||
onChange={value=>{setDevice(value);setPassword('');}} disabled={pending}/>}
|
||||
{selected&&<>
|
||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите сеть, доступную бортовому компьютеру." actions={
|
||||
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}/>
|
||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
|
||||
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
|
||||
onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>}
|
||||
<TextField label="Название сети Wi-Fi" value={ssid} onChange={event=>{setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/>
|
||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
|
||||
<Button disabled={pending||!enrollmentAllowed(state,'verify')} aria-busy={busy==='verify'}
|
||||
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем подключение':'Проверить текущее подключение'}</Button>
|
||||
</>}
|
||||
</>}
|
||||
{notice&&<SettingsCard title={notice}/>}
|
||||
<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</>,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||
import {K1Detail} from './K1Detail';
|
||||
export {DeviceEnrollmentWindow as K1EnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
|
||||
export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user