@@ -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 > ,
} ) ;
}