This document defines the binary communication protocol used between the Python host application and the Arduino-based peripheral device over BLE. It includes packet structure, opcodes, payload formats, and usage notes.
The protocol is designed to be compact, binary, and easy to parse on resource-limited devices. It uses a framed packet format with a fixed header and variable payload. Communication is unidirectional per opcode group:
- 0x01–0x7F → Python commands sent to Arduino
- 0x80–0xFF → Arduino events sent to Python
This version of the protocol uses no acknowledgments, as BLE handles transport-level reliability.
Both GATT characteristics require an encrypted, MITM-authenticated link:
- TX (notify) —
ESP_GATT_PERM_READ_ENC_MITM - RX (write) —
ESP_GATT_PERM_WRITE_ENC_MITM
Pairing flow on first connect:
- Host (Windows app) initiates pairing through the OS BLE stack.
- Device displays a 6-digit passkey on its OLED.
- User types the passkey into the OS pairing prompt.
- Bond is stored in NVS and reused on subsequent connects.
Without an active bond, GATT writes are rejected by the controller before the application layer sees them. This blocks unauthenticated centrals from sending LOCK_DEVICE, CHANGE_PROFILE, or RGB commands.
| Item | Value |
|---|---|
| Device name | BLEDeck |
| Service UUID | 4FAFC201-1FB5-459E-8FCC-C5C9C331914B |
| TX characteristic (Device → PC, NOTIFY) | BEB5483E-36E1-4688-B7F5-EA07361B26A8 |
| RX characteristic (PC → Device, WRITE) | EAB5483E-36E1-4688-B7F5-EA07361B26A9 |
TX uses notify (device pushes events). RX uses write-without-response (PC pushes commands).
All packets follow this structure:
+-----------+-----------+--------------+------------------+
| START(1B) | OPCODE(1B)| LENGTH(2B) | PAYLOAD (0..n B) |
+-----------+-----------+--------------+------------------+
| Field | Size | Description |
|---|---|---|
| START | 1 byte | Always 0xAA (frame sync marker) |
| OPCODE | 1 byte | Operation identifier |
| LENGTH | 2 bytes | Payload length in bytes, big-endian |
| PAYLOAD | variable | Operation-specific data |
| Opcode | Name | Description |
|---|---|---|
0x01 |
Keep Alive | Ping packet used to keep BLE connection active |
0x02 |
Change Profile | Notifies device of active profile (index + name) |
0x03 |
Sync Profiles | Sends a dictionary of available profiles |
0x04 |
Set RGB Key | Sets RGBW value for a single key |
0x05 |
Set All RGB Keys | Sets RGBW values for all 16 keys at once |
0x06 |
Lock / Unlock Device | Locks or unlocks device functionality |
0x07 |
Hello | Protocol-version handshake sent immediately after notify subscription |
| Opcode | Name | Description |
|---|---|---|
0x81 |
Keep Alive Reply | Arduino's response to keep-alive ping |
0x82 |
Profile Changed | Arduino notifies that the user switched profile |
0x83 |
Button Pressed | Arduino notifies that a button was pressed |
0x84 |
Key Pressed | Arduino notifies that a keypad key was pressed |
0x85 |
Battery Status | Arduino reports current battery level (sent after each ADC reading, ~every 30 s) |
0x86 |
Device Telemetry | Arduino replies to HELLO with firmware/protocol version + runtime stats |
(No lock event exists; locking is one-way as requested.)
This section defines the payload layout for each opcode.
Payload: none
Packet:
AA 01 00 00
Notifies the device that the active profile has changed and provides its display name.
+------------------+
| profile_index 1B |
| name_length 1B |
| name ... |
+------------------+
profile_index- integer 1..255name_length- length of profile name in bytesname- UTF-8 encoded string
RGB colors are pushed separately via
SET_ALL_RGB_KEYS(0x05) following the profile switch — that is the actual connection bootstrap sequence.
Sends a dictionary of index → name entries.
+------------------+
| count 1B |
| index0 1B |
| name0_len 1B |
| name0 ... |
| index1 1B |
| name1_len 1B |
| name1 ... |
| ... |
+------------------+
count- number of profile entries- For each entry:
index- profile indexname_len- name lengthname- profile name (UTF-8)
Sets the RGBW value for a single key.
+--------------------+
| key_index 1B |
| R 1B | G 1B | B 1B |
| W 1B |
+--------------------+
key_index- which key (0–15)R,G,B,W- color values (0–255)
Sets RGBW values for all 16 keys at once. This is typically sent when switching profiles to update all LEDs simultaneously.
+--------------------+
| key0 R G B W |
| key1 R G B W |
| key2 R G B W |
| ... |
| key15 R G B W |
+--------------------+
- 16 × RGBW values (64 bytes total)
- Each RGBW: R(1B), G(1B), B(1B), W(1B)
- Keys are in order from 0 to 15
Locks or unlocks device functionality.
+----------------+
| lock_flag 1B |
+----------------+
0x01= lock0x00= unlock
No event/acknowledgment is generated.
Example (lock):
AA 06 00 01 01
Protocol-version handshake. Sent by the host immediately after it finishes subscribing to the TX notify characteristic, before any other command is issued.
+----------------------+
| protocol_version 1B |
| app_version_len 1B |
| app_version ... |
+----------------------+
protocol_version- currently0x01(seePROTOCOL_VERSIONconstant inwindows_app/ble_protocol.pyandfirmware/src/protocolparser.h)app_version_len- length of the host application version string (bytes)app_version- UTF-8 encoded semver string, e.g."0.2.3"
Upon receipt the firmware replies with OP_DEVICE_TELEMETRY (0x86) carrying its own protocol version, firmware version, and runtime stats. The host compares protocol_version values: a mismatch surfaces a CRITICAL entry in the debug log and a warning in the status bar, then continues at the host's risk.
PROTOCOL_VERSION is bumped on any breaking change to packet framing or payload layout. Additive changes (new opcode, additional trailing field) do not bump the version. Both sides must stay in lockstep — if you change one, change the other.
Example (host v0.2.3, protocol_version=1):
AA 07 00 07 01 05 30 2E 32 2E 33
Payload: none
Packet:
AA 81 00 00
Sent when the user switches profile on the device.
+------------------+
| new_profile 1B |
+------------------+
Sent when a button (CON, BACK, PUSH) is pressed on the device.
+------------------+
| profile_index 1B |
| name_length 1B |
| button_name ... |
+------------------+
profile_index- current active profile indexname_length- length of button namebutton_name- button identifier string (e.g., "CON", "BACK", "PUSH")
Sent when a keypad key (0-9, A-F) is pressed on the device.
+------------------+
| profile_index 1B |
| key 1B |
+------------------+
profile_index- current active profile indexkey- ASCII value of the key pressed (0-9, A-F)
Sent automatically after each ADC battery reading (~every 30 s) while a host is connected.
+-----------+
| percent 1B|
+-----------+
percent- battery level0–100(integer percentage), or0xFF(255) when no battery is detected (e.g. device running on USB without a LiPo cell)
Example (72 % battery):
AA 85 00 01 48
Example (USB / no battery):
AA 85 00 01 FF
Reply to OP_HELLO (0x07). Carries the firmware's protocol version, firmware version string, and runtime statistics. Sent once per handshake; not periodic.
+------------------------+
| protocol_version 1B |
| fw_version_len 1B |
| firmware_version ... |
| uptime_ms 4B | (big-endian uint32)
| reset_reason 1B |
| free_heap 4B | (big-endian uint32)
| ble_error_count 2B | (big-endian uint16)
+------------------------+
protocol_version- device-sidePROTOCOL_VERSIONconstant; compared against the host's value fromOP_HELLO. Mismatch ⇒ host logs a CRITICAL warning.fw_version_len- length offirmware_versionin bytes.firmware_version- UTF-8 semver string (e.g."1.2.3"), fromfirmware/src/version.h.uptime_ms- milliseconds since boot, big-endian uint32. Wraps at ~49.7 days.reset_reason- byte mirroringesp_reset_reason():0= unknown1= power-on3= software reset5= deep-sleep wake6= brownout8= task watchdog9= interrupt watchdog- (full set: ESP-IDF
esp_reset_reason_tdocs)
free_heap- free heap in bytes at the moment of telemetry, big-endian uint32.ble_error_count- count of malformed packets, send failures, and oversize-payload rejections since boot, big-endian uint16. A sudden spike points to radio interference or app/firmware-version drift — investigate before suspecting other layers.
Example (fw 1.2.3, uptime 5000 ms, reset_reason=1 power-on, free_heap=200000, ble_error_count=0):
AA 86 00 12 01 05 31 2E 32 2E 33 00 00 13 88 01 00 03 0D 40 00 00
- All numbers are unsigned.
- All multi-byte values are big-endian.
- Strings use UTF-8 encoding.
- BLE characteristics should be configured for binary (not hex or text) transfer.
- No checksums are required since BLE guarantees delivery and integrity. Application-layer recovery relies on the
0xAAstart byte; the parser silently discards any bytes received outside a valid frame. PROTOCOL_VERSION(currently1) is the single-byte version constant exchanged inOP_HELLO/OP_DEVICE_TELEMETRY. Bump policy: increment on any breaking change to packet framing or payload layout. Additive changes (new opcode, new trailing field on an existing opcode) do not bump the version. The constant is defined in bothwindows_app/ble_protocol.pyandfirmware/src/protocolparser.h— keep them in lockstep.
- Opcodes below
0x80are commands. - Opcodes above
0x80are events. 0xAAis used as a constant start byte for easy packet framing.- Variable-length strings always use
[length][data...]format. - The protocol is designed to be extensible for new features.
This document describes Protocol Version 1 (PROTOCOL_VERSION = 1). Version negotiation is implemented via OP_HELLO (0x07) → OP_DEVICE_TELEMETRY (0x86); see §4.7 and §5.6. See §6 for bump policy.
This specification may be shared and published publicly.