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