11import asyncio
22import collections
3+ import logging
34import os
45import platform
56import re
2425from jadepy import jade_ble as jade_ble_module
2526from jadepy .jade_ble import JadeBleImpl as BlockstreamJadeBleImpl
2627
28+ logger = logging .getLogger (__name__ )
29+
2730DEFAULT_MAX_AUTH_ATTEMPTS = 3
2831DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0
2932DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0
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
4279def _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+
62145def 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 :
0 commit comments