Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 95 additions & 8 deletions bitcoin_usb/jade_ble_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import collections
import logging
import os
import platform
import re
Expand All @@ -24,6 +25,8 @@
from jadepy import jade_ble as jade_ble_module
from jadepy.jade_ble import JadeBleImpl as BlockstreamJadeBleImpl

logger = logging.getLogger(__name__)

DEFAULT_MAX_AUTH_ATTEMPTS = 3
DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0
DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0
Expand All @@ -38,6 +41,40 @@
_PREFERRED_BLE_ADDRESS: ContextVar[str | None] = ContextVar("_PREFERRED_BLE_ADDRESS", default=None)


class _SafeBleakCallbackLoop:
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop

def create_future(self) -> asyncio.Future[Any]:
return self._loop.create_future()

def call_soon_threadsafe(self, callback: Any, *args: Any) -> Any:
if self._loop.is_closed():
logger.debug("Ignoring late CoreBluetooth callback on closed event loop")
return None
try:
return self._loop.call_soon_threadsafe(callback, *args)
except RuntimeError as e:
if str(e) != "Event loop is closed":
raise
logger.debug("Ignoring late CoreBluetooth callback after event loop shutdown")
return None


def _protect_corebluetooth_manager_loop(manager: Any) -> None:
event_loop = manager.event_loop
if isinstance(event_loop, _SafeBleakCallbackLoop):
return
manager.event_loop = _SafeBleakCallbackLoop(event_loop)


def _protect_corebluetooth_peripheral_delegate_loop(delegate: Any) -> None:
event_loop = delegate._event_loop
if isinstance(event_loop, _SafeBleakCallbackLoop):
return
delegate._event_loop = _SafeBleakCallbackLoop(event_loop)


@contextmanager
def _preferred_ble_address(address: str | None) -> Iterator[None]:
# set() returns a token representing the previous value for this context.
Expand All @@ -59,11 +96,57 @@ def _extract_jade_serial_number(device_name: str) -> str | None:
return match.groupdict().get("serial")


def _protect_corebluetooth_callback_loop(scanner: BleakScanner) -> None:
if platform.system() != "Darwin":
return
backend: Any = scanner._backend
_protect_corebluetooth_manager_loop(backend._manager)


def _protect_corebluetooth_client_callback_loops(client: Any) -> None:
if platform.system() != "Darwin":
return
try:
backend = client._backend
except AttributeError:
return

try:
manager = backend._central_manager_delegate
except AttributeError:
manager = None
if manager is not None:
_protect_corebluetooth_manager_loop(manager)

try:
delegate = backend._delegate
except AttributeError:
delegate = None
if delegate is not None:
_protect_corebluetooth_peripheral_delegate_loop(delegate)


async def _discover_ble_devices_async(scan_timeout: float) -> list[Any]:
scanner = BleakScanner()
_protect_corebluetooth_callback_loop(scanner)
async with scanner:
await asyncio.sleep(max(0.1, scan_timeout))
return scanner.discovered_devices


async def _scan_ble_devices_async(scan_timeout: float) -> list[Any]:
return await _discover_ble_devices_async(scan_timeout=scan_timeout)


def scan_ble_devices(loop_in_thread: LoopInThread, scan_timeout: float) -> list[Any]:
return loop_in_thread.run_foreground(_scan_ble_devices_async(scan_timeout=scan_timeout))


def discover_jade_ble_devices(
loop_in_thread: LoopInThread,
scan_timeout: float = DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS,
) -> list[dict[str, Any]]:
devices = loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(1.0, scan_timeout)))
devices = scan_ble_devices(loop_in_thread, scan_timeout=max(1.0, scan_timeout))
discovered: list[dict[str, Any]] = []
seen_addresses: set[str] = set()

Expand Down Expand Up @@ -193,7 +276,7 @@ async def _input_stream():
self.scan_timeout -= scan_time

devices = await self._await_ble_operation(
BleakScanner.discover(timeout=scan_time),
_discover_ble_devices_async(scan_timeout=scan_time),
timeout_seconds=float(scan_time) + self.gatt_operation_timeout_seconds,
operation_name="discover",
)
Expand Down Expand Up @@ -237,18 +320,22 @@ def _disconnection_handler(client: Any) -> None:
attempts_remaining -= 1
try:
client = jade_ble_module.bleak.BleakClient(
device_mac, disconnected_callback=_disconnection_handler
device_mac,
disconnected_callback=_disconnection_handler,
)
except TypeError:
client = jade_ble_module.bleak.BleakClient(device_mac)
needs_set_disconnection_callback = True

jade_ble_module.logger.info(f"Connecting to: {full_name} ({device_mac})")
await self._await_ble_operation(
client.connect(),
timeout_seconds=self.connect_timeout_seconds,
operation_name="connect",
)
try:
await self._await_ble_operation(
client.connect(),
timeout_seconds=self.connect_timeout_seconds,
operation_name="connect",
)
finally:
_protect_corebluetooth_client_callback_loops(client)
connected = client.is_connected
jade_ble_module.logger.info(f"Connected: {connected}")
except Exception as e:
Expand Down
4 changes: 2 additions & 2 deletions bitcoin_usb/usb_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from bitcoin_usb.address_types import AddressType
from bitcoin_usb.dialogs import DeviceDialog, get_message_box
from bitcoin_usb.jade_ble_client import discover_jade_ble_devices
from bitcoin_usb.jade_ble_client import discover_jade_ble_devices, scan_ble_devices

from .device import USBDevice, bdknetwork_to_chain
from .i18n import translate
Expand All @@ -41,7 +41,7 @@ def can_scan_bluetooth_devices(loop_in_thread: LoopInThread, probe_timeout: floa
return False

try:
loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(0.1, probe_timeout)))
scan_ble_devices(loop_in_thread, scan_timeout=max(0.1, probe_timeout))
except Exception as e:
logger.info("Bluetooth scanning unavailable in this environment: %s", e)
return False
Expand Down
27 changes: 14 additions & 13 deletions tests/test_bluetooth_scan_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,33 @@ def test_can_scan_bluetooth_devices_uses_ble_operation_wrapper(monkeypatch) -> N
calls: dict[str, object] = {"run_called": False}

monkeypatch.setattr(usb_gui, "is_ble_available", lambda: True)
monkeypatch.setattr(usb_gui.BleakScanner, "discover", lambda timeout: {"timeout": timeout})
loop_in_thread = object()

class _LoopInThread:
def run_foreground(self, coroutine):
calls["run_called"] = True
calls["discover_call"] = coroutine
return []
def fake_scan_ble_devices(loop, scan_timeout):
calls["run_called"] = True
calls["loop"] = loop
calls["scan_timeout"] = scan_timeout
return []

loop_in_thread = _LoopInThread()
monkeypatch.setattr(usb_gui, "scan_ble_devices", fake_scan_ble_devices)

assert usb_gui.can_scan_bluetooth_devices(loop_in_thread) is True
assert calls == {
"run_called": True,
"discover_call": {"timeout": 0.2},
"loop": loop_in_thread,
"scan_timeout": 0.2,
}


def test_can_scan_bluetooth_devices_returns_false_on_probe_exception(monkeypatch) -> None:
monkeypatch.setattr(usb_gui, "is_ble_available", lambda: True)

class _LoopInThread:
def run_foreground(self, coroutine):
coroutine.close()
raise RuntimeError("probe failed")
def fail_scan_ble_devices(_loop_in_thread, _scan_timeout):
raise RuntimeError("probe failed")

assert usb_gui.can_scan_bluetooth_devices(_LoopInThread()) is False
monkeypatch.setattr(usb_gui, "scan_ble_devices", fail_scan_ble_devices)

assert usb_gui.can_scan_bluetooth_devices(object()) is False


def test_get_bluetooth_devices_retries_support_probe_on_macos(monkeypatch) -> None:
Expand Down
119 changes: 103 additions & 16 deletions tests/test_jade_ble_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import pytest
from hwilib.devices.jadepy.jade_error import JadeError

from bitcoin_usb.jade_ble_client import CompatibleJadeBleImpl, discover_jade_ble_devices
from bitcoin_usb.jade_ble_client import (
CompatibleJadeBleImpl,
_SafeBleakCallbackLoop,
_protect_corebluetooth_client_callback_loops,
_scan_ble_devices_async,
discover_jade_ble_devices,
)


async def _never_stream():
Expand Down Expand Up @@ -54,22 +60,18 @@ def __init__(self, name: str, address: str) -> None:
self.address = address

captured: dict[str, object] = {}
loop_in_thread = object()

monkeypatch.setattr(
"bitcoin_usb.jade_ble_client.BleakScanner.discover",
lambda timeout: {"timeout": timeout},
)
def fake_scan_ble_devices(loop, scan_timeout):
captured["loop"] = loop
captured["scan_timeout"] = scan_timeout
return [
_Device("Jade ABC123", "AA:BB"),
_Device("Jade ABC123", "AA:BB"),
_Device("Other Device", "CC:DD"),
]

class _LoopInThread:
def run_foreground(self, coroutine):
captured["discover_call"] = coroutine
return [
_Device("Jade ABC123", "AA:BB"),
_Device("Jade ABC123", "AA:BB"),
_Device("Other Device", "CC:DD"),
]

loop_in_thread = _LoopInThread()
monkeypatch.setattr("bitcoin_usb.jade_ble_client.scan_ble_devices", fake_scan_ble_devices)

assert discover_jade_ble_devices(loop_in_thread, scan_timeout=2.5) == [
{
Expand All @@ -84,4 +86,89 @@ def run_foreground(self, coroutine):
"bluetooth_serial_number": "ABC123",
}
]
assert captured["discover_call"] == {"timeout": 2.5}
assert captured["loop"] is loop_in_thread
assert captured["scan_timeout"] == 2.5


def test_safe_bleak_callback_loop_ignores_closed_loop_callbacks() -> None:
loop = asyncio.new_event_loop()
wrapped = _SafeBleakCallbackLoop(loop)
loop.close()

wrapped.call_soon_threadsafe(lambda: None)


def test_scan_ble_devices_async_wraps_darwin_manager_loop(monkeypatch) -> None:
class _Manager:
def __init__(self) -> None:
self.event_loop = asyncio.new_event_loop()

manager = _Manager()
manager_loop = manager.event_loop

class _Backend:
def __init__(self) -> None:
self._manager = manager

class _Scanner:
def __init__(self) -> None:
self._backend = _Backend()
self.discovered_devices = ["device"]

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return None

async def _fake_sleep(_timeout: float) -> None:
return None

monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin")
monkeypatch.setattr("bitcoin_usb.jade_ble_client.BleakScanner", _Scanner)
monkeypatch.setattr("bitcoin_usb.jade_ble_client.asyncio.sleep", _fake_sleep)

loop = asyncio.new_event_loop()
try:
assert loop.run_until_complete(_scan_ble_devices_async(0.2)) == ["device"]
finally:
loop.close()
manager_loop.close()

assert isinstance(manager.event_loop, _SafeBleakCallbackLoop)


def test_protect_corebluetooth_client_callback_loops_wraps_manager_and_delegate(
monkeypatch,
) -> None:
class _Manager:
def __init__(self) -> None:
self.event_loop = asyncio.new_event_loop()

class _Delegate:
def __init__(self) -> None:
self._event_loop = asyncio.new_event_loop()

class _Backend:
def __init__(self) -> None:
self._central_manager_delegate = _Manager()
self._delegate = _Delegate()

class _Client:
def __init__(self) -> None:
self._backend = _Backend()

client = _Client()
manager_loop = client._backend._central_manager_delegate.event_loop
delegate_loop = client._backend._delegate._event_loop

monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin")

try:
_protect_corebluetooth_client_callback_loops(client)
finally:
manager_loop.close()
delegate_loop.close()

assert isinstance(client._backend._central_manager_delegate.event_loop, _SafeBleakCallbackLoop)
assert isinstance(client._backend._delegate._event_loop, _SafeBleakCallbackLoop)
Loading