Fix onboard K1 enrollment continuity and share named acquisition preparation

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 18:53:38 +03:00
parent 4f333fc11c
commit eed53dbc0b
23 changed files with 362 additions and 92 deletions
@@ -1991,7 +1991,7 @@ test("observed SCANNING recovery is a successful STOP-only connection outcome",
);
assert.ok(
acquisitionSource.indexOf("terminalPhysicalStopObserved ? (")
< acquisitionSource.indexOf("<div className=\"scan-configuration-grid\">"),
< acquisitionSource.indexOf("<K1AcquisitionFields"),
"recovered SCANNING must render the STOP-only branch before project/START controls",
);
});
@@ -21,6 +21,8 @@ const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{de
test('manual K1 surface admits one START only after current control proof',()=>{
const markup=render(device);
assert.match(markup,/Инициировать запуск/);
assert.match(markup,/<button[^>]*disabled=""[^>]*>Инициировать запуск<\/button>/);
for(const label of ['Тип установки / носитель','Режим GNSS','Название проекта'])assert.ok(markup.includes(label));
assert.match(markup,/Настройки устройства/);
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
const waiting={...device,online:false,verified:false,control:{...device.control,can_start:false,network_applied:true,reason_code:'application_authority_unavailable'}};
@@ -36,7 +38,7 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
const markup=render(active);
assert.match(markup,/Остановить устройство/);
assert.match(markup,/rerun-viewport__canvas/);
assert.match(markup,/>Статус</);
assert.doesNotMatch(markup,/>Статус</);
assert.match(markup,/>Пространственная сцена</);
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
assert.ok(markup.includes('data-presented="false"'));
@@ -195,3 +195,12 @@ test('lost BLE candidate tells the operator to rescan without blaming Ethernet o
assert.match(notice,/Настройки Wi-Fi не были отправлены/);
assert.doesNotMatch(notice,/пароль|Ethernet/);
});
test('pending enrollment is presented by its action button without a second notice',()=>{
for(const phase of ['connecting','network_applied']){
assert.equal(api.enrollmentNotice({...initial,connection_attempt:{
schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'running',phase,
}}),'');
}
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.10"
VERSION = "0.8.11"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
@@ -0,0 +1,74 @@
# K1 R14: enrollment failure, recheck and launch controls
## Observed failures
The owner exercised installed R13 through the UI. Camera, cloud and a 2.47 m
trajectory were visible, and STOP was accepted. This supports that bounded run;
it is not acceptance of arbitrary network outages or long acquisitions.
The first provisioning failure at 18:03:32 MSK terminated in the installed
Bleak BlueZ manager `_check_device`, line 217, called by `add_device_watcher`
while `BleakClient.connect` was opening its D-Bus connection. The exact selected
device path was absent from the manager. The installed code was inspected to
confirm the branch. This precedes the physical Connect request, GATT baseline
and Wi-Fi write; an incorrect Wi-Fi password did not cause this failure.
The preflight cache check and client entry had an asynchronous gap. Discovery
was already stopped, so the checked path could disappear in that gap. BlueZ
cache cleanup is the implementation mechanism addressed here; the bounded
daemon journal did not record a reason for removing this particular path.
The second owner search/provisioning succeeded. No password, raw payload or
private device evidence is included in this report.
At 18:09:40 MSK, post-STOP Verify failed in `ensure_device_for_gatt` while
requiring rediscovery of the exact selected Bluetooth transport. At 18:09:52,
the next Verify had no exact BLE device available. These are discovery failures,
not a rejected Wi-Fi password. The Node detail adapter used legacy server-target
resolution, which prioritizes fresh/retained BLE even when an admitted durable
network target is available.
## Changes
- Hold one BlueZ discovery reference from the selected-path preflight until
the one Bleak client connection completes. Release it before characteristic
reads/writes, including failure and cancellation. The existing exact address,
adapter, capture, owner and GATT checks remain. No Connect or Wi-Fi-write retry
is introduced. The CoreBluetooth path is unchanged.
- Node Verify requests the exact persisted Bridge target only when the existing
server policy admits it without mandatory live GATT validation. The facade
continues to verify ledger lineage, route, DeviceInfo, and physical recovery
requirements. All other cases retain the existing verification path.
- Failed verification has a direct action to reopen wireless enrollment. The
separate Status card is removed; actionable failure stays in the device card.
- Enrollment pending feedback stays in its button. The full-width primary
configuration button now also has a full-width footer action container.
- The camera notice is bounded by a relative media-content container. It no
longer covers the shared floating window's draggable header and actions.
- Direct and onboard launch consume shared `K1AcquisitionFields`: existing
installation/GNSS options and project validation. Empty/invalid names disable
START. Node validates all three values before workspace/project commands and
passes the operator name to `acquisition.prepare`; a mismatched prepared
project cannot be started. Admitted options remain handheld and no RTK.
## Validation and delivery boundary
Control Station tests: 805/805 passed, including architecture and direct K1
recovery regressions. Native/Node/Wi-Fi/installer tests: 93/93 passed; the final
START subset passed 29/29. They cover the BlueZ cache gap, exact target/adapter,
cancellation cleanup, at most one GATT connection/write, persisted verification
admission, invalid launch drafts and propagation of the operator project name.
Ruff and whitespace checks passed. The final Control Station production build
(including typecheck) and Node UI boundary test passed. Tests use synthetic
fixtures, without commands
to the physical scanner.
The canonical endpoint remains 8000. CUA exposes no documented click/cache
controls; no clean-cache hardware acceptance is claimed from source checks or
an in-app page load. Owner acceptance must confirm first-attempt enrollment,
post-STOP Verify, full-width configuration, draggable camera while waiting/live,
and a named acquisition through the existing UI.
R14 is a public matched update: Node 0.8.11 and K1 0.1.11. It uses the existing
encrypted onboard credential. No Keychain export, new secret package, physical
CLI probe, Git push or Ops retry is part of this change. Production build,
artifact identity and installation outcomes are recorded when completed.
+1 -1
View File
@@ -35,7 +35,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
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 key={device.snapshot.context.session_id} 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>:<>
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<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&&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;
@@ -12,7 +12,7 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
footer={actions?<WindowFooterActions>{actions}</WindowFooterActions>:undefined}>
footer={actions?<WindowFooterActions style={{width:'100%'}}>{actions}</WindowFooterActions>:undefined}>
<div className="sensor-content">
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
+1 -1
View File
@@ -7,7 +7,7 @@ import type {RerunHostFactory} from './rerunHost';
export interface SensorDetailProps {
device:Sensor; transport:SensorTransport; enabled:boolean;
back:()=>void; refresh:()=>Promise<void>; failure:(error:unknown)=>void;
createRerunHost?:RerunHostFactory;
createRerunHost?:RerunHostFactory; reconnect?:()=>void;
}
export interface SensorEnrollmentProps {
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
@@ -0,0 +1,3 @@
.k1-acquisition-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--nodedc-space-3)}
.k1-acquisition-fields > div {display:grid;min-width:0;gap:var(--nodedc-space-2)}
@media (max-width:960px) {.k1-acquisition-fields {grid-template-columns:1fr}}
@@ -0,0 +1,55 @@
import {Select,TextField} from '@nodedc/ui-react';
import {mountTypeOptions,gnssModeOptions,type MountType,type GnssMode} from '../configuration';
import {normalizeProjectName,validateProjectName} from '../projectName';
import './K1AcquisitionFields.css';
export function K1AcquisitionFields({mountType,setMountType,gnssMode,setGnssMode,projectName,setProjectName,
projectNameTouched,setProjectNameTouched,disabled,projectDisabled=disabled}:{
mountType:MountType;setMountType:(value:MountType)=>void;gnssMode:GnssMode;setGnssMode:(value:GnssMode)=>void;
projectName:string;setProjectName:(value:string|((current:string)=>string))=>void;
projectNameTouched:boolean;setProjectNameTouched:(value:boolean)=>void;disabled:boolean;projectDisabled?:boolean;
}) {
const projectNameValidation=validateProjectName(projectName);
return <>
<div className="scan-configuration-grid k1-acquisition-fields">
<div className="configuration-field">
<span className="nodedc-field__description">Тип установки / носитель</span>
<Select
label="Тип установки / носитель"
value={mountType}
options={mountTypeOptions}
onChange={setMountType}
disabled={disabled}
variant="split"
/>
</div>
<div className="configuration-field">
<span className="nodedc-field__description">Режим GNSS</span>
<Select
label="Режим GNSS"
value={gnssMode}
options={gnssModeOptions}
onChange={setGnssMode}
disabled={disabled}
variant="split"
/>
</div>
</div>
<TextField
label="Название проекта"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={projectDisabled}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: undefined}
placeholder="Например, TEST001"
/>
</>;
}
@@ -1,3 +1,4 @@
import {K1AcquisitionFields} from './K1AcquisitionFields';
import { useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
@@ -5,7 +6,6 @@ import {
Checker,
GlassSurface,
Icon,
Select,
SegmentedControl,
StatusBadge,
TextField,
@@ -21,8 +21,6 @@ import {
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
gnssModeOptions,
mountTypeOptions,
type GnssMode,
type MountType,
} from "../configuration";
@@ -41,7 +39,6 @@ import {
} from "../lifecycle";
import { connectionPolicyOperatorGuidance } from "../presentation";
import {
normalizeProjectName,
projectNameAfterConnectionModeSelection,
shouldHydratePreparedProject,
validateProjectName,
@@ -443,46 +440,9 @@ export function K1AcquisitionPipeline({
</div>
) : effectiveSessionIntent === "live" ? (
<div className="session-form">
<div className="scan-configuration-grid">
<div className="configuration-field">
<span className="nodedc-field__description">Тип установки / носитель</span>
<Select
label="Тип установки / носитель"
value={mountType}
options={mountTypeOptions}
onChange={setMountType}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
<div className="configuration-field">
<span className="nodedc-field__description">Режим GNSS</span>
<Select
label="Режим GNSS"
value={gnssMode}
options={gnssModeOptions}
onChange={setGnssMode}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
</div>
<TextField
label="Название проекта"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={isBusy || preparedAcquisition !== null || sourceRuntimeBusy}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: undefined}
placeholder="Например, TEST001"
/>
<K1AcquisitionFields mountType={mountType} setMountType={setMountType} gnssMode={gnssMode} setGnssMode={setGnssMode}
projectName={projectName} setProjectName={setProjectName} projectNameTouched={projectNameTouched} setProjectNameTouched={setProjectNameTouched}
disabled={isBusy || sessionLocked} projectDisabled={isBusy || preparedAcquisition !== null || sourceRuntimeBusy}/>
<Button
variant="primary"
aria-busy={pendingAction === "live"}
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
}
return renderWindow({busy:pending,
actions:confirmed?<Button variant="primary" style={{width:'100%'}} disabled={pending}
actions:confirmed?<Button variant="primary" width="full" disabled={pending}
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
content:<div className="sensor-content" ref={content}>
@@ -1,3 +1,6 @@
import {K1AcquisitionFields} from '../components/K1AcquisitionFields';
import {SUPPORTED_MOUNT_TYPE,SUPPORTED_GNSS_MODE,type MountType,type GnssMode} from '../configuration';
import {validateProjectName} from '../projectName';
import {K1SpatialSession, k1SpatialPhasePresentation} from '../components/K1SpatialSession';
import {deviceTelemetry} from '../spatialSessionModel';
import type {AcquisitionState} from '../spatialSessionModel';
@@ -9,11 +12,12 @@ import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {K1SceneWindows,type SceneTool} from './K1SceneWindows';
import {useK1SceneSettings} from './useK1SceneSettings';
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
import {pendingPreview} from './useK1Preview';
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost,reconnect}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory;reconnect?:()=>void}){
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
const [preview,setPreview]=useState(pendingPreview);
const [projectName,setProjectName]=useState(''),[projectNameTouched,setProjectNameTouched]=useState(false);
const [mountType,setMountType]=useState<MountType>(SUPPORTED_MOUNT_TYPE),[gnssMode,setGnssMode]=useState<GnssMode>(SUPPORTED_GNSS_MODE);
const project=validateProjectName(projectName);
const [sceneOpen,setSceneOpen]=useState(['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition));
const acquisitionId=device.control?.acquisition_id;
useEffect(()=>{if(acquisitionId&&['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition))setSceneOpen(true);},[acquisitionId]);
@@ -25,10 +29,10 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
async function act(action:'start'|'stop'|'verify'){
if(running.current||!enabled)return;
if(action==='start')setSceneOpen(true);
if(action==='start'){setProjectNameTouched(true);if(project.error)return;setSceneOpen(true);}
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});}
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null,...(action==='start'?{project_name:project.value,mount_type:mountType,gnss_mode:gnssMode}:{})});await refresh();}
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1. Проверьте питание сканера; если связь не восстановится, откройте подключение устройства и найдите K1 заново.');await refresh().catch(()=>{});}
finally{running.current=false;setBusy('');}
}
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
@@ -42,15 +46,19 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><Icon name="settings"/></IconButton>}>
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
{manual.connected&&!manual.showStop&&<K1AcquisitionFields mountType={mountType} setMountType={setMountType} gnssMode={gnssMode} setGnssMode={setGnssMode}
projectName={projectName} setProjectName={setProjectName} projectNameTouched={projectNameTouched} setProjectNameTouched={setProjectNameTouched} disabled={!!pending}/>}
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart||!!project.error} aria-busy={pending==='start'}
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
{!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
{!manual.connected&&<p role="status">{summary}</p>}
{!manual.connected&&reconnect&&!manual.active&&<Button disabled={!enabled||!!pending} onClick={reconnect}>Подключить устройство</Button>}
{manual.connected&&!confirmed&&operationError&&<p role="status">{operationError}</p>}
</SettingsCard>
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
{!sceneOpen&&manual.showStop&&<Button onClick={()=>setSceneOpen(true)}>Открыть пространственную сцену</Button>}
{sceneOpen&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled&&streaming} scene={scene} openTool={setTool} close={()=>setSceneOpen(false)} sessionControls={sessionControls}/>}
{sceneOpen&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} enabled={enabled&&streaming} scene={scene} openTool={setTool} close={()=>setSceneOpen(false)} sessionControls={sessionControls}/>}
</div>;
}
@@ -6,11 +6,10 @@ import type { Sensor, SensorTransport } from './runtime';
import type { K1SceneState, SceneTool } from './K1SceneWindows';
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
import './livePreview.css';
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool, close, sessionControls }: {
export function K1LiveView({ device, transport, createRerunHost, enabled, scene, openTool, close, sessionControls }: {
device: Sensor;
transport: SensorTransport;
createRerunHost: RerunHostFactory;
onStatus: (value: PreviewStatus) => void;
enabled: boolean;
scene: K1SceneState;
openTool: (tool: SceneTool) => void;
@@ -22,7 +21,7 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
const attempts = useRef(0);
const report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
const report = useCallback((value: PreviewStatus) => { setStatus(value); }, []);
useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
useEffect(() => {
if (!status.retry || !enabled)
@@ -74,8 +73,9 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={()=>{}} onClose={()=>{setCameraMaximized(false);setVisible(current=>{const next=new Set(current);next.delete('camera');return next;});}}
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
<video className="observation-media__asset" style={{visibility:status.cameraPresented?'visible':'hidden'}} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
<div className="k1-camera-content"><video className="observation-media__asset" style={{visibility:status.cameraPresented?'visible':'hidden'}} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
{!status.cameraPresented&&<div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
</div>
</FloatingMediaWindow>}
/>
</ApplicationPanel>;
@@ -112,7 +112,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.';
if(state.command_result?.action==='scan')return ''; // Scan failures belong to the search toast, not a Wi-Fi notice.
if(attempt?.status==='accepted'||attempt?.status==='running'){
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
return ''; // The pending action is already shown inside its button.
}
const code=attempt?.public_error_code??state.command_result?.error_code;
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.';
@@ -2,5 +2,5 @@
.k1-preview {min-width:0;width:100%}
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)}
.k1-camera-content {position:relative;height:100%;min-height:0}
.k1-camera-notice {position:absolute;inset:0}
.k1-preview-status {margin:0;text-align:center;color:var(--nodedc-text-muted);font-size:var(--nodedc-font-size-xs)}
+2 -2
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.10"
VERSION = "0.1.11"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -135,7 +135,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: mission-core-node (>= 0.8.10), mission-core-node (<< 0.9.0),
Depends: mission-core-node (>= 0.8.11), mission-core-node (<< 0.9.0),
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
Breaks: mission-core-node (<< 0.8.0)
Replaces: mission-core-node (<< 0.8.0)
@@ -5,7 +5,7 @@ import math
import re
import sys
from collections.abc import Callable
from contextlib import suppress
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, replace
from importlib.metadata import version
from threading import Lock
@@ -839,6 +839,35 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
return None
@asynccontextmanager
async def hold_bluez_device_for_connect(device: BLEDevice):
"""Keep discovery alive across the cache-check / D-Bus-connect gap.
BlueZ can discard an unconnected path after StopDiscovery. Holding one
scanner reference until BleakClient connects avoids consuming a path just
removed by that cleanup. This neither selects another device nor repeats
Connect/GATT writes, and is released before any characteristic is read.
"""
details = getattr(device, "details", None)
if not sys.platform.startswith("linux") or not isinstance(details, dict):
yield
return
path = details.get("path", "")
match = re.fullmatch(r"/org/bluez/(hci[0-9]+)/dev_[0-9A-F_]+", path)
if (match is None
or not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", device.address)
or path.rsplit("/", 1)[-1] != "dev_" + device.address.upper().replace(":", "_")):
raise BleakDeviceNotFoundError(device.address, "Invalid selected BlueZ transport")
runtime = ble_runtime_snapshot()
owner = ble_runtime_owner_epoch_for_current_loop()
if (owner is None or runtime["owner_epoch"] != owner
or runtime["poisoned"] or not runtime["owner_loop_bound"]
or runtime["active_operation_kind"] not in {"status-read", "wifi-provision"}):
raise BleakDeviceNotFoundError(device.address, "BLE operation owner changed")
async with BleakScanner(bluez={"adapter": match[1]}):
yield
async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None:
"""Restore a vanished BlueZ path before the one admitted GATT connection.
@@ -4,6 +4,7 @@ import asyncio
import ipaddress
import math
from collections.abc import Callable
from contextlib import AsyncExitStack
from importlib.metadata import version
from time import monotonic
from typing import Literal, TypedDict
@@ -24,6 +25,7 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
discover_known_device_capture_for_status_read,
discovered_device_selection,
ensure_device_for_gatt,
hold_bluez_device_for_connect,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read,
@@ -391,12 +393,16 @@ async def _read_wifi_status_impl(
"Exact BLE device is unavailable; run an explicit recovery or scan.",
)
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
progress.operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
async with AsyncExitStack() as gatt_session:
async with hold_bluez_device_for_connect(device):
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
progress.operation_stage = "connect"
client = await gatt_session.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
progress.operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(
@@ -619,13 +625,17 @@ async def _provision_wifi_impl(
"Device was not rediscovered; keep the K1 powered and nearby.",
)
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
operation_stage = "connect"
progress.operation_stage = operation_stage
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
async with AsyncExitStack() as gatt_session:
async with hold_bluez_device_for_connect(device):
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
operation_stage = "connect"
progress.operation_stage = operation_stage
client = await gatt_session.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
@@ -10,6 +10,7 @@ from .facade import (
XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION,
ViewerSettingsRequest,
normalize_project_name,
)
from .node_bridge import ATTESTATION, plugin_operation_id
@@ -106,6 +107,33 @@ def project_sensor(snapshot, node_id):
}
def verification_parameters(state, operation_id):
"""Use the admitted persisted Bridge target before asking for BLE again.
The facade still checks its ledger, exact identity, route, DeviceInfo and
physical reconciliation fences. A saved address alone never grants START.
"""
parameters = {
"expected_snapshot_runtime_id": state["snapshot_runtime_id"],
"operation_id": operation_id,
}
policy = (state.get("connection_policy") or {}).get("actions", {})
decision = policy.get("observe-configured-device-network") or {}
target = decision.get("required_transport_ref")
selected = state.get("selected_device_id")
if (decision.get("allowed") is True
and decision.get("requires_live_gatt_validation") is False
and decision.get("required_connection_mode") == "bridge"
and isinstance(target, str) and isinstance(selected, str)
and target.casefold() == selected.casefold()):
parameters.update(
device_id=target, source="durable-configured-state",
compatibility_attestation=ATTESTATION,
expected_mode_revision=state.get("desired_connection_mode_revision"),
)
return parameters
class NodeK1Sensor:
def __init__(self, bridge, peers):
self.bridge, self.peers = bridge, peers
@@ -194,7 +222,7 @@ class NodeK1Sensor:
if action == "verify":
result = await self.bridge.invoke(
"connection.verify",
{"expected_snapshot_runtime_id": runtime, "operation_id": plugin_identifier},
verification_parameters(state, plugin_identifier),
identifier,
)
return project_sensor(result, node_id)
@@ -224,6 +252,12 @@ class NodeK1Sensor:
)
await self.peers.close_all()
return project_sensor(result, node_id)
# Validate the complete operator draft before workspace/project commands.
if not isinstance(params.get("project_name"), str):
raise ValueError("Введите название проекта")
project_name = normalize_project_name(params["project_name"])
if params.get("mount_type") != "handheld" or params.get("gnss_mode") != "none":
raise ValueError("Unsupported acquisition configuration")
deadline = min(
datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165
)
@@ -253,10 +287,13 @@ class NodeK1Sensor:
self.cas(state),
operation_id=prepare_identifier,
idempotency_key=prepare_identifier,
project_name="node-" + identifier[3:15],
project_name=project_name,
mount_type=params["mount_type"], gnss_mode=params["gnss_mode"],
compatibility_attestation=ATTESTATION,
)
elif phase == "project-ready" and acquisition.get("state") == "prepared":
if acquisition.get("project_name") != project_name:
raise ValueError("Prepared project changed")
next_action = "acquisition.start"
payload.update(
self.cas(state),
+35 -6
View File
@@ -28,8 +28,10 @@ def isolated_owner(tmp_path):
@pytest.mark.parametrize("operation", ["read", "provision"])
@pytest.mark.parametrize("native", [
"macos", "present", "vanished", "absent", "wrong-address", "wrong-adapter",
"vanished-again", "owner-changed", "invalidated", "connect-failed", "write-failed",
"macos", "present", "cache-cleanup-race", "vanished", "absent",
"wrong-address", "wrong-adapter",
"vanished-again", "owner-changed", "invalidated", "connect-failed",
"connect-cancelled", "write-failed",
])
def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, native):
address = "AA:BB:CC:DD:EE:FF"
@@ -42,7 +44,7 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
async def retrieve(selected_address, details):
assert selected_address == address and details["path"] == path
events.append("cache")
present = native == "present" or (
present = native in {"present", "cache-cleanup-race"} or (
events.count("cache") == 2 and native != "vanished-again"
)
return device if present else None
@@ -90,6 +92,12 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
async def __aenter__(self):
events.append("connect")
if native == "cache-cleanup-race":
await asyncio.sleep(0) # The D-Bus connection yields to BlueZ cleanup.
if events.count("hold-discovery") <= events.count("release-discovery"):
raise BleakError("selected device not found before watcher registration")
if native == "connect-cancelled":
raise asyncio.CancelledError()
if native == "connect-failed":
raise BleakError("synthetic native connection failure")
return self
@@ -99,6 +107,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
async def read_gatt_char(self, characteristic):
assert characteristic is status
if native != "macos":
assert "release-discovery" in events
events.append("read")
value = bytearray(52)
value[0] = 11
@@ -118,7 +128,20 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
platform="darwin" if native == "macos" else "linux",
))
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve)
monkeypatch.setattr(scanner.BleakScanner, "find_device_by_address", find)
class Scanner:
find_device_by_address = staticmethod(find)
def __init__(self, *, bluez):
assert bluez == {"adapter": "hci7"}
async def __aenter__(self):
events.append("hold-discovery")
return self
async def __aexit__(self, *_args):
events.append("release-discovery")
monkeypatch.setattr(scanner, "BleakScanner", Scanner)
monkeypatch.setattr(wifi, "BleakClient", Client)
async def scenario():
@@ -142,7 +165,11 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
"absent", "wrong-address", "wrong-adapter", "vanished-again",
"owner-changed", "invalidated",
}
if fails_before_connect or native == "connect-failed" or (
if native == "connect-cancelled":
with pytest.raises(asyncio.CancelledError):
await action
assert "read" not in events and "write" not in events
elif fails_before_connect or native == "connect-failed" or (
native == "write-failed" and operation == "provision"
):
with pytest.raises(BleakError) as raised:
@@ -165,6 +192,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
assert scanner._runtime_handle_generation == generation # noqa: SLF001
asyncio.run(scenario())
assert events.count("scan") == (native not in {"macos", "present"})
assert events.count("scan") == (native not in {"macos", "present", "cache-cleanup-race"})
assert events.count("hold-discovery") == events.count("release-discovery")
assert events.count("hold-discovery") == (native != "macos")
assert events.count("connect") <= 1
assert events.count("write") <= 1
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode()
assert "Depends: mission-core-node (>= 0.8.10)" in control
assert "Depends: mission-core-node (>= 0.8.11)" in control
assert "Replaces: mission-core-node (<< 0.8.0)" in control
+55 -1
View File
@@ -10,7 +10,11 @@ from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
from k1link.device_plugins.xgrids_k1.linux_host import nm_fields, route_fields
from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge, plugin_operation_id
from k1link.device_plugins.xgrids_k1.node_sensor import NodeK1Sensor, project_sensor
from k1link.device_plugins.xgrids_k1.node_sensor import (
NodeK1Sensor,
project_sensor,
verification_parameters,
)
from k1link.viewer.node_rerun import NodeRerunHub
@@ -53,6 +57,7 @@ class Facade:
elif request.action_id == "acquisition.prepare":
self.current["acquisition"] = {
"acquisition_id": "acquisition-test",
"project_name": request.parameters["project_name"],
"state": "prepared",
"state_revision": 1,
}
@@ -418,6 +423,8 @@ def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"parameters": {
"operator_confirmed": True,
"project_name": " Synthetic survey ",
"mount_type": "handheld", "gnss_mode": "none",
"control_generation": 1,
"acquisition_id": None,
},
@@ -433,6 +440,9 @@ def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
journal = OperationJournal()
for action, payload in device.facade.actions:
if action == "acquisition.prepare":
assert payload["project_name"] == "Synthetic survey"
assert payload["mount_type"] == "handheld" and payload["gnss_mode"] == "none"
if action in {"acquisition.prepare", "acquisition.start"}:
row, created = journal.begin(
action, operation_id=payload["operation_id"],
@@ -594,3 +604,47 @@ def test_preview_offer_cannot_cross_acquisition_boundary(replacement):
assert all(action == "state.read" for action, _ in device.facade.actions)
asyncio.run(run())
@pytest.mark.parametrize("draft", [
{}, {"project_name": " "}, {"project_name": "x" * 97},
{"project_name": "invalid\nname"}, {"project_name": "Valid", "mount_type": "uav"},
{"project_name": "Valid", "mount_type": "handheld", "gnss_mode": "rtk"},
])
def test_invalid_start_draft_dispatches_no_workspace_or_project_command(draft):
async def run():
device = bridge()
item = project_sensor(state(), "node-test")
command = {
"operation_id": "op_" + "b" * 32, "action_id": "start",
"session": {"device_id": item["id"], "session_id": "session-test"},
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"parameters": {"operator_confirmed": True, "control_generation": 1,
"acquisition_id": None, **draft},
}
with pytest.raises(ValueError):
await NodeK1Sensor(device, None).execute(command, "node-test")
assert all(action == "state.read" for action, _ in device.facade.actions)
asyncio.run(run())
def test_recheck_uses_only_exact_admitted_durable_bridge_target():
current = state()
decision = {
"allowed": True, "requires_live_gatt_validation": False,
"required_transport_ref": current["selected_device_id"],
"required_connection_mode": "bridge",
}
current["connection_policy"] = {"actions": {"observe-configured-device-network": decision}}
result = verification_parameters(current, "synthetic-operation")
assert result["source"] == "durable-configured-state"
assert result["device_id"] == current["selected_device_id"]
assert result["expected_mode_revision"] == current["desired_connection_mode_revision"]
for patch in [{"allowed": False}, {"requires_live_gatt_validation": True},
{"required_connection_mode": "quick-connect"},
{"required_transport_ref": "other"}]:
decision.update(patch)
assert "source" not in verification_parameters(current, "synthetic-operation")
decision.update(allowed=True, requires_live_gatt_validation=False,
required_transport_ref=current["selected_device_id"],
required_connection_mode="bridge")