fix(node): clarify wireless enrollment and restore active K1 after upgrade

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 13:02:28 +03:00
parent ef1c1f50de
commit 5c4546a2a6
10 changed files with 229 additions and 18 deletions
@@ -1,9 +1,9 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
import {ActivityIndicator,Button,ResourceList,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';
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorEnrollmentProps){
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
const [state,setState]=useState<EnrollmentState|null>(null);
const [device,setDevice]=useState('');
const [ssid,setSSID]=useState('');
@@ -13,6 +13,8 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
const [busy,setBusy]=useState('loading');
const [error,setError]=useState('');
const [scanned,setScanned]=useState(false);
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
const [needsScan,setNeedsScan]=useState(false);
const lifetime=useRef<AbortController|null>(null);
const running=useRef(false);
@@ -42,17 +44,21 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
const notice=state?enrollmentNotice(state):'';
useEffect(()=>{
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
setVerified(null);
setNeedsScan(false);
},[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(value=>value.id===device)??false;
const pending=!!busy||waiting;
const confirmed=enrollmentProofCurrent(verified,state,device);
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('');
if(action!=='networks')setVerified(null);
if(action==='scan')setScanned(false);
const parameters=action==='connect'||action==='verify'?{
device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,
@@ -68,6 +74,10 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
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(action==='connect'||action==='verify')setVerified(enrollmentProof(result,device));
if(action==='connect'&&connectionAttempt(result)?.public_error_code==='network-provision-candidate-not-fresh'){
setNeedsScan(true);setDevice('');
}
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
}catch(cause){
if(!signal?.aborted)setError(cause instanceof Error?cause.message:'Не удалось выполнить действие K1.');
@@ -78,12 +88,11 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
}
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,
actions:confirmed?<Button variant="primary" disabled={pending}
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
content:<>
<SettingsCard title={state?.name||'Бортовой компьютер'}
description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':'Подключение недоступно'}</StatusBadge>}/>
</SettingsCard>
@@ -95,10 +104,12 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
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&&<>
{!!state?.candidates?.length&&<ResourceList aria-label="Найденные устройства K1">
{state.candidates.map(value=><li key={value.id}><ResourceRow title={value.name}
actions={<Button disabled={pending||needsScan||device===value.id}
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
</ResourceList>}
{selected&&!confirmed&&<>
<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>}/>
@@ -107,9 +118,14 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE
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>
<ResourceRow title="Проверка подключения" description="Передадим настройки Wi-Fi сканеру K1 и проверим связь с БК." actions={
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}</Button>}/>
{attempt&&attempt.phase==='network_outcome_unknown'&&enrollmentAllowed(state,'verify')&&
<Button disabled={pending} aria-busy={busy==='verify'}
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
</>}
</>}
{notice&&<SettingsCard title={notice}/>}
@@ -115,6 +115,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
}
const code=attempt?.public_error_code??state.command_result?.error_code;
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
if(state.connected)return 'K1 подключён к БК.';
@@ -124,6 +125,25 @@ export function enrollmentNotice(state:EnrollmentState):string {
return '';
}
export interface EnrollmentProof {
runtime:string; generation:number|undefined; modeRevision:number|undefined;
device:string; session:string;
}
export function enrollmentProof(state:EnrollmentState,device:string):EnrollmentProof|null {
if(!state.available||state.fresh===false||!state.connected||!state.runtime_id||
state.selected_device_id!==device||!state.device_session?.device_session_id||
['failed','rejected'].includes(state.command_result?.status??''))return null;
return {runtime:state.runtime_id,generation:state.discovery_generation,modeRevision:state.mode_revision,
device,session:state.device_session.device_session_id};
}
export function enrollmentProofCurrent(proof:EnrollmentProof|null,state:EnrollmentState|null,device:string):boolean {
if(!proof||!state)return false;
const current=enrollmentProof(state,device);
return !!current&&Object.entries(proof).every(([key,value])=>current[key as keyof EnrollmentProof]===value);
}
export function bridgeFormValid(ssid:string,password:string):boolean{
const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64;
}