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( assert.ok(
acquisitionSource.indexOf("terminalPhysicalStopObserved ? (") 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", "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',()=>{ test('manual K1 surface admits one START only after current control proof',()=>{
const markup=render(device); const markup=render(device);
assert.match(markup,/Инициировать запуск/); assert.match(markup,/Инициировать запуск/);
assert.match(markup,/<button[^>]*disabled=""[^>]*>Инициировать запуск<\/button>/);
for(const label of ['Тип установки / носитель','Режим GNSS','Название проекта'])assert.ok(markup.includes(label));
assert.match(markup,/Настройки устройства/); assert.match(markup,/Настройки устройства/);
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/); 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'}}; 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); const markup=render(active);
assert.match(markup,/Остановить устройство/); assert.match(markup,/Остановить устройство/);
assert.match(markup,/rerun-viewport__canvas/); assert.match(markup,/rerun-viewport__canvas/);
assert.match(markup,/>Статус</); assert.doesNotMatch(markup,/>Статус</);
assert.match(markup,/>Пространственная сцена</); assert.match(markup,/>Пространственная сцена</);
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label)); for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
assert.ok(markup.includes('data-presented="false"')); 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.match(notice,/Настройки Wi-Fi не были отправлены/);
assert.doesNotMatch(notice,/пароль|Ethernet/); 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] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.10" VERSION = "0.8.11"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package 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); const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]); useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null; 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> <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=>{ {!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 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 Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>( const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose} <Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
footer={actions?<WindowFooterActions>{actions}</WindowFooterActions>:undefined}> footer={actions?<WindowFooterActions style={{width:'100%'}}>{actions}</WindowFooterActions>:undefined}>
<div className="sensor-content"> <div className="sensor-content">
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy} <Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true}, options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
+1 -1
View File
@@ -7,7 +7,7 @@ import type {RerunHostFactory} from './rerunHost';
export interface SensorDetailProps { export interface SensorDetailProps {
device:Sensor; transport:SensorTransport; enabled:boolean; device:Sensor; transport:SensorTransport; enabled:boolean;
back:()=>void; refresh:()=>Promise<void>; failure:(error:unknown)=>void; back:()=>void; refresh:()=>Promise<void>; failure:(error:unknown)=>void;
createRerunHost?:RerunHostFactory; createRerunHost?:RerunHostFactory; reconnect?:()=>void;
} }
export interface SensorEnrollmentProps { export interface SensorEnrollmentProps {
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void; 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 { useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -5,7 +6,6 @@ import {
Checker, Checker,
GlassSurface, GlassSurface,
Icon, Icon,
Select,
SegmentedControl, SegmentedControl,
StatusBadge, StatusBadge,
TextField, TextField,
@@ -21,8 +21,6 @@ import {
import { import {
SUPPORTED_GNSS_MODE, SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE, SUPPORTED_MOUNT_TYPE,
gnssModeOptions,
mountTypeOptions,
type GnssMode, type GnssMode,
type MountType, type MountType,
} from "../configuration"; } from "../configuration";
@@ -41,7 +39,6 @@ import {
} from "../lifecycle"; } from "../lifecycle";
import { connectionPolicyOperatorGuidance } from "../presentation"; import { connectionPolicyOperatorGuidance } from "../presentation";
import { import {
normalizeProjectName,
projectNameAfterConnectionModeSelection, projectNameAfterConnectionModeSelection,
shouldHydratePreparedProject, shouldHydratePreparedProject,
validateProjectName, validateProjectName,
@@ -443,46 +440,9 @@ export function K1AcquisitionPipeline({
</div> </div>
) : effectiveSessionIntent === "live" ? ( ) : effectiveSessionIntent === "live" ? (
<div className="session-form"> <div className="session-form">
<div className="scan-configuration-grid"> <K1AcquisitionFields mountType={mountType} setMountType={setMountType} gnssMode={gnssMode} setGnssMode={setGnssMode}
<div className="configuration-field"> projectName={projectName} setProjectName={setProjectName} projectNameTouched={projectNameTouched} setProjectNameTouched={setProjectNameTouched}
<span className="nodedc-field__description">Тип установки / носитель</span> disabled={isBusy || sessionLocked} projectDisabled={isBusy || preparedAcquisition !== null || sourceRuntimeBusy}/>
<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"
/>
<Button <Button
variant="primary" variant="primary"
aria-busy={pendingAction === "live"} aria-busy={pendingAction === "live"}
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
} }
return renderWindow({busy:pending, 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} aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void configure()}>Настроить устройство</Button>:undefined, onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
content:<div className="sensor-content" ref={content}> 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 {K1SpatialSession, k1SpatialPhasePresentation} from '../components/K1SpatialSession';
import {deviceTelemetry} from '../spatialSessionModel'; import {deviceTelemetry} from '../spatialSessionModel';
import type {AcquisitionState} 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 {K1SceneWindows,type SceneTool} from './K1SceneWindows';
import {useK1SceneSettings} from './useK1SceneSettings'; import {useK1SceneSettings} from './useK1SceneSettings';
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation'; 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 [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 [sceneOpen,setSceneOpen]=useState(['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition));
const acquisitionId=device.control?.acquisition_id; const acquisitionId=device.control?.acquisition_id;
useEffect(()=>{if(acquisitionId&&['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition))setSceneOpen(true);},[acquisitionId]); 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':''); const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
async function act(action:'start'|'stop'|'verify'){ async function act(action:'start'|'stop'|'verify'){
if(running.current||!enabled)return; 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); 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();} 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.');await refresh().catch(()=>{});} catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1. Проверьте питание сканера; если связь не восстановится, откройте подключение устройства и найдите K1 заново.');await refresh().catch(()=>{});}
finally{running.current=false;setBusy('');} finally{running.current=false;setBusy('');}
} }
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected); 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> <div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК." <SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><Icon name="settings"/></IconButton>}> 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>} 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'} {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>} 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&&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>
<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}/>} {manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
{!sceneOpen&&manual.showStop&&<Button onClick={()=>setSceneOpen(true)}>Открыть пространственную сцену</Button>} {!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>; </div>;
} }
@@ -6,11 +6,10 @@ import type { Sensor, SensorTransport } from './runtime';
import type { K1SceneState, SceneTool } from './K1SceneWindows'; import type { K1SceneState, SceneTool } from './K1SceneWindows';
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview'; import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
import './livePreview.css'; 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; device: Sensor;
transport: SensorTransport; transport: SensorTransport;
createRerunHost: RerunHostFactory; createRerunHost: RerunHostFactory;
onStatus: (value: PreviewStatus) => void;
enabled: boolean; enabled: boolean;
scene: K1SceneState; scene: K1SceneState;
openTool: (tool: SceneTool) => void; 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 [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview); const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
const attempts = useRef(0); 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); useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
useEffect(() => { useEffect(() => {
if (!status.retry || !enabled) 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;});}} 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>} status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></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>} {!status.cameraPresented&&<div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
</div>
</FloatingMediaWindow>} </FloatingMediaWindow>}
/> />
</ApplicationPanel>; </ApplicationPanel>;
@@ -112,7 +112,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.'; 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(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'){ 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; const code=attempt?.public_error_code??state.command_result?.error_code;
if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.'; 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 {min-width:0;width:100%}
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)} .k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)} .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-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 debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.10" VERSION = "0.1.11"
RESOURCES = ( RESOURCES = (
"plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json", "plugins/xgrids-k1/plugin.manifest.json",
@@ -135,7 +135,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid> Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin Section: admin
Priority: optional 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 systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
Breaks: mission-core-node (<< 0.8.0) Breaks: mission-core-node (<< 0.8.0)
Replaces: mission-core-node (<< 0.8.0) Replaces: mission-core-node (<< 0.8.0)
@@ -5,7 +5,7 @@ import math
import re import re
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from importlib.metadata import version from importlib.metadata import version
from threading import Lock from threading import Lock
@@ -839,6 +839,35 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
return None 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: async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None:
"""Restore a vanished BlueZ path before the one admitted GATT connection. """Restore a vanished BlueZ path before the one admitted GATT connection.
@@ -4,6 +4,7 @@ import asyncio
import ipaddress import ipaddress
import math import math
from collections.abc import Callable from collections.abc import Callable
from contextlib import AsyncExitStack
from importlib.metadata import version from importlib.metadata import version
from time import monotonic from time import monotonic
from typing import Literal, TypedDict 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, discover_known_device_capture_for_status_read,
discovered_device_selection, discovered_device_selection,
ensure_device_for_gatt, ensure_device_for_gatt,
hold_bluez_device_for_connect,
mark_captured_device_gatt_validated, mark_captured_device_gatt_validated,
retrieve_connected_device_capture, retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read, 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.", "Exact BLE device is unavailable; run an explicit recovery or scan.",
) )
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) async with AsyncExitStack() as gatt_session:
if (active_captured_device is not None async with hold_bluez_device_for_connect(device):
and captured_device_handle(active_captured_device) is not device): await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") if (active_captured_device is not None
progress.operation_stage = "connect" and captured_device_handle(active_captured_device) is not device):
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: 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" progress.operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID) service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic( 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.", "Device was not rediscovered; keep the K1 powered and nearby.",
) )
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) async with AsyncExitStack() as gatt_session:
if (active_captured_device is not None async with hold_bluez_device_for_connect(device):
and captured_device_handle(active_captured_device) is not device): await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") if (active_captured_device is not None
operation_stage = "connect" and captured_device_handle(active_captured_device) is not device):
progress.operation_stage = operation_stage raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: 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 device_name = client.name
operation_stage = "gatt-contract" operation_stage = "gatt-contract"
progress.operation_stage = operation_stage progress.operation_stage = operation_stage
@@ -10,6 +10,7 @@ from .facade import (
XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION, XGRIDS_K1_PLUGIN_VERSION,
ViewerSettingsRequest, ViewerSettingsRequest,
normalize_project_name,
) )
from .node_bridge import ATTESTATION, plugin_operation_id 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: class NodeK1Sensor:
def __init__(self, bridge, peers): def __init__(self, bridge, peers):
self.bridge, self.peers = bridge, peers self.bridge, self.peers = bridge, peers
@@ -194,7 +222,7 @@ class NodeK1Sensor:
if action == "verify": if action == "verify":
result = await self.bridge.invoke( result = await self.bridge.invoke(
"connection.verify", "connection.verify",
{"expected_snapshot_runtime_id": runtime, "operation_id": plugin_identifier}, verification_parameters(state, plugin_identifier),
identifier, identifier,
) )
return project_sensor(result, node_id) return project_sensor(result, node_id)
@@ -224,6 +252,12 @@ class NodeK1Sensor:
) )
await self.peers.close_all() await self.peers.close_all()
return project_sensor(result, node_id) 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( deadline = min(
datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165 datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165
) )
@@ -253,10 +287,13 @@ class NodeK1Sensor:
self.cas(state), self.cas(state),
operation_id=prepare_identifier, operation_id=prepare_identifier,
idempotency_key=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, compatibility_attestation=ATTESTATION,
) )
elif phase == "project-ready" and acquisition.get("state") == "prepared": 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" next_action = "acquisition.start"
payload.update( payload.update(
self.cas(state), 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("operation", ["read", "provision"])
@pytest.mark.parametrize("native", [ @pytest.mark.parametrize("native", [
"macos", "present", "vanished", "absent", "wrong-address", "wrong-adapter", "macos", "present", "cache-cleanup-race", "vanished", "absent",
"vanished-again", "owner-changed", "invalidated", "connect-failed", "write-failed", "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): def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, native):
address = "AA:BB:CC:DD:EE:FF" 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): async def retrieve(selected_address, details):
assert selected_address == address and details["path"] == path assert selected_address == address and details["path"] == path
events.append("cache") events.append("cache")
present = native == "present" or ( present = native in {"present", "cache-cleanup-race"} or (
events.count("cache") == 2 and native != "vanished-again" events.count("cache") == 2 and native != "vanished-again"
) )
return device if present else None 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): async def __aenter__(self):
events.append("connect") 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": if native == "connect-failed":
raise BleakError("synthetic native connection failure") raise BleakError("synthetic native connection failure")
return self return self
@@ -99,6 +107,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
async def read_gatt_char(self, characteristic): async def read_gatt_char(self, characteristic):
assert characteristic is status assert characteristic is status
if native != "macos":
assert "release-discovery" in events
events.append("read") events.append("read")
value = bytearray(52) value = bytearray(52)
value[0] = 11 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", platform="darwin" if native == "macos" else "linux",
)) ))
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve) 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) monkeypatch.setattr(wifi, "BleakClient", Client)
async def scenario(): 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", "absent", "wrong-address", "wrong-adapter", "vanished-again",
"owner-changed", "invalidated", "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" native == "write-failed" and operation == "provision"
): ):
with pytest.raises(BleakError) as raised: 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 assert scanner._runtime_handle_generation == generation # noqa: SLF001
asyncio.run(scenario()) 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("connect") <= 1
assert events.count("write") <= 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 position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive: with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode() 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 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.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_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 from k1link.viewer.node_rerun import NodeRerunHub
@@ -53,6 +57,7 @@ class Facade:
elif request.action_id == "acquisition.prepare": elif request.action_id == "acquisition.prepare":
self.current["acquisition"] = { self.current["acquisition"] = {
"acquisition_id": "acquisition-test", "acquisition_id": "acquisition-test",
"project_name": request.parameters["project_name"],
"state": "prepared", "state": "prepared",
"state_revision": 1, "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(), "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"parameters": { "parameters": {
"operator_confirmed": True, "operator_confirmed": True,
"project_name": " Synthetic survey ",
"mount_type": "handheld", "gnss_mode": "none",
"control_generation": 1, "control_generation": 1,
"acquisition_id": None, "acquisition_id": None,
}, },
@@ -433,6 +440,9 @@ def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
journal = OperationJournal() journal = OperationJournal()
for action, payload in device.facade.actions: 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"}: if action in {"acquisition.prepare", "acquisition.start"}:
row, created = journal.begin( row, created = journal.begin(
action, operation_id=payload["operation_id"], 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) assert all(action == "state.read" for action, _ in device.facade.actions)
asyncio.run(run()) 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")