Deep Dive: Giao tiếp BLE cho thiết bị IoT
Hướng dẫn thực tế để thiết kế giao tiếp BLE ổn định cho thiết bị IoT: GATT, packet, MTU, notify, reconnect, bảo mật và tối ưu pin với ESP32 NimBLE.
Chia sẻ

BLE rất phổ biến trong cảm biến, khóa cửa, thiết bị đeo và các món IoT chạy bằng pin. Nhưng nhiều hướng dẫn chỉ dừng ở mức “điện thoại đọc được Hello World”. Đến lúc gửi dữ liệu thật, mất sóng giữa chừng hoặc nâng firmware, mọi thứ bắt đầu rối.
Bài này là cách mình thường dựng một đường giao tiếp BLE nhỏ nhưng đủ dùng cho sản phẩm. Ví dụ xuyên suốt là một cảm biến ESP32 dùng NimBLE, gửi nhiệt độ và pin lên điện thoại, đồng thời nhận cấu hình và lệnh điều khiển. Mình không đi vào BLE Mesh, beacon hay LE Audio.

BLE chạy được trong demo khá nhanh; phần tốn thời gian thật sự là làm cho protocol vẫn rõ ràng khi mất kết nối, đổi firmware hoặc thêm loại dữ liệu mới.
Hai lớp cần phân biệt: GAP và GATT
Với một cảm biến thông thường:
- GAP là việc xuất hiện và kết nối: advertising, scan, địa chỉ, connection parameters.
- GATT định nghĩa dữ liệu sau khi kết nối: service, characteristic, read, write, notify.
- ESP32 thường là peripheral + GATT server; điện thoại là central + GATT client. Đây là cách bố trí phổ biến, không phải quy luật bắt buộc.
Flow đầy đủ nên được nhìn như một state machine:
IDLE -> ADVERTISING -> CONNECTED -> DISCOVERED -> SUBSCRIBED -> READY
^ | |
+-------------------------+---- DISCONNECTED <-----+
Chỉ báo “connected” chưa có nghĩa là ứng dụng đã sẵn sàng. Client còn phải discover đúng service, tìm characteristic và bật notification qua CCCD. Mình chỉ chuyển sang READY sau khi các bước đó hoàn tất.
Thiết kế GATT nhỏ và có chủ đích
Đừng biến một characteristic thành “serial port không dây” rồi nhét mọi thứ vào chuỗi JSON. Với cảm biến nhỏ, mình thường bắt đầu bằng một custom service và ba characteristic:
| Characteristic | Property | Mục đích |
|---|---|---|
status | Read + Notify | Nhiệt độ, độ ẩm, pin, cờ lỗi |
command | Write | Lệnh tức thời như đo ngay, bật relay |
config | Read + Write | Chu kỳ đo, ngưỡng cảnh báo, chế độ |
Thêm standard service như Battery Service hoặc Device Information khi dữ liệu đúng với ý nghĩa chuẩn. Với dữ liệu riêng, dùng UUID 128-bit do dự án tự sinh và lưu tập trung trong một file; đừng rải literal UUID khắp callback.

Mình thường chốt data model trên giấy trước khi viết UUID. Ba đường status, command và config đã đủ cho phần lớn thiết bị IoT nhỏ.
Chọn thao tác theo ý nghĩa dữ liệu
- Read: lấy snapshot hoặc đọc cấu hình ít thay đổi.
- Write with response: Hợp với config và lệnh actuator vì client biết server đã nhận ở tầng ATT.
- Write without response: ít overhead hơn, nhưng ứng dụng phải tự pacing; không nên dùng cho lệnh quan trọng chỉ vì nó nhanh.
- Notify: server đẩy telemetry, không có ACK ở tầng ATT.
- Indicate: có confirmation ở tầng ATT, chậm hơn; dùng cho sự kiện hiếm cần biết peer đã nhận.
Theo Bluetooth Core GATT, notification không chờ xác nhận, còn indication có confirmation. Dù vậy, ACK ở tầng radio không nói rằng business logic đã được thực thi. Với lệnh như “mở khóa”, mình vẫn trả về một command_result có transaction ID.
Packet nên tự mô tả vừa đủ
Chuỗi JSON dễ nhìn lúc demo nhưng tốn byte, parse chậm và dễ sinh nhiều biến thể. Với telemetry đều đặn, một packet nhị phân versioned rõ ràng hơn:
byte 0 protocol_version = 1
byte 1..2 sequence uint16, little-endian
byte 3..4 temperature_c_x100 int16, little-endian
byte 5..6 humidity_pct_x100 uint16, little-endian
byte 7 battery_pct uint8
byte 8 flags bit field
Ba quy tắc mình luôn ghi vào tài liệu protocol:
- Có version ngay từ đầu. Client cũ phải từ chối hoặc bỏ qua version không hiểu.
- Chốt byte order và scale.
2534nghĩa là25.34 °C; không truyềnfloattheo layout của compiler. - Có sequence/transaction ID. Nó giúp thấy packet bị bỏ qua, bị nhận trùng hoặc response thuộc lệnh nào.
BLE link đã có kiểm tra lỗi ở các tầng thấp, nên không cần tự thêm CRC vào mọi payload. CRC riêng chỉ đáng cân nhắc khi packet còn được lưu, được chuyển qua transport khác hoặc cần kiểm tra end-to-end ngoài BLE.
MTU: đừng hard-code 20 byte rồi quên nó
ATT MTU mặc định của BLE là 23 byte. Với notification thông thường, opcode và attribute handle chiếm 3 byte, nên payload characteristic còn 20 byte. Khi hai bên exchange MTU lớn hơn, giới hạn payload tương ứng thường là ATT_MTU - 3.
Nhưng MTU lớn không tự động đồng nghĩa với throughput cao. Data Length, PHY, connection interval, số packet trong mỗi connection event và buffer của stack đều tham gia. Vì vậy:
- Packet ngắn như status 9 byte: gửi nguyên packet, đừng tối ưu sớm.
- Message dài: chia ở application layer với
message_id,chunk_index,chunk_countvà length. - Luôn kiểm tra MTU đã negotiate trên connection hiện tại; đừng lấy kết quả của điện thoại A làm mặc định cho mọi client.
- Đặt giới hạn queue. Khi client chậm, nên drop telemetry cũ hoặc gộp mẫu thay vì ăn hết RAM.
Một flow server đủ chắc để bắt đầu
Đây là pseudo-code C++ theo cách tổ chức của NimBLE-Arduino, cố tình bỏ bớt API phụ để thấy lifecycle:
void onConnect() {
connected = true;
subscribed = false;
}
void onSubscribe(bool enabled) {
subscribed = enabled;
if (enabled) publishStatus();
}
void onCommand(const uint8_t* data, size_t len) {
Command cmd;
if (!decodeAndValidate(data, len, cmd)) return;
if (seenTransaction(cmd.txnId)) return resendLastResult(cmd.txnId);
CommandResult result = execute(cmd);
remember(result);
notifyCommandResult(result);
}
void onDisconnect() {
connected = false;
subscribed = false;
startAdvertising();
}
Điểm quan trọng không nằm ở tên hàm. Lệnh được validate trước khi chạy, transaction trùng không chạy actuator lần hai, và trạng thái subscribe bị xóa khi connection kết thúc.
Reconnect mới là bài test thật
Một demo tốt thường hỏng khi mình đi điện thoại ra khỏi phòng rồi quay lại. Client nên có chiến lược rõ:
- Dừng mọi thao tác GATT đang pending khi nhận disconnect.
- Backoff trước khi scan lại, ví dụ tăng từ 500 ms lên vài giây và có jitter.
- Lọc theo service UUID, không chỉ dựa vào tên thiết bị có thể trùng hoặc đổi.
- Sau khi connect, discover/validate lại GATT database và bật lại subscription khi cần.
- Chỉ phát lệnh khi state đã là
READY.
Nếu firmware thay đổi GATT table, cache phía điện thoại có thể làm bạn thấy characteristic cũ. Trong giai đoạn phát triển, hãy đổi firmware revision, xử lý Service Changed đúng cách, hoặc xóa bond/cache khi debug. Tắt/bật Bluetooth ngẫu nhiên nhiều lần không phải là một reconnect strategy.
Security: pairing không thay cho authorization
Ví dụ NimBLE_Security của Espressif có private address, encryption, bonding và passkey. Mình dùng nó để kiểm tra flow bảo mật trước khi đưa vào service thật.
Baseline thực tế:
- Telemetry công khai, ít nhạy cảm có thể chỉ cần kết nối; config và actuator nên yêu cầu encrypted link.
- “Just Works” có thể mã hóa nhưng không chống MITM như phương thức xác thực phù hợp có passkey/numeric comparison.
- Bonding lưu khóa để lần sau reconnect mượt hơn; cần có cách xóa bond khi chuyển chủ thiết bị.
- Validate range, quyền và state của mọi command. Một link đã mã hóa vẫn có thể gửi giá trị vô lý.
- Không nhét token, serial nhạy cảm hoặc thông tin riêng vào advertising packet.
Tối ưu pin bằng cách giảm số lần radio thức
Điểm mình ưu tiên không phải “payload ít hơn một byte”, mà là radio thức bao nhiêu lần:
- Advertising nhanh trong khoảng onboarding ngắn, sau đó chuyển sang interval chậm hơn.
- Không notify mỗi lần sensor được sample nếu app chỉ cần cập nhật mỗi 10 giây; hãy gộp hoặc chỉ gửi khi thay đổi đủ lớn.
- Chọn connection interval/latency theo UX thật, rồi đo trên cả Android và iOS thay vì ép một con số.
- Tắt scan liên tục ở central khi không cần.
- Đo current profile qua đủ các pha: advertising, connect, burst, idle và reconnect thất bại.
ESP-IDF có example nimble/power_save dựa trên bleprph; đây là mốc tốt để so sánh trước và sau khi bật modem sleep.

Đừng đo pin khi board chỉ nằm yên. Hãy đo cả advertising, lúc kết nối, lúc gửi burst và những lần reconnect thất bại.
Checklist debug mình hay dùng
- Dùng app GATT Scanner để kiểm tra advertise, service, read/write và subscribe trước khi debug app riêng.
- Log state transition, disconnect reason, negotiated MTU và subscribe event; đừng chỉ log “BLE error”.
- Test packet sai version, thiếu byte, command trùng và giá trị ngoài range.
- Tắt nguồn peripheral giữa một write; đi ra khỏi vùng phủ sóng khi đang notify; reboot điện thoại khi đã bond.
- Chạy soak test vài giờ với reconnect lặp lại và theo dõi heap/queue.
- Bắt đầu 1 connection. Chỉ tăng multi-connection sau khi RAM và lifecycle đã được đo.
Cấu hình khởi đầu mình sẽ chọn
Cho một cảm biến IoT chạy pin, mình sẽ bắt đầu với NimBLE, một custom service, status dạng Read + Notify, command Write with response, config Read + Write có encryption, packet nhị phân có version/sequence và reconnect state machine có backoff. Giữ payload dưới 20 byte ở bản đầu cũng là một lựa chọn rất dễ sống.
BLE không khó ở chuyện “gửi được byte”. Phần khó là quy ước byte đó có nghĩa gì, khi nào được gửi lại và hệ thống phục hồi thế nào sau lỗi. Chốt ba chuyện ấy trước, phần code sẽ gọn hơn đáng kể.
Nguồn tham khảo
Chia sẻ
Tiếp tục khám phá
Đọc tiếp
Bài viết liên quan
Xu hướng IoT Security: Một baseline thực tế cho thiết bị kết nối
Hướng dẫn bảo mật IoT thực tế cho ESP32 và thiết bị kết nối, từ threat model, danh tính riêng, Secure Boot đến OTA an toàn, mã hóa dữ liệu và kiểm thử.
Radar mmWave: Phát hiện chuyển động, cử chỉ và sự hiện diện
Ghi chú thực tế để phân biệt motion, gesture và presence detection, chọn đúng module radar mmWave và làm một prototype ESP32 dễ hiệu chỉnh.
Smart thermostat tự học cách căn phòng ấm lên
Ghi chú làm smart thermostat bằng ESP32, ESPHome và Home Assistant, có lớp tốc độ làm ấm để bật đúng lúc mà vẫn giữ giới hạn an toàn.