Fix onboard K1 enrollment continuity and share named acquisition preparation

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 18:53:38 +03:00
parent 4f333fc11c
commit eed53dbc0b
23 changed files with 362 additions and 92 deletions
@@ -5,7 +5,7 @@ import math
import re
import sys
from collections.abc import Callable
from contextlib import suppress
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, replace
from importlib.metadata import version
from threading import Lock
@@ -839,6 +839,35 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
return None
@asynccontextmanager
async def hold_bluez_device_for_connect(device: BLEDevice):
"""Keep discovery alive across the cache-check / D-Bus-connect gap.
BlueZ can discard an unconnected path after StopDiscovery. Holding one
scanner reference until BleakClient connects avoids consuming a path just
removed by that cleanup. This neither selects another device nor repeats
Connect/GATT writes, and is released before any characteristic is read.
"""
details = getattr(device, "details", None)
if not sys.platform.startswith("linux") or not isinstance(details, dict):
yield
return
path = details.get("path", "")
match = re.fullmatch(r"/org/bluez/(hci[0-9]+)/dev_[0-9A-F_]+", path)
if (match is None
or not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", device.address)
or path.rsplit("/", 1)[-1] != "dev_" + device.address.upper().replace(":", "_")):
raise BleakDeviceNotFoundError(device.address, "Invalid selected BlueZ transport")
runtime = ble_runtime_snapshot()
owner = ble_runtime_owner_epoch_for_current_loop()
if (owner is None or runtime["owner_epoch"] != owner
or runtime["poisoned"] or not runtime["owner_loop_bound"]
or runtime["active_operation_kind"] not in {"status-read", "wifi-provision"}):
raise BleakDeviceNotFoundError(device.address, "BLE operation owner changed")
async with BleakScanner(bluez={"adapter": match[1]}):
yield
async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None:
"""Restore a vanished BlueZ path before the one admitted GATT connection.
@@ -4,6 +4,7 @@ import asyncio
import ipaddress
import math
from collections.abc import Callable
from contextlib import AsyncExitStack
from importlib.metadata import version
from time import monotonic
from typing import Literal, TypedDict
@@ -24,6 +25,7 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
discover_known_device_capture_for_status_read,
discovered_device_selection,
ensure_device_for_gatt,
hold_bluez_device_for_connect,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read,
@@ -391,12 +393,16 @@ async def _read_wifi_status_impl(
"Exact BLE device is unavailable; run an explicit recovery or scan.",
)
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
progress.operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
async with AsyncExitStack() as gatt_session:
async with hold_bluez_device_for_connect(device):
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
progress.operation_stage = "connect"
client = await gatt_session.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
progress.operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(
@@ -619,13 +625,17 @@ async def _provision_wifi_impl(
"Device was not rediscovered; keep the K1 powered and nearby.",
)
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
operation_stage = "connect"
progress.operation_stage = operation_stage
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
async with AsyncExitStack() as gatt_session:
async with hold_bluez_device_for_connect(device):
await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds)
if (active_captured_device is not None
and captured_device_handle(active_captured_device) is not device):
raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated")
operation_stage = "connect"
progress.operation_stage = operation_stage
client = await gatt_session.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
@@ -10,6 +10,7 @@ from .facade import (
XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION,
ViewerSettingsRequest,
normalize_project_name,
)
from .node_bridge import ATTESTATION, plugin_operation_id
@@ -106,6 +107,33 @@ def project_sensor(snapshot, node_id):
}
def verification_parameters(state, operation_id):
"""Use the admitted persisted Bridge target before asking for BLE again.
The facade still checks its ledger, exact identity, route, DeviceInfo and
physical reconciliation fences. A saved address alone never grants START.
"""
parameters = {
"expected_snapshot_runtime_id": state["snapshot_runtime_id"],
"operation_id": operation_id,
}
policy = (state.get("connection_policy") or {}).get("actions", {})
decision = policy.get("observe-configured-device-network") or {}
target = decision.get("required_transport_ref")
selected = state.get("selected_device_id")
if (decision.get("allowed") is True
and decision.get("requires_live_gatt_validation") is False
and decision.get("required_connection_mode") == "bridge"
and isinstance(target, str) and isinstance(selected, str)
and target.casefold() == selected.casefold()):
parameters.update(
device_id=target, source="durable-configured-state",
compatibility_attestation=ATTESTATION,
expected_mode_revision=state.get("desired_connection_mode_revision"),
)
return parameters
class NodeK1Sensor:
def __init__(self, bridge, peers):
self.bridge, self.peers = bridge, peers
@@ -194,7 +222,7 @@ class NodeK1Sensor:
if action == "verify":
result = await self.bridge.invoke(
"connection.verify",
{"expected_snapshot_runtime_id": runtime, "operation_id": plugin_identifier},
verification_parameters(state, plugin_identifier),
identifier,
)
return project_sensor(result, node_id)
@@ -224,6 +252,12 @@ class NodeK1Sensor:
)
await self.peers.close_all()
return project_sensor(result, node_id)
# Validate the complete operator draft before workspace/project commands.
if not isinstance(params.get("project_name"), str):
raise ValueError("Введите название проекта")
project_name = normalize_project_name(params["project_name"])
if params.get("mount_type") != "handheld" or params.get("gnss_mode") != "none":
raise ValueError("Unsupported acquisition configuration")
deadline = min(
datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165
)
@@ -253,10 +287,13 @@ class NodeK1Sensor:
self.cas(state),
operation_id=prepare_identifier,
idempotency_key=prepare_identifier,
project_name="node-" + identifier[3:15],
project_name=project_name,
mount_type=params["mount_type"], gnss_mode=params["gnss_mode"],
compatibility_attestation=ATTESTATION,
)
elif phase == "project-ready" and acquisition.get("state") == "prepared":
if acquisition.get("project_name") != project_name:
raise ValueError("Prepared project changed")
next_action = "acquisition.start"
payload.update(
self.cas(state),