Unify K1 discovery ownership and verification across enrollment paths

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 20:04:46 +03:00
parent 9c4d70b0e9
commit 762d77ef95
27 changed files with 615 additions and 191 deletions
+140
View File
@@ -0,0 +1,140 @@
"""Exercise the installed Bleak manager against BlueZ's single-session contract.
Only the D-Bus wire is synthetic. No Bluetooth adapter or device is accessed.
"""
import asyncio
from types import SimpleNamespace
import pytest
from bleak import BleakScanner
from bleak.backends.bluezdbus import defs
from bleak.backends.bluezdbus import manager as manager_module
from bleak.backends.bluezdbus import scanner as backend_module
from bleak.backends.bluezdbus.manager import BlueZManager
from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus
from bleak.backends.device import BLEDevice
from bleak.exc import BleakDBusError
from dbus_fast import Message, MessageType
from k1link.device_plugins.xgrids_k1.ble import scanner
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
bind_ble_runtime_owner_loop,
configure_ble_runtime_process_lease,
reset_ble_runtime_arbiter_for_tests,
run_ble_operation,
)
@pytest.mark.parametrize("kind", ["status-read", "wifi-provision", "ap-enable"])
@pytest.mark.parametrize(
"mode", ["present", "missing", "busy", "absent", "cancelled", "connect-failed"]
)
def test_one_discovery_session_retains_exact_path_until_connect(monkeypatch, tmp_path, kind, mode):
async def scenario():
reset_ble_runtime_arbiter_for_tests()
configure_ble_runtime_process_lease(tmp_path)
bind_ble_runtime_owner_loop()
manager = BlueZManager()
adapter = "/org/bluez/hci7"
address = "AA:BB:CC:DD:EE:FF"
path = adapter + "/dev_AA_BB_CC_DD_EE_FF"
device = BLEDevice(address, "synthetic", {"path": path})
manager._properties[adapter] = {defs.ADAPTER_INTERFACE: {"Powered": True}}
calls = []
discovery_active = False
def advertise():
props = {
"Address": address,
"Alias": "synthetic",
"Name": "synthetic",
"Adapter": adapter,
"RSSI": -45,
}
manager._properties[path] = {defs.DEVICE_INTERFACE: props}
for callback in tuple(manager._advertisement_callbacks[adapter]):
callback(path, props)
class Bus:
async def call(self, message):
nonlocal discovery_active
calls.append(message.member)
if message.member == "StartDiscovery":
if discovery_active or mode == "busy":
return Message(
message_type=MessageType.ERROR,
reply_serial=1,
error_name="org.bluez.Error.InProgress",
signature="s",
body=["Operation already in progress"],
)
discovery_active = True
if mode != "absent":
asyncio.get_running_loop().call_later(0.01, advertise)
elif message.member == "StopDiscovery":
discovery_active = False
manager._properties.pop(path, None)
return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1)
manager._bus = Bus()
if mode == "present":
advertise()
async def get_manager():
return manager
monkeypatch.setattr(manager_module, "get_global_bluez_manager", get_manager)
monkeypatch.setattr(backend_module, "get_global_bluez_manager", get_manager)
monkeypatch.setattr(scanner, "sys", SimpleNamespace(platform="linux"))
class LinuxScanner(BleakScanner):
def __init__(self, *args, **kwargs):
super().__init__(*args, backend=BleakScannerBlueZDBus, **kwargs)
monkeypatch.setattr(scanner, "BleakScanner", LinuxScanner)
async def connect():
async with scanner.selected_device_discovery(device, timeout_seconds=1):
await asyncio.sleep(0)
assert manager.get_device_address(path) == address
assert discovery_active
calls.append("Connect")
if mode == "cancelled":
raise asyncio.CancelledError()
if mode == "connect-failed":
raise ConnectionError("synthetic connection failure")
try:
action = run_ble_operation(
kind, operation=lambda _progress: connect(), hard_timeout_seconds=2
)
expected_error = {
"busy": BleakDBusError,
"absent": TimeoutError,
"cancelled": asyncio.CancelledError,
"connect-failed": ConnectionError,
}.get(mode)
if expected_error:
with pytest.raises(expected_error) as raised:
await action
if mode in {"busy", "absent"}:
assert "Connect" not in calls
assert raised.value.reason_code == (
"ble-discovery-busy"
if mode == "busy"
else "ble-selected-device-unavailable"
)
else:
await action
assert calls.count("StartDiscovery") == 1
assert calls.count("StopDiscovery") == (mode != "busy")
if "Connect" in calls:
assert calls.index("Connect") < calls.index("StopDiscovery")
assert not discovery_active
assert not manager._advertisement_callbacks[adapter]
assert not manager._device_removed_callbacks
finally:
reset_ble_runtime_arbiter_for_tests()
asyncio.run(scenario())
+16 -11
View File
@@ -49,10 +49,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
)
return device if present else None
async def find(selected_address, *, timeout, bluez):
assert selected_address == address
assert 0 < timeout <= 8
assert bluez == {"adapter": "hci7"}
async def advertise(callback):
await asyncio.sleep(0)
events.append("scan")
assert events.count("scan") == 1
if native == "absent":
@@ -61,11 +59,11 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
monkeypatch.setattr(scanner, "ble_runtime_owner_epoch_for_current_loop", lambda: -1)
if native == "invalidated":
scanner.demote_connected_device_handle_after_gatt_failure(capture)
return BLEDevice(
callback(BLEDevice(
"AA:BB:CC:DD:EE:00" if native == "wrong-address" else address,
"synthetic",
{"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path},
)
), None)
service = SimpleNamespace(uuid=wifi.SERVICE_UUID)
write = SimpleNamespace(
@@ -129,17 +127,21 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
))
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve)
class Scanner:
find_device_by_address = staticmethod(find)
def __init__(self, *, bluez):
def __init__(self, *, detection_callback, bluez):
assert bluez == {"adapter": "hci7"}
self.callback = detection_callback
self.task = None
async def __aenter__(self):
events.append("hold-discovery")
if native not in {"present", "cache-cleanup-race"}:
self.task = asyncio.create_task(advertise(self.callback))
return self
async def __aexit__(self, *_args):
events.append("release-discovery")
if self.task:
await self.task
monkeypatch.setattr(scanner, "BleakScanner", Scanner)
monkeypatch.setattr(wifi, "BleakClient", Client)
@@ -172,14 +174,17 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
elif fails_before_connect or native == "connect-failed" or (
native == "write-failed" and operation == "provision"
):
with pytest.raises(BleakError) as raised:
with pytest.raises((BleakError, TimeoutError)) as raised:
await action
if fails_before_connect:
assert isinstance(raised.value, BleakDeviceNotFoundError)
assert isinstance(raised.value, (BleakDeviceNotFoundError, TimeoutError))
assert "connect" not in events
if operation == "provision":
assert raised.value.device_write_attempted is (native == "write-failed")
assert events.count("write") == (native == "write-failed")
if fails_before_connect and native not in {"owner-changed", "invalidated"}:
# A failed discovery has not tested/revoked the original capture.
assert scanner.captured_device_handle(capture) is device
else:
result = await action
assert result.get("outcome", "lan_address_observed") == "lan_address_observed"
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode()
assert "Depends: mission-core-node (>= 0.8.11)" in control
assert "Depends: mission-core-node (>= 0.8.12)" in control
assert "Replaces: mission-core-node (<< 0.8.0)" in control
+57
View File
@@ -198,6 +198,33 @@ def test_failure_locations_respects_suppression_and_bounds_cyclic_chains():
assert failure_locations(first) == "RuntimeError[]"
def test_native_failure_facts_keep_stage_and_write_boundary_without_messages():
from bleak.exc import BleakDBusError
from k1link.device_plugins.xgrids_k1.node_bridge import failure_transport_facts
secret = secrets.token_hex(20)
native = BleakDBusError("org.bluez.Error.InProgress", [secret])
native.operation_stage = "resolution"
native.device_write_attempted = False
native.device_write_confirmed = False
wrapper = RuntimeError(secret)
wrapper.__cause__ = native
facts = failure_transport_facts(wrapper)
assert facts == (
"stage=resolution bluez=org.bluez.Error.InProgress "
"device_write_attempted=False device_write_confirmed=False"
)
assert secret not in facts
native = BleakDBusError(secret, [secret])
native.operation_stage = secret
wrapper.__cause__ = native
assert secret not in failure_transport_facts(wrapper)
wrapper.__suppress_context__ = True
wrapper.__cause__ = None
assert failure_transport_facts(wrapper) == "unavailable"
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
"""Exercise the actual admission boundary, replacing only the BLE service."""
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
@@ -640,6 +667,9 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target():
assert result["source"] == "durable-configured-state"
assert result["device_id"] == current["selected_device_id"]
assert result["expected_mode_revision"] == current["desired_connection_mode_revision"]
assert "source" not in verification_parameters(
current, "synthetic-operation", requested_device_id="other-device",
)
for patch in [{"allowed": False}, {"requires_live_gatt_validation": True},
{"required_connection_mode": "quick-connect"},
{"required_transport_ref": "other"}]:
@@ -648,3 +678,30 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target():
decision.update(allowed=True, requires_live_gatt_validation=False,
required_transport_ref=current["selected_device_id"],
required_connection_mode="bridge")
@pytest.mark.parametrize("requires_gatt", [False, True])
def test_enrollment_verify_and_detail_share_durable_target_admission(requires_gatt):
async def run():
device = bridge()
current = device.facade.current
current["connection_policy"] = {"actions": {"observe-configured-device-network": {
"allowed": True, "requires_live_gatt_validation": requires_gatt,
"required_connection_mode": "bridge",
"required_transport_ref": current["selected_device_id"],
}}}
command = {
"operation_id": "op_" + "d" * 32, "action": "verify", "runtime_id": "runtime-one",
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"parameters": {"device_id": current["selected_device_id"],
"discovery_generation": 1, "mode_revision": 0},
}
await device.execute(command)
actions = [(a, p) for a, p in device.facade.actions if a != "state.read"]
assert len(actions) == 1 and actions[0][0] == "connection.verify"
payload = actions[0][1]
assert (payload.get("source") == "durable-configured-state") is not requires_gatt
assert payload["device_id"] == current["selected_device_id"]
assert payload["expected_discovery_generation"] == 1
assert "password" not in payload
asyncio.run(run())