feat(k1): add local connection matrix
This commit is contained in:
parent
fecb5885d0
commit
57b2208cae
|
|
@ -18,6 +18,7 @@ let automaticSourceStart;
|
||||||
let presentation;
|
let presentation;
|
||||||
let operatorIntentGeneration;
|
let operatorIntentGeneration;
|
||||||
let configuration;
|
let configuration;
|
||||||
|
let compatibility;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -52,6 +53,9 @@ before(async () => {
|
||||||
configuration = await server.ssrLoadModule(
|
configuration = await server.ssrLoadModule(
|
||||||
"@xgrids-k1/frontend/configuration.ts",
|
"@xgrids-k1/frontend/configuration.ts",
|
||||||
);
|
);
|
||||||
|
compatibility = await server.ssrLoadModule(
|
||||||
|
"@xgrids-k1/frontend/compatibility.ts",
|
||||||
|
);
|
||||||
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
|
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
|
||||||
"@xgrids-k1/frontend/api.ts",
|
"@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.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
|
||||||
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
|
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
|
||||||
{
|
{
|
||||||
profileId: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||||
path: "profiles/fw-3.0.2/direct-lan.v1.json",
|
path: "profiles/fw-3.0.2/local-network.v2.json",
|
||||||
modelId: "xgrids.lixelkity-k1",
|
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", () => {
|
test("K1 configuration exposes all reviewed local connection directions", () => {
|
||||||
assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge");
|
assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge");
|
||||||
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
|
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
|
||||||
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
|
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||||
[
|
[
|
||||||
{ value: "bridge", disabled: false },
|
{ value: "bridge", disabled: false },
|
||||||
{ value: "quick-connect", disabled: true },
|
{ value: "quick-connect", disabled: false },
|
||||||
{ value: "direct-connect", disabled: true },
|
{ value: "direct-connect", disabled: false },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
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", () => {
|
test("a provisioned address is not presented as a verified device connection", () => {
|
||||||
assert.equal(lifecycle.normalizeRuntimePhase({
|
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||||
phase: "connected",
|
phase: "connected",
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,7 @@ function declaredState(cameraRows = [
|
||||||
foxglove_ws_url: "ws://192.168.7.10:8765",
|
foxglove_ws_url: "ws://192.168.7.10:8765",
|
||||||
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
|
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
|
||||||
compatibility: {
|
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",
|
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
|
||||||
},
|
},
|
||||||
device_ref: {
|
device_ref: {
|
||||||
|
|
@ -413,7 +413,7 @@ function declaredState(cameraRows = [
|
||||||
device_session: {
|
device_session: {
|
||||||
device_session_id: "device-session-001",
|
device_session_id: "device-session-001",
|
||||||
device_id: "device-k1-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",
|
connectivity: "connected",
|
||||||
},
|
},
|
||||||
sensor_catalog: {
|
sensor_catalog: {
|
||||||
|
|
@ -444,7 +444,7 @@ function pointCloudStreamingState() {
|
||||||
acquisition_id: "acquisition-001",
|
acquisition_id: "acquisition-001",
|
||||||
device_id: "device-k1-001",
|
device_id: "device-k1-001",
|
||||||
device_session_id: "device-session-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",
|
control_mode: "operator-manual",
|
||||||
requested_streams: ["spatial.point-cloud.live"],
|
requested_streams: ["spatial.point-cloud.live"],
|
||||||
target_host: "127.0.0.1",
|
target_host: "127.0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export interface XgridsCompatibilityState {
|
||||||
firmware_claim?: string | null;
|
firmware_claim?: string | null;
|
||||||
vendor_writes_enabled?: boolean;
|
vendor_writes_enabled?: boolean;
|
||||||
camera_preview?: string | null;
|
camera_preview?: string | null;
|
||||||
|
attestation?: CompatibilityAttestation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XgridsModelingControlSafety {
|
export interface XgridsModelingControlSafety {
|
||||||
|
|
@ -289,7 +290,7 @@ export interface XgridsK1State {
|
||||||
devices?: BleDevice[];
|
devices?: BleDevice[];
|
||||||
selected_device_id?: string | null;
|
selected_device_id?: string | null;
|
||||||
k1_ip?: string | null;
|
k1_ip?: string | null;
|
||||||
connection_mode?: "bridge" | null;
|
connection_mode?: "bridge" | "quick-connect" | "direct-connect" | null;
|
||||||
foxglove_ws_url?: string | null;
|
foxglove_ws_url?: string | null;
|
||||||
foxglove_viewer_url?: string | null;
|
foxglove_viewer_url?: string | null;
|
||||||
rerun_grpc_url?: string | null;
|
rerun_grpc_url?: string | null;
|
||||||
|
|
@ -322,7 +323,7 @@ export interface ScanRequest {
|
||||||
|
|
||||||
export interface CompatibilityAttestation {
|
export interface CompatibilityAttestation {
|
||||||
firmware_version: "3.0.2";
|
firmware_version: "3.0.2";
|
||||||
topology: "direct-lan";
|
topology: "direct-lan" | "device-ap" | "controller-hotspot";
|
||||||
verification: "live-device-info";
|
verification: "live-device-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +331,7 @@ export interface ConnectRequest {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
ssid: string;
|
ssid: string;
|
||||||
password: string;
|
password: string;
|
||||||
connection_mode: "bridge";
|
connection_mode: "bridge" | "quick-connect" | "direct-connect";
|
||||||
compatibility_attestation: CompatibilityAttestation;
|
compatibility_attestation: CompatibilityAttestation;
|
||||||
operation_id?: string;
|
operation_id?: string;
|
||||||
idempotency_key?: string;
|
idempotency_key?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
import type { CompatibilityAttestation } from "./api";
|
import type { CompatibilityAttestation } from "./api";
|
||||||
|
import type { ConnectionMode } from "./configuration";
|
||||||
|
|
||||||
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
|
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
|
||||||
firmware_version: "3.0.2",
|
firmware_version: "3.0.2",
|
||||||
topology: "direct-lan",
|
topology: "direct-lan",
|
||||||
verification: "live-device-info",
|
verification: "live-device-info",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const topologyByConnectionMode = {
|
||||||
|
bridge: "direct-lan",
|
||||||
|
"quick-connect": "device-ap",
|
||||||
|
"direct-connect": "controller-hotspot",
|
||||||
|
} as const satisfies Record<ConnectionMode, CompatibilityAttestation["topology"]>;
|
||||||
|
|
||||||
|
export function profileSelectionForConnectionMode(
|
||||||
|
connectionMode: ConnectionMode,
|
||||||
|
): CompatibilityAttestation {
|
||||||
|
return {
|
||||||
|
firmware_version: "3.0.2",
|
||||||
|
topology: topologyByConnectionMode[connectionMode],
|
||||||
|
verification: "live-device-info",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||||
import {
|
import {
|
||||||
SUPPORTED_GNSS_MODE,
|
SUPPORTED_GNSS_MODE,
|
||||||
SUPPORTED_MOUNT_TYPE,
|
SUPPORTED_MOUNT_TYPE,
|
||||||
|
|
@ -149,7 +149,9 @@ export function K1AcquisitionPipeline({
|
||||||
project_name: projectNameValidation.value,
|
project_name: projectNameValidation.value,
|
||||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
compatibility_attestation: profileSelectionForConnectionMode(
|
||||||
|
state.connection_mode ?? "bridge",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import {
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BleDevice } from "../api";
|
import type { BleDevice } from "../api";
|
||||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||||
import {
|
import {
|
||||||
SUPPORTED_CONNECTION_MODE,
|
DEFAULT_CONNECTION_MODE,
|
||||||
connectionModeOptions,
|
connectionModeOptions,
|
||||||
type ConnectionMode,
|
type ConnectionMode,
|
||||||
} from "../configuration";
|
} from "../configuration";
|
||||||
|
|
@ -21,6 +21,36 @@ import { provisioningIntentKey } from "../lifecycle";
|
||||||
import { finiteMetric } from "../presentation";
|
import { finiteMetric } from "../presentation";
|
||||||
import type { XgridsK1Controller } from "../runtimeContext";
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
|
||||||
|
const connectionCopy: Record<ConnectionMode, {
|
||||||
|
stepTitle: string;
|
||||||
|
ssidLabel: string;
|
||||||
|
ssidPlaceholder: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
safetyNote: string;
|
||||||
|
}> = {
|
||||||
|
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({
|
function WizardStep({
|
||||||
number,
|
number,
|
||||||
title,
|
title,
|
||||||
|
|
@ -96,13 +126,17 @@ export function K1ProvisioningPipeline({
|
||||||
const [ssid, setSsid] = useState("");
|
const [ssid, setSsid] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
|
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
|
||||||
SUPPORTED_CONNECTION_MODE,
|
DEFAULT_CONNECTION_MODE,
|
||||||
);
|
);
|
||||||
const provisioningIntentRef = useRef<string | null>(null);
|
const provisioningIntentRef = useRef<string | null>(null);
|
||||||
const devices = state?.devices ?? [];
|
const devices = state?.devices ?? [];
|
||||||
const isBusy = pendingAction !== null;
|
const isBusy = pendingAction !== null;
|
||||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||||
|
const modeCopy = connectionCopy[connectionMode];
|
||||||
|
const selectedModeConnected = Boolean(
|
||||||
|
state?.k1_ip && state.connection_mode === connectionMode,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.selected_device_id) {
|
if (state?.selected_device_id) {
|
||||||
|
|
@ -117,6 +151,12 @@ export function K1ProvisioningPipeline({
|
||||||
}
|
}
|
||||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.connection_mode) {
|
||||||
|
setConnectionMode(state.connection_mode);
|
||||||
|
}
|
||||||
|
}, [state?.connection_mode]);
|
||||||
|
|
||||||
const deviceSummary = useMemo(
|
const deviceSummary = useMemo(
|
||||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||||
[devices, selectedDeviceId],
|
[devices, selectedDeviceId],
|
||||||
|
|
@ -134,8 +174,8 @@ export function K1ProvisioningPipeline({
|
||||||
device_id: selectedDeviceId,
|
device_id: selectedDeviceId,
|
||||||
ssid: ssid.trim(),
|
ssid: ssid.trim(),
|
||||||
password,
|
password,
|
||||||
connection_mode: SUPPORTED_CONNECTION_MODE,
|
connection_mode: connectionMode,
|
||||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||||
idempotency_key: idempotencyKey,
|
idempotency_key: idempotencyKey,
|
||||||
});
|
});
|
||||||
if (succeeded) {
|
if (succeeded) {
|
||||||
|
|
@ -152,7 +192,7 @@ export function K1ProvisioningPipeline({
|
||||||
</header>
|
</header>
|
||||||
<div className="configuration-anchor">
|
<div className="configuration-anchor">
|
||||||
<span className="nodedc-field__description">
|
<span className="nodedc-field__description">
|
||||||
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
|
Выберите направление связи. Каждый путь выполняет не более одного сетевого изменения и не повторяет его автоматически.
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<Select
|
||||||
label="Способ подключения"
|
label="Способ подключения"
|
||||||
|
|
@ -160,6 +200,8 @@ export function K1ProvisioningPipeline({
|
||||||
options={connectionModeOptions}
|
options={connectionModeOptions}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setConnectionMode(value);
|
setConnectionMode(value);
|
||||||
|
setSsid("");
|
||||||
|
setPassword("");
|
||||||
resetProvisioningIntent();
|
resetProvisioningIntent();
|
||||||
}}
|
}}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -206,19 +248,19 @@ export function K1ProvisioningPipeline({
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
<WizardStep
|
<WizardStep
|
||||||
number="03"
|
number="03"
|
||||||
title="Передайте настройки общей сети"
|
title={modeCopy.stepTitle}
|
||||||
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
|
status={selectedModeConnected ? "Адрес получен" : "Ожидает подключения"}
|
||||||
tone={state?.k1_ip ? "warning" : "neutral"}
|
tone={selectedModeConnected ? "success" : "neutral"}
|
||||||
>
|
>
|
||||||
<div className="field-stack">
|
<div className="field-stack">
|
||||||
<TextField label="Название сети Wi‑Fi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
|
<TextField label={modeCopy.ssidLabel} hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder={modeCopy.ssidPlaceholder} />
|
||||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||||
</div>
|
</div>
|
||||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
|
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
|
||||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
{pendingAction === "connect" ? "Подключаем…" : modeCopy.buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
<p className="safety-note">{modeCopy.safetyNote} Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
|
||||||
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
||||||
export type GnssMode = "none" | "rtk" | "ppk";
|
export type GnssMode = "none" | "rtk" | "ppk";
|
||||||
|
|
||||||
export const SUPPORTED_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
||||||
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
||||||
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
||||||
|
|
||||||
|
|
@ -12,19 +12,17 @@ export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
|
||||||
{
|
{
|
||||||
value: "bridge",
|
value: "bridge",
|
||||||
label: "Общая сеть · Bridge",
|
label: "Общая сеть · Bridge",
|
||||||
description: "K1 и Mission Core работают в одной локальной сети. Подтверждённый путь.",
|
description: "Mission Core передаёт K1 реквизиты существующей общей сети.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "quick-connect",
|
value: "quick-connect",
|
||||||
label: "Точка доступа K1 · Quick Connect",
|
label: "Точка доступа K1 · Quick Connect",
|
||||||
description: "Mission Core подключается к сети сканера. Будет доступно после отдельной приёмки.",
|
description: "Этот Mac один раз подключается к Wi‑Fi сканера; K1 остаётся точкой доступа.",
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "direct-connect",
|
value: "direct-connect",
|
||||||
label: "Хотспот контроллера · Direct Connect",
|
label: "Хотспот контроллера · Direct Connect",
|
||||||
description: "K1 подключается к сети управляющего устройства. Будет доступно после отдельной приёмки.",
|
description: "Mission Core передаёт K1 реквизиты хотспота управляющего устройства.",
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import CoreWLAN
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private struct AssociationRequest: Decodable {
|
||||||
|
let ssid: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AssociationResponse: Encodable {
|
||||||
|
let ok: Bool
|
||||||
|
let alreadyAssociated: Bool?
|
||||||
|
let reasonCode: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case ok
|
||||||
|
case alreadyAssociated = "already_associated"
|
||||||
|
case reasonCode = "reason_code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(_ response: AssociationResponse, exitCode: Int32) -> Never {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
if let data = try? encoder.encode(response) {
|
||||||
|
FileHandle.standardOutput.write(data)
|
||||||
|
}
|
||||||
|
exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let input = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
|
guard input.count <= 1024 else {
|
||||||
|
emit(
|
||||||
|
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "request-too-large"),
|
||||||
|
exitCode: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let request = try JSONDecoder().decode(AssociationRequest.self, from: input)
|
||||||
|
guard let ssidData = request.ssid.data(using: .utf8),
|
||||||
|
(1 ... 32).contains(ssidData.count),
|
||||||
|
let passwordData = request.password.data(using: .utf8),
|
||||||
|
(1 ... 64).contains(passwordData.count)
|
||||||
|
else {
|
||||||
|
emit(
|
||||||
|
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "credential-bounds"),
|
||||||
|
exitCode: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let interface = CWWiFiClient.shared().interface() else {
|
||||||
|
emit(
|
||||||
|
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "wifi-interface-unavailable"),
|
||||||
|
exitCode: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if interface.ssid() == request.ssid {
|
||||||
|
emit(AssociationResponse(ok: true, alreadyAssociated: true, reasonCode: nil), exitCode: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let networks = try interface.scanForNetworks(withSSID: ssidData)
|
||||||
|
guard let network = networks.first(where: { $0.ssid == request.ssid }) else {
|
||||||
|
emit(
|
||||||
|
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "network-not-found"),
|
||||||
|
exitCode: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try interface.associate(to: network, password: request.password)
|
||||||
|
// CoreWLAN's synchronous association call throws on failure. Reading the
|
||||||
|
// current SSID again would require Location authorization on recent macOS
|
||||||
|
// versions and could turn a successful association into a false negative.
|
||||||
|
emit(AssociationResponse(ok: true, alreadyAssociated: false, reasonCode: nil), exitCode: 0)
|
||||||
|
} catch {
|
||||||
|
emit(
|
||||||
|
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "corewlan-error"),
|
||||||
|
exitCode: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"kind": "DevicePlugin",
|
"kind": "DevicePlugin",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "nodedc.device.xgrids-lixelkity-k1",
|
"id": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"displayName": "XGRIDS K1 Integration"
|
"displayName": "XGRIDS K1 Integration"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
|
|
@ -14,14 +14,15 @@
|
||||||
},
|
},
|
||||||
"compatibilityProfiles": [
|
"compatibilityProfiles": [
|
||||||
{
|
{
|
||||||
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||||
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
|
"path": "profiles/fw-3.0.2/local-network.v2.json",
|
||||||
"modelId": "xgrids.lixelkity-k1"
|
"modelId": "xgrids.lixelkity-k1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"device.discovery.ble",
|
"device.discovery.ble",
|
||||||
"device.provisioning.wifi-over-ble",
|
"device.provisioning.wifi-over-ble",
|
||||||
|
"host.network.wifi-associate-local",
|
||||||
"network.mqtt.subscribe-private-lan",
|
"network.mqtt.subscribe-private-lan",
|
||||||
"network.mqtt.publish-private-lan",
|
"network.mqtt.publish-private-lan",
|
||||||
"network.rtsp.read-private-lan",
|
"network.rtsp.read-private-lan",
|
||||||
|
|
@ -61,11 +62,12 @@
|
||||||
"vendor": "XGRIDS",
|
"vendor": "XGRIDS",
|
||||||
"displayName": "XGRIDS LixelKity K1",
|
"displayName": "XGRIDS LixelKity K1",
|
||||||
"category": "Мобильный лидарный сканер",
|
"category": "Мобильный лидарный сканер",
|
||||||
"description": "Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, облако точек, поза и raw-first запись.",
|
"description": "Локальный профиль Bridge, Quick Connect и Direct Connect: BLE/CoreWLAN, MQTT, камеры и raw-first запись.",
|
||||||
"verified": true,
|
"verified": true,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
{ "id": "device.discovery.ble", "label": "Поиск BLE" },
|
{ "id": "device.discovery.ble", "label": "Поиск BLE" },
|
||||||
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
|
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
|
||||||
|
{ "id": "host.network.wifi-associate-local", "label": "Подключение к точке доступа K1" },
|
||||||
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
|
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
|
||||||
{ "id": "spatial.pose.live", "label": "Траектория" },
|
{ "id": "spatial.pose.live", "label": "Траектория" },
|
||||||
{ "id": "device.modeling.live", "label": "Метрики маршрута" },
|
{ "id": "device.modeling.live", "label": "Метрики маршрута" },
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
PROFILE_SCHEMA_VERSION = 1
|
PROFILE_SCHEMA_VERSION = 1
|
||||||
DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
DEFAULT_PROFILE_PATH = Path(__file__).parent / "profiles" / "fw-3.0.2" / "direct-lan.v1.json"
|
DEFAULT_PROFILE_PATH = (
|
||||||
|
Path(__file__).parent / "profiles" / "fw-3.0.2" / "local-network.v2.json"
|
||||||
|
)
|
||||||
EVIDENCE_FLAGS = (
|
EVIDENCE_FLAGS = (
|
||||||
"observed",
|
"observed",
|
||||||
"decoded",
|
"decoded",
|
||||||
|
|
@ -129,6 +131,49 @@ def _validate_sources(profile: dict[str, Any]) -> set[str]:
|
||||||
return set(sources)
|
return set(sources)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_connection_modes(scope: dict[str, Any]) -> None:
|
||||||
|
modes = _unique_index(
|
||||||
|
_array(scope.get("connection_modes"), "$.scope.connection_modes"),
|
||||||
|
"$.scope.connection_modes",
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
"bridge": {
|
||||||
|
"topology": "direct-lan",
|
||||||
|
"direction": "k1-joins-existing-network",
|
||||||
|
"device_network_action": "single-reviewed-ble-provisioning-write",
|
||||||
|
"host_network_action": "none",
|
||||||
|
"target_address_policy": "ble-status-non-ap-private-ipv4",
|
||||||
|
"acceptance": "mission-core-physical-accepted",
|
||||||
|
},
|
||||||
|
"direct-connect": {
|
||||||
|
"topology": "controller-hotspot",
|
||||||
|
"direction": "k1-joins-controller-network",
|
||||||
|
"device_network_action": "single-reviewed-ble-provisioning-write",
|
||||||
|
"host_network_action": "operator-prepared-hotspot",
|
||||||
|
"target_address_policy": "ble-status-non-ap-private-ipv4",
|
||||||
|
"acceptance": "implementation-ready-physical-acceptance-pending",
|
||||||
|
},
|
||||||
|
"quick-connect": {
|
||||||
|
"topology": "device-ap",
|
||||||
|
"direction": "controller-joins-k1-network",
|
||||||
|
"device_network_action": "none",
|
||||||
|
"host_network_action": "single-corewlan-association",
|
||||||
|
"target_address_policy": "reviewed-fixed-k1-ap-private-ipv4",
|
||||||
|
"acceptance": (
|
||||||
|
"lixelgo-data-plane-observed-mission-core-physical-acceptance-pending"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if set(modes) != set(expected):
|
||||||
|
raise CompatibilityProfileError("connection mode set differs from reviewed evidence")
|
||||||
|
for mode_id, expected_fields in expected.items():
|
||||||
|
actual = {key: value for key, value in modes[mode_id].items() if key != "id"}
|
||||||
|
if actual != expected_fields:
|
||||||
|
raise CompatibilityProfileError(
|
||||||
|
f"connection mode {mode_id!r} differs from reviewed evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_source_references(value: Any, source_ids: set[str], path: str = "$") -> None:
|
def _validate_source_references(value: Any, source_ids: set[str], path: str = "$") -> None:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
if "source_ids" in value:
|
if "source_ids" in value:
|
||||||
|
|
@ -155,7 +200,7 @@ def _validate_transports(profile: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
if set(transports) != {
|
if set(transports) != {
|
||||||
"ble.wifi-bootstrap.fw3.v1",
|
"ble.wifi-bootstrap.fw3.v1",
|
||||||
"mqtt.direct-lan.fw3.v1",
|
"mqtt.local-ipv4.fw3.v1",
|
||||||
"rtsp.camera-preview.fw3.v1",
|
"rtsp.camera-preview.fw3.v1",
|
||||||
}:
|
}:
|
||||||
raise CompatibilityProfileError("$.transports must contain only the reviewed v1 transports")
|
raise CompatibilityProfileError("$.transports must contain only the reviewed v1 transports")
|
||||||
|
|
@ -180,14 +225,14 @@ def _validate_transports(profile: dict[str, Any]) -> None:
|
||||||
physical_verified=True,
|
physical_verified=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
mqtt = transports["mqtt.direct-lan.fw3.v1"]
|
mqtt = transports["mqtt.local-ipv4.fw3.v1"]
|
||||||
if mqtt.get("protocol") != "MQTT 3.1.1":
|
if mqtt.get("protocol") != "MQTT 3.1.1":
|
||||||
raise CompatibilityProfileError("direct-LAN application protocol must remain MQTT 3.1.1")
|
raise CompatibilityProfileError("local application protocol must remain MQTT 3.1.1")
|
||||||
network = _object(mqtt.get("network"), "$.transports[mqtt].network")
|
network = _object(mqtt.get("network"), "$.transports[mqtt].network")
|
||||||
if network.get("transport") != "TCP" or network.get("port") != 1883:
|
if network.get("transport") != "TCP" or network.get("port") != 1883:
|
||||||
raise CompatibilityProfileError("direct-LAN MQTT endpoint must remain TCP 1883")
|
raise CompatibilityProfileError("local MQTT endpoint must remain TCP 1883")
|
||||||
if network.get("tls") is not False or network.get("authentication") != "none-observed":
|
if network.get("tls") is not False or network.get("authentication") != "none-observed":
|
||||||
raise CompatibilityProfileError("direct-LAN MQTT security claim differs from observation")
|
raise CompatibilityProfileError("local MQTT security claim differs from observation")
|
||||||
allowlist = _array(
|
allowlist = _array(
|
||||||
mqtt.get("subscription_allowlist"),
|
mqtt.get("subscription_allowlist"),
|
||||||
"$.transports[mqtt].subscription_allowlist",
|
"$.transports[mqtt].subscription_allowlist",
|
||||||
|
|
@ -202,7 +247,7 @@ def _validate_transports(profile: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
_expect_evidence(
|
_expect_evidence(
|
||||||
mqtt,
|
mqtt,
|
||||||
"$.transports[mqtt.direct-lan.fw3.v1]",
|
"$.transports[mqtt.local-ipv4.fw3.v1]",
|
||||||
observed=True,
|
observed=True,
|
||||||
decoded=False,
|
decoded=False,
|
||||||
replay_verified=False,
|
replay_verified=False,
|
||||||
|
|
@ -504,7 +549,7 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
|
||||||
|
|
||||||
|
|
||||||
def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
||||||
"""Validate and return one read-only firmware-3/direct-LAN profile object."""
|
"""Validate the read-only firmware-3 local connection matrix."""
|
||||||
root = _object(profile, "$")
|
root = _object(profile, "$")
|
||||||
if root.get("schema_version") != PROFILE_SCHEMA_VERSION:
|
if root.get("schema_version") != PROFILE_SCHEMA_VERSION:
|
||||||
raise CompatibilityProfileError("unsupported compatibility profile schema_version")
|
raise CompatibilityProfileError("unsupported compatibility profile schema_version")
|
||||||
|
|
@ -519,8 +564,9 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
||||||
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
||||||
if firmware != {"match": "exact", "version": "3.0.2"}:
|
if firmware != {"match": "exact", "version": "3.0.2"}:
|
||||||
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
||||||
if scope.get("topology") != "direct-lan":
|
if scope.get("topology") != "local-network-matrix":
|
||||||
raise CompatibilityProfileError("profile topology must remain direct-lan")
|
raise CompatibilityProfileError("profile topology must remain local-network-matrix")
|
||||||
|
_validate_connection_modes(scope)
|
||||||
|
|
||||||
vocabulary = _object(root.get("evidence_vocabulary"), "$.evidence_vocabulary")
|
vocabulary = _object(root.get("evidence_vocabulary"), "$.evidence_vocabulary")
|
||||||
if set(vocabulary) != set(EVIDENCE_FLAGS):
|
if set(vocabulary) != set(EVIDENCE_FLAGS):
|
||||||
|
|
@ -580,7 +626,10 @@ def matches_target(
|
||||||
validated = validate_compatibility_profile(profile)
|
validated = validate_compatibility_profile(profile)
|
||||||
scope = validated["scope"]
|
scope = validated["scope"]
|
||||||
firmware_scope = _object(scope.get("firmware"), "$.scope.firmware")
|
firmware_scope = _object(scope.get("firmware"), "$.scope.firmware")
|
||||||
return firmware_scope.get("version") == firmware and scope.get("topology") == topology
|
modes = _array(scope.get("connection_modes"), "$.scope.connection_modes")
|
||||||
|
return firmware_scope.get("version") == firmware and any(
|
||||||
|
isinstance(mode, dict) and mode.get("topology") == topology for mode in modes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _main() -> int:
|
def _main() -> int:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,496 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||||
|
"profile_status": "experimental-evidence-backed",
|
||||||
|
"scope": {
|
||||||
|
"vendor": "XGRIDS",
|
||||||
|
"model": "LixelKity K1",
|
||||||
|
"platform_type": "A4",
|
||||||
|
"firmware": {
|
||||||
|
"match": "exact",
|
||||||
|
"version": "3.0.2"
|
||||||
|
},
|
||||||
|
"topology": "local-network-matrix",
|
||||||
|
"claim_limit": "One owner-controlled K1 on exact firmware 3.0.2 across controlled Bridge/Mission Core and owner-operated Quick Connect LixelGO/iPhone runs. Direct Connect reuses the physically accepted 99-byte BLE Wi-Fi frame but still requires its own Mission Core physical acceptance. This is not a vendor API or a cross-firmware compatibility claim.",
|
||||||
|
"connection_modes": [
|
||||||
|
{
|
||||||
|
"id": "bridge",
|
||||||
|
"topology": "direct-lan",
|
||||||
|
"direction": "k1-joins-existing-network",
|
||||||
|
"device_network_action": "single-reviewed-ble-provisioning-write",
|
||||||
|
"host_network_action": "none",
|
||||||
|
"target_address_policy": "ble-status-non-ap-private-ipv4",
|
||||||
|
"acceptance": "mission-core-physical-accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "direct-connect",
|
||||||
|
"topology": "controller-hotspot",
|
||||||
|
"direction": "k1-joins-controller-network",
|
||||||
|
"device_network_action": "single-reviewed-ble-provisioning-write",
|
||||||
|
"host_network_action": "operator-prepared-hotspot",
|
||||||
|
"target_address_policy": "ble-status-non-ap-private-ipv4",
|
||||||
|
"acceptance": "implementation-ready-physical-acceptance-pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quick-connect",
|
||||||
|
"topology": "device-ap",
|
||||||
|
"direction": "controller-joins-k1-network",
|
||||||
|
"device_network_action": "none",
|
||||||
|
"host_network_action": "single-corewlan-association",
|
||||||
|
"target_address_policy": "reviewed-fixed-k1-ap-private-ipv4",
|
||||||
|
"acceptance": "lixelgo-data-plane-observed-mission-core-physical-acceptance-pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence_vocabulary": {
|
||||||
|
"observed": "The channel, transport, or physical behavior was directly seen on the owner-controlled K1.",
|
||||||
|
"decoded": "A bounded decoder produces the stated semantic payload from observed bytes; transport framing alone is not a semantic decode.",
|
||||||
|
"replay_verified": "A captured payload of this semantic channel has passed the repository replay-to-view path.",
|
||||||
|
"physical_verified": "The result was correlated with a controlled physical K1 state or operator action.",
|
||||||
|
"write_enabled": "This profile authorizes software to emit the state-changing vendor request. False never grants runtime write authority."
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"default_mode": "read-only",
|
||||||
|
"vendor_writes_enabled": false,
|
||||||
|
"unknown_firmware_policy": "reject-profile",
|
||||||
|
"request_topic_subscription_enabled": false,
|
||||||
|
"notes": [
|
||||||
|
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
|
||||||
|
"The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
|
||||||
|
"Observed LixelGO modeling requests remain write-disabled in this descriptive profile; the separately installed acceptance transport requires a live DeviceInfo match and one operator-present action permit.",
|
||||||
|
"Unknown firmware, transport, topics, fields, and action responses fail closed."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence_sources": [
|
||||||
|
{
|
||||||
|
"id": "wifi-provisioning-profile",
|
||||||
|
"kind": "reviewed-profile",
|
||||||
|
"path": "docs/04_K1_WIFI_PROVISIONING_PROFILE.md",
|
||||||
|
"scope": "Observed firmware, GATT UUIDs, 99-byte provisioning frame, status read and physical LAN association."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mqtt-stream-profile",
|
||||||
|
"kind": "reviewed-profile",
|
||||||
|
"path": "docs/05_K1_MQTT_STREAM_PROFILE.md",
|
||||||
|
"scope": "Local MQTT transport, report topics, bounded point/pose codecs and static modeling-request mapping."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lab-001",
|
||||||
|
"kind": "redacted-physical-lab-report",
|
||||||
|
"path": "docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md",
|
||||||
|
"scope": "Physical BLE-to-Wi-Fi result, MQTT message counts, point/pose decode totals and negative camera observation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "live-viewer-profile",
|
||||||
|
"kind": "implemented-path-description",
|
||||||
|
"path": "docs/06_K1_LIVE_VIEWER.md",
|
||||||
|
"scope": "Raw-first live/replay path for point cloud and pose."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lab-002",
|
||||||
|
"kind": "redacted-physical-protocol-report",
|
||||||
|
"path": "docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md",
|
||||||
|
"scope": "Owner-operated LixelGO start/stop mapping, local-only bounded traffic result and left/right RTSP/H.264 camera-preview discovery."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transports": [
|
||||||
|
{
|
||||||
|
"id": "ble.wifi-bootstrap.fw3.v1",
|
||||||
|
"role": "bootstrap-and-status",
|
||||||
|
"protocol": "BLE GATT",
|
||||||
|
"service_uuid": "00007f00-0000-1000-8000-00805f9b34fb",
|
||||||
|
"characteristics": {
|
||||||
|
"wifi_request": "00007f01-0000-1000-8000-00805f9b34fb",
|
||||||
|
"wifi_status": "00007f02-0000-1000-8000-00805f9b34fb"
|
||||||
|
},
|
||||||
|
"reviewed_profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||||
|
"request_frame_bytes": 99,
|
||||||
|
"status_semantics": {
|
||||||
|
"ap_baseline_ipv4": "192.168.56.1",
|
||||||
|
"lan_acceptance": "A non-AP private IPv4 must be observed and independently confirmed on the intended LAN."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"wifi-provisioning-profile",
|
||||||
|
"lab-001"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mqtt.local-ipv4.fw3.v1",
|
||||||
|
"role": "report-data-plane",
|
||||||
|
"protocol": "MQTT 3.1.1",
|
||||||
|
"network": {
|
||||||
|
"transport": "TCP",
|
||||||
|
"port": 1883,
|
||||||
|
"tls": false,
|
||||||
|
"authentication": "none-observed",
|
||||||
|
"addressing": "confirmed-device-private-ipv4-only"
|
||||||
|
},
|
||||||
|
"subscription_allowlist": [
|
||||||
|
"lixel/application/report/#",
|
||||||
|
"RealtimePointcloud",
|
||||||
|
"RealtimePath",
|
||||||
|
"DeviceStatus"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"live-viewer-profile",
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rtsp.camera-preview.fw3.v1",
|
||||||
|
"role": "camera-preview-data-plane",
|
||||||
|
"protocol": "RTSP 1.0 with interleaved RTP over TCP",
|
||||||
|
"network": {
|
||||||
|
"transport": "TCP",
|
||||||
|
"port": 8554,
|
||||||
|
"tls": false,
|
||||||
|
"authentication": "none-observed",
|
||||||
|
"addressing": "confirmed-device-private-ipv4-only"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"codec": "H.264",
|
||||||
|
"rtp_payload_type": 96,
|
||||||
|
"clock_hz": 90000,
|
||||||
|
"framing": "RTP/AVP/TCP interleaved channels 0-1"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"id": "spatial.point-cloud.live",
|
||||||
|
"kind": "point-cloud",
|
||||||
|
"direction": "device-report",
|
||||||
|
"topic": "lixel/application/report/lio_pcl",
|
||||||
|
"wire_format": "protobuf MqttCompressMsg containing raw-LZ4 LioPclReport",
|
||||||
|
"semantic_payload": "metric XYZ, complete uint32 rgbi and verified low-byte intensity",
|
||||||
|
"bounds": {
|
||||||
|
"max_mqtt_payload_bytes": 2097152,
|
||||||
|
"max_compressed_bytes": 1048576,
|
||||||
|
"max_decoded_bytes": 8388608,
|
||||||
|
"max_expansion_ratio": 64,
|
||||||
|
"max_points_per_frame": 250000
|
||||||
|
},
|
||||||
|
"unverified_fields": [
|
||||||
|
"upper 24 bits of rgbi as RGB",
|
||||||
|
"sensor timestamp epoch",
|
||||||
|
"sensor-to-vehicle extrinsics"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": true,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"live-viewer-profile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spatial.pose.live",
|
||||||
|
"kind": "pose",
|
||||||
|
"direction": "device-report",
|
||||||
|
"topic": "lixel/application/report/lio_pose",
|
||||||
|
"wire_format": "protobuf LioPoseReport",
|
||||||
|
"semantic_payload": "position XYZ, quaternion XYZW, distance and pose accuracy",
|
||||||
|
"unverified_fields": [
|
||||||
|
"sensor timestamp epoch",
|
||||||
|
"coordinate-frame convention",
|
||||||
|
"sensor-to-vehicle extrinsics"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": true,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"live-viewer-profile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device.modeling.live",
|
||||||
|
"kind": "acquisition-telemetry",
|
||||||
|
"direction": "device-report",
|
||||||
|
"topic": "lixel/application/report/modeling",
|
||||||
|
"wire_format": "protobuf ModelingReport acquisition telemetry subset",
|
||||||
|
"semantic_payload": "nonnegative MoveDistance metres, MoveSpeed metres per second, int64 ScanTime at two ticks per second, and int32 PgoProgress",
|
||||||
|
"bounds": {
|
||||||
|
"max_mqtt_payload_bytes": 65536,
|
||||||
|
"max_fields": 64,
|
||||||
|
"max_nested_fields": 16
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"ScanTime scale is exact-profile evidence and must not be generalized to other firmware.",
|
||||||
|
"Replay-to-view acceptance remains open even though retained physical runs establish units and live decoding."
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"live-viewer-profile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device.status.live",
|
||||||
|
"kind": "device-status",
|
||||||
|
"direction": "device-report",
|
||||||
|
"topic": "lixel/application/report/device_status",
|
||||||
|
"wire_format": "protobuf DeviceStatusReport acquisition lifecycle subset",
|
||||||
|
"semantic_payload": "bounded modeling-state base-offset mapping, init-ready flag, project presence and redacted identity fields",
|
||||||
|
"limitations": [
|
||||||
|
"Nested system and RTK status payloads are presence-checked but not semantically decoded.",
|
||||||
|
"ScanOver and Ready observations do not prove durable artifact save completion."
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device.heartbeat.live",
|
||||||
|
"kind": "heartbeat",
|
||||||
|
"direction": "device-report",
|
||||||
|
"topic": "lixel/application/report/heartbeat",
|
||||||
|
"wire_format": "opaque bytes",
|
||||||
|
"semantic_payload": null,
|
||||||
|
"limitations": [
|
||||||
|
"Only channel presence and approximate report cadence are verified.",
|
||||||
|
"Payload semantics are not decoded."
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camera.preview.live",
|
||||||
|
"kind": "camera-preview",
|
||||||
|
"direction": "device-report",
|
||||||
|
"discovery_status": "observed",
|
||||||
|
"topic": null,
|
||||||
|
"endpoint_templates": [
|
||||||
|
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main",
|
||||||
|
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main"
|
||||||
|
],
|
||||||
|
"wire_format": "RTSP 1.0, interleaved RTP/TCP, H.264 PT96 at 90000 Hz",
|
||||||
|
"semantic_payload": "compressed live left/right camera preview selected by endpoint",
|
||||||
|
"limitations": [
|
||||||
|
"No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.",
|
||||||
|
"Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.",
|
||||||
|
"Mission Core implements bounded copy-remux, archival and replay admission, but no newly archived physical K1 camera session has passed shared-timeline playback acceptance."
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"acquisition_control": {
|
||||||
|
"mode": "operator-manual",
|
||||||
|
"write_enabled": false,
|
||||||
|
"software_acceptance_transport": {
|
||||||
|
"status": "installed-operator-present",
|
||||||
|
"default_authority": "disabled",
|
||||||
|
"profile_gate": "live-device-info-exact-match",
|
||||||
|
"dialogue": "single-socket-canonical-start-to-stop",
|
||||||
|
"automatic_retry": false,
|
||||||
|
"supported_mount_type": "handheld",
|
||||||
|
"supported_gnss_mode": "none"
|
||||||
|
},
|
||||||
|
"verified_device_control": {
|
||||||
|
"gesture": "physical-double-click",
|
||||||
|
"state_dependent_result": "start from steady-green standby; stop during active scanning",
|
||||||
|
"single_click_result": "not a verified scan-start action",
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"lab-001"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"semantic_actions": [
|
||||||
|
{
|
||||||
|
"id": "acquisition.start",
|
||||||
|
"execution": "operator-manual",
|
||||||
|
"operator_control": "physical-double-click from steady-green standby",
|
||||||
|
"observed_application_control": "owner-operated LixelGO project confirmation",
|
||||||
|
"vendor_request_mapping": {
|
||||||
|
"evidence_kind": "owner-controlled-wire-observation",
|
||||||
|
"transport": "MQTT 3.1.1",
|
||||||
|
"topic": "lixel/application/request/modeling",
|
||||||
|
"qos": 2,
|
||||||
|
"retain": false,
|
||||||
|
"message_type": "ModelingRequest",
|
||||||
|
"action_field_value": 1,
|
||||||
|
"header_contract": {
|
||||||
|
"device_id": "explicit-observed-identity",
|
||||||
|
"session_id": "{device_id}:ModelingRequest",
|
||||||
|
"openapi_key": "private-application-level-runtime-authority"
|
||||||
|
},
|
||||||
|
"request_fields": {
|
||||||
|
"project_name": "required-operator-value",
|
||||||
|
"record_mode": 2,
|
||||||
|
"scan_mode": 1,
|
||||||
|
"mount_type": 0,
|
||||||
|
"pre_project_id": "omitted-in-retained-request"
|
||||||
|
},
|
||||||
|
"success_result_code": 302252033,
|
||||||
|
"required_unresolved_context": [
|
||||||
|
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||||
|
"authorization policy for any setting outside the retained request",
|
||||||
|
"timeout, rejection and rollback contract"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "acquisition.stop",
|
||||||
|
"execution": "operator-manual",
|
||||||
|
"operator_control": "physical-double-click during active scanning",
|
||||||
|
"observed_application_control": "owner-operated LixelGO stop confirmation",
|
||||||
|
"vendor_request_mapping": {
|
||||||
|
"evidence_kind": "owner-controlled-wire-observation",
|
||||||
|
"transport": "MQTT 3.1.1",
|
||||||
|
"topic": "lixel/application/request/modeling",
|
||||||
|
"qos": 2,
|
||||||
|
"retain": false,
|
||||||
|
"message_type": "ModelingRequest",
|
||||||
|
"action_field_value": 2,
|
||||||
|
"header_contract": {
|
||||||
|
"device_id": "explicit-observed-identity",
|
||||||
|
"session_id": "{device_id}:ModelingRequest",
|
||||||
|
"openapi_key": "private-application-level-runtime-authority"
|
||||||
|
},
|
||||||
|
"request_fields": {},
|
||||||
|
"success_result_code": 302252033,
|
||||||
|
"required_unresolved_context": [
|
||||||
|
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||||
|
"save-completion and final-standby state mapping",
|
||||||
|
"timeout and rollback contract"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"observed": true,
|
||||||
|
"decoded": true,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": true,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"mqtt-stream-profile",
|
||||||
|
"lab-001",
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "calibration.device.start",
|
||||||
|
"execution": "unavailable",
|
||||||
|
"operator_control": null,
|
||||||
|
"vendor_request_mapping": null,
|
||||||
|
"observed_behavior": "Static initialization follows acquisition.start; no independent calibration action was observed.",
|
||||||
|
"limitations": [
|
||||||
|
"No standalone calibration command topic, request schema, acknowledgment or state transition is verified."
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"observed": false,
|
||||||
|
"decoded": false,
|
||||||
|
"replay_verified": false,
|
||||||
|
"physical_verified": false,
|
||||||
|
"write_enabled": false
|
||||||
|
},
|
||||||
|
"source_ids": [
|
||||||
|
"lab-002"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ from collections.abc import Callable, Mapping
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Concatenate, Literal, Protocol, cast
|
from typing import Any, Concatenate, Literal, Protocol, Self, cast
|
||||||
|
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
from missioncore_plugin_sdk.v0alpha2 import (
|
from missioncore_plugin_sdk.v0alpha2 import (
|
||||||
|
|
@ -28,6 +28,7 @@ from pydantic import (
|
||||||
SecretStr,
|
SecretStr,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
field_validator,
|
field_validator,
|
||||||
|
model_validator,
|
||||||
)
|
)
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
from k1link.artifacts import write_json_atomic
|
||||||
|
|
@ -44,6 +45,7 @@ from k1link.device_plugins.xgrids_k1.camera import (
|
||||||
XgridsK1CameraGateway,
|
XgridsK1CameraGateway,
|
||||||
build_xgrids_k1_camera_router,
|
build_xgrids_k1_camera_router,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.macos_wifi import associate_with_wifi_once
|
||||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
|
@ -53,6 +55,9 @@ from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
|
||||||
ApplicationAuthorityLoader,
|
ApplicationAuthorityLoader,
|
||||||
DormantApplicationControlCoordinator,
|
DormantApplicationControlCoordinator,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
|
ReviewedApplicationMqttTransport,
|
||||||
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
UninstalledApplicationPublishSink,
|
UninstalledApplicationPublishSink,
|
||||||
WriteDisabledOneShotPublisher,
|
WriteDisabledOneShotPublisher,
|
||||||
|
|
@ -90,9 +95,19 @@ from k1link.web.plugin_runtime import (
|
||||||
)
|
)
|
||||||
|
|
||||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
XGRIDS_K1_PLUGIN_VERSION = "0.5.0"
|
XGRIDS_K1_PLUGIN_VERSION = "0.6.0"
|
||||||
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
||||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = (
|
||||||
|
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
|
||||||
|
ConnectionTopology = Literal["direct-lan", "device-ap", "controller-hotspot"]
|
||||||
|
CONNECTION_TOPOLOGY_BY_MODE: dict[ConnectionMode, ConnectionTopology] = {
|
||||||
|
"bridge": "direct-lan",
|
||||||
|
"quick-connect": "device-ap",
|
||||||
|
"direct-connect": "controller-hotspot",
|
||||||
|
}
|
||||||
|
|
||||||
ACTION_STATE_READ = "state.read"
|
ACTION_STATE_READ = "state.read"
|
||||||
ACTION_DISCOVERY_SCAN = "discovery.scan"
|
ACTION_DISCOVERY_SCAN = "discovery.scan"
|
||||||
|
|
@ -182,7 +197,7 @@ class CompatibilityAttestationRequest(StrictRequest):
|
||||||
"""Selected profile whose facts must be verified from live DeviceInfo."""
|
"""Selected profile whose facts must be verified from live DeviceInfo."""
|
||||||
|
|
||||||
firmware_version: Literal["3.0.2"]
|
firmware_version: Literal["3.0.2"]
|
||||||
topology: Literal["direct-lan"]
|
topology: ConnectionTopology
|
||||||
verification: Literal["live-device-info"]
|
verification: Literal["live-device-info"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -190,11 +205,26 @@ class ConnectRequest(StrictRequest):
|
||||||
device_id: str = Field(min_length=1, max_length=128)
|
device_id: str = Field(min_length=1, max_length=128)
|
||||||
ssid: str = Field(min_length=1, max_length=128)
|
ssid: str = Field(min_length=1, max_length=128)
|
||||||
password: SecretStr = Field(min_length=1, max_length=256)
|
password: SecretStr = Field(min_length=1, max_length=256)
|
||||||
connection_mode: Literal["bridge"] = "bridge"
|
connection_mode: ConnectionMode = "bridge"
|
||||||
compatibility_attestation: CompatibilityAttestationRequest
|
compatibility_attestation: CompatibilityAttestationRequest
|
||||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_connection_topology(self) -> Self:
|
||||||
|
if not 1 <= len(self.ssid.encode("utf-8")) <= 32:
|
||||||
|
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||||
|
if not 1 <= len(self.password.get_secret_value().encode("utf-8")) <= 64:
|
||||||
|
raise ValueError(
|
||||||
|
"Wi-Fi password must contain between 1 and 64 UTF-8 bytes"
|
||||||
|
)
|
||||||
|
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
|
||||||
|
if self.compatibility_attestation.topology != expected:
|
||||||
|
raise ValueError(
|
||||||
|
f"connection_mode={self.connection_mode} requires topology={expected}"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class LiveRequest(StrictRequest):
|
class LiveRequest(StrictRequest):
|
||||||
project_name: str = Field(min_length=1, max_length=96)
|
project_name: str = Field(min_length=1, max_length=96)
|
||||||
|
|
@ -335,7 +365,7 @@ class XgridsK1CompatibilityService:
|
||||||
self._devices: list[dict[str, Any]] = []
|
self._devices: list[dict[str, Any]] = []
|
||||||
self._selected_device_id: str | None = None
|
self._selected_device_id: str | None = None
|
||||||
self._k1_ip: str | None = None
|
self._k1_ip: str | None = None
|
||||||
self._connection_mode: Literal["bridge"] | None = None
|
self._connection_mode: ConnectionMode | None = None
|
||||||
self._device_ids_by_transport_ref: dict[str, str] = {}
|
self._device_ids_by_transport_ref: dict[str, str] = {}
|
||||||
self._device_id: str | None = None
|
self._device_id: str | None = None
|
||||||
self._device_session_id: str | None = None
|
self._device_session_id: str | None = None
|
||||||
|
|
@ -369,7 +399,8 @@ class XgridsK1CompatibilityService:
|
||||||
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
|
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
|
||||||
)
|
)
|
||||||
self._application_control_session = InteractiveApplicationControlSession(
|
self._application_control_session = InteractiveApplicationControlSession(
|
||||||
authority_loader
|
authority_loader,
|
||||||
|
transport_factory=self._application_control_transport,
|
||||||
)
|
)
|
||||||
self.runtime = VisualizationRuntime(
|
self.runtime = VisualizationRuntime(
|
||||||
normalizer=normalize_k1_message,
|
normalizer=normalize_k1_message,
|
||||||
|
|
@ -380,6 +411,14 @@ class XgridsK1CompatibilityService:
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _application_control_transport(self, host: str) -> ReviewedApplicationMqttTransport:
|
||||||
|
with self._lock:
|
||||||
|
connection_mode = self._connection_mode
|
||||||
|
return ReviewedApplicationMqttTransport(
|
||||||
|
host,
|
||||||
|
allow_device_ap=connection_mode == "quick-connect",
|
||||||
|
)
|
||||||
|
|
||||||
@_serialized_acquisition_access
|
@_serialized_acquisition_access
|
||||||
def state(self) -> dict[str, Any]:
|
def state(self) -> dict[str, Any]:
|
||||||
application_control = self._application_control.snapshot().as_dict()
|
application_control = self._application_control.snapshot().as_dict()
|
||||||
|
|
@ -600,9 +639,10 @@ class XgridsK1CompatibilityService:
|
||||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||||
if request.device_id not in known_ids:
|
if request.device_id not in known_ids:
|
||||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||||
# Unwrap once at the provisioning service boundary. The plain value is
|
# Unwrap once at the network service boundary. The plain value is kept
|
||||||
# kept only in this stack frame, included in a keyed request digest, and
|
# only in this stack frame, included in a keyed request digest, and
|
||||||
# passed to the reviewed BLE write boundary; it is never journaled.
|
# passed either to the reviewed BLE write or to the short-lived macOS
|
||||||
|
# CoreWLAN helper over stdin; it is never journaled.
|
||||||
password = request.password.get_secret_value()
|
password = request.password.get_secret_value()
|
||||||
request_fingerprint = self._request_fingerprint(
|
request_fingerprint = self._request_fingerprint(
|
||||||
ACTION_NETWORK_PROVISION,
|
ACTION_NETWORK_PROVISION,
|
||||||
|
|
@ -647,7 +687,8 @@ class XgridsK1CompatibilityService:
|
||||||
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
|
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
|
||||||
|
|
||||||
session_dir: Path | None = None
|
session_dir: Path | None = None
|
||||||
write_attempted = False
|
network_change_attempted = False
|
||||||
|
quick_connect = request.connection_mode == "quick-connect"
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
active_acquisition = self._acquisition
|
active_acquisition = self._acquisition
|
||||||
|
|
@ -659,55 +700,109 @@ class XgridsK1CompatibilityService:
|
||||||
"нельзя менять устройство или его сеть во время активной acquisition-сессии"
|
"нельзя менять устройство или его сеть во время активной acquisition-сессии"
|
||||||
)
|
)
|
||||||
self._provisioning_active = True
|
self._provisioning_active = True
|
||||||
|
# Once a new network operation is admitted, the previous route
|
||||||
|
# and device session can no longer be represented as current.
|
||||||
|
# This is especially important when Quick Connect may move the
|
||||||
|
# host off the prior LAN before CoreWLAN reports an outcome.
|
||||||
|
self._selected_device_id = None
|
||||||
|
self._k1_ip = None
|
||||||
|
self._connection_mode = None
|
||||||
|
self._compatibility_attestation = None
|
||||||
|
self._device_session_id = None
|
||||||
|
self._device_session_opened_at = None
|
||||||
|
self._connection_verification = {
|
||||||
|
"status": "not-probed",
|
||||||
|
"endpoint_validation": "not-performed",
|
||||||
|
"network_reachability": "unknown",
|
||||||
|
"observed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
# A reprovision can replace both the device session and its address.
|
# A connection-mode change can replace both the device session and
|
||||||
|
# its address.
|
||||||
self._application_control.disarm()
|
self._application_control.disarm()
|
||||||
# Revoke preview/producer state only after the active-acquisition
|
# Revoke preview/producer state only after the active-acquisition
|
||||||
# guard; a rejected network write must never stop evidence capture.
|
# guard; a rejected network write must never stop evidence capture.
|
||||||
self.camera_preview.stop_current()
|
self.camera_preview.stop_current()
|
||||||
|
|
||||||
self._set_operation(
|
operation_message = (
|
||||||
"provisioning",
|
"Подключаем этот Mac к точке доступа выбранного K1 одним запросом."
|
||||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
if quick_connect
|
||||||
|
else "Передаём устройству настройки Wi-Fi одним подтверждённым запросом."
|
||||||
)
|
)
|
||||||
|
self._set_operation("provisioning", operation_message)
|
||||||
session_dir = _new_operation_session_dir(
|
session_dir = _new_operation_session_dir(
|
||||||
self.evidence_root,
|
self.evidence_root,
|
||||||
"viewer_wifi_provisioning",
|
"viewer_k1_ap_association"
|
||||||
|
if quick_connect
|
||||||
|
else "viewer_wifi_provisioning",
|
||||||
)
|
)
|
||||||
session_dir.mkdir(parents=True, exist_ok=False)
|
session_dir.mkdir(parents=True, exist_ok=False)
|
||||||
self._operations.transition(
|
self._operations.transition(
|
||||||
operation.operation_id,
|
operation.operation_id,
|
||||||
"running",
|
"running",
|
||||||
stage_code="ble-provisioning-write",
|
stage_code=(
|
||||||
|
"host-wifi-association" if quick_connect else "ble-provisioning-write"
|
||||||
|
),
|
||||||
message_code="network.provision.running",
|
message_code="network.provision.running",
|
||||||
)
|
)
|
||||||
write_attempted = True
|
network_change_attempted = True
|
||||||
result = await provision_wifi_once(
|
if quick_connect:
|
||||||
request.device_id,
|
started_at = _utc_now_iso()
|
||||||
request.ssid,
|
association = await asyncio.to_thread(
|
||||||
password,
|
associate_with_wifi_once,
|
||||||
timeout_seconds=45.0,
|
self.repository_root
|
||||||
write_mode="auto",
|
/ "plugins"
|
||||||
)
|
/ "xgrids-k1"
|
||||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
/ "macos"
|
||||||
ipv4 = _provisioned_ipv4(result)
|
/ "associate_wifi.swift",
|
||||||
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
|
request.ssid,
|
||||||
write_json_atomic(
|
password,
|
||||||
session_dir / "manifest.redacted.json",
|
timeout_seconds=45.0,
|
||||||
{
|
)
|
||||||
|
completed_at = _utc_now_iso()
|
||||||
|
ipv4: str | None = AP_FALLBACK_IPV4
|
||||||
|
connection_manifest: dict[str, Any] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"started_at_utc": started_at,
|
||||||
|
"completed_at_utc": completed_at,
|
||||||
|
"operation": "single_corewlan_k1_ap_association",
|
||||||
|
"connection_mode": request.connection_mode,
|
||||||
|
"topology": request.compatibility_attestation.topology,
|
||||||
|
"outcome": association["outcome"],
|
||||||
|
"credentials_persisted_by_connector": False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result = await provision_wifi_once(
|
||||||
|
request.device_id,
|
||||||
|
request.ssid,
|
||||||
|
password,
|
||||||
|
timeout_seconds=45.0,
|
||||||
|
write_mode="auto",
|
||||||
|
)
|
||||||
|
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||||
|
ipv4 = _provisioned_ipv4(result)
|
||||||
|
connection_manifest = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"started_at_utc": result["started_at_utc"],
|
"started_at_utc": result["started_at_utc"],
|
||||||
"completed_at_utc": result["completed_at_utc"],
|
"completed_at_utc": result["completed_at_utc"],
|
||||||
"operation": "single_reviewed_wifi_provisioning_write",
|
"operation": "single_reviewed_wifi_provisioning_write",
|
||||||
"profile_id": result["profile_id"],
|
"profile_id": result["profile_id"],
|
||||||
|
"connection_mode": request.connection_mode,
|
||||||
|
"topology": request.compatibility_attestation.topology,
|
||||||
"outcome": result["outcome"],
|
"outcome": result["outcome"],
|
||||||
"k1_lan_address_observed": ipv4 is not None,
|
|
||||||
"k1_lan_address_admitted": ipv4 is not None
|
|
||||||
and not local_address_conflict,
|
|
||||||
"local_address_conflict": local_address_conflict,
|
|
||||||
"credentials_persisted_by_connector": False,
|
"credentials_persisted_by_connector": False,
|
||||||
},
|
}
|
||||||
|
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
|
||||||
|
connection_manifest.update(
|
||||||
|
{
|
||||||
|
"k1_lan_address_observed": ipv4 is not None,
|
||||||
|
"k1_lan_address_admitted": (
|
||||||
|
ipv4 is not None and not local_address_conflict
|
||||||
|
),
|
||||||
|
"local_address_conflict": local_address_conflict,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest)
|
||||||
if ipv4 is None:
|
if ipv4 is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||||
|
|
@ -730,9 +825,21 @@ class XgridsK1CompatibilityService:
|
||||||
self._compatibility_attestation = _attestation_snapshot(
|
self._compatibility_attestation = _attestation_snapshot(
|
||||||
request.compatibility_attestation
|
request.compatibility_attestation
|
||||||
)
|
)
|
||||||
self._operation_message = (
|
self._connection_verification = {
|
||||||
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
"status": "not-probed",
|
||||||
)
|
"endpoint_validation": "not-performed",
|
||||||
|
"network_reachability": "unknown",
|
||||||
|
"observed_at": None,
|
||||||
|
}
|
||||||
|
self._operation_message = {
|
||||||
|
"bridge": "K1 подключён к общей сети и сообщил локальный адрес.",
|
||||||
|
"direct-connect": (
|
||||||
|
"K1 подключён к хотспоту контроллера и сообщил локальный адрес."
|
||||||
|
),
|
||||||
|
"quick-connect": (
|
||||||
|
"Mission Core подключён к точке доступа K1; адрес K1 подтверждён."
|
||||||
|
),
|
||||||
|
}[request.connection_mode]
|
||||||
self._operations.transition(
|
self._operations.transition(
|
||||||
operation.operation_id,
|
operation.operation_id,
|
||||||
"succeeded",
|
"succeeded",
|
||||||
|
|
@ -742,6 +849,13 @@ class XgridsK1CompatibilityService:
|
||||||
"device_id": self._device_id,
|
"device_id": self._device_id,
|
||||||
"device_session_id": self._device_session_id,
|
"device_session_id": self._device_session_id,
|
||||||
"lan_address_observed": True,
|
"lan_address_observed": True,
|
||||||
|
"connection_mode": request.connection_mode,
|
||||||
|
"topology": request.compatibility_attestation.topology,
|
||||||
|
"address_source": (
|
||||||
|
"reviewed-k1-ap-baseline"
|
||||||
|
if quick_connect
|
||||||
|
else "ble-wifi-status"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
evidence_refs=(f"evidence-session-{session_dir.name}",),
|
evidence_refs=(f"evidence-session-{session_dir.name}",),
|
||||||
)
|
)
|
||||||
|
|
@ -753,9 +867,11 @@ class XgridsK1CompatibilityService:
|
||||||
message_code="network.provision.failed",
|
message_code="network.provision.failed",
|
||||||
error=_operation_error(
|
error=_operation_error(
|
||||||
exc,
|
exc,
|
||||||
category="device",
|
category="transport" if quick_connect else "device",
|
||||||
side_effect_status="unknown" if write_attempted else "none",
|
side_effect_status=(
|
||||||
safe_to_retry=not write_attempted,
|
"unknown" if network_change_attempted else "none"
|
||||||
|
),
|
||||||
|
safe_to_retry=not network_change_attempted,
|
||||||
),
|
),
|
||||||
evidence_refs=(
|
evidence_refs=(
|
||||||
(f"evidence-session-{session_dir.name}",) if session_dir is not None else ()
|
(f"evidence-session-{session_dir.name}",) if session_dir is not None else ()
|
||||||
|
|
@ -853,7 +969,7 @@ class XgridsK1CompatibilityService:
|
||||||
if self._k1_ip is None or self._selected_device_id is None:
|
if self._k1_ip is None or self._selected_device_id is None:
|
||||||
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||||
if self._compatibility_attestation is None:
|
if self._compatibility_attestation is None:
|
||||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
|
raise RuntimeError("exact FW 3.0.2 local-network profile не выбран")
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -908,10 +1024,24 @@ class XgridsK1CompatibilityService:
|
||||||
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
||||||
)
|
)
|
||||||
target = validate_private_ipv4(target)
|
target = validate_private_ipv4(target)
|
||||||
if target == AP_FALLBACK_IPV4:
|
with self._lock:
|
||||||
|
connection_mode = self._connection_mode
|
||||||
|
if connection_mode is None and request.compatibility_attestation.topology != "direct-lan":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"адрес точки доступа устройства нельзя использовать как direct-LAN target"
|
"не-direct-LAN topology требует сначала завершить выбранный connection flow"
|
||||||
)
|
)
|
||||||
|
if connection_mode is not None:
|
||||||
|
expected_topology = CONNECTION_TOPOLOGY_BY_MODE[connection_mode]
|
||||||
|
if request.compatibility_attestation.topology != expected_topology:
|
||||||
|
raise ValueError(
|
||||||
|
"compatibility attestation не совпадает с активным способом подключения"
|
||||||
|
)
|
||||||
|
if target == AP_FALLBACK_IPV4 and connection_mode != "quick-connect":
|
||||||
|
raise ValueError(
|
||||||
|
"адрес точки доступа K1 разрешён только после Quick Connect"
|
||||||
|
)
|
||||||
|
if connection_mode == "quick-connect" and target != AP_FALLBACK_IPV4:
|
||||||
|
raise ValueError("Quick Connect должен использовать подтверждённый AP-адрес K1")
|
||||||
if control_mode == "plugin-commanded":
|
if control_mode == "plugin-commanded":
|
||||||
with self._lock:
|
with self._lock:
|
||||||
control_target = self._k1_ip
|
control_target = self._k1_ip
|
||||||
|
|
@ -1830,6 +1960,7 @@ class XgridsK1CompatibilityService:
|
||||||
current_session_id = self._device_session_id
|
current_session_id = self._device_session_id
|
||||||
target = self._k1_ip
|
target = self._k1_ip
|
||||||
attestation = self._compatibility_attestation
|
attestation = self._compatibility_attestation
|
||||||
|
connection_mode = self._connection_mode
|
||||||
if current_session_id is None or device_session_id != current_session_id:
|
if current_session_id is None or device_session_id != current_session_id:
|
||||||
raise ValueError("указана неактивная device-сессия")
|
raise ValueError("указана неактивная device-сессия")
|
||||||
if attestation is None:
|
if attestation is None:
|
||||||
|
|
@ -1837,8 +1968,10 @@ class XgridsK1CompatibilityService:
|
||||||
if target is None:
|
if target is None:
|
||||||
raise ValueError("у плагина нет подтверждённого локального адреса K1")
|
raise ValueError("у плагина нет подтверждённого локального адреса K1")
|
||||||
target = validate_private_ipv4(target)
|
target = validate_private_ipv4(target)
|
||||||
if target == AP_FALLBACK_IPV4:
|
if target == AP_FALLBACK_IPV4 and connection_mode != "quick-connect":
|
||||||
raise ValueError("адрес точки доступа K1 нельзя использовать как camera target")
|
raise ValueError("адрес точки доступа K1 не принят для этой device-сессии")
|
||||||
|
if connection_mode == "quick-connect" and target != AP_FALLBACK_IPV4:
|
||||||
|
raise ValueError("Quick Connect camera target не совпадает с AP-адресом K1")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
|
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
|
||||||
|
|
@ -2647,7 +2780,7 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||||
|
|
||||||
plugin_root = repository_root.resolve() / "plugins" / "xgrids-k1"
|
plugin_root = repository_root.resolve() / "plugins" / "xgrids-k1"
|
||||||
loader_path = plugin_root / "profile_loader.py"
|
loader_path = plugin_root / "profile_loader.py"
|
||||||
profile_path = plugin_root / "profiles" / "fw-3.0.2" / "direct-lan.v1.json"
|
profile_path = plugin_root / "profiles" / "fw-3.0.2" / "local-network.v2.json"
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
"missioncore_installed_xgrids_k1_profile_loader",
|
"missioncore_installed_xgrids_k1_profile_loader",
|
||||||
loader_path,
|
loader_path,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class HostWifiAssociationResult(TypedDict):
|
||||||
|
schema_version: int
|
||||||
|
adapter: str
|
||||||
|
outcome: str
|
||||||
|
already_associated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class HostWifiAssociationError(RuntimeError):
|
||||||
|
"""One bounded host-side Wi-Fi association attempt failed."""
|
||||||
|
|
||||||
|
def __init__(self, reason_code: str) -> None:
|
||||||
|
self.reason_code = reason_code
|
||||||
|
super().__init__(f"macOS Wi-Fi association failed: {reason_code}")
|
||||||
|
|
||||||
|
|
||||||
|
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||||
|
|
||||||
|
|
||||||
|
def associate_with_wifi_once(
|
||||||
|
helper_path: Path,
|
||||||
|
ssid: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 45.0,
|
||||||
|
runner: RunProcess = subprocess.run,
|
||||||
|
) -> HostWifiAssociationResult:
|
||||||
|
"""Associate the Mac with one operator-selected Wi-Fi network exactly once.
|
||||||
|
|
||||||
|
The credential is sent to the short-lived CoreWLAN helper through stdin. It
|
||||||
|
never appears in argv, the environment, stdout, stderr, or a persisted
|
||||||
|
artifact. The helper performs at most one scan and one association call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
raise HostWifiAssociationError("unsupported-platform")
|
||||||
|
if not helper_path.is_file():
|
||||||
|
raise HostWifiAssociationError("corewlan-helper-missing")
|
||||||
|
if not 1 <= len(ssid.encode("utf-8")) <= 32:
|
||||||
|
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||||
|
if not 1 <= len(password.encode("utf-8")) <= 64:
|
||||||
|
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
|
||||||
|
request_bytes = bytearray(
|
||||||
|
json.dumps(
|
||||||
|
{"ssid": ssid, "password": password},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed = runner(
|
||||||
|
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
|
||||||
|
input=request_bytes,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise HostWifiAssociationError("corewlan-helper-unavailable") from exc
|
||||||
|
finally:
|
||||||
|
request_bytes[:] = b"\x00" * len(request_bytes)
|
||||||
|
|
||||||
|
if len(completed.stdout) > 4096:
|
||||||
|
raise HostWifiAssociationError("corewlan-response-too-large")
|
||||||
|
try:
|
||||||
|
response: Any = json.loads(completed.stdout.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise HostWifiAssociationError("corewlan-response-invalid") from exc
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||||
|
|
||||||
|
reason_code = response.get("reason_code")
|
||||||
|
if completed.returncode != 0 or response.get("ok") is not True:
|
||||||
|
if not isinstance(reason_code, str) or not reason_code:
|
||||||
|
reason_code = "corewlan-association-failed"
|
||||||
|
raise HostWifiAssociationError(reason_code)
|
||||||
|
|
||||||
|
already_associated = response.get("already_associated")
|
||||||
|
if not isinstance(already_associated, bool):
|
||||||
|
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"adapter": "CoreWLAN",
|
||||||
|
"outcome": "already-associated" if already_associated else "associated",
|
||||||
|
"already_associated": already_associated,
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
|
||||||
MAX_HEADER_BYTES = 4 * 1024
|
MAX_HEADER_BYTES = 4 * 1024
|
||||||
MAX_TEXT_BYTES = 4 * 1024
|
MAX_TEXT_BYTES = 4 * 1024
|
||||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||||
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"
|
||||||
REVIEWED_DEVICE_MODEL = "LixelKity K1"
|
REVIEWED_DEVICE_MODEL = "LixelKity K1"
|
||||||
REVIEWED_DEVICE_TYPE = "A4"
|
REVIEWED_DEVICE_TYPE = "A4"
|
||||||
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
|
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
|
||||||
|
|
@ -73,8 +73,10 @@ class ApplicationControlAuthority:
|
||||||
|
|
||||||
openapi_key: str = field(repr=False)
|
openapi_key: str = field(repr=False)
|
||||||
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
|
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
|
||||||
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
|
compatibility_profile_id: Literal[
|
||||||
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
|
] = (
|
||||||
|
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -292,11 +292,12 @@ class ReviewedApplicationMqttTransport:
|
||||||
port: int = 1883,
|
port: int = 1883,
|
||||||
connect_timeout_seconds: float = CONTROL_CONNECT_TIMEOUT_SECONDS,
|
connect_timeout_seconds: float = CONTROL_CONNECT_TIMEOUT_SECONDS,
|
||||||
exchange_timeout_seconds: float = CONTROL_EXCHANGE_TIMEOUT_SECONDS,
|
exchange_timeout_seconds: float = CONTROL_EXCHANGE_TIMEOUT_SECONDS,
|
||||||
|
allow_device_ap: bool = False,
|
||||||
client_factory: Callable[[], mqtt.Client] | None = None,
|
client_factory: Callable[[], mqtt.Client] | None = None,
|
||||||
monotonic: Callable[[], float] = time.monotonic,
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._target_ipv4 = validate_private_ipv4(host)
|
self._target_ipv4 = validate_private_ipv4(host)
|
||||||
if self._target_ipv4 == AP_FALLBACK_IPV4:
|
if self._target_ipv4 == AP_FALLBACK_IPV4 and not allow_device_ap:
|
||||||
raise ValueError("K1 access-point fallback address is not a direct-LAN control target")
|
raise ValueError("K1 access-point fallback address is not a direct-LAN control target")
|
||||||
if not 1 <= port <= 65535:
|
if not 1 <= port <= 65535:
|
||||||
raise ValueError("port must be between 1 and 65535")
|
raise ValueError("port must be between 1 and 65535")
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,12 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||||
)
|
)
|
||||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||||
assert plugin["metadata"]["version"] == "0.5.0"
|
assert plugin["metadata"]["version"] == "0.6.0"
|
||||||
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
||||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||||
{
|
{
|
||||||
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||||
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
|
"path": "profiles/fw-3.0.2/local-network.v2.json",
|
||||||
"modelId": "xgrids.lixelkity-k1",
|
"modelId": "xgrids.lixelkity-k1",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -125,14 +125,14 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
} <= action_ids
|
} <= action_ids
|
||||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"pluginVersion": "0.5.0",
|
"pluginVersion": "0.6.0",
|
||||||
"id": "xgrids.lixelkity-k1",
|
"id": "xgrids.lixelkity-k1",
|
||||||
"vendor": "XGRIDS",
|
"vendor": "XGRIDS",
|
||||||
"displayName": "XGRIDS LixelKity K1",
|
"displayName": "XGRIDS LixelKity K1",
|
||||||
"category": "Мобильный лидарный сканер",
|
"category": "Мобильный лидарный сканер",
|
||||||
"description": (
|
"description": (
|
||||||
"Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, "
|
"Локальный профиль Bridge, Quick Connect и Direct Connect: "
|
||||||
"облако точек, поза и raw-first запись."
|
"BLE/CoreWLAN, MQTT, камеры и raw-first запись."
|
||||||
),
|
),
|
||||||
"verified": True,
|
"verified": True,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
|
@ -141,6 +141,10 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
"id": "device.provisioning.wifi-over-ble",
|
"id": "device.provisioning.wifi-over-ble",
|
||||||
"label": "Wi-Fi через BLE",
|
"label": "Wi-Fi через BLE",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "host.network.wifi-associate-local",
|
||||||
|
"label": "Подключение к точке доступа K1",
|
||||||
|
},
|
||||||
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
|
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
|
||||||
{"id": "spatial.pose.live", "label": "Траектория"},
|
{"id": "spatial.pose.live", "label": "Траектория"},
|
||||||
{"id": "device.modeling.live", "label": "Метрики маршрута"},
|
{"id": "device.modeling.live", "label": "Метрики маршрута"},
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,16 @@ ATTESTATION = CompatibilityAttestationRequest(
|
||||||
topology="direct-lan",
|
topology="direct-lan",
|
||||||
verification="live-device-info",
|
verification="live-device-info",
|
||||||
)
|
)
|
||||||
|
QUICK_CONNECT_ATTESTATION = CompatibilityAttestationRequest(
|
||||||
|
firmware_version="3.0.2",
|
||||||
|
topology="device-ap",
|
||||||
|
verification="live-device-info",
|
||||||
|
)
|
||||||
|
DIRECT_CONNECT_ATTESTATION = CompatibilityAttestationRequest(
|
||||||
|
firmware_version="3.0.2",
|
||||||
|
topology="controller-hotspot",
|
||||||
|
verification="live-device-info",
|
||||||
|
)
|
||||||
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
||||||
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
||||||
PROJECT_NAME = "K1 lifecycle test"
|
PROJECT_NAME = "K1 lifecycle test"
|
||||||
|
|
@ -225,15 +235,48 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
|
||||||
host="192.168.1.20",
|
host="192.168.1.20",
|
||||||
compatibility_attestation=ATTESTATION,
|
compatibility_attestation=ATTESTATION,
|
||||||
)
|
)
|
||||||
def test_only_physically_accepted_configuration_values_are_admitted() -> None:
|
|
||||||
|
|
||||||
|
def test_connection_modes_require_their_exact_topology_attestation() -> None:
|
||||||
|
assert ConnectRequest(
|
||||||
|
device_id="synthetic-device",
|
||||||
|
ssid="synthetic-network",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
|
).connection_mode == "quick-connect"
|
||||||
|
assert ConnectRequest(
|
||||||
|
device_id="synthetic-device",
|
||||||
|
ssid="synthetic-network",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="direct-connect",
|
||||||
|
compatibility_attestation=DIRECT_CONNECT_ATTESTATION,
|
||||||
|
).connection_mode == "direct-connect"
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
ConnectRequest(
|
ConnectRequest(
|
||||||
device_id="synthetic-device",
|
device_id="synthetic-device",
|
||||||
ssid="synthetic-network",
|
ssid="synthetic-network",
|
||||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
connection_mode="quick-connect", # type: ignore[arg-type]
|
connection_mode="quick-connect",
|
||||||
compatibility_attestation=ATTESTATION,
|
compatibility_attestation=ATTESTATION,
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValidationError, match="32 UTF-8 bytes"):
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="synthetic-device",
|
||||||
|
ssid="🛰️" * 9,
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError, match="64 UTF-8 bytes"):
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="synthetic-device",
|
||||||
|
ssid="synthetic-network",
|
||||||
|
password=SecretStr("🔒" * 17),
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_physically_accepted_mount_and_gnss_values_are_admitted() -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
PrepareAcquisitionRequest(
|
PrepareAcquisitionRequest(
|
||||||
project_name=PROJECT_NAME,
|
project_name=PROJECT_NAME,
|
||||||
|
|
@ -1372,15 +1415,15 @@ def test_exact_profile_is_inactive_until_selected_for_live_device_info_verificat
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_rejects_device_ap_fallback_as_direct_lan_target(tmp_path: Path) -> None:
|
def test_prepare_rejects_device_ap_without_completed_quick_connect(tmp_path: Path) -> None:
|
||||||
service, _ = service_with_fake_runtime(tmp_path)
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="точки доступа"):
|
with pytest.raises(ValueError, match="connection flow"):
|
||||||
service.prepare_acquisition(
|
service.prepare_acquisition(
|
||||||
PrepareAcquisitionRequest(
|
PrepareAcquisitionRequest(
|
||||||
project_name=PROJECT_NAME,
|
project_name=PROJECT_NAME,
|
||||||
host="192.168.56.1",
|
host="192.168.56.1",
|
||||||
compatibility_attestation=ATTESTATION,
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1812,6 +1855,165 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_connect_associates_the_host_without_a_ble_write(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||||
|
association_calls: list[tuple[Path, str, str]] = []
|
||||||
|
|
||||||
|
def fake_associate(
|
||||||
|
helper_path: Path,
|
||||||
|
ssid: str,
|
||||||
|
password: str,
|
||||||
|
**_: object,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
association_calls.append((helper_path, ssid, password))
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"adapter": "CoreWLAN",
|
||||||
|
"outcome": "associated",
|
||||||
|
"already_associated": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def forbidden_ble_write(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
raise AssertionError("Quick Connect must not provision the K1 over BLE")
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "associate_with_wifi_once", fake_associate)
|
||||||
|
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_ble_write)
|
||||||
|
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||||
|
|
||||||
|
state = asyncio.run(
|
||||||
|
service.connect(
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="k1-a",
|
||||||
|
ssid="XGR-TEST",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(association_calls) == 1
|
||||||
|
assert association_calls[0][0].name == "associate_wifi.swift"
|
||||||
|
assert association_calls[0][1:] == ("XGR-TEST", PRIMARY_TEST_CREDENTIAL)
|
||||||
|
assert state["connection_mode"] == "quick-connect"
|
||||||
|
assert state["k1_ip"] == "192.168.56.1"
|
||||||
|
assert state["compatibility"]["attestation"]["topology"] == "device-ap"
|
||||||
|
quick_sessions = sorted(service.evidence_root.glob("*viewer_k1_ap_association*"))
|
||||||
|
assert len(quick_sessions) == 1
|
||||||
|
assert not (quick_sessions[0] / "provisioning.sensitive.json").exists()
|
||||||
|
redacted_manifest = (quick_sessions[0] / "manifest.redacted.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert PRIMARY_TEST_CREDENTIAL not in redacted_manifest
|
||||||
|
assert "XGR-TEST" not in redacted_manifest
|
||||||
|
assert service._camera_target_for_session( # noqa: SLF001
|
||||||
|
state["device_session"]["device_session_id"]
|
||||||
|
) == "192.168.56.1"
|
||||||
|
|
||||||
|
prepared = service.prepare_acquisition(
|
||||||
|
PrepareAcquisitionRequest(
|
||||||
|
project_name=PROJECT_NAME,
|
||||||
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert prepared["acquisition"]["target_host"] == "192.168.56.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||||
|
provisioning_calls: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
async def fake_provision(
|
||||||
|
device_id: str,
|
||||||
|
ssid: str,
|
||||||
|
password: str,
|
||||||
|
**_: object,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
provisioning_calls.append((device_id, ssid, password))
|
||||||
|
return {
|
||||||
|
"started_at_utc": "2026-07-19T01:00:00Z",
|
||||||
|
"completed_at_utc": "2026-07-19T01:00:01Z",
|
||||||
|
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||||
|
"outcome": "lan_address_observed",
|
||||||
|
"observations": [{"status": {"ipv4": "172.20.10.2"}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def forbidden_host_association(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
raise AssertionError("Direct Connect must not switch the host Wi-Fi network")
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
facade_module,
|
||||||
|
"associate_with_wifi_once",
|
||||||
|
forbidden_host_association,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||||
|
|
||||||
|
state = asyncio.run(
|
||||||
|
service.connect(
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="k1-a",
|
||||||
|
ssid="controller-hotspot",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="direct-connect",
|
||||||
|
compatibility_attestation=DIRECT_CONNECT_ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert provisioning_calls == [
|
||||||
|
("k1-a", "controller-hotspot", PRIMARY_TEST_CREDENTIAL)
|
||||||
|
]
|
||||||
|
assert state["connection_mode"] == "direct-connect"
|
||||||
|
assert state["k1_ip"] == "172.20.10.2"
|
||||||
|
assert state["compatibility"]["attestation"]["topology"] == (
|
||||||
|
"controller-hotspot"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_connection_change_revokes_the_previous_route(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||||
|
service._selected_device_id = "previous-k1" # noqa: SLF001
|
||||||
|
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||||
|
service._connection_mode = "bridge" # noqa: SLF001
|
||||||
|
|
||||||
|
def failed_association(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
raise RuntimeError("offline association fixture failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "associate_with_wifi_once", failed_association)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="offline association fixture failed"):
|
||||||
|
asyncio.run(
|
||||||
|
service.connect(
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="k1-a",
|
||||||
|
ssid="XGR-OFFLINE",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
state = service.state()
|
||||||
|
assert state["selected_device_id"] is None
|
||||||
|
assert state["k1_ip"] is None
|
||||||
|
assert state["connection_mode"] is None
|
||||||
|
assert state["compatibility"]["attestation"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,15 @@ def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
|
||||||
ReviewedApplicationMqttTransport("192.168.56.1")
|
ReviewedApplicationMqttTransport("192.168.56.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_transport_admits_the_k1_ap_only_with_an_explicit_gate() -> None:
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.56.1",
|
||||||
|
allow_device_ap=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transport.snapshot().state == "new"
|
||||||
|
|
||||||
|
|
||||||
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
|
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
|
||||||
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
|
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
|
||||||
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
|
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,29 @@ def _boolean_write_flags(value: Any) -> list[bool]:
|
||||||
def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None:
|
def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None:
|
||||||
profile = LOADER.load_compatibility_profile()
|
profile = LOADER.load_compatibility_profile()
|
||||||
|
|
||||||
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
assert profile["scope"]["vendor"] == "XGRIDS"
|
assert profile["scope"]["vendor"] == "XGRIDS"
|
||||||
assert profile["scope"]["model"] == "LixelKity K1"
|
assert profile["scope"]["model"] == "LixelKity K1"
|
||||||
assert profile["scope"]["platform_type"] == "A4"
|
assert profile["scope"]["platform_type"] == "A4"
|
||||||
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
||||||
assert profile["scope"]["topology"] == "direct-lan"
|
assert profile["scope"]["topology"] == "local-network-matrix"
|
||||||
|
modes = _by_id(profile["scope"]["connection_modes"])
|
||||||
|
assert {
|
||||||
|
mode_id: mode["topology"] for mode_id, mode in modes.items()
|
||||||
|
} == {
|
||||||
|
"bridge": "direct-lan",
|
||||||
|
"direct-connect": "controller-hotspot",
|
||||||
|
"quick-connect": "device-ap",
|
||||||
|
}
|
||||||
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
||||||
|
assert LOADER.matches_target(profile, firmware="3.0.2", topology="device-ap")
|
||||||
|
assert LOADER.matches_target(
|
||||||
|
profile,
|
||||||
|
firmware="3.0.2",
|
||||||
|
topology="controller-hotspot",
|
||||||
|
)
|
||||||
assert not LOADER.matches_target(profile, firmware="3.0.3", topology="direct-lan")
|
assert not LOADER.matches_target(profile, firmware="3.0.3", topology="direct-lan")
|
||||||
assert not LOADER.matches_target(profile, firmware="3.0.2", topology="device-ap")
|
assert not LOADER.matches_target(profile, firmware="3.0.2", topology="usb")
|
||||||
|
|
||||||
for source in profile["evidence_sources"]:
|
for source in profile["evidence_sources"]:
|
||||||
assert (REPOSITORY_ROOT / source["path"]).is_file()
|
assert (REPOSITORY_ROOT / source["path"]).is_file()
|
||||||
|
|
@ -119,7 +133,7 @@ def test_xgrids_compatibility_profile_declares_only_reviewed_transports() -> Non
|
||||||
assert ble["evidence"]["physical_verified"] is True
|
assert ble["evidence"]["physical_verified"] is True
|
||||||
assert ble["evidence"]["write_enabled"] is False
|
assert ble["evidence"]["write_enabled"] is False
|
||||||
|
|
||||||
mqtt = transports["mqtt.direct-lan.fw3.v1"]
|
mqtt = transports["mqtt.local-ipv4.fw3.v1"]
|
||||||
assert mqtt["protocol"] == "MQTT 3.1.1"
|
assert mqtt["protocol"] == "MQTT 3.1.1"
|
||||||
assert mqtt["network"]["transport"] == "TCP"
|
assert mqtt["network"]["transport"] == "TCP"
|
||||||
assert mqtt["network"]["port"] == 1883
|
assert mqtt["network"]["port"] == 1883
|
||||||
|
|
@ -205,6 +219,18 @@ def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
|
||||||
LOADER.validate_compatibility_profile(modified)
|
LOADER.validate_compatibility_profile(modified)
|
||||||
|
|
||||||
|
|
||||||
|
def test_xgrids_compatibility_profile_rejects_connection_matrix_drift() -> None:
|
||||||
|
profile = LOADER.load_compatibility_profile()
|
||||||
|
modified = copy.deepcopy(profile)
|
||||||
|
modes = _by_id(modified["scope"]["connection_modes"])
|
||||||
|
modes["quick-connect"]["device_network_action"] = (
|
||||||
|
"unreviewed-ble-credential-read"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(LOADER.CompatibilityProfileError, match="differs"):
|
||||||
|
LOADER.validate_compatibility_profile(modified)
|
||||||
|
|
||||||
|
|
||||||
def test_xgrids_compatibility_profile_rejects_noncanonical_modeling_header() -> None:
|
def test_xgrids_compatibility_profile_rejects_noncanonical_modeling_header() -> None:
|
||||||
profile = LOADER.load_compatibility_profile()
|
profile = LOADER.load_compatibility_profile()
|
||||||
modified = copy.deepcopy(profile)
|
modified = copy.deepcopy(profile)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1 import macos_wifi
|
||||||
|
|
||||||
|
TEST_PASSWORD = "fixture-only-network-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def _helper(tmp_path: Path) -> Path:
|
||||||
|
helper = tmp_path / "associate_wifi.swift"
|
||||||
|
helper.write_text("// offline fixture\n", encoding="utf-8")
|
||||||
|
return helper
|
||||||
|
|
||||||
|
|
||||||
|
def test_association_passes_secret_only_through_stdin(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(macos_wifi.sys, "platform", "darwin")
|
||||||
|
calls: list[dict[str, object]] = []
|
||||||
|
live_inputs: list[bytearray] = []
|
||||||
|
|
||||||
|
def fake_runner(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
assert isinstance(kwargs["input"], bytearray)
|
||||||
|
live_inputs.append(kwargs["input"])
|
||||||
|
calls.append(
|
||||||
|
{
|
||||||
|
"argv": argv,
|
||||||
|
**kwargs,
|
||||||
|
"input": bytes(kwargs["input"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
argv,
|
||||||
|
0,
|
||||||
|
stdout=b'{"ok":true,"already_associated":false}',
|
||||||
|
stderr=b"",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = macos_wifi.associate_with_wifi_once(
|
||||||
|
_helper(tmp_path),
|
||||||
|
"XGR-OFFLINE",
|
||||||
|
TEST_PASSWORD,
|
||||||
|
runner=fake_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"schema_version": 1,
|
||||||
|
"adapter": "CoreWLAN",
|
||||||
|
"outcome": "associated",
|
||||||
|
"already_associated": False,
|
||||||
|
}
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
assert call["argv"][:2] == ["/usr/bin/xcrun", "swift"]
|
||||||
|
assert TEST_PASSWORD not in " ".join(call["argv"])
|
||||||
|
request = json.loads(bytes(call["input"]).decode("utf-8"))
|
||||||
|
assert request == {"ssid": "XGR-OFFLINE", "password": TEST_PASSWORD}
|
||||||
|
assert call["check"] is False
|
||||||
|
assert call["timeout"] == 45.0
|
||||||
|
assert live_inputs and not any(live_inputs[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_association_reports_only_sanitized_helper_reason(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(macos_wifi.sys, "platform", "darwin")
|
||||||
|
|
||||||
|
def fake_runner(argv: list[str], **_: object) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
argv,
|
||||||
|
1,
|
||||||
|
stdout=b'{"ok":false,"reason_code":"network-not-found"}',
|
||||||
|
stderr=f"private diagnostic {TEST_PASSWORD}".encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
macos_wifi.HostWifiAssociationError,
|
||||||
|
match="network-not-found",
|
||||||
|
) as raised:
|
||||||
|
macos_wifi.associate_with_wifi_once(
|
||||||
|
_helper(tmp_path),
|
||||||
|
"XGR-OFFLINE",
|
||||||
|
TEST_PASSWORD,
|
||||||
|
runner=fake_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert TEST_PASSWORD not in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_association_is_macos_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(macos_wifi.sys, "platform", "linux")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
macos_wifi.HostWifiAssociationError,
|
||||||
|
match="unsupported-platform",
|
||||||
|
):
|
||||||
|
macos_wifi.associate_with_wifi_once(
|
||||||
|
_helper(tmp_path),
|
||||||
|
"XGR-OFFLINE",
|
||||||
|
TEST_PASSWORD,
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue