Files
OpenParcelBox/docs/firmware/bluetooth.md
T

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