From 387cd1c57dad936f492f72149c74251179210e08 Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 18 May 2026 15:32:00 -0700 Subject: [PATCH] Gate 2C works on jp, TODO: test on zerodev1 --- ...dex_prompt_20260518_1504_Phase2_Gate_2C.md | 79 +++++++ ...ssionManager_pybind_tests_20260518_1507.md | 115 ++++++++++ migration/protocol_core/ble_protocol_core.cpp | 211 ++++++++++++++++++ migration/protocol_core/setup.py | 5 +- ...col_session_pybind_tests_20260518_1507.sql | 92 ++++++++ .../test_ble_peer_session_manager_pybind.py | 180 +++++++++++++++ 6 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 migration/Codex_prompt_20260518_1504_Phase2_Gate_2C.md create mode 100644 migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_20260518_1507.md create mode 100644 migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.sql create mode 100644 migration/tests/test_ble_peer_session_manager_pybind.py diff --git a/migration/Codex_prompt_20260518_1504_Phase2_Gate_2C.md b/migration/Codex_prompt_20260518_1504_Phase2_Gate_2C.md new file mode 100644 index 0000000..2321407 --- /dev/null +++ b/migration/Codex_prompt_20260518_1504_Phase2_Gate_2C.md @@ -0,0 +1,79 @@ +Gate 2C task: pybind11 binding for BLEPeerSessionManager plus Python tests + +Context: +Gate 2B is complete. BLEPeerSessionManager compiles and native C++ tests pass on jp and zerodev1. + +Goal: +Expose BLEPeerSessionManager to Python through the existing protocol_core / pybind11 module so Python tests can instantiate the manager, call its methods, and inspect result/action values. + +Restrictions: +- Do not modify BLEInterface.py. +- Do not modify live BLE behavior. +- Do not add environment-flag integration yet. +- Do not run bilateral field tests yet. +- Keep this as binding + Python unit tests only. + +Implement: +1. pybind11 bindings for: + - BLEPeerSessionManager + - ConnectionId + - ConnectionSnapshot + - HandshakeResult + - SessionAction + - PeerSessionView if useful + - relevant enums: + - LocalRole + - InputDecision + - SessionActionType + +2. Bind static helpers: + - isIdentityHandshakePayload + - identityFromPayload + - computeIdentityKey + - computeFragmenterKey + +3. Python tests under: + migration/tests/test_ble_peer_session_manager_pybind.py + +Test cases: +- import module succeeds +- non-16-byte payload returns PassToReassembler and consumed=false +- new 16-byte identity returns AcceptedNewIdentity and consumed=true +- identity_key equals first 8 bytes as 16 lowercase hex chars +- fragmenter_key equals full 16-byte identity as 32 lowercase hex chars +- known identity duplicate same returns ConsumedDuplicateSameIdentity +- known identity duplicate mismatch returns ConsumedDuplicateMismatchedIdentity +- MTU provided is preserved +- MTU missing falls back to 23 +- duplicate identity active elsewhere requests DisconnectCurrentPeer +- pending identity timeout can be marked and expired +- peer address update preserves identity/fragmenter key + +Also run existing tests: +- migration/tests/test_fragmentation_cpp_equivalence.py +- migration/tests/test_fragmentation_backend_shim.py +- migration/tests/test_identity_helpers_cpp_equivalence.py +- migration/tests/test_ble_peer_session_manager_pybind.py + +Deliverables: +1. Updated pybind11 binding source. +2. Python tests. +3. Gate 2C report: + migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_YYYYMMDD_HHMM.md +4. SQL: + migration/sql/mark_gate2c_protocol_session_pybind_tests_YYYYMMDD_HHMM.sql + +SQL requirements: +- Do not modify Phase 1 FIELD_ACCEPTED records. +- Do not mark Gate 2F / FIELD_ACCEPTED. +- Update _handle_identity_handshake tracking row: + phase = '2_ble_protocol_session_manager' + status = 'PYTHON_BOUND' + cpp_candidate = 1 +- Insert a reviews row describing: + Gate 2C completed pybind11 exposure and Python unit tests. +- Include a SELECT verification query. + +Before final response: +- Run the Python tests. +- Report exact commands and results. diff --git a/migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_20260518_1507.md b/migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_20260518_1507.md new file mode 100644 index 0000000..1392893 --- /dev/null +++ b/migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_20260518_1507.md @@ -0,0 +1,115 @@ +# Gate 2C: BLEPeerSessionManager pybind11 Tests + +Date: 2026-05-18 15:07 America/Los_Angeles + +Scope: pybind11 exposure and Python unit tests only. + +## Summary + +Gate 2C exposed the native C++ `BLEPeerSessionManager` skeleton through the existing `ble_protocol_core_cpp` pybind11 module and added Python tests for the binding surface. No live BLE behavior was changed. + +Modified: + +- `migration/protocol_core/ble_protocol_core.cpp` +- `migration/protocol_core/setup.py` + +Added: + +- `migration/tests/test_ble_peer_session_manager_pybind.py` +- `migration/phase2/Gate2C_BLEPeerSessionManager_pybind_tests_20260518_1507.md` +- `migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.sql` + +Not changed: + +- `src/ble_reticulum/BLEInterface.py` +- live BLE backend selection behavior +- BlueZ/Bleak/DBus integration +- `RNS.Transport` integration +- ESP32 BLE integration + +## Binding Surface + +Bound classes and structs: + +- `BLEPeerSessionManager` +- `ConnectionId` +- `ConnectionSnapshot` +- `HandshakeResult` +- `SessionAction` +- `PeerSessionView` + +Bound enums: + +- `LocalRole` +- `InputDecision` +- `SessionActionType` + +Bound static helpers: + +- `is_identity_handshake_payload` +- `identity_from_payload` +- `compute_identity_key` +- `compute_fragmenter_key` + +The binding accepts Python `bytes` for identities and payloads, while preserving the C++ manager as the native source of session decisions. + +## Tests Added + +`migration/tests/test_ble_peer_session_manager_pybind.py` covers: + +- import module succeeds; +- non-16-byte payload returns `PassToReassembler` and `consumed=false`; +- new 16-byte identity returns `AcceptedNewIdentity` and `consumed=true`; +- identity key equals first 8 bytes as 16 lowercase hex chars; +- fragmenter key equals full 16-byte identity as 32 lowercase hex chars; +- known identity duplicate same returns `ConsumedDuplicateSameIdentity`; +- known identity duplicate mismatch returns `ConsumedDuplicateMismatchedIdentity`; +- MTU provided is preserved; +- MTU missing falls back to `23`; +- duplicate identity active elsewhere requests `DisconnectCurrentPeer`; +- pending identity timeout can be marked and expired; +- peer address update preserves identity and fragmenter key; +- invalid `identity_from_payload` input raises. + +## Verification + +Build command: + +```sh +cd migration/protocol_core +python3 setup.py build_ext --inplace +``` + +New test command: + +```sh +PYTHONPATH=migration/protocol_core pytest -q migration/tests/test_ble_peer_session_manager_pybind.py +``` + +Result: + +```text +12 passed, 2 warnings in 0.12s +``` + +Full requested test command: + +```sh +pytest -q migration/tests/test_fragmentation_cpp_equivalence.py migration/tests/test_fragmentation_backend_shim.py migration/tests/test_identity_helpers_cpp_equivalence.py migration/tests/test_ble_peer_session_manager_pybind.py +``` + +Result: + +```text +68 passed, 1 skipped, 2 warnings in 1.00s +``` + +Note: running the full set with an outer `PYTHONPATH=src:migration/protocol_core` makes `test_python_backend_still_works_when_cpp_backend_is_unavailable` fail because that test intentionally controls `PYTHONPATH` inside subprocesses to simulate C++ backend absence. The passing command above leaves that isolation intact. + +## SQL + +Companion SQL: + +`migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.sql` + +The SQL marks `_handle_identity_handshake` as `PYTHON_BOUND` in phase `2_ble_protocol_session_manager`, keeps tag `GLUE`, keeps `cpp_candidate=1`, and does not mark any Gate 2F or field-accepted status. diff --git a/migration/protocol_core/ble_protocol_core.cpp b/migration/protocol_core/ble_protocol_core.cpp index a92b4a7..eeef75a 100644 --- a/migration/protocol_core/ble_protocol_core.cpp +++ b/migration/protocol_core/ble_protocol_core.cpp @@ -1,6 +1,8 @@ #include #include +#include "BLEPeerSessionManager.h" + #include #include #include @@ -44,6 +46,21 @@ std::string get_fragmenter_key(py::object peer_identity_obj, return py::str(peer_identity_obj.attr("hex")()).cast(); } +ble_reticulum::PeerIdentity peer_identity_from_py_bytes(const py::bytes &value) { + const std::string data = py_bytes_to_string(value); + return ble_reticulum::BLEPeerSessionManager::identityFromPayload( + reinterpret_cast(data.data()), data.size()); +} + +py::bytes peer_identity_to_py_bytes(const ble_reticulum::PeerIdentity &identity) { + return py::bytes(reinterpret_cast(identity.data()), identity.size()); +} + +std::vector py_bytes_to_uint8_vector(const py::bytes &value) { + const std::string data = py_bytes_to_string(value); + return std::vector(data.begin(), data.end()); +} + struct Buffer { std::map fragments; uint16_t total = 0; @@ -504,4 +521,198 @@ PYBIND11_MODULE(ble_protocol_core_cpp, m) { .def_readonly_static("FLAG", &HDLCFramerCpp::FLAG_VALUE) .def_readonly_static("ESCAPE", &HDLCFramerCpp::ESCAPE_VALUE) .def_readonly_static("ESCAPE_XOR", &HDLCFramerCpp::ESCAPE_XOR_VALUE); + + py::enum_(m, "LocalRole") + .value("Unknown", ble_reticulum::LocalRole::Unknown) + .value("Central", ble_reticulum::LocalRole::Central) + .value("Peripheral", ble_reticulum::LocalRole::Peripheral); + + py::enum_(m, "InputDecision") + .value("PassToReassembler", ble_reticulum::InputDecision::PassToReassembler) + .value("ConsumedDuplicateSameIdentity", + ble_reticulum::InputDecision::ConsumedDuplicateSameIdentity) + .value("ConsumedDuplicateMismatchedIdentity", + ble_reticulum::InputDecision::ConsumedDuplicateMismatchedIdentity) + .value("AcceptedNewIdentity", ble_reticulum::InputDecision::AcceptedNewIdentity) + .value("RejectedDuplicateIdentity", + ble_reticulum::InputDecision::RejectedDuplicateIdentity) + .value("ErrorConsumed", ble_reticulum::InputDecision::ErrorConsumed); + + py::enum_(m, "SessionActionType") + .value("ConsumeInput", ble_reticulum::SessionActionType::ConsumeInput) + .value("PassToReassembler", ble_reticulum::SessionActionType::PassToReassembler) + .value("AcceptNewIdentity", ble_reticulum::SessionActionType::AcceptNewIdentity) + .value("RejectDuplicateIdentity", + ble_reticulum::SessionActionType::RejectDuplicateIdentity) + .value("DisconnectCurrentPeer", + ble_reticulum::SessionActionType::DisconnectCurrentPeer) + .value("DisconnectOldPeer", ble_reticulum::SessionActionType::DisconnectOldPeer) + .value("CreateFragmentationState", + ble_reticulum::SessionActionType::CreateFragmentationState) + .value("MarkPeerReady", ble_reticulum::SessionActionType::MarkPeerReady) + .value("UpdatePeerAddress", ble_reticulum::SessionActionType::UpdatePeerAddress) + .value("RemovePendingIdentity", + ble_reticulum::SessionActionType::RemovePendingIdentity) + .value("MarkRealData", ble_reticulum::SessionActionType::MarkRealData) + .value("CleanupOldAddress", ble_reticulum::SessionActionType::CleanupOldAddress) + .value("Warn", ble_reticulum::SessionActionType::Warn); + + py::class_(m, "ConnectionId") + .def(py::init<>()) + .def(py::init([](const std::string &address, uint16_t handle) { + ble_reticulum::ConnectionId id; + id.address = address; + id.handle = handle; + return id; + }), + py::arg("address"), py::arg("handle") = 0xffff) + .def_readwrite("address", &ble_reticulum::ConnectionId::address) + .def_readwrite("handle", &ble_reticulum::ConnectionId::handle); + + py::class_(m, "ConnectionSnapshot") + .def(py::init<>()) + .def_readwrite("current", &ble_reticulum::ConnectionSnapshot::current) + .def_readwrite("local_role", &ble_reticulum::ConnectionSnapshot::local_role) + .def_readwrite("negotiated_mtu", + &ble_reticulum::ConnectionSnapshot::negotiated_mtu) + .def_readwrite("existing_address_for_identity", + &ble_reticulum::ConnectionSnapshot::existing_address_for_identity) + .def_readwrite("identity_has_pending_detach", + &ble_reticulum::ConnectionSnapshot::identity_has_pending_detach) + .def_readwrite("existing_address_connected", + &ble_reticulum::ConnectionSnapshot::existing_address_connected) + .def_readwrite("existing_address_in_peer_table", + &ble_reticulum::ConnectionSnapshot::existing_address_in_peer_table) + .def_readwrite("existing_connection_is_zombie", + &ble_reticulum::ConnectionSnapshot::existing_connection_is_zombie) + .def_readwrite("existing_last_real_data", + &ble_reticulum::ConnectionSnapshot::existing_last_real_data) + .def("set_known_identity", + [](ble_reticulum::ConnectionSnapshot &snapshot, const py::bytes &identity) { + snapshot.known_identity_for_address = peer_identity_from_py_bytes(identity); + }, + py::arg("identity")) + .def("clear_known_identity", + [](ble_reticulum::ConnectionSnapshot &snapshot) { + snapshot.known_identity_for_address.reset(); + }) + .def_property_readonly("known_identity", + [](const ble_reticulum::ConnectionSnapshot &snapshot) -> py::object { + if (!snapshot.known_identity_for_address.has_value()) { + return py::none(); + } + return peer_identity_to_py_bytes( + snapshot.known_identity_for_address.value()); + }); + + py::class_(m, "SessionAction") + .def(py::init<>()) + .def_readwrite("type", &ble_reticulum::SessionAction::type) + .def_readwrite("target", &ble_reticulum::SessionAction::target) + .def_readwrite("old_address", &ble_reticulum::SessionAction::old_address) + .def_readwrite("new_address", &ble_reticulum::SessionAction::new_address) + .def_readwrite("message", &ble_reticulum::SessionAction::message); + + py::class_(m, "HandshakeResult") + .def(py::init<>()) + .def_readwrite("decision", &ble_reticulum::HandshakeResult::decision) + .def_readwrite("actions", &ble_reticulum::HandshakeResult::actions) + .def_readwrite("consumed", &ble_reticulum::HandshakeResult::consumed) + .def_readwrite("accepted", &ble_reticulum::HandshakeResult::accepted) + .def_readwrite("should_disconnect_current", + &ble_reticulum::HandshakeResult::should_disconnect_current) + .def_readwrite("should_disconnect_old", + &ble_reticulum::HandshakeResult::should_disconnect_old) + .def_readwrite("identity_key", &ble_reticulum::HandshakeResult::identity_key) + .def_readwrite("fragmenter_key", &ble_reticulum::HandshakeResult::fragmenter_key) + .def_readwrite("mtu", &ble_reticulum::HandshakeResult::mtu) + .def_property_readonly("peer_identity", + [](const ble_reticulum::HandshakeResult &result) -> py::object { + if (!result.peer_identity.has_value()) { + return py::none(); + } + return peer_identity_to_py_bytes(result.peer_identity.value()); + }); + + py::class_(m, "PeerSessionView") + .def(py::init<>()) + .def_readwrite("identity_key", &ble_reticulum::PeerSessionView::identity_key) + .def_readwrite("current_address", &ble_reticulum::PeerSessionView::current_address) + .def_readwrite("current_handle", &ble_reticulum::PeerSessionView::current_handle) + .def_readwrite("mtu", &ble_reticulum::PeerSessionView::mtu) + .def_readwrite("has_fragmentation_state", + &ble_reticulum::PeerSessionView::has_fragmentation_state) + .def_readwrite("peer_ready", &ble_reticulum::PeerSessionView::peer_ready) + .def_readwrite("pending_identity_since", + &ble_reticulum::PeerSessionView::pending_identity_since) + .def_readwrite("last_real_data", &ble_reticulum::PeerSessionView::last_real_data) + .def_property_readonly("identity", + [](const ble_reticulum::PeerSessionView &view) { + return peer_identity_to_py_bytes(view.identity); + }); + + py::class_(m, "BLEPeerSessionManager") + .def(py::init(), py::arg("pending_identity_timeout") = 30.0, + py::arg("zombie_timeout") = 45.0) + .def("handle_identity_handshake", + [](ble_reticulum::BLEPeerSessionManager &manager, + const ble_reticulum::ConnectionSnapshot &connection, + const py::bytes &data, + double now_seconds) { + const std::vector bytes = py_bytes_to_uint8_vector(data); + return manager.handleIdentityHandshake( + connection, bytes.data(), bytes.size(), now_seconds); + }, + py::arg("connection"), py::arg("data"), py::arg("now_seconds")) + .def("mark_connected", &ble_reticulum::BLEPeerSessionManager::markConnected, + py::arg("connection"), py::arg("now_seconds")) + .def("mark_disconnected", &ble_reticulum::BLEPeerSessionManager::markDisconnected, + py::arg("connection"), py::arg("now_seconds")) + .def("mark_mtu", &ble_reticulum::BLEPeerSessionManager::markMtu, + py::arg("connection"), py::arg("mtu")) + .def("mark_pending_identity", + &ble_reticulum::BLEPeerSessionManager::markPendingIdentity, + py::arg("connection"), py::arg("now_seconds")) + .def("remove_pending_identity", + &ble_reticulum::BLEPeerSessionManager::removePendingIdentity, + py::arg("connection")) + .def("expired_pending_identities", + &ble_reticulum::BLEPeerSessionManager::expiredPendingIdentities, + py::arg("now_seconds")) + .def("session_by_address", + &ble_reticulum::BLEPeerSessionManager::sessionByAddress, + py::arg("address")) + .def("session_by_identity", + [](const ble_reticulum::BLEPeerSessionManager &manager, + const py::bytes &identity) { + return manager.sessionByIdentity(peer_identity_from_py_bytes(identity)); + }, + py::arg("identity")) + .def_static("is_identity_handshake_payload", + [](const py::bytes &data) { + const std::vector bytes = py_bytes_to_uint8_vector(data); + return ble_reticulum::BLEPeerSessionManager:: + isIdentityHandshakePayload(bytes.data(), bytes.size()); + }, + py::arg("data")) + .def_static("identity_from_payload", + [](const py::bytes &data) { + const std::vector bytes = py_bytes_to_uint8_vector(data); + return peer_identity_to_py_bytes( + ble_reticulum::BLEPeerSessionManager::identityFromPayload( + bytes.data(), bytes.size())); + }, + py::arg("data")) + .def_static("compute_identity_key", + [](const py::bytes &identity) { + return ble_reticulum::BLEPeerSessionManager::computeIdentityKey( + peer_identity_from_py_bytes(identity)); + }, + py::arg("identity")) + .def_static("compute_fragmenter_key", + [](const py::bytes &identity) { + return ble_reticulum::BLEPeerSessionManager::computeFragmenterKey( + peer_identity_from_py_bytes(identity)); + }, + py::arg("identity")); } diff --git a/migration/protocol_core/setup.py b/migration/protocol_core/setup.py index 84e83c0..1fba17b 100644 --- a/migration/protocol_core/setup.py +++ b/migration/protocol_core/setup.py @@ -13,7 +13,10 @@ setup( ext_modules=[ Pybind11Extension( "ble_protocol_core_cpp", - [str(ROOT / "ble_protocol_core.cpp")], + [ + str(ROOT / "ble_protocol_core.cpp"), + str(ROOT / "BLEPeerSessionManager.cpp"), + ], cxx_std=17, ) ], diff --git a/migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.sql b/migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.sql new file mode 100644 index 0000000..d96f5d3 --- /dev/null +++ b/migration/sql/mark_gate2c_protocol_session_pybind_tests_20260518_1507.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', + 'PYTHON_BOUND', + 1, + 'high', + 'Gate 2C exposed the native C++ BLEPeerSessionManager through pybind11 and added Python unit tests. This remains reference behavior for C++ session ownership, not a literal BLEInterface method port.', + '_data_received_callback', + '_compute_identity_hash; _check_duplicate_identity; driver.disconnect; driver.get_peer_mtu; _get_fragmenter_key; BLEFragmenter; BLEReassembler; _spawn_peer_interface', + 'Gate 2C pybind11 tests passed. No BLEInterface.py changes, live BLE behavior changes, field tests, or Phase 1 FIELD_ACCEPTED row changes were made.', + 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 = 'PYTHON_BOUND', + 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', + 'NATIVE_TESTED', + 'PYTHON_BOUND', + 'Gate 2C completed pybind11 exposure for BLEPeerSessionManager and Python unit tests. Full requested pytest set passed: 68 passed, 1 skipped. No live BLE behavior or Phase 1 FIELD_ACCEPTED records changed.' +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_pybind.py b/migration/tests/test_ble_peer_session_manager_pybind.py new file mode 100644 index 0000000..c505bee --- /dev/null +++ b/migration/tests/test_ble_peer_session_manager_pybind.py @@ -0,0 +1,180 @@ +import os +import sys + +import pytest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CPP_BUILD_DIR = os.path.join(REPO_ROOT, "migration", "protocol_core") + +sys.path.insert(0, CPP_BUILD_DIR) + +cpp = pytest.importorskip( + "ble_protocol_core_cpp", + reason=( + "compiled pybind11 module missing; build with " + "`python3 migration/protocol_core/setup.py build_ext --inplace`" + ), +) + + +def identity(base): + return bytes((base + i) & 0xFF for i in range(16)) + + +def snapshot(address, *, mtu=None): + snap = cpp.ConnectionSnapshot() + snap.current = cpp.ConnectionId(address) + snap.local_role = cpp.LocalRole.Peripheral + snap.negotiated_mtu = mtu + return snap + + +def action_types(result): + return {action.type for action in result.actions} + + +def test_import_module_succeeds(): + assert cpp.BLEPeerSessionManager is not None + assert cpp.ConnectionSnapshot is not None + + +def test_non_16_byte_payload_passes_to_reassembler(): + manager = cpp.BLEPeerSessionManager() + result = manager.handle_identity_handshake(snapshot("AA:00"), b"abc", 1.0) + + assert result.decision == cpp.InputDecision.PassToReassembler + assert result.consumed is False + + +def test_new_16_byte_identity_sets_keys_and_consumes(): + manager = cpp.BLEPeerSessionManager() + peer_identity = identity(0x10) + + result = manager.handle_identity_handshake( + snapshot("AA:01"), peer_identity, 2.0 + ) + + assert result.decision == cpp.InputDecision.AcceptedNewIdentity + assert result.consumed is True + assert result.accepted is True + assert result.identity_key == "1011121314151617" + assert result.fragmenter_key == "101112131415161718191a1b1c1d1e1f" + assert result.peer_identity == peer_identity + + +def test_static_key_helpers(): + peer_identity = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + + assert cpp.BLEPeerSessionManager.compute_identity_key(peer_identity) == "0102030405060708" + assert ( + cpp.BLEPeerSessionManager.compute_fragmenter_key(peer_identity) + == "0102030405060708090a0b0c0d0e0f10" + ) + assert cpp.BLEPeerSessionManager.identity_from_payload(peer_identity) == peer_identity + + +def test_identity_from_payload_rejects_non_16_byte_input(): + with pytest.raises(Exception): + cpp.BLEPeerSessionManager.identity_from_payload(b"abc") + + +def test_known_identity_duplicate_same(): + manager = cpp.BLEPeerSessionManager() + peer_identity = identity(0x20) + snap = snapshot("AA:02") + snap.set_known_identity(peer_identity) + + result = manager.handle_identity_handshake(snap, peer_identity, 3.0) + + assert result.decision == cpp.InputDecision.ConsumedDuplicateSameIdentity + assert result.consumed is True + + +def test_known_identity_duplicate_mismatch(): + manager = cpp.BLEPeerSessionManager() + snap = snapshot("AA:03") + snap.set_known_identity(identity(0x30)) + + result = manager.handle_identity_handshake(snap, identity(0x40), 4.0) + + assert result.decision == cpp.InputDecision.ConsumedDuplicateMismatchedIdentity + assert result.consumed is True + assert cpp.SessionActionType.Warn in action_types(result) + + +def test_mtu_provided_is_preserved(): + manager = cpp.BLEPeerSessionManager() + peer_identity = identity(0x50) + + result = manager.handle_identity_handshake( + snapshot("AA:04", mtu=185), peer_identity, 5.0 + ) + + assert result.mtu == 185 + view = manager.session_by_identity(peer_identity) + assert view is not None + assert view.mtu == 185 + + +def test_mtu_missing_falls_back_to_23(): + manager = cpp.BLEPeerSessionManager() + + result = manager.handle_identity_handshake( + snapshot("AA:05"), identity(0x60), 6.0 + ) + + assert result.mtu == 23 + + +def test_duplicate_identity_active_elsewhere_requests_disconnect_current(): + manager = cpp.BLEPeerSessionManager() + peer_identity = identity(0x70) + manager.handle_identity_handshake(snapshot("AA:06"), peer_identity, 7.0) + + duplicate = snapshot("AA:07") + duplicate.existing_address_for_identity = "AA:06" + duplicate.existing_address_connected = True + duplicate.existing_address_in_peer_table = True + + result = manager.handle_identity_handshake(duplicate, peer_identity, 8.0) + + assert result.decision == cpp.InputDecision.RejectedDuplicateIdentity + assert result.should_disconnect_current is True + assert cpp.SessionActionType.DisconnectCurrentPeer in action_types(result) + + +def test_pending_identity_timeout_can_be_marked_and_expired(): + manager = cpp.BLEPeerSessionManager(30.0, 45.0) + first = cpp.ConnectionId("AA:08", 8) + second = cpp.ConnectionId("AA:09", 9) + + manager.mark_pending_identity(first, 100.0) + manager.mark_pending_identity(second, 120.0) + + expired = manager.expired_pending_identities(131.0) + assert len(expired) == 1 + assert expired[0].address == "AA:08" + assert expired[0].handle == 8 + + +def test_peer_address_update_preserves_identity_and_fragmenter_key(): + manager = cpp.BLEPeerSessionManager() + peer_identity = identity(0x80) + first_result = manager.handle_identity_handshake( + snapshot("AA:0A"), peer_identity, 9.0 + ) + + rotated = snapshot("AA:0B") + rotated.existing_address_for_identity = "AA:0A" + rotated.identity_has_pending_detach = True + + second_result = manager.handle_identity_handshake(rotated, peer_identity, 10.0) + + assert second_result.decision == cpp.InputDecision.AcceptedNewIdentity + assert first_result.fragmenter_key == second_result.fragmenter_key + assert manager.session_by_address("AA:0A") is None + view = manager.session_by_address("AA:0B") + assert view is not None + assert view.identity == peer_identity + assert view.identity_key == first_result.identity_key