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
@@ -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);
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+7 -6
View File
@@ -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});
}
+7 -1
View File
@@ -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(
+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>}/>
}
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},
};
+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
@@ -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": (
+47
View File
@@ -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()