feat: bumble BLE transport for external-HCI SMP#99
Conversation
…ration Temporarily switch smpclient to the feature/bumble-transport branch on the intercreate fork so smpmgr can pull in SMPBumbleTransport, the bumble keystore/pairing modules, and the bundled zephyr-4-4-0-hci firmware packages. Reverts to a tagged PyPI release once intercreate/smpclient#107 merges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an external-HCI BLE path via Google's bumble stack so users can run SMP over the same dongle on every host OS, independent of the system BLE stack. The transport is selected with --bumble <BD_ADDR|name>; HCI source, keystore, pair-on-connect delegate, and pair timeout are tunable from the root callback. Surface a `smpmgr bumble` subcommand group covering the transport-level verbs that don't fit the SMP request/response shape: scan, pair (one-shot bonding via PIN entry by default), bonds list/clear/clear-all, and firmware paths/extract for the bundled Zephyr HCI controller images. Convert connect_with_spinner into an @asynccontextmanager so every subcommand disconnects the SMPClient before asyncio.run returns. This is required for bumble: its libusb USB transport owns background threads that post into the event loop, and without an explicit disconnect the loop is closed while callbacks are still in flight, producing RuntimeError: Event loop is closed and a hung thread-pool join at process exit. Pause the active rich Progress spinner around interactive PIN prompts so the delegate's "Enter PIN" line is actually visible during connect. Read stdin via a daemon-thread + asyncio.Future pattern so a pair_on_connect timeout no longer leaves a non-daemon worker blocked on input() — which previously deadlocked interpreter shutdown with the _enter_buffered_busy abort. Refresh the smpclient pin to the PR #107 race-fix tip (5f92a75 -> 7506224) so first-connect pairing works without a pre-pair step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls in the smpclient PR #107 follow-up where _on_disconnection pattern-matches HCI_ErrorCode by reason: local-host-terminated drops to DEBUG, peer-initiated graceful disconnects go to INFO, real failures stay WARNING. End users no longer see a spurious "Peer disconnected: reason=0x16" warning after every successful SMP exchange. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… table The rich.Table layout shrank the only column that matters — the HEX path — to `/home/jp/repos/smpmgr/.venv/lib/py…`. Replace with `<name> <absolute path>` one-per-line so the output is greppable and copy-pasteable into `west flash --hex-file=...`. Move board and full SHA256 behind --verbose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use the typed `Firmware` NamedTuple and `FirmwareModule` Protocol from the upstream zephyr_4_4_0_hci package (re-exported via `smpclient.transport.firmware.hci`) instead of stringly-keyed `getattr` introspection over `Firmware._fields`. Drop the `paths` and `extract` subcommands. Register one subcommand per firmware variant on `smpmgr bumble firmware`, so the variants are discoverable via `--help` and individually invokable (e.g. `smpmgr bumble firmware nrf52840dk_default ~/hci.hex`). Each subcommand defaults to verifying the embedded SHA-256 before writing; pass `--no-verify` to skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The most pipeable thing a firmware subcommand can do is emit the absolute
path of its bundled .hex on stdout — no decoration, no markup — so it
composes with shell substitution:
west flash --hex-file=$(smpmgr bumble firmware nrf52840dk_default)
Move the copy-out behaviour behind --extract <PATH>. SHA-256 verification
still runs by default during --extract; --no-verify skips. The wrote-N-bytes
status line goes to stderr so it doesn't contaminate stdout for callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a Bumble-based BLE transport path for SMP operations using an external HCI controller, alongside new Bumble-specific CLI utilities for scanning, pairing, bond management, and firmware access.
Changes:
- Adds root transport options for
--bumble, HCI selection, keystore behavior, and pairing mode/timeouts. - Refactors SMP command call sites to use
connect_with_spinneras an async context manager with explicit disconnect. - Introduces the
smpmgr bumblecommand group and temporarily updatessmpclientto a Git branch dependency.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
smpmgr/common.py |
Adds Bumble transport construction, pairing helpers, keystore resolution, prompt handling, and disconnect-on-exit connection lifecycle. |
smpmgr/main.py |
Wires Bumble CLI/options into the main app and updates upgrade flow to the new connection context manager. |
smpmgr/bumble.py |
Adds Bumble scan, pair, bonds, and firmware commands. |
smpmgr/image_management.py |
Updates image commands to use the connection context manager. |
smpmgr/file_management.py |
Updates file commands to use the connection context manager. |
smpmgr/stat_management.py |
Updates statistics commands to use the connection context manager. |
smpmgr/shell_management.py |
Updates shell command execution to use the connection context manager. |
smpmgr/os_management.py |
Updates OS commands to use the connection context manager. |
smpmgr/enumeration_management.py |
Updates enumeration commands to use the connection context manager. |
smpmgr/user/intercreate.py |
Updates Intercreate upload command to use the connection context manager. |
pyproject.toml |
Temporarily switches smpclient to the Bumble feature branch. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # TODO(bumble): revert to a tagged PyPI release once intercreate/smpclient#107 lands. | ||
| smpclient = { git = "https://github.com/intercreate/smpclient.git", branch = "feature/bumble-transport", extras = ["all"] } |
There was a problem hiding this comment.
Acknowledged — this is the explicit un-draft blocker called out in the PR description. The pin will be reverted to a tagged PyPI smpclient release once intercreate/smpclient#107 merges and a release is cut; the TODO(bumble): revert to a tagged PyPI release comment on the pin marks the spot. Leaving this thread open so it remains visible until that swap happens.
- Update bundled plugin examples to call `connect_with_spinner` as an
async context manager. The previous `await connect_with_spinner(...)`
pattern raised TypeError after the ctx-manager refactor; Copilot review
flagged this against `plugins/example_group.py` and
`plugins/another/another_group.py`.
- Apply black + isort reflows in `smpmgr/{bumble,common,main}.py` so the
Lint workflow passes on Python 3.11-3.14.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wildcard the per-board firmware sibling packages via the upstream typed `Firmware` NamedTuple from `zephyr_4_4_0_hci` and emit a `--collect-data` flag for each, so new board variants are picked up automatically without editing this script. Without these data files the .py wrappers ship but their .hex resources are missing, and `smpmgr bumble firmware <name>` raises FileNotFoundError inside the portable bundle. Verified locally: build succeeds, all 5 variants resolvable, extracted .hex SHA-256 matches the embedded constant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Adds an external-HCI BLE transport via Google's bumble stack, so users can run SMP over the same dongle on every host OS — useful when the system BLE stack is unavailable (Linux without BlueZ, certain CI runners) or when reproducible cross-platform behaviour matters.
The transport is selected with
--bumble <BD_ADDR|local-name>. A newsmpmgr bumblesubcommand group covers the transport-level verbs that don't fit the SMP request/response shape:smpmgr bumble scan— discover advertising devices; marks SMP servers.smpmgr bumble pair <addr|name> [--force]— one-shot bonding via PIN entry; useful when the SMP characteristic requires encryption on first connect.smpmgr bumble bonds list|clear <addr>|clear-all— manage the local keystore.smpmgr bumble firmware paths|extract <board>— surface the bundled Zephyr HCI controller images shipped viasmpclient[hci_firmware].What's in the PR
Transport selection (smpmgr/main.py, smpmgr/common.py)
--bumble,--hci(env-varSMPMGR_BUMBLE_HCI, defaultusb:0),--keystore local|tempfile|memory|<path>(defaultlocal— persistent platformdirs user data dir; diverges from smpclient'stempfiledefault since smpmgr is a daily-driver),--pair-on-connect keyboard|display|nio|none(defaultkeyboard),--pair-timeout-s.get_custom_smpclientgains a bumble branch using_resolve_keystore_strategy+_build_pair_delegate(both exhaustivematchover the smpclient sum types).connect_with_spinnernow catchesSMPBumbleTransportDeviceNotFound/SMPBumbleTransportNotSMPServer/ genericSMPBumbleTransportExceptionwith friendly messages instead of tracebacks.Bumble subcommand (smpmgr/bumble.py)
PairingResultexhaustive match mapsPairingSucceeded/PairingAlreadyBonded/PairingTimedOut/PairingFailedto exit codes and human-readable lines.firmware extractdoes a straightshutil.copyof the bundled.hexto a user-supplied destination so portable-binary users can flash without an installed smpclient.Lifecycle fix (
connect_with_spinner→@asynccontextmanager, all 17 callsites across 9 files)smpclient.disconnect()beforeasyncio.run()returns, the loop is torn down while libusb callbacks are still in flight, producingRuntimeError: Event loop is closedand a hung thread-pool join at process exit.async with connect_with_spinner(smpclient):and the connect helper auto-disconnects on context exit.Interactive prompt fix (smpmgr/common.py)
Progressspinner was repainting over the bumble pair delegate's PIN prompt during connect. A small module-level stack of active spinners + a_with_paused_spinnerhelper now pauses the spinner around the prompt and resumes after.asyncio.Futureso apair_on_connecttimeout doesn't leave a non-daemon worker blocked oninput()— which previously deadlocked interpreter shutdown with_enter_buffered_busy.Dependency pin (pyproject.toml, poetry.lock)
smpclientis temporarily pinned to theintercreate/smpclientgit branchfeature/bumble-transport(the corresponding PR is intercreate/smpclient#107).TODO(bumble): revert to a tagged PyPI release once intercreate/smpclient#107 landscomment on the pin marks the spot for un-pinning before this PR merges.Test plan
mypy smpmgr— clean (16 source files).flake8 smpmgr— clean.pytest— existing suite green.smpmgr --bumble "<name>" os echo hiagainst a Zephyr-SMP-secure peer. PIN prompt visible, bond stored, echo round-trips, clean exit.smpmgr bumble pair "<name>"+smpmgr --bumble "<name>" --pair-on-connect none os echo hi.bumble bonds list/bumble bonds clear <addr>.Event loop is closederrors, no Ctrl-C needed.--port,--ble,--ipafter theconnect_with_spinnerrefactor (mechanical change, low risk).--bumbleandsmpmgr bumble.Blockers before un-draft
smpclient = { extras = ["all"], version = "==X.Y.Z" }and refresh the lockfile.Out of scope
--ble(bleak) with bumble. Both coexist; user picks per invocation.west flash/nrfjprogis left to the user).🤖 Generated with Claude Code