Fix onboard K1 enrollment continuity and share named acquisition preparation
This commit is contained in:
@@ -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,
|
||||
}}),'');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user