Skip to content

Add Nordic UART Service BLE bridge for serial commands - #2745

Open
loratrak wants to merge 2 commits into
BruceDevices:mainfrom
loratrak:nus-ble-serial-bridge
Open

Add Nordic UART Service BLE bridge for serial commands#2745
loratrak wants to merge 2 commits into
BruceDevices:mainfrom
loratrak:nus-ble-serial-bridge

Conversation

@loratrak

@loratrak loratrak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Proposed Changes

Adds a standard Nordic UART Service (NUS) BLE bridge so any BLE central
(nRF Connect, a companion app, ...) can drive Bruce's existing serial command
parser over BLE, and makes that bridge fast.

The bridge maps the NUS RX/TX characteristics onto the existing SerialDevice
interface, so every serial command already supported by Bruce works unchanged
over BLE. It is enabled under Config > Advanced > "Toggle BLE API" (off by
default); the device then advertises as "Bruce".

  • Service: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
  • RX (write) 6E400002-...: write a newline-terminated command line
  • TX (notify) 6E400003-...: subscribe to receive parser output
  • Battery: standard 0x180F / 0x2A19

The second commit addresses latency: output was emitted as one BLE notification
per print/println with a fixed delay per chunk, so a single command produced
dozens of notifications, each waiting for its own connection event. The bridge
now:

  • coalesces TX into full MTU-3 byte notifications instead of one per call;
  • drops the fixed per-chunk delay in favour of real backpressure
    (notify() returning false when the controller queues are full);
  • only sends when a central is actually subscribed to TX — otherwise every
    notify failed and burned the retry budget, which slowed down USB serial
    too whenever the BLE API was enabled;
  • requests ATT MTU 517, uses iOS-compatible connection params (15–30 ms; the
    previous 7.5 ms minimum was rejected outright by iOS), and prefers LE 2M PHY
    on chips that support it (guarded out for the classic ESP32, which is BLE 4.2).

Types of Changes

  • New Feature (Nordic UART Service BLE bridge)
  • Performance improvement (TX coalescing + link parameters)
  • Bugfix (vprintf previously passed a va_list straight to sprintf; TX no
    longer stalls USB serial when no central is subscribed)

No breaking changes. The feature is opt-in and off by default; behaviour with
the BLE API disabled is unchanged.

Verification

  1. Build and flash. Enable Config > Advanced > "Toggle BLE API"; the device
    advertises as "Bruce".
  2. Connect with nRF Connect (or any NUS client), subscribe to the TX
    characteristic 6E400003-..., and write a command such as info\n to RX
    6E400002-... using write without response.
  3. The command output arrives on the TX notifications, identical to what the
    USB serial console returns.
  4. Latency check: run a command with long output (e.g. list a folder). It
    completes in ~1 s where the pre-optimisation bridge took ~7 s.
  5. Regression check: disable the BLE API and confirm USB serial output is
    unchanged and full speed.

Built and linked for both ESP32-S3 (lilygo-t-embed-cc1101) and classic ESP32
(Marauder-Mini).

Testing

Not covered by automated tests — the change is on the BLE/serial I/O path, which
has no unit-test harness in the project today. Verified manually on hardware
(see Verification). Happy to add coverage if the maintainers can point to an
existing pattern for exercising the SerialDevice layer.

Linked Issues

None.

User-Facing Change

Add an optional Nordic UART Service (NUS) BLE bridge (Config > Advanced > "Toggle BLE API", off by default) that lets any BLE central drive Bruce's serial command parser over BLE, with coalesced notifications and tuned link parameters for fast response.


loratrak and others added 2 commits August 2, 2026 18:31
Turn the ble_api module into a standard Nordic UART Service (NUS) so any
BLE central (nRF Connect, a companion app, ...) can drive Bruce's serial
command parser over BLE.

Changes:
- BLESerialService: standard NUS with separate RX (write) and TX (notify)
  characteristics instead of a single custom characteristic; RX bytes are
  buffered under a mutex and delivered to the parser as complete lines; TX
  output is chunked to the negotiated MTU; fixed vprintf (was passing the
  va_list straight to sprintf).
- ble_api: advertise as "Bruce" with a scan response so the 128-bit NUS
  UUID stays discoverable alongside the battery service.
- settings: show "BLE API ON/OFF" feedback when toggling, so it is clear
  the service actually started.

How to use the BLE API:
1. On the device, enable it in Config > Advanced > "Toggle BLE API"
   (off by default). It then advertises as "Bruce".
2. Connect and talk to the NUS service:
   - Service:      6E400001-B5A3-F393-E0A9-E50E24DCCA9E
   - RX  (write)   6E400002-...: write a command line, e.g. "info\n".
                    Every command must end with a newline.
   - TX  (notify)  6E400003-...: subscribe to receive the parser output.
   - Battery:      standard 0x180F / 0x2A19 is also exposed.
3. Any command accepted by the USB serial CLI works over BLE
   (info, free, uptime, ir, subghz, storage, ...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NUS bridge felt slow not because GATT is slow but because of how the
bridge emitted data and how the link was set up. This reworks both without
changing the RX contract (newline-terminated command lines) or the
SerialDevice interface seen by the command handlers.

Coalesced TX notifications:
- print/println/printf/write used to emit one notification per call and
  sleep a fixed 5 ms after every chunk. A command that prints 40 lines
  therefore cost 40 notifications, each waiting for its own connection
  event. Output is now appended to a mutex-guarded buffer and flushed as
  full MTU-3 byte notifications; a trailing partial chunk is flushed by
  flush() or automatically ~12 ms later, piggybacking on the poll the
  command task already does in available(). flush() is no longer a no-op.
- Dropped the fixed 5 ms per-chunk delay. Backpressure now comes from
  bleNotifyRetry() returning false when the controller queues are full,
  which is the real signal; the fixed sleep was pure latency.

Don't stall the USB serial when nobody is listening:
- With no central subscribed to the TX characteristic every notify()
  failed and burned the whole retry budget, so enabling the BLE API slowed
  down ALL serial output even with no app connected. An onSubscribe
  callback on the TX characteristic now tracks subscription state and
  queueTx() simply drops output when there is no subscriber.

Link setup:
- Request ATT MTU 517 in setup(); previously we advertised the NimBLE
  default and capped every notification far below what the peer allowed.
  The central still picks the final value (iOS settles around 185).
- Connection params changed from 6..24 to 12..24 (15..30 ms). iOS rejects
  the entire request when the minimum interval is under 15 ms, which left
  us on whatever interval iOS chose on its own.
- Prefer LE 2M PHY (roughly 2x raw throughput) on chips that have it,
  guarded out for the classic ESP32 (BLE 4.2, no 2M radio). The peer keeps
  1M if it does not support 2M.
- Clear the TX buffer and reset MTU/subscription on disconnect.

Command loop:
- serialcmds polls one command per pass; a burst queued over BLE used to
  pay the full 10 ms idle tick between each one. Tick drops to 1 ms while
  input is still buffered, and still always yields.

To benefit, a central must subscribe to the TX characteristic (the
firmware now only sends to subscribers) and should write to RX with
write-without-response.

Co-Authored-By: Claude Opus 5 <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.

1 participant