Fix onboard BLE admission and stage wireless device enrollment

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 12:11:48 +03:00
parent 2fdf1d3cc4
commit 77acb377e2
17 changed files with 297 additions and 65 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import {xgridsK1SensorUi,K1EnrollmentWindow} from './sensors/plugin';
import {xgridsK1SensorUi} from './sensors/plugin';
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
import { XgridsK1Connection } from "./XgridsK1Connection";
import { K1SpatialControls } from "./components/K1SpatialControls";
@@ -8,7 +8,7 @@ import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,
sensorUi: {contributions: [xgridsK1SensorUi], Enrollment: K1EnrollmentWindow},
sensorUi: {contributions: [xgridsK1SensorUi]},
RuntimeProvider: XgridsK1RuntimeProvider,
SpatialControlsView: K1SpatialControls,
connectionViews: Object.freeze({
@@ -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('')}/>
</>,
});
}
@@ -1,7 +1,8 @@
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
import {K1Detail} from './K1Detail';
export {DeviceEnrollmentWindow as K1EnrollmentWindow} from './DeviceEnrollmentWindow';
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
export const xgridsK1SensorUi:SensorUiContribution={
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
};
+2 -2
View File
@@ -20,7 +20,7 @@ Removing the optional package preserves recordings, journals and material.
## Private autonomous installer
The public code package contains no key. The private edition
`0.1.0+private.1` carries the reviewed application material in one root-owned
`0.1.1+private.1` carries the reviewed application material in one root-owned
mode-0600 member. The resulting `.deb` is itself private (mode 0600); distribute
it only as the owner's prepared installer, never through Git or a public
package registry. File permissions on the installed member do not encrypt the
@@ -40,7 +40,7 @@ Building the private edition reads the material only from protected stdin:
```text
python plugins/xgrids-k1/packaging/build_deb.py
--wheel-root <reviewed-wheel-cache>
--output <private-output-directory>/mission-core-xgrids-k1_0.1.0+private.1_amd64.deb
--output <private-output-directory>/mission-core-xgrids-k1_0.1.1+private.1_amd64.deb
--private-authority-stdin
```
+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.0"
VERSION = "0.1.1"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
+2 -2
View File
@@ -3,8 +3,8 @@ set -eu
mc_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$mc_release_dir"
/usr/bin/sha256sum --check SHA256SUMS
mc_node_package="$mc_release_dir/mission-core-node_0.8.0_amd64.deb"
mc_k1_package="$mc_release_dir/mission-core-xgrids-k1_0.1.0+private.1_amd64.deb"
mc_node_package="$mc_release_dir/mission-core-node_0.8.1_amd64.deb"
mc_k1_package="$mc_release_dir/mission-core-xgrids-k1_0.1.1+private.1_amd64.deb"
if [ -t 0 ]; then
exec /usr/bin/sudo /usr/bin/apt-get install -y "$mc_node_package" "$mc_k1_package"
fi