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 configuration;
let compatibility;
let networkProvisionFailureMessage;
before(async () => {
server = await createServer({
@@ -62,6 +63,9 @@ before(async () => {
({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts",
));
({ networkProvisionFailureMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/useXgridsK1Runtime.ts",
));
});
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.acquisitionStart, "acquisition.start");
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
assert.equal(xgridsK1Actions.connectionVerify, "connection.verify");
});
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.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, /Повторить остановку/);
});
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", () => {
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
const composition = readFileSync(compositionPath, "utf8");
+9
View File
@@ -386,6 +386,11 @@ export interface ConnectRequest {
idempotency_key?: string;
}
export interface ConnectionVerifyRequest {
device_id: string;
compatibility_attestation: CompatibilityAttestation;
}
export interface PrepareAcquisitionRequest {
project_name: string;
mount_type: "handheld";
@@ -593,6 +598,10 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.networkProvision, body);
},
verifyConnection(body?: ConnectionVerifyRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.connectionVerify, body);
},
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
@@ -118,7 +118,7 @@ export function K1ProvisioningPipeline({
phaseLabel: string;
phaseTone: StatusTone;
}) {
const { state, pendingAction, scan, connect } = controller;
const { state, pendingAction, scan, connect, verifyConnection } = controller;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
@@ -160,6 +160,11 @@ export function K1ProvisioningPipeline({
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const canAdoptExistingBridge = connectionMode === "bridge"
&& powerConfirmed
&& selectedDeviceId.length > 0
&& deviceSummary !== undefined
&& !isBusy;
const resetProvisioningIntent = () => {
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 (
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
@@ -275,6 +288,24 @@ export function K1ProvisioningPipeline({
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
: modeCopy.buttonLabel}
</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">
{modeCopy.safetyNote}
{connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."}
@@ -6,6 +6,7 @@ import {
ApiError,
xgridsK1Api,
openEventSocket,
type ConnectionVerifyRequest,
type ConnectRequest,
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
@@ -34,6 +35,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "verify"
| "control"
| "live"
| "replay"
@@ -191,7 +193,7 @@ function messageFor(error: unknown): string {
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function networkProvisionFailureMessage(
export function networkProvisionFailureMessage(
operation: XgridsOperation | null | undefined,
): string | null {
if (!operation || operation.status !== "failed") return null;
@@ -199,6 +201,8 @@ function networkProvisionFailureMessage(
if (typeof code !== "string") return null;
const messages: Record<string, string> = {
BleakGATTProtocolError:
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
"network-not-found":
"Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.",
"credential-entry-cancelled":
@@ -392,6 +396,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
[acceptState, run, state],
);
const verifyConnection = useCallback(
(request?: ConnectionVerifyRequest) =>
run("verify", () => xgridsK1Api.verifyConnection(request)),
[run],
);
const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
@@ -773,6 +783,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null),
scan,
connect,
verifyConnection,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
@@ -11,7 +11,6 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError
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 (
AP_FALLBACK_IPV4,
SERVICE_UUID,
@@ -128,12 +127,13 @@ async def device_ap_activation_session(
try:
async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid)
if device is None:
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
# AP activation is a mutation boundary: prove that the selected
# device is advertising now instead of trusting a CoreBluetooth
# handle retained by an earlier UI scan.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
@@ -239,12 +239,13 @@ async def provision_wifi_once(
try:
async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid)
if device is None:
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
# A provisioning write is a mutation boundary: prove that the
# selected device is advertising now instead of trusting a
# CoreBluetooth handle retained by an earlier UI scan.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
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"
CONTROL_MQTT_PORT = 1883
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
logger = logging.getLogger(__name__)
@@ -277,6 +278,28 @@ class ConnectRequest(StrictRequest):
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):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15)
@@ -422,6 +445,8 @@ class XgridsK1CompatibilityService:
self._provisioning_active = False
self._fingerprint_key = secrets.token_bytes(32)
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._k1_ip: str | None = None
self._connection_mode: ConnectionMode | None = None
@@ -494,6 +519,25 @@ class XgridsK1CompatibilityService:
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
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
@@ -507,7 +551,8 @@ class XgridsK1CompatibilityService:
with self._lock:
operation_phase = self._operation_phase
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
k1_ip = self._k1_ip
connection_mode = self._connection_mode
@@ -572,6 +617,9 @@ class XgridsK1CompatibilityService:
f"Найдено BLE-устройств: {len(devices)}. "
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
)
elif discovery_stale:
phase = "idle"
message = "Результаты Bluetooth-поиска устарели. Выполните поиск ещё раз."
else:
phase = "idle"
message = runtime["message"]
@@ -686,6 +734,15 @@ class XgridsK1CompatibilityService:
}
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(
ACTION_DISCOVERY_SCAN,
deadline_seconds=duration_seconds + 10.0,
@@ -710,9 +767,16 @@ class XgridsK1CompatibilityService:
}
for item in result["devices"]
]
observed_monotonic = time.monotonic()
with self._lock:
self._devices = devices
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
if self._ble_discovery_generation == scan_generation:
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(
operation.operation_id,
"succeeded",
@@ -721,6 +785,10 @@ class XgridsK1CompatibilityService:
result={"candidate_count": len(devices)},
)
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(
operation.operation_id,
"failed",
@@ -731,16 +799,20 @@ class XgridsK1CompatibilityService:
raise
finally:
with self._lock:
self._operation_phase = None
if self._ble_discovery_generation == scan_generation:
self._operation_phase = None
return self.state()
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:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
quick_connect = request.connection_mode == "quick-connect"
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()
control_snapshot = self._application_control_session.snapshot()
@@ -860,6 +932,18 @@ class XgridsK1CompatibilityService:
raise HostWifiProfileError("credential-source-unavailable")
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
if (
active_acquisition is not None
@@ -1142,21 +1226,41 @@ class XgridsK1CompatibilityService:
evidence_refs=(f"evidence-session-{session_dir.name}",),
)
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(
operation.operation_id,
"failed",
stage_code=f"{operation_stage}-failed",
message_code="network.provision.failed",
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,
),
error=operation_error,
evidence_refs=(
(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
finally:
password = ""
@@ -1216,12 +1320,169 @@ class XgridsK1CompatibilityService:
payload=segment.payload,
)
def verify_connection(self) -> dict[str, Any]:
"""Refresh the session-scoped DHCP address from the read-only BLE status."""
def verify_connection(
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()
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:
with self._lock:
selected_device_id = self._selected_device_id
@@ -3156,7 +3417,10 @@ class XgridsK1ServicePort(Protocol):
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]: ...
@@ -3282,8 +3546,8 @@ class XgridsK1PluginFacade:
connect_request = ConnectRequest.model_validate(payload)
return await self.service.connect(connect_request)
if action_id == ACTION_CONNECTION_VERIFY:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection)
verify_request = ConnectionVerifyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection, verify_request)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE:
EmptyRequest.model_validate(payload)
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"
_EXTRA_FIELDS: Final = (
"event_code",
"operation_id",
"operation_stage",
"connection_mode",
"error_category",
"error_code",
"reason_code",
"failed_phase",
"dialogue_stage",
@@ -21,6 +26,8 @@ _EXTRA_FIELDS: Final = (
"modeling_command_attempted",
"outcome_unknown",
"safe_to_retry",
"side_effect_status",
"network_change_attempted",
"status_reconciliation",
"network_change_admissible",
"network_change_reconciliation",
+61
View File
@@ -20,6 +20,7 @@ from pydantic import ValidationError
import k1link.web.device_plugin_composition as plugin_composition
from k1link.device_plugins.xgrids_k1.facade import (
ACTION_CONNECTION_VERIFY,
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION,
@@ -30,6 +31,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION,
CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
@@ -72,6 +74,13 @@ class FakeXgridsService:
self.calls.append(("connect", request))
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(
self,
project_name: str,
@@ -158,6 +167,58 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non
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:
repository_root = Path(__file__).resolve().parents[1]
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",
extra={
"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",
"mqtt_loop_result_code": 7,
"mqtt_loop_result_name": "The connection was lost.",
"mqtt_loop_phase": "post-publish-drain",
"automatic_retry": False,
"side_effect_status": "unknown",
"network_change_attempted": True,
"camera_source_id": "sensor.camera.right",
"evidence_session_id": "20260728T163450Z_viewer_live",
"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.stat().st_mode) == 0o600
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["mqtt_loop_result_code"] == 7
assert document["mqtt_loop_phase"] == "post-publish-drain"
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["evidence_session_id"] == "20260728T163450Z_viewer_live"
assert document["activation_trigger"] == "application-control-scanning"
+40
View File
@@ -3,12 +3,14 @@ from types import SimpleNamespace
from typing import Any
import pytest
from bleak.exc import BleakDeviceNotFoundError
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
FRAME_LENGTH,
build_wifi_provisioning_frame,
parse_wifi_status,
provision_wifi_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"
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 json
import logging
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -18,6 +19,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
AbortAcquisitionRequest,
CameraPreviewSelectRequest,
CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest,
OpenApplicationControlSessionRequest,
OperatorPresenceRequest,
@@ -198,7 +200,7 @@ def service_with_fake_runtime(
return service, runtime
def _wifi_status_read(ipv4: str) -> dict[str, Any]:
def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
return {
"schema_version": 1,
"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(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -2273,10 +2652,10 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture
{"device_id": "k1-a"},
{"device_id": "k1-b"},
]
_set_scanned_devices(
service,
[{"device_id": "k1-a"}, {"device_id": "k1-b"}],
)
boundary_calls: list[tuple[str, str, str]] = []
async def scenario() -> dict[str, Any]:
@@ -2346,7 +2725,7 @@ def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error(
tmp_path: Path,
) -> None:
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(
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
@@ -2403,7 +2782,7 @@ def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_s
tmp_path: Path,
) -> None:
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]:
return {
@@ -2457,7 +2836,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
tmp_path: Path,
) -> None:
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] = []
association_calls: list[tuple[Path, str, str]] = []
ble_session_open = False
@@ -2574,7 +2953,7 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
tmp_path: Path,
) -> None:
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]] = []
async def fake_provision(
@@ -2631,7 +3010,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write(
tmp_path: Path,
) -> None:
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._k1_ip = "192.168.1.20" # 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,
) -> None:
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(
facade_module,
"ensure_wifi_profile_from_credential_source",
@@ -2749,7 +3128,7 @@ def test_failed_connection_change_revokes_the_previous_route(
tmp_path: Path,
) -> None:
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._k1_ip = "192.168.1.20" # 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(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
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]:
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, "_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(
service.connect(
ConnectRequest(
@@ -2854,6 +3237,19 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
assert operation["status"] == "failed"
assert operation["error"]["safe_to_retry"] is False
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(
@@ -2861,7 +3257,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
tmp_path: Path,
) -> None:
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(
PrepareAcquisitionRequest(
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 (
COMMAND_OFFSET,
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:
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 == []