feat: add bumble BLE transport with pairing#107
Conversation
|
There was a problem hiding this comment.
Pull request overview
Adds a new optional BLE transport implementation backed by Google’s bumble stack (for external HCI controllers) along with supporting keystore, pairing, scanning helpers, and a minimal smpbumble CLI entrypoint.
Changes:
- Add
smpclient[bumble]optional dependency set and register thesmpbumbleCLI script. - Implement
SMPBumbleTransportwith a small connection state machine, proactive bonded-encryption, plus one-shotpair_device()helper. - Add bumble-specific helpers for scanning, pairing delegates/result types, and keystore strategy resolution.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
pyproject.toml |
Adds bumble optional extra + smpbumble script; tightens dependency version ranges. |
src/smpclient/transport/bumble/__init__.py |
Implements SMPBumbleTransport, bumble device context manager, and pair_device() helper. |
src/smpclient/transport/bumble/__main__.py |
Adds smpbumble CLI commands: scan, pair, echo. |
src/smpclient/transport/bumble/keystore.py |
Introduces keystore strategy sum type and resolver to bumble KeyStore. |
src/smpclient/transport/bumble/pairing.py |
Adds pairing delegates and a PairingResult sum type for outcomes. |
src/smpclient/transport/bumble/scan.py |
Adds async scan helper with name matching and SMP-service marker detection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
JPHutchins
left a comment
There was a problem hiding this comment.
Seems a lot of repetition, unnecessary variable aliasing, not enough use of Final.
|
PR description is out of date. |
Adds an optional `bumble` extra (`smpclient[bumble]`) providing SMPBumbleTransport — a `SMPTransport` backed by Google's bumble Bluetooth stack driving an external HCI USB controller. Useful when the OS BLE stack is unavailable or for reproducible cross-platform behavior via the same HCI dongle. - transport/bumble/__init__.py — SMPBumbleTransport with a Disconnected | Connecting | Connected sum-type state machine, a bumble_device() async context manager, pair_device() one-shot bonding, and a pair() method on the transport itself - transport/bumble/keystore.py — KeystoreStrategy sum type (Tempfile | Local | Custom | InMemory) - transport/bumble/pairing.py — NoInputNoOutput / KeyboardOnly / DisplayOnly delegates and a PairingResult sum type - transport/bumble/scan.py — async BLE scan with name filter, eager mode, and SMP-service marker - transport/bumble/__main__.py — minimal CLI registered as smpbumble (scan, pair, echo) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…T_FOUND, ExistingCustom - assert_never imports moved to typing_extensions (Python 3.10 compat; caught by Copilot) - SMP_SERVICE_UUID + SMP_CHARACTERISTIC_UUID centralized in smpclient.transport; re-exported through ble.py and bumble/__init__.py - pair_on_connect: PairingDelegate | None added to SMPBumbleTransport constructor; connect() pairs after LE-connect and before GATT discovery when no bond exists - module-level pair() function factored out and shared by SMPBumbleTransport.pair(), pair_device(), and the pair_on_connect path - SMPBumbleTransport.scan() static method using bumble_device() ctx mgr - typed NamedTuple args (_ScanArgs / _PairArgs / _EchoArgs) for CLI dispatch instead of bare argparse.Namespace - PairingFailureReason.NOT_FOUND for scan-by-name misses (was USER_REJECTED, which was misleading) - ExistingCustom(path) keystore variant alongside Custom(path); Tempfile and Local filenames validated as bare names (reject path separators) - _prompt_pin enforces exactly 6 digits - mtu raises in non-Connected state instead of returning 0 - _echo uses assert_never on the impossible-after-success/error branch - ATT_WRITE_OVERHEAD named constant replaces the bare `- 3` - _encrypt_using_bond consolidates _encrypt_if_bonded + the inline on-security-request logic - scan.py: _scanning context manager extracts the 3-level nested try/finally - walrus operators, removed self._state aliasing, trimmed multi-line "essay" comments to one-liners - pyproject: norecursedirs += ".claude" and ruff extend-exclude += ".claude" so other agents' worktrees don't break lint/test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- ConnectedBorrowed sum-type state for caller-owned LE links - ConnectedProtocol structural type shared by Connected/ConnectedBorrowed — send/receive/mtu work on either without procedural sentinel checks - use_connection(connection, *, peer=None) lets SMP share a bumble link with non-SMP traffic; disconnect() only unsubscribes (caller owns the rest of the lifecycle) - transport-extras CI matrix gets a bumble-only row and the "all" row now expects bumble to import as well - tests/test_smp_bumble_transport.py: 39 tests covering state machine, send/receive notify queue, pair delegates + result sum type, keystore strategies (Tempfile/Local/Custom/ExistingCustom/InMemory + filename validation), GATT discovery helpers, module-level pair(), pair_device() end-to-end with mocked bumble stack, bumble_device() context manager, scan() helper, and CLI dispatch (prompt_pin / scan / pair / echo handlers + argparse routing). Coverage now 91% (was 80%). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… transitivity - macOS resolves /tmp via /var → /private/var symlink; tests now compare Path.resolve() so the keystore filename assertions match regardless. - The bumble extra depends on pyserial-asyncio, which transitively installs pyserial — so `from smpclient.transport.serial import SMPSerialTransport` succeeds in a bumble-only install too. Updating expect-serial=pass for the bumble-only matrix row to reflect the actual install surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot: - ScanMode sum type (ScanAll | ScanForName) replaces eager:bool / name:str|None on scan() — invalid states (eager without name) are unrepresentable at the type level. ScanForName has an opt-out eager:bool = True flag so callers can enumerate multiple peers sharing a name. - _check_bare_filename uses PurePath; rejects absolute paths, separators, "./..", and Windows drive prefixes like "C:foo". - ExistingCustom keystore strategy validates path.is_file() so a directory or symlink-to-non-file errors clearly at resolve time. - Keystore namespace is now consistently derived from host_address in both connect() and bumble_device(); _standalone_keystore agrees, so bonded_devices() / clear_bond() see the same bonds connect() stored. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9a7073d to
a7698af
Compare
…irmware `smpclient[hci_firmware]` pulls in `zephyr-4.4.0-hci`, the umbrella that depends on every published `hci_usb` build (3× nrf52840dk + 2× nrf5340dk). `smpclient.transport.bumble.firmware` re-exports the umbrella's typed `firmware: Firmware` NamedTuple so callers can do `firmware.nrf52840dk_default.HEX_PATH` with full IDE autocomplete, or raise a friendly error pointing at the extra when it isn't installed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Move firmware re-export out from under `transport/bumble/` to `smpclient.transport.firmware.hci` — firmware is not bumble-specific and the prior path forced `[hci_firmware]`-only installs to import the bumble package (which fails without the `[bumble]` extra). * `transport.firmware.hci`: narrow `except ModuleNotFoundError` so transitive import errors aren't masked. Drop pip-specific install hint. * `_teardown_borrowed`: actually remove the `EVENT_DISCONNECTION` listener registered by `use_connection()` so the caller's Connection isn't left holding references to the transport after teardown. * Docstring fixes: module docstring + `use_connection()` mention `ConnectedBorrowed`; `scan()` docstring correctly reports `ScanAll()` as the default mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JPHutchins
left a comment
There was a problem hiding this comment.
Review round focused on cleaning up the new transport's __init__.py
* Move generic helpers out of `transport/bumble/__init__.py`:
- `bumble_device()` + host/HCI constants → new `bumble/device.py`
- `pair()`, `pair_device()`, `encrypt_using_bond()` + pairing constants →
`bumble/pairing.py` (was just delegates + result types)
* `SMPBumbleTransport`: split `clear_bond(address)` and `clear_bonds()`
(no `None`-coded meaning), inline `bonds` local in `bonded_devices`,
drop the impl-detail line from its docstring.
* `_notifications` / `_disconnected_event`: `Final` annotations.
* `_next_chunk`: walrus-style `match (chunk := await ...)`.
* `_on_security_request`: pattern-match on state and log at the right level
(info for borrowed, warn for Disconnected/Connecting, debug for Connected).
* Add `borrowed_connection()` async context manager wrapping
`use_connection()` + `disconnect()`.
* Trim narrative comments where the code is the explanation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JPHutchins
left a comment
There was a problem hiding this comment.
More cleanup.
* Trim docstrings across `bumble/__init__.py`, `bumble/pairing.py`, and `firmware/hci.py` — drop impl-detail and SSOT-violating narrative. State NamedTuples lose redundant docstrings; module overview is one line. * Type `_on_security_request(auth_req: AuthReq)` instead of `object`; bumble emits `bumble.smp.AuthReq` on `EVENT_SECURITY_REQUEST`. * `logger: Final = logging.getLogger(__name__)` in both bumble modules. * `use_connection()`: walrus on the optional-peer assignment. * Move `_resolve_target` and `_negotiate_mtu` to module-level helpers (they didn't use `self`). `_negotiate_mtu` now takes `preferred_mtu`. * `_teardown_borrowed` is back on `SMPBumbleTransport` and reads from `self._state` / `self._on_disconnection` directly — no callback to pass. * `scan._extract_name`: `continue` on `UnicodeDecodeError` so a bad COMPLETE_LOCAL_NAME doesn't shadow a valid SHORTENED_LOCAL_NAME. * `firmware.hci`: raise `ImportError` (matches `ble.py` / `serial.py`) instead of `ModuleNotFoundError` for the missing-extra path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…request Reported by smpmgr integration testing against a BACtrack Z9 Pro (Zephyr SMP with CONFIG_BT_SMP_ENFORCE_MITM): the peer issues an SMP_Security_Request the instant the LE link is up, before connect()'s explicit pair() call. Two windows combined to drop the PIN prompt: * `device.pairing_config_factory` was set *inside* `pair()`, after `device.connect()` — so a peer-initiated SMP exchange consulted the default delegate (JustWorks) instead of the caller's KeyboardOnly. * `_on_security_request` for the `Connecting` state just logged a warning and dropped the request — leaving the peer waiting and the central hanging at "Connecting…". Fix: * Install `pairing_config_factory` before `device.connect()` whenever `pair_on_connect` is set, so the delegate is in place regardless of who initiates the SMP exchange. * `_on_security_request` now handles the `Connecting` case by driving the pair flow via a shared idempotent helper `_pair_during_connect()`. * `_pair_during_connect()` uses an `asyncio.Lock` + cached `PairingResult` so concurrent peer-driven and central-driven paths only run `pair()` once; the second caller returns the cached outcome. Tests: * `test_pair_on_connect_installs_delegate_before_device_connect`: snapshots `device.pairing_config_factory` from inside the mocked `device.connect()` and asserts it's already populated. * `test_peer_initiated_security_during_connecting_drives_pair`: schedules the security-request event right after `device.connect()` resolves and asserts `connection.pair()` runs and the transport reaches `Connected`. * `test_pair_during_connect_is_idempotent_across_callers`: two concurrent callers see the same `PairingResult`; `connection.pair()` runs once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported during smpmgr integration: every successful run ends with a `reason=0x16` disconnect (LOCALHOST_TERM_CONN — we hung up cleanly), so WARNING-level logging there made well-behaved teardowns look like errors. Use `bumble.hci.HCI_ErrorCode` (a SpecableEnum that compares equal to its int value) to pattern-match on the reason and choose level + render the enum name in the message: * `CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR` → DEBUG * `REMOTE_USER_TERMINATED_CONNECTION_ERROR` → INFO * `REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR` → INFO * `REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_POWER_OFF_ERROR` → INFO * anything else (incl. timeout / failed-to-establish / unmapped) → WARNING If the reason isn't a known `HCI_ErrorCode`, the name suffix is omitted and the log stays at WARNING. Parameterized test covers the four graceful reasons + two real-failure codes + one unmapped int, verifying both level and message contents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d_mtu Reported from a downstream app using a borrowed connection: the host had already negotiated ATT MTU 498 before lending the connection, but `_negotiate_mtu` still issued `peer.request_mtu(247)` and logged `INFO Requested MTU 247, negotiated 498` — making it look like the peer overrode our preferred value. ATT MTU only ratchets up. If `connection.att_mtu >= preferred_mtu`, skip the round-trip entirely and log at DEBUG instead. 3 new unit tests cover: the skip path, the still-need-to-request path, and the fallback path when `request_mtu()` raises. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
This has now been integrated and tested end-to-end in 2 downstream applications, including DFU testing (smpmgr and other). |
Summary
Adds an optional
bumbleextra (smpclient[bumble]) providingSMPBumbleTransport— a BLESMPTransportbacked by Google's bumble stack driving an external HCI USB controller (e.g. an nRF52840 DK running the Zephyrhci_usbsample). Alternative to the bleak-backedSMPBLETransportfor when the OS BLE stack is unavailable or reproducible cross-platform behavior is desired via the same HCI dongle everywhere.Validated end-to-end against a real device: scan → pair (PIN flow) → reconnect with proactive
connection.encrypt()from stored LTK → SMP Echo round-trip.What's in the PR
Transport core (
src/smpclient/transport/bumble/__init__.py)SMPBumbleTransportwith aDisconnected | Connecting | Connected | ConnectedBorrowedsum-type state machine; per-steptry/exceptin_teardown()is load-bearing (skipping a bumble cleanup step can hang process exit).ConnectedProtocol(Protocol)— structural type shared byConnected(owned) andConnectedBorrowed(caller-owned LE link).send/receive/mtuoperate on it via a single_require_connected()guard; no procedural None-sentinel checks.pair_on_connect: PairingDelegate | Noneconstructor arg —connect()pairs after LE-connect and before GATT discovery when no bond exists; when a bond is present, proactive_encrypt_using_bondruns instead.use_connection(connection, *, peer=None)— borrow an existing bumbleConnectionfor SMP traffic without owning its lifecycle. Caller manages connect/disconnect/encryption.bumble_device()async context manager owns HCI transport + device lifecycle.pair_device()one-shot bonding (connect → pair → disconnect, no GATT).pair()function shared bypair_device(),SMPBumbleTransport.pair(), and thepair_on_connectpath.SMPBumbleTransport.scan()static method usingbumble_device().Keystore (
src/smpclient/transport/bumble/keystore.py)KeystoreStrategy = Tempfile | Local | Custom | ExistingCustom | InMemorysum type.Custom(path)auto-creates parent dirs;ExistingCustom(path)errors if the file is missing.Tempfile/Localfilenames validated as bare names (reject path separators).Pairing (
src/smpclient/transport/bumble/pairing.py)NoInputNoOutput/KeyboardOnly(pin_cb)/DisplayOnly(display_cb)delegates.PairingResult = PairingSucceeded | PairingAlreadyBonded | PairingTimedOut | PairingFailed.PairingFailureReasonenum incl.NOT_FOUNDfor scan misses.Scan (
src/smpclient/transport/bumble/scan.py)_scanning()async context manager.CLI (
src/smpclient/transport/bumble/__main__.py)smpbumbleentry point:scan,pair [--force],echo. Alsopython -m smpclient.transport.bumble._ScanArgs/_PairArgs/_EchoArgsNamedTuples for dispatch (no bareargparse.Namespacehand-off).Tests (
tests/test_smp_bumble_transport.py)PairingResultsum type, keystore strategies (Tempfile/Local/Custom/ExistingCustom/InMemory + filename validation), GATT discovery helpers, module-levelpair(),pair_device()end-to-end with mocked bumble stack,bumble_device()context manager, scan helper, and CLI dispatch (_prompt_pin/_scan/_pair/_echohandlers + argparse routing). Bumble module at 91%+ coverage.CI (
.github/workflows/test.yaml)bumble-onlyrow intransport-extrasmatrix. Each row asserts the bumble import passes/fails as expected for that extras combination.Cross-cutting
SMP_SERVICE_UUIDandSMP_CHARACTERISTIC_UUIDcentralized insmpclient.transport; re-exported byble.pyandbumble/__init__.py.assert_neverimported fromtyping_extensionseverywhere (Python 3.10 compat).ATT_WRITE_OVERHEADnamed constant replaces magic- 3in MTU math.Test plan
uv run task all— lint, typecheck, full test suite (254 passed, 14 skipped)uv run task matrix— same across Python 3.10–3.14uv run task coverage— total coverage ≥ 91% gateuv run smpbumble scan— lists all advertising devices, marks SMP onesuv run smpbumble pair <addr> [--force]— pairs a device, optionally wiping local bond firstuv run smpbumble echo <addr> <msg>— connects, auto-encrypts from stored LTK, SMP Echo round-tripFollow-up (not blocking this PR)
intercreate/zephyr-hciselectable via per-board smpclient extras +all_firmwareaggregate (separate PR coordinating with the build farm side).FYI @raykamp @raykamp-tht
🤖 Generated with Claude Code