Fix onboard BLE admission and stage wireless device enrollment
This commit is contained in:
@@ -105,7 +105,6 @@ export interface DevicePluginConnectionProps {
|
||||
export interface DeviceUiPlugin {
|
||||
sensorUi?: {
|
||||
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||
Enrollment?: ComponentType<import('../../../../../packages/sensor-ui/src/extensions').SensorEnrollmentProps>;
|
||||
};
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
|
||||
@@ -7,8 +7,6 @@ import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=useMemo(()=>registry.plugins.flatMap(plugin=>plugin.sensorUi?.contributions??[]),[registry]);
|
||||
const enrollmentViews=registry.plugins.flatMap(plugin=>plugin.sensorUi?.Enrollment?[plugin.sensorUi.Enrollment]:[]);
|
||||
const SensorEnrollmentView=enrollmentViews.length===1?enrollmentViews[0]:undefined;
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
enrollment:{
|
||||
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||
@@ -20,5 +18,5 @@ export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:str
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} EnrollmentView={SensorEnrollmentView} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {readFileSync,readdirSync} from 'node:fs';
|
||||
import {createServer} from 'vite';
|
||||
let server,api,resolveContribution;
|
||||
let server,api,resolveContribution,wirelessContributions;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
api=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/enrollment.ts');
|
||||
({sensorContribution:resolveContribution}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||
({wirelessContributions}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const initial={node_id:'node-one',available:true,fresh:true,runtime_id:'runtime-one',snapshot_revision:1,runtime_started_at:'2026-09-06T00:00:00Z',selected_device_id:'synthetic-ble'};
|
||||
@@ -87,6 +88,14 @@ test('sensor host can resolve zero or unrelated integrations and rejects ambigui
|
||||
assert.doesNotMatch(readFileSync(new URL('../../../packages/sensor-ui/src/'+name,import.meta.url),'utf8'),/xgrids|lixel|\bk1\b|K1Detail/,'vendor dependency in '+name);
|
||||
}
|
||||
});
|
||||
test('wireless device choices come from installed contributions without a single-vendor slot',()=>{
|
||||
const wired={kind:'wired',Detail:()=>null};
|
||||
const alpha={kind:'alpha',wirelessEnrollment:{label:'Device alpha',View:()=>null}};
|
||||
const beta={kind:'beta',wirelessEnrollment:{label:'Device beta',View:()=>null}};
|
||||
assert.deepEqual(wirelessContributions([]),[]);
|
||||
assert.deepEqual(wirelessContributions([wired,alpha,beta]),[alpha,beta]);
|
||||
assert.deepEqual(wirelessContributions([alpha,{...alpha},beta]),[beta]);
|
||||
});
|
||||
test('onboard actions use current server policy and never infer authority from readiness',()=>{
|
||||
const state={...initial,connected:true,allowed_actions:['verify-control-read-only']};
|
||||
assert.equal(api.enrollmentAllowed(state,'connect'),false);
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.8.0"
|
||||
VERSION = "0.8.1"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {xgridsK1SensorUi,K1EnrollmentWindow} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi]} EnrollmentView={K1EnrollmentWindow} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Node discovery admission and wireless enrollment R3
|
||||
|
||||
The owner tested the newly installed private K1 package through the Core Fleet
|
||||
UI after clearing Chrome cache. At 11:55 MSK, choosing Find K1 produced an
|
||||
unconfirmed-action notification. The board continued reporting idle with no
|
||||
discovery generation change. Installation and worker readiness from R2 remain
|
||||
valid observations; physical discovery was not accepted by that readiness check.
|
||||
|
||||
## Proven defect
|
||||
|
||||
NodeBridge passed `duration_seconds` and `operation_id` to `discovery.scan`,
|
||||
but omitted `expected_snapshot_runtime_id`. The real XgridsK1PluginFacade
|
||||
requires that field before invoking its BLE service. It raised
|
||||
SnapshotRuntimeConflict before discovery, and the worker's generic HTTP 409
|
||||
became the broker's unconfirmed-action result. Journald had no diagnostic entry
|
||||
because that exception was deliberately swallowed without logging.
|
||||
|
||||
The earlier Node test replaced the facade, so it did not exercise this real
|
||||
admission boundary. A regression test now replaces only the physical service:
|
||||
the original code reproduces SnapshotRuntimeConflict; the fixed code admits
|
||||
one scan with the exact requested runtime ID and rejects an old runtime without
|
||||
another service call. No BLE I/O occurs in this test.
|
||||
|
||||
The adapter now forwards the admitted command's runtime ID. The runtime fence
|
||||
itself is unchanged. Worker failures also emit only the admitted action and
|
||||
exception class to the service journal; exception text, request bodies,
|
||||
credentials and device data are never logged there.
|
||||
|
||||
## Owner-approved UI composition
|
||||
|
||||
The operator job is adding a supported wireless sensor to the chosen onboard
|
||||
computer through its device-list plus action, in Core or the Node application.
|
||||
The owner requested the same bounded modal with the title
|
||||
«Подключение беспроводных устройств к БК» and a supported-device selector first.
|
||||
The selector comes from installed sensor UI contributions. No hardware form or
|
||||
request is mounted before choosing the model. Only XGRIDS LixelKity K1 is
|
||||
currently contributed; a second model is not claimed to have a backend.
|
||||
|
||||
The host owns WirelessEnrollmentWindow and the selector; the selected plugin
|
||||
owns its device workflow and actions through a typed render contract. This
|
||||
replaces the single EnrollmentView slot that disappeared whenever more than one
|
||||
plugin supplied a view. Ambiguous contribution IDs remain unavailable. The
|
||||
alternative of putting a hard-coded model dropdown inside the K1 plugin was
|
||||
rejected because it would leave the shared plus action owned by one vendor.
|
||||
|
||||
After choosing the K1 model, the operator can search Bluetooth and choose a
|
||||
found scanner. Wi-Fi selection, credentials and Connect appear only for that
|
||||
selected current candidate. Search, network lookup, verification and Connect
|
||||
show their pending indicator and text inside their own button; the detached
|
||||
bottom indicator is removed. Repeated actions/model changes are disabled during
|
||||
an operation; closing stops observation without replaying or cancelling the
|
||||
physical intent. Runtime/discovery changes retire selected-device credentials.
|
||||
|
||||
This is domain content in the existing modal. It reuses the pinned Design
|
||||
Guideline Button, ActivityIndicator compact icon slot, Select, Window,
|
||||
WindowFooterActions, ResourceRow, SettingsCard, TextField, StatusBadge and
|
||||
ToastStack. No design-system geometry, CSS, icon or new product root was added.
|
||||
Core Fleet and Node share the same component. LAB, recorded and live Rerun
|
||||
settings and the K1 network/acquisition recovery state machine were not edited.
|
||||
|
||||
## Software acceptance
|
||||
|
||||
- Core architecture: 4 checks passed; TypeScript passed.
|
||||
- Core full frontend suite: 787 passed, no failures or skips.
|
||||
- Core production build passed; canonical port 8000 serves the exact new index
|
||||
with Cache-Control no-store and operational readiness true.
|
||||
- Node UI boundary check, TypeScript and production build passed; Go build and
|
||||
Go package tests passed.
|
||||
- 25 Python Node bridge, installer/import-boundary and Fleet enrollment checks
|
||||
passed, including the real-facade regression and retained secret/idempotency
|
||||
checks. Ruff and whitespace checks passed.
|
||||
|
||||
Node 0.8.1 and private K1 0.1.1+private.1 identify the replacement release;
|
||||
previous R2 artifact bytes are retained. Exact source/release hashes and board
|
||||
installation results belong in the following acceptance addendum. Hardware UI
|
||||
discovery, provisioning, camera/LiDAR and recovery remain pending until a new
|
||||
owner run with cache cleared before the test.
|
||||
@@ -1,12 +1,13 @@
|
||||
import {useCallback,useEffect,useState,type ComponentType} from 'react';
|
||||
import {useCallback,useEffect,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
import {sensorStatus} from './sensorStatus';
|
||||
import {sensorContribution,type SensorUiContribution,type SensorEnrollmentProps} from './extensions';
|
||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
import './sensors.css';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],EnrollmentView}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];EnrollmentView?:ComponentType<SensorEnrollmentProps>}){
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
||||
const [adding,setAdding]=useState(false);
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
@@ -27,8 +28,8 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&EnrollmentView&&<IconButton label="Подключить устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте его через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||
@@ -37,7 +38,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
{adding&&transport.enrollment&&EnrollmentView&&<EnrollmentView transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
|
||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
||||
|
||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{
|
||||
contributions:readonly SensorUiContribution[];
|
||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||
}) {
|
||||
const [selected,setSelected]=useState('');
|
||||
const supported=wirelessContributions(contributions);
|
||||
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
|
||||
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
|
||||
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
|
||||
footer={<WindowFooterActions><Button onClick={onClose}>Закрыть</Button>{actions}</WindowFooterActions>}>
|
||||
<div className="sensor-content">
|
||||
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
|
||||
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
|
||||
...supported.map(value=>({value:value.kind,label:value.wirelessEnrollment!.label}))]}
|
||||
onChange={setSelected}/>
|
||||
{content}
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} renderWindow={renderWindow}/>:renderWindow({content:null});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {ComponentType} from 'react';
|
||||
import type {ComponentType,ReactNode} from 'react';
|
||||
import type {IconName} from '@nodedc/ui-react';
|
||||
import type {Sensor, SensorTransport} from './contracts';
|
||||
import type {EnrollmentTransport} from './enrollment';
|
||||
@@ -11,6 +11,7 @@ export interface SensorDetailProps {
|
||||
}
|
||||
export interface SensorEnrollmentProps {
|
||||
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
kind:string;
|
||||
@@ -18,6 +19,11 @@ export interface SensorUiContribution {
|
||||
icon:IconName;
|
||||
retainOffline:boolean;
|
||||
supportsPreparation:boolean;
|
||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||
}
|
||||
|
||||
export function wirelessContributions(contributions:readonly SensorUiContribution[]):SensorUiContribution[] {
|
||||
return contributions.filter(value=>value.wirelessEnrollment&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||
}
|
||||
|
||||
export function sensorContribution(
|
||||
|
||||
@@ -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();
|
||||
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>}/>
|
||||
}
|
||||
|
||||
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'?<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!=='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>
|
||||
</>}
|
||||
{!!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>;
|
||||
</>}
|
||||
{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},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ invocations. Its public projection contains no credentials or raw evidence.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -151,6 +152,7 @@ class NodeBridge:
|
||||
{
|
||||
"duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
|
||||
"operation_id": identifier,
|
||||
"expected_snapshot_runtime_id": command["runtime_id"],
|
||||
},
|
||||
identifier,
|
||||
)
|
||||
@@ -288,7 +290,17 @@ def create_app(repository_root: Path):
|
||||
raise ValueError("Request too large")
|
||||
body = await request.json()
|
||||
return await bridge.deliver(body)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
# Class and admitted action are sufficient for transport diagnosis.
|
||||
# Never log the exception text, incoming payload or credentials.
|
||||
action = body.get("action") if isinstance(body, dict) else None
|
||||
logging.getLogger(__name__).warning(
|
||||
"K1 operation failed: action=%s exception=%s",
|
||||
action
|
||||
if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"}
|
||||
else "invalid",
|
||||
type(error).__name__,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
|
||||
@@ -70,6 +70,53 @@ def bridge():
|
||||
return result
|
||||
|
||||
|
||||
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
||||
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
||||
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
||||
|
||||
class Service:
|
||||
def __init__(self):
|
||||
self.scans = []
|
||||
self.fences = []
|
||||
self.current = state()
|
||||
|
||||
def bind_runtime_event_loop(self, _loop):
|
||||
pass
|
||||
|
||||
def state(self):
|
||||
return copy.deepcopy(self.current)
|
||||
|
||||
def require_snapshot_runtime_id(self, expected):
|
||||
if expected != self.current["snapshot_runtime_id"]:
|
||||
raise SnapshotRuntimeConflict()
|
||||
self.fences.append(expected)
|
||||
|
||||
async def scan_ble(self, request):
|
||||
self.scans.append(request.operation_id)
|
||||
return self.state()
|
||||
|
||||
async def run():
|
||||
service = Service()
|
||||
device = NodeBridge(Path.cwd(), service=service)
|
||||
command = {
|
||||
"operation_id": "op_" + "b" * 32,
|
||||
"runtime_id": "runtime-one",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"action": "scan",
|
||||
"parameters": {},
|
||||
}
|
||||
output = await device.deliver(command)
|
||||
assert service.fences == ["runtime-one"]
|
||||
assert service.scans == [command["operation_id"]]
|
||||
assert len(output["candidates"]) == 1
|
||||
command["runtime_id"] = "retired-runtime"
|
||||
rejected = await device.deliver(command)
|
||||
assert rejected["command_result"]["status"] == "rejected"
|
||||
assert service.scans == [command["operation_id"]]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_node_bridge_forces_reviewed_bridge_and_clears_input_secret():
|
||||
async def run():
|
||||
device = bridge()
|
||||
|
||||
Reference in New Issue
Block a user