Skip to content

Commit dcf3938

Browse files
Merge pull request #67 from andreasgriffin/fix-ble-mac-issue
Fix bluetooth issue on Mac
2 parents 79e6334 + 33b3712 commit dcf3938

4 files changed

Lines changed: 214 additions & 39 deletions

File tree

bitcoin_usb/jade_ble_client.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import collections
3+
import logging
34
import os
45
import platform
56
import re
@@ -24,6 +25,8 @@
2425
from jadepy import jade_ble as jade_ble_module
2526
from jadepy.jade_ble import JadeBleImpl as BlockstreamJadeBleImpl
2627

28+
logger = logging.getLogger(__name__)
29+
2730
DEFAULT_MAX_AUTH_ATTEMPTS = 3
2831
DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0
2932
DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0
@@ -38,6 +41,40 @@
3841
_PREFERRED_BLE_ADDRESS: ContextVar[str | None] = ContextVar("_PREFERRED_BLE_ADDRESS", default=None)
3942

4043

44+
class _SafeBleakCallbackLoop:
45+
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
46+
self._loop = loop
47+
48+
def create_future(self) -> asyncio.Future[Any]:
49+
return self._loop.create_future()
50+
51+
def call_soon_threadsafe(self, callback: Any, *args: Any) -> Any:
52+
if self._loop.is_closed():
53+
logger.debug("Ignoring late CoreBluetooth callback on closed event loop")
54+
return None
55+
try:
56+
return self._loop.call_soon_threadsafe(callback, *args)
57+
except RuntimeError as e:
58+
if str(e) != "Event loop is closed":
59+
raise
60+
logger.debug("Ignoring late CoreBluetooth callback after event loop shutdown")
61+
return None
62+
63+
64+
def _protect_corebluetooth_manager_loop(manager: Any) -> None:
65+
event_loop = manager.event_loop
66+
if isinstance(event_loop, _SafeBleakCallbackLoop):
67+
return
68+
manager.event_loop = _SafeBleakCallbackLoop(event_loop)
69+
70+
71+
def _protect_corebluetooth_peripheral_delegate_loop(delegate: Any) -> None:
72+
event_loop = delegate._event_loop
73+
if isinstance(event_loop, _SafeBleakCallbackLoop):
74+
return
75+
delegate._event_loop = _SafeBleakCallbackLoop(event_loop)
76+
77+
4178
@contextmanager
4279
def _preferred_ble_address(address: str | None) -> Iterator[None]:
4380
# set() returns a token representing the previous value for this context.
@@ -59,11 +96,57 @@ def _extract_jade_serial_number(device_name: str) -> str | None:
5996
return match.groupdict().get("serial")
6097

6198

99+
def _protect_corebluetooth_callback_loop(scanner: BleakScanner) -> None:
100+
if platform.system() != "Darwin":
101+
return
102+
backend: Any = scanner._backend
103+
_protect_corebluetooth_manager_loop(backend._manager)
104+
105+
106+
def _protect_corebluetooth_client_callback_loops(client: Any) -> None:
107+
if platform.system() != "Darwin":
108+
return
109+
try:
110+
backend = client._backend
111+
except AttributeError:
112+
return
113+
114+
try:
115+
manager = backend._central_manager_delegate
116+
except AttributeError:
117+
manager = None
118+
if manager is not None:
119+
_protect_corebluetooth_manager_loop(manager)
120+
121+
try:
122+
delegate = backend._delegate
123+
except AttributeError:
124+
delegate = None
125+
if delegate is not None:
126+
_protect_corebluetooth_peripheral_delegate_loop(delegate)
127+
128+
129+
async def _discover_ble_devices_async(scan_timeout: float) -> list[Any]:
130+
scanner = BleakScanner()
131+
_protect_corebluetooth_callback_loop(scanner)
132+
async with scanner:
133+
await asyncio.sleep(max(0.1, scan_timeout))
134+
return scanner.discovered_devices
135+
136+
137+
async def _scan_ble_devices_async(scan_timeout: float) -> list[Any]:
138+
return await _discover_ble_devices_async(scan_timeout=scan_timeout)
139+
140+
141+
def scan_ble_devices(loop_in_thread: LoopInThread, scan_timeout: float) -> list[Any]:
142+
return loop_in_thread.run_foreground(_scan_ble_devices_async(scan_timeout=scan_timeout))
143+
144+
62145
def discover_jade_ble_devices(
63146
loop_in_thread: LoopInThread,
64147
scan_timeout: float = DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS,
65148
) -> list[dict[str, Any]]:
66-
devices = loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(1.0, scan_timeout)))
149+
devices = scan_ble_devices(loop_in_thread, scan_timeout=max(1.0, scan_timeout))
67150
discovered: list[dict[str, Any]] = []
68151
seen_addresses: set[str] = set()
69152

@@ -193,7 +276,7 @@ async def _input_stream():
193276
self.scan_timeout -= scan_time
194277

195278
devices = await self._await_ble_operation(
196-
BleakScanner.discover(timeout=scan_time),
279+
_discover_ble_devices_async(scan_timeout=scan_time),
197280
timeout_seconds=float(scan_time) + self.gatt_operation_timeout_seconds,
198281
operation_name="discover",
199282
)
@@ -237,18 +320,22 @@ def _disconnection_handler(client: Any) -> None:
237320
attempts_remaining -= 1
238321
try:
239322
client = jade_ble_module.bleak.BleakClient(
240-
device_mac, disconnected_callback=_disconnection_handler
323+
device_mac,
324+
disconnected_callback=_disconnection_handler,
241325
)
242326
except TypeError:
243327
client = jade_ble_module.bleak.BleakClient(device_mac)
244328
needs_set_disconnection_callback = True
245329

246330
jade_ble_module.logger.info(f"Connecting to: {full_name} ({device_mac})")
247-
await self._await_ble_operation(
248-
client.connect(),
249-
timeout_seconds=self.connect_timeout_seconds,
250-
operation_name="connect",
251-
)
331+
try:
332+
await self._await_ble_operation(
333+
client.connect(),
334+
timeout_seconds=self.connect_timeout_seconds,
335+
operation_name="connect",
336+
)
337+
finally:
338+
_protect_corebluetooth_client_callback_loops(client)
252339
connected = client.is_connected
253340
jade_ble_module.logger.info(f"Connected: {connected}")
254341
except Exception as e:

bitcoin_usb/usb_gui.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from bitcoin_usb.address_types import AddressType
2222
from bitcoin_usb.dialogs import DeviceDialog, get_message_box
23-
from bitcoin_usb.jade_ble_client import discover_jade_ble_devices
23+
from bitcoin_usb.jade_ble_client import discover_jade_ble_devices, scan_ble_devices
2424

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

4343
try:
44-
loop_in_thread.run_foreground(BleakScanner.discover(timeout=max(0.1, probe_timeout)))
44+
scan_ble_devices(loop_in_thread, scan_timeout=max(0.1, probe_timeout))
4545
except Exception as e:
4646
logger.info("Bluetooth scanning unavailable in this environment: %s", e)
4747
return False

tests/test_bluetooth_scan_support.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,33 @@ def test_can_scan_bluetooth_devices_uses_ble_operation_wrapper(monkeypatch) -> N
66
calls: dict[str, object] = {"run_called": False}
77

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

11-
class _LoopInThread:
12-
def run_foreground(self, coroutine):
13-
calls["run_called"] = True
14-
calls["discover_call"] = coroutine
15-
return []
11+
def fake_scan_ble_devices(loop, scan_timeout):
12+
calls["run_called"] = True
13+
calls["loop"] = loop
14+
calls["scan_timeout"] = scan_timeout
15+
return []
1616

17-
loop_in_thread = _LoopInThread()
17+
monkeypatch.setattr(usb_gui, "scan_ble_devices", fake_scan_ble_devices)
1818

1919
assert usb_gui.can_scan_bluetooth_devices(loop_in_thread) is True
2020
assert calls == {
2121
"run_called": True,
22-
"discover_call": {"timeout": 0.2},
22+
"loop": loop_in_thread,
23+
"scan_timeout": 0.2,
2324
}
2425

2526

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

29-
class _LoopInThread:
30-
def run_foreground(self, coroutine):
31-
coroutine.close()
32-
raise RuntimeError("probe failed")
30+
def fail_scan_ble_devices(_loop_in_thread, _scan_timeout):
31+
raise RuntimeError("probe failed")
3332

34-
assert usb_gui.can_scan_bluetooth_devices(_LoopInThread()) is False
33+
monkeypatch.setattr(usb_gui, "scan_ble_devices", fail_scan_ble_devices)
34+
35+
assert usb_gui.can_scan_bluetooth_devices(object()) is False
3536

3637

3738
def test_get_bluetooth_devices_retries_support_probe_on_macos(monkeypatch) -> None:

tests/test_jade_ble_client.py

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
import pytest
44
from hwilib.devices.jadepy.jade_error import JadeError
55

6-
from bitcoin_usb.jade_ble_client import CompatibleJadeBleImpl, discover_jade_ble_devices
6+
from bitcoin_usb.jade_ble_client import (
7+
CompatibleJadeBleImpl,
8+
_SafeBleakCallbackLoop,
9+
_protect_corebluetooth_client_callback_loops,
10+
_scan_ble_devices_async,
11+
discover_jade_ble_devices,
12+
)
713

814

915
async def _never_stream():
@@ -54,22 +60,18 @@ def __init__(self, name: str, address: str) -> None:
5460
self.address = address
5561

5662
captured: dict[str, object] = {}
63+
loop_in_thread = object()
5764

58-
monkeypatch.setattr(
59-
"bitcoin_usb.jade_ble_client.BleakScanner.discover",
60-
lambda timeout: {"timeout": timeout},
61-
)
65+
def fake_scan_ble_devices(loop, scan_timeout):
66+
captured["loop"] = loop
67+
captured["scan_timeout"] = scan_timeout
68+
return [
69+
_Device("Jade ABC123", "AA:BB"),
70+
_Device("Jade ABC123", "AA:BB"),
71+
_Device("Other Device", "CC:DD"),
72+
]
6273

63-
class _LoopInThread:
64-
def run_foreground(self, coroutine):
65-
captured["discover_call"] = coroutine
66-
return [
67-
_Device("Jade ABC123", "AA:BB"),
68-
_Device("Jade ABC123", "AA:BB"),
69-
_Device("Other Device", "CC:DD"),
70-
]
71-
72-
loop_in_thread = _LoopInThread()
74+
monkeypatch.setattr("bitcoin_usb.jade_ble_client.scan_ble_devices", fake_scan_ble_devices)
7375

7476
assert discover_jade_ble_devices(loop_in_thread, scan_timeout=2.5) == [
7577
{
@@ -84,4 +86,89 @@ def run_foreground(self, coroutine):
8486
"bluetooth_serial_number": "ABC123",
8587
}
8688
]
87-
assert captured["discover_call"] == {"timeout": 2.5}
89+
assert captured["loop"] is loop_in_thread
90+
assert captured["scan_timeout"] == 2.5
91+
92+
93+
def test_safe_bleak_callback_loop_ignores_closed_loop_callbacks() -> None:
94+
loop = asyncio.new_event_loop()
95+
wrapped = _SafeBleakCallbackLoop(loop)
96+
loop.close()
97+
98+
wrapped.call_soon_threadsafe(lambda: None)
99+
100+
101+
def test_scan_ble_devices_async_wraps_darwin_manager_loop(monkeypatch) -> None:
102+
class _Manager:
103+
def __init__(self) -> None:
104+
self.event_loop = asyncio.new_event_loop()
105+
106+
manager = _Manager()
107+
manager_loop = manager.event_loop
108+
109+
class _Backend:
110+
def __init__(self) -> None:
111+
self._manager = manager
112+
113+
class _Scanner:
114+
def __init__(self) -> None:
115+
self._backend = _Backend()
116+
self.discovered_devices = ["device"]
117+
118+
async def __aenter__(self):
119+
return self
120+
121+
async def __aexit__(self, exc_type, exc, tb):
122+
return None
123+
124+
async def _fake_sleep(_timeout: float) -> None:
125+
return None
126+
127+
monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin")
128+
monkeypatch.setattr("bitcoin_usb.jade_ble_client.BleakScanner", _Scanner)
129+
monkeypatch.setattr("bitcoin_usb.jade_ble_client.asyncio.sleep", _fake_sleep)
130+
131+
loop = asyncio.new_event_loop()
132+
try:
133+
assert loop.run_until_complete(_scan_ble_devices_async(0.2)) == ["device"]
134+
finally:
135+
loop.close()
136+
manager_loop.close()
137+
138+
assert isinstance(manager.event_loop, _SafeBleakCallbackLoop)
139+
140+
141+
def test_protect_corebluetooth_client_callback_loops_wraps_manager_and_delegate(
142+
monkeypatch,
143+
) -> None:
144+
class _Manager:
145+
def __init__(self) -> None:
146+
self.event_loop = asyncio.new_event_loop()
147+
148+
class _Delegate:
149+
def __init__(self) -> None:
150+
self._event_loop = asyncio.new_event_loop()
151+
152+
class _Backend:
153+
def __init__(self) -> None:
154+
self._central_manager_delegate = _Manager()
155+
self._delegate = _Delegate()
156+
157+
class _Client:
158+
def __init__(self) -> None:
159+
self._backend = _Backend()
160+
161+
client = _Client()
162+
manager_loop = client._backend._central_manager_delegate.event_loop
163+
delegate_loop = client._backend._delegate._event_loop
164+
165+
monkeypatch.setattr("bitcoin_usb.jade_ble_client.platform.system", lambda: "Darwin")
166+
167+
try:
168+
_protect_corebluetooth_client_callback_loops(client)
169+
finally:
170+
manager_loop.close()
171+
delegate_loop.close()
172+
173+
assert isinstance(client._backend._central_manager_delegate.event_loop, _SafeBleakCallbackLoop)
174+
assert isinstance(client._backend._delegate._event_loop, _SafeBleakCallbackLoop)

0 commit comments

Comments
 (0)