Compare commits

...

2 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 75d2e5da4b docs(k1): record connection mode acceptance 2026-07-19 09:52:33 +03:00
DCCONSTRUCTIONS 57b2208cae feat(k1): add local connection matrix 2026-07-19 09:52:22 +03:00
29 changed files with 1536 additions and 137 deletions

View File

@ -130,7 +130,8 @@ uv run k1link serve
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
BLE Wi-Fi provisioning write, a one-shot CoreWLAN K1-AP association path,
read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage, device-reported scan
time/distance/speed and measured preview metrics. Its frontend contribution now
also owns an optional spatial-scene control block, including acquisition phase,
@ -138,6 +139,15 @@ telemetry and the stop action. Operator-manual acquisition still finalizes only
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
canonical K1 START/STOP dialogue.
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
default; Direct Connect sends the same reviewed BLE frame with credentials for
an already-running controller hotspot; Quick Connect sends no BLE write and
instead associates this Mac with the K1 AP once. Each mode has a distinct
topology attestation and no automatic retry. The Bridge path is physically
accepted. Direct Connect and the Mission Core Quick Connect host adapter are
implemented and offline-verified but still require their own owner-operated
physical acceptance cycles; see [ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
The exact recovered `ModelingRequest` start/stop encoder, response correlator
and device-status state machine are installed in the operator-gated interactive
control path.

View File

@ -320,8 +320,10 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
случайные GATT writes и автоматические повторы запрещены.
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
повторы запрещены.
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
plugin-owned canonical control session и доступны только через отдельные
operator-present UI checkpoints.

View File

@ -18,6 +18,7 @@ let automaticSourceStart;
let presentation;
let operatorIntentGeneration;
let configuration;
let compatibility;
before(async () => {
server = await createServer({
@ -52,6 +53,9 @@ before(async () => {
configuration = await server.ssrLoadModule(
"@xgrids-k1/frontend/configuration.ts",
);
compatibility = await server.ssrLoadModule(
"@xgrids-k1/frontend/compatibility.ts",
);
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
@ -204,8 +208,8 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
{
profileId: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
path: "profiles/fw-3.0.2/direct-lan.v1.json",
profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
path: "profiles/fw-3.0.2/local-network.v2.json",
modelId: "xgrids.lixelkity-k1",
},
]);
@ -563,16 +567,16 @@ test("replay ignores a stale failed live acquisition", () => {
);
});
test("K1 configuration anchors expose future modes without enabling them", () => {
assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge");
test("K1 configuration exposes all reviewed local connection directions", () => {
assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge");
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
assert.deepEqual(
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "bridge", disabled: false },
{ value: "quick-connect", disabled: true },
{ value: "direct-connect", disabled: true },
{ value: "quick-connect", disabled: false },
{ value: "direct-connect", disabled: false },
],
);
assert.deepEqual(
@ -594,6 +598,22 @@ test("K1 configuration anchors expose future modes without enabling them", () =>
);
});
test("connection directions select distinct fail-closed topologies", () => {
assert.deepEqual(compatibility.profileSelectionForConnectionMode("bridge"), {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
});
assert.equal(
compatibility.profileSelectionForConnectionMode("quick-connect").topology,
"device-ap",
);
assert.equal(
compatibility.profileSelectionForConnectionMode("direct-connect").topology,
"controller-hotspot",
);
});
test("a provisioned address is not presented as a verified device connection", () => {
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",

View File

@ -401,7 +401,7 @@ function declaredState(cameraRows = [
foxglove_ws_url: "ws://192.168.7.10:8765",
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
compatibility: {
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
},
device_ref: {
@ -413,7 +413,7 @@ function declaredState(cameraRows = [
device_session: {
device_session_id: "device-session-001",
device_id: "device-k1-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
connectivity: "connected",
},
sensor_catalog: {
@ -444,7 +444,7 @@ function pointCloudStreamingState() {
acquisition_id: "acquisition-001",
device_id: "device-k1-001",
device_session_id: "device-session-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
control_mode: "operator-manual",
requested_streams: ["spatial.point-cloud.live"],
target_host: "127.0.0.1",

View File

@ -3,7 +3,7 @@
This plan supersedes the app-dependent experiment order in the reference Bible.
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
## Current checkpoint — 2026-07-18
## Current checkpoint — 2026-07-19
| Stage | Result |
| --- | --- |
@ -11,6 +11,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Gate 1 physical operation | GO — autonomous double-click start/stop verified |
| Stage 1 BLE discovery | GO — repeatable advertisement and GATT profile |
| Stage 2 BLE provisioning | GO — reviewed 99-byte profile, LAN association confirmed |
| Stage 2 local connection matrix | GO (implementation) — Bridge remains physically accepted; Direct Connect reuses the reviewed one-shot BLE frame; Quick Connect uses one CoreWLAN host association and no GATT write. Direct Connect and Mission Core Quick Connect still require separate physical acceptance |
| Stage 3 application session | GO — MQTT 3.1.1 on confirmed K1 TCP 1883 |
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |

View File

@ -13,6 +13,11 @@ that Mission Core reproduces all ten requests before START, plus START and STOP,
byte-for-byte. This describes the current device dialogue but does not authorize
writes to any K1.
The profile identifier above records the accepted v0.5.0 boundary. ADR 0013
supersedes the active runtime selection with
`xgrids.lixelkity-k1.fw-3.0.2.local-network.v2`; it does not change the command
dialogue or retroactively broaden this acceptance evidence.
The protocol exposes identities with different ownership. Mission Core's
inventory UUID and the macOS CoreBluetooth UUID are local transport identities.
The vendor device ID and serial identify the live scanner. The OpenAPI value is

View File

@ -0,0 +1,71 @@
# ADR 0013: explicit K1 local connection matrix
- Status: accepted for implementation; physical acceptance is mode-specific
- Date: 2026-07-19
- Extends: ADR 0004, ADR 0005 and ADR 0012
## Context
Mission Core v0.5.0 exposed only the already accepted shared-LAN path even
though the product UI named the other XGRIDS connection directions. Retained
owner-operated evidence establishes two different network ownership models:
- the K1 joins a network whose credentials are sent in the reviewed 99-byte BLE
provisioning frame; and
- the controller joins the K1 access point and then reaches the same local
MQTT/RTSP data plane at the observed AP address.
Official workflow notes separately name Bridge, Direct Connect and Quick
Connect. The names alone do not authorize another BLE command or an inferred
credential decoder.
## Decision
Plugin v0.6.0 exposes three explicit, fail-closed choices under one exact
firmware `3.0.2` compatibility profile:
| UI mode | Topology | Device action | Host action | Acceptance |
| --- | --- | --- | --- | --- |
| Bridge | `direct-lan` | one reviewed BLE provisioning write | none | physically accepted in Mission Core |
| Direct Connect | `controller-hotspot` | the same reviewed BLE provisioning write | operator prepares the hotspot and route | implementation complete; physical run pending |
| Quick Connect | `device-ap` | none | one CoreWLAN scan and association | LixelGO data plane observed; Mission Core host run pending |
Bridge remains the default. Every API request carries both `connection_mode`
and its exact topology attestation; mismatched pairs are rejected before any
network action. Acquisition and camera admission must match the active mode.
The fixed K1 AP address is admitted only after a successful Quick Connect host
association.
Direct Connect does not introduce a new vendor payload. The operator first
starts a hotspot on the controlling device and enters that hotspot's SSID and
password. Mission Core performs the same single, physically reviewed BLE write
used by Bridge and accepts only the non-AP private IPv4 returned by K1 status.
Quick Connect does not write GATT. A short-lived macOS Swift/CoreWLAN helper
receives the operator-entered AP SSID and password as bounded JSON on stdin.
The credential never appears in argv, environment, stdout, stderr, manifests or
browser persistence. The helper performs at most one scan and one association;
Mission Core does not retry. The implementation intentionally does not infer or
read the K1 AP password from an undocumented BLE structure. Until that read has
its own captured characteristic, bounded decoder and review, the operator must
enter the AP credentials shown for the owner-controlled scanner.
No connection-verification button emits a probe. It validates only the admitted
private address; the later canonical MQTT session supplies the real data-plane
connection attempt. A failed or ambiguous network action remains terminal until
the operator checks physical state and explicitly starts a new operation.
## Consequences
- The UI now represents all three intended directions instead of disabled
placeholders.
- Quick Connect can temporarily remove the Mac from its previous Wi-Fi network.
macOS may require Wi-Fi/location permission for the process running Mission
Core.
- Direct Connect requires an already-running hotspot and a controller route;
Mission Core does not create or manage that hotspot.
- The application-control, START/STOP and raw-first acquisition protocol is
unchanged after a target address is admitted.
- Direct Connect and Mission Core Quick Connect remain explicitly pending one
owner-operated physical acceptance cycle each. Offline tests cannot promote
those claims.

View File

@ -84,8 +84,10 @@ to captured evidence.
## Current supported choices
- Connection: direct LAN is active; alternative connection modes remain disabled
UI anchors until independently accepted.
- Connection at the time of this acceptance: direct LAN only. ADR 0013 later
enabled Bridge, Quick Connect and Direct Connect as distinct v0.6.0 paths;
that later implementation does not retroactively add physical acceptance to
this archived run.
- Mount/scan carrier: handheld is active. Ground UGV and aerial UAV choices are
visible but disabled anchors.
- GNSS: no RTK is the active default. RTK is visible but disabled and optional;

View File

@ -19,7 +19,8 @@ The plugin owns:
- the exact firmware/topology compatibility profiles under
[`profiles/`](profiles/), with strict evidence flags and a fail-closed loader;
- BLE discovery hints and K1 GATT metadata;
- the reviewed firmware-3 Wi-Fi provisioning profile;
- Bridge and Direct Connect through the reviewed firmware-3 BLE Wi-Fi
provisioning frame, plus a one-shot macOS CoreWLAN Quick Connect adapter;
- K1 LAN status and private-address validation;
- subscribe-only data MQTT transport plus a separately bounded canonical
application-control transport;
@ -74,7 +75,8 @@ in-process and its health is lifecycle-only; process isolation, crash/restart
containment and signed deployment remain later supervisor gates.
The compatibility profile is descriptive and cannot itself authorize a vendor
write. The UI selects the exact firmware `3.0.2` / direct-LAN profile; the
write. The UI selects the exact firmware `3.0.2` local-network profile and an
exact topology for Bridge, Quick Connect or Direct Connect; the
canonical bootstrap then requires a correlated live `DeviceInfo` match for
model `LixelKity K1`, platform type `A4`, activation and firmware before START.
Validate the current exact-match profile without device I/O with:
@ -83,7 +85,18 @@ Validate the current exact-match profile without device I/O with:
uv run python plugins/xgrids-k1/profile_loader.py
```
Plugin v0.5.0 installs that reviewed transport behind explicit plugin actions.
Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
the connection matrix behind the existing explicit `network.provision` action.
Bridge remains the default. Direct Connect sends the same single reviewed
99-byte frame with credentials for an already-running controller hotspot. Quick
Connect sends no BLE write: a short-lived Swift/CoreWLAN helper receives the
operator-entered K1 AP credentials only over stdin, performs at most one scan
and one association, and never receives an automatic retry. Mission Core then
uses the observed K1 AP data-plane address. Automatic extraction of AP
credentials from undocumented BLE data is deliberately not implemented.
Plugin v0.5.0 installed the reviewed application-control transport behind
explicit plugin actions.
The normal UI accepts the project name and one explicit launch confirmation.
It then performs operations 16, operation 7 and operations 810 only after the
previous response barrier succeeds, prepares local reception, and emits one

View File

@ -6,15 +6,16 @@ generic application source tree.
The contribution contains:
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the
reviewed Wi-Fi provisioning write;
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the three
explicit local connection directions: Bridge, Quick Connect and Direct
Connect;
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
START checkpoints, local receiver preparation and compatibility file replay;
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
READY plus steady-green completion gate;
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
observation-source mapping and scoped styles;
- typed v0.5.0 interactive application-control state plus legacy shadow
- typed v0.6.0 local-network and interactive application-control state plus legacy shadow
inspection contracts;
- `plugin.ts`, which binds the manifest `device.connection` component key to
the runtime provider and connection view.

View File

@ -65,6 +65,7 @@ export interface XgridsCompatibilityState {
firmware_claim?: string | null;
vendor_writes_enabled?: boolean;
camera_preview?: string | null;
attestation?: CompatibilityAttestation | null;
}
export interface XgridsModelingControlSafety {
@ -289,7 +290,7 @@ export interface XgridsK1State {
devices?: BleDevice[];
selected_device_id?: string | null;
k1_ip?: string | null;
connection_mode?: "bridge" | null;
connection_mode?: "bridge" | "quick-connect" | "direct-connect" | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
@ -322,7 +323,7 @@ export interface ScanRequest {
export interface CompatibilityAttestation {
firmware_version: "3.0.2";
topology: "direct-lan";
topology: "direct-lan" | "device-ap" | "controller-hotspot";
verification: "live-device-info";
}
@ -330,7 +331,7 @@ export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
connection_mode: "bridge";
connection_mode: "bridge" | "quick-connect" | "direct-connect";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;

View File

@ -1,7 +1,24 @@
import type { CompatibilityAttestation } from "./api";
import type { ConnectionMode } from "./configuration";
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
});
const topologyByConnectionMode = {
bridge: "direct-lan",
"quick-connect": "device-ap",
"direct-connect": "controller-hotspot",
} as const satisfies Record<ConnectionMode, CompatibilityAttestation["topology"]>;
export function profileSelectionForConnectionMode(
connectionMode: ConnectionMode,
): CompatibilityAttestation {
return {
firmware_version: "3.0.2",
topology: topologyByConnectionMode[connectionMode],
verification: "live-device-info",
};
}

View File

@ -11,7 +11,7 @@ import {
type StatusTone,
} from "@nodedc/ui-react";
import { EXACT_PROFILE_SELECTION } from "../compatibility";
import { profileSelectionForConnectionMode } from "../compatibility";
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
@ -149,7 +149,9 @@ export function K1AcquisitionPipeline({
project_name: projectNameValidation.value,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
compatibility_attestation: profileSelectionForConnectionMode(
state.connection_mode ?? "bridge",
),
},
physicalAcceptance: PHYSICAL_ACCEPTANCE,
}),

View File

@ -11,9 +11,9 @@ import {
} from "@nodedc/ui-react";
import type { BleDevice } from "../api";
import { EXACT_PROFILE_SELECTION } from "../compatibility";
import { profileSelectionForConnectionMode } from "../compatibility";
import {
SUPPORTED_CONNECTION_MODE,
DEFAULT_CONNECTION_MODE,
connectionModeOptions,
type ConnectionMode,
} from "../configuration";
@ -21,6 +21,36 @@ import { provisioningIntentKey } from "../lifecycle";
import { finiteMetric } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext";
const connectionCopy: Record<ConnectionMode, {
stepTitle: string;
ssidLabel: string;
ssidPlaceholder: string;
buttonLabel: string;
safetyNote: string;
}> = {
bridge: {
stepTitle: "Передайте настройки общей сети",
ssidLabel: "Название общей сети WiFi",
ssidPlaceholder: "Сеть локального контура",
buttonLabel: "Подключить K1 к общей сети",
safetyNote: "K1 получит реквизиты существующей сети одним рассмотренным BLE-запросом без автоматического повтора.",
},
"quick-connect": {
stepTitle: "Подключитесь к точке доступа K1",
ssidLabel: "Название точки доступа K1",
ssidPlaceholder: "SSID сканера, например XGR-…",
buttonLabel: "Подключить этот Mac к K1",
safetyNote: "Введите SSID и пароль точки доступа вашего K1. Mac сменит текущую WiFi сеть одним CoreWLAN-запросом; недокументированный BLE-секрет не читается и K1 не получает BLE-запись.",
},
"direct-connect": {
stepTitle: "Подключите K1 к хотспоту контроллера",
ssidLabel: "Название хотспота контроллера",
ssidPlaceholder: "SSID управляющего устройства",
buttonLabel: "Подключить K1 к хотспоту",
safetyNote: "Хотспот должен быть уже включён, а этот Mac — иметь к нему маршрут. K1 получит его реквизиты одним рассмотренным BLE-запросом.",
},
};
function WizardStep({
number,
title,
@ -96,13 +126,17 @@ export function K1ProvisioningPipeline({
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
SUPPORTED_CONNECTION_MODE,
DEFAULT_CONNECTION_MODE,
);
const provisioningIntentRef = useRef<string | null>(null);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const modeCopy = connectionCopy[connectionMode];
const selectedModeConnected = Boolean(
state?.k1_ip && state.connection_mode === connectionMode,
);
useEffect(() => {
if (state?.selected_device_id) {
@ -117,6 +151,12 @@ export function K1ProvisioningPipeline({
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
useEffect(() => {
if (state?.connection_mode) {
setConnectionMode(state.connection_mode);
}
}, [state?.connection_mode]);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
@ -134,8 +174,8 @@ export function K1ProvisioningPipeline({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
connection_mode: SUPPORTED_CONNECTION_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
connection_mode: connectionMode,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
idempotency_key: idempotencyKey,
});
if (succeeded) {
@ -152,7 +192,7 @@ export function K1ProvisioningPipeline({
</header>
<div className="configuration-anchor">
<span className="nodedc-field__description">
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
Выберите направление связи. Каждый путь выполняет не более одного сетевого изменения и не повторяет его автоматически.
</span>
<Select
label="Способ подключения"
@ -160,6 +200,8 @@ export function K1ProvisioningPipeline({
options={connectionModeOptions}
onChange={(value) => {
setConnectionMode(value);
setSsid("");
setPassword("");
resetProvisioningIntent();
}}
disabled={isBusy}
@ -206,19 +248,19 @@ export function K1ProvisioningPipeline({
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки общей сети"
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
tone={state?.k1_ip ? "warning" : "neutral"}
title={modeCopy.stepTitle}
status={selectedModeConnected ? "Адрес получен" : "Ожидает подключения"}
tone={selectedModeConnected ? "success" : "neutral"}
>
<div className="field-stack">
<TextField label="Название сети WiFi" 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="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</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()}>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
{pendingAction === "connect" ? "Подключаем…" : modeCopy.buttonLabel}
</Button>
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
<p className="safety-note">{modeCopy.safetyNote} Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
</WizardStep>
</div>
</GlassSurface>

View File

@ -4,7 +4,7 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
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_GNSS_MODE = "none" as const satisfies GnssMode;
@ -12,19 +12,17 @@ export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
{
value: "bridge",
label: "Общая сеть · Bridge",
description: "K1 и Mission Core работают в одной локальной сети. Подтверждённый путь.",
description: "Mission Core передаёт K1 реквизиты существующей общей сети.",
},
{
value: "quick-connect",
label: "Точка доступа K1 · Quick Connect",
description: "Mission Core подключается к сети сканера. Будет доступно после отдельной приёмки.",
disabled: true,
description: "Этот Mac один раз подключается к WiFi сканера; K1 остаётся точкой доступа.",
},
{
value: "direct-connect",
label: "Хотспот контроллера · Direct Connect",
description: "K1 подключается к сети управляющего устройства. Будет доступно после отдельной приёмки.",
disabled: true,
description: "Mission Core передаёт K1 реквизиты хотспота управляющего устройства.",
},
];

View File

@ -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
)
}

View File

@ -3,7 +3,7 @@
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.5.0",
"version": "0.6.0",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
@ -14,14 +14,15 @@
},
"compatibilityProfiles": [
{
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
"path": "profiles/fw-3.0.2/local-network.v2.json",
"modelId": "xgrids.lixelkity-k1"
}
],
"permissions": [
"device.discovery.ble",
"device.provisioning.wifi-over-ble",
"host.network.wifi-associate-local",
"network.mqtt.subscribe-private-lan",
"network.mqtt.publish-private-lan",
"network.rtsp.read-private-lan",
@ -61,11 +62,12 @@
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
"category": "Мобильный лидарный сканер",
"description": "Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, облако точек, поза и raw-first запись.",
"description": "Локальный профиль Bridge, Quick Connect и Direct Connect: BLE/CoreWLAN, MQTT, камеры и raw-first запись.",
"verified": true,
"capabilities": [
{ "id": "device.discovery.ble", "label": "Поиск 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.pose.live", "label": "Траектория" },
{ "id": "device.modeling.live", "label": "Метрики маршрута" },

View File

@ -12,8 +12,10 @@ from pathlib import Path
from typing import Any
PROFILE_SCHEMA_VERSION = 1
DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
DEFAULT_PROFILE_PATH = Path(__file__).parent / "profiles" / "fw-3.0.2" / "direct-lan.v1.json"
DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
DEFAULT_PROFILE_PATH = (
Path(__file__).parent / "profiles" / "fw-3.0.2" / "local-network.v2.json"
)
EVIDENCE_FLAGS = (
"observed",
"decoded",
@ -129,6 +131,49 @@ def _validate_sources(profile: dict[str, Any]) -> set[str]:
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:
if isinstance(value, dict):
if "source_ids" in value:
@ -155,7 +200,7 @@ def _validate_transports(profile: dict[str, Any]) -> None:
)
if set(transports) != {
"ble.wifi-bootstrap.fw3.v1",
"mqtt.direct-lan.fw3.v1",
"mqtt.local-ipv4.fw3.v1",
"rtsp.camera-preview.fw3.v1",
}:
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,
)
mqtt = transports["mqtt.direct-lan.fw3.v1"]
mqtt = transports["mqtt.local-ipv4.fw3.v1"]
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")
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":
raise CompatibilityProfileError("direct-LAN MQTT security claim differs from observation")
raise CompatibilityProfileError("local MQTT security claim differs from observation")
allowlist = _array(
mqtt.get("subscription_allowlist"),
"$.transports[mqtt].subscription_allowlist",
@ -202,7 +247,7 @@ def _validate_transports(profile: dict[str, Any]) -> None:
)
_expect_evidence(
mqtt,
"$.transports[mqtt.direct-lan.fw3.v1]",
"$.transports[mqtt.local-ipv4.fw3.v1]",
observed=True,
decoded=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]:
"""Validate and return one read-only firmware-3/direct-LAN profile object."""
"""Validate the read-only firmware-3 local connection matrix."""
root = _object(profile, "$")
if root.get("schema_version") != 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")
if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
if scope.get("topology") != "direct-lan":
raise CompatibilityProfileError("profile topology must remain direct-lan")
if scope.get("topology") != "local-network-matrix":
raise CompatibilityProfileError("profile topology must remain local-network-matrix")
_validate_connection_modes(scope)
vocabulary = _object(root.get("evidence_vocabulary"), "$.evidence_vocabulary")
if set(vocabulary) != set(EVIDENCE_FLAGS):
@ -580,7 +626,10 @@ def matches_target(
validated = validate_compatibility_profile(profile)
scope = validated["scope"]
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:

View File

@ -4,14 +4,17 @@ This directory is the plugin-local, versioned compatibility vocabulary for
verified K1 wire behavior. It is deliberately **not** a platform ontology and
does not add runtime dependencies on NODE.DC Ontology Core.
The first profile is
[`fw-3.0.2/direct-lan.v1.json`](fw-3.0.2/direct-lan.v1.json). It matches exactly:
The active profile is
[`fw-3.0.2/local-network.v2.json`](fw-3.0.2/local-network.v2.json). It matches exactly:
- model: XGRIDS LixelKity K1;
- firmware: `3.0.2`;
- topology: K1 and connector on the same owner-controlled LAN;
- evidence scope: one physical scanner across controlled direct-LAN and
owner-operated LixelGO/iPhone laboratory runs.
- topology matrix: Bridge (`direct-lan`), Quick Connect (`device-ap`) and
Direct Connect (`controller-hotspot`);
- evidence scope: one physical scanner across Mission Core Bridge and
owner-operated LixelGO/iPhone Quick Connect runs. The Direct Connect adapter
reuses the same physically accepted 99-byte BLE Wi-Fi frame, but its complete
Mission Core path still awaits a separate physical acceptance run.
It must not be applied to another firmware or treated as a vendor API claim.
Unknown firmware fails closed.
@ -63,14 +66,15 @@ The loader uses only the Python standard library and performs no device I/O:
python plugins/xgrids-k1/profile_loader.py
```
It verifies the exact firmware/topology scope, evidence vocabulary, source
references, GATT UUIDs, MQTT subscribe-only boundary, channel evidence, camera
RTSP/H.264 endpoints, operator-manual acquisition, and disabled vendor request
mappings.
It verifies the exact firmware/topology matrix, each mode's bounded network
actions, evidence vocabulary, source references, GATT UUIDs, local MQTT
boundary, channel evidence, camera RTSP/H.264 endpoints, operator-manual
acquisition, and disabled descriptive-profile vendor request mappings.
## Evolution rules
1. A different firmware or topology gets a new profile file and profile ID.
1. A different firmware or connection topology gets a new profile file and
profile ID unless it is added through another evidence-backed schema revision.
2. New evidence may only promote the flags supported by retained raw evidence,
a documented decoder/replay check, or a physical lab report.
3. Unknown fields remain explicit; they are never filled from naming or payload

View File

@ -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"
]
}
]
}
}

View File

@ -14,7 +14,7 @@ from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from functools import wraps
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 missioncore_plugin_sdk.v0alpha2 import (
@ -28,6 +28,7 @@ from pydantic import (
SecretStr,
ValidationError,
field_validator,
model_validator,
)
from k1link.artifacts import write_json_atomic
@ -44,6 +45,7 @@ from k1link.device_plugins.xgrids_k1.camera import (
XgridsK1CameraGateway,
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.capture import seal_capture_clock
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,
DormantApplicationControlCoordinator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher,
@ -90,9 +95,19 @@ from k1link.web.plugin_runtime import (
)
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_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_DISCOVERY_SCAN = "discovery.scan"
@ -182,7 +197,7 @@ class CompatibilityAttestationRequest(StrictRequest):
"""Selected profile whose facts must be verified from live DeviceInfo."""
firmware_version: Literal["3.0.2"]
topology: Literal["direct-lan"]
topology: ConnectionTopology
verification: Literal["live-device-info"]
@ -190,11 +205,26 @@ class ConnectRequest(StrictRequest):
device_id: 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)
connection_mode: Literal["bridge"] = "bridge"
connection_mode: ConnectionMode = "bridge"
compatibility_attestation: CompatibilityAttestationRequest
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)
@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):
project_name: str = Field(min_length=1, max_length=96)
@ -335,7 +365,7 @@ class XgridsK1CompatibilityService:
self._devices: list[dict[str, Any]] = []
self._selected_device_id: 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_id: str | None = None
self._device_session_id: str | None = None
@ -369,7 +399,8 @@ class XgridsK1CompatibilityService:
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
)
self._application_control_session = InteractiveApplicationControlSession(
authority_loader
authority_loader,
transport_factory=self._application_control_transport,
)
self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
@ -380,6 +411,14 @@ class XgridsK1CompatibilityService:
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
def state(self) -> dict[str, Any]:
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"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
# Unwrap once at the provisioning service boundary. The plain value is
# kept only in this stack frame, included in a keyed request digest, and
# passed to the reviewed BLE write boundary; it is never journaled.
# Unwrap once at the network service boundary. The plain value is kept
# only in this stack frame, included in a keyed request digest, and
# 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()
request_fingerprint = self._request_fingerprint(
ACTION_NETWORK_PROVISION,
@ -647,7 +687,8 @@ class XgridsK1CompatibilityService:
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
session_dir: Path | None = None
write_attempted = False
network_change_attempted = False
quick_connect = request.connection_mode == "quick-connect"
try:
with self._lock:
active_acquisition = self._acquisition
@ -659,55 +700,109 @@ class XgridsK1CompatibilityService:
"нельзя менять устройство или его сеть во время активной acquisition-сессии"
)
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()
# Revoke preview/producer state only after the active-acquisition
# guard; a rejected network write must never stop evidence capture.
self.camera_preview.stop_current()
self._set_operation(
"provisioning",
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
operation_message = (
"Подключаем этот Mac к точке доступа выбранного K1 одним запросом."
if quick_connect
else "Передаём устройству настройки Wi-Fi одним подтверждённым запросом."
)
self._set_operation("provisioning", operation_message)
session_dir = _new_operation_session_dir(
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)
self._operations.transition(
operation.operation_id,
"running",
stage_code="ble-provisioning-write",
stage_code=(
"host-wifi-association" if quick_connect else "ble-provisioning-write"
),
message_code="network.provision.running",
)
write_attempted = True
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)
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
write_json_atomic(
session_dir / "manifest.redacted.json",
{
network_change_attempted = True
if quick_connect:
started_at = _utc_now_iso()
association = await asyncio.to_thread(
associate_with_wifi_once,
self.repository_root
/ "plugins"
/ "xgrids-k1"
/ "macos"
/ "associate_wifi.swift",
request.ssid,
password,
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,
"started_at_utc": result["started_at_utc"],
"completed_at_utc": result["completed_at_utc"],
"operation": "single_reviewed_wifi_provisioning_write",
"profile_id": result["profile_id"],
"connection_mode": request.connection_mode,
"topology": request.compatibility_attestation.topology,
"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,
},
}
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:
raise RuntimeError(
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
@ -730,9 +825,21 @@ class XgridsK1CompatibilityService:
self._compatibility_attestation = _attestation_snapshot(
request.compatibility_attestation
)
self._operation_message = (
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
)
self._connection_verification = {
"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(
operation.operation_id,
"succeeded",
@ -742,6 +849,13 @@ class XgridsK1CompatibilityService:
"device_id": self._device_id,
"device_session_id": self._device_session_id,
"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}",),
)
@ -753,9 +867,11 @@ class XgridsK1CompatibilityService:
message_code="network.provision.failed",
error=_operation_error(
exc,
category="device",
side_effect_status="unknown" if write_attempted else "none",
safe_to_retry=not write_attempted,
category="transport" if quick_connect else "device",
side_effect_status=(
"unknown" if network_change_attempted else "none"
),
safe_to_retry=not network_change_attempted,
),
evidence_refs=(
(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:
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
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
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
@ -908,10 +1024,24 @@ class XgridsK1CompatibilityService:
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
)
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(
"адрес точки доступа устройства нельзя использовать как 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":
with self._lock:
control_target = self._k1_ip
@ -1830,6 +1960,7 @@ class XgridsK1CompatibilityService:
current_session_id = self._device_session_id
target = self._k1_ip
attestation = self._compatibility_attestation
connection_mode = self._connection_mode
if current_session_id is None or device_session_id != current_session_id:
raise ValueError("указана неактивная device-сессия")
if attestation is None:
@ -1837,8 +1968,10 @@ class XgridsK1CompatibilityService:
if target is None:
raise ValueError("у плагина нет подтверждённого локального адреса K1")
target = validate_private_ipv4(target)
if target == AP_FALLBACK_IPV4:
raise ValueError("адрес точки доступа K1 нельзя использовать как camera target")
if target == AP_FALLBACK_IPV4 and connection_mode != "quick-connect":
raise ValueError("адрес точки доступа K1 не принят для этой device-сессии")
if connection_mode == "quick-connect" and target != AP_FALLBACK_IPV4:
raise ValueError("Quick Connect camera target не совпадает с AP-адресом K1")
return target
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"
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(
"missioncore_installed_xgrids_k1_profile_loader",
loader_path,

View File

@ -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,
}

View File

@ -19,7 +19,7 @@ MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024
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_TYPE = "A4"
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
@ -73,8 +73,10 @@ class ApplicationControlAuthority:
openapi_key: str = field(repr=False)
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
compatibility_profile_id: Literal[
"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:

View File

@ -292,11 +292,12 @@ class ReviewedApplicationMqttTransport:
port: int = 1883,
connect_timeout_seconds: float = CONTROL_CONNECT_TIMEOUT_SECONDS,
exchange_timeout_seconds: float = CONTROL_EXCHANGE_TIMEOUT_SECONDS,
allow_device_ap: bool = False,
client_factory: Callable[[], mqtt.Client] | None = None,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
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")
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")

View File

@ -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"
)
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"]["compatibilityProfiles"] == [
{
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
"path": "profiles/fw-3.0.2/local-network.v2.json",
"modelId": "xgrids.lixelkity-k1",
}
]
@ -125,14 +125,14 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
} <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.5.0",
"pluginVersion": "0.6.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
"category": "Мобильный лидарный сканер",
"description": (
"Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, "
"облако точек, поза и raw-first запись."
"Локальный профиль Bridge, Quick Connect и Direct Connect: "
"BLE/CoreWLAN, MQTT, камеры и raw-first запись."
),
"verified": True,
"capabilities": [
@ -141,6 +141,10 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
"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.pose.live", "label": "Траектория"},
{"id": "device.modeling.live", "label": "Метрики маршрута"},

View File

@ -33,6 +33,16 @@ ATTESTATION = CompatibilityAttestationRequest(
topology="direct-lan",
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
SECONDARY_TEST_CREDENTIAL = "y" * 24
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",
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):
ConnectRequest(
device_id="synthetic-device",
ssid="synthetic-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
connection_mode="quick-connect", # type: ignore[arg-type]
connection_mode="quick-connect",
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):
PrepareAcquisitionRequest(
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)
with pytest.raises(ValueError, match="точки доступа"):
with pytest.raises(ValueError, match="connection flow"):
service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
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"}
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(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,

View File

@ -275,6 +275,15 @@ def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
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:
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)

View File

@ -45,15 +45,29 @@ def _boolean_write_flags(value: Any) -> list[bool]:
def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None:
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"]["model"] == "LixelKity K1"
assert profile["scope"]["platform_type"] == "A4"
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="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.2", topology="device-ap")
assert not LOADER.matches_target(profile, firmware="3.0.2", topology="usb")
for source in profile["evidence_sources"]:
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"]["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["network"]["transport"] == "TCP"
assert mqtt["network"]["port"] == 1883
@ -205,6 +219,18 @@ def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
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:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)

View File

@ -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,
)