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:
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.
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 | |
|---|---|
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 | |
|---|---|
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:
- All characteristics appear under one service UUID.
- Motor characteristics show
WRITEandNOTIFYin the property list. - Expand any motor characteristic. The
BLE2902descriptor appears as "Client Characteristic Configuration". - Write
01 01(hex) to Motor 1. The motor starts. - Write
01 00. The motor stops. - 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:
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.