A value a phone can write
A characteristic the phone can write to, and the twenty bytes that actually arrive when you send thirty. The link drops the rest and reports success.
Twenty bytes, and no error
onWrite() as bytes, not as a string — getValue() hands you a buffer, and it is your job to decide whether "on" and "ON" and "1" all mean the same thing.BLE's default ATT payload is 23 bytes, three of which are header. Send more than
twenty and the extra never leaves the phone — the write completes, onWrite
fires, and your callback receives a truncated command it has no way to know was
truncated.
Two ways out. Negotiate a bigger MTU (BLEDevice::setMTU(517), and the client
has to agree, which iOS and Android both will) — or keep the protocol small
enough that it never comes up. Short commands and a separate notify for results
is the design that never has this problem.
The callback is not loop()
onWrite runs on the BLE stack's own task, with a small stack and a deadline.
Anything slow there — a Serial.print at 9600, an HTTP request, a delay —
either blocks the radio or crashes it.
Take a copy of the bytes, set a flag, return. The pattern is exactly the one an interrupt handler uses, and for exactly the same reason.
Bytes, not strings
getValue() returns a buffer and a length. It is worth working in those terms
rather than converting to a String immediately, because the length is the one
piece of information that tells you the truncation above happened.
It also forces the question a command protocol needs answered anyway: is on
the same as ON the same as 1? Decide once, in one place, rather than in
three if statements that disagree.
Nothing here is authenticated
A writable characteristic on an advertising board is a control anybody within range can operate. BLE has pairing and bonding for this and it is genuinely fiddly. If the write turns on a lamp, ignore the problem. If it opens a door, do not.
The code
The callback fires on the BLE task, not in loop. Treat it the way you would an interrupt — take a copy, set a flag, and do the work somewhere else.
#include <BLEDevice.h>
#define SVC "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define CHR "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
volatile bool haveCmd = false;
char cmd[64];
class OnWrite : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) override {
std::string v = c->getValue();
size_t n = min(v.size(), sizeof(cmd) - 1);
memcpy(cmd, v.data(), n);
cmd[n] = 0;
haveCmd = true; // the work happens in loop()
}
};
void setup() {
Serial.begin(115200);
pinMode(2, OUTPUT);
BLEDevice::init("tinkerblock-ctl");
BLEServer *s = BLEDevice::createServer();
BLEService *svc = s->createService(SVC);
svc->createCharacteristic(CHR,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR)
->setCallbacks(new OnWrite());
svc->start();
s->getAdvertising()->start();
}
void loop() {
if (!haveCmd) return;
haveCmd = false;
Serial.printf("got %u bytes: %s\n", strlen(cmd), cmd);
if (!strcmp(cmd, "on")) digitalWrite(2, HIGH);
if (!strcmp(cmd, "off")) digitalWrite(2, LOW);
}getValue() hands you bytes with a length. Comparing them against a String works and hides the length, which is exactly the information you need when a command arrives truncated.
written() waits for the next write and returns the bytes. Because it is a coroutine, the waiting costs nothing and the handling happens on the normal task.
import aioble, bluetooth, asyncio
from machine import Pin
SVC = bluetooth.UUID("6e400001-b5a3-f393-e0a9-e50e24dcca9e")
CHR = bluetooth.UUID("6e400002-b5a3-f393-e0a9-e50e24dcca9e")
led = Pin(2, Pin.OUT)
service = aioble.Service(SVC)
ctl = aioble.Characteristic(service, CHR, write=True, capture=True, max_len=512)
aioble.register_services(service)
async def handle():
while True:
_conn, data = await ctl.written()
cmd = bytes(data).decode().strip()
print("got", len(data), "bytes:", cmd)
if cmd == "on":
led.on()
elif cmd == "off":
led.off()
asyncio.run(handle())The 512-byte characteristic buffer is the maximum the characteristic will store, not what one packet can carry. Those are two different limits and only the second one silently truncates.
When it does not work
The default ATT payload is twenty bytes, and anything past that is dropped by the link rather than by your code. onWrite still fires and the write is reported as successful. Negotiate a larger MTU on both ends, or keep commands short.
The callback runs on the BLE stack's task with a small stack, and anything slow or blocking there will fault. Copy the bytes, set a flag, and act in loop.
Two write types exist. WRITE expects a response, WRITE_NR does not; a client using one against a characteristic that only offers the other fails silently. Declare both properties and neither client is wrong.
The characteristic stores the last value written, so a read after a write returns it. If that is confusing, do not add PROPERTY_READ to a command characteristic — it has no meaning here.
The older, heavier radio, and the reason it is still worth knowing about: it is the one that gives you a plain serial port to a laptop.
Classic Bluetooth serial →Edit this page — content/esp32/ble-write-from-a-phone.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.