Gate 2E passes on jp
Some checks failed
Tests / Detect Changes (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM) (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM)-1 (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Unit Tests-1 (push) Has been cancelled
Tests / Unit Tests-2 (push) Has been cancelled
Tests / Unit Tests-3 (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Integration Tests-1 (push) Has been cancelled
Tests / Integration Tests-2 (push) Has been cancelled
Tests / Integration Tests-3 (push) Has been cancelled
Tests / Installer Test (Fresh System) (push) Has been cancelled
Tests / Installer Test (Fresh System)-1 (push) Has been cancelled
Tests / Installer Test (Fresh System)-2 (push) Has been cancelled
Tests / Installer Test (Fresh System)-3 (push) Has been cancelled
Tests / Installer Test (Fresh System)-4 (push) Has been cancelled
Some checks failed
Tests / Detect Changes (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM) (push) Has been cancelled
Tests / Installer Test (Raspberry Pi OS - ARM)-1 (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Unit Tests-1 (push) Has been cancelled
Tests / Unit Tests-2 (push) Has been cancelled
Tests / Unit Tests-3 (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Integration Tests-1 (push) Has been cancelled
Tests / Integration Tests-2 (push) Has been cancelled
Tests / Integration Tests-3 (push) Has been cancelled
Tests / Installer Test (Fresh System) (push) Has been cancelled
Tests / Installer Test (Fresh System)-1 (push) Has been cancelled
Tests / Installer Test (Fresh System)-2 (push) Has been cancelled
Tests / Installer Test (Fresh System)-3 (push) Has been cancelled
Tests / Installer Test (Fresh System)-4 (push) Has been cancelled
This commit is contained in:
parent
5d90822dd8
commit
8589f97f49
6 changed files with 880 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
360
migration/tests/test_ble_session_backend_integration.py
Normal file
360
migration/tests/test_ble_session_backend_integration.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
104
src/ble_reticulum/BLESessionBackend.py
Normal file
104
src/ble_reticulum/BLESessionBackend.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue