Back to all articles
Embedded SystemsNew

Designing a BLE Command Protocol for Connected Devices

The command layer between a phone and a BLE device is the part most teams improvise on a deadline and regret in the field. Here is how to design a compact, versioned, reliable, and secure BLE command protocol that lets host and firmware evolve independently.

July 19, 2026·11 min read·embedded, ble, protocol, iot

Your device works flawlessly on the bench. Then you ship a firmware update that changes one command's payload by two bytes, and every phone in the field keeps sending the old format — which your device silently misinterprets and executes. Nobody planned for that, because nobody designed the command protocol; they exposed a few GATT characteristics, wired JSON through them, and shipped. The BLE command/control layer is the single most improvised part of a connected product, and it is the one that costs you a recall, a support fire drill, or a 'we can't change the app without bricking v1 hardware' meeting. This is how to design that layer so the host and the firmware can evolve independently for years.

GATT Is a Database, Not a Message Bus

The most common mistake is treating GATT like a socket. It isn't. GATT gives you a table: services group characteristics, and each characteristic is an addressable value with a handle, a set of permissions, and operations the client can perform on it. The phone is the GATT client (central); your device is the GATT server (peripheral). That's the whole model. There is no built-in request/response, no framing, no delivery guarantee at the application level. You have to build a protocol on top of characteristics — and the cleanest design uses very few of them: one characteristic the host writes commands into, one the device notifies responses out of, and maybe one for high-rate telemetry. Teams that instead expose one characteristic per feature end up with 40 handles, a bespoke read/write dance for each, and no way to add a feature without an app update pinned to a firmware version.

Notify vs Indicate vs Write vs Write-Without-Response

GATT gives you four operations that matter, and choosing wrong here is the root of most 'commands get dropped' bugs. Know exactly what each guarantees:

  • Write (with response): the client sends and the stack returns an ATT acknowledgement. Only one is outstanding at a time, so it is slower, but you know the peripheral's stack received the bytes. Use this for host-to-device commands.
  • Write Without Response: fire-and-forget. Higher throughput because many can be pipelined, but if the peripheral's buffers are full the write is dropped with no error. Use it only for streaming where losing a packet is acceptable.
  • Notify: server-to-client push with no confirmation. Fast, and the delivery vehicle for command responses and telemetry — but a notification can be lost on a congested link.
  • Indicate: server-to-client push with an ATT confirmation, one outstanding at a time. It guarantees the client's stack received it, at the cost of throughput. Use it for rare critical events, not for a steady response stream.

The important nuance: none of these confirm that your application processed the command. A Write-with-response only tells you the ATT transaction landed. Reliable delivery of intent is your job at the application layer, which is why the framing below carries its own acknowledgement.

Design a Compact Binary Frame, Not JSON Over BLE

Stringly-typed JSON over BLE is the design smell we see most. A '{"cmd":"setColor","r":255,"g":0,"b":0}' payload is 38 bytes to say something that fits in 5. On a link where your usable payload might be 20 bytes until MTU is negotiated, you've turned a one-packet command into a multi-packet reassembly problem, burned radio-on time (which is battery), and coupled your firmware parser to a JSON library it shouldn't need. Design a fixed binary frame instead:

  • Opcode (1 byte): the command or response identifier. Reserve ranges — for example 0x00–0x7F for commands, 0x80–0xFF for responses/events.
  • Flags (1 byte): bits for 'this is a response', 'more fragments follow', 'ack requested'.
  • Sequence (1 byte): a monotonic counter the response echoes, so the host can match reply to request and safely retry.
  • Length (2 bytes, little-endian): payload length, so the receiver knows when a command is complete across fragments.
  • Payload (N bytes): the command arguments, ideally as fixed-offset fields or TLV.
  • Status (1 byte, responses only): 0x00 = OK, non-zero = a specific error code the host can act on, not a generic failure.

Pick an endianness (little-endian matches every ARM Cortex-M part you'll use — nRF52840, STM32WB, STM32L4) and write it into the spec so a second engineer implementing the host can't guess wrong.

Version the Protocol From v1

The single change that saves you the most future pain costs one byte and one opcode today. Assign the protocol a version, expose a GET_PROTOCOL_VERSION command (or a read-only version characteristic), and have the host query it on connect and adapt. Now the phone app and the firmware are decoupled: a v2 app talking to a v1 device knows to fall back, and a v1 app talking to a v2 device still works because v2 only added opcodes, never repurposed old ones. This is the same discipline that makes field firmware updates survivable — if you're building an update path, design the protocol version handshake alongside your OTA update and rollback strategy so a half-updated fleet running mixed versions still speaks a common baseline. Anyone who promises they'll 'add versioning later' is really promising a breaking-change migration across every device already in a customer's hands.

TLV Fields for Forward-Compatible Payloads

Fixed-offset payloads are compact and fast to parse, but they're rigid — adding a field shifts every offset. For commands you expect to grow, use TLV (type-length-value): each optional field is a 1-byte type, a 1-byte length, then its value. The receiver iterates fields it recognizes and skips unknown types by their length. That skip-the-unknown rule is the whole point: a v1 device ignores a v2 field it doesn't understand instead of choking on it, and a v2 host can send richer commands to old devices without a hard break. Reserve fixed offsets for the mandatory core of a command; use TLV for the optional tail.

Sequence Numbers and Idempotency

BLE links drop, and when you retry a command you must not double-execute it. That's why the sequence byte matters. The host increments it per command; the device tracks the last sequence it processed and, if it sees a repeat, returns the cached result instead of running the command again. Make commands idempotent wherever the physics allows — 'set brightness to 40' is naturally idempotent; 'increment brightness' is not, so avoid relative commands for anything you'll retry. The response always echoes the sequence number so the host can match a reply to the exact request it retried, rather than confusing a late v1 response with the answer to v2.

MTU Negotiation and Chunking Large Payloads

The default ATT MTU is 23 bytes, which leaves 20 usable for payload — brutally small. On connect, negotiate it up: modern stacks on nRF52840, ESP32-S3, iOS, and Android will settle somewhere between ~182 usable bytes (iOS caps ATT_MTU at 185, and the 3-byte ATT header eats into that) and 244 usable (a 247-byte ATT_MTU). Always design assuming the negotiated value is the floor of what you got, because the host and peripheral each cap it. For anything larger than the negotiated MTU — a firmware image, a config blob, a log dump — you fragment: use the 'more fragments' flag and the length field so the receiver reassembles, and don't send the next fragment until the previous one is acknowledged if you're using Write-with-response. Never assume the MTU is what you asked for; read back what was actually negotiated and size your frames to it.

Throughput, Connection Interval, and the Latency-vs-Power Tax

The connection interval — how often the two radios wake to talk — is the master dial, and it trades latency directly against battery. A 7.5 ms interval (the BLE minimum, though iOS won't go below ~15 ms and often negotiates 30 ms) gives snappy command response and higher throughput but keeps the radio busy and drains the cell. A 200 ms–1 s interval sips power but means a command might wait a fifth of a second to a full second before the device even hears it. With Data Length Extension (251-byte PDUs) and a good MTU you can realistically move hundreds of kilobits per second — Nordic measures roughly 700–800 kbps at the application layer on the classic 1M PHY, and BLE 5's 2M PHY pushes past 1 Mbps. That comfortably covers control, telemetry, and even compressed audio (LE Audio's LC3 codec runs around 48–96 kbps); the only thing it won't do is stream lossless or high-bitrate audio. Pick the interval per mode: fast while a user is actively interacting, slow when idle, and request a connection parameter update to switch. This is inseparable from your battery and low-power design — a coin-cell device that holds a 15 ms interval permanently will die in days, and the radio, not the MCU, is usually the dominant draw.

Acknowledgements, Timeouts, and Retries

Because the ATT layer only confirms bytes arrived — not that your firmware acted on them — critical commands need an application-layer acknowledgement. The device sends a response frame carrying the echoed sequence number and a status byte only after it has actually done the thing. The host arms a timeout (1–2 seconds is a sane default, longer for slow actuations), and on expiry it retries with the same sequence number, which the idempotency rule makes safe. Give up after a small bounded number of retries and surface a real error to the user rather than looping forever. The one rule that prevents most field pain: never send a command that matters — unlock, dispense, fire, commit — over Write-Without-Response with no app-layer ack. You will not know it was lost until a customer tells you.

Disconnection, Reconnection, and State Resync

BLE connections drop constantly — the user pockets the phone, walks out of range, another app grabs the radio. Design for it as the normal case, not the exception. Bond the devices so reconnection is fast and encrypted without re-pairing. On reconnect, do not assume any command that was in flight completed: the host issues a GET_STATE command, the device returns its full authoritative state, and the host reconciles its UI to that truth. If the app assumed 'the light is on' because it sent SET_ON before the drop, it must be willing to discover the command never landed. State resync on every reconnect is cheap insurance against a UI that lies to the user.

Pairing, Bonding, and LE Secure Connections

Encryption on a BLE link comes from pairing, and the pairing method decides whether you're actually protected against a man-in-the-middle. LE Secure Connections (mandatory to prefer on any modern design) uses ECDH on the P-256 curve to establish keys. The association model on top of that is what varies:

  • Just Works: no user interaction, no MITM protection. An attacker in the middle can pair with both sides. Acceptable for a step counter, unacceptable for anything with consequences.
  • Passkey Entry: one side shows a six-digit code the user types into the other. MITM-resistant. Good when one device has a display and the other an input.
  • Numeric Comparison: both sides show a number and the user confirms they match. MITM-resistant, and the best UX when both ends have a display (phone plus a device with a screen).

Bonding then stores the keys so future connections encrypt automatically. Gate your command characteristics behind encryption-and-authentication permissions so an unbonded client can't even write to them. Using Just Works on a device that controls a lock, a motor, a dose, or a payment is a decision you should have to defend in a design review — because you're shipping a product any nearby radio can command.

App-Layer Auth for Commands That Actually Matter

Link encryption protects the bytes in transit; it does not prove the sender is authorized. For high-value commands, add an application-layer check on top of the encrypted link: a challenge-response where the device issues a nonce and the host returns a signature or HMAC computed with a provisioned key, or per-command signing for the truly sensitive actions like unlocking or accepting firmware. This means that even a legitimately bonded but compromised phone can't invoke privileged commands without the credential, and it lets you scope which hosts can do what. It's the difference between 'anyone who paired once can do anything forever' and 'this specific action requires proving authority right now.'

Keep the Device Thin: Application Logic Lives on the Host

The cleanest connected products keep the peripheral a thin, well-specified command target and put the application intelligence on the host. Think about an RGB light over BLE: the firmware should expose SET_COLOR(r, g, b), SET_BRIGHTNESS(level), GET_STATE, and maybe SET_EFFECT(id) — primitive, testable, stable operations. 'Party mode,' schedules, scenes, and sunset-timing logic belong in the phone app or the cloud, where you can change them without touching a single deployed device. Every behavior you bake into firmware is a behavior you can only change through an OTA campaign; every behavior on the host you can change with an app update tomorrow. This host-versus-device split is the same boundary decision that shapes your whole system, and it's worth working out deliberately against your broader embedded and IoT architecture before you freeze the command set. A thin device with a small, versioned command vocabulary is one you can support for a decade; a device stuffed with product logic is one you'll be reflashing every time marketing has an idea.

The Failure Modes We Clean Up Most Often

  • No protocol version, so the first breaking change strands every device already in the field and forces a synchronized app-and-firmware migration.
  • Stringly-typed JSON over BLE, wasting a scarce MTU and coupling firmware to a parser it never needed.
  • No application-layer acknowledgement on critical commands, so lost writes are invisible until a customer reports the device 'didn't do anything.'
  • Just Works pairing on a device that controls something that matters, leaving it open to any nearby radio.
  • One characteristic per feature, producing handle sprawl and a bespoke access pattern for every function instead of one clean command channel.
  • Assuming Write-Without-Response and Notify always arrive, when both can be silently dropped on a congested link.
  • Relative commands ('increment,' 'toggle') that double-execute on retry because they were never idempotent.

Frequently Asked Questions

Should I use one command characteristic or a separate characteristic per feature?

One command characteristic (host writes) plus one response characteristic (device notifies) is almost always the right answer. It gives you a single place to version, frame, sequence, and secure everything, and it keeps your GATT table small. Add a separate high-rate characteristic only when you have genuine streaming telemetry whose timing you don't want fighting the command channel.

Notify or indicate for command responses?

Notify for the normal case, with your own application-layer acknowledgement providing the real delivery guarantee. Indicate's ATT-level confirmation is slower (one outstanding at a time) and only confirms the client's stack received it, not that your app processed it — so it doesn't replace the app-layer ack. Reserve indicate for rare critical events where the extra stack-level assurance is worth the throughput hit.

If the BLE link is already encrypted, do I still need application-layer authentication?

For anything sensitive, yes. Encryption protects the data in transit but doesn't prove the sender is authorized — a compromised or malicious bonded host is still 'encrypted.' A challenge-response or per-command signature scoped to privileged actions is what stops a paired-but-untrusted client from unlocking, dispensing, or flashing firmware.

Need a BLE Protocol That Survives Firmware Updates?

Designing the command layer once, correctly — versioned, framed, acknowledged, and secured — is a few weeks of work that pays back for the entire life of the product, and it's far cheaper than retrofitting it after v1 has shipped. At inteRFace R&D we design BLE command protocols, firmware, and companion-app integration as one system, so your host and device can evolve independently instead of being welded together. If you're specifying a connected device and want the control layer done right the first time, talk to an engineer at https://interfacernd.com/#contact.

Wondering what your project would cost?
Two minutes, honest ranges in engineering hours, the units we price in. You get a written quote with phases before any work starts.
Try the free estimator

Building something hardware-shaped?

We help hardware founders go from sketch to certified product. Tell us what you're working on — a senior engineer reads every email.