fix(k1): restore canonical local connection lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 13:52:46 +03:00
parent 52da9b75b7
commit aff331082f
19 changed files with 2169 additions and 274 deletions
@@ -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)
+164 -9
View File
@@ -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(
+224 -4
View File
@@ -1,9 +1,15 @@
from __future__ import annotations
import hashlib
import json
import os
import stat
import subprocess
import sys
from collections.abc import Callable
import tempfile
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any, Literal, TypedDict
@@ -70,6 +76,14 @@ _ERROR_MESSAGES = {
"host-wifi-operation-timeout": (
"оператор не завершил системное подключение Wi-Fi за отведённое время"
),
"host-wifi-helper-build-timeout": (
"локальный Wi-Fi helper не успел скомпилироваться за отведённое время"
),
"host-wifi-helper-build-failed": "локальный Wi-Fi helper не удалось скомпилировать",
"host-wifi-helper-compiler-unavailable": "компилятор локального Wi-Fi helper недоступен",
"host-wifi-helper-cache-unavailable": "кэш локального Wi-Fi helper недоступен",
"host-wifi-helper-missing": "исходный файл локального Wi-Fi helper не найден",
"host-wifi-helper-unavailable": "локальный Wi-Fi helper недоступен",
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
}
@@ -84,15 +98,20 @@ class HostWifiProfileError(RuntimeError):
*,
scan_attempt_count: int | None = None,
scan_elapsed_ms: int | None = None,
helper_stage: str | None = None,
helper_elapsed_ms: int | None = None,
) -> None:
self.reason_code = reason_code
self.scan_attempt_count = scan_attempt_count
self.scan_elapsed_ms = scan_elapsed_ms
self.helper_stage = helper_stage
self.helper_elapsed_ms = helper_elapsed_ms
message = _ERROR_MESSAGES.get(reason_code, "операция системного Wi-Fi завершилась ошибкой")
super().__init__(f"{message} ({reason_code})")
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
def _validate_profile_id(profile_id: str) -> None:
@@ -105,26 +124,227 @@ def _validate_profile_id(profile_id: str) -> None:
raise ValueError("host Wi-Fi profile id contains unsupported characters")
def _helper_cache_directory(helper_path: Path) -> Path:
"""Resolve the process-local helper cache without an environment override."""
source = helper_path.expanduser().resolve()
for ancestor in source.parents:
if ancestor.name != "plugins":
continue
try:
relative = source.relative_to(ancestor)
except ValueError: # pragma: no cover - guarded by Path.parents
continue
if relative.parts[:2] == ("xgrids-k1", "macos"):
return ancestor.parent / ".runtime" / "mission-core" / "helpers"
# Tests and separately packaged adapters still get a stable cache beside
# their source tree. The repository layout above is the production path.
return source.parent / ".runtime" / "mission-core" / "helpers"
def _compiled_macos_helper_path(helper_path: Path) -> Path:
source = helper_path.expanduser().resolve()
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
return _helper_cache_directory(source) / f"{source.stem}-{source_sha256}"
def _is_ready_executable(path: Path) -> bool:
try:
metadata = path.lstat()
except OSError:
return False
return (
stat.S_ISREG(metadata.st_mode)
and metadata.st_size > 0
and metadata.st_mode & 0o111 != 0
)
@contextmanager
def _exclusive_helper_build_lock(
path: Path,
*,
timeout_seconds: float,
) -> Iterator[None]:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
try:
import fcntl
except ImportError as exc: # pragma: no cover - production target is macOS
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = False
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise HostWifiProfileError("host-wifi-helper-cache-unavailable")
lock_started = time.monotonic()
while True:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError as exc:
elapsed_seconds = max(0.0, time.monotonic() - lock_started)
if elapsed_seconds >= timeout_seconds:
raise HostWifiProfileError(
"host-wifi-helper-build-timeout",
helper_stage="compile-lock",
helper_elapsed_ms=int(elapsed_seconds * 1000),
) from exc
time.sleep(min(0.05, timeout_seconds - elapsed_seconds))
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = True
yield
finally:
if locked:
with suppress(OSError):
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _ensure_compiled_macos_helper(
helper_path: Path,
*,
build_timeout_seconds: float,
runner: RunProcess,
) -> Path:
"""Build the source-hash-addressed helper once and reuse it thereafter."""
if build_timeout_seconds <= 0:
raise ValueError("build_timeout_seconds must be positive")
source = helper_path.expanduser().resolve()
if not source.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
try:
executable = _compiled_macos_helper_path(source)
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
if _is_ready_executable(executable):
return executable
build_started_ns = time.monotonic_ns()
build_deadline = time.monotonic() + build_timeout_seconds
def build_error(
reason_code: str,
*,
helper_stage: str = "compile",
) -> HostWifiProfileError:
elapsed_ms = max(0, (time.monotonic_ns() - build_started_ns) // 1_000_000)
return HostWifiProfileError(
reason_code,
helper_stage=helper_stage,
helper_elapsed_ms=elapsed_ms,
)
lock_path = executable.with_name(f".{executable.name}.lock")
with _exclusive_helper_build_lock(
lock_path,
timeout_seconds=max(0.001, build_deadline - time.monotonic()),
):
if _is_ready_executable(executable):
return executable
try:
descriptor, staging_name = tempfile.mkstemp(
prefix=f".{executable.name}.",
suffix=".tmp",
dir=executable.parent,
)
os.close(descriptor)
staging = Path(staging_name)
staging.unlink()
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
try:
remaining_build_seconds = build_deadline - time.monotonic()
if remaining_build_seconds <= 0:
raise build_error("host-wifi-helper-build-timeout")
try:
completed = runner(
[
"/usr/bin/xcrun",
"swiftc",
str(source),
"-o",
str(staging),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=remaining_build_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise build_error("host-wifi-helper-build-timeout") from exc
except OSError as exc:
raise build_error("host-wifi-helper-compiler-unavailable") from exc
if completed.returncode != 0 or not _is_ready_executable(staging):
raise build_error("host-wifi-helper-build-failed")
try:
staging.chmod(0o700)
with staging.open("rb") as stream:
os.fsync(stream.fileno())
os.replace(staging, executable)
_fsync_directory(executable.parent)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
finally:
with suppress(OSError):
staging.unlink(missing_ok=True)
return executable
def _run_macos_helper(
helper_path: Path,
request: dict[str, object],
*,
timeout_seconds: float,
runner: RunProcess,
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
) -> dict[str, Any]:
if sys.platform != "darwin":
raise HostWifiProfileError("unsupported-platform")
if not helper_path.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
executable = _ensure_compiled_macos_helper(
helper_path,
build_timeout_seconds=build_timeout_seconds,
runner=runner,
)
request_bytes = bytearray(
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
try:
completed = runner(
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
[str(executable)],
input=request_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
+6
View File
@@ -28,6 +28,12 @@ _EXTRA_FIELDS: Final = (
"safe_to_retry",
"side_effect_status",
"network_change_attempted",
"device_write_attempted",
"device_write_confirmed",
"ble_att_error_code",
"ble_att_error_name",
"helper_stage",
"helper_elapsed_ms",
"status_reconciliation",
"network_change_admissible",
"network_change_reconciliation",