From 77acb377e2a996f70eb6e24f38e9a924da8be023 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 12:11:48 +0300 Subject: [PATCH] Fix onboard BLE admission and stage wireless device enrollment --- .../src/core/device-plugins/contracts.ts | 1 - .../src/workspaces/fleet/VehicleSensors.tsx | 4 +- .../test/sensorEnrollment.test.mjs | 11 +- apps/node-agent/packaging/build_deb.py | 2 +- apps/node-agent/ui/src/NodeSensors.tsx | 4 +- ...07-k1-node-discovery-and-wireless-ui-r3.md | 77 ++++++++++ packages/sensor-ui/src/SensorWorkspace.tsx | 13 +- .../src/WirelessEnrollmentWindow.tsx | 25 ++++ packages/sensor-ui/src/extensions.ts | 8 +- plugins/xgrids-k1/frontend/src/plugin.ts | 4 +- .../src/sensors/DeviceEnrollmentWindow.tsx | 139 ++++++++++++------ .../xgrids-k1/frontend/src/sensors/plugin.ts | 3 +- plugins/xgrids-k1/packaging/README.md | 4 +- plugins/xgrids-k1/packaging/build_deb.py | 2 +- plugins/xgrids-k1/packaging/install | 4 +- .../device_plugins/xgrids_k1/node_bridge.py | 14 +- tests/test_node_k1_bridge.py | 47 ++++++ 17 files changed, 297 insertions(+), 65 deletions(-) create mode 100644 docs/audits/2026-09-07-k1-node-discovery-and-wireless-ui-r3.md create mode 100644 packages/sensor-ui/src/WirelessEnrollmentWindow.tsx diff --git a/apps/control-station/src/core/device-plugins/contracts.ts b/apps/control-station/src/core/device-plugins/contracts.ts index 473710f..c621e86 100644 --- a/apps/control-station/src/core/device-plugins/contracts.ts +++ b/apps/control-station/src/core/device-plugins/contracts.ts @@ -105,7 +105,6 @@ export interface DevicePluginConnectionProps { export interface DeviceUiPlugin { sensorUi?: { contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[]; - Enrollment?: ComponentType; }; manifest: DevicePluginManifest; RuntimeProvider: ComponentType<{ diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index ba1c849..6686905 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -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(()=>({ 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 ; + return ; } diff --git a/apps/control-station/test/sensorEnrollment.test.mjs b/apps/control-station/test/sensorEnrollment.test.mjs index 7c92a4e..61fc2a2 100644 --- a/apps/control-station/test/sensorEnrollment.test.mjs +++ b/apps/control-station/test/sensorEnrollment.test.mjs @@ -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); diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 8a2dca7..b5ab174 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -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 diff --git a/apps/node-agent/ui/src/NodeSensors.tsx b/apps/node-agent/ui/src/NodeSensors.tsx index 248184b..04e069e 100644 --- a/apps/node-agent/ui/src/NodeSensors.tsx +++ b/apps/node-agent/ui/src/NodeSensors.tsx @@ -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 ;} +export function NodeSensors(){return ;} diff --git a/docs/audits/2026-09-07-k1-node-discovery-and-wireless-ui-r3.md b/docs/audits/2026-09-07-k1-node-discovery-and-wireless-ui-r3.md new file mode 100644 index 0000000..32f818b --- /dev/null +++ b/docs/audits/2026-09-07-k1-node-discovery-and-wireless-ui-r3.md @@ -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. diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index ffa70de..50c6070 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -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}){ +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(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState(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
{device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:
:<> -
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&EnrollmentView&&setAdding(true)}>}{void refresh();}}>
- {!inventory?:connected.length===0?:{connected.map(item=>{ +
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(true)}>}{void refresh();}}>
+ {!inventory?:connected.length===0?:{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&&{inventory.preparation.steps.map(step=>:step.state==='running'?:{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}}/>) }:Ожидает}/>} } setEditing(null)} footer={}>
setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
- {adding&&transport.enrollment&&EnrollmentView&&setAdding(false)} onChange={()=>{void refresh();}}/>} + {adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(false)} onChange={()=>{void refresh();}}/>} setError('')}/>
; } diff --git a/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx new file mode 100644 index 0000000..eb00b62 --- /dev/null +++ b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx @@ -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})=>( + {actions}}> +
+ ({value:v.id,label:v.name}))]} onChange={setDevice} disabled={!!busy||waiting}/>} - void run('networks')}>Найти сети}/> - {!!networks.length&&({value:value.id,label:value.name}))]} + onChange={value=>{setDevice(value);setPassword('');}} disabled={pending}/>} + {selected&&<> + :undefined} + onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}}/> + {!!networks.length&&