πŸŽ›οΈ Receive Control Commands from the Phone

Learning Objectives

By the end of this lesson you will be able to:

  • Declare a BLE characteristic that accepts writes from a phone.
  • Explain the difference between a Write Request and a Write Command (Write Without Response).
  • Read and validate an incoming payload inside the onWrite callback.
  • Hand a command safely from the BLE stack task to your loop() and drive an LED.
  • Reject bad input without crashing or silently doing the wrong thing.

Quick Review

In the last lesson you built the push half of the gadget: a data characteristic with Read and Notify, subscribed by the phone through the CCCD at 0x2902, and a millis()-driven loop that called setValue() then notify().

That was the ESP32 talking. Now the phone talks back. Same service, a second characteristic, but with the data flowing the other way.

Theory

The write half of the gadget

Notifications travel from the ESP32 to the phone. A write travels from the phone to the ESP32. Both live inside the same GATT service, but they are separate characteristics with separate UUIDs and, importantly, separate properties.

A characteristic only accepts writes if you declare it that way. To let the phone send a Write Request, the characteristic must be declared with BLECharacteristic::{{c1::PROPERTY_WRITE}}. Skip that property and your phone app will show the characteristic as not writable, and nothing you do in code will help.

For our gadget the control characteristic gets write only:

#define CONTROL_UUID "6e400002-b5a3-f393-e0a9-e50e24dcca9e"

BLECharacteristic *pControl = pService->createCharacteristic(
    CONTROL_UUID,
    BLECharacteristic::PROPERTY_WRITE);

pControl->setCallbacks(new ControlCallbacks());

Note what is missing: no PROPERTY_READ, no PROPERTY_NOTIFY. The phone never reads it, it only writes into it.

Write Request vs Write Without Response

The BLE standard gives you two flavours of write, and phone apps let you pick either one:

  • Write Request (PROPERTY_WRITE): the ESP32 answers with an {{c3::ATT Write Response}}, so the phone knows the command arrived. Reliable, slightly slower.
  • Write Without Response (PROPERTY_WRITE_NR, also called a Write Command): fire and forget. Faster, no delivery confirmation.

For a gadget whose commands are one or two bytes, a Write Request is the friendlier choice: the phone app can report "success" and you never wonder if the tap registered. You can enable both properties if you want the phone to choose.

Where the bytes land: onWrite

Every accepted write calls your callback. Inside it you read the payload with pCharacteristic->{{c2::getValue()}}, or grab the raw buffer with getData() plus getLength().

Use getLength() or std::string::length() rather than strlen(). A BLE payload is just bytes, and it may legitimately contain a {{c4::0x00}} byte, which strlen() would treat as the end of the string.

The callback runs in the {{c5::BLE stack task}}, a separate FreeRTOS task from your Arduino loop(). That has one big consequence: keep it short. No delay(), no long Serial floods, no waiting on sensors.

flowchart LR
    Phone["πŸ“±<br/>Write 0x01"] --> Stack["πŸ“Ά<br/>BLE stack task"]
    Stack --> CB["βš™οΈ<br/>onWrite callback"]
    CB --> Check{"βœ…<br/>Length and value OK?"}
    Check -->|No| Log["πŸ–₯️<br/>Serial log: rejected"]
    Check -->|Yes| Flag["🚩<br/>Set shared flag"]
    Flag --> Loop["πŸ”<br/>loop() reads flag"]
    Loop --> GPIO["πŸ’‘<br/>digitalWrite LED"]

From callback to GPIO, safely

Two tasks touch the same variable, so make that explicit. A volatile int holding "the last command, or -1 for nothing new" is enough for a beginner gadget:

volatile int pendingCommand = -1;

class ControlCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *pCharacteristic) override {
    std::string value = pCharacteristic->getValue();

    if (value.length() != 1) {
      Serial.printf("Rejected: %u bytes\n", (unsigned)value.length());
      return;
    }

    uint8_t cmd = (uint8_t)value[0];
    if (cmd == 0x01 || cmd == 0x00) {
      pendingCommand = cmd;
    } else {
      Serial.printf("Unknown command 0x%02X\n", cmd);
    }
  }
};

And in loop():

int cmd = pendingCommand;
if (cmd != -1) {
  pendingCommand = -1;
  digitalWrite(LED_PIN, cmd == 1 ? HIGH : LOW);
  Serial.printf("LED -> %s\n", cmd == 1 ? "ON" : "OFF");
}

The callback does one thing: validate and record. The loop() does the hardware work. That split keeps the BLE stack responsive even if the rest of your code gets busy.

Worked Examples

Example 1: single-byte command. The phone writes one byte 0x01 to the control characteristic. The stack delivers 1 byte, onWrite checks length() == 1, matches 0x01, sets pendingCommand = 1, and returns. Milliseconds later loop() sees it, calls digitalWrite(LED_PIN, HIGH), and clears the flag. LED on.

Example 2: string command. Your app sends the ASCII text LED:ON, which is 6 bytes: L E D : O N. onWrite sees length() == 6, so the single-byte check fails. Two options: reject it and keep the protocol simple, or add a text branch that compares value == "LED:ON". Pick one protocol and document it on the phone side; do not support both half-heartedly.

Example 3: bad input. The phone writes 3 bytes by mistake. length() is 3, the guard rejects it, a line appears on Serial, and the LED keeps its current state. Nothing crashes, nothing flickers.

Discussion & Reflection Prompts

  1. Why is it better to reject an unknown command loudly on Serial than to guess what the phone meant?
  2. If the phone sends a Write Without Response and the ESP32 is momentarily busy, what could the user see? How would a Write Request change that?
  3. Your gadget has no pairing. What could a stranger with a BLE scanner do to your LED, and does that matter for this project?

Expert Corner

  • Log every write. Printing length and hex bytes is the fastest way to discover that your phone app sends a trailing newline.
  • Reflect state back. After applying a command, call setValue() on a readable state characteristic (or notify it) so the phone can confirm the LED really changed.
  • Descriptor writes are not yours. When the phone subscribes, it writes to the CCCD. The stack handles that; onWrite on your control characteristic will not fire.
  • Common mistake: declaring PROPERTY_WRITE but forgetting setCallbacks(). The write succeeds at the BLE level and your code never hears about it.
  • Common mistake: calling delay() or notify() heavy loops inside onWrite. Move that work to loop().
  • NimBLE note: if you use NimBLE-Arduino instead of the default Bluedroid stack, the callback signature is onWrite(NimBLECharacteristic*) and the value type is NimBLEAttValue. Same logic, slightly different names.

Check your understanding: a Write Request expects an {{c3::ATT Write Response}} from the ESP32, and the value you parse inside the callback comes from {{c1::PROPERTY_WRITE}}-enabled characteristic data read with getValue().

A payload of length 1 containing byte 0x00 is valid, so compare {{c1::length()}} rather than calling strlen(). Because onWrite runs in the {{c2::BLE stack task}}, heavy work belongs in loop().
[
  { "front": "Which property must a characteristic declare to receive a Write Request?", "back": "BLECharacteristic::PROPERTY_WRITE." },
  { "front": "What is the difference between PROPERTY_WRITE and PROPERTY_WRITE_NR?", "back": "WRITE gets an ATT Write Response from the ESP32; WRITE_NR (Write Command) gets no response." },
  { "front": "Which callback fires when the phone writes to a characteristic?", "back": "onWrite(BLECharacteristic *pCharacteristic)." },
  { "front": "How do you read the incoming payload bytes?", "back": "pCharacteristic->getValue() as a std::string, or getData() plus getLength()." },
  { "front": "Why use getLength()/length() instead of strlen() on the payload?", "back": "The payload is raw bytes and may contain 0x00, which strlen() treats as a terminator." },
  { "front": "Which task does onWrite run in?", "back": "The BLE stack task, separate from the Arduino loop() task." },
  { "front": "Why must onWrite return quickly?", "back": "Blocking it stalls the BLE stack and can drop the connection." },
  { "front": "How do you pass a command safely from onWrite to loop()?", "back": "Store it in a shared volatile or atomic variable and act on it in loop()." },
  { "front": "What should happen to a write with the wrong length or an unknown value?", "back": "Reject it, log it to Serial, and leave the output unchanged." },
  { "front": "Who sends the ATT Write Response?", "back": "The BLE stack does it automatically; your code does not." },
  { "front": "Does a CCCD subscription write reach your onWrite callback?", "back": "No, the stack handles descriptor writes itself." },
  { "front": "How can the phone confirm the LED state after a command?", "back": "setValue() on a readable state characteristic, plus notify() if the phone subscribed." },
  { "front": "Which tools can send a raw write while testing?", "back": "Generic BLE apps such as nRF Connect or LightBlue." },
  { "front": "What is the NimBLE equivalent of onWrite?", "back": "onWrite(NimBLECharacteristic*), with the value as a NimBLEAttValue." }
]

Summary & Next Steps

You now have both directions working on paper: notifications push readings out, and a write-enabled control characteristic pulls commands in. The recipe is short. Declare PROPERTY_WRITE, attach callbacks, validate length and value inside onWrite, stash the command in a shared flag, and let loop() touch the hardware.

Next lesson is the Module 2 checkpoint, where you will combine the notify half and the write half into one sketch, then test the full loop from your phone: subscribe, watch a reading arrive, tap a button, and watch the LED respond.