diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index b1070cd..c01490e 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -18,6 +18,7 @@ let automaticSourceStart; let presentation; let operatorIntentGeneration; let configuration; +let compatibility; before(async () => { server = await createServer({ @@ -52,6 +53,9 @@ before(async () => { configuration = await server.ssrLoadModule( "@xgrids-k1/frontend/configuration.ts", ); + compatibility = await server.ssrLoadModule( + "@xgrids-k1/frontend/compatibility.ts", + ); ({ xgridsK1Api, ApiError } = await server.ssrLoadModule( "@xgrids-k1/frontend/api.ts", )); @@ -204,8 +208,8 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2"); assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [ { - profileId: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", - path: "profiles/fw-3.0.2/direct-lan.v1.json", + profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2", + path: "profiles/fw-3.0.2/local-network.v2.json", modelId: "xgrids.lixelkity-k1", }, ]); @@ -563,16 +567,16 @@ test("replay ignores a stale failed live acquisition", () => { ); }); -test("K1 configuration anchors expose future modes without enabling them", () => { - assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge"); +test("K1 configuration exposes all reviewed local connection directions", () => { + assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge"); assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld"); assert.equal(configuration.SUPPORTED_GNSS_MODE, "none"); assert.deepEqual( configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })), [ { value: "bridge", disabled: false }, - { value: "quick-connect", disabled: true }, - { value: "direct-connect", disabled: true }, + { value: "quick-connect", disabled: false }, + { value: "direct-connect", disabled: false }, ], ); assert.deepEqual( @@ -594,6 +598,22 @@ test("K1 configuration anchors expose future modes without enabling them", () => ); }); +test("connection directions select distinct fail-closed topologies", () => { + assert.deepEqual(compatibility.profileSelectionForConnectionMode("bridge"), { + firmware_version: "3.0.2", + topology: "direct-lan", + verification: "live-device-info", + }); + assert.equal( + compatibility.profileSelectionForConnectionMode("quick-connect").topology, + "device-ap", + ); + assert.equal( + compatibility.profileSelectionForConnectionMode("direct-connect").topology, + "controller-hotspot", + ); +}); + test("a provisioned address is not presented as a verified device connection", () => { assert.equal(lifecycle.normalizeRuntimePhase({ phase: "connected", diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs index d4a6db8..95db8db 100644 --- a/apps/control-station/test/observationSources.test.mjs +++ b/apps/control-station/test/observationSources.test.mjs @@ -401,7 +401,7 @@ function declaredState(cameraRows = [ foxglove_ws_url: "ws://192.168.7.10:8765", foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer", compatibility: { - profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2", camera_preview: "rtsp://192.168.7.10:8554/vendor-preview", }, device_ref: { @@ -413,7 +413,7 @@ function declaredState(cameraRows = [ device_session: { device_session_id: "device-session-001", device_id: "device-k1-001", - compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2", connectivity: "connected", }, sensor_catalog: { @@ -444,7 +444,7 @@ function pointCloudStreamingState() { acquisition_id: "acquisition-001", device_id: "device-k1-001", device_session_id: "device-session-001", - compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2", control_mode: "operator-manual", requested_streams: ["spatial.point-cloud.live"], target_host: "127.0.0.1", diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 1930a89..eb731f7 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -65,6 +65,7 @@ export interface XgridsCompatibilityState { firmware_claim?: string | null; vendor_writes_enabled?: boolean; camera_preview?: string | null; + attestation?: CompatibilityAttestation | null; } export interface XgridsModelingControlSafety { @@ -289,7 +290,7 @@ export interface XgridsK1State { devices?: BleDevice[]; selected_device_id?: string | null; k1_ip?: string | null; - connection_mode?: "bridge" | null; + connection_mode?: "bridge" | "quick-connect" | "direct-connect" | null; foxglove_ws_url?: string | null; foxglove_viewer_url?: string | null; rerun_grpc_url?: string | null; @@ -322,7 +323,7 @@ export interface ScanRequest { export interface CompatibilityAttestation { firmware_version: "3.0.2"; - topology: "direct-lan"; + topology: "direct-lan" | "device-ap" | "controller-hotspot"; verification: "live-device-info"; } @@ -330,7 +331,7 @@ export interface ConnectRequest { device_id: string; ssid: string; password: string; - connection_mode: "bridge"; + connection_mode: "bridge" | "quick-connect" | "direct-connect"; compatibility_attestation: CompatibilityAttestation; operation_id?: string; idempotency_key?: string; diff --git a/plugins/xgrids-k1/frontend/src/compatibility.ts b/plugins/xgrids-k1/frontend/src/compatibility.ts index e7ccda0..b4feb15 100644 --- a/plugins/xgrids-k1/frontend/src/compatibility.ts +++ b/plugins/xgrids-k1/frontend/src/compatibility.ts @@ -1,7 +1,24 @@ import type { CompatibilityAttestation } from "./api"; +import type { ConnectionMode } from "./configuration"; export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({ firmware_version: "3.0.2", topology: "direct-lan", verification: "live-device-info", }); + +const topologyByConnectionMode = { + bridge: "direct-lan", + "quick-connect": "device-ap", + "direct-connect": "controller-hotspot", +} as const satisfies Record; + +export function profileSelectionForConnectionMode( + connectionMode: ConnectionMode, +): CompatibilityAttestation { + return { + firmware_version: "3.0.2", + topology: topologyByConnectionMode[connectionMode], + verification: "live-device-info", + }; +} diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx index 2c1bd4f..b08aacd 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -11,7 +11,7 @@ import { type StatusTone, } from "@nodedc/ui-react"; -import { EXACT_PROFILE_SELECTION } from "../compatibility"; +import { profileSelectionForConnectionMode } from "../compatibility"; import { SUPPORTED_GNSS_MODE, SUPPORTED_MOUNT_TYPE, @@ -149,7 +149,9 @@ export function K1AcquisitionPipeline({ project_name: projectNameValidation.value, mount_type: SUPPORTED_MOUNT_TYPE, gnss_mode: SUPPORTED_GNSS_MODE, - compatibility_attestation: EXACT_PROFILE_SELECTION, + compatibility_attestation: profileSelectionForConnectionMode( + state.connection_mode ?? "bridge", + ), }, physicalAcceptance: PHYSICAL_ACCEPTANCE, }), diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index e8145c1..641778f 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -11,9 +11,9 @@ import { } from "@nodedc/ui-react"; import type { BleDevice } from "../api"; -import { EXACT_PROFILE_SELECTION } from "../compatibility"; +import { profileSelectionForConnectionMode } from "../compatibility"; import { - SUPPORTED_CONNECTION_MODE, + DEFAULT_CONNECTION_MODE, connectionModeOptions, type ConnectionMode, } from "../configuration"; @@ -21,6 +21,36 @@ import { provisioningIntentKey } from "../lifecycle"; import { finiteMetric } from "../presentation"; import type { XgridsK1Controller } from "../runtimeContext"; +const connectionCopy: Record = { + bridge: { + stepTitle: "Передайте настройки общей сети", + ssidLabel: "Название общей сети Wi‑Fi", + ssidPlaceholder: "Сеть локального контура", + buttonLabel: "Подключить K1 к общей сети", + safetyNote: "K1 получит реквизиты существующей сети одним рассмотренным BLE-запросом без автоматического повтора.", + }, + "quick-connect": { + stepTitle: "Подключитесь к точке доступа K1", + ssidLabel: "Название точки доступа K1", + ssidPlaceholder: "SSID сканера, например XGR-…", + buttonLabel: "Подключить этот Mac к K1", + safetyNote: "Введите SSID и пароль точки доступа вашего K1. Mac сменит текущую Wi‑Fi сеть одним CoreWLAN-запросом; недокументированный BLE-секрет не читается и K1 не получает BLE-запись.", + }, + "direct-connect": { + stepTitle: "Подключите K1 к хотспоту контроллера", + ssidLabel: "Название хотспота контроллера", + ssidPlaceholder: "SSID управляющего устройства", + buttonLabel: "Подключить K1 к хотспоту", + safetyNote: "Хотспот должен быть уже включён, а этот Mac — иметь к нему маршрут. K1 получит его реквизиты одним рассмотренным BLE-запросом.", + }, +}; + function WizardStep({ number, title, @@ -96,13 +126,17 @@ export function K1ProvisioningPipeline({ const [ssid, setSsid] = useState(""); const [password, setPassword] = useState(""); const [connectionMode, setConnectionMode] = useState( - SUPPORTED_CONNECTION_MODE, + DEFAULT_CONNECTION_MODE, ); const provisioningIntentRef = useRef(null); const devices = state?.devices ?? []; const isBusy = pendingAction !== null; const credentialsReady = ssid.trim().length > 0 && password.length > 0; const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy; + const modeCopy = connectionCopy[connectionMode]; + const selectedModeConnected = Boolean( + state?.k1_ip && state.connection_mode === connectionMode, + ); useEffect(() => { if (state?.selected_device_id) { @@ -117,6 +151,12 @@ export function K1ProvisioningPipeline({ } }, [selectedDeviceId, state?.devices, state?.selected_device_id]); + useEffect(() => { + if (state?.connection_mode) { + setConnectionMode(state.connection_mode); + } + }, [state?.connection_mode]); + const deviceSummary = useMemo( () => devices.find((device) => device.device_id === selectedDeviceId), [devices, selectedDeviceId], @@ -134,8 +174,8 @@ export function K1ProvisioningPipeline({ device_id: selectedDeviceId, ssid: ssid.trim(), password, - connection_mode: SUPPORTED_CONNECTION_MODE, - compatibility_attestation: EXACT_PROFILE_SELECTION, + connection_mode: connectionMode, + compatibility_attestation: profileSelectionForConnectionMode(connectionMode), idempotency_key: idempotencyKey, }); if (succeeded) { @@ -152,7 +192,7 @@ export function K1ProvisioningPipeline({
- Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки. + Выберите направление связи. Каждый путь выполняет не более одного сетевого изменения и не повторяет его автоматически.