Group onboard wireless steps and trace journalled Bluetooth failures
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import {useEffect,useRef,useState} from '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,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
|
||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
|
||||
import {revealEnrollment} from './revealEnrollment';
|
||||
|
||||
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
|
||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||
@@ -15,6 +16,9 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
const [scanned,setScanned]=useState(false);
|
||||
const [verified,setVerified]=useState<EnrollmentProof|null>(null);
|
||||
const [needsScan,setNeedsScan]=useState(false);
|
||||
const [dismissedAttempt,setDismissedAttempt]=useState('');
|
||||
const [reveal,setReveal]=useState({target:'',revision:0});
|
||||
const content=useRef<HTMLDivElement>(null);
|
||||
const lifetime=useRef<AbortController|null>(null);
|
||||
const running=useRef(false);
|
||||
|
||||
@@ -41,7 +45,8 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
|
||||
const attempt=connectionAttempt(state);
|
||||
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
|
||||
const notice=state?enrollmentNotice(state):'';
|
||||
const notice=state&&(!state.available||state.fresh===false||attempt?.attempt_id!==dismissedAttempt)?enrollmentNotice(state):'';
|
||||
const bluetoothFailure=!!notice&&enrollmentBluetoothFailure(state);
|
||||
useEffect(()=>{
|
||||
setDevice('');setPassword('');setSSID('');setNetwork('manual');setNetworks([]);
|
||||
setVerified(null);
|
||||
@@ -52,6 +57,17 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
const selected=state?.candidates?.some(value=>value.id===device)??false;
|
||||
const pending=!!busy||waiting;
|
||||
const confirmed=enrollmentProofCurrent(verified,state,device);
|
||||
function show(target:string){setReveal(current=>({target,revision:current.revision+1}));}
|
||||
useEffect(()=>{
|
||||
if(notice&&(scanned||device))show('result');
|
||||
},[notice,confirmed]);
|
||||
useEffect(()=>{
|
||||
const frame=requestAnimationFrame(()=>{
|
||||
const target=content.current?.querySelector<HTMLElement>(`[data-enrollment-reveal="${reveal.target}"]`);
|
||||
if(target)revealEnrollment(target);
|
||||
});
|
||||
return()=>cancelAnimationFrame(frame);
|
||||
},[reveal]);
|
||||
|
||||
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
||||
if(!state||busy||running.current)return;
|
||||
@@ -72,10 +88,13 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
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(action==='connect'||action==='verify')setVerified(enrollmentProof(result,device));
|
||||
if(action==='connect'&&connectionAttempt(result)?.public_error_code==='network-provision-candidate-not-fresh'){
|
||||
if(action==='scan'){
|
||||
setDevice('');setScanned(true);setNeedsScan(false);
|
||||
setDismissedAttempt(connectionAttempt(result)?.attempt_id??'');show('devices');
|
||||
}
|
||||
if(action==='networks'){setNetworks(result.networks??[]);show('networks');}
|
||||
if(action==='connect'||action==='verify'){setVerified(enrollmentProof(result,device));setDismissedAttempt('');}
|
||||
if(action==='connect'&&enrollmentBluetoothFailure(result)){
|
||||
setNeedsScan(true);setDevice('');
|
||||
}
|
||||
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
|
||||
@@ -90,7 +109,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
return renderWindow({busy:pending,
|
||||
actions:confirmed?<Button variant="primary" disabled={pending}
|
||||
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
|
||||
content:<>
|
||||
content:<div className="sensor-content" ref={content}>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'}
|
||||
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
|
||||
<ResourceRow title="Общая сеть · Bridge" status={busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:
|
||||
@@ -98,38 +117,46 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
||||
</SettingsCard>
|
||||
{busy!=='loading'&&!ready?<SettingsCard title="Служба подключения устройств на БК недоступна"
|
||||
description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:ready&&<>
|
||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={
|
||||
<SettingsCard eyebrow="Шаг 01" title="Bluetooth" description="Найдите K1 рядом с бортовым компьютером и выберите его из списка." 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>}/>
|
||||
onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}</Button>}>
|
||||
<div data-enrollment-reveal="devices">
|
||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден"
|
||||
description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||
{!!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>)}
|
||||
actions={<Button disabled={pending||needsScan||bluetoothFailure||device===value.id}
|
||||
onClick={()=>{setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}</Button>}/></li>)}
|
||||
</ResourceList>}
|
||||
{selected&&!confirmed&&<>
|
||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите сеть, доступную бортовому компьютеру." actions={
|
||||
</div>
|
||||
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||
</SettingsCard>
|
||||
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
||||
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
|
||||
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}/>
|
||||
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}>
|
||||
{!confirmed&&<>
|
||||
<div data-enrollment-reveal="networks">
|
||||
{!!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}/>}
|
||||
</div>
|
||||
<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"/>
|
||||
<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>}/>
|
||||
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&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||
</SettingsCard></div>}
|
||||
</>}
|
||||
{notice&&<SettingsCard title={notice}/>}
|
||||
{notice&&!selected&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard title={notice}/></div>}
|
||||
<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</>,
|
||||
</div>,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
||||
}
|
||||
const code=attempt?.public_error_code??state.command_result?.error_code;
|
||||
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
|
||||
if(enrollmentBluetoothFailure(state))return 'Не удалось установить связь с K1 по Bluetooth. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и выберите его из списка.';
|
||||
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 подключён к БК.';
|
||||
@@ -125,6 +126,13 @@ export function enrollmentNotice(state:EnrollmentState):string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function enrollmentBluetoothFailure(state:EnrollmentState|null):boolean {
|
||||
const attempt=connectionAttempt(state);
|
||||
return !!attempt&&attempt.status==='failed'&&attempt.side_effect_status==='none'&&
|
||||
(attempt.public_error_code==='network-provision-candidate-not-fresh'||
|
||||
(attempt.stage==='connect-failed'&&['BleakError','BleakDBusError','BleakDeviceNotFoundError','TimeoutError'].includes(attempt.public_error_code??'')));
|
||||
}
|
||||
|
||||
export interface EnrollmentProof {
|
||||
runtime:string; generation:number|undefined; modeRevision:number|undefined;
|
||||
device:string; session:string;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Reveal a newly available workflow block inside its existing scroll surface.
|
||||
* Never scroll the page behind the modal or move keyboard focus.
|
||||
*/
|
||||
export function revealEnrollment(target:HTMLElement):void {
|
||||
let scroller=target.parentElement;
|
||||
while(scroller){
|
||||
if(/auto|scroll/.test(getComputedStyle(scroller).overflowY)&&scroller.scrollHeight>scroller.clientHeight)break;
|
||||
scroller=scroller.parentElement;
|
||||
}
|
||||
if(!scroller||scroller===document.body||scroller===document.documentElement)return;
|
||||
const viewport=scroller.getBoundingClientRect();
|
||||
const block=target.getBoundingClientRect();
|
||||
const visible=block.top>=viewport.top&&Math.min(block.bottom,block.top+scroller.clientHeight)<=viewport.bottom;
|
||||
if(visible)return;
|
||||
// Large steps align their heading; short results move only enough to become visible.
|
||||
const delta=block.height>scroller.clientHeight||block.top<viewport.top
|
||||
?block.top-viewport.top:block.bottom-viewport.bottom;
|
||||
scroller.scrollTo({top:scroller.scrollTop+delta,behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});
|
||||
}
|
||||
Reference in New Issue
Block a user