diff --git a/migration/phase2/Gate2E_BLEPeerSessionManager_optional_integration_20260518_1628.md b/migration/phase2/Gate2E_BLEPeerSessionManager_optional_integration_20260518_1628.md new file mode 100644 index 0000000..7698da2 --- /dev/null +++ b/migration/phase2/Gate2E_BLEPeerSessionManager_optional_integration_20260518_1628.md @@ -0,0 +1,135 @@ +# Gate 2E: BLEPeerSessionManager Optional Integration + +Date: 2026-05-18 16:28 America/Los_Angeles + +Scope: optional `BLEPeerSessionManager` integration behind `BLE_RETICULUM_SESSION_BACKEND`. + +## Summary + +Gate 2E adds an optional C++ session-manager path for `BLEInterface._handle_identity_handshake`. + +`BLEInterface.py` was modified in this gate. The existing Python handshake implementation was preserved as `_handle_identity_handshake_python`, and `_handle_identity_handshake` is now a dispatcher. + +No live BLE field testing was performed. + +## Backend Flag + +New environment variable: + +```text +BLE_RETICULUM_SESSION_BACKEND=auto|cpp|python +``` + +Behavior: + +- `python`: always use the existing Python handshake logic. +- `cpp`: require `ble_protocol_core_cpp.BLEPeerSessionManager`; fail clearly if unavailable. +- `auto`: prefer C++ session manager if importable, otherwise use Python. + +This is separate from: + +```text +BLE_RETICULUM_FRAGMENTATION_BACKEND +``` + +The fragmentation backend was not conflated with session backend selection. + +## Modified Source + +- `src/ble_reticulum/BLESessionBackend.py` +- `src/ble_reticulum/BLEInterface.py` + +`handle_peripheral_data` was not modified. + +The C++ manager remains a decision engine only. Python still performs: + +- `RNS.log` +- `driver.disconnect` +- Python fragmenter/reassembler creation +- `_spawn_peer_interface` +- `address_to_identity` / `identity_to_address` mirroring +- `address_to_interface` mirroring +- `_pending_identity_connections` cleanup +- `_last_real_data` updates + +## Tests Added + +Added: + +- `migration/tests/test_ble_session_backend_integration.py` + +Coverage: + +- default/auto backend selects C++ when available; +- auto falls back to Python when C++ is unavailable; +- Python backend forces Python even when C++ is available; +- C++ backend fails clearly when unavailable; +- Python backend still uses existing Python handshake behavior; +- C++ backend covers Gate 2D equivalence cases through the `BLEInterface._handle_identity_handshake` dispatcher: + - non-16-byte payload; + - new identity accepted; + - duplicate same identity consumed; + - duplicate mismatched identity consumed with warning intent; + - duplicate active elsewhere disconnects current address; + - stale/pending detach replacement accepted; + - zombie old connection accepted and old peer disconnect requested; + - MTU fallback to 23; + - MTU provided honored; + - pending identity removed; + - existing spawned interface path updates address. + +## Verification + +Backend shim regression: + +```sh +pytest -q migration/tests/test_fragmentation_backend_shim.py +``` + +Result: + +```text +9 passed, 2 warnings in 0.59s +``` + +Gate 2C / Gate 2D / Gate 2E regression: + +```sh +PYTHONPATH=migration/protocol_core pytest -q migration/tests/test_ble_peer_session_manager_pybind.py migration/tests/test_ble_peer_session_manager_python_equivalence.py migration/tests/test_ble_session_backend_integration.py +``` + +Result: + +```text +39 passed, 1 skipped, 2 warnings in 0.51s +``` + +Migration regression set: + +```sh +PYTHONPATH=migration/protocol_core pytest -q migration/tests/test_fragmentation_cpp_equivalence.py migration/tests/test_identity_helpers_cpp_equivalence.py migration/tests/test_ble_peer_session_manager_pybind.py migration/tests/test_ble_peer_session_manager_python_equivalence.py migration/tests/test_ble_session_backend_integration.py +``` + +Result: + +```text +87 passed, 2 skipped, 2 warnings in 0.77s +``` + +The pybind11 extension was already current for Gate 2E; no rebuild was needed in this gate. + +## Notes + +The default backend selector follows the Gate 2E requested `auto` behavior: prefer C++ when available and fall back to Python when unavailable. Operators can force the reference Python path with: + +```sh +BLE_RETICULUM_SESSION_BACKEND=python +``` + +## SQL + +Companion SQL: + +`migration/sql/mark_gate2e_protocol_session_optional_integration_20260518_1628.sql` + +The SQL marks `_handle_identity_handshake` as `OPTIONAL_INTEGRATION` for phase `2_ble_protocol_session_manager`, keeps tag `GLUE`, keeps `cpp_candidate=1`, and does not mark field acceptance. diff --git a/migration/sql/mark_gate2e_protocol_session_optional_integration_20260518_1628.sql b/migration/sql/mark_gate2e_protocol_session_optional_integration_20260518_1628.sql new file mode 100644 index 0000000..0fd90f8 --- /dev/null +++ b/migration/sql/mark_gate2e_protocol_session_optional_integration_20260518_1628.sql @@ -0,0 +1,92 @@ +BEGIN TRANSACTION; + +INSERT INTO symbols ( + source_file, + symbol_name, + symbol_type, + class_name, + line_number, + tag, + phase, + status, + cpp_candidate, + confidence, + rationale, + callers, + callees, + notes, + first_seen_at, + updated_at +) +VALUES ( + 'src/ble_reticulum/BLEInterface.py', + '_handle_identity_handshake', + 'method', + 'BLEInterface', + 1202, + 'GLUE', + '2_ble_protocol_session_manager', + 'OPTIONAL_INTEGRATION', + 1, + 'high', + 'Gate 2E added optional BLEPeerSessionManager integration behind BLE_RETICULUM_SESSION_BACKEND. The existing Python handshake path remains available and the C++ manager remains a decision engine with Python adapter side effects.', + '_data_received_callback', + '_handle_identity_handshake_python; _handle_identity_handshake_cpp; _compute_identity_hash; _check_duplicate_identity; driver.disconnect; driver.get_peer_mtu; _get_fragmenter_key; BLEFragmenter; BLEReassembler; _spawn_peer_interface', + 'Gate 2E optional integration tests passed. BLEInterface.py was modified, handle_peripheral_data was not modified, no live BLE field tests were performed, and no Phase 1 FIELD_ACCEPTED rows were changed.', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) +ON CONFLICT(source_file, class_name, symbol_name, line_number) DO UPDATE SET + tag = 'GLUE', + phase = '2_ble_protocol_session_manager', + status = 'OPTIONAL_INTEGRATION', + cpp_candidate = 1, + confidence = 'high', + rationale = excluded.rationale, + callers = excluded.callers, + callees = excluded.callees, + notes = excluded.notes, + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO reviews ( + symbol_id, + reviewed_at, + reviewer, + old_tag, + new_tag, + old_status, + new_status, + note +) +SELECT + symbol_id, + CURRENT_TIMESTAMP, + 'Codex', + 'GLUE', + 'GLUE', + 'PYTHON_EQUIVALENT', + 'OPTIONAL_INTEGRATION', + 'Gate 2E added optional BLEPeerSessionManager integration behind BLE_RETICULUM_SESSION_BACKEND. Regression results: backend shim 9 passed; Gate 2C/2D/2E 39 passed, 1 skipped; migration set 87 passed, 2 skipped. No FIELD_ACCEPTED status assigned.' +FROM symbols +WHERE source_file = 'src/ble_reticulum/BLEInterface.py' + AND class_name = 'BLEInterface' + AND symbol_name = '_handle_identity_handshake' + AND line_number = 1202; + +SELECT + symbol_id, + source_file, + class_name, + symbol_name, + tag, + phase, + status, + cpp_candidate, + updated_at +FROM symbols +WHERE source_file = 'src/ble_reticulum/BLEInterface.py' + AND class_name = 'BLEInterface' + AND symbol_name = '_handle_identity_handshake' + AND line_number = 1202; + +COMMIT; diff --git a/migration/tests/test_ble_peer_session_manager_python_equivalence.py b/migration/tests/test_ble_peer_session_manager_python_equivalence.py index 253bb34..8ec54a0 100644 --- a/migration/tests/test_ble_peer_session_manager_python_equivalence.py +++ b/migration/tests/test_ble_peer_session_manager_python_equivalence.py @@ -117,7 +117,7 @@ class FakePeerInterface: class Harness: - _handle_identity_handshake = BLEInterface._handle_identity_handshake + _handle_identity_handshake = BLEInterface._handle_identity_handshake_python _check_duplicate_identity = BLEInterface._check_duplicate_identity _compute_identity_hash = BLEInterface._compute_identity_hash _get_fragmenter_key = BLEInterface._get_fragmenter_key diff --git a/migration/tests/test_ble_session_backend_integration.py b/migration/tests/test_ble_session_backend_integration.py new file mode 100644 index 0000000..9616cfe --- /dev/null +++ b/migration/tests/test_ble_session_backend_integration.py @@ -0,0 +1,360 @@ +import json +import os +import subprocess +import sys +import types + +import pytest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SRC_DIR = os.path.join(REPO_ROOT, "src") +CPP_BUILD_DIR = os.path.join(REPO_ROOT, "migration", "protocol_core") + +sys.path.insert(0, SRC_DIR) +sys.path.insert(0, CPP_BUILD_DIR) + +cpp = pytest.importorskip("ble_protocol_core_cpp") + + +def install_fake_rns(): + rns = types.ModuleType("RNS") + rns.log = lambda *args, **kwargs: None + rns.LOG_EXTREME = 0 + rns.LOG_DEBUG = 1 + rns.LOG_INFO = 2 + rns.LOG_NOTICE = 2 + rns.LOG_WARNING = 3 + rns.LOG_ERROR = 4 + rns.LOG_CRITICAL = 5 + rns.Reticulum = types.SimpleNamespace(configdir=None) + rns.Transport = types.SimpleNamespace(interfaces=[]) + sys.modules["RNS"] = rns + + transport = types.ModuleType("RNS.Transport") + transport.interfaces = [] + sys.modules["RNS.Transport"] = transport + sys.modules["RNS.Interfaces"] = types.ModuleType("RNS.Interfaces") + interface_mod = types.ModuleType("RNS.Interfaces.Interface") + + class Interface: + MODE_FULL = 0 + + interface_mod.Interface = Interface + sys.modules["RNS.Interfaces.Interface"] = interface_mod + + +install_fake_rns() +import ble_reticulum.BLEInterface as ble_interface_module +from ble_reticulum.BLEInterface import BLEInterface + + +class FakeRNS: + LOG_DEBUG = 1 + LOG_INFO = 2 + LOG_NOTICE = 2 + LOG_WARNING = 3 + LOG_ERROR = 4 + logs = [] + + @staticmethod + def log(message, level): + FakeRNS.logs.append((level, message)) + + +class FakeDriver: + def __init__(self, mtu=None, connected_peers=None): + self.mtu = mtu + self.connected_peers = set(connected_peers or []) + self.disconnect_calls = [] + + def disconnect(self, address): + self.disconnect_calls.append(address) + + def get_peer_mtu(self, address): + return self.mtu + + +class FakeFragmenter: + instances = [] + + def __init__(self, mtu=185): + self.mtu = mtu + FakeFragmenter.instances.append(self) + + +class FakeReassembler: + instances = [] + + def __init__(self, timeout=None): + self.timeout = timeout + FakeReassembler.instances.append(self) + + +class FakeLock: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakePeerInterface: + def __init__(self, address): + self.peer_address = address + + +class Harness: + _handle_identity_handshake = BLEInterface._handle_identity_handshake + _handle_identity_handshake_cpp = BLEInterface._handle_identity_handshake_cpp + _handle_identity_handshake_python = BLEInterface._handle_identity_handshake_python + _get_session_backend = BLEInterface._get_session_backend + _get_session_manager = BLEInterface._get_session_manager + _build_session_snapshot = BLEInterface._build_session_snapshot + _check_duplicate_identity = BLEInterface._check_duplicate_identity + _compute_identity_hash = BLEInterface._compute_identity_hash + _get_fragmenter_key = BLEInterface._get_fragmenter_key + + def __init__(self, backend="cpp", mtu=None, connected_peers=None): + self._session_backend = backend + self._session_manager = cpp.BLEPeerSessionManager() if backend == "cpp" else None + self.driver = FakeDriver(mtu=mtu, connected_peers=connected_peers) + self.address_to_identity = {} + self.identity_to_address = {} + self.address_to_interface = {} + self.spawned_interfaces = {} + self.peers = {} + self.fragmenters = {} + self.reassemblers = {} + self.frag_lock = FakeLock() + self._pending_identity_connections = {} + self._pending_identity_timeout = 30 + self._pending_detach = {} + self._last_real_data = {} + self._zombie_timeout = 45.0 + self.cleanup_calls = [] + self.spawn_calls = [] + + def _cleanup_stale_address(self, identity_hash, old_address): + self.cleanup_calls.append((identity_hash, old_address)) + self.address_to_identity.pop(old_address, None) + self.address_to_interface.pop(old_address, None) + self.peers.pop(old_address, None) + + def _spawn_peer_interface( + self, + address, + name, + peer_identity, + client=None, + mtu=None, + connection_type="central", + ): + identity_hash = self._compute_identity_hash(peer_identity) + peer = FakePeerInterface(address) + self.spawned_interfaces[identity_hash] = peer + self.address_to_interface[address] = peer + self.spawn_calls.append( + { + "address": address, + "peer_identity": peer_identity, + "mtu": mtu, + "connection_type": connection_type, + } + ) + return peer + + def __str__(self): + return "HarnessBLEInterface" + + +@pytest.fixture(autouse=True) +def patch_bleinterface_globals(monkeypatch): + FakeRNS.logs = [] + FakeFragmenter.instances = [] + FakeReassembler.instances = [] + monkeypatch.setattr(ble_interface_module, "RNS", FakeRNS) + monkeypatch.setattr(ble_interface_module, "BLEFragmenter", FakeFragmenter) + monkeypatch.setattr(ble_interface_module, "BLEReassembler", FakeReassembler) + + +def identity(base): + return bytes((base + i) & 0xFF for i in range(16)) + + +def run_backend_probe(requested=None, include_cpp=True): + env = os.environ.copy() + if requested is None: + env.pop("BLE_RETICULUM_SESSION_BACKEND", None) + else: + env["BLE_RETICULUM_SESSION_BACKEND"] = requested + pythonpath = [SRC_DIR] + if include_cpp: + pythonpath.append(CPP_BUILD_DIR) + env["PYTHONPATH"] = os.pathsep.join(pythonpath) + completed = subprocess.run( + [ + sys.executable, + "-c", + "import json, ble_reticulum.BLESessionBackend as b; " + "print(json.dumps({'backend': b.BACKEND, 'requested': b.REQUESTED_BACKEND}))", + ], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + ) + return completed + + +def test_default_auto_backend_selects_cpp_when_available(): + completed = run_backend_probe(requested=None, include_cpp=True) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout)["backend"] == "cpp" + + +def test_auto_backend_falls_back_to_python_when_cpp_unavailable(): + completed = run_backend_probe(requested="auto", include_cpp=False) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout)["backend"] == "python" + + +def test_python_backend_forces_python_even_when_cpp_available(): + completed = run_backend_probe(requested="python", include_cpp=True) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout)["backend"] == "python" + + +def test_cpp_backend_fails_clearly_when_unavailable(): + completed = run_backend_probe(requested="cpp", include_cpp=False) + assert completed.returncode != 0 + assert "C++ BLE session backend is not available" in completed.stderr + + +def test_python_backend_still_uses_existing_python_behavior(): + harness = Harness(backend="python", mtu=185) + peer_identity = identity(0x10) + + result = harness._handle_identity_handshake("AA:01", peer_identity) + + assert result is True + assert harness.address_to_identity["AA:01"] == peer_identity + frag_key = harness._get_fragmenter_key(peer_identity, "AA:01") + assert harness.fragmenters[frag_key].mtu == 185 + + +def test_cpp_backend_non_16_byte_payload_returns_false(): + harness = Harness(backend="cpp") + assert harness._handle_identity_handshake("AA:02", b"abc") is False + + +def test_cpp_backend_new_identity_accepted(): + harness = Harness(backend="cpp", mtu=185) + harness._pending_identity_connections["AA:03"] = 1.0 + peer_identity = identity(0x20) + + result = harness._handle_identity_handshake("AA:03", peer_identity) + + assert result is True + assert harness.address_to_identity["AA:03"] == peer_identity + assert "AA:03" not in harness._pending_identity_connections + assert harness.spawn_calls + frag_key = harness._get_fragmenter_key(peer_identity, "AA:03") + assert harness.fragmenters[frag_key].mtu == 185 + + +def test_cpp_backend_duplicate_same_identity_consumed(): + harness = Harness(backend="cpp") + peer_identity = identity(0x30) + harness.address_to_identity["AA:04"] = peer_identity + assert harness._handle_identity_handshake("AA:04", peer_identity) is True + + +def test_cpp_backend_duplicate_mismatch_consumed_with_warning(): + harness = Harness(backend="cpp") + harness.address_to_identity["AA:05"] = identity(0x40) + assert harness._handle_identity_handshake("AA:05", identity(0x50)) is True + assert FakeRNS.logs + + +def test_cpp_backend_duplicate_active_elsewhere_disconnects_current(): + old_address = "AA:06" + current_address = "AA:07" + peer_identity = identity(0x60) + harness = Harness(backend="cpp", connected_peers={old_address}) + identity_hash = harness._compute_identity_hash(peer_identity) + harness.identity_to_address[identity_hash] = old_address + harness.peers[old_address] = object() + + assert harness._handle_identity_handshake(current_address, peer_identity) is True + assert harness.driver.disconnect_calls == [current_address] + + +def test_cpp_backend_stale_pending_detach_replacement_accepted(): + old_address = "AA:08" + current_address = "AA:09" + peer_identity = identity(0x70) + harness = Harness(backend="cpp") + identity_hash = harness._compute_identity_hash(peer_identity) + harness.identity_to_address[identity_hash] = old_address + harness.address_to_identity[old_address] = peer_identity + harness._pending_detach[identity_hash] = 1.0 + + assert harness._handle_identity_handshake(current_address, peer_identity) is True + assert harness.cleanup_calls == [(identity_hash, old_address)] + assert harness.address_to_identity[current_address] == peer_identity + + +def test_cpp_backend_zombie_old_connection_disconnects_old(): + old_address = "AA:0A" + current_address = "AA:0B" + peer_identity = identity(0x80) + harness = Harness(backend="cpp", connected_peers={old_address}) + identity_hash = harness._compute_identity_hash(peer_identity) + harness.identity_to_address[identity_hash] = old_address + harness.peers[old_address] = object() + harness.address_to_identity[old_address] = peer_identity + harness._last_real_data[identity_hash] = 1.0 + + assert harness._handle_identity_handshake(current_address, peer_identity) is True + assert old_address in harness.driver.disconnect_calls + assert harness.cleanup_calls == [(identity_hash, old_address)] + + +def test_cpp_backend_mtu_fallback_to_23(): + harness = Harness(backend="cpp", mtu=None) + peer_identity = identity(0x90) + assert harness._handle_identity_handshake("AA:0C", peer_identity) is True + frag_key = harness._get_fragmenter_key(peer_identity, "AA:0C") + assert harness.fragmenters[frag_key].mtu == 23 + + +def test_cpp_backend_mtu_provided_is_honored(): + harness = Harness(backend="cpp", mtu=247) + peer_identity = identity(0xA0) + assert harness._handle_identity_handshake("AA:0D", peer_identity) is True + frag_key = harness._get_fragmenter_key(peer_identity, "AA:0D") + assert harness.fragmenters[frag_key].mtu == 247 + + +def test_cpp_backend_pending_identity_removed(): + harness = Harness(backend="cpp") + harness._pending_identity_connections["AA:0E"] = 1.0 + assert harness._handle_identity_handshake("AA:0E", identity(0xB0)) is True + assert "AA:0E" not in harness._pending_identity_connections + + +def test_cpp_backend_existing_spawned_interface_updates_address(): + old_address = "AA:0F" + current_address = "AA:10" + peer_identity = identity(0xC0) + harness = Harness(backend="cpp") + identity_hash = harness._compute_identity_hash(peer_identity) + existing = FakePeerInterface(old_address) + harness.spawned_interfaces[identity_hash] = existing + harness.identity_to_address[identity_hash] = old_address + harness._pending_detach[identity_hash] = 1.0 + + assert harness._handle_identity_handshake(current_address, peer_identity) is True + assert existing.peer_address == current_address + assert harness.address_to_interface[current_address] is existing diff --git a/src/ble_reticulum/BLEInterface.py b/src/ble_reticulum/BLEInterface.py index 4ed0eef..081fd8a 100644 --- a/src/ble_reticulum/BLEInterface.py +++ b/src/ble_reticulum/BLEInterface.py @@ -98,6 +98,41 @@ except ImportError as e: BLE_FRAGMENTATION_BACKEND = "python-direct" from ble_reticulum.BLEFragmentation import BLEFragmenter, BLEReassembler +# Import optional session backend selector. +try: + from BLESessionBackend import ( + BACKEND as BLE_SESSION_BACKEND, + BLEPeerSessionManager, + ConnectionId as BLESessionConnectionId, + ConnectionSnapshot as BLESessionConnectionSnapshot, + InputDecision as BLESessionInputDecision, + LocalRole as BLESessionLocalRole, + SessionActionType as BLESessionActionType, + ) +except ImportError as e: + if "C++ BLE session backend is not available" in str(e): + raise + try: + from ble_reticulum.BLESessionBackend import ( + BACKEND as BLE_SESSION_BACKEND, + BLEPeerSessionManager, + ConnectionId as BLESessionConnectionId, + ConnectionSnapshot as BLESessionConnectionSnapshot, + InputDecision as BLESessionInputDecision, + LocalRole as BLESessionLocalRole, + SessionActionType as BLESessionActionType, + ) + except ImportError as e: + if "C++ BLE session backend is not available" in str(e): + raise + BLE_SESSION_BACKEND = "python-direct" + BLEPeerSessionManager = None + BLESessionConnectionId = None + BLESessionConnectionSnapshot = None + BLESessionInputDecision = None + BLESessionLocalRole = None + BLESessionActionType = None + # Import GATT server for peripheral mode try: from BLEGATTServer import BLEGATTServer @@ -412,6 +447,13 @@ class BLEInterface(Interface): # This handles BLE link degradation where keepalives work but data doesn't. self._last_real_data = {} self._zombie_timeout = 30.0 # seconds - connection is zombie if no real data for this long + self._session_backend = BLE_SESSION_BACKEND + self._session_manager = None + if self._session_backend == "cpp" and BLEPeerSessionManager is not None: + self._session_manager = BLEPeerSessionManager( + self._pending_identity_timeout, + self._zombie_timeout, + ) # Fragmentation self.fragmenters = {} # address -> BLEFragmenter (per MTU) @@ -477,6 +519,7 @@ class BLEInterface(Interface): f"reassembler={BLEReassembler.__module__}.{BLEReassembler.__name__})", RNS.LOG_NOTICE ) + RNS.log(f"{self} session backend: {self._session_backend}", RNS.LOG_NOTICE) if os.environ.get("BLE_RETICULUM_FRAGMENTATION_BACKEND_REPORT", "").lower() in ["1", "yes", "true", "on"]: print( f"{self} fragmentation backend: {BLE_FRAGMENTATION_BACKEND} " @@ -1199,7 +1242,152 @@ class BLEInterface(Interface): self.address_to_interface[address] = existing_if RNS.log(f"{self} updated peer interface address for MAC rotation: {old_addr} -> {address}", RNS.LOG_DEBUG) + def _get_session_backend(self): + return getattr(self, "_session_backend", BLE_SESSION_BACKEND) + + def _get_session_manager(self): + manager = getattr(self, "_session_manager", None) + if manager is None: + if BLEPeerSessionManager is None: + raise ImportError( + "C++ BLE session backend is not available. Build or install " + "ble_protocol_core_cpp, or set BLE_RETICULUM_SESSION_BACKEND=python." + ) + manager = BLEPeerSessionManager( + getattr(self, "_pending_identity_timeout", 30), + getattr(self, "_zombie_timeout", 30), + ) + self._session_manager = manager + return manager + + def _build_session_snapshot(self, address: str, data: bytes): + snapshot = BLESessionConnectionSnapshot() + snapshot.current = BLESessionConnectionId(address) + snapshot.local_role = BLESessionLocalRole.Peripheral + + peer_identity = self.address_to_identity.get(address) + if peer_identity: + snapshot.set_known_identity(peer_identity) + + if len(data) == 16: + identity_hash = self._compute_identity_hash(bytes(data)) + existing_address = self.identity_to_address.get(identity_hash) + if existing_address is not None: + snapshot.existing_address_for_identity = existing_address + snapshot.identity_has_pending_detach = identity_hash in self._pending_detach + snapshot.existing_address_connected = existing_address in self.driver.connected_peers + snapshot.existing_address_in_peer_table = existing_address in self.peers + last_real_data = self._last_real_data.get(identity_hash, 0) + snapshot.existing_last_real_data = last_real_data + snapshot.existing_connection_is_zombie = ( + last_real_data > 0 and time.time() - last_real_data > self._zombie_timeout + ) + + try: + mtu = self.driver.get_peer_mtu(address) + except Exception: + mtu = None + if mtu: + snapshot.negotiated_mtu = int(mtu) + + return snapshot + + def _handle_identity_handshake_cpp(self, address: str, data: bytes) -> bool: + try: + manager = self._get_session_manager() + snapshot = self._build_session_snapshot(address, data) + result = manager.handle_identity_handshake(snapshot, bytes(data), time.time()) + + if result.decision == BLESessionInputDecision.PassToReassembler: + return False + + if result.decision == BLESessionInputDecision.ConsumedDuplicateSameIdentity: + RNS.log(f"{self} received duplicate identity handshake from {address} (already known via callback)", RNS.LOG_DEBUG) + return True + + if result.decision == BLESessionInputDecision.ConsumedDuplicateMismatchedIdentity: + RNS.log(f"{self} received 16-byte data from {address} that differs from known identity, consuming as handshake", RNS.LOG_WARNING) + return True + + if result.decision == BLESessionInputDecision.RejectedDuplicateIdentity: + RNS.log( + f"{self} duplicate identity rejected for {address} in peripheral mode (MAC rotation)", + RNS.LOG_WARNING + ) + try: + self.driver.disconnect(address) + except Exception as e: + RNS.log(f"{self} failed to disconnect duplicate {address}: {e}", RNS.LOG_DEBUG) + return True + + if result.decision != BLESessionInputDecision.AcceptedNewIdentity: + return True + + central_identity = result.peer_identity + if central_identity is None: + central_identity = bytes(data) + identity_hash = result.identity_key or self._compute_identity_hash(central_identity) + + for action in result.actions: + if action.type == BLESessionActionType.CleanupOldAddress and action.old_address: + self._cleanup_stale_address(identity_hash, action.old_address) + elif action.type == BLESessionActionType.DisconnectOldPeer and action.old_address: + try: + self.driver.disconnect(action.old_address) + except Exception as e: + RNS.log(f"{self} failed to disconnect zombie {action.old_address}: {e}", RNS.LOG_DEBUG) + + self.address_to_identity[address] = central_identity + self.identity_to_address[identity_hash] = address + + RNS.log(f"{self} received identity handshake from {address}: {identity_hash}", RNS.LOG_INFO) + + mtu = result.mtu or 23 + frag_key = result.fragmenter_key or self._get_fragmenter_key(central_identity, address) + + with self.frag_lock: + self.fragmenters[frag_key] = BLEFragmenter(mtu=mtu) + if frag_key not in self.reassemblers: + self.reassemblers[frag_key] = BLEReassembler() + + if identity_hash not in self.spawned_interfaces: + peer_name = f"Central-{address[-8:]}" + connection_type = "peripheral" + + self._spawn_peer_interface( + address=address, + name=peer_name, + peer_identity=central_identity, + mtu=mtu, + connection_type=connection_type + ) + else: + existing_if = self.spawned_interfaces[identity_hash] + old_addr = existing_if.peer_address + if old_addr != address: + existing_if.peer_address = address + self.address_to_interface[address] = existing_if + RNS.log(f"{self} updated peer interface address for MAC rotation: {old_addr} -> {address}", RNS.LOG_DEBUG) + + RNS.log(f"{self} identity handshake complete for {address}", RNS.LOG_INFO) + + self._last_real_data[identity_hash] = time.time() + + if address in self._pending_identity_connections: + del self._pending_identity_connections[address] + + return True + + except Exception as e: + RNS.log(f"{self} failed to process identity handshake via C++ session backend from {address}: {e}", RNS.LOG_ERROR) + return True + def _handle_identity_handshake(self, address: str, data: bytes) -> bool: + if self._get_session_backend() == "cpp": + return self._handle_identity_handshake_cpp(address, data) + return self._handle_identity_handshake_python(address, data) + + def _handle_identity_handshake_python(self, address: str, data: bytes) -> bool: """ Handle identity handshake from central device (peripheral role only). diff --git a/src/ble_reticulum/BLESessionBackend.py b/src/ble_reticulum/BLESessionBackend.py new file mode 100644 index 0000000..c248fe6 --- /dev/null +++ b/src/ble_reticulum/BLESessionBackend.py @@ -0,0 +1,104 @@ +import os + + +BACKEND_ENV_VAR = "BLE_RETICULUM_SESSION_BACKEND" +DEFAULT_BACKEND = "auto" +VALID_BACKENDS = {"auto", "cpp", "python"} + + +def _requested_backend(): + value = os.environ.get(BACKEND_ENV_VAR, DEFAULT_BACKEND).strip().lower() + if value not in VALID_BACKENDS: + raise ValueError( + f"Invalid {BACKEND_ENV_VAR}={value!r}; expected 'auto', 'cpp', or 'python'" + ) + return value + + +def _load_cpp_backend(): + try: + from ble_protocol_core_cpp import ( + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) + except ImportError as e: + raise ImportError( + "C++ BLE session backend is not available. Build or install " + "ble_protocol_core_cpp, or set BLE_RETICULUM_SESSION_BACKEND=python." + ) from e + + return ( + "cpp", + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) + + +def _load_python_backend(): + return ("python", None, None, None, None, None, None) + + +REQUESTED_BACKEND = _requested_backend() + +if REQUESTED_BACKEND == "python": + ( + BACKEND, + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) = _load_python_backend() +elif REQUESTED_BACKEND == "cpp": + ( + BACKEND, + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) = _load_cpp_backend() +else: + try: + ( + BACKEND, + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) = _load_cpp_backend() + except ImportError: + ( + BACKEND, + BLEPeerSessionManager, + ConnectionId, + ConnectionSnapshot, + InputDecision, + LocalRole, + SessionActionType, + ) = _load_python_backend() + + +__all__ = [ + "BACKEND", + "BACKEND_ENV_VAR", + "REQUESTED_BACKEND", + "BLEPeerSessionManager", + "ConnectionId", + "ConnectionSnapshot", + "InputDecision", + "LocalRole", + "SessionActionType", +]