πŸ“± Test Your Gadget with a Phone App

Quick Review

In the last module you built both directions of your gadget's GATT service: a sensor characteristic with PROPERTY_NOTIFY that pushes readings out, and a control characteristic with PROPERTY_WRITE whose onWrite callback receives commands. Both directions work in code. Today you prove they work in the real world, with a phone in your hand.

Learning Objectives

By the end of this lesson you can:

  • Scan for your ESP32 and connect to it from a phone BLE scanner app.
  • Subscribe to the sensor characteristic and watch notifications arrive.
  • Send a text command to the control characteristic and confirm it on the ESP32.
  • Read the Serial Monitor alongside the phone and match events on both sides.

Worked Example: One Complete Test Session

We will use nRF Connect for Mobile (Nordic Semiconductor), which is free on Android and iOS. LightBlue works too, but the icons differ slightly.

flowchart LR
    Scan["πŸ“‘<br/>Scan"] --> Conn["πŸ”—<br/>Connect"]
    Conn --> Sub["πŸ””<br/>Subscribe (CCCD)"]
    Sub --> Watch["πŸ“ˆ<br/>Watch notifications"]
    Watch --> Write["⌨️<br/>Write command"]
    Write --> Serial["πŸ–₯️<br/>Check Serial Monitor"]
1. Flash your gadget and open the Serial Monitor at 115200 baud. You should see the advertising line, for example `Advertising as ESP32_Gadget`.
2. Install nRF Connect, then grant Bluetooth permission. On Android 12+ this is the "Nearby devices" permission; without it the scan returns nothing.
3. Open the SCAN tab. Find your device by its advertised name and note the RSSI value, for example `-52 dBm`.
4. Tap CONNECT. The Serial Monitor should print a connect event with the phone's address.
5. Expand your custom service by its UUID, then expand the sensor characteristic. Tap the three-arrow (subscribe) icon.
6. Watch the value field update every second. This is a notification arriving over BLE.
7. Expand the control characteristic, tap the up-arrow (write) icon, choose **UTF-8** as the format, type `LED_ON`, and send it as **Write** (with response).
8. Confirm on the Serial Monitor that `onWrite` fired and printed your command. Then disconnect and check that the ESP32 starts advertising again.

If all eight steps pass, your gadget is functionally complete. That is a real milestone.

[!NOTE] Subscribe (step 5) works by writing 0x01 0x00 to the CCCD, the Client Characteristic Configuration Descriptor with UUID 0x2902. That write is what triggers your characteristic's onSubscribe callback. Without it, notify() has nowhere to go.

Guided Practice: Run Your Own Test Pass

Do the session yourself, once with the serial monitor open and once without, and record what you see. Write your answers down; you will reuse this log in the capstone demo.

  1. Scan pass. Note the advertised name and RSSI at arm's length and at 5 metres. Which one is closer to 0 dBm?
  2. Subscribe pass. Before subscribing, confirm the value field is empty or static. After subscribing, confirm it changes. This proves the notifications come from your notify() call, not from the app polling.
  3. Command pass. Send three commands: one valid (LED_ON), one valid (LED_OFF), one nonsense (BANANA). Your onWrite callback should handle the unknown case without rebooting.
  4. Reconnect pass. Disconnect, then connect again and re-subscribe. Do notifications resume?
  5. Cross-check pass. If you have a second phone with a different OS, repeat steps 1 to 3. iOS and Android behave differently in the details.

Fill in this log as you go:

Check Expected result Your result
Scan finds device Name visible, RSSI between -30 and -80 dBm
Connect Serial prints client address
Subscribe Notifications start within 1 to 2 seconds
Write LED_ON onWrite prints the command
Disconnect Advertising restarts

Quick self-check with cloze:

  • The characteristic that pushes readings to the phone must have the property {{c1::NOTIFY}}.
  • Tapping subscribe in the app writes to the {{c2::CCCD}} descriptor, UUID {{c3::0x2902}}.
  • A {{c4::Write with response}} waits for the ESP32 to acknowledge; a write without response does not.
  • The ESP32's side of every test is visible in the {{c5::Serial Monitor}} at 115200 baud.
  • Signal strength is reported as {{c6::RSSI}} in dBm, where values closer to 0 are stronger.

Common Mistakes and Troubleshooting

Symptom Likely cause Fix
Device never appears in the scan list Advertising not started, or Bluetooth permission denied Check the Serial Monitor for the advertising line, then re-grant permissions
Connects but the service list is empty Android cached an old GATT table after reflashing Turn phone Bluetooth off and on, or "forget" the device, then rescan
Subscribe icon is greyed out Characteristic has no PROPERTY_NOTIFY Recheck the property flags in your sketch
Subscribed, but no values arrive Not calling setValue() before notify(), or notifying with no delay Set the value, call notify(), then delay(1000)
Command sent, nothing happens Sent as hex instead of UTF-8, or wrote to the wrong characteristic Choose UTF-8 format and confirm you wrote the control characteristic
Values look like garbage Wrong display format in the app Switch between UTF-8, hex, and signed integer views

[!TIP] Use Write with response while testing. It gives you an immediate error if the write is rejected, which is far easier to debug than a silent write without response.

Expert Corner

  • Keep the Serial Monitor open for every test. The phone shows you the client view; the serial log shows you the truth.
  • Test at arm's length first. If it fails there, range is not your problem.
  • Name the device with a version suffix, such as ESP32_Gadget_v2, so you never connect to a stale cached device.
  • Test both write types. Write without response is faster and is what production apps often use, but only after with-response works.
  • Log the handle numbers of both characteristics in your notes. They are handy when a demo goes sideways.

Summary and Next Steps

You scanned, connected, subscribed to notifications, and sent commands from a phone, and you matched every phone-side event to a line in the Serial Monitor. That is the complete two-way loop, verified end to end.

One thing you probably noticed: connections drop. Phones go to sleep, users walk away, and the app is backgrounded. In the next lesson, Handle Disconnects and Common BLE Pitfalls, you will make your gadget survive all of that and come back cleanly.

[
  { "front": "Which free app is the standard BLE scanner on Android?", "back": "nRF Connect for Mobile by Nordic Semiconductor." },
  { "front": "Which app is commonly used as an iOS BLE scanner?", "back": "LightBlue, or nRF Connect for iOS." },
  { "front": "What do you tap to start receiving notifications?", "back": "The subscribe icon (three arrows) on the notify characteristic, which writes to the CCCD." },
  { "front": "Which descriptor enables notifications, and what is its UUID?", "back": "The Client Characteristic Configuration Descriptor (CCCD), UUID 0x2902." },
  { "front": "What happens on the ESP32 when the phone subscribes?", "back": "The CCCD write triggers the characteristic's onSubscribe callback." },
  { "front": "Which write type waits for an acknowledgement from the ESP32?", "back": "Write with response (Write Request)." },
  { "front": "Where do you see the ESP32 side of a test?", "back": "In the Serial Monitor at 115200 baud." },
  { "front": "What does RSSI measure?", "back": "Signal strength in dBm; closer to 0 is stronger, around -70 dBm or better at arm's length." },
  { "front": "Why does Android sometimes show an empty service list after reflashing?", "back": "Android caches the GATT table; toggle Bluetooth or forget the device to refresh it." },
  { "front": "Which permission does Android 12+ need for BLE scanning?", "back": "The Nearby devices permission (BLUETOOTH_SCAN and BLUETOOTH_CONNECT)." },
  { "front": "Why check the subscription flag before calling notify()?", "back": "It confirms a client actually subscribed; notifying otherwise wastes power and can error." },
  { "front": "What form does a written command arrive in on the ESP32?", "back": "Raw bytes; decode them with the value pointer and length." },
  { "front": "Which property must the control characteristic declare to receive commands?", "back": "PROPERTY_WRITE." }
]