-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathBLEInterface.py
More file actions
2428 lines (2013 loc) · 111 KB
/
Copy pathBLEInterface.py
File metadata and controls
2428 lines (2013 loc) · 111 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
# MIT License
#
# Copyright (c) 2025 Reticulum BLE Interface Contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
BLEInterface - Bluetooth Low Energy interface for Reticulum
This interface enables Reticulum mesh networking over BLE on Linux devices
without additional hardware.
Key features:
- Auto-discovery of BLE peers
- Multi-peer mesh support (up to 7 simultaneous connections)
- Packet fragmentation for BLE MTU limits
- Power management modes for battery efficiency
- Linux-only (requires BlueZ 5.x for GATT server)
"""
import RNS
import sys
import os
import threading
import time
import asyncio
import logging
from collections import deque
from typing import Optional
# Add interface directory to path for importing other BLE modules
# This is needed when loaded as external interface
try:
# __file__ exists when imported normally
_interface_dir = os.path.dirname(os.path.abspath(__file__))
except NameError:
# __file__ doesn't exist when loaded via exec() by Reticulum
# Try to get the config directory from RNS
_interface_dir = None
try:
import RNS
if hasattr(RNS.Reticulum, 'configdir') and RNS.Reticulum.configdir:
_interface_dir = os.path.join(RNS.Reticulum.configdir, "interfaces")
except (ImportError, AttributeError):
pass
# Fall back to default if we couldn't get it from RNS
if _interface_dir is None:
_interface_dir = os.path.expanduser("~/.reticulum/interfaces")
if _interface_dir not in sys.path:
sys.path.insert(0, _interface_dir)
# Import base Interface class from Reticulum
try:
from RNS.Interfaces.Interface import Interface
except ImportError:
# Fallback for development
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../'))
from RNS.Interfaces.Interface import Interface
# Import fragmentation module
# Note: When loaded as external interface, use absolute imports
try:
from BLEFragmentation import BLEFragmenter, BLEReassembler
except ImportError:
# Fallback for when loaded as part of RNS package
from ble_reticulum.BLEFragmentation import BLEFragmenter, BLEReassembler
# Import GATT server for peripheral mode
try:
from BLEGATTServer import BLEGATTServer
HAS_GATT_SERVER = True
except ImportError:
try:
from ble_reticulum.BLEGATTServer import BLEGATTServer
HAS_GATT_SERVER = True
except ImportError:
HAS_GATT_SERVER = False
# Import driver abstraction
try:
from bluetooth_driver import BLEDriverInterface, BLEDevice
except ImportError:
from ble_reticulum.bluetooth_driver import BLEDriverInterface, BLEDevice
# Import platform-specific driver (optional - can be overridden by subclasses)
try:
from linux_bluetooth_driver import LinuxBluetoothDriver
HAS_LINUX_DRIVER = True
except ImportError:
try:
from ble_reticulum.linux_bluetooth_driver import LinuxBluetoothDriver
HAS_LINUX_DRIVER = True
except ImportError:
HAS_LINUX_DRIVER = False
LinuxBluetoothDriver = None
HAS_DRIVER = True
class DiscoveredPeer:
"""
Tracks information about a discovered BLE peer for connection prioritization.
This class stores signal strength (RSSI), connection history, and timing
information to enable smart peer selection in mesh networks.
Algorithm Design Decisions:
---------------------------
1. RSSI Tracking: Signal strength is the primary indicator of connection
quality in BLE networks. We track and update RSSI on every discovery
to adapt to changing environmental conditions (movement, obstacles).
2. Connection History: Past behavior is a strong predictor of future
reliability. We track attempts vs successes to identify consistently
reachable peers vs flaky ones.
3. Temporal Data: Both first_seen and last_seen timestamps enable:
- Recency-based prioritization (prefer active peers)
- Stale peer cleanup (remove disappeared peers)
- Connection attempt rate limiting
4. Separation of Concerns: We track successful_connections separately
from failed_connections to enable nuanced scoring (e.g., a peer with
80% success from 100 attempts is more reliable than one with 100%
from 2 attempts).
"""
def __init__(self, address, name, rssi):
"""
Initialize a discovered peer.
Args:
address: BLE MAC address of the peer
name: Advertised device name
rssi: Signal strength in dBm (typically -30 to -100)
"""
self.address = address
self.name = name
self.rssi = rssi
self.first_seen = time.time()
self.last_seen = time.time()
# Connection tracking
self.connection_attempts = 0
self.successful_connections = 0
self.failed_connections = 0
self.last_connection_attempt = 0
def update_rssi(self, rssi):
"""Update RSSI and last seen timestamp."""
self.rssi = rssi
self.last_seen = time.time()
def record_connection_attempt(self):
"""Record that a connection attempt is being made."""
self.connection_attempts += 1
self.last_connection_attempt = time.time()
def record_connection_success(self):
"""Record a successful connection."""
self.successful_connections += 1
def record_connection_failure(self):
"""Record a failed connection."""
self.failed_connections += 1
def get_success_rate(self):
"""
Get the connection success rate.
Returns:
float: Success rate from 0.0 to 1.0, or 0.0 if no attempts
"""
if self.connection_attempts == 0:
return 0.0
return self.successful_connections / self.connection_attempts
def __repr__(self):
return (f"DiscoveredPeer({self.address}, {self.name}, "
f"RSSI={self.rssi}, attempts={self.connection_attempts}, "
f"success_rate={self.get_success_rate():.2f})")
class BLEInterface(Interface):
"""
BLE interface for Reticulum networking.
Implements the Reticulum Interface API for Bluetooth Low Energy
transport, enabling mesh networking over BLE connections.
ARCHITECTURE:
- Dual-mode: Acts as both central (client) and peripheral (server)
- Spawns BLEPeerInterface for each connected peer
- Fragments packets larger than BLE MTU (~185 bytes)
- Auto-reconnects on connection loss
THREADING MODEL:
- Driver owns async event loop in separate thread
- LOCK ORDERING CONVENTION (to prevent deadlocks):
1. peer_lock - ALWAYS acquire first for peer state access
2. frag_lock - THEN acquire for fragmentation state
NEVER acquire locks in reverse order! (HIGH #2: deadlock prevention)
- Driver callbacks invoked from driver thread
MEMORY USAGE (per-peer overhead):
- Fragmenter + Reassembler: ~400 bytes per peer
- Max peers: configurable (default 7)
- Reassembly buffers: Auto-cleanup after 30s timeout (CRITICAL #2)
- Discovery cache: ~100 bytes per discovered device (limited to 100)
ERROR RECOVERY:
- Connection failure: Exponential backoff + blacklist
- Transmission timeout: Packet dropped (Reticulum retransmits)
- Fragmentation failure: Buffer cleanup after timeout
- Adapter error: Interface marked offline, Transport handles
"""
# Interface constants
HW_MTU = 500 # Reticulum standard MTU
BITRATE_GUESS = 700_000 # ~700 Kbps average BLE throughput
DEFAULT_IFAC_SIZE = 16
# BLE-specific constants
SERVICE_UUID = "37145b00-442d-4a94-917f-8f42c5da28e3" # Custom Reticulum BLE service
CHARACTERISTIC_RX_UUID = "37145b00-442d-4a94-917f-8f42c5da28e5" # RX characteristic
CHARACTERISTIC_TX_UUID = "37145b00-442d-4a94-917f-8f42c5da28e4" # TX characteristic
CHARACTERISTIC_IDENTITY_UUID = "37145b00-442d-4a94-917f-8f42c5da28e6" # Identity characteristic (Protocol v2)
# Discovery and connection settings
DISCOVERY_INTERVAL = 5.0 # seconds between discovery scans
CONNECTION_TIMEOUT = 30.0 # seconds before connection times out
MAX_PEERS = 7 # Maximum simultaneous BLE connections (conservative default)
MIN_RSSI = -85 # Minimum signal strength (dBm) - more permissive for better peer discovery
# Power management modes
POWER_MODE_AGGRESSIVE = "aggressive" # Continuous scanning
POWER_MODE_BALANCED = "balanced" # Intermittent scanning (default)
POWER_MODE_SAVER = "saver" # Minimal scanning
# Fragmentation constants
FRAG_TYPE_START = 0x01
FRAG_TYPE_CONTINUE = 0x02
FRAG_TYPE_END = 0x03
FRAG_HEADER_SIZE = 5 # bytes: type(1) + sequence(2) + total(2)
# Platform-specific driver class (override in subclasses for different platforms)
driver_class = LinuxBluetoothDriver
def __init__(self, owner, configuration):
"""
Initialize BLE interface.
Args:
owner: The Reticulum.Transport instance that owns this interface
configuration: Dictionary or ConfigObj with interface settings
"""
# Check dependencies
if not HAS_DRIVER:
raise ImportError(
"BLEInterface requires the driver abstraction. "
"Ensure bluetooth_driver.py and linux_bluetooth_driver.py are available."
)
super().__init__()
# CRITICAL: Set HW_MTU as instance attribute after super().__init__()
#
# Bug explanation:
# - Base Interface.__init__() sets self.HW_MTU = None
# - BLEInterface.HW_MTU = 500 is a CLASS attribute, not instance
# - After super().__init__(), self.HW_MTU is None (instance shadows class)
# - BLEPeerInterface copies: self.HW_MTU = parent.HW_MTU (gets None)
#
# Impact when HW_MTU is None:
# - Transport.py line ~1855 checks: if packet.receiving_interface.HW_MTU == None
# - If true, it TRUNCATES packet.data by 3 bytes (LINK_MTU_SIZE) before
# passing to Link.validate_request()
# - Link.link_id_from_lr_packet() uses len(packet.data) to compute truncation
# - Since packet.data was pre-truncated, it computes WRONG link_id
# - Link proof's destination_hash won't match pending link's link_id
# - Result: Links time out despite proof arriving correctly
#
# This bug ONLY affects BLE because other interfaces set HW_MTU in __init__
self.HW_MTU = BLEInterface.HW_MTU
# Parse configuration
c = Interface.get_config_obj(configuration)
# Basic interface setup
self.IN = True
self.OUT = True # Enable bidirectional communication
self.name = c.get("name", "BLEInterface")
self.owner = owner
self.online = False
self.bitrate = BLEInterface.BITRATE_GUESS
self.mode = Interface.MODE_FULL # Full mode: enable announce propagation, meshing, transport
# BLE configuration
self.service_uuid = c.get("service_uuid", BLEInterface.SERVICE_UUID)
# Device name for BLE advertising (optional, configurable via config file)
# Default is None (no device name) to save advertisement packet space (31-byte limit).
# Discovery is based on service UUID only. Identity is obtained from the Identity
# characteristic after connection. If set, keep it short (max 8 chars recommended).
self.device_name = c.get("device_name", None)
self.discovery_interval = float(c.get("discovery_interval", BLEInterface.DISCOVERY_INTERVAL))
self.max_peers = int(c.get("max_connections", BLEInterface.MAX_PEERS))
self.min_rssi = int(c.get("min_rssi", BLEInterface.MIN_RSSI))
self.connection_timeout = float(c.get("connection_timeout", BLEInterface.CONNECTION_TIMEOUT))
# Service discovery delay (for bluezero D-Bus registration timing)
# bluezero registers characteristics asynchronously with BlueZ D-Bus
# A small delay after connection allows registration to complete before discovery
self.service_discovery_delay = float(c.get("service_discovery_delay", 1.5)) # Default 1.5s
# Power management
self.power_mode = c.get("power_mode", BLEInterface.POWER_MODE_BALANCED)
if self.power_mode not in [BLEInterface.POWER_MODE_AGGRESSIVE,
BLEInterface.POWER_MODE_BALANCED,
BLEInterface.POWER_MODE_SAVER]:
RNS.log(f"{self} Invalid power mode '{self.power_mode}', using balanced", RNS.LOG_WARNING)
self.power_mode = BLEInterface.POWER_MODE_BALANCED
# Central mode (scanning and connecting) configuration
enable_central_val = c.get("enable_central", True)
# Convert string "yes"/"no" to boolean
if isinstance(enable_central_val, str):
self.enable_central = enable_central_val.lower() in ["yes", "true", "1"]
else:
self.enable_central = bool(enable_central_val)
# Peripheral mode (GATT server) configuration
enable_peripheral_val = c.get("enable_peripheral", True)
# Convert string "yes"/"no" to boolean
if isinstance(enable_peripheral_val, str):
self.enable_peripheral = enable_peripheral_val.lower() in ["yes", "true", "1"]
else:
self.enable_peripheral = bool(enable_peripheral_val)
if self.enable_peripheral and not HAS_GATT_SERVER:
RNS.log(f"{self} Peripheral mode requested but BLEGATTServer not available", RNS.LOG_WARNING)
self.enable_peripheral = False
# Local announce forwarding workaround
# WORKAROUND: Reticulum Transport.py doesn't forward locally-originated announces (hops=0)
# to physical interfaces. This option enables manual forwarding of local announces to BLE peers.
# See: Transport.py lines 987-1069 (locally originated announces skip forwarding block)
# Default: False (disabled, assume Transport behavior is intentional)
enable_local_announce_val = c.get("enable_local_announce_forwarding", False)
if isinstance(enable_local_announce_val, str):
self.enable_local_announce_forwarding = enable_local_announce_val.lower() in ["yes", "true", "1"]
else:
self.enable_local_announce_forwarding = bool(enable_local_announce_val)
# State tracking
self.peers = {} # address -> (client, last_seen, mtu)
self.peer_lock = threading.Lock()
# Identity-based interface tracking
self.spawned_interfaces = {} # identity_hash (16 hex chars) -> BLEPeerInterface
self.address_to_identity = {} # address -> peer_identity (16-byte identity)
self.identity_to_address = {} # identity_hash -> address (for reverse lookup)
self.address_to_interface = {} # address -> BLEPeerInterface (for cleanup fallback)
# Cache for recently disconnected identities (address -> (identity, timestamp))
# Used to restore identity when peer reconnects before cache expiry (60s)
self._identity_cache = {}
self._identity_cache_ttl = 60 # seconds
# Pending connections awaiting identity handshake (address -> timestamp)
# If identity not received within timeout, connection is closed
self._pending_identity_connections = {}
self._pending_identity_timeout = 30 # seconds
# Pending interface detachments with grace period (identity_hash -> timestamp)
# Allows new connections to establish before detaching the interface
self._pending_detach = {}
self._pending_detach_grace_period = 2.0 # seconds
# Fragmentation
self.fragmenters = {} # address -> BLEFragmenter (per MTU)
self.reassemblers = {} # address -> BLEReassembler
self.frag_lock = threading.Lock()
self.pending_mtu = {} # address -> mtu (for MTU/identity race condition)
# Discovery state with prioritization
# Initialize BLE driver (uses class attribute, can be overridden by subclasses)
if self.driver_class is None:
raise ImportError(
"No BLE driver available. LinuxBluetoothDriver not found and no "
"driver_class override provided by subclass."
)
self.driver = self.driver_class(
discovery_interval=self.discovery_interval,
connection_timeout=self.connection_timeout,
min_rssi=self.min_rssi,
service_discovery_delay=self.service_discovery_delay,
max_peers=self.max_peers,
adapter_index=0 # TODO: Make configurable
)
RNS.log(f"{self} Using driver: {type(self.driver).__name__}", RNS.LOG_DEBUG)
# Set driver callbacks
self.driver.on_device_discovered = self._device_discovered_callback
self.driver.on_device_connected = self._device_connected_callback
self.driver.on_mtu_negotiated = self._mtu_negotiated_callback
self.driver.on_data_received = self._data_received_callback
self.driver.on_device_disconnected = self._device_disconnected_callback
self.driver.on_error = self._error_callback
self.driver.on_duplicate_identity_detected = self._check_duplicate_identity
self.driver.on_address_changed = self._address_changed_callback
# Redirect Python logging to RNS logging for proper formatting
self._setup_logging_redirect()
# Set driver power mode
self.driver.set_power_mode(self.power_mode)
self.discovered_peers = {} # address -> DiscoveredPeer
self.connection_blacklist = {} # address -> (blacklist_until_timestamp, failure_count)
self.scanning = False
# HIGH #4: Limit discovered peers to prevent unbounded memory growth
self.max_discovered_peers = int(c.get("max_discovered_peers", 100)) # Reasonable limit for discovery cache
# Connection prioritization configuration
self.connection_rotation_interval = float(c.get("connection_rotation_interval", 600)) # 10 minutes
self.connection_retry_backoff = float(c.get("connection_retry_backoff", 60)) # 1 minute
self.max_connection_failures = int(c.get("max_connection_failures", 3)) # blacklist threshold
# Local adapter address (will be populated on first scan)
self.local_address = None
RNS.log(f"{self} initializing with service UUID {self.service_uuid}", RNS.LOG_INFO)
RNS.log(f"{self} power mode: {self.power_mode}, max peers: {self.max_peers}", RNS.LOG_DEBUG)
RNS.log(f"{self} central mode: {'ENABLED' if self.enable_central else 'DISABLED'}", RNS.LOG_INFO)
RNS.log(f"{self} peripheral mode: {'ENABLED' if self.enable_peripheral else 'DISABLED'}", RNS.LOG_INFO)
# Local announce forwarding status log
if self.enable_local_announce_forwarding:
RNS.log(f"{self} local packet forwarding ENABLED (workaround for Transport hops=0 bug)", RNS.LOG_INFO)
else:
RNS.log(f"{self} local packet forwarding DISABLED (relies on Transport for propagation)", RNS.LOG_DEBUG)
# CRITICAL #2: Periodic cleanup task for stale reassembly buffers
# This prevents memory leaks from incomplete packet transmissions (disconnects, corrupted data)
# Runs every 30 seconds to clean up timed-out buffers
self.cleanup_timer = None
self._start_cleanup_timer()
# Start the interface
self.start()
def start(self):
"""Start the BLE interface operations."""
RNS.log(f"{self} starting BLE operations", RNS.LOG_INFO)
# Start the BLE driver
try:
self.driver.start(
service_uuid=self.service_uuid,
rx_char_uuid=BLEInterface.CHARACTERISTIC_RX_UUID,
tx_char_uuid=BLEInterface.CHARACTERISTIC_TX_UUID,
identity_char_uuid=BLEInterface.CHARACTERISTIC_IDENTITY_UUID
)
RNS.log(f"{self} driver started successfully", RNS.LOG_INFO)
except Exception as e:
RNS.log(f"{self} failed to start driver: {e}", RNS.LOG_ERROR)
return
# If central mode is enabled, start scanning for peers
if self.enable_central:
try:
self.driver.start_scanning()
RNS.log(f"{self} started scanning for peers", RNS.LOG_INFO)
except Exception as e:
RNS.log(f"{self} failed to start scanning: {e}", RNS.LOG_ERROR)
# Bug #13 workaround: Clear stale BLE paths from Transport.path_table
# Reticulum core bug: Paths loaded from storage may have timestamp=0,
# causing immediate expiration and message delivery failures.
# This workaround removes stale BLE paths on interface startup.
# TODO: Remove when upstream Transport.py is fixed (see session notes)
self._clear_stale_ble_paths()
# Set interface online
self.online = True
RNS.log(f"{self} interface online", RNS.LOG_INFO)
def final_init(self):
"""
Interface lifecycle hook called AFTER interface is added to Transport.interfaces
but BEFORE Transport.start() loads Transport.identity.
Use this to start a background thread that waits for Transport.identity to be
loaded, then sets it on the driver and starts advertising.
"""
if self.enable_peripheral:
RNS.log(f"{self} Launching driver advertising startup thread (will wait for Transport.identity)", RNS.LOG_DEBUG)
startup_thread = threading.Thread(target=self._start_advertising_when_identity_ready, daemon=True, name="BLE-Advertising-Startup")
startup_thread.start()
def _setup_logging_redirect(self):
"""
Redirect Python logging from the BLE driver to RNS logging for consistent formatting.
Only redirects logs from 'root' logger (used by linux_bluetooth_driver), not from
underlying libraries like bleak, dbus_fast, etc.
"""
class RNSLoggingHandler(logging.Handler):
def __init__(self, interface_name):
super().__init__()
self.interface_name = interface_name
def emit(self, record):
try:
# Only process logs from root logger (linux_bluetooth_driver)
# Ignore verbose logs from underlying libraries (bleak, dbus_fast, etc.)
if record.name != 'root':
return
# Map Python logging levels to RNS log levels
level_map = {
logging.DEBUG: RNS.LOG_DEBUG,
logging.INFO: RNS.LOG_INFO,
logging.WARNING: RNS.LOG_WARNING,
logging.ERROR: RNS.LOG_ERROR,
logging.CRITICAL: RNS.LOG_CRITICAL
}
rns_level = level_map.get(record.levelno, RNS.LOG_INFO)
# Format message
message = self.format(record)
# Log to RNS
RNS.log(f"{self.interface_name} {message}", rns_level)
except Exception:
# Silently fail if RNS logging fails (don't want to break the driver)
pass
# Get root logger (used by linux_bluetooth_driver)
root_logger = logging.getLogger()
# Remove any existing stream handlers from root logger to prevent duplicate console output
for handler in root_logger.handlers[:]:
if isinstance(handler, logging.StreamHandler):
root_logger.removeHandler(handler)
# Only add handler if not already added (avoid duplicates)
handler_exists = any(isinstance(h, RNSLoggingHandler) for h in root_logger.handlers)
if not handler_exists:
handler = RNSLoggingHandler(str(self))
handler.setLevel(logging.INFO) # Only INFO and above from driver
handler.setFormatter(logging.Formatter('%(message)s'))
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO) # Don't capture DEBUG from libraries
def _start_advertising_when_identity_ready(self):
"""
Background thread that waits for Transport.identity, sets it on driver,
then starts advertising. Times out after 60 seconds if identity doesn't load.
"""
import RNS.Transport as Transport
attempt = 0
start_time = time.time()
timeout = 60.0 # 60 second timeout
RNS.log(f"{self} Waiting for Transport.identity to be loaded...", RNS.LOG_DEBUG)
# Poll until Transport.identity is available (with 60s timeout)
while time.time() - start_time < timeout:
attempt += 1
try:
if hasattr(Transport, 'identity') and Transport.identity:
identity_hash = Transport.identity.hash
if identity_hash and len(identity_hash) == 16:
elapsed = time.time() - start_time
RNS.log(f"{self} Transport.identity available after {elapsed:.1f}s", RNS.LOG_INFO)
# Set identity on driver
self.driver.set_identity(identity_hash)
# Start advertising
try:
self.driver.start_advertising(self.device_name, identity_hash)
if self.device_name:
RNS.log(f"{self} Started advertising as {self.device_name}", RNS.LOG_INFO)
else:
RNS.log(f"{self} Started advertising (no device name)", RNS.LOG_INFO)
except Exception as e:
RNS.log(f"{self} Failed to start advertising: {e}", RNS.LOG_ERROR)
return
except Exception as e:
RNS.log(f"{self} Error waiting for identity: {e}", RNS.LOG_DEBUG)
time.sleep(0.5)
RNS.log(f"{self} Timeout waiting for Transport.identity after {timeout}s", RNS.LOG_ERROR)
def _clear_stale_ble_paths(self):
"""
Clear stale BLE paths from Transport.path_table on interface startup.
Bug #13 workaround: Reticulum core loads path table entries from storage
with timestamp=0 (or very old timestamps), causing paths to immediately
expire. This prevents LXMF message delivery as messages wait for paths
that are constantly expiring and being recreated.
This workaround clears any BLE paths with invalid timestamps on startup,
forcing fresh path discovery via announces.
TODO: Remove this workaround when Reticulum core is fixed to refresh
timestamps when loading paths from storage (Transport.py:252).
"""
try:
import RNS.Transport as Transport
if not hasattr(Transport, 'path_table') or not Transport.path_table:
return
current_time = time.time()
stale_threshold = 60 # Paths older than 60 seconds are considered stale
stale_paths = []
# Scan for stale BLE paths
for dest_hash, entry in list(Transport.path_table.items()):
try:
timestamp = entry[0] # IDX_PT_TIMESTAMP
receiving_interface = entry[5] # IDX_PT_RVCD_IF
# Check if this is a BLE path
if receiving_interface and "BLE" in str(type(receiving_interface).__name__):
# Check for timestamp=0 bug or very old timestamps
if timestamp == 0:
stale_paths.append((dest_hash, timestamp, "timestamp=0 (Unix epoch bug)"))
elif (current_time - timestamp) > stale_threshold:
stale_paths.append((dest_hash, timestamp, f"age={(current_time - timestamp):.0f}s (stale from previous session)"))
except (IndexError, TypeError) as e:
# Malformed path entry
RNS.log(f"{self} Skipping malformed path table entry: {e}", RNS.LOG_DEBUG)
continue
# Remove stale paths
if stale_paths:
RNS.log(f"{self} Bug #13 workaround: Found {len(stale_paths)} stale BLE path(s) to clear", RNS.LOG_INFO)
for dest_hash, old_timestamp, reason in stale_paths:
Transport.path_table.pop(dest_hash)
RNS.log(f"{self} Cleared stale BLE path for {RNS.prettyhexrep(dest_hash)} - {reason}", RNS.LOG_DEBUG)
RNS.log(f"{self} Stale path cleanup complete. Fresh paths will be discovered via announces.", RNS.LOG_INFO)
else:
RNS.log(f"{self} No stale BLE paths found in path table", RNS.LOG_DEBUG)
except Exception as e:
RNS.log(f"{self} Error during stale path cleanup (non-fatal): {e}", RNS.LOG_WARNING)
def _start_cleanup_timer(self):
"""
Start the periodic cleanup timer.
CRITICAL #2: This timer prevents memory leaks from incomplete reassembly buffers
caused by peer disconnections or corrupted partial transmissions.
"""
if self.cleanup_timer:
self.cleanup_timer.cancel()
self.cleanup_timer = threading.Timer(30.0, self._periodic_cleanup_task)
self.cleanup_timer.daemon = True
self.cleanup_timer.start()
RNS.log(f"{self} cleanup timer started (30s interval)", RNS.LOG_WARNING)
def _periodic_cleanup_task(self):
"""
Periodically clean up stale reassembly buffers and orphaned interfaces.
This task runs every 30 seconds to:
1. Remove incomplete packet reassembly buffers that have timed out
(prevents memory exhaustion on long-running instances)
2. Validate spawned interfaces against actual connections
(catches orphaned interfaces from race conditions)
"""
if not self.online:
return # Don't reschedule if interface is offline
RNS.log(f"{self} periodic cleanup running, pending identity connections: {len(self._pending_identity_connections)}", RNS.LOG_WARNING)
with self.frag_lock:
total_cleaned = 0
for peer_address, reassembler in list(self.reassemblers.items()):
cleaned = reassembler.cleanup_stale_buffers()
if cleaned > 0:
total_cleaned += cleaned
RNS.log(f"{self} cleaned {cleaned} stale reassembly buffer(s) for {peer_address}",
RNS.LOG_DEBUG)
if total_cleaned > 0:
RNS.log(f"{self} periodic cleanup: removed {total_cleaned} stale reassembly buffer(s) total",
RNS.LOG_INFO)
# Validate spawned interfaces against actual connections
self._validate_spawned_interfaces()
# Check for pending connections that never received identity (timeout)
self._cleanup_pending_identity_connections()
# Process pending interface detachments (after grace period)
self._process_pending_detaches()
# Reschedule for next cleanup cycle
self._start_cleanup_timer()
def _cleanup_pending_identity_connections(self):
"""
Disconnect connections that never completed identity handshake.
This handles cases like non-Reticulum BLE devices (AirTags, scanners)
that connect to our GATT server but never send the identity handshake.
These connections are tracked when established and disconnected if
identity is not received within the timeout period.
"""
now = time.time()
timed_out = []
for address, connect_time in list(self._pending_identity_connections.items()):
elapsed = now - connect_time
if elapsed > self._pending_identity_timeout:
timed_out.append(address)
RNS.log(
f"{self} connection from {address} timed out waiting for identity "
f"({elapsed:.1f}s > {self._pending_identity_timeout}s), disconnecting",
RNS.LOG_WARNING
)
# Disconnect timed-out connections
for address in timed_out:
del self._pending_identity_connections[address]
try:
self.driver.disconnect(address)
except Exception as e:
RNS.log(f"{self} error disconnecting timed-out connection {address}: {e}", RNS.LOG_ERROR)
def _process_pending_detaches(self):
"""
Process pending interface detachments after grace period.
When an address disconnects, we schedule the interface for delayed detachment
to allow new connections with the same identity to establish first (MAC rotation).
After the grace period, we check again if any addresses are connected with that
identity - if not, we detach the interface.
"""
now = time.time()
to_detach = []
for identity_hash, scheduled_time in list(self._pending_detach.items()):
elapsed = now - scheduled_time
if elapsed >= self._pending_detach_grace_period:
# Grace period expired - check if any addresses now have this identity
has_connected_address = False
for addr, identity in self.address_to_identity.items():
if self._compute_identity_hash(identity) == identity_hash:
has_connected_address = True
break
if has_connected_address:
# New connection arrived during grace period - cancel detach
del self._pending_detach[identity_hash]
RNS.log(f"{self} cancelled detach for {identity_hash[:8]} - address reconnected during grace period", RNS.LOG_DEBUG)
else:
# No connections - safe to detach
to_detach.append(identity_hash)
# Actually detach interfaces
for identity_hash in to_detach:
del self._pending_detach[identity_hash]
peer_if = self.spawned_interfaces.get(identity_hash)
if peer_if:
# Get peer_identity for fragmenter cleanup before detaching
peer_identity = peer_if.peer_identity
peer_if.detach()
RNS.log(f"{self} detached interface for {identity_hash[:8]} after grace period", RNS.LOG_DEBUG)
if identity_hash in self.spawned_interfaces:
del self.spawned_interfaces[identity_hash]
if identity_hash in self.identity_to_address:
del self.identity_to_address[identity_hash]
# Clean up fragmenter/reassembler now that interface is fully detached
if peer_identity:
frag_key = self._get_fragmenter_key(peer_identity, "") # Address unused in key computation
with self.frag_lock:
if frag_key in self.fragmenters:
del self.fragmenters[frag_key]
RNS.log(f"{self} cleaned up fragmenter for {identity_hash[:8]}", RNS.LOG_DEBUG)
if frag_key in self.reassemblers:
del self.reassemblers[frag_key]
else:
RNS.log(f"{self} pending detach for {identity_hash[:8]} but interface already gone", RNS.LOG_DEBUG)
def _validate_spawned_interfaces(self):
"""
Validate that all spawned interfaces have actual underlying connections.
Cleans up orphaned interfaces where the BLE connection is gone but the
interface remains (race condition protection). This is a safety net for
cases where cleanup in disconnect callbacks fails due to timing issues.
"""
try:
# Get list of actually connected peers from driver
connected_addresses = set(self.driver.connected_peers)
# First pass: collect orphaned address mappings (addresses not in connected_addresses)
orphaned_addresses = []
for address in list(self.address_to_interface.keys()):
if address not in connected_addresses:
orphaned_addresses.append(address)
# Second pass: for each orphaned address, clean up mappings and check if interface should be detached
interfaces_to_detach = set() # Use set to avoid detaching same interface multiple times
for address in orphaned_addresses:
peer_if = self.address_to_interface.get(address)
if not peer_if:
continue
RNS.log(f"{self} cleaning up orphaned address mapping for {address}", RNS.LOG_DEBUG)
# Get identity info
peer_identity = None
identity_hash = None
if peer_if.peer_identity:
peer_identity = peer_if.peer_identity
identity_hash = self._compute_identity_hash(peer_identity)
# Remove address-specific mappings
if address in self.address_to_interface:
del self.address_to_interface[address]
if address in self.address_to_identity:
del self.address_to_identity[address]
# NOTE: Do NOT clean up fragmenters here - they are keyed by identity, not address
# Fragmenters are only cleaned up when the interface is fully detached (third pass)
# Check if ANY other addresses still use this identity/interface
if identity_hash:
other_addresses_connected = False
for other_addr in list(self.address_to_interface.keys()):
if other_addr in connected_addresses:
other_if = self.address_to_interface.get(other_addr)
if other_if == peer_if:
other_addresses_connected = True
# Update identity_to_address to point to a connected address
self.identity_to_address[identity_hash] = other_addr
break
if not other_addresses_connected:
# No other addresses connected with this identity - mark for detach
interfaces_to_detach.add((peer_if, identity_hash, peer_identity))
# Third pass: detach interfaces that have no connected addresses
for peer_if, identity_hash, peer_identity in interfaces_to_detach:
RNS.log(f"{self} detaching orphaned interface for {identity_hash[:8]} (no active connections)", RNS.LOG_WARNING)
peer_if.detach()
if identity_hash in self.spawned_interfaces:
del self.spawned_interfaces[identity_hash]
if identity_hash in self.identity_to_address:
del self.identity_to_address[identity_hash]
# Clean up fragmenter/reassembler only when interface is fully detached
if peer_identity:
frag_key = self._get_fragmenter_key(peer_identity, "") # Address unused in key computation
with self.frag_lock:
if frag_key in self.fragmenters:
del self.fragmenters[frag_key]
if frag_key in self.reassemblers:
del self.reassemblers[frag_key]
if orphaned_addresses:
RNS.log(f"{self} periodic validation: cleaned up {len(orphaned_addresses)} orphaned address(es), detached {len(interfaces_to_detach)} interface(s)", RNS.LOG_INFO)
except Exception as e:
RNS.log(f"{self} error during interface validation (non-fatal): {e}", RNS.LOG_WARNING)
def _device_discovered_callback(self, device: BLEDevice):
"""
Driver callback: Handle discovered BLE device.
This callback is invoked by the driver when a device is discovered during scanning.
We use peer scoring and connection logic to decide whether to connect.
"""
# Primary: Match by service UUID (standard BLE discovery)
if self.service_uuid not in device.service_uuids:
RNS.log(f"{self} device {device.name if device.name else device.address} does not advertise Reticulum service UUID, skipping", RNS.LOG_EXTREME)
return
# Validate RSSI - skip devices with invalid/sentinel values
if device.rssi in (-127, -128, 0):
RNS.log(f"{self} skipping {device.name or device.address} ({device.address}): invalid sentinel RSSI {device.rssi} dBm", RNS.LOG_DEBUG)
return
# Update or create discovered peer entry
if device.address not in self.discovered_peers:
self.discovered_peers[device.address] = DiscoveredPeer(
address=device.address,
name=device.name,
rssi=device.rssi
)
else:
self.discovered_peers[device.address].update_rssi(device.rssi)
# Prune discovery cache if needed (HIGH #4)
if len(self.discovered_peers) > self.max_discovered_peers:
# Remove oldest entries by last_seen timestamp
sorted_peers = sorted(
self.discovered_peers.items(),
key=lambda x: x[1].last_seen
)
to_remove = sorted_peers[:-self.max_discovered_peers]
for addr, _ in to_remove:
del self.discovered_peers[addr]
# Decide whether to connect based on peer scoring
peers_to_connect = self._select_peers_to_connect()
if device.address in [p.address for p in peers_to_connect]:
# Record connection attempt BEFORE calling driver.connect()
# This prevents rapid-fire retries if discovery callback fires again
if device.address in self.discovered_peers:
self.discovered_peers[device.address].record_connection_attempt()
# Initiate connection via driver
try:
self.driver.connect(device.address)
except Exception as e:
RNS.log(f"{self} failed to initiate connection to {device.name}: {e}", RNS.LOG_ERROR)
def _device_connected_callback(self, address: str, peer_identity: Optional[bytes]):
"""
Driver callback: Handle successful device connection.
Called when driver has established a connection. For central connections,
the peer_identity is provided. For peripheral connections, identity will
arrive later via handshake.
Args:
address: MAC address of connected peer
peer_identity: 16-byte identity hash (None for peripheral connections)
"""
role = self.driver.get_peer_role(address)
if peer_identity is not None:
# Identity provided by driver (central mode direct, peripheral mode via late callback)
if len(peer_identity) == 16:
identity_hash = self._compute_identity_hash(peer_identity)
# Cancel any pending detach for this identity (new connection arrived in time)
if identity_hash in self._pending_detach:
del self._pending_detach[identity_hash]
RNS.log(f"{self} cancelled pending detach for {identity_hash[:8]} (new connection from {address})", RNS.LOG_DEBUG)
# Store identity mappings
self.address_to_identity[address] = peer_identity
self.identity_to_address[identity_hash] = address
role_str = role.upper() if role else "UNKNOWN"
RNS.log(f"{self} connected to {address} as {role_str}, received identity: {identity_hash}", RNS.LOG_INFO)
self._record_connection_success(address)
# Remove from pending identity tracking if it was tracked
if address in self._pending_identity_connections:
del self._pending_identity_connections[address]
# Check for pending MTU (race condition: MTU negotiated before identity)
if address in self.pending_mtu:
pending_mtu = self.pending_mtu.pop(address)
RNS.log(f"{self} creating deferred fragmenter for {address} (MTU={pending_mtu})", RNS.LOG_DEBUG)
self._mtu_negotiated_callback(address, pending_mtu)
else:
RNS.log(f"{self} invalid identity from {address} (wrong length), disconnecting", RNS.LOG_WARNING)
self.driver.disconnect(address)
self._record_connection_failure(address)
elif role == "peripheral":