fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from importlib.metadata import version
|
||||
from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
@@ -11,15 +11,18 @@ 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_selection
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
SERVICE_UUID,
|
||||
STATUS_CHARACTERISTIC_UUID,
|
||||
WRITE_CHARACTERISTIC_UUID,
|
||||
BleOperationStage,
|
||||
ResolvedWriteMode,
|
||||
StatusObservation,
|
||||
WifiStatus,
|
||||
WriteMode,
|
||||
_annotate_ble_operation_error,
|
||||
parse_wifi_status,
|
||||
)
|
||||
|
||||
@@ -124,149 +127,198 @@ async def device_ap_activation_session(
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
operation_stage: BleOperationStage = "resolution"
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# 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),
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# Keep the explicit scan and AP activation in one CoreBluetooth
|
||||
# lifecycle. Re-looking up the UUID here lost a physically present
|
||||
# K1 during acceptance, while the retained BLEDevice connected.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
# Preserve a bounded fallback for non-UI callers that did not
|
||||
# establish a fresh explicit scan lease.
|
||||
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,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
raise
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
device_name = client.name
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
async with AsyncExitStack() as client_stack:
|
||||
operation_stage = "connect"
|
||||
try:
|
||||
client = await client_stack.enter_async_context(
|
||||
BleakClient(device, timeout=timeout_seconds, pair=False)
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 AP-control characteristic is attached to an unexpected service"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
raise
|
||||
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = write_characteristic.max_write_without_response_size
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
device_name = client.name
|
||||
operation_stage = "gatt-contract"
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 status characteristic not found: "
|
||||
f"{STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 AP-control characteristic is attached to an unexpected service"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = write_characteristic.max_write_without_response_size
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
operation_stage = "baseline-read"
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
operation_stage = "gatt-write"
|
||||
device_write_attempted = True
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
device_write_confirmed = resolved_write_mode == "with_response"
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
operation_stage = "status-poll"
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
break
|
||||
raise
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if is_ap_ready_status(status):
|
||||
break
|
||||
raise
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
result: ApActivationResult = {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if is_ap_ready_status(status):
|
||||
break
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
raise
|
||||
|
||||
result: ApActivationResult = {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
# Keep the same CoreBluetooth session alive while the caller waits
|
||||
# for and performs the host-side CoreWLAN association. LixelGO does
|
||||
# not tear down this BLE manager between its AP-ready callback and
|
||||
# native Wi-Fi connect call.
|
||||
# native Wi-Fi connect call. Caller exceptions are intentionally not
|
||||
# annotated as BLE failures when they are thrown back through yield.
|
||||
yield result
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib.metadata import version
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -10,8 +12,31 @@ from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
# The operator-visible candidate lease is the admission contract. The exact
|
||||
# CoreBluetooth handle gets a small internal grace window because it is
|
||||
# published just before the facade timestamps the same scan result. This
|
||||
# guarantees that a UI-admissible candidate can never fall into a second UUID
|
||||
# lookup at the millisecond boundary.
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS = 60.0
|
||||
BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS = (
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS + 5.0
|
||||
)
|
||||
# Compatibility alias for callers that historically treated this as the
|
||||
# low-level retained-handle lifetime.
|
||||
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
|
||||
|
||||
_runtime_handle_lock = Lock()
|
||||
_runtime_handles: dict[str, BLEDevice] = {}
|
||||
_runtime_handle_generation = 0
|
||||
_runtime_handle_observed_at_monotonic: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredDeviceSelection:
|
||||
"""One device selection from the latest still-live explicit BLE scan."""
|
||||
|
||||
device: BLEDevice | None
|
||||
from_fresh_scan: bool
|
||||
|
||||
|
||||
class BleDeviceRecord(TypedDict):
|
||||
@@ -62,11 +87,69 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
|
||||
}
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return the live CoreBluetooth handle retained by the latest explicit scan."""
|
||||
def _invalidate_runtime_handles_locked() -> None:
|
||||
global _runtime_handle_observed_at_monotonic
|
||||
|
||||
_runtime_handles.clear()
|
||||
_runtime_handle_observed_at_monotonic = None
|
||||
|
||||
|
||||
def _begin_scan_generation() -> int:
|
||||
global _runtime_handle_generation
|
||||
|
||||
with _runtime_handle_lock:
|
||||
return _runtime_handles.get(macos_uuid)
|
||||
_runtime_handle_generation += 1
|
||||
_invalidate_runtime_handles_locked()
|
||||
return _runtime_handle_generation
|
||||
|
||||
|
||||
def _finish_failed_scan(scan_generation: int) -> None:
|
||||
with _runtime_handle_lock:
|
||||
if _runtime_handle_generation == scan_generation:
|
||||
_invalidate_runtime_handles_locked()
|
||||
|
||||
|
||||
def _publish_scan_handles(
|
||||
scan_generation: int,
|
||||
handles: dict[str, BLEDevice],
|
||||
) -> None:
|
||||
global _runtime_handle_observed_at_monotonic
|
||||
|
||||
with _runtime_handle_lock:
|
||||
if _runtime_handle_generation != scan_generation:
|
||||
return
|
||||
_runtime_handles.update(handles)
|
||||
_runtime_handle_observed_at_monotonic = monotonic()
|
||||
|
||||
|
||||
def discovered_device_selection(macos_uuid: str) -> DiscoveredDeviceSelection:
|
||||
"""Resolve a device against the latest unexpired explicit scan generation.
|
||||
|
||||
``from_fresh_scan`` distinguishes a fresh scan that did not contain the
|
||||
requested device from a caller that has no usable explicit scan lease. A
|
||||
mutating caller may perform fallback discovery only in the latter case.
|
||||
"""
|
||||
|
||||
with _runtime_handle_lock:
|
||||
observed_at = _runtime_handle_observed_at_monotonic
|
||||
if observed_at is None:
|
||||
return DiscoveredDeviceSelection(device=None, from_fresh_scan=False)
|
||||
|
||||
age_seconds = monotonic() - observed_at
|
||||
if age_seconds < 0.0 or age_seconds > BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS:
|
||||
_invalidate_runtime_handles_locked()
|
||||
return DiscoveredDeviceSelection(device=None, from_fresh_scan=False)
|
||||
|
||||
return DiscoveredDeviceSelection(
|
||||
device=_runtime_handles.get(macos_uuid),
|
||||
from_fresh_scan=True,
|
||||
)
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return a CoreBluetooth handle only while its explicit scan lease is fresh."""
|
||||
|
||||
return discovered_device_selection(macos_uuid).device
|
||||
|
||||
|
||||
async def scan(duration_seconds: float) -> BleScanResult:
|
||||
@@ -74,25 +157,36 @@ async def scan(duration_seconds: float) -> BleScanResult:
|
||||
raise ValueError("duration_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
with _runtime_handle_lock:
|
||||
_runtime_handles.clear()
|
||||
_runtime_handles.update(
|
||||
{device.address: device for device, _advertisement in discovered.values()}
|
||||
scan_generation = _begin_scan_generation()
|
||||
try:
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
handles = {device.address: device for device, _advertisement in discovered.values()}
|
||||
devices = [
|
||||
advertisement_record(device, advertisement)
|
||||
for device, advertisement in discovered.values()
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda item: (
|
||||
not item["k1_name_candidate"],
|
||||
-item["rssi"],
|
||||
item["macos_uuid"],
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda item: (not item["k1_name_candidate"], -item["rssi"], item["macos_uuid"])
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"duration_seconds": duration_seconds,
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
result: BleScanResult = {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"duration_seconds": duration_seconds,
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
except BaseException:
|
||||
_finish_failed_scan(scan_generation)
|
||||
raise
|
||||
|
||||
# A slower, superseded scan may return useful data to its own caller, but
|
||||
# it must never replace the handle lease published by a newer generation.
|
||||
_publish_scan_handles(scan_generation, handles)
|
||||
return result
|
||||
|
||||
@@ -7,10 +7,12 @@ from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError, BleakGATTProtocolError
|
||||
|
||||
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.scanner import (
|
||||
discovered_device_selection,
|
||||
)
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
|
||||
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
@@ -28,6 +30,14 @@ ProvisioningOutcome = Literal[
|
||||
]
|
||||
WriteMode = Literal["auto", "with_response", "without_response"]
|
||||
ResolvedWriteMode = Literal["with_response", "without_response"]
|
||||
BleOperationStage = Literal[
|
||||
"resolution",
|
||||
"connect",
|
||||
"gatt-contract",
|
||||
"baseline-read",
|
||||
"gatt-write",
|
||||
"status-poll",
|
||||
]
|
||||
|
||||
|
||||
class WifiStatus(TypedDict):
|
||||
@@ -82,6 +92,23 @@ class WifiStatusReadResult(TypedDict):
|
||||
status: WifiStatus
|
||||
|
||||
|
||||
def _annotate_ble_operation_error(
|
||||
exc: Exception,
|
||||
*,
|
||||
operation_stage: BleOperationStage,
|
||||
device_write_attempted: bool,
|
||||
device_write_confirmed: bool,
|
||||
) -> None:
|
||||
"""Attach non-secret transport facts while preserving the exception type."""
|
||||
|
||||
exc.operation_stage = operation_stage # type: ignore[attr-defined]
|
||||
exc.device_write_attempted = device_write_attempted # type: ignore[attr-defined]
|
||||
exc.device_write_confirmed = device_write_confirmed # type: ignore[attr-defined]
|
||||
if isinstance(exc, BleakGATTProtocolError):
|
||||
exc.att_error_code = int(exc.code) # type: ignore[attr-defined]
|
||||
exc.att_error_name = exc.code.name # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
|
||||
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
|
||||
ssid_bytes = ssid.encode("utf-8")
|
||||
@@ -170,12 +197,14 @@ async def read_wifi_status_once(
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
async with asyncio.timeout(timeout_seconds + 5.0):
|
||||
# A CoreBluetooth handle retained by an earlier scan is an optimization,
|
||||
# not durable connection state. Recovery after sleep, Wi-Fi transition,
|
||||
# or a completed provisioning GATT session must rediscover the device
|
||||
# instead of repeatedly opening a stale handle.
|
||||
device = None if rediscover else discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
# A still-live explicit scan lease is authoritative even for a caller
|
||||
# requesting recovery. Physical acceptance proved that immediately
|
||||
# looking the same CoreBluetooth UUID up again can lose a present K1.
|
||||
# ``rediscover`` therefore permits fallback only after that short lease
|
||||
# has expired; it never discards a fresh retained BLEDevice.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=timeout_seconds,
|
||||
@@ -236,24 +265,36 @@ async def provision_wifi_once(
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
operation_stage: BleOperationStage = "resolution"
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# 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),
|
||||
)
|
||||
# The explicit UI scan and its selected network action are one
|
||||
# CoreBluetooth lifecycle. Physical acceptance proved that a
|
||||
# second UUID lookup can fail moments after a successful scan, so
|
||||
# use the exact retained handle while its short lease is fresh.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
# Non-UI callers without a current explicit scan retain the
|
||||
# bounded lookup fallback. A fresh scan missing this device is
|
||||
# authoritative and must not be silently replaced here.
|
||||
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,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
operation_stage = "connect"
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
device_name = client.name
|
||||
operation_stage = "gatt-contract"
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
@@ -301,17 +342,22 @@ async def provision_wifi_once(
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
operation_stage = "baseline-read"
|
||||
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
baseline = parse_wifi_status(baseline_value)
|
||||
|
||||
operation_stage = "gatt-write"
|
||||
device_write_attempted = True
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
device_write_confirmed = resolved_write_mode == "with_response"
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
operation_stage = "status-poll"
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
@@ -353,5 +399,13 @@ async def provision_wifi_once(
|
||||
"observations": observations,
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
@@ -39,7 +39,12 @@ from k1link.compute.live_perception import LivePerceptionIngress
|
||||
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
device_ap_activation_session,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS as BLE_DISCOVERY_LEASE_TTL_SECONDS,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
scan,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
provision_wifi_once,
|
||||
@@ -128,8 +133,6 @@ 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__)
|
||||
|
||||
ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
|
||||
@@ -149,6 +152,14 @@ class ConnectionLeaseUnavailable(RuntimeError):
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class NetworkWriteReconciliationRequired(RuntimeError):
|
||||
"""A prior K1 network write must be observed before another mutation."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = "network-write-reconciliation-required"
|
||||
|
||||
|
||||
class LocalAcquisitionLifecycleError(RuntimeError):
|
||||
"""A local producer lifecycle invariant failed before scanner authority."""
|
||||
|
||||
@@ -455,6 +466,11 @@ class XgridsK1CompatibilityService:
|
||||
self._device_session_id: str | None = None
|
||||
self._device_session_opened_at: str | None = None
|
||||
self._connection_lease_generation = 0
|
||||
# This is deliberately process-owned and cannot be cleared by a
|
||||
# browser refresh, a new idempotency key, or another discovery scan.
|
||||
# It is raised only when a BLE write may have left device state
|
||||
# unknown; a reviewed read-only status observation clears it.
|
||||
self._network_write_reconciliation: dict[str, Any] | None = None
|
||||
self._connection_verification: dict[str, Any] = {
|
||||
"status": "not-probed",
|
||||
"lease_state": "disconnected",
|
||||
@@ -560,6 +576,11 @@ class XgridsK1CompatibilityService:
|
||||
device_session_id = self._device_session_id
|
||||
device_session_opened_at = self._device_session_opened_at
|
||||
connection_lease_generation = self._connection_lease_generation
|
||||
network_write_reconciliation = (
|
||||
dict(self._network_write_reconciliation)
|
||||
if self._network_write_reconciliation is not None
|
||||
else None
|
||||
)
|
||||
connection_verification = dict(self._connection_verification)
|
||||
compatibility_attestation = (
|
||||
dict(self._compatibility_attestation)
|
||||
@@ -700,6 +721,7 @@ class XgridsK1CompatibilityService:
|
||||
**connection_verification,
|
||||
"lease_generation": connection_lease_generation,
|
||||
},
|
||||
"network_write_reconciliation": network_write_reconciliation,
|
||||
"sensor_catalog": _sensor_catalog(
|
||||
active_profile_id,
|
||||
device_session_id,
|
||||
@@ -807,9 +829,36 @@ class XgridsK1CompatibilityService:
|
||||
with self._lock:
|
||||
scanned_devices = self._fresh_ble_devices_locked()
|
||||
discovery_generation = self._ble_discovery_generation
|
||||
network_write_reconciliation = (
|
||||
dict(self._network_write_reconciliation)
|
||||
if self._network_write_reconciliation is not None
|
||||
else None
|
||||
)
|
||||
known_ids = {str(item["device_id"]) for item in scanned_devices}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
if network_write_reconciliation is not None:
|
||||
logger.warning(
|
||||
"K1 network mutation blocked pending read-only reconciliation",
|
||||
extra={
|
||||
"event_code": "k1_network_write_reconciliation_required",
|
||||
"operation_id": network_write_reconciliation.get("operation_id"),
|
||||
"operation_stage": network_write_reconciliation.get("operation_stage"),
|
||||
"connection_mode": network_write_reconciliation.get("connection_mode"),
|
||||
"reason_code": "network-write-reconciliation-required",
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": network_write_reconciliation.get(
|
||||
"device_write_confirmed",
|
||||
False,
|
||||
),
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
raise NetworkWriteReconciliationRequired(
|
||||
"предыдущая BLE-запись завершилась до подтверждения актуального "
|
||||
"состояния K1; новая запись заблокирована. Выполните свежий "
|
||||
"Bluetooth-поиск и read-only проверку существующего Bridge-подключения"
|
||||
)
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
selected_device = next(
|
||||
item for item in scanned_devices if item["device_id"] == request.device_id
|
||||
@@ -903,7 +952,9 @@ class XgridsK1CompatibilityService:
|
||||
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
|
||||
|
||||
session_dir: Path | None = None
|
||||
network_change_attempted = False
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
device_state_reconciled = False
|
||||
retired_ingress_session_id: str | None = None
|
||||
operation_stage = "device-ap-activation" if quick_connect else "ble-provisioning-write"
|
||||
try:
|
||||
@@ -1019,7 +1070,6 @@ class XgridsK1CompatibilityService:
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.running",
|
||||
)
|
||||
network_change_attempted = True
|
||||
if quick_connect:
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "device-ap-activation"
|
||||
@@ -1028,6 +1078,18 @@ class XgridsK1CompatibilityService:
|
||||
timeout_seconds=15.0,
|
||||
write_mode="auto",
|
||||
) as activation:
|
||||
device_write_attempted = bool(activation["write_performed"])
|
||||
device_write_confirmed = bool(
|
||||
activation["write_performed"]
|
||||
and (
|
||||
activation["write_mode"] == "with_response"
|
||||
or activation["ready_observed"]
|
||||
)
|
||||
)
|
||||
# Only an actual post-write 7f02 observation reconciles
|
||||
# device state. A GATT acknowledgement alone confirms the
|
||||
# transport write, not the network state K1 retained.
|
||||
device_state_reconciled = bool(activation.get("observations"))
|
||||
write_json_atomic(
|
||||
session_dir / "ap-activation.redacted.json",
|
||||
activation,
|
||||
@@ -1114,6 +1176,19 @@ class XgridsK1CompatibilityService:
|
||||
timeout_seconds=45.0,
|
||||
write_mode="auto",
|
||||
)
|
||||
device_write_attempted = True
|
||||
# A successful return may still describe a disconnect before
|
||||
# any post-write status was read. Keep the ambiguity fence in
|
||||
# that case even when write-with-response was acknowledged.
|
||||
observations = result.get("observations") or []
|
||||
device_state_reconciled = bool(observations)
|
||||
device_write_confirmed = bool(
|
||||
result.get("write_mode") == "with_response"
|
||||
or (
|
||||
observations
|
||||
and observations[-1].get("status") != result.get("baseline_status")
|
||||
)
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
connection_manifest = {
|
||||
@@ -1155,6 +1230,7 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest)
|
||||
with self._lock:
|
||||
self._network_write_reconciliation = None
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._connection_mode = request.connection_mode
|
||||
@@ -1226,11 +1302,44 @@ class XgridsK1CompatibilityService:
|
||||
evidence_refs=(f"evidence-session-{session_dir.name}",),
|
||||
)
|
||||
except Exception as exc:
|
||||
annotated_stage = getattr(exc, "operation_stage", None)
|
||||
if isinstance(annotated_stage, str) and annotated_stage:
|
||||
operation_stage = annotated_stage
|
||||
device_write_attempted = bool(
|
||||
getattr(exc, "device_write_attempted", device_write_attempted)
|
||||
)
|
||||
device_write_confirmed = bool(
|
||||
getattr(exc, "device_write_confirmed", device_write_confirmed)
|
||||
)
|
||||
if device_write_confirmed:
|
||||
device_write_attempted = True
|
||||
if device_write_attempted and not device_state_reconciled:
|
||||
with self._lock:
|
||||
self._network_write_reconciliation = {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": operation.operation_id,
|
||||
"transport_ref": request.device_id,
|
||||
"connection_mode": request.connection_mode,
|
||||
"operation_stage": operation_stage,
|
||||
"reason_code": getattr(exc, "reason_code", None)
|
||||
or type(exc).__name__,
|
||||
"device_write_confirmed": device_write_confirmed,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": _utc_now_iso(),
|
||||
}
|
||||
side_effect_status: Literal["none", "confirmed", "unknown"] = (
|
||||
"confirmed"
|
||||
if device_write_confirmed and device_state_reconciled
|
||||
else "unknown"
|
||||
if device_write_attempted
|
||||
else "none"
|
||||
)
|
||||
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,
|
||||
side_effect_status=side_effect_status,
|
||||
safe_to_retry=not device_write_attempted,
|
||||
)
|
||||
self._operations.transition_if_pending(
|
||||
operation.operation_id,
|
||||
@@ -1257,7 +1366,13 @@ class XgridsK1CompatibilityService:
|
||||
"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,
|
||||
"network_change_attempted": device_write_attempted,
|
||||
"device_write_attempted": device_write_attempted,
|
||||
"device_write_confirmed": device_write_confirmed,
|
||||
"ble_att_error_code": operation_error.get("ble_att_error_code"),
|
||||
"ble_att_error_name": operation_error.get("ble_att_error_name"),
|
||||
"helper_stage": operation_error.get("helper_stage"),
|
||||
"helper_elapsed_ms": operation_error.get("helper_elapsed_ms"),
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
@@ -1391,6 +1506,29 @@ class XgridsK1CompatibilityService:
|
||||
or status_read.get("write_performed") is not False
|
||||
):
|
||||
raise RuntimeError("BLE status read не подтвердил read-only операцию")
|
||||
if status_read.get("device_macos_uuid") != device_id:
|
||||
raise RuntimeError("BLE status read вернул состояние другого устройства")
|
||||
# The exact current 7f02 state has now been observed without a
|
||||
# device write. Clear only the process-owned ambiguity fence; all
|
||||
# route/admission checks below still have to pass independently.
|
||||
with self._lock:
|
||||
reconciliation = self._network_write_reconciliation
|
||||
reconciliation_cleared = bool(
|
||||
reconciliation is not None
|
||||
and reconciliation.get("transport_ref") == device_id
|
||||
)
|
||||
if reconciliation_cleared:
|
||||
self._network_write_reconciliation = None
|
||||
if reconciliation_cleared:
|
||||
logger.info(
|
||||
"K1 network write ambiguity reconciled by read-only status",
|
||||
extra={
|
||||
"event_code": "k1_network_write_reconciled",
|
||||
"reason_code": "read-only-ble-status-observed",
|
||||
"device_write_performed": False,
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
observed_target = status_read["status"]["ipv4"]
|
||||
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
|
||||
@@ -3659,13 +3797,30 @@ def _operation_error(
|
||||
safe_to_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
reason_code = getattr(exc, "reason_code", None)
|
||||
return {
|
||||
error: dict[str, Any] = {
|
||||
"category": category,
|
||||
"code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__,
|
||||
"retryable": False,
|
||||
"safe_to_retry": safe_to_retry,
|
||||
"side_effect_status": side_effect_status,
|
||||
}
|
||||
safe_fields = {
|
||||
"operation_stage": getattr(exc, "operation_stage", None),
|
||||
"device_write_attempted": getattr(exc, "device_write_attempted", None),
|
||||
"device_write_confirmed": getattr(exc, "device_write_confirmed", None),
|
||||
"ble_att_error_code": getattr(exc, "att_error_code", None),
|
||||
"ble_att_error_name": getattr(exc, "att_error_name", None),
|
||||
"helper_stage": getattr(exc, "helper_stage", None),
|
||||
"helper_elapsed_ms": getattr(exc, "helper_elapsed_ms", None),
|
||||
}
|
||||
error.update(
|
||||
{
|
||||
field: value
|
||||
for field, value in safe_fields.items()
|
||||
if isinstance(value, (str, int, bool))
|
||||
}
|
||||
)
|
||||
return error
|
||||
|
||||
|
||||
def _validated_requested_streams(
|
||||
|
||||
Reference in New Issue
Block a user