-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathlinux_bluetooth_driver.py
More file actions
2501 lines (2047 loc) · 105 KB
/
Copy pathlinux_bluetooth_driver.py
File metadata and controls
2501 lines (2047 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Linux Bluetooth Driver for BLE
This module implements the BLEDriverInterface abstraction for Linux using:
- bleak: BLE central operations (scanning, connecting, GATT client)
- bluezero: BLE peripheral operations (GATT server, advertising)
- D-Bus: Direct BlueZ API access for platform-specific workarounds
Platform-specific workarounds included:
1. BlueZ ServicesResolved race condition (Bleak 1.1.1 + bluezero)
2. LE-only connection via D-Bus ConnectDevice (BlueZ >= 5.49)
3. BLE Agent registration for automatic pairing
4. MTU negotiation via 3 fallback methods
USAGE EXAMPLE:
--------------
from linux_bluetooth_driver import LinuxBluetoothDriver
# Create driver instance (no Reticulum dependencies)
driver = LinuxBluetoothDriver(
discovery_interval=5.0,
connection_timeout=10.0,
min_rssi=-90,
service_discovery_delay=1.5,
max_peers=7,
adapter_index=0 # hci0
)
# Set up callbacks
def on_device_discovered(device):
print(f"Discovered: {device.name} ({device.address}) RSSI: {device.rssi}")
def on_device_connected(address):
print(f"Connected: {address}")
def on_data_received(address, data):
print(f"Received {len(data)} bytes from {address}")
def on_mtu_negotiated(address, mtu):
print(f"MTU negotiated with {address}: {mtu}")
driver.on_device_discovered = on_device_discovered
driver.on_device_connected = on_device_connected
driver.on_data_received = on_data_received
driver.on_mtu_negotiated = on_mtu_negotiated
# Start driver
driver.start(
service_uuid="37145b00-442d-4a94-917f-8f42c5da28e3",
rx_char_uuid="37145b00-442d-4a94-917f-8f42c5da28e5",
tx_char_uuid="37145b00-442d-4a94-917f-8f42c5da28e4",
identity_char_uuid="37145b00-442d-4a94-917f-8f42c5da28e6"
)
# Set identity for peripheral mode
driver.set_identity(b"\\x01\\x02\\x03...\\x10") # 16 bytes
# Start scanning (central mode)
driver.start_scanning()
# Start advertising (peripheral mode)
driver.start_advertising("MyDevice", b"\\x01\\x02\\x03...\\x10")
# Connect to a peer
driver.connect("AA:BB:CC:DD:EE:FF")
# Send data (automatically uses GATT write or notification)
driver.send("AA:BB:CC:DD:EE:FF", b"Hello, peer!")
# Stop driver
driver.stop()
ARCHITECTURE:
-------------
The driver uses a dedicated asyncio event loop in a separate thread to handle
all BLE operations asynchronously. This allows the main thread to remain
responsive while BLE operations run in the background.
Thread Architecture:
- Main thread: User-facing API (start, stop, connect, send, etc.)
- Event loop thread: All async BLE operations (scanning, connecting, GATT ops)
- GATT server thread: Bluezero peripheral (blocking publish())
Cross-thread communication:
- Main → Event loop: asyncio.run_coroutine_threadsafe()
- Event loop → Main: Callbacks (on_device_discovered, on_data_received, etc.)
- GATT server → Main: Callbacks from bluezero write_callback
ROLE-AWARE send():
------------------
The send() method automatically determines whether to use GATT write (central)
or notification (peripheral) based on the connection type:
- Central connection (we connected to them): GATT write to RX characteristic
- Peripheral connection (they connected to us): Notification on TX characteristic
This abstraction simplifies the high-level interface logic by hiding the
BLE role complexity at the driver level.
DEPENDENCIES:
-------------
Required:
- bleak >= 0.22.0 (BLE central operations)
- dbus-fast >= 1.0.0 (D-Bus communication)
Optional (for peripheral mode):
- bluezero >= 0.9.1 (GATT server)
- dbus-python >= 1.2.18 (bluezero dependency)
Author: Reticulum BLE Interface Contributors
License: MIT
"""
from __future__ import annotations
import asyncio
import threading
import time
import logging
import warnings
from typing import Optional, Callable, List, Dict
from dataclasses import dataclass
# Import RNS for logging
try:
import RNS
except ImportError:
# Fallback for when RNS is not available (standalone testing)
RNS = None
# Capture Python warnings and route them through RNS logger
def _rns_showwarning(message, category, filename, lineno, file=None, line=None):
"""Custom warning handler that routes warnings to RNS logger."""
if RNS:
warning_msg = f"{category.__name__}: {message} ({filename}:{lineno})"
RNS.log(warning_msg, RNS.LOG_WARNING)
else:
# Fallback to default warning behavior
import sys
if file is None:
file = sys.stderr
try:
file.write(warnings.formatwarning(message, category, filename, lineno, line))
except (AttributeError, IOError):
pass
# Install custom warning handler
warnings.showwarning = _rns_showwarning
# Import the abstraction
try:
from bluetooth_driver import BLEDriverInterface, BLEDevice, DriverState
except ImportError:
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from bluetooth_driver import BLEDriverInterface, BLEDevice, DriverState
# Bleak (BLE central operations)
try:
import bleak
from bleak import BleakScanner, BleakClient
from bleak.backends.bluezdbus.manager import BlueZManager
HAS_BLEAK = True
except ImportError:
HAS_BLEAK = False
BleakScanner = None
BleakClient = None
# Bluezero (BLE peripheral operations)
try:
from bluezero import peripheral, adapter
BLUEZERO_AVAILABLE = True
except ImportError:
BLUEZERO_AVAILABLE = False
# BLE Agent for automatic pairing
try:
from BLEAgent import register_agent, unregister_agent
HAS_BLE_AGENT = True
except ImportError:
try:
from ble_reticulum.BLEAgent import register_agent, unregister_agent
HAS_BLE_AGENT = True
except ImportError:
HAS_BLE_AGENT = False
# D-Bus for platform-specific operations
try:
from dbus_fast.aio import MessageBus
from dbus_fast import BusType, Variant
HAS_DBUS = True
except ImportError:
HAS_DBUS = False
# ============================================================================
# BlueZ ServicesResolved Race Condition Workaround
# ============================================================================
# Issue: When connecting to BlueZ-based GATT servers (like bluezero), BlueZ
# sets ServicesResolved=True BEFORE services are fully exported to D-Bus
# Cause: BlueZ GATT database cache timing issue (bluez/bluez#1489)
# Impact: Bleak attempts to enumerate services before they're available,
# causing -5 (EIO) error and immediate disconnect
# Fix: Poll D-Bus service map to verify services actually exist before proceeding
# Status: Works with bluezero; proper fix should be in BlueZ or Bleak upstream
# GitHub: https://github.com/hbldh/bleak/issues/1677
# ============================================================================
def apply_bluez_services_resolved_patch():
"""
Apply monkey patch to fix BlueZ ServicesResolved race condition.
This must be called before any BleakClient connections are made.
"""
if not HAS_BLEAK:
return False
try:
# Store original method
_original_wait_for_services_discovery = BlueZManager._wait_for_services_discovery
async def _patched_wait_for_services_discovery(self, device_path: str) -> None:
"""
Patched version that waits for services to actually appear in D-Bus.
Fixes race condition where ServicesResolved=True before services
are fully exported to D-Bus (common when connecting to BlueZ peripherals).
"""
# Call original wait for ServicesResolved property
await _original_wait_for_services_discovery(self, device_path)
# Additional verification: Poll until services actually appear in D-Bus
max_attempts = 20 # 20 attempts * 100ms = 2 seconds max
retry_delay = 0.1 # 100ms between attempts
for attempt in range(max_attempts):
# Check if services are actually present in the service map
service_paths = self._service_map.get(device_path, set())
if service_paths and len(service_paths) > 0:
# Services found! Verify at least one service has been fully loaded
# by checking if it exists in the properties dictionary
try:
first_service_path = next(iter(service_paths))
if first_service_path in self._properties:
# Success: Services are actually in D-Bus
if RNS:
RNS.log(f"BlueZ timing fix: Services verified in D-Bus after {attempt * retry_delay:.2f}s", RNS.LOG_EXTREME)
return
except (StopIteration, KeyError):
pass # Service not ready yet
# Services not ready yet, wait before next check
if attempt < max_attempts - 1: # Don't sleep on last attempt
await asyncio.sleep(retry_delay)
# If we get here, services didn't appear within timeout
# Log warning but don't raise - let get_services() handle it
if RNS:
RNS.log(f"BlueZ timing fix: Services not found in D-Bus after {max_attempts * retry_delay}s, proceeding anyway", RNS.LOG_WARNING)
# Apply the patch
BlueZManager._wait_for_services_discovery = _patched_wait_for_services_discovery
if RNS:
RNS.log("Applied Bleak BlueZ ServicesResolved timing patch for bluezero compatibility", RNS.LOG_INFO)
return True
except Exception as e:
# If patching fails, log warning but don't prevent driver from loading
if RNS:
RNS.log(f"Failed to apply Bleak BlueZ timing patch: {e}. Connections to bluezero peripherals may fail.", RNS.LOG_WARNING)
return False
@dataclass
class PeerConnection:
"""Tracks information about a connected peer."""
address: str
client: Optional[BleakClient] = None # For central connections
mtu: int = 23 # Negotiated MTU
connection_type: str = "unknown" # "central" or "peripheral"
connected_at: float = 0.0
peer_identity: Optional[bytes] = None # 16-byte identity hash
class LinuxBluetoothDriver(BLEDriverInterface):
"""
Linux implementation of BLE driver using bleak and bluezero.
This driver provides:
- Central mode: BLE scanning and connections via bleak
- Peripheral mode: GATT server and advertising via bluezero
- Platform workarounds for BlueZ quirks
- Dedicated asyncio event loop in separate thread
- Role-aware send() that automatically uses GATT write or notification
Architecture:
- Main thread: User-facing API (start, stop, send, etc.)
- Event loop thread: All async BLE operations
- Cross-thread communication via run_coroutine_threadsafe
"""
def __init__(
self,
discovery_interval: float = 5.0,
connection_timeout: float = 10.0,
min_rssi: int = -90,
service_discovery_delay: float = 1.5,
max_peers: int = 7,
adapter_index: int = 0,
agent_capability: str = "NoInputNoOutput"
):
"""
Initialize Linux BLE driver.
Args:
discovery_interval: Seconds between discovery scans (default: 5.0)
connection_timeout: Connection timeout in seconds (default: 10.0)
min_rssi: Minimum RSSI for connection attempts (default: -90 dBm)
service_discovery_delay: Delay after connection for bluezero D-Bus registration (default: 1.5s)
max_peers: Maximum simultaneous connections (default: 7)
adapter_index: Bluetooth adapter index (0 = hci0, 1 = hci1, etc.)
agent_capability: BLE pairing agent capability (default: "NoInputNoOutput" for Just Works pairing)
"""
# Validate dependencies
if not HAS_BLEAK:
raise ImportError("bleak library required for Linux BLE driver. Install with: pip install bleak>=0.22.0")
# Configuration
self.discovery_interval = discovery_interval
self.connection_timeout = connection_timeout
self.min_rssi = min_rssi
self.service_discovery_delay = service_discovery_delay
self.max_peers = max_peers
self.adapter_index = adapter_index
self.adapter_path = f"/org/bluez/hci{adapter_index}"
self.agent_capability = agent_capability
# Service UUIDs (set by start())
self.service_uuid: Optional[str] = None
self.rx_char_uuid: Optional[str] = None
self.tx_char_uuid: Optional[str] = None
self.identity_char_uuid: Optional[str] = None
# State
self._state = DriverState.IDLE
self._running = False
self._scanning = False
self._advertising = False
# Connected peers
self._peers: Dict[str, PeerConnection] = {} # address -> PeerConnection
self._peers_lock = threading.RLock()
# Pending connections (prevents race condition from concurrent connection attempts)
self._connecting_peers: set = set() # addresses with connection attempts in progress
self._connecting_lock = threading.Lock()
# Local identity (for peripheral mode)
self._local_identity: Optional[bytes] = None
# Local adapter address (for connection direction preference)
self.local_address: Optional[str] = None
# Power mode
self.power_mode = "balanced" # "aggressive", "balanced", "saver"
# Event loop management
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.loop_thread: Optional[threading.Thread] = None
# Peripheral mode (bluezero)
self.gatt_server: Optional['BluezeroGATTServer'] = None
self.ble_agent = None
# BlueZ version detection
self.bluez_version: Optional[tuple] = None
self.has_connect_device = None # None = unknown, True/False = tested
# Logging
self.log_prefix = "LinuxBLEDriver"
# Scanner health tracking
self.consecutive_empty_scans = 0
# Apply BlueZ timing patch
apply_bluez_services_resolved_patch()
# Detect BlueZ version
self._detect_bluez_version()
def _log(self, message: str, level: str = "INFO"):
"""Log message with appropriate level."""
if RNS:
# Map Python logging level strings to RNS log levels
level_map = {
"DEBUG": RNS.LOG_DEBUG,
"INFO": RNS.LOG_INFO,
"WARNING": RNS.LOG_WARNING,
"ERROR": RNS.LOG_ERROR,
"CRITICAL": RNS.LOG_CRITICAL,
"EXTREME": RNS.LOG_EXTREME,
}
rns_level = level_map.get(level.upper(), RNS.LOG_INFO)
RNS.log(f"{self.log_prefix} {message}", rns_level)
else:
# Fallback to standard Python logging if RNS not available
log_func = getattr(logging, level.lower(), logging.info)
log_func(f"{self.log_prefix} {message}")
# ========================================================================
# Lifecycle & Configuration
# ========================================================================
def start(self, service_uuid: str, rx_char_uuid: str, tx_char_uuid: str, identity_char_uuid: str):
"""
Initialize the driver and start the BLE stack.
This creates the dedicated event loop thread and initializes the GATT server.
"""
if self._running:
self._log("Driver already running", "WARNING")
return
self._log("Starting Linux BLE driver...")
# Store UUIDs
self.service_uuid = service_uuid
self.rx_char_uuid = rx_char_uuid
self.tx_char_uuid = tx_char_uuid
self.identity_char_uuid = identity_char_uuid
# Start event loop thread
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True, name="BLE-EventLoop")
self.loop_thread.start()
# Wait for event loop to be ready
timeout = 5.0
start_time = time.time()
while self.loop is None and (time.time() - start_time) < timeout:
time.sleep(0.1)
if self.loop is None:
raise RuntimeError("Failed to start event loop within timeout")
# Get local adapter address
future = asyncio.run_coroutine_threadsafe(self._get_local_adapter_address(), self.loop)
try:
self.local_address = future.result(timeout=5.0)
if self.local_address:
self._log(f"Local adapter address: {self.local_address}")
except Exception as e:
self._log(f"Could not get local adapter address: {e}", "WARNING")
# Initialize GATT server for peripheral mode (if bluezero available)
if BLUEZERO_AVAILABLE:
try:
self.gatt_server = BluezeroGATTServer(
driver=self,
service_uuid=service_uuid,
rx_char_uuid=rx_char_uuid,
tx_char_uuid=tx_char_uuid,
identity_char_uuid=identity_char_uuid,
adapter_index=self.adapter_index,
agent_capability=self.agent_capability
)
self._log("GATT server initialized")
except Exception as e:
self._log(f"Failed to initialize GATT server: {e}", "WARNING")
self.gatt_server = None
else:
self._log("Bluezero not available, peripheral mode disabled", "WARNING")
self._running = True
self._state = DriverState.IDLE
self._log("Driver started successfully")
def stop(self):
"""Stop all BLE activity and release resources."""
if not self._running:
return
self._log("Stopping Linux BLE driver...")
self._running = False
# Stop scanning
if self._scanning:
self.stop_scanning()
# Stop advertising
if self._advertising:
self.stop_advertising()
# Disconnect all peers
with self._peers_lock:
for address in list(self._peers.keys()):
try:
self.disconnect(address)
except Exception as e:
self._log(f"Error disconnecting {address}: {e}", "WARNING")
# Stop GATT server
if self.gatt_server:
try:
self.gatt_server.stop()
except Exception as e:
self._log(f"Error stopping GATT server: {e}", "WARNING")
# Stop event loop
if self.loop and self.loop.is_running():
self.loop.call_soon_threadsafe(self.loop.stop)
# Wait for thread to exit
if self.loop_thread and self.loop_thread.is_alive():
self.loop_thread.join(timeout=5.0)
self._state = DriverState.IDLE
self._log("Driver stopped")
def set_identity(self, identity_bytes: bytes):
"""Set the local identity for the GATT server."""
if not isinstance(identity_bytes, bytes):
raise TypeError(f"identity_bytes must be bytes, got {type(identity_bytes)}")
if len(identity_bytes) != 16:
raise ValueError(f"identity_bytes must be 16 bytes, got {len(identity_bytes)}")
self._local_identity = identity_bytes
if self.gatt_server:
self.gatt_server.set_identity(identity_bytes)
self._log(f"Local identity set: {identity_bytes.hex()}")
# ========================================================================
# State & Properties
# ========================================================================
@property
def state(self) -> DriverState:
"""Return current driver state."""
return self._state
@property
def connected_peers(self) -> List[str]:
"""Return list of connected peer addresses."""
with self._peers_lock:
return list(self._peers.keys())
# ========================================================================
# Scanning (Central Mode)
# ========================================================================
def start_scanning(self):
"""Start scanning for BLE devices."""
if not self._running:
self._log("Cannot start scanning: driver not running", "ERROR")
return
if self._scanning:
self._log("Already scanning", "DEBUG")
return
self._log("Starting BLE scanning...")
self._scanning = True
self._state = DriverState.SCANNING
# Start scan loop in event loop
asyncio.run_coroutine_threadsafe(self._scan_loop(), self.loop)
def stop_scanning(self):
"""Stop scanning for BLE devices."""
if not self._scanning:
return
self._log("Stopping BLE scanning...")
self._scanning = False
if not self._advertising:
self._state = DriverState.IDLE
def _should_pause_scanning(self) -> bool:
"""
Check if scanning should be paused due to active connections.
Scanner interference with active connections can cause BlueZ
"Operation already in progress" errors. We pause scanning when
connections are being established.
Returns:
True if scanning should be paused (connections in progress)
False if scanning can proceed normally
"""
return len(self._connecting_peers) > 0
async def _scan_loop(self):
"""Main scanning loop (runs in event loop thread)."""
self._log("Scan loop started", "DEBUG")
while self._scanning and self._running:
try:
await self._perform_scan()
# Sleep based on power mode
if self.power_mode == "aggressive":
sleep_time = 1.0
elif self.power_mode == "saver":
# Skip scanning if we have connected peers
with self._peers_lock:
if len(self._peers) > 0:
sleep_time = 60.0
else:
sleep_time = 30.0
else: # balanced
sleep_time = self.discovery_interval
await asyncio.sleep(sleep_time)
except Exception as e:
self._log(f"Error in scan loop: {e}", "ERROR")
await asyncio.sleep(5.0) # Back off on errors
self._log("Scan loop stopped", "DEBUG")
async def _perform_scan(self):
"""Perform a single BLE scan."""
# Check if we should pause scanning due to active connections
# This prevents "Operation already in progress" errors from BlueZ
if self._should_pause_scanning():
self._log("Pausing scan: connection(s) in progress", "DEBUG")
return # Skip this scan cycle, will retry on next loop iteration
discovered_devices = []
callback_count = [0] # Use list to allow modification in nested function
def detection_callback(device, advertisement_data):
"""Called for each discovered device."""
callback_count[0] += 1
self._log(f"🔍 CALLBACK INVOKED: {device.address} ({device.name or 'Unknown'}) RSSI={advertisement_data.rssi} UUIDs={advertisement_data.service_uuids}", "EXTRA")
discovered_devices.append((device, advertisement_data))
# Scan duration based on power mode
if self.power_mode == "aggressive":
scan_time = 2.0
elif self.power_mode == "saver":
scan_time = 0.5
else: # balanced
scan_time = 1.0
self._log(f"🔍 Starting BleakScanner (power_mode={self.power_mode}, scan_time={scan_time}s, service_uuid={self.service_uuid})", "EXTRA")
scanner = BleakScanner(
detection_callback=detection_callback,
service_uuids=[self.service_uuid] if self.service_uuid else None
)
try:
self._log("🔍 Calling scanner.start()", "EXTRA")
await scanner.start()
self._log(f"🔍 Scanner started, sleeping for {scan_time}s", "EXTRA")
await asyncio.sleep(scan_time)
self._log("🔍 Calling scanner.stop()", "EXTRA")
await scanner.stop()
self._log(f"🔍 Scanner stopped. Total devices discovered: {len(discovered_devices)}", "EXTRA")
except Exception as e:
error_msg = str(e)
self._log(f"🔍 Scanner exception: {error_msg}", "ERROR")
# Check for adapter power issues
if "No powered Bluetooth adapters" in error_msg or "Not Powered" in error_msg:
self._log("Bluetooth adapter is not powered!", "ERROR")
if self.on_error:
self.on_error("error", "Bluetooth adapter not powered. Run 'bluetoothctl power on'", e)
return
else:
raise
# Detect scanner callback corruption
if callback_count[0] == 0:
self.consecutive_empty_scans += 1
self._log(f"⚠️ Scanner corruption detected: 0 callbacks after {scan_time}s scan (streak: {self.consecutive_empty_scans})", "WARNING")
if self.consecutive_empty_scans >= 3:
self._log("⚠️ CRITICAL: Bleak scanner callbacks not firing", "ERROR")
self._log("⚠️ Bluetooth/BlueZ/D-Bus state is corrupted", "ERROR")
self._log("⚠️ System reboot required to restore BLE scanning", "ERROR")
if self.on_error:
self.on_error("critical",
f"Scanner callback failure detected (0 callbacks for {self.consecutive_empty_scans} consecutive scans). "
"Bluetooth stack requires reboot.",
Exception("BleakScanner callbacks not invoked"))
else:
# Reset counter on successful callback
if self.consecutive_empty_scans > 0:
self._log(f"✓ Scanner callbacks resumed after {self.consecutive_empty_scans} empty scans", "INFO")
self.consecutive_empty_scans = 0
# Process discovered devices
self._log(f"🔍 Processing {len(discovered_devices)} discovered devices", "EXTRA")
for device, adv_data in discovered_devices:
# Check if device advertises our service UUID
if self.service_uuid and self.service_uuid.lower() in [uuid.lower() for uuid in adv_data.service_uuids]:
self._log(f"✓ {device.address} has service UUID {self.service_uuid}", "EXTRA")
# Check RSSI threshold
if adv_data.rssi < self.min_rssi:
self._log(f"✗ {device.address}: RSSI {adv_data.rssi} below threshold {self.min_rssi}", "EXTRA")
continue
# Check for invalid/sentinel RSSI values (-127, -128 indicate no signal/error)
if adv_data.rssi in (-127, -128, 0):
self._log(f"✗ {device.address}: invalid sentinel RSSI {adv_data.rssi} dBm", "DEBUG")
continue
self._log(f"✓ {device.address} passed all filters, notifying callback", "EXTRA")
# Create BLEDevice and notify callback
ble_device = BLEDevice(
address=device.address,
name=device.name or "Unknown",
rssi=adv_data.rssi,
service_uuids=list(adv_data.service_uuids),
manufacturer_data=dict(adv_data.manufacturer_data) if hasattr(adv_data, 'manufacturer_data') else {}
)
if self.on_device_discovered:
try:
self.on_device_discovered(ble_device)
except Exception as e:
self._log(f"Error in device discovered callback: {e}", "ERROR")
else:
self._log(f"✗ {device.address} ({device.name or 'Unknown'}): service UUID mismatch (has {adv_data.service_uuids}, want {self.service_uuid})", "EXTRA")
# ========================================================================
# Advertising (Peripheral Mode)
# ========================================================================
def start_advertising(self, device_name: Optional[str], identity: bytes):
"""Start advertising as a BLE peripheral."""
if not self._running:
self._log("Cannot start advertising: driver not running", "ERROR")
return
if not self.gatt_server:
self._log("Cannot start advertising: GATT server not available", "ERROR")
if self.on_error:
self.on_error("error", "GATT server not available (bluezero not installed?)", None)
return
if self._advertising:
self._log("Already advertising", "DEBUG")
return
if device_name:
self._log(f"Starting BLE advertising as '{device_name}'...")
else:
self._log("Starting BLE advertising (no device name)...")
# Set identity
self.set_identity(identity)
# Start GATT server
try:
self.gatt_server.start(device_name)
self._advertising = True
self._state = DriverState.ADVERTISING
self._log("Advertising started")
except Exception as e:
self._log(f"Failed to start advertising: {e}", "ERROR")
if self.on_error:
self.on_error("error", f"Failed to start advertising: {e}", e)
def stop_advertising(self):
"""Stop advertising."""
if not self._advertising:
return
self._log("Stopping BLE advertising...")
if self.gatt_server:
try:
self.gatt_server.stop()
except Exception as e:
self._log(f"Error stopping GATT server: {e}", "WARNING")
self._advertising = False
if not self._scanning:
self._state = DriverState.IDLE
# ========================================================================
# Connection Management (Central Mode)
# ========================================================================
def connect(self, address: str):
"""Connect to a peer device (central role)."""
if not self._running:
self._log("Cannot connect: driver not running", "ERROR")
return
# Check if already connected
with self._peers_lock:
if address in self._peers:
self._log(f"Already connected to {address}", "DEBUG")
return
# Check if connection already in progress
with self._connecting_lock:
if address in self._connecting_peers:
self._log(f"Connection already in progress to {address}", "DEBUG")
return
self._connecting_peers.add(address)
# Diagnostic: Log when connection attempt starts
self._log(f"Added {address} to connecting set (total: {len(self._connecting_peers)})", "INFO")
# Check max peers
with self._peers_lock:
if len(self._peers) >= self.max_peers:
self._log(f"Cannot connect to {address}: max peers ({self.max_peers}) reached", "WARNING")
# Remove from connecting set since we're not actually connecting
with self._connecting_lock:
self._connecting_peers.discard(address)
return
# Start connection in event loop
future = asyncio.run_coroutine_threadsafe(self._connect_to_peer(address), self.loop)
# Add callback to ensure cleanup even if coroutine fails unexpectedly
# This guarantees cleanup on success, failure, timeout, or cancellation
def cleanup_connecting_state(fut):
"""Callback to clean up connecting state when connection attempt completes."""
import sys
try:
if RNS:
RNS.log(f"{self.log_prefix} [BLE-CLEANUP] Callback invoked for {address}", RNS.LOG_EXTREME)
with self._connecting_lock:
was_present = address in self._connecting_peers
self._connecting_peers.discard(address)
# Try logging, but don't fail if it doesn't work
try:
if was_present:
self._log(f"Cleaned up connecting state for {address}", "INFO")
else:
# This indicates the finally block cleaned it up first
if RNS:
RNS.log(f"{self.log_prefix} [BLE-CLEANUP] {address} already cleaned by finally block", RNS.LOG_EXTREME)
except Exception as log_exc:
if RNS:
RNS.log(f"{self.log_prefix} [BLE-CLEANUP] Logging failed for {address}: {log_exc}", RNS.LOG_EXTREME)
except Exception as e:
if RNS:
RNS.log(f"{self.log_prefix} [BLE-CLEANUP-ERROR] Callback failed for {address}: {e}", RNS.LOG_EXTREME)
# Emergency cleanup
try:
with self._connecting_lock:
self._connecting_peers.discard(address)
except:
pass
future.add_done_callback(cleanup_connecting_state)
def disconnect(self, address: str):
"""Disconnect from a peer device."""
with self._peers_lock:
if address not in self._peers:
self._log(f"Not connected to {address}", "DEBUG")
return
peer = self._peers[address]
# Disconnect based on connection type
if peer.connection_type == "central" and peer.client:
# Central connection: disconnect client
future = asyncio.run_coroutine_threadsafe(peer.client.disconnect(), self.loop)
try:
future.result(timeout=5.0)
except Exception as e:
self._log(f"Error disconnecting from {address}: {e}", "WARNING")
# For peripheral connections, client disconnects from us (we can't force disconnect)
# Clean up
with self._peers_lock:
if address in self._peers:
del self._peers[address]
if self.on_device_disconnected:
try:
self.on_device_disconnected(address)
except Exception as e:
self._log(f"Error in device disconnected callback: {e}", "ERROR")
self._log(f"Disconnected from {address}")
def _handle_peripheral_disconnected(self, address: str):
"""
Handle disconnection of a central device from our GATT server (peripheral mode).
This is called by the GATT server when a central disconnects. It performs cleanup
of the peer connection from the driver's _peers dictionary and notifies callbacks.
This fixes the bug where peripheral mode disconnections were never cleaned up,
causing the peer limit to be reached and blocking new connections.
Args:
address: MAC address of the disconnected central device
"""
self._log(f"Handling peripheral disconnection from {address}", "DEBUG")
# Clean up from _peers dictionary
with self._peers_lock:
if address in self._peers:
del self._peers[address]
self._log(f"Removed {address} from _peers (peripheral disconnect)", "DEBUG")
else:
self._log(f"Central {address} not in _peers during disconnect", "DEBUG")
return
# Notify higher-level callbacks (BLEInterface)
if self.on_device_disconnected:
try:
self.on_device_disconnected(address)
except Exception as e:
self._log(f"Error in device disconnected callback for {address}: {e}", "ERROR")
self._log(f"Peripheral disconnection cleanup complete for {address}")
async def _remove_bluez_device(self, address: str) -> bool:
"""
Remove stale device object from BlueZ via D-Bus.
This clears any lingering connection state that might cause
"Operation already in progress" errors on subsequent attempts.
Args:
address: MAC address of the device to remove (e.g., "AA:BB:CC:DD:EE:FF")
Returns:
True if device was removed successfully, False otherwise
"""
if not HAS_DBUS:
self._log(f"Cannot remove BlueZ device {address}: D-Bus not available", "DEBUG")
return False
try:
# Convert MAC address to D-Bus path format
# AA:BB:CC:DD:EE:FF → /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
dev_path = f"{self.adapter_path}/dev_{address.replace(':', '_')}"
# Connect to D-Bus
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
# Get adapter interface
introspection = await bus.introspect('org.bluez', self.adapter_path)
adapter_obj = bus.get_proxy_object('org.bluez', self.adapter_path, introspection)
adapter_iface = adapter_obj.get_interface('org.bluez.Adapter1')
# Remove device
await adapter_iface.call_remove_device(dev_path)
self._log(f"Removed stale BlueZ device object for {address}", "DEBUG")
return True
except Exception as e:
# Device might not exist or already removed - that's fine
# Only log at DEBUG since this is expected in many cases
error_str = str(e).lower()
if "does not exist" in error_str or "unknownobject" in error_str:
self._log(f"BlueZ device {address} already removed or doesn't exist", "DEBUG")
else:
self._log(f"Could not remove BlueZ device {address}: {e}", "DEBUG")
return False
async def _connect_to_peer(self, address: str):
"""Connect to a peer (runs in event loop thread)."""
connection_start_time = time.time()
self._log(f"[CONNECT-FLOW] Starting connection to {address}", "INFO")
try: # Outer try-finally to ensure cleanup of connecting state
# Create disconnection callback
def disconnected_callback(client_obj):
"""Called when device disconnects."""
# Enhanced diagnostics: Log disconnect timing and potential reason
connection_duration = time.time() - connection_start_time
self._log(f"Device {address} disconnected unexpectedly after {connection_duration:.2f}s", "WARNING")
# Clean up
with self._peers_lock:
if address in self._peers:
del self._peers[address]
if self.on_device_disconnected: