Refresh selected BlueZ path before K1 GATT operations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user