Group onboard wireless steps and trace journalled Bluetooth failures

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 13:27:21 +03:00
parent a0732386f1
commit d7986cb62f
10 changed files with 217 additions and 27 deletions
@@ -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'});
}
+1 -1
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.2"
VERSION = "0.1.3"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",