Refresh selected BlueZ path before K1 GATT operations

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 15:50:06 +03:00
parent 7ce09eabcd
commit ad24518b71
8 changed files with 303 additions and 55 deletions
@@ -0,0 +1,62 @@
# K1 BlueZ selection R11
## Observed failure
The owner reported another Bluetooth failure after installing R10. Read-only
dpkg verification confirms Node 0.8.8 and K1 0.1.7+private.1. The bounded
service-journal error identifies network.provision, _provision_wifi_impl,
BleakClient.connect, add_device_watcher and BlueZManager._check_device.
The failure occurs before GATT contract validation or the Wi-Fi write.
The screenshot's statement that Wi-Fi was not sent is consistent with this
trace. A new screenshot alone does not prove cache clearing or preview acceptance.
[Bleak's Linux client implementation](https://bleak.readthedocs.io/en/stable/_modules/bleak/backends/bluezdbus/client.html)
retains the D-Bus path of a supplied BLEDevice and skips discovery when that
path is already set. A Python object retained through the enrollment form
does not keep the BlueZ object alive. R10 addressed only status reads, leaving
provisioning with the same stale-path failure.
## Change and boundaries
One plugin-local ensure_device_for_gatt preflight now serves both status
reads and provisioning. On Linux it first checks the exact selected BlueZ
path. When absent, it observes the same address on the same adapter once,
for at most eight seconds. It requires that exact native path to exist again
before allowing the original BLEDevice into the single GATT connection.
Neither device selection nor capture identity is replaced. This also preserves
the validated capture handed to the facade's existing session admission.
The preflight requires an active status-read or wifi-provision arbiter owner
and checks that owner after each await. Invalidated captures are rejected
again immediately before GATT. Public discovery generations are unchanged.
Wrong address, different adapter, missing object and owner loss fail before
the baseline read or write. There is no retry after a failed connection or
write, no pairing, notification subscription, alternate device, fallback
network scan or new credential source. The existing durable write-dispatch
fence, reviewed GATT contract, baseline read and frame zeroing remain intact.
CoreBluetooth behavior is unchanged.
The optional K1 package advances to 0.1.8+private.1. Node stays on the installed
0.8.8; its binary and UI need no rebuild. Rerun live acquisition, recorded
review and LAB profiles, preview framing and UI are unchanged by R11.
## Validation
131 focused checks pass across Bluetooth selection, provisioning, scanner,
runtime arbitration, Node bridge and package lifecycle. The 22 native-cache
cases exercise both read and provision with a present/vanished/missing path,
wrong address or adapter, a second disappearance, owner/capture invalidation,
connect failure, write failure and unchanged macOS behavior. They verify
at most one connect and one write, dispatch fencing, original capture handoff
and unchanged public generation.
Eight media tests pass separately with UDP loopback permission. The first
sandboxed combined run timed out in the native WebRTC loopback test; the
same media code passes with the required local socket access. Scoped Ruff and
git diff --check pass. No agent-issued physical K1 commands or Bluetooth scans
were used. Hardware connection and R10 live-preview acceptance remain pending
an owner UI run after clearing the browser cache.
Release hashes and installed readback follow when available. The prior Ops
publication and remote full-journal-copy approval blocks remain unresolved;
this report is local and contains no raw device identifiers or credentials.
@@ -116,3 +116,12 @@ acceptance directory was rejected as well; that journal was not copied.
The owner was asked for explicit permission for an additive report and that
bounded private journal copy. Existing private Fleet/screenshots are retained;
no publication, permission workaround or alternative remote destination was used.
## Installation readback and provisioning follow-up
After the owner's next screenshot, read-only dpkg confirms Node 0.8.8 and
optional K1 0.1.7+private.1 installed. This closes the installation gate only.
The new failure is in network.provision before the first GATT connection:
Bleak's add_device_watcher calls the manager's missing-device check. R10's
status-read-only refresh did not cover this path. The shared native-object
preflight and its remaining physical acceptance are recorded in the R11 audit.
+1 -1
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.7"
VERSION = "0.1.8"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -17,6 +17,7 @@ from uuid import UUID
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
@@ -838,16 +839,60 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev
return None
async def status_read_device_is_current(device: BLEDevice) -> bool:
"""Check a BlueZ handle before the one explicit status-read connection.
async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None:
"""Restore a vanished BlueZ path before the one admitted GATT connection.
BlueZ may remove an unpaired object after a completed scan session. This
cache check neither scans nor connects and never changes macOS selection.
A BLEDevice stores a D-Bus path, not a native object lease. After the form
has been filled in, BlueZ may have removed that path. Observe the same
address on the same adapter once, then require that exact path to exist.
Keep the original selection/capture; no session pin, public scan generation,
GATT connection or write is created here. CoreBluetooth needs no refresh.
"""
details = getattr(device, "details", None)
if not sys.platform.startswith("linux") or not isinstance(details, dict):
return True
return await _retrieve_bluez_device(device.address, details) is not None
return
path = details.get("path", "")
address = device.address
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}", address)
or path.rsplit("/", 1)[-1] != "dev_" + address.upper().replace(":", "_")
or not math.isfinite(timeout_seconds) or timeout_seconds <= 0):
raise BleakDeviceNotFoundError(address, "Invalid selected BlueZ transport")
owner_epoch = ble_runtime_owner_epoch_for_current_loop()
operation_kind = ble_runtime_snapshot()["active_operation_kind"]
def require_owner() -> None:
runtime = ble_runtime_snapshot()
if (owner_epoch is None
or ble_runtime_owner_epoch_for_current_loop() != owner_epoch
or runtime["owner_epoch"] != owner_epoch
or not runtime["owner_loop_bound"] or runtime["poisoned"]
or operation_kind not in {"status-read", "wifi-provision"}
or runtime["active_operation_kind"] != operation_kind):
raise BleakDeviceNotFoundError(address, "BLE operation owner changed")
require_owner()
current = await _retrieve_bluez_device(address, details)
require_owner()
if current is not None:
return
candidate = await BleakScanner.find_device_by_address(
address,
timeout=min(timeout_seconds, 8.0),
bluez={"adapter": match[1]},
)
require_owner()
if (candidate is None or candidate.address.casefold() != address.casefold()
or not isinstance(candidate.details, dict)
or candidate.details.get("path") != path):
raise BleakDeviceNotFoundError(address, "Selected BlueZ transport unavailable")
current = await _retrieve_bluez_device(address, details)
require_owner()
if current is None:
raise BleakDeviceNotFoundError(address, "Selected BlueZ transport disappeared")
async def _retrieve_corebluetooth_device(
@@ -23,10 +23,10 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
demote_connected_device_handle_after_gatt_failure,
discover_known_device_capture_for_status_read,
discovered_device_selection,
ensure_device_for_gatt,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read,
status_read_device_is_current,
)
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
@@ -391,18 +391,10 @@ async def _read_wifi_status_impl(
"Exact BLE device is unavailable; run an explicit recovery or scan.",
)
if not await status_read_device_is_current(device):
# One explicit read may refresh the exact vanished BlueZ object
# before GATT. No failed connect/write is retried; public discovery
# generations, the pinned target and macOS behavior are unchanged.
progress.operation_stage = "exact-uuid-scan"
active_captured_device = await discover_known_device_capture_for_status_read(
device_macos_uuid, timeout_seconds=min(timeout_seconds, 8.0),
)
device = (captured_device_handle(active_captured_device)
if active_captured_device is not None else None)
if device is None:
raise BleakDeviceNotFoundError(device_macos_uuid, "Exact BLE device unavailable")
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:
progress.operation_stage = "gatt-contract"
@@ -627,6 +619,10 @@ 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:
-18
View File
@@ -1702,21 +1702,3 @@ def test_exact_session_invalidation_does_not_clear_new_scan_handle() -> None:
) is not None
asyncio.run(scenario())
@pytest.mark.parametrize("platform,present", [("linux", True), ("linux", False), ("darwin", False)])
def test_status_read_native_cache_check_is_linux_only(monkeypatch, platform, present):
from types import SimpleNamespace
calls = []
device = BLEDevice("AA:BB:CC:DD:EE:FF", "synthetic", {"path": "/synthetic/bluez"})
async def retrieve(address, details):
calls.append((address, details))
return device if present else None
monkeypatch.setattr(scanner_module, "sys", SimpleNamespace(platform=platform))
monkeypatch.setattr(scanner_module, "_retrieve_bluez_device", retrieve)
assert asyncio.run(scanner_module.status_read_device_is_current(device)) is (
present or platform == "darwin")
assert len(calls) == (1 if platform == "linux" else 0)
+170
View File
@@ -0,0 +1,170 @@
"""Native cache loss between owner selection and one admitted GATT operation."""
import asyncio
from types import SimpleNamespace
import pytest
from bleak.backends.device import BLEDevice
from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.device_plugins.xgrids_k1.ble import scanner
from k1link.device_plugins.xgrids_k1.ble import wifi_provisioning as wifi
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,
)
@pytest.fixture(autouse=True)
def isolated_owner(tmp_path):
scanner.reset_runtime_handles_for_tests()
reset_ble_runtime_arbiter_for_tests()
configure_ble_runtime_process_lease(tmp_path)
yield
scanner.reset_runtime_handles_for_tests()
reset_ble_runtime_arbiter_for_tests()
@pytest.mark.parametrize("operation", ["read", "provision"])
@pytest.mark.parametrize("native", [
"macos", "present", "vanished", "absent", "wrong-address", "wrong-adapter",
"vanished-again", "owner-changed", "invalidated", "connect-failed", "write-failed",
])
def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, native):
address = "AA:BB:CC:DD:EE:FF"
path = "/org/bluez/hci7/dev_AA_BB_CC_DD_EE_FF"
device = BLEDevice(address, "synthetic", {"path": path})
events = []
capture = None
validations = []
async def retrieve(selected_address, details):
assert selected_address == address and details["path"] == path
events.append("cache")
present = native == "present" or (
events.count("cache") == 2 and native != "vanished-again"
)
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"}
events.append("scan")
assert events.count("scan") == 1
if native == "absent":
return None
if native == "owner-changed":
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(
"AA:BB:CC:DD:EE:00" if native == "wrong-address" else address,
"synthetic",
{"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path},
)
service = SimpleNamespace(uuid=wifi.SERVICE_UUID)
write = SimpleNamespace(
uuid=wifi.WRITE_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID,
properties=["write"],
)
status = SimpleNamespace(
uuid=wifi.STATUS_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID,
properties=["read"],
)
services = SimpleNamespace(
get_service=lambda uuid: service if uuid == service.uuid else None,
get_characteristic=lambda uuid: {write.uuid: write, status.uuid: status}.get(uuid),
)
class Client:
name = "synthetic"
is_connected = True
def __init__(self, selected, **_kwargs):
# Restore the native path without replacing the owner capture.
assert selected is device
self.services = services
async def __aenter__(self):
events.append("connect")
if native == "connect-failed":
raise BleakError("synthetic native connection failure")
return self
async def __aexit__(self, *_args):
events.append("disconnect")
async def read_gatt_char(self, characteristic):
assert characteristic is status
events.append("read")
value = bytearray(52)
value[0] = 11
value[1:12] = b"WIFI_CLIENT"
value[33] = 4
value[34:38] = bytes((10, 255, 254, 77))
return value
async def write_gatt_char(self, characteristic, _frame, *, response):
assert characteristic is write and response is True
assert events[-2:] == ["read", "dispatch"]
events.append("write")
if native == "write-failed":
raise BleakError("synthetic unconfirmed write")
monkeypatch.setattr(scanner, "sys", SimpleNamespace(
platform="darwin" if native == "macos" else "linux",
))
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve)
monkeypatch.setattr(scanner.BleakScanner, "find_device_by_address", find)
monkeypatch.setattr(wifi, "BleakClient", Client)
async def scenario():
nonlocal capture
owner_epoch = bind_ble_runtime_owner_loop()
capture = scanner.CapturedDiscoveredDevice(
device=device, macos_uuid=address, owner_epoch=owner_epoch,
)
scanner.pin_connected_device_handle(capture, device_session_id="synthetic-session")
generation = scanner._runtime_handle_generation # noqa: SLF001
kwargs = dict(captured_device=capture, timeout_seconds=1)
action = (
wifi.read_wifi_status_once(address, on_gatt_validated=validations.append, **kwargs)
if operation == "read" else
wifi.provision_wifi_once(
address, "SyntheticNet", "x" * 13, write_mode="with_response",
on_write_dispatch=lambda *_args: events.append("dispatch"), **kwargs,
)
)
fails_before_connect = native in {
"absent", "wrong-address", "wrong-adapter", "vanished-again",
"owner-changed", "invalidated",
}
if fails_before_connect or native == "connect-failed" or (
native == "write-failed" and operation == "provision"
):
with pytest.raises(BleakError) as raised:
await action
if fails_before_connect:
assert isinstance(raised.value, BleakDeviceNotFoundError)
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")
else:
result = await action
assert result.get("outcome", "lan_address_observed") == "lan_address_observed"
assert scanner.connected_device_capture(
address, device_session_id="synthetic-session",
).device is device
if operation == "read":
assert validations == [capture]
assert events.count("write") == (operation == "provision")
assert scanner._runtime_handle_generation == generation # noqa: SLF001
asyncio.run(scenario())
assert events.count("scan") == (native not in {"macos", "present"})
assert events.count("connect") <= 1
assert events.count("write") <= 1
+1 -17
View File
@@ -134,10 +134,8 @@ def test_parse_wifi_status_rejects_short_frame() -> None:
parse_wifi_status(bytes(50))
@pytest.mark.parametrize("current_handle", [True, False])
def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
monkeypatch: pytest.MonkeyPatch,
current_handle: bool,
) -> None:
value = bytearray(54)
value[0] = 11
@@ -202,19 +200,6 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
),
)
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
fresh_handle, capture, scans = object(), object(), []
async def is_current(_device):
return current_handle
async def refresh_exact(address, **_kwargs):
scans.append(address)
return capture
monkeypatch.setattr(wifi_module, "status_read_device_is_current", is_current)
monkeypatch.setattr(wifi_module, "discover_known_device_capture_for_status_read", refresh_exact)
monkeypatch.setattr(wifi_module, "captured_device_handle", lambda value: fresh_handle)
monkeypatch.setattr(wifi_module, "mark_captured_device_gatt_validated", lambda *_a, **_k: True)
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid"))
@@ -225,8 +210,7 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
assert result["max_write_without_response_size"] == 244
assert result["mtu_size"] == 256
assert result["status"]["ipv4"] == "10.255.254.77"
assert connected == [retained_handle if current_handle else fresh_handle]
assert scans == ([] if current_handle else ["synthetic-corebluetooth-uuid"])
assert connected == [retained_handle]
def test_read_wifi_status_recovery_keeps_fresh_retained_handle(