fix(k1): harden BLE discovery and bridge recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 12:43:00 +03:00
parent be50144ca8
commit 52da9b75b7
14 changed files with 1010 additions and 48 deletions
@@ -11,7 +11,6 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4,
SERVICE_UUID,
@@ -128,12 +127,13 @@ async def device_ap_activation_session(
try:
async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid)
if device is None:
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
# AP activation is a mutation boundary: prove that the selected
# device is advertising now instead of trusting a CoreBluetooth
# handle retained by an earlier UI scan.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
@@ -239,12 +239,13 @@ async def provision_wifi_once(
try:
async with asyncio.timeout(timeout_seconds + 25.0):
device = discovered_device(device_macos_uuid)
if device is None:
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
# A provisioning write is a mutation boundary: prove that the
# selected device is advertising now instead of trusting a
# CoreBluetooth handle retained by an earlier UI scan.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
+282 -18
View File
@@ -128,6 +128,7 @@ XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
CONTROL_MQTT_PORT = 1883
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
logger = logging.getLogger(__name__)
@@ -277,6 +278,28 @@ class ConnectRequest(StrictRequest):
return self
class ConnectionVerifyRequest(StrictRequest):
"""Refresh one lease or adopt a scanned K1 already on the direct LAN."""
device_id: str | None = Field(default=None, min_length=1, max_length=128)
compatibility_attestation: CompatibilityAttestationRequest | None = None
@model_validator(mode="after")
def validate_adoption_request(self) -> Self:
has_device = self.device_id is not None
has_attestation = self.compatibility_attestation is not None
if has_device != has_attestation:
raise ValueError(
"device_id and compatibility_attestation must be provided together"
)
if (
self.compatibility_attestation is not None
and self.compatibility_attestation.topology != "direct-lan"
):
raise ValueError("connection.verify adoption requires topology=direct-lan")
return self
class LiveRequest(StrictRequest):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15)
@@ -422,6 +445,8 @@ class XgridsK1CompatibilityService:
self._provisioning_active = False
self._fingerprint_key = secrets.token_bytes(32)
self._devices: list[dict[str, Any]] = []
self._ble_discovery_generation = 0
self._ble_device_last_seen_monotonic: dict[str, float] = {}
self._selected_device_id: str | None = None
self._k1_ip: str | None = None
self._connection_mode: ConnectionMode | None = None
@@ -494,6 +519,25 @@ class XgridsK1CompatibilityService:
allow_device_ap=connection_mode == "quick-connect",
)
def _fresh_ble_devices_locked(
self,
*,
now_monotonic: float | None = None,
) -> list[dict[str, Any]]:
"""Return only candidates leased by the current in-memory scan generation."""
now = time.monotonic() if now_monotonic is None else now_monotonic
fresh: list[dict[str, Any]] = []
for item in self._devices:
device_id = str(item.get("device_id") or "")
last_seen = self._ble_device_last_seen_monotonic.get(device_id)
if last_seen is None:
continue
age_seconds = now - last_seen
if 0.0 <= age_seconds <= BLE_DISCOVERY_LEASE_TTL_SECONDS:
fresh.append(dict(item))
return fresh
@_serialized_acquisition_access
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
@@ -507,7 +551,8 @@ class XgridsK1CompatibilityService:
with self._lock:
operation_phase = self._operation_phase
operation_message = self._operation_message
devices = list(self._devices)
devices = self._fresh_ble_devices_locked()
discovery_stale = bool(self._devices) and not devices
selected_device_id = self._selected_device_id
k1_ip = self._k1_ip
connection_mode = self._connection_mode
@@ -572,6 +617,9 @@ class XgridsK1CompatibilityService:
f"Найдено BLE-устройств: {len(devices)}. "
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
)
elif discovery_stale:
phase = "idle"
message = "Результаты Bluetooth-поиска устарели. Выполните поиск ещё раз."
else:
phase = "idle"
message = runtime["message"]
@@ -686,6 +734,15 @@ class XgridsK1CompatibilityService:
}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
with self._lock:
if self._provisioning_active:
raise RuntimeError("нельзя запускать BLE-поиск во время настройки Wi-Fi")
self._ble_discovery_generation += 1
scan_generation = self._ble_discovery_generation
# Discovery results are an in-memory lease, not a durable catalog.
# A new explicit scan invalidates the previous generation before I/O.
self._devices = []
self._ble_device_last_seen_monotonic = {}
operation, _ = self._operations.begin(
ACTION_DISCOVERY_SCAN,
deadline_seconds=duration_seconds + 10.0,
@@ -710,9 +767,16 @@ class XgridsK1CompatibilityService:
}
for item in result["devices"]
]
observed_monotonic = time.monotonic()
with self._lock:
self._devices = devices
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
if self._ble_discovery_generation == scan_generation:
self._devices = devices
self._ble_device_last_seen_monotonic = {
str(item["device_id"]): observed_monotonic for item in devices
}
self._operation_message = (
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
)
self._operations.transition(
operation.operation_id,
"succeeded",
@@ -721,6 +785,10 @@ class XgridsK1CompatibilityService:
result={"candidate_count": len(devices)},
)
except Exception as exc:
with self._lock:
if self._ble_discovery_generation == scan_generation:
self._devices = []
self._ble_device_last_seen_monotonic = {}
self._operations.transition(
operation.operation_id,
"failed",
@@ -731,16 +799,20 @@ class XgridsK1CompatibilityService:
raise
finally:
with self._lock:
self._operation_phase = None
if self._ble_discovery_generation == scan_generation:
self._operation_phase = None
return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
with self._lock:
scanned_devices = self._fresh_ble_devices_locked()
discovery_generation = self._ble_discovery_generation
known_ids = {str(item["device_id"]) for item in scanned_devices}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
quick_connect = request.connection_mode == "quick-connect"
selected_device = next(
item for item in self.state()["devices"] if item["device_id"] == request.device_id
item for item in scanned_devices if item["device_id"] == request.device_id
)
selected_device_name = str(selected_device.get("name") or "").strip()
control_snapshot = self._application_control_session.snapshot()
@@ -860,6 +932,18 @@ class XgridsK1CompatibilityService:
raise HostWifiProfileError("credential-source-unavailable")
with self._lock:
if (
self._ble_discovery_generation != discovery_generation
or request.device_id
not in {
str(item["device_id"])
for item in self._fresh_ble_devices_locked()
}
):
raise ValueError(
"результаты Bluetooth-поиска изменились или устарели; "
"выполните поиск ещё раз"
)
active_acquisition = self._acquisition
if (
active_acquisition is not None
@@ -1142,21 +1226,41 @@ class XgridsK1CompatibilityService:
evidence_refs=(f"evidence-session-{session_dir.name}",),
)
except Exception as exc:
operation_error = _operation_error(
exc,
category="transport" if quick_connect else "device",
side_effect_status=("unknown" if network_change_attempted else "none"),
safe_to_retry=not network_change_attempted,
)
self._operations.transition_if_pending(
operation.operation_id,
"failed",
stage_code=f"{operation_stage}-failed",
message_code="network.provision.failed",
error=_operation_error(
exc,
category="transport" if quick_connect else "device",
side_effect_status=("unknown" if network_change_attempted else "none"),
safe_to_retry=not network_change_attempted,
),
error=operation_error,
evidence_refs=(
(f"evidence-session-{session_dir.name}",) if session_dir is not None else ()
),
)
# Never log the request, SSID, password, BLE payload, or exception
# text here: lower transport errors may embed sensitive frame data.
# The structured record is sufficient to correlate the HTTP 502
# with its operation stage and retry/side-effect safety contract.
logger.error(
"K1 network provisioning failed",
extra={
"event_code": "k1_network_provision_failed",
"operation_id": operation.operation_id,
"operation_stage": operation_stage,
"connection_mode": request.connection_mode,
"error_category": operation_error["category"],
"error_code": operation_error["code"],
"safe_to_retry": operation_error["safe_to_retry"],
"side_effect_status": operation_error["side_effect_status"],
"network_change_attempted": network_change_attempted,
"automatic_retry": False,
},
)
raise
finally:
password = ""
@@ -1216,12 +1320,169 @@ class XgridsK1CompatibilityService:
payload=segment.payload,
)
def verify_connection(self) -> dict[str, Any]:
"""Refresh the session-scoped DHCP address from the read-only BLE status."""
def verify_connection(
self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]:
"""Refresh a lease or adopt an externally configured direct-LAN K1."""
self._refresh_live_lan_address(rediscover=True)
request = request or ConnectionVerifyRequest()
if request.device_id is None:
self._refresh_live_lan_address(rediscover=True)
else:
assert request.compatibility_attestation is not None
self._adopt_existing_lan_connection(
request.device_id,
request.compatibility_attestation,
)
return self.state()
def _adopt_existing_lan_connection(
self,
device_id: str,
compatibility_attestation: CompatibilityAttestationRequest,
) -> str:
"""Create a process lease from BLE status without changing K1 Wi-Fi state."""
if compatibility_attestation.topology != "direct-lan":
raise ValueError("read-only K1 adoption supports only direct-lan topology")
with self._lock:
scanned_device = next(
(
dict(item)
for item in self._fresh_ble_devices_locked()
if str(item.get("device_id")) == device_id
),
None,
)
discovery_generation = self._ble_discovery_generation
acquisition = self._acquisition
acquisition_session_lease = self._acquisition_session_lease
provisioning_active = self._provisioning_active
previous_target = self._k1_ip
if scanned_device is None:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
if scanned_device.get("connectable") is False:
raise ValueError("выбранное Bluetooth-устройство сейчас недоступно для подключения")
if provisioning_active:
raise RuntimeError("нельзя проверять существующую сеть во время настройки Wi-Fi")
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
"нельзя менять connection lease во время активной acquisition-сессии"
)
if acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
runtime_state = self.runtime.snapshot()
if runtime_state.get("source_mode") != "idle":
raise RuntimeError("нельзя менять connection lease при активном live/replay источнике")
if not self._provisioning_gate.acquire(blocking=False):
raise RuntimeError("другая операция настройки или проверки сети уже выполняется")
try:
status_read = asyncio.run(
read_wifi_status_once(
device_id,
timeout_seconds=20.0,
rediscover=True,
)
)
if (
status_read.get("operation") != "single_reviewed_wifi_status_read"
or status_read.get("write_performed") is not False
):
raise RuntimeError("BLE status read не подтвердил read-only операцию")
observed_target = status_read["status"]["ipv4"]
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
target = validate_private_ipv4(observed_target)
if _target_is_local_ipv4(target):
raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру")
host_route_class = _host_route_class(target)
if host_route_class in {"default-route", "tunnel"}:
raise RuntimeError(
"K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует"
)
if not _control_endpoint_reachable(target):
raise RuntimeError(
"K1 сообщил адрес общей сети, но MQTT endpoint 1883 недоступен"
)
# Retire only local, terminal control ownership after every external
# observation has passed. No scanner command is sent by this call.
self._application_control_session.retire_for_network_change()
observed_at = str(status_read["observed_at_utc"])
with self._lock:
if self._provisioning_active:
raise RuntimeError("настройка Wi-Fi началась во время проверки сети")
if self._ble_discovery_generation != discovery_generation or not any(
str(item.get("device_id")) == device_id for item in self._devices
):
raise RuntimeError("список найденных Bluetooth-устройств изменился")
current_acquisition = self._acquisition
if (
current_acquisition is not None
and current_acquisition.state not in TERMINAL_ACQUISITION_STATES
):
raise RuntimeError(
"acquisition-сессия началась во время проверки существующей сети"
)
if self._acquisition_session_lease is not None:
raise RuntimeError(
"evidence-сессия изменилась во время проверки существующей сети"
)
persistent_device_id = self._device_ids_by_transport_ref.get(device_id)
if persistent_device_id is None:
persistent_device_id = new_device_id()
self._device_ids_by_transport_ref[device_id] = persistent_device_id
self._selected_device_id = device_id
self._k1_ip = target
self._connection_mode = "bridge"
self._device_id = persistent_device_id
self._device_session_id = new_device_session_id()
self._device_session_opened_at = observed_at
self._connection_lease_generation += 1
self._compatibility_attestation = _attestation_snapshot(
compatibility_attestation
)
self._device_calibration = unavailable_device_calibration_snapshot(
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
)
self._connection_verification = {
"status": "adopted",
"lease_state": "reachable",
"lease_generation": self._connection_lease_generation,
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
"network_reachability": "reachable",
"host_route_class": host_route_class,
"address_source": "ble-wifi-status-read",
"connection_origin": "external-existing-network",
"admission_source": ACTION_CONNECTION_VERIFY,
"address_changed": previous_target != target,
"previous_address_present": previous_target is not None,
"write_performed": False,
"observed_at": observed_at,
}
self._operation_message = (
"K1 найден в существующей локальной сети без изменения его настроек."
)
lease_generation = self._connection_lease_generation
logger.info(
"K1 existing direct-LAN connection adopted without provisioning",
extra={
"event_code": "k1_existing_lan_connection_adopted",
"lease_generation": lease_generation,
"connection_origin": "external-existing-network",
"address_source": "ble-wifi-status-read",
"host_route_class": host_route_class,
"endpoint_reachable": True,
"device_write_performed": False,
"automatic_retry": False,
},
)
return target
finally:
self._provisioning_gate.release()
def _refresh_live_lan_address(self, *, rediscover: bool = False) -> str:
with self._lock:
selected_device_id = self._selected_device_id
@@ -3156,7 +3417,10 @@ class XgridsK1ServicePort(Protocol):
def inspect_device(self) -> dict[str, Any]: ...
def verify_connection(self) -> dict[str, Any]: ...
def verify_connection(
self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]: ...
def read_device_calibration_snapshot(self) -> dict[str, Any]: ...
@@ -3282,8 +3546,8 @@ class XgridsK1PluginFacade:
connect_request = ConnectRequest.model_validate(payload)
return await self.service.connect(connect_request)
if action_id == ACTION_CONNECTION_VERIFY:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection)
verify_request = ConnectionVerifyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.verify_connection, verify_request)
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.state)
+7
View File
@@ -13,6 +13,11 @@ SCANNER_DIAGNOSTIC_FILE: Final = "scanner-diagnostics.jsonl"
_HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path"
_EXTRA_FIELDS: Final = (
"event_code",
"operation_id",
"operation_stage",
"connection_mode",
"error_category",
"error_code",
"reason_code",
"failed_phase",
"dialogue_stage",
@@ -21,6 +26,8 @@ _EXTRA_FIELDS: Final = (
"modeling_command_attempted",
"outcome_unknown",
"safe_to_retry",
"side_effect_status",
"network_change_attempted",
"status_reconciliation",
"network_change_admissible",
"network_change_reconciliation",