11 KiB
Firmware Bluetooth
Purpose
The XIAO nRF52840 firmware exposes a local Bluetooth Low Energy administration service for the Flutter application.
This service is used to:
- synchronize the firmware clock;
- open the lock through the normal lock-control path;
- add or remove stored six-digit access codes;
- enroll, name, update, or remove stored NFC tags;
- read the current stored configuration and seven-day opening history;
- provision named administrator and guest phone identities.
Before phone synchronization, the clock starts at 2026-06-01T00:00:00Z, or
at the newest persisted opening timestamp after a reboot.
Security
Pairing is restricted to BLE LE Secure Connections with legacy pairing disabled. The headless XIAO uses encrypted Security Mode 1 Level 2 pairing; Security Mode 1 Level 4 is not requested because it requires a passkey display, input, or out-of-band confirmation that the box does not provide. BLE AES-CCM link encryption protects every GATT command, state read, and notification, and Zephyr persists bonding keys. The separate random application identity remains mandatory for command authorization.
At startup, the controller is enabled first, the Zephyr bt/* settings subtree
is then restored, and advertising starts only after the Bluetooth identity has
made the host stack ready. Reversing these last two operations causes Zephyr to
reject advertising with -EAGAIN (-11).
This complete sequence runs in a dedicated thread after local keypad initialization. Bluetooth failure or slow bond restoration therefore cannot prevent offline keypad access or lock operation.
The command, state, and CCC attributes require encrypted GATT access. On Android, the application explicitly creates the BLE bond immediately after the connection and before MTU negotiation, service discovery, state reads, or command writes. The firmware does not force a security upgrade from the connection callback; this avoids racing Android's bonding state machine before the application can remove stale bonds and control the pairing flow.
The firmware supports an ATT MTU of 247 bytes with matching 251-byte ACL buffers. This provides a 244-byte GATT payload, allowing provisioning and other JSON commands to remain atomic writes with responses. The default Zephyr MTU of 65 bytes only provides a 62-byte payload and cannot carry the administrator provisioning document.
Authenticated state can exceed the 512-byte maximum GATT attribute value. The
mobile application therefore sends read_state_page commands and reassembles a
frozen JSON snapshot from successive 480-byte characteristic values before
decoding it. This keeps complete code, NFC-tag, history, and guest lists
available without truncating the JSON document.
Connectable advertising stops when Zephyr allocates the only connection object.
The firmware restarts advertising from the connection recycled callback after
every disconnect, including failed initial pairing, so the box remains
discoverable for another attempt.
The firmware keeps up to four phone bonds and allows Zephyr to replace the oldest unused bond when the bond store is full. This prevents a stale Android bond, for example after reinstalling the app or clearing phone-side Bluetooth pairing data, from permanently blocking a legitimate phone while command access still requires the separate application identity key.
When no administrator identity exists yet, the firmware clears stored BLE bonds after loading Bluetooth settings and before advertising. This keeps stale phone pairing keys from blocking the first administrator registration with an Android system-level invalid key or passcode error.
Dragging a UF2 firmware image onto the XIAO bootloader drive updates the application image only. It does not erase the Zephyr Settings/NVS records that hold codes, tags, history, box settings, application identities, or Bluetooth bonds. This prevents a simple USB firmware update from becoming an easy third-party factory reset path.
If the administrator identity is lost, the box can still clear its stored app
identities from an internal reset button wired between GND and XIAO
GPIO1 pin 11 (P1.11). The firmware configures this input with an internal
pull-up, so the button is active low. Holding this internal button for three seconds clears
administrator and guest identities from NVS, asks the BLE stack to clear stored
bonds, and blinks the blue LED for a few seconds. Access codes, NFC tags,
opening history, and the box name are preserved. The external keypad must not
provide this reset path because it is accessible to third parties.
Each phone also owns a random 128-bit identity key stored in its secure
keystore. The first phone can send provision_admin only while no administrator
exists. Afterwards every command requires a valid administrator or guest
identity_key. Until the connection authenticates, state reads expose only the
box name and admin_exists; codes, tags, identities, and history remain hidden.
The box never returns the administrator key. After administrator
authentication, it returns guest names and guest keys so the administrator can
redisplay or back up invitation QR codes. A guest never receives identity
or NFC-tag records, but does receive the global access-code lists and opening
history.
An administrator can register a named guest using a random key generated by the administrator application and transferred through a versioned invitation QR code.
GATT Service
Device name:
OpenParcelBox
The static name is intentionally retained while the per-board naming path is being isolated from the validated startup sequence.
Service UUID:
f2a00000-8e7a-4f8d-9b1d-7d8e4b7a0001
Characteristics:
| Characteristic | UUID | Properties |
|---|---|---|
| Command | f2a00001-8e7a-4f8d-9b1d-7d8e4b7a0001 |
Write |
| State | f2a00002-8e7a-4f8d-9b1d-7d8e4b7a0001 |
Read, Notify |
Command Format
Commands are UTF-8 JSON objects written to the command characteristic.
Authenticated clients retrieve state pages with:
{
"command": "read_state_page",
"identity_key": "<32 hexadecimal characters>",
"offset": 0
}
Offset zero creates a consistent snapshot. The client increments offset by
the number of bytes read and stops after a page shorter than 480 bytes. Direct
unauthenticated reads remain available for the compact registration state.
Except for initial administrator provisioning, each command includes
identity_key.
Initial administrator provisioning also stores the box name:
{
"command": "provision_admin",
"identity_key": "<32 hexadecimal characters>",
"name": "Administrator",
"box_name": "Front gate"
}
An existing phone authenticates immediately after reconnecting:
{
"command": "authenticate",
"identity_key": "<32 hexadecimal characters>"
}
Clock synchronization:
{
"command": "sync_clock",
"iso_utc": "2026-07-15T17:04:00.000Z",
"unix_ms": 1784135040000,
"timezone_offset_minutes": 120
}
Open the lock:
{
"command": "open_lock"
}
Add a permanent code:
{
"command": "add_code",
"code": "123456",
"kind": "permanent",
"exclusive": 1
}
Add a one-time code:
{
"command": "add_code",
"code": "654321",
"kind": "one_time",
"exclusive": 1
}
exclusive: 1 rejects the command if the six-digit value already belongs to
either kind. Generated mobile codes always use this mode, so creating a code
can never silently change an existing code's kind.
Replace a permanent code atomically:
{
"command": "replace_code",
"old_code": "123456",
"new_code": "654321",
"kind": "permanent"
}
Only administrators can replace permanent codes. If new_code currently
belongs to a one-time code, the old permanent slot is removed and that existing
code is converted to permanent in one persistent update.
Remove a code:
{
"command": "remove_code",
"code": "123456"
}
Administrators can add or remove either code kind. Guests can add and remove
one-time codes, but the firmware rejects every guest attempt to add or remove a
permanent code, or to use replace_code. The limits of eight permanent and
twenty temporary codes are global to the box, not per application identity.
Add or remove an NFC tag UID:
{
"command": "add_nfc_tag",
"uid": "60:4F:E2:B5",
"name": "Alice"
}
{
"command": "remove_nfc_tag",
"uid": "60:4F:E2:B5"
}
Add or revoke a guest:
{
"command": "add_guest",
"name": "Alice",
"guest_key": "<32 hexadecimal characters>"
}
{
"command": "remove_guest",
"guest_key": "<32 hexadecimal characters>"
}
An administrator can restore all persistent tables to their defaults and clear every BLE bond:
{
"command": "factory_reset"
}
State Format
The state characteristic returns UTF-8 JSON.
Example:
{
"box_name": "Front gate",
"role": "administrator",
"clock_synced": true,
"unix_ms": 1784135045000,
"codes": [
{
"slot": 0,
"code": "784512",
"kind": "permanent"
}
],
"nfc_tags": [
{
"slot": 0,
"uid": "60:4F:E2:B5",
"name": "Alice"
}
],
"nfc_enrollment": {
"active": false,
"uid": "",
"error": ""
},
"history": [
{
"unix_ms": 1784135045000,
"kind": 2,
"actor": "Alice"
}
],
"guests": [
{
"name": "Delivery team",
"key": "<32 hexadecimal characters>"
}
],
"admin_exists": true
}
The firmware sends a compact notification when state changes independently of
the mobile command flow, for example after local keypad use or an internal
identity reset. The application reads state explicitly after its own successful
commands, avoiding duplicate concurrent synchronization.
Authenticated guests receive codes and history, with empty nfc_tags and
guests arrays. Administrators receive every collection.
Access Code Behavior
Access codes remain six-digit numeric strings.
The firmware stores up to eight permanent codes and twenty temporary codes. Permanent codes remain active until removed. One-time codes are removed automatically after the first successful keypad use.
An older persistent code table without code-kind metadata is migrated at boot; existing codes become permanent codes.
Runtime Logs
Successful openings are persisted with a timestamp and source: permanent code, temporary code, named NFC tag, or named mobile identity. Events older than seven days are removed and the newest 64 events are retained.
NFC enrollment is started with:
{
"command": "start_nfc_enrollment",
"identity_key": "<32 hexadecimal characters>"
}
The PN532 scans immediately for 15 seconds. Authenticated state exposes
nfc_enrollment.active, then either a new uid or an error (timeout,
already_exists, or reader_unavailable). The application asks for a name
only after receiving a new UID, then stores it with the regular add_nfc_tag
command. cancel_nfc_enrollment stops an active scan and returns the reader to
power-down mode. All enrollment commands remain administrator-only.
When the phone has synchronized the clock, lock-opening logs include a Unix timestamp in milliseconds.
Example:
Lock open requested source=keypad timestamp_ms=1784135045000
Before clock synchronization, the same log is emitted with:
timestamp_ms=unsynced