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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user