Skip to content

feat: add bumble BLE transport with pairing#107

Open
JPHutchins wants to merge 5 commits into
mainfrom
feature/bumble-transport
Open

feat: add bumble BLE transport with pairing#107
JPHutchins wants to merge 5 commits into
mainfrom
feature/bumble-transport

Conversation

@JPHutchins
Copy link
Copy Markdown
Collaborator

@JPHutchins JPHutchins commented May 22, 2026

Summary

Adds an optional bumble extra (smpclient[bumble]) providing SMPBumbleTransport — a BLE SMPTransport backed by Google's bumble stack driving an external HCI USB controller (e.g. an nRF52840 DK running the Zephyr hci_usb sample). Alternative to the bleak-backed SMPBLETransport for 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)

  • SMPBumbleTransport with a Disconnected | Connecting | Connected | ConnectedBorrowed sum-type state machine; per-step try/except in _teardown() is load-bearing (skipping a bumble cleanup step can hang process exit).
  • ConnectedProtocol(Protocol) — structural type shared by Connected (owned) and ConnectedBorrowed (caller-owned LE link). send/receive/mtu operate on it via a single _require_connected() guard; no procedural None-sentinel checks.
  • pair_on_connect: PairingDelegate | None constructor arg — connect() pairs after LE-connect and before GATT discovery when no bond exists; when a bond is present, proactive _encrypt_using_bond runs instead.
  • use_connection(connection, *, peer=None) — borrow an existing bumble Connection for 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).
  • Module-level pair() function shared by pair_device(), SMPBumbleTransport.pair(), and the pair_on_connect path.
  • SMPBumbleTransport.scan() static method using bumble_device().

Keystore (src/smpclient/transport/bumble/keystore.py)

  • KeystoreStrategy = Tempfile | Local | Custom | ExistingCustom | InMemory sum type.
  • Custom(path) auto-creates parent dirs; ExistingCustom(path) errors if the file is missing.
  • Tempfile/Local filenames 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.
  • PairingFailureReason enum incl. NOT_FOUND for scan misses.

Scan (src/smpclient/transport/bumble/scan.py)

  • Async BLE scan with name filter, eager mode, and SMP-service marker.
  • Cleanup factored into _scanning() async context manager.

CLI (src/smpclient/transport/bumble/__main__.py)

  • smpbumble entry point: scan, pair [--force], echo. Also python -m smpclient.transport.bumble.
  • Typed _ScanArgs / _PairArgs / _EchoArgs NamedTuples for dispatch (no bare argparse.Namespace hand-off).

Tests (tests/test_smp_bumble_transport.py)

  • 39 tests covering: state machine transitions, send/receive notification queue, pair delegates + PairingResult 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). Bumble module at 91%+ coverage.

CI (.github/workflows/test.yaml)

  • New bumble-only row in transport-extras matrix. Each row asserts the bumble import passes/fails as expected for that extras combination.

Cross-cutting

  • SMP_SERVICE_UUID and SMP_CHARACTERISTIC_UUID centralized in smpclient.transport; re-exported by ble.py and bumble/__init__.py.
  • assert_never imported from typing_extensions everywhere (Python 3.10 compat).
  • ATT_WRITE_OVERHEAD named constant replaces magic - 3 in 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.14
  • uv run task coverage — total coverage ≥ 91% gate
  • uv run smpbumble scan — lists all advertising devices, marks SMP ones
  • uv run smpbumble pair <addr> [--force] — pairs a device, optionally wiping local bond first
  • uv run smpbumble echo <addr> <msg> — connects, auto-encrypts from stored LTK, SMP Echo round-trip

Follow-up (not blocking this PR)

  • Firmware-lookup PR: companion PyPI packages from intercreate/zephyr-hci selectable via per-board smpclient extras + all_firmware aggregate (separate PR coordinating with the build farm side).

FYI @raykamp @raykamp-tht

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 22, 2026 01:18
@JPHutchins
Copy link
Copy Markdown
Collaborator Author

uv run smpbumble pair 4E:D0:35:AE:33:0F --force
2026-05-21 18:10:14,618 smpclient.transport.bumble INFO force=True; deleting local bond for FB:C2:95:7E:16:A9
Enter the 6-digit PIN shown on the device: 567970
2026-05-21 18:10:26,033 smpclient.transport.bumble INFO Pair succeeded; settling for 1.5s
Pairing succeeded (bonded=True)
uv run smpbumble echo 67:1D:1F:D5:3E:23 hellow
2026-05-21 18:14:25,901 smpclient.transport.bumble INFO Connecting to 67:1D:1F:D5:3E:23
2026-05-21 18:14:25,929 smpclient.transport.bumble INFO Bond exists for FB:C2:95:7E:16:A9; initiating encryption
2026-05-21 18:14:27,848 smpclient.transport.bumble INFO Requested MTU 247, negotiated 247
2026-05-21 18:14:27,848 smpclient.transport.bumble INFO Connected to 67:1D:1F:D5:3E:23, max_write=244
hellow
2026-05-21 18:14:28,118 smpclient.transport.bumble WARNING Peer disconnected: reason=0x16
2026-05-21 18:14:28,122 smpclient.transport.bumble INFO Disconnected

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the smpbumble CLI script.
  • Implement SMPBumbleTransport with a small connection state machine, proactive bonded-encryption, plus one-shot pair_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.

Comment thread src/smpclient/transport/bumble/__init__.py
Comment thread src/smpclient/transport/bumble/keystore.py Outdated
Comment thread src/smpclient/transport/bumble/__main__.py Outdated
Comment on lines +371 to +375

def _on_disconnection(self, reason: int) -> None:
logger.warning(f"Peer disconnected: reason=0x{reason:02x}")
self._disconnected_event.set()
self._notifications.put_nowait(_DisconnectSentinel())
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — keeping. _on_disconnection sets _disconnected_event and pushes a _DisconnectSentinel onto _notifications, which makes any pending send()/receive() raise SMPTransportDisconnected per the SMPTransport Protocol contract. We deliberately avoid fire-and-forget asyncio.create_task(self.disconnect()) — bumble's teardown is hang-prone and must run under explicit caller control. State stays Connected until the caller calls disconnect(), which is the canonical place to free bumble resources.

Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/keystore.py
Comment thread src/smpclient/transport/bumble/__main__.py
Comment thread src/smpclient/transport/bumble/__init__.py
Copy link
Copy Markdown
Collaborator Author

@JPHutchins JPHutchins left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a lot of repetition, unnecessary variable aliasing, not enough use of Final.

Comment thread src/smpclient/transport/bumble/__main__.py Outdated
Comment thread src/smpclient/transport/bumble/__main__.py Outdated
Comment thread src/smpclient/transport/bumble/__main__.py Outdated
Comment thread src/smpclient/transport/bumble/__main__.py
Comment thread src/smpclient/transport/bumble/keystore.py
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py
Comment thread src/smpclient/transport/bumble/__init__.py Outdated
@JPHutchins
Copy link
Copy Markdown
Collaborator Author

PR description is out of date.

…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.

Comment thread src/smpclient/transport/bumble/keystore.py
Comment thread src/smpclient/transport/bumble/keystore.py
Comment thread src/smpclient/transport/bumble/__init__.py
Comment thread src/smpclient/transport/bumble/scan.py Outdated
Comment thread src/smpclient/transport/bumble/__init__.py
JPHutchins and others added 3 commits May 22, 2026 12:11
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants