Skip to content

BLE GATT Profile Design for Hardware Control: AtomBrick

When you define a BLE GATT profile for the first time, it feels like boilerplate: pick some UUIDs, create a service, add characteristics. Those decisions define the protocol between your firmware and every app that ever connects to the device. AtomBrick went through one revision of this, and the mistakes are specific enough to be worth documenting.

Prerequisites

  • Familiarity with the BLE GATT model: services, characteristics, descriptors, and properties
  • An ESP32 with the Arduino BLE stack (BLEDevice.h)
  • nRF Connect (iOS or Android) for verifying profile structure

Stack used in this post

ESP32 Arduino core 2.x · BLEDevice.h · ESPMX1508 1.0.5 · nRF Connect 4.x

Background

A GATT profile is a hierarchy: a peripheral exposes services, each containing characteristics, each optionally with descriptors. A characteristic is a typed value whose properties (READ, WRITE, WRITE_WITHOUT_RESPONSE, NOTIFY, INDICATE) declare what operations are allowed.

The Bluetooth SIG publishes a registry of 16-bit UUIDs for common data types. For custom hardware, you either define a custom 128-bit UUID or reuse an existing SIG UUID if the semantics are genuinely close.

Reusing SIG UUIDs for custom data will cause confusion

AtomBrick uses 0x180D (Heart Rate Service) and 0x2A31 to 0x2A36 (Heart Rate measurement variants) for motor control. nRF Connect labels every characteristic as a heart rate field. Any developer looking at the device cold will be confused. Custom 128-bit UUIDs are unambiguous and cost nothing extra.

Implementation

1. The AtomBrick GATT profile as shipped

Service: 0000180D-0000-1000-8000-00805F9B34FB  (Heart Rate, repurposed)
Motor 1: 00002A31-...  READ | WRITE | NOTIFY
Motor 2: 00002A32-...  READ | WRITE | NOTIFY
Motor 3: 00002A33-...  READ | WRITE | NOTIFY
Motor 4: 00002A34-...  READ | WRITE | NOTIFY
Motor 5: 00002A35-...  READ | WRITE | NOTIFY
Name:    00002A36-...  READ | WRITE

Six characteristics in one service. Each motor characteristic carries a BLE2902 descriptor (CCCD, UUID 0x2902) to enable NOTIFY subscriptions.

2. UUID selection: what to do instead

Generate a custom base UUID with uuidgen and derive each characteristic from it:

Service:    A1B2C3D4-0000-1000-8000-00805F9B34FB
Motor 1:    A1B2C3D4-0001-1000-8000-00805F9B34FB
Motor 2:    A1B2C3D4-0002-1000-8000-00805F9B34FB
Motor 3:    A1B2C3D4-0003-1000-8000-00805F9B34FB
Motor 4:    A1B2C3D4-0004-1000-8000-00805F9B34FB
Motor 5:    A1B2C3D4-0005-1000-8000-00805F9B34FB
Device Name: A1B2C3D4-0006-1000-8000-00805F9B34FB

On the app side, constants live in a single shared file:

data/helpers/BluetoothService.kt
object BluetoothService {
    val SERVICE_UUID          = Uuid.parse("A1B2C3D4-0000-1000-8000-00805F9B34FB")
    val DEVICE_NAME_CHAR_UUID = Uuid.parse("A1B2C3D4-0006-1000-8000-00805F9B34FB")
}

3. Characteristic property choices

All five motor characteristics carry PROPERTY_READ | PROPERTY_WRITE | PROPERTY_NOTIFY. This is broader than necessary.

READ was added for debugging: you can read the last written value back from nRF Connect without sending a command. In production, the peripheral state is owned by the app and READ is never called programmatically.

WRITE with response (PROPERTY_WRITE) means the BLE stack acknowledges every write at the protocol level before the next write proceeds. For motor commands where a dropped write leaves a motor stuck on or at the wrong speed, this is the right choice.

src/main.cpp
pmotor1 = pService->createCharacteristic(
    MOTOR_1_UUID,
    BLECharacteristic::PROPERTY_READ  |
    BLECharacteristic::PROPERTY_WRITE |
    BLECharacteristic::PROPERTY_NOTIFY
);
pmotor1->addDescriptor(new BLE2902());

WRITE_WITHOUT_RESPONSE suits high-frequency streaming

If the use case were a joystick sending 50 position updates per second, WRITE_WITHOUT_RESPONSE removes the acknowledgement round-trip and allows pipelining. For discrete motor commands, the latency difference is imperceptible and the reliability guarantee from WRITE is worth it.

4. Descriptors: BLE2902 and what you are missing

BLE2902 is the Client Characteristic Configuration Descriptor. Every characteristic with PROPERTY_NOTIFY must have one attached. Without it, notify() sends to nobody and logs no error.

The profile also omits 0x2901 (Characteristic User Description), which holds a human-readable label. Adding it makes the profile readable in any generic BLE tool:

src/main.cpp
1
2
3
4
5
pmotor1->addDescriptor(new BLE2902());

BLEDescriptor *desc = new BLEDescriptor(BLEUUID((uint16_t)0x2901));
desc->setValue("Motor 1 Control");
pmotor1->addDescriptor(desc);

This costs negligible memory and cuts debugging time significantly when inspecting the device cold.

5. The 2-byte command protocol

The payload written to each characteristic is the actual protocol. It is independent of GATT: the same encoding could run over any transport.

Byte 0: Command
  0x01  Power on/off
  0x02  Forward at speed
  0x03  Reverse at speed
  0x04  Timed run

Byte 1: Data
  For 0x01:      0x01 = on, 0x00 = off
  For 0x02/0x03: speed 0 to 127
  For 0x04:      duration in seconds, clamped to 60

The firmware dispatches in four lines:

src/motor.cpp
1
2
3
4
5
6
7
8
void processMotorCommand(MX1508 &motor, uint8_t cmd, int8_t value) {
    switch (cmd) {
        case 1: value == 1 ? motor.motorGo(128) : motor.motorStop(); break;
        case 2: motor.motorGo(value);  break;
        case 3: motor.motorRev(value); break;
        default: Serial.printf("Unknown command: %d\n", cmd);
    }
}

Three known constraints in the current design: commands are not idempotent (the firmware re-executes on every write regardless of current state); there is no error response (notify fires unconditionally after every write whether the command was valid or not); the speed range is split across two commands (0x02 for forward, 0x03 for reverse) where a single signed byte would encode both direction and magnitude.

6. A revised profile for production

Collapsing five motor characteristics into one Motor Control characteristic with a three-byte payload makes adding a sixth motor a firmware-only change:

Service: [custom UUID]
Motor Control (WRITE_WITH_RESPONSE):
    [motor_id, command, data]  3 bytes
Motor Status (NOTIFY):
    [motor_id, status, speed]  3 bytes per motor
Device Config (READ | WRITE):
    UTF-8 device name

The Motor Status characteristic closes the feedback loop that is currently open: the app can confirm commands landed and detect fault conditions without polling READ.

Testing and Verification

Connect with nRF Connect and expand the service:

  1. All characteristics appear under one service UUID.
  2. Motor characteristics show WRITE and NOTIFY in the property list.
  3. Expand any motor characteristic. The BLE2902 descriptor appears as "Client Characteristic Configuration".
  4. Write 01 01 (hex) to Motor 1. The motor starts.
  5. Write 01 00. The motor stops.
  6. Subscribe to notifications on Motor 1 and write a command. A notification fires immediately after each write.

If 0x2901 descriptors are present, nRF Connect shows "Motor 1 Control" as the characteristic label instead of the raw UUID.

Pitfalls

Missing BLE2902 on a NOTIFY characteristic silently drops all notifications

pCharacteristic->notify() succeeds at the C++ level with no error. The BLE stack sends the notification to no subscribers because the CCCD was never registered. The bug is invisible until you check with nRF Connect and notice the notification counter never increments.

services[2] on the app side is fragile

Kable enumerates services in the order the peripheral reports them. This can differ across iOS and Android, and across firmware versions. The app currently finds the AtomBrick service by position (services[2]). Find it by UUID to be safe:

ui/motor/MotorViewModel.kt
val service = services?.firstOrNull { it.serviceUuid == BluetoothService.SERVICE_UUID }

Production Considerations

The current profile reuses SIG UUIDs, uses one characteristic per motor, and has no status feedback. None of these break correctness in a single-device, single-user context. In a multi-device or multi-developer context, all three become problems:

A custom base UUID is the highest-return change. Generate it once with uuidgen, define it in both the firmware and the app's constants file, and every tool that touches the device will label it correctly.

The one-characteristic-per-motor design requires a profile change to add motors, which means a firmware update and an app update in lockstep. The three-byte [motor_id, command, data] encoding eliminates that coupling.

Wrapping Up

The AtomBrick profile works and the protocol is clean. Three specific decisions would not survive a production review: the reuse of SIG UUIDs, the per-motor characteristic structure, and the absence of status feedback in notifications. None are expensive to fix in a v2 profile: the firmware and app constants change, but the protocol bytes stay the same.

Start with the UUID change. Run uuidgen, replace 0x180D in the firmware and BluetoothService.SERVICE_UUID in the app, and redeploy. The rest of the protocol is unaffected.


Comments