fix(k1): harden BLE discovery and bridge recovery
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user