fix(k1): harden BLE discovery and bridge recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 12:43:00 +03:00
parent be50144ca8
commit 52da9b75b7
14 changed files with 1010 additions and 48 deletions
@@ -19,6 +19,7 @@ let presentation;
let operatorIntentGeneration; let operatorIntentGeneration;
let configuration; let configuration;
let compatibility; let compatibility;
let networkProvisionFailureMessage;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -62,6 +63,9 @@ before(async () => {
({ localizeRuntimeMessage } = await server.ssrLoadModule( ({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts", "@xgrids-k1/frontend/messages.ts",
)); ));
({ networkProvisionFailureMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/useXgridsK1Runtime.ts",
));
}); });
after(async () => { after(async () => {
@@ -216,6 +220,7 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare"); assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start"); assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop"); assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
assert.equal(xgridsK1Actions.connectionVerify, "connection.verify");
}); });
test("one operator action can open at most one K1 control session", () => { test("one operator action can open at most one K1 control session", () => {
@@ -693,3 +698,56 @@ test("device mutations send explicit nested compatibility attestation", async ()
assert.deepEqual(prepare.input.compatibility_attestation, attestation); assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01"); assert.equal(prepare.input.project_name, "Mission 01");
}); });
test("connection verification supports refresh and explicit read-only adoption", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (path, init) => {
calls.push({ path, init });
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const attestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
};
try {
await xgridsK1Api.verifyConnection();
await xgridsK1Api.verifyConnection({
device_id: "fresh-ble-device",
compatibility_attestation: attestation,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(calls.length, 2);
assert.match(String(calls[0].path), /actions\/connection\.verify$/);
assert.match(String(calls[1].path), /actions\/connection\.verify$/);
assert.deepEqual(JSON.parse(calls[0].init.body), { input: {} });
const adoption = JSON.parse(calls[1].init.body).input;
assert.deepEqual(adoption, {
device_id: "fresh-ble-device",
compatibility_attestation: attestation,
});
assert.equal("ssid" in adoption, false);
assert.equal("password" in adoption, false);
assert.equal("connection_mode" in adoption, false);
});
test("rejected network-profile writes use safe operator copy", () => {
const message = networkProvisionFailureMessage({
status: "failed",
error: { code: "BleakGATTProtocolError" },
});
assert.equal(
message,
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
);
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
});
@@ -162,6 +162,30 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
assert.match(spatialControls, /Повторить остановку/); assert.match(spatialControls, /Повторить остановку/);
}); });
test("K1 Bridge adoption remains one explicit read-only plugin action", () => {
const provisioning = readFileSync(
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
"utf8",
);
const selectionEffectStart = provisioning.indexOf("useEffect(() => {");
const selectionEffectEnd = provisioning.indexOf(
"useEffect(() => {",
selectionEffectStart + 1,
);
const selectionEffect = provisioning.slice(selectionEffectStart, selectionEffectEnd);
assert.match(provisioning, /connectionMode === "bridge"/);
assert.match(provisioning, /Подхватить существующее подключение/);
assert.match(provisioning, /pendingAction === "verify"/);
assert.match(
provisioning,
/compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/,
);
assert.match(provisioning, /без изменения настроек Wi‑Fi/);
assert.match(provisioning, /deviceSummary !== undefined/);
assert.match(selectionEffect, /!state\.devices\.some/);
assert.match(selectionEffect, /setSelectedDeviceId\(""\)/);
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => { test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts"); const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
const composition = readFileSync(compositionPath, "utf8"); const composition = readFileSync(compositionPath, "utf8");
+9
View File
@@ -386,6 +386,11 @@ export interface ConnectRequest {
idempotency_key?: string; idempotency_key?: string;
} }
export interface ConnectionVerifyRequest {
device_id: string;
compatibility_attestation: CompatibilityAttestation;
}
export interface PrepareAcquisitionRequest { export interface PrepareAcquisitionRequest {
project_name: string; project_name: string;
mount_type: "handheld"; mount_type: "handheld";
@@ -593,6 +598,10 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.networkProvision, body); return invokeState(xgridsK1Actions.networkProvision, body);
}, },
verifyConnection(body?: ConnectionVerifyRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.connectionVerify, body);
},
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> { prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body); return invokeState(xgridsK1Actions.acquisitionPrepare, body);
}, },
@@ -118,7 +118,7 @@ export function K1ProvisioningPipeline({
phaseLabel: string; phaseLabel: string;
phaseTone: StatusTone; phaseTone: StatusTone;
}) { }) {
const { state, pendingAction, scan, connect } = controller; const { state, pendingAction, scan, connect, verifyConnection } = controller;
const [powerConfirmed, setPowerConfirmed] = useState(false); const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState(""); const [ssid, setSsid] = useState("");
@@ -160,6 +160,11 @@ export function K1ProvisioningPipeline({
() => devices.find((device) => device.device_id === selectedDeviceId), () => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId], [devices, selectedDeviceId],
); );
const canAdoptExistingBridge = connectionMode === "bridge"
&& powerConfirmed
&& selectedDeviceId.length > 0
&& deviceSummary !== undefined
&& !isBusy;
const resetProvisioningIntent = () => { const resetProvisioningIntent = () => {
provisioningIntentRef.current = null; provisioningIntentRef.current = null;
@@ -190,6 +195,14 @@ export function K1ProvisioningPipeline({
} }
}; };
const submitExistingBridgeAdoption = async () => {
if (!canAdoptExistingBridge) return;
await verifyConnection({
device_id: selectedDeviceId,
compatibility_attestation: profileSelectionForConnectionMode("bridge"),
});
};
return ( return (
<GlassSurface className="connection-panel" padding="lg"> <GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading"> <header className="panel-heading">
@@ -275,6 +288,24 @@ export function K1ProvisioningPipeline({
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…" ? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
: modeCopy.buttonLabel} : modeCopy.buttonLabel}
</Button> </Button>
{connectionMode === "bridge" ? (
<>
<Button
width="full"
variant="secondary"
icon={<Icon name="refresh" />}
disabled={!canAdoptExistingBridge}
onClick={() => void submitExistingBridgeAdoption()}
>
{pendingAction === "verify"
? "Проверяем существующее подключение…"
: "Подхватить существующее подключение"}
</Button>
<p className="safety-note">
Если K1 уже подключён к этой сети другим способом, Mission Core проверит и примет существующее подключение без изменения настроек WiFi.
</p>
</>
) : null}
<p className="safety-note"> <p className="safety-note">
{modeCopy.safetyNote} {modeCopy.safetyNote}
{connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."} {connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."}
@@ -6,6 +6,7 @@ import {
ApiError, ApiError,
xgridsK1Api, xgridsK1Api,
openEventSocket, openEventSocket,
type ConnectionVerifyRequest,
type ConnectRequest, type ConnectRequest,
type EventSocketStatus, type EventSocketStatus,
type OpenApplicationControlSessionRequest, type OpenApplicationControlSessionRequest,
@@ -34,6 +35,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction = export type PendingAction =
| "scan" | "scan"
| "connect" | "connect"
| "verify"
| "control" | "control"
| "live" | "live"
| "replay" | "replay"
@@ -191,7 +193,7 @@ function messageFor(error: unknown): string {
return "Запрос к локальному сервису устройства завершился ошибкой."; return "Запрос к локальному сервису устройства завершился ошибкой.";
} }
function networkProvisionFailureMessage( export function networkProvisionFailureMessage(
operation: XgridsOperation | null | undefined, operation: XgridsOperation | null | undefined,
): string | null { ): string | null {
if (!operation || operation.status !== "failed") return null; if (!operation || operation.status !== "failed") return null;
@@ -199,6 +201,8 @@ function networkProvisionFailureMessage(
if (typeof code !== "string") return null; if (typeof code !== "string") return null;
const messages: Record<string, string> = { const messages: Record<string, string> = {
BleakGATTProtocolError:
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
"network-not-found": "network-not-found":
"Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.", "Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.",
"credential-entry-cancelled": "credential-entry-cancelled":
@@ -392,6 +396,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
[acceptState, run, state], [acceptState, run, state],
); );
const verifyConnection = useCallback(
(request?: ConnectionVerifyRequest) =>
run("verify", () => xgridsK1Api.verifyConnection(request)),
[run],
);
const openApplicationControlSession = useCallback( const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) => (request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)), run("control", () => xgridsK1Api.openApplicationControlSession(request)),
@@ -773,6 +783,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null), clearError: () => setError(null),
scan, scan,
connect, connect,
verifyConnection,
openApplicationControlSession, openApplicationControlSession,
enterApplicationWorkspace, enterApplicationWorkspace,
closeApplicationControlSession, closeApplicationControlSession,
@@ -11,7 +11,6 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4, AP_FALLBACK_IPV4,
SERVICE_UUID, SERVICE_UUID,
@@ -128,12 +127,13 @@ async def device_ap_activation_session(
try: try:
async with asyncio.timeout(timeout_seconds + 25.0): async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid) # AP activation is a mutation boundary: prove that the selected
if device is None: # device is advertising now instead of trusting a CoreBluetooth
device = await BleakScanner.find_device_by_address( # handle retained by an earlier UI scan.
device_macos_uuid, device = await BleakScanner.find_device_by_address(
timeout=min(20.0, timeout_seconds), device_macos_uuid,
) timeout=min(20.0, timeout_seconds),
)
if device is None: if device is None:
raise BleakDeviceNotFoundError( raise BleakDeviceNotFoundError(
device_macos_uuid, device_macos_uuid,
@@ -239,12 +239,13 @@ async def provision_wifi_once(
try: try:
async with asyncio.timeout(timeout_seconds + 25.0): async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid) # A provisioning write is a mutation boundary: prove that the
if device is None: # selected device is advertising now instead of trusting a
device = await BleakScanner.find_device_by_address( # CoreBluetooth handle retained by an earlier UI scan.
device_macos_uuid, device = await BleakScanner.find_device_by_address(
timeout=min(20.0, timeout_seconds), device_macos_uuid,
) timeout=min(20.0, timeout_seconds),
)
if device is None: if device is None:
raise BleakDeviceNotFoundError( raise BleakDeviceNotFoundError(
device_macos_uuid, device_macos_uuid,
+282 -18
View File
@@ -128,6 +128,7 @@ XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right" DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
CONTROL_MQTT_PORT = 1883 CONTROL_MQTT_PORT = 1883
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5 CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -277,6 +278,28 @@ class ConnectRequest(StrictRequest):
return self return self
class ConnectionVerifyRequest(StrictRequest):
"""Refresh one lease or adopt a scanned K1 already on the direct LAN."""
device_id: str | None = Field(default=None, min_length=1, max_length=128)
compatibility_attestation: CompatibilityAttestationRequest | None = None
@model_validator(mode="after")
def validate_adoption_request(self) -> Self:
has_device = self.device_id is not None
has_attestation = self.compatibility_attestation is not None
if has_device != has_attestation:
raise ValueError(
"device_id and compatibility_attestation must be provided together"
)
if (
self.compatibility_attestation is not None
and self.compatibility_attestation.topology != "direct-lan"
):
raise ValueError("connection.verify adoption requires topology=direct-lan")
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)
host: str | None = Field(default=None, max_length=15) host: str | None = Field(default=None, max_length=15)
@@ -422,6 +445,8 @@ class XgridsK1CompatibilityService:
self._provisioning_active = False self._provisioning_active = False
self._fingerprint_key = secrets.token_bytes(32) self._fingerprint_key = secrets.token_bytes(32)
self._devices: list[dict[str, Any]] = [] self._devices: list[dict[str, Any]] = []
self._ble_discovery_generation = 0
self._ble_device_last_seen_monotonic: dict[str, float] = {}
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: ConnectionMode | None = None self._connection_mode: ConnectionMode | None = None
@@ -494,6 +519,25 @@ class XgridsK1CompatibilityService:
allow_device_ap=connection_mode == "quick-connect", allow_device_ap=connection_mode == "quick-connect",
) )
def _fresh_ble_devices_locked(
self,
*,
now_monotonic: float | None = None,
) -> list[dict[str, Any]]:
"""Return only candidates leased by the current in-memory scan generation."""
now = time.monotonic() if now_monotonic is None else now_monotonic
fresh: list[dict[str, Any]] = []
for item in self._devices:
device_id = str(item.get("device_id") or "")
last_seen = self._ble_device_last_seen_monotonic.get(device_id)
if last_seen is None:
continue
age_seconds = now - last_seen
if 0.0 <= age_seconds <= BLE_DISCOVERY_LEASE_TTL_SECONDS:
fresh.append(dict(item))
return fresh
@_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()
@@ -507,7 +551,8 @@ class XgridsK1CompatibilityService:
with self._lock: with self._lock:
operation_phase = self._operation_phase operation_phase = self._operation_phase
operation_message = self._operation_message operation_message = self._operation_message
devices = list(self._devices) devices = self._fresh_ble_devices_locked()
discovery_stale = bool(self._devices) and not devices
selected_device_id = self._selected_device_id selected_device_id = self._selected_device_id
k1_ip = self._k1_ip k1_ip = self._k1_ip
connection_mode = self._connection_mode connection_mode = self._connection_mode
@@ -572,6 +617,9 @@ class XgridsK1CompatibilityService:
f"Найдено BLE-устройств: {len(devices)}. " f"Найдено BLE-устройств: {len(devices)}. "
f"Совместимых профилей: {likely_count}. Выберите нужное устройство." f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
) )
elif discovery_stale:
phase = "idle"
message = "Результаты Bluetooth-поиска устарели. Выполните поиск ещё раз."
else: else:
phase = "idle" phase = "idle"
message = runtime["message"] message = runtime["message"]
@@ -686,6 +734,15 @@ class XgridsK1CompatibilityService:
} }
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
with self._lock:
if self._provisioning_active:
raise RuntimeError("нельзя запускать BLE-поиск во время настройки Wi-Fi")
self._ble_discovery_generation += 1
scan_generation = self._ble_discovery_generation
# Discovery results are an in-memory lease, not a durable catalog.
# A new explicit scan invalidates the previous generation before I/O.
self._devices = []
self._ble_device_last_seen_monotonic = {}
operation, _ = self._operations.begin( operation, _ = self._operations.begin(
ACTION_DISCOVERY_SCAN, ACTION_DISCOVERY_SCAN,
deadline_seconds=duration_seconds + 10.0, deadline_seconds=duration_seconds + 10.0,
@@ -710,9 +767,16 @@ class XgridsK1CompatibilityService:
} }
for item in result["devices"] for item in result["devices"]
] ]
observed_monotonic = time.monotonic()
with self._lock: with self._lock:
self._devices = devices if self._ble_discovery_generation == scan_generation:
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." self._devices = devices
self._ble_device_last_seen_monotonic = {
str(item["device_id"]): observed_monotonic for item in devices
}
self._operation_message = (
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
)
self._operations.transition( self._operations.transition(
operation.operation_id, operation.operation_id,
"succeeded", "succeeded",
@@ -721,6 +785,10 @@ class XgridsK1CompatibilityService:
result={"candidate_count": len(devices)}, result={"candidate_count": len(devices)},
) )
except Exception as exc: except Exception as exc:
with self._lock:
if self._ble_discovery_generation == scan_generation:
self._devices = []
self._ble_device_last_seen_monotonic = {}
self._operations.transition( self._operations.transition(
operation.operation_id, operation.operation_id,
"failed", "failed",
@@ -731,16 +799,20 @@ class XgridsK1CompatibilityService:
raise raise
finally: finally:
with self._lock: with self._lock:
self._operation_phase = None if self._ble_discovery_generation == scan_generation:
self._operation_phase = None
return self.state() return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]: async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]} with self._lock:
scanned_devices = self._fresh_ble_devices_locked()
discovery_generation = self._ble_discovery_generation
known_ids = {str(item["device_id"]) for item in scanned_devices}
if request.device_id not in known_ids: if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth") raise ValueError("сначала найдите и выберите устройство через Bluetooth")
quick_connect = request.connection_mode == "quick-connect" quick_connect = request.connection_mode == "quick-connect"
selected_device = next( selected_device = next(
item for item in self.state()["devices"] if item["device_id"] == request.device_id item for item in scanned_devices if item["device_id"] == request.device_id
) )
selected_device_name = str(selected_device.get("name") or "").strip() selected_device_name = str(selected_device.get("name") or "").strip()
control_snapshot = self._application_control_session.snapshot() control_snapshot = self._application_control_session.snapshot()
@@ -860,6 +932,18 @@ class XgridsK1CompatibilityService:
raise HostWifiProfileError("credential-source-unavailable") raise HostWifiProfileError("credential-source-unavailable")
with self._lock: with self._lock:
if (
self._ble_discovery_generation != discovery_generation
or request.device_id
not in {
str(item["device_id"])
for item in self._fresh_ble_devices_locked()
}
):
raise ValueError(
"результаты Bluetooth-поиска изменились или устарели; "
"выполните поиск ещё раз"
)
active_acquisition = self._acquisition active_acquisition = self._acquisition
if ( if (
active_acquisition is not None active_acquisition is not None
@@ -1142,21 +1226,41 @@ class XgridsK1CompatibilityService:
evidence_refs=(f"evidence-session-{session_dir.name}",), evidence_refs=(f"evidence-session-{session_dir.name}",),
) )
except Exception as exc: except Exception as exc:
operation_error = _operation_error(
exc,
category="transport" if quick_connect else "device",
side_effect_status=("unknown" if network_change_attempted else "none"),
safe_to_retry=not network_change_attempted,
)
self._operations.transition_if_pending( self._operations.transition_if_pending(
operation.operation_id, operation.operation_id,
"failed", "failed",
stage_code=f"{operation_stage}-failed", stage_code=f"{operation_stage}-failed",
message_code="network.provision.failed", message_code="network.provision.failed",
error=_operation_error( error=operation_error,
exc,
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=( 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 ()
), ),
) )
# Never log the request, SSID, password, BLE payload, or exception
# text here: lower transport errors may embed sensitive frame data.
# The structured record is sufficient to correlate the HTTP 502
# with its operation stage and retry/side-effect safety contract.
logger.error(
"K1 network provisioning failed",
extra={
"event_code": "k1_network_provision_failed",
"operation_id": operation.operation_id,
"operation_stage": operation_stage,
"connection_mode": request.connection_mode,
"error_category": operation_error["category"],
"error_code": operation_error["code"],
"safe_to_retry": operation_error["safe_to_retry"],
"side_effect_status": operation_error["side_effect_status"],
"network_change_attempted": network_change_attempted,
"automatic_retry": False,
},
)
raise raise
finally: finally:
password = "" password = ""
@@ -1216,12 +1320,169 @@ class XgridsK1CompatibilityService:
payload=segment.payload, payload=segment.payload,
) )
def verify_connection(self) -> dict[str, Any]: def verify_connection(
"""Refresh the session-scoped DHCP address from the read-only BLE status.""" self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]:
"""Refresh a lease or adopt an externally configured direct-LAN K1."""
self._refresh_live_lan_address(rediscover=True) request = request or ConnectionVerifyRequest()
if request.device_id is None:
self._refresh_live_lan_address(rediscover=True)
else:
assert request.compatibility_attestation is not None
self._adopt_existing_lan_connection(
request.device_id,
request.compatibility_attestation,
)
return self.state() return self.state()
def _adopt_existing_lan_connection(
self,
device_id: str,
compatibility_attestation: CompatibilityAttestationRequest,
) -> str:
"""Create a process lease from BLE status without changing K1 Wi-Fi state."""
if compatibility_attestation.topology != "direct-lan":
raise ValueError("read-only K1 adoption supports only direct-lan topology")
with self._lock:
scanned_device = next(
(
dict(item)
for item in self._fresh_ble_devices_locked()
if str(item.get("device_id")) == device_id
),
None,
)
discovery_generation = self._ble_discovery_generation
acquisition = self._acquisition
acquisition_session_lease = self._acquisition_session_lease
provisioning_active = self._provisioning_active
previous_target = self._k1_ip
if scanned_device is None:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
if scanned_device.get("connectable") is False:
raise ValueError("выбранное Bluetooth-устройство сейчас недоступно для подключения")
if provisioning_active:
raise RuntimeError("нельзя проверять существующую сеть во время настройки Wi-Fi")
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
"нельзя менять connection lease во время активной acquisition-сессии"
)
if acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
runtime_state = self.runtime.snapshot()
if runtime_state.get("source_mode") != "idle":
raise RuntimeError("нельзя менять connection lease при активном live/replay источнике")
if not self._provisioning_gate.acquire(blocking=False):
raise RuntimeError("другая операция настройки или проверки сети уже выполняется")
try:
status_read = asyncio.run(
read_wifi_status_once(
device_id,
timeout_seconds=20.0,
rediscover=True,
)
)
if (
status_read.get("operation") != "single_reviewed_wifi_status_read"
or status_read.get("write_performed") is not False
):
raise RuntimeError("BLE status read не подтвердил read-only операцию")
observed_target = status_read["status"]["ipv4"]
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
target = validate_private_ipv4(observed_target)
if _target_is_local_ipv4(target):
raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру")
host_route_class = _host_route_class(target)
if host_route_class in {"default-route", "tunnel"}:
raise RuntimeError(
"K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует"
)
if not _control_endpoint_reachable(target):
raise RuntimeError(
"K1 сообщил адрес общей сети, но MQTT endpoint 1883 недоступен"
)
# Retire only local, terminal control ownership after every external
# observation has passed. No scanner command is sent by this call.
self._application_control_session.retire_for_network_change()
observed_at = str(status_read["observed_at_utc"])
with self._lock:
if self._provisioning_active:
raise RuntimeError("настройка Wi-Fi началась во время проверки сети")
if self._ble_discovery_generation != discovery_generation or not any(
str(item.get("device_id")) == device_id for item in self._devices
):
raise RuntimeError("список найденных Bluetooth-устройств изменился")
current_acquisition = self._acquisition
if (
current_acquisition is not None
and current_acquisition.state not in TERMINAL_ACQUISITION_STATES
):
raise RuntimeError(
"acquisition-сессия началась во время проверки существующей сети"
)
if self._acquisition_session_lease is not None:
raise RuntimeError(
"evidence-сессия изменилась во время проверки существующей сети"
)
persistent_device_id = self._device_ids_by_transport_ref.get(device_id)
if persistent_device_id is None:
persistent_device_id = new_device_id()
self._device_ids_by_transport_ref[device_id] = persistent_device_id
self._selected_device_id = device_id
self._k1_ip = target
self._connection_mode = "bridge"
self._device_id = persistent_device_id
self._device_session_id = new_device_session_id()
self._device_session_opened_at = observed_at
self._connection_lease_generation += 1
self._compatibility_attestation = _attestation_snapshot(
compatibility_attestation
)
self._device_calibration = unavailable_device_calibration_snapshot(
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
)
self._connection_verification = {
"status": "adopted",
"lease_state": "reachable",
"lease_generation": self._connection_lease_generation,
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
"network_reachability": "reachable",
"host_route_class": host_route_class,
"address_source": "ble-wifi-status-read",
"connection_origin": "external-existing-network",
"admission_source": ACTION_CONNECTION_VERIFY,
"address_changed": previous_target != target,
"previous_address_present": previous_target is not None,
"write_performed": False,
"observed_at": observed_at,
}
self._operation_message = (
"K1 найден в существующей локальной сети без изменения его настроек."
)
lease_generation = self._connection_lease_generation
logger.info(
"K1 existing direct-LAN connection adopted without provisioning",
extra={
"event_code": "k1_existing_lan_connection_adopted",
"lease_generation": lease_generation,
"connection_origin": "external-existing-network",
"address_source": "ble-wifi-status-read",
"host_route_class": host_route_class,
"endpoint_reachable": True,
"device_write_performed": False,
"automatic_retry": False,
},
)
return target
finally:
self._provisioning_gate.release()
def _refresh_live_lan_address(self, *, rediscover: bool = False) -> str: def _refresh_live_lan_address(self, *, rediscover: bool = False) -> str:
with self._lock: with self._lock:
selected_device_id = self._selected_device_id selected_device_id = self._selected_device_id
@@ -3156,7 +3417,10 @@ class XgridsK1ServicePort(Protocol):
def inspect_device(self) -> dict[str, Any]: ... def inspect_device(self) -> dict[str, Any]: ...
def verify_connection(self) -> dict[str, Any]: ... def verify_connection(
self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]: ...
def read_device_calibration_snapshot(self) -> dict[str, Any]: ... def read_device_calibration_snapshot(self) -> dict[str, Any]: ...
@@ -3282,8 +3546,8 @@ class XgridsK1PluginFacade:
connect_request = ConnectRequest.model_validate(payload) connect_request = ConnectRequest.model_validate(payload)
return await self.service.connect(connect_request) return await self.service.connect(connect_request)
if action_id == ACTION_CONNECTION_VERIFY: if action_id == ACTION_CONNECTION_VERIFY:
EmptyRequest.model_validate(payload) verify_request = ConnectionVerifyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection) return await asyncio.to_thread(self.service.verify_connection, verify_request)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE: if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE:
EmptyRequest.model_validate(payload) EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.state) return await asyncio.to_thread(self.service.state)
+7
View File
@@ -13,6 +13,11 @@ SCANNER_DIAGNOSTIC_FILE: Final = "scanner-diagnostics.jsonl"
_HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path" _HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path"
_EXTRA_FIELDS: Final = ( _EXTRA_FIELDS: Final = (
"event_code", "event_code",
"operation_id",
"operation_stage",
"connection_mode",
"error_category",
"error_code",
"reason_code", "reason_code",
"failed_phase", "failed_phase",
"dialogue_stage", "dialogue_stage",
@@ -21,6 +26,8 @@ _EXTRA_FIELDS: Final = (
"modeling_command_attempted", "modeling_command_attempted",
"outcome_unknown", "outcome_unknown",
"safe_to_retry", "safe_to_retry",
"side_effect_status",
"network_change_attempted",
"status_reconciliation", "status_reconciliation",
"network_change_admissible", "network_change_admissible",
"network_change_reconciliation", "network_change_reconciliation",
+61
View File
@@ -20,6 +20,7 @@ from pydantic import ValidationError
import k1link.web.device_plugin_composition as plugin_composition import k1link.web.device_plugin_composition as plugin_composition
from k1link.device_plugins.xgrids_k1.facade import ( from k1link.device_plugins.xgrids_k1.facade import (
ACTION_CONNECTION_VERIFY,
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ, ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
ACTION_DISCOVERY_SCAN, ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION, ACTION_NETWORK_PROVISION,
@@ -30,6 +31,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION, XGRIDS_K1_PLUGIN_VERSION,
CompatibilityAttestationRequest, CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest, ConnectRequest,
ViewerSettingsRequest, ViewerSettingsRequest,
XgridsK1PluginFacade, XgridsK1PluginFacade,
@@ -72,6 +74,13 @@ class FakeXgridsService:
self.calls.append(("connect", request)) self.calls.append(("connect", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"} return {"phase": "connected", "k1_ip": "192.168.1.20"}
def verify_connection(
self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]:
self.calls.append(("verify", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"}
def start_live( def start_live(
self, self,
project_name: str, project_name: str,
@@ -158,6 +167,58 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non
assert service.calls == [("calibration", None)] assert service.calls == [("calibration", None)]
def test_connection_verify_action_accepts_read_only_adoption_request() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
result = asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_CONNECTION_VERIFY,
{
"device_id": "test-ble-transport",
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"verification": "live-device-info",
},
},
)
)
assert result == {"phase": "connected", "k1_ip": "192.168.1.20"}
assert len(service.calls) == 1
action, request = service.calls[0]
assert action == "verify"
assert isinstance(request, ConnectionVerifyRequest)
assert request.device_id == "test-ble-transport"
assert request.compatibility_attestation is not None
assert request.compatibility_attestation.topology == "direct-lan"
def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_CONNECTION_VERIFY,
{},
)
)
action, request = service.calls[0]
assert action == "verify"
assert isinstance(request, ConnectionVerifyRequest)
assert request.device_id is None
assert request.compatibility_attestation is None
def test_repository_runtime_composition_exactly_matches_catalog() -> None: def test_repository_runtime_composition_exactly_matches_catalog() -> None:
repository_root = Path(__file__).resolve().parents[1] repository_root = Path(__file__).resolve().parents[1]
environment = load_installed_device_plugins(repository_root) environment = load_installed_device_plugins(repository_root)
+14
View File
@@ -43,11 +43,18 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
"field control failure", "field control failure",
extra={ extra={
"event_code": "k1_application_control_session_failed", "event_code": "k1_application_control_session_failed",
"operation_id": "operation-test-01",
"operation_stage": "ble-provisioning-write",
"connection_mode": "bridge",
"error_category": "device",
"error_code": "BleakGATTProtocolError",
"reason_code": "mqtt_network_loop_failed", "reason_code": "mqtt_network_loop_failed",
"mqtt_loop_result_code": 7, "mqtt_loop_result_code": 7,
"mqtt_loop_result_name": "The connection was lost.", "mqtt_loop_result_name": "The connection was lost.",
"mqtt_loop_phase": "post-publish-drain", "mqtt_loop_phase": "post-publish-drain",
"automatic_retry": False, "automatic_retry": False,
"side_effect_status": "unknown",
"network_change_attempted": True,
"camera_source_id": "sensor.camera.right", "camera_source_id": "sensor.camera.right",
"evidence_session_id": "20260728T163450Z_viewer_live", "evidence_session_id": "20260728T163450Z_viewer_live",
"activation_trigger": "application-control-scanning", "activation_trigger": "application-control-scanning",
@@ -73,10 +80,17 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700 assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700
assert stat.S_IMODE(target.stat().st_mode) == 0o600 assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert document["event_code"] == "k1_application_control_session_failed" assert document["event_code"] == "k1_application_control_session_failed"
assert document["operation_id"] == "operation-test-01"
assert document["operation_stage"] == "ble-provisioning-write"
assert document["connection_mode"] == "bridge"
assert document["error_category"] == "device"
assert document["error_code"] == "BleakGATTProtocolError"
assert document["reason_code"] == "mqtt_network_loop_failed" assert document["reason_code"] == "mqtt_network_loop_failed"
assert document["mqtt_loop_result_code"] == 7 assert document["mqtt_loop_result_code"] == 7
assert document["mqtt_loop_phase"] == "post-publish-drain" assert document["mqtt_loop_phase"] == "post-publish-drain"
assert document["automatic_retry"] is False assert document["automatic_retry"] is False
assert document["side_effect_status"] == "unknown"
assert document["network_change_attempted"] is True
assert document["camera_source_id"] == "sensor.camera.right" assert document["camera_source_id"] == "sensor.camera.right"
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live" assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
assert document["activation_trigger"] == "application-control-scanning" assert document["activation_trigger"] == "application-control-scanning"
+40
View File
@@ -3,12 +3,14 @@ from types import SimpleNamespace
from typing import Any from typing import Any
import pytest import pytest
from bleak.exc import BleakDeviceNotFoundError
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
FRAME_LENGTH, FRAME_LENGTH,
build_wifi_provisioning_frame, build_wifi_provisioning_frame,
parse_wifi_status, parse_wifi_status,
provision_wifi_once,
read_wifi_status_once, read_wifi_status_once,
) )
@@ -185,3 +187,41 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
) )
assert result["status"]["ipv4"] == "10.255.254.77" assert result["status"]["ipv4"] == "10.255.254.77"
def test_provisioning_write_requires_fresh_rediscovery_before_connecting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stale_handle = object()
rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = []
async def missing_device(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
return None
class ForbiddenClient:
def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device)
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
monkeypatch.setattr(
wifi_module.BleakScanner,
"find_device_by_address",
missing_device,
)
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
with pytest.raises(BleakDeviceNotFoundError):
asyncio.run(
provision_wifi_once(
"synthetic-corebluetooth-uuid",
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)]
assert client_calls == []
+411 -15
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging
import threading import threading
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -18,6 +19,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
AbortAcquisitionRequest, AbortAcquisitionRequest,
CameraPreviewSelectRequest, CameraPreviewSelectRequest,
CompatibilityAttestationRequest, CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest, ConnectRequest,
OpenApplicationControlSessionRequest, OpenApplicationControlSessionRequest,
OperatorPresenceRequest, OperatorPresenceRequest,
@@ -198,7 +200,7 @@ def service_with_fake_runtime(
return service, runtime return service, runtime
def _wifi_status_read(ipv4: str) -> dict[str, Any]: def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
return { return {
"schema_version": 1, "schema_version": 1,
"profile_id": "xgrids-k1-fw3-wifi-v1", "profile_id": "xgrids-k1-fw3-wifi-v1",
@@ -222,6 +224,383 @@ def _wifi_status_read(ipv4: str) -> dict[str, Any]:
} }
def _set_scanned_devices(
service: XgridsK1CompatibilityService,
devices: list[dict[str, Any]],
) -> None:
service._devices = devices # noqa: SLF001
observed_monotonic = facade_module.time.monotonic()
service._ble_device_last_seen_monotonic = { # noqa: SLF001
str(item["device_id"]): observed_monotonic for item in devices
}
def _set_scanned_k1(
service: XgridsK1CompatibilityService,
*,
device_id: str = "test-ble-transport",
) -> None:
_set_scanned_devices(service, [
{
"device_id": device_id,
"name": "XGR-K1",
"rssi": -44,
"address": None,
"connectable": True,
"likely_k1": True,
}
])
def test_ble_discovery_lease_expiry_hides_and_rejects_candidate(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
service._ble_device_last_seen_monotonic["test-ble-transport"] = ( # noqa: SLF001
facade_module.time.monotonic()
- facade_module.BLE_DISCOVERY_LEASE_TTL_SECONDS
- 0.001
)
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an expired candidate must not be rediscovered by an action")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an expired candidate must not reach the Wi-Fi write boundary")
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
stale_state = service.state()
assert stale_state["devices"] == []
assert "устарели" in stale_state["message"]
with pytest.raises(ValueError, match="найдите и выберите"):
service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=ATTESTATION,
)
)
with pytest.raises(ValueError, match="найдите и выберите"):
asyncio.run(
service.connect(
ConnectRequest(
device_id="test-ble-transport",
ssid="lab-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
)
def test_new_ble_scan_generation_invalidates_old_candidates_before_io_and_on_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service, device_id="old-scan-device")
async def scenario() -> None:
entered = asyncio.Event()
release = asyncio.Event()
async def failing_scan(_duration_seconds: float) -> dict[str, Any]:
entered.set()
await release.wait()
raise RuntimeError("synthetic BLE scan failure")
monkeypatch.setattr(facade_module, "scan", failing_scan)
scan_task = asyncio.create_task(service.scan_ble(6.0))
await asyncio.wait_for(entered.wait(), timeout=1.0)
assert service.state()["devices"] == []
release.set()
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
await asyncio.wait_for(scan_task, timeout=1.0)
asyncio.run(scenario())
assert service.state()["devices"] == []
assert service._devices == [] # noqa: SLF001
assert service._ble_device_last_seen_monotonic == {} # noqa: SLF001
def test_older_ble_scan_cannot_replace_a_newer_generation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
first_entered = asyncio.Event()
release_first = asyncio.Event()
call_count = 0
def scan_result(device_id: str) -> dict[str, Any]:
return {
"devices": [
{
"macos_uuid": device_id,
"name": "XGR-K1",
"local_name": "XGR-K1",
"rssi": -44,
"k1_name_candidate": True,
}
]
}
async def overlapping_scan(_duration_seconds: float) -> dict[str, Any]:
nonlocal call_count
call_count += 1
if call_count == 1:
first_entered.set()
await release_first.wait()
return scan_result("older-generation")
return scan_result("newer-generation")
async def scenario() -> dict[str, Any]:
monkeypatch.setattr(facade_module, "scan", overlapping_scan)
older_task = asyncio.create_task(service.scan_ble(6.0))
await asyncio.wait_for(first_entered.wait(), timeout=1.0)
newer_state = await service.scan_ble(6.0)
release_first.set()
await asyncio.wait_for(older_task, timeout=1.0)
return newer_state
newer_state = asyncio.run(scenario())
assert [item["device_id"] for item in newer_state["devices"]] == ["newer-generation"]
assert [item["device_id"] for item in service.state()["devices"]] == [
"newer-generation"
]
def test_connect_stops_before_ble_write_when_a_new_scan_replaces_its_generation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
preflight_entered = threading.Event()
release_preflight = threading.Event()
def delayed_preflight(*_: object, **__: object) -> dict[str, Any]:
preflight_entered.set()
if not release_preflight.wait(timeout=1.0):
raise RuntimeError("test preflight release timed out")
return {
"schema_version": 1,
"adapter": "macOS Keychain",
"available": True,
"profile_enrolled": False,
"credential_source": "exact-firmware-profile",
}
async def replacement_scan(_duration_seconds: float) -> dict[str, Any]:
return {
"devices": [
{
"macos_uuid": "replacement-device",
"name": "XGR-NEW",
"local_name": "XGR-NEW",
"rssi": -40,
"k1_name_candidate": True,
}
]
}
@asynccontextmanager
async def forbidden_activation(
*_args: object,
**_kwargs: object,
) -> AsyncIterator[dict[str, Any]]:
raise AssertionError("changed discovery generation must stop before BLE write")
yield {}
monkeypatch.setattr(
facade_module,
"ensure_wifi_profile_from_credential_source",
delayed_preflight,
)
monkeypatch.setattr(facade_module, "scan", replacement_scan)
monkeypatch.setattr(facade_module, "device_ap_activation_session", forbidden_activation)
async def scenario() -> None:
connect_task = asyncio.create_task(
service.connect(
ConnectRequest(
device_id="test-ble-transport",
connection_mode="quick-connect",
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
)
)
)
entered = await asyncio.wait_for(
asyncio.to_thread(preflight_entered.wait, 1.0),
timeout=1.5,
)
assert entered is True
replacement_state = await service.scan_ble(6.0)
assert [item["device_id"] for item in replacement_state["devices"]] == [
"replacement-device"
]
release_preflight.set()
with pytest.raises(ValueError, match="изменились или устарели"):
await asyncio.wait_for(connect_task, timeout=1.0)
try:
asyncio.run(scenario())
finally:
release_preflight.set()
def test_verify_connection_adopts_scanned_existing_lan_without_device_write(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
status_reads: list[tuple[str, float, bool]] = []
async def fake_status_read(
device_id: str,
*,
timeout_seconds: float,
rediscover: bool,
) -> dict[str, Any]:
status_reads.append((device_id, timeout_seconds, rediscover))
return _wifi_status_read("10.255.254.77")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "direct-or-routed")
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
state = service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=ATTESTATION,
)
)
assert status_reads == [("test-ble-transport", 20.0, True)]
assert state["selected_device_id"] == "test-ble-transport"
assert state["k1_ip"] == "10.255.254.77"
assert state["connection_mode"] == "bridge"
assert state["device_ref"]["transport_alias"] == "test-ble-transport"
assert state["device_session"]["connectivity"] == "connected"
assert state["compatibility"]["attestation"]["topology"] == "direct-lan"
assert state["connection_verification"] == {
"status": "adopted",
"lease_state": "reachable",
"lease_generation": 1,
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
"network_reachability": "reachable",
"host_route_class": "direct-or-routed",
"address_source": "ble-wifi-status-read",
"connection_origin": "external-existing-network",
"admission_source": "connection.verify",
"address_changed": True,
"previous_address_present": False,
"write_performed": False,
"observed_at": "2026-07-20T12:00:00Z",
}
def test_verify_connection_adoption_requires_current_scan_candidate(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an unscanned device must not be probed")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
with pytest.raises(ValueError, match="найдите и выберите"):
service.verify_connection(
ConnectionVerifyRequest(
device_id="not-in-current-scan",
compatibility_attestation=ATTESTATION,
)
)
@pytest.mark.parametrize(
("ipv4", "is_local", "route_class", "endpoint_reachable", "message"),
[
(None, False, "direct-or-routed", True, "не сообщил актуальный DHCP-адрес"),
("192.168.56.1", False, "device-ap", True, "не сообщил актуальный DHCP-адрес"),
("10.255.254.77", True, "direct-or-routed", True, "этому компьютеру"),
("10.255.254.77", False, "tunnel", True, "прямой локальный маршрут"),
("10.255.254.77", False, "default-route", True, "прямой локальный маршрут"),
("10.255.254.77", False, "direct-or-routed", False, "1883 недоступен"),
],
)
def test_verify_connection_adoption_fails_closed_before_establishing_lease(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
ipv4: str | None,
is_local: bool,
route_class: str,
endpoint_reachable: bool,
message: str,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
async def fake_status_read(*_: object, **__: object) -> dict[str, Any]:
return _wifi_status_read(ipv4)
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: is_local)
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: route_class)
monkeypatch.setattr(
facade_module,
"_control_endpoint_reachable",
lambda _target: endpoint_reachable,
)
with pytest.raises(RuntimeError, match=message):
service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=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["device_session"] is None
assert state["connection_verification"].get("write_performed") is not True
def test_connection_verify_request_requires_exact_bridge_attestation() -> None:
with pytest.raises(ValidationError, match="provided together"):
ConnectionVerifyRequest(device_id="test-ble-transport")
with pytest.raises(ValidationError, match="topology=direct-lan"):
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
)
def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session( def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
@@ -2273,10 +2652,10 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture _set_scanned_devices(
{"device_id": "k1-a"}, service,
{"device_id": "k1-b"}, [{"device_id": "k1-a"}, {"device_id": "k1-b"}],
] )
boundary_calls: list[tuple[str, str, str]] = [] boundary_calls: list[tuple[str, str, str]] = []
async def scenario() -> dict[str, Any]: async def scenario() -> dict[str, Any]:
@@ -2346,7 +2725,7 @@ def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, runtime = service_with_fake_runtime(tmp_path) service, runtime = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
prepared = service.prepare_acquisition( prepared = service.prepare_acquisition(
PrepareAcquisitionRequest( PrepareAcquisitionRequest(
project_name=PROJECT_NAME, project_name=PROJECT_NAME,
@@ -2403,7 +2782,7 @@ def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_s
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
async def fake_provision(*_: object, **__: object) -> dict[str, Any]: async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
return { return {
@@ -2457,7 +2836,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
activation_calls: list[str] = [] activation_calls: list[str] = []
association_calls: list[tuple[Path, str, str]] = [] association_calls: list[tuple[Path, str, str]] = []
ble_session_open = False ble_session_open = False
@@ -2574,7 +2953,7 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
provisioning_calls: list[tuple[str, str, str]] = [] provisioning_calls: list[tuple[str, str, str]] = []
async def fake_provision( async def fake_provision(
@@ -2631,7 +3010,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
service._selected_device_id = "previous-k1" # noqa: SLF001 service._selected_device_id = "previous-k1" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001 service._k1_ip = "192.168.1.20" # noqa: SLF001
service._connection_mode = "bridge" # noqa: SLF001 service._connection_mode = "bridge" # noqa: SLF001
@@ -2688,7 +3067,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
monkeypatch.setattr( monkeypatch.setattr(
facade_module, facade_module,
"ensure_wifi_profile_from_credential_source", "ensure_wifi_profile_from_credential_source",
@@ -2749,7 +3128,7 @@ def test_failed_connection_change_revokes_the_previous_route(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
service._selected_device_id = "previous-k1" # noqa: SLF001 service._selected_device_id = "previous-k1" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001 service._k1_ip = "192.168.1.20" # noqa: SLF001
service._connection_mode = "bridge" # noqa: SLF001 service._connection_mode = "bridge" # noqa: SLF001
@@ -2819,9 +3198,10 @@ def test_failed_connection_change_revokes_the_previous_route(
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,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a"}])
async def fake_provision(*_: object, **__: object) -> dict[str, Any]: async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
return { return {
@@ -2835,7 +3215,10 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision) monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True) monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"): with (
caplog.at_level(logging.ERROR, logger=facade_module.__name__),
pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"),
):
asyncio.run( asyncio.run(
service.connect( service.connect(
ConnectRequest( ConnectRequest(
@@ -2854,6 +3237,19 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
assert operation["status"] == "failed" assert operation["status"] == "failed"
assert operation["error"]["safe_to_retry"] is False assert operation["error"]["safe_to_retry"] is False
assert operation["error"]["side_effect_status"] == "unknown" assert operation["error"]["side_effect_status"] == "unknown"
failure_log = next(
record
for record in caplog.records
if getattr(record, "event_code", None) == "k1_network_provision_failed"
)
assert failure_log.operation_stage == "ble-provisioning-write"
assert failure_log.connection_mode == "bridge"
assert failure_log.error_code == "RuntimeError"
assert failure_log.safe_to_retry is False
assert failure_log.side_effect_status == "unknown"
assert failure_log.network_change_attempted is True
assert PRIMARY_TEST_CREDENTIAL not in caplog.text
assert "lab-network" not in caplog.text
def test_provisioning_cannot_switch_device_during_active_acquisition( def test_provisioning_cannot_switch_device_during_active_acquisition(
@@ -2861,7 +3257,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 _set_scanned_devices(service, [{"device_id": "k1-a"}])
service.prepare_acquisition( service.prepare_acquisition(
PrepareAcquisitionRequest( PrepareAcquisitionRequest(
project_name=PROJECT_NAME, project_name=PROJECT_NAME,
+46
View File
@@ -1,3 +1,10 @@
import asyncio
import pytest
from bleak.exc import BleakDeviceNotFoundError
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
from k1link.device_plugins.xgrids_k1.ble.ap_activation import ( from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
COMMAND_OFFSET, COMMAND_OFFSET,
ENABLE_AP_COMMAND, ENABLE_AP_COMMAND,
@@ -33,3 +40,42 @@ def test_ap_control_mode_without_ready_flag_is_not_ready() -> None:
def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None: def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None:
assert is_ap_ready_status(_status(reserved=1)) assert is_ap_ready_status(_status(reserved=1))
def test_ap_activation_requires_fresh_rediscovery_before_connecting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
stale_handle = object()
rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = []
async def missing_device(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
return None
class ForbiddenClient:
def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device)
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
# Seed the real retained-handle cache so the test fails if the mutating
# path ever regresses to discovered_device(...)-first behavior.
monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001
monkeypatch.setattr(
ap_module.BleakScanner,
"find_device_by_address",
missing_device,
)
monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient)
with pytest.raises(BleakDeviceNotFoundError):
asyncio.run(
ap_module.activate_device_ap_once(
device_id,
timeout_seconds=1.0,
)
)
assert rediscovery_calls == [(device_id, 1.0)]
assert client_calls == []