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