Spec: BLE Dead Drop
Overview
This prototype builds a small fixed-location BLE device that acts as a physical message dead-drop: phones connect via Bluetooth Low Energy, deposit encrypted messages for other users, and retrieve messages addressed to them — all without the sender and recipient ever being present at the same time. The relay hardware can be an ESP32 microcontroller (~$5), a Raspberry Pi Zero W (~$15), or a spare Android phone running a background service; the phone app speaks a common GATT wire protocol to any of these. Messages are E2E encrypted on the depositing phone before they reach the relay; the relay stores opaque ciphertext blobs addressed only by a truncated recipient hash and has no ability to read content. Multiple relays deployed across physical locations form a delay-tolerant network without requiring any internet infrastructure. See Prototype 6 on the Research Prototypes page for how this fits into the broader prototype roadmap, and Story 6: The Dead Drop for the human scenario this validates.
API Surface / Key Interfaces
GATT Service Definition
Service UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567890
Characteristic: RELAY_WRITE
UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567891
Properties: WRITE | WRITE_WITHOUT_RESPONSE
Description: deposit a message chunk from phone to relay
Max payload: ATT_MTU - 3 bytes (typically 244 bytes with DLE, 20 bytes without)
Payload: RelayWritePacket (see wire format below)
Characteristic: RELAY_NOTIFY
UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567892
Properties: NOTIFY | READ
Description: relay pushes available message chunks to connected phone
Usage: phone enables notifications, relay streams chunks for requested messages
Payload: RelayRetrieveResponse (see wire format below)
Characteristic: RELAY_CONTROL
UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567893
Properties: WRITE | READ
Description: command channel — query inbox, trigger retrieval, acknowledge receipt
Commands:
LIST_INBOX [0x20, recipient_hash[8]]
→ relay responds via READ: [count:uint16, message_id[0]:uint16, ..., message_id[N]:uint16]
RETRIEVE_MSG [0x21, message_id:uint16]
→ relay begins streaming chunks via RELAY_NOTIFY
ACK_RECEIVED [0x22, message_id:uint16]
→ relay deletes message; responds [0x00] on success, [0x01] if not found
Wire Protocol Packets
// RelayWritePacket — phone deposits a message chunk to relay
struct RelayWritePacket {
uint8_t type; // 0x01=DEPOSIT, 0x02=RETRIEVE_REQ, 0x03=ACK
uint8_t recipient_hash[8]; // first 8 bytes of SHA-256(recipient_feed_id)
uint16_t message_id; // sender-assigned message ID (0–65535)
uint16_t chunk_index; // 0-indexed fragment number
uint16_t total_chunks; // total fragments for this message
uint8_t data[N]; // ciphertext payload (up to ATT_MTU - 12 bytes)
};
// Minimum overhead: 12 bytes. At 20-byte MTU → 8 bytes data/chunk.
// At 244-byte MTU (DLE) → 232 bytes data/chunk.
// 1 KB message @ 232 bytes/chunk = 5 chunks.
// RelayRetrieveResponse — relay pushes a message chunk to phone via NOTIFY
struct RelayRetrieveResponse {
uint8_t type; // 0x10=INBOX_ITEM, 0x11=CHUNK, 0x12=DONE
uint8_t sender_hash[8]; // first 8 bytes of SHA-256(sender_feed_id)
uint16_t message_id;
uint16_t chunk_index;
uint16_t total_chunks;
uint32_t stored_at; // unix timestamp (seconds, little-endian)
uint8_t data[N]; // ciphertext payload (same MTU calculation as above)
};
// type=0x12 (DONE) has data[N] length 0 — signals end of stream for a message.
MTU negotiation: The phone should request a larger MTU immediately after connection (BluetoothGatt.requestMtu(247)). This enables 244-byte data payloads on BLE 4.2+ (DLE) and dramatically reduces chunk count. Relay firmware must handle both 20-byte and 244-byte MTU paths.
Recipient hash rationale: The relay stores messages by recipient_hash[8] (8 bytes) — a truncated SHA-256 of the recipient's feed ID. This prevents the relay from knowing the full identity of recipients while still enabling routing. See Risks & Unknowns for the collision risk analysis.
Phone SDK Interfaces (Kotlin)
// Discovery
object BleRelayClient {
/**
* Scans for BLE relay devices advertising the relay service UUID.
* Calls callback for each discovered device; scan runs until stopScan() or timeout.
*
* @param callback Invoked on the main thread for each found RelayDevice
* @param timeout Stop scan after this duration (default 10s)
*/
fun scan(
callback: (RelayDevice) -> Unit,
timeout: Duration = 10.seconds
)
fun stopScan()
/**
* Opens a GATT connection to the given device.
* Negotiates MTU, discovers services, and returns a ready RelaySession.
*
* @throws RelayConnectionException if GATT connect or service discovery fails
* @throws RelayServiceNotFoundException if device does not expose relay service UUID
*/
suspend fun connect(device: BluetoothDevice): RelaySession
}
data class RelayDevice(
val bluetoothDevice: BluetoothDevice,
val rssi: Int, // signal strength at time of discovery
val deviceName: String? // from advertisement data, may be null
)
// Session — all relay operations go through this interface
interface RelaySession {
/**
* Encrypts plaintext on-device (Tink AEAD) then deposits the ciphertext
* to the relay in chunks via RELAY_WRITE. Returns the relay-side message ID.
*
* @param recipientFeedId Full feed ID of the recipient (hashed internally)
* @param ciphertext Pre-encrypted payload (encrypted by caller before calling)
*/
suspend fun deposit(
recipientFeedId: String,
ciphertext: ByteArray
): Result<MessageId>
/**
* Queries the relay for messages addressed to the given feed ID.
* Returns list of message IDs available for retrieval.
*/
suspend fun listInbox(myFeedId: String): List<MessageId>
/**
* Retrieves a specific message by ID, reassembling chunks from RELAY_NOTIFY.
* Returns the raw ciphertext; caller is responsible for decryption.
*/
suspend fun retrieve(messageId: MessageId): ByteArray
/**
* Sends ACK_RECEIVED to the relay, which deletes the message.
* Call only after successful decryption on the phone side.
*/
suspend fun acknowledge(messageId: MessageId)
fun disconnect()
val isConnected: Boolean
}
@JvmInline value class MessageId(val value: UShort) // 0–65535
Key design decisions:
deposit()takes pre-encrypted ciphertext — the relay never sees plaintext. Encryption (Tink AEAD) is the caller's responsibility before callingdeposit().acknowledge()triggers relay-side deletion. If the phone crashes before ACKing, the message remains on the relay until TTL expiry (72 hours). This is intentional — prefer duplicate delivery over message loss.- All
suspend funcalls run onDispatchers.IO; callers should collect onDispatchers.Main.
Dependencies & Libraries (with versions)
Relay Firmware / Software
ESP32 Path (recommended for hardware prototype)
| Library | Version | Purpose | License |
|---|---|---|---|
| ESP-IDF | ^5.2 | Espressif IoT Development Framework — RTOS, BLE stack, flash drivers | Apache-2.0 |
| Arduino Framework for ESP32 | ^3.0 | Arduino compatibility layer over ESP-IDF (easier prototyping) | LGPL-2.1 |
| NimBLE-Arduino | ^1.4 | Lightweight NimBLE BLE stack — lower RAM use than default ESP32 BLE library | Apache-2.0 |
| ArduinoJson | ^7.0 | JSON serialisation for diagnostic output and config (optional) | MIT |
| LittleFS (ESP32) | built-in | Flash filesystem with wear levelling — persists messages across reboots | Apache-2.0 |
| PlatformIO | ^6.0 | Build system, dependency manager, serial monitor (dev toolchain) | Apache-2.0 |
ESP32 hardware notes: NimBLE-Arduino is preferred over the default Arduino BLE library — it uses ~50% less RAM and has better multi-connection support. The default Arduino ESP32 BLE library (based on Bluedroid) consumes ~100KB of heap; NimBLE consumes ~50KB, which matters on a 520KB RAM device. ESP-IDF direct path (no Arduino layer) gives the most control but requires significantly more boilerplate.
Pi Zero W Path (Linux)
| Library | Version | Purpose | License |
|---|---|---|---|
| Python | 3.11+ | Runtime | PSF |
| bluezero | ^0.8 | Python GATT server/peripheral role over BlueZ D-Bus API | MIT |
| aiosqlite | ^0.20 | Async SQLite bindings — message persistence | MIT |
| cryptography | ^42.0 | SHA-256 for recipient hash computation | Apache-2.0 / BSD |
| systemd (via Debian pkg) | system | Auto-start relay service on boot | LGPL-2.1 |
Alternative (Rust): btleplug ^0.11 (peripheral role via D-Bus) + rusqlite ^0.31 + tokio ^1.0. Better performance; more complex setup on Pi Zero W (cross-compilation needed).
Old Android Phone Path
| Library | Version | Purpose | License |
|---|---|---|---|
| Android BLE GATT Server API | built-in (API 21+) | BluetoothGattServer for peripheral/server role |
Apache-2.0 |
| Room | ^2.6 | Message persistence (SQLite DAO) | Apache-2.0 |
| kotlinx.coroutines | ^1.8 | Async BLE callbacks → coroutine bridges | Apache-2.0 |
Phone App (Common to All Relay Types)
| Library | Version | Purpose | License |
|---|---|---|---|
Android BLE APIs (BluetoothLeScanner, BluetoothGatt) |
built-in (API 21+) | Central role — scan, connect, GATT read/write/notify | Apache-2.0 |
| tink-android | ^1.13 | E2E encryption: AEAD (AES-256-GCM) for message content before deposit | Apache-2.0 |
| Room | ^2.6 | Local message cache — retrieved messages, send queue | Apache-2.0 |
| kotlinx.coroutines-android | ^1.8 | Structured concurrency for BLE operations | Apache-2.0 |
Platform Constraints
ESP32
Flash: 4MB total. ESP-IDF firmware typically occupies 1.5–2MB; OTA partition adds another ~1MB. Leaves ~500KB–1MB for message storage. At 1KB average message size, this allows 50–100 messages in steady state. LittleFS mitigates flash wear; flash is rated for ~100,000 erase cycles per sector. The relay must enforce a hard message limit (e.g. 50 messages) and reject deposits when full.
RAM: 520KB (ESP32 classic) or 520KB + 4MB PSRAM (ESP32 S3 variants). NimBLE stack requires ~50KB. Each active BLE connection needs ~10–20KB for fragmentation buffers. At 520KB total, headroom is tight for >2 simultaneous connections.
BLE version: BLE 4.2 on standard ESP32; BLE 5.0 on ESP32-S3. ESP32-S3 supports Data Length Extension (DLE) with 244-byte payloads — strongly preferred for throughput. Standard ESP32 is limited to 20-byte ATT MTU without DLE, meaning a 1KB message requires 50+ chunks at 8 bytes of payload each.
Simultaneous connections: Most ESP32 variants support 1–3 simultaneous BLE connections. With NimBLE, CONFIG_BT_NIMBLE_MAX_CONNECTIONS defaults to 3 but each connection consumes RAM. For the prototype, limit to 2 concurrent connections and queue additional phones.
Power: ~240mA during active BLE advertising and connection handling; ~10mA in light sleep with BLE scanning paused. A 10,000mAh USB battery bank provides ~40+ hours of continuous operation. USB power input is sufficient; no battery management circuitry required for a stationary relay.
Arduino vs ESP-IDF: Arduino (with NimBLE-Arduino) is recommended for initial prototype — less boilerplate, good NimBLE library support, fast iteration. ESP-IDF directly gives more control over BLE parameter tuning (connection intervals, advertising intervals) but is harder to develop. Start with Arduino, migrate if BLE tuning is needed.
OTA updates: ESP32 supports WiFi-based OTA firmware updates. Useful for field-deployed relays. However, enabling WiFi for OTA also expands the attack surface — disable WiFi at compile time for production relay builds, use USB reflash for firmware updates.
Pi Zero W
Hardware: ARM1176JZF-S (single core, 1 GHz), 512MB RAM, BCM43438 BLE/WiFi combo chip.
BLE: On-board BLE 4.1 via BCM43438. Lower throughput and less stable than ESP32-S3 BLE 5.0. BlueZ (Linux BLE stack) manages the hardware.
BlueZ peripheral role: Running as a BLE peripheral (GATT server) on Linux requires careful configuration. The bluetoothd daemon must be started with the --experimental flag for full GATT server support. The bluezero Python library abstracts the D-Bus API into a usable interface, but D-Bus event loop management (asyncio vs GLib mainloop) can cause subtle bugs. Allocate extra time for BlueZ setup compared to ESP32.
Power: ~120mA idle (Raspbian running, BLE advertising), ~300mA under active BLE transfers. A 2500mAh power bank provides ~8 hours of operation. Needs clean shutdown support — Pi Zero does not handle abrupt power loss gracefully; implement write-ahead logging or use aiosqlite WAL mode to protect SQLite.
Advantages over ESP32: Full Linux environment — proper crypto libraries, SQLite, Python, Rust. No flash storage limits. Far easier to debug (SSH in, read logs). Better choice if concurrent connections >2 are required.
Phone (Android)
BLE central role: Standard BLE central (client) role; no special permissions beyond BLUETOOTH_SCAN, BLUETOOTH_CONNECT (API 31+) and ACCESS_FINE_LOCATION (required for BLE scan on API 28).
Background scan: Android allows background BLE scan filtered by service UUID (ScanFilter with serviceUuid). This enables the phone app to opportunistically connect to known relays in the background. On Android 12+, foreground service of type connectedDevice is required for sustained background BLE operations.
iOS: iOS can connect to custom BLE GATT peripherals in the foreground without restrictions. Background scanning for specific service UUIDs is allowed with CBCentralManagerScanOptionAllowDuplicatesKey = false. Background transfer time is limited — long retrieve operations (>30s) should be resumed when the app returns to foreground.
BLE range: Typical indoor range to a stationary relay: 10–30m depending on obstructions. Because the relay is fixed, range is more predictable than phone-to-phone BLE. ESP32 with external antenna can extend range to ~50m line-of-sight.
Build & Test Instructions
ESP32 Path
# Prerequisites: PlatformIO Core (or PlatformIO IDE extension in VS Code)
pip install platformio # or install via VS Code extension
# 1. Create PlatformIO project
mkdir ble-relay-esp32 && cd ble-relay-esp32
pio project init --board esp32dev
# 2. platformio.ini — key config
# [env:esp32dev]
# platform = espressif32
# board = esp32dev # use 'esp32-s3-devkitc-1' for S3 variant (BLE 5.0 + DLE)
# framework = arduino
# lib_deps =
# h2zero/NimBLE-Arduino@^1.4
# bblanchon/ArduinoJson@^7.0
# monitor_speed = 115200
# 3. Build firmware
pio run
# 4. Flash to ESP32 via USB serial
pio run --target upload --upload-port /dev/ttyUSB0
# macOS: /dev/cu.usbserial-XXXX
# Windows: COM3 (check Device Manager)
# 5. Monitor serial output (relay logs startup, connections, deposits)
pio device monitor --baud 115200
# 6. Verify GATT service is advertising
# On macOS: open Bluetooth → LightBlue app → scan → look for service UUID A1B2C3D4...
# On Android: use nRF Connect app → scan → find relay device → inspect GATT services
# 7. Run unit tests (PlatformIO Unity test framework)
pio test --environment native # native tests: chunking logic, hash routing, TTL expiry
Pi Zero W Path
# On Pi Zero W running Raspbian Bookworm Lite
sudo apt update && sudo apt install -y python3-pip python3-venv bluetooth bluez
# Enable experimental BlueZ features (required for GATT server)
sudo sed -i 's|ExecStart=/usr/lib/bluetooth/bluetoothd|ExecStart=/usr/lib/bluetooth/bluetoothd --experimental|' \
/lib/systemd/system/bluetooth.service
sudo systemctl daemon-reload && sudo systemctl restart bluetooth
# Install Python deps
python3 -m venv .venv && source .venv/bin/activate
pip install bluezero aiosqlite cryptography
# Run relay server (foreground for testing)
python relay_server.py
# Install as systemd service for auto-start on boot
sudo cp relay.service /etc/systemd/system/
sudo systemctl enable relay.service
sudo systemctl start relay.service
sudo journalctl -u relay.service -f # follow logs
# Run unit tests
pytest tests/ -v
Old Android Phone Path
# Build phone-as-relay APK
./gradlew :relay-app:assembleDebug
# Install on old phone
adb install relay-app/build/outputs/apk/debug/relay-app-debug.apk
# Grant permissions (BLE advertise requires BLUETOOTH_ADVERTISE on API 31+)
adb shell pm grant com.example.blerelay android.permission.BLUETOOTH_ADVERTISE
# Verify advertising with nRF Connect on a second phone
# Scan → should see service UUID A1B2C3D4-E5F6-7890-ABCD-EF1234567890
Phone App
# Build debug APK
./gradlew assembleDebug
# Install on test phone
adb install app/build/outputs/apk/debug/app-debug.apk
# Run unit tests (JVM — no device needed)
./gradlew test
# Run instrumented tests (requires connected device)
./gradlew connectedAndroidTest
Test Matrix
| Test type | What is tested | Tool | Pass criteria |
|---|---|---|---|
| Unit — chunking/reassembly | Split 1KB, 5KB, 50KB payloads into chunks at various MTU sizes; reassemble; verify byte-for-byte equality | JUnit 5 / pytest | All round-trips produce identical output; zero data corruption |
| Unit — recipient hash routing | Deposit 10 messages for 3 different recipient hashes; LIST_INBOX returns correct IDs per recipient | JUnit 5 / pytest | Each inbox contains exactly the expected message IDs |
| Unit — TTL expiry | Insert messages with timestamps 0h, 48h, 73h ago; run TTL sweep; verify only 73h message deleted | JUnit 5 / pytest | Exactly 1 deletion; 0h and 48h messages intact |
| Integration — power cycle persistence | Deposit 3 messages → power off relay → power on → LIST_INBOX returns all 3 | Manual / pytest (Pi path) | All 3 messages retrievable after reboot |
| Integration — two-phone dead drop | Phone A deposits message for Phone B → Phone A leaves BLE range → Phone B connects → retrieves message → ACKs | Two physical phones + relay hardware | Message content matches; relay deletes after ACK |
| Integration — simultaneous connections | Phones A and B connect concurrently; A deposits while B retrieves; no data corruption | Two phones + relay | Both operations complete successfully; no interleaving errors |
| Performance — deposit throughput | Measure wall-clock time to deposit 1KB message at 20-byte MTU and at 244-byte MTU (S3 with DLE) | Stopwatch / logcat timestamps | Results documented in findings/throughput.md |
| Performance — range | Measure RSSI and successful GATT connection rate at 5m, 15m, 30m with obstacles | nRF Connect + manual testing | Results documented in findings/range.md |
| Security — no plaintext on relay | After deposit, read raw LittleFS/SQLite storage; verify no message plaintext present | strings on flash dump / SQLite inspect |
Zero plaintext matches for test message strings |
Success Criteria
The prototype is "done" when all of the following are true:
- Phone discovers relay device within 5 seconds of initiating a BLE scan, measured from
startScan()to firstonScanResult()callback with the relay's service UUID. - GATT connection established and MTU negotiated within 3 seconds of
connect()call, measured toonServicesDiscovered()callback with all three characteristics present. - Deposit 1 KB of ciphertext in under 5 seconds end-to-end (phone calls
deposit()to relay confirms receipt), measured at 244-byte MTU on ESP32-S3 or Pi Zero W. - Retrieve 1 KB of ciphertext in under 5 seconds (phone calls
retrieve()to last chunk received via NOTIFY), same MTU conditions. - Messages persist through relay power cycle — deposit messages, power cycle relay hardware, reconnect, LIST_INBOX returns original message IDs. Tested 5 consecutive times with zero data-loss events.
- Messages deleted after explicit ACK — after
acknowledge(), LIST_INBOX no longer contains that message ID. Verified immediately after ACK. - Messages deleted after 72-hour TTL — insert test messages with backdated timestamps; TTL sweep removes them; no orphan entries in storage. Tested in unit tests with synthetic timestamps.
- Relay handles at least 2 simultaneous phone connections — both phones can deposit and retrieve concurrently without errors or data corruption.
- ESP32 + USB battery bank runs for >8 hours of continuous BLE advertising and intermittent connections (measured with USB power meter logging current draw over time).
- No plaintext stored on relay — after a full deposit/retrieve session, raw flash dump (ESP32) or SQLite file (Pi/Android) contains zero instances of test message plaintext strings.
- Findings documented —
findings/throughput.mdwith deposit/retrieve times at both MTU sizes,findings/range.mdwith RSSI data at 5m/15m/30m, committed to the repository.
Risks & Unknowns
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| ESP32 simultaneous connection limit (1–2 max on classic ESP32) | High | Medium | Use ESP32-S3 (supports up to 5 connections with NimBLE) or Pi Zero W for any deployment needing >2 concurrent phones. Queue phones when relay is at connection limit — advertise a "relay busy" service data flag. |
| ESP32 flash wear from frequent small writes | Medium | Medium | Use LittleFS (built-in wear levelling). Batch writes where possible. At 50 messages × 1KB, each 4KB sector handles ~2000 write cycles before wear; at prototype scale (low write frequency) this is not a practical concern. |
| BlueZ peripheral role on Pi Zero W is notoriously difficult | High | High | Allocate 2–3 extra days for Pi path vs ESP32. bluezero abstracts the worst of D-Bus but GLib/asyncio event loop conflicts are a known pain point. Consider Rust + btleplug as an alternative if Python path stalls. |
| iOS BLE central background time limit | Medium | Medium | iOS allows background GATT connections but limits transfer time. Large messages (>50 chunks) may need foreground completion. Document iOS limitation clearly; deprioritise iOS for prototype, focus on Android. |
recipient_hash[8] collision (8 bytes = 64-bit truncated hash) |
Low | High | Probability of collision with N users ≈ N²/2³³. With <1000 users on a relay, collision probability is negligible (<0.01%). For production, expand to 16 bytes. For prototype, 8 bytes is acceptable and documented. |
| Message authentication — relay cannot verify depositor identity | Medium | Low | By design: relay stores opaque encrypted blobs and does not authenticate senders. Spam/flooding risk exists. Mitigations: rate-limit deposits per connection (e.g. max 10 messages per BLE session), enforce max message size (e.g. 4KB), require no authentication at relay layer. Out-of-band reputation is the production answer. |
| Physical seizure of relay hardware exposes stored ciphertext | Medium | High | Messages are E2E encrypted on phone before deposit. Seized relay hardware yields only ciphertext. Relay hardware should have no keys. TTL (72h) limits exposure window. Note in deployment docs that relay is not a sensitive asset — it holds only opaque blobs. |
| ESP32 WiFi-based OTA as attack vector | Low | High | Disable WiFi entirely at compile time (CONFIG_ESP32_WIFI_ENABLED=n) for field deployments. OTA via USB serial only. This eliminates the OTA attack surface entirely at the cost of requiring physical access for firmware updates. |
| BLE range insufficient for deployment scenario | Medium | Medium | Standard ESP32 PCB antenna reaches 10–20m indoors. Add an external 2dBi antenna (U.FL connector on most ESP32 dev boards) for 20–40m range. Measure actual range in test environment before committing to placement. |
| LittleFS corruption on abrupt power loss (ESP32) | Low | High | LittleFS has journaling; designed to survive power loss without full corruption. Enable LittleFS.begin(true) with format-on-fail to recover gracefully. Test power-loss recovery explicitly in integration tests. |
2-Week Day-by-Day Build Plan
| Day | Goal | Deliverable | Dependencies |
|---|---|---|---|
| 1 | Choose hardware path. Recommended: ESP32-S3 DevKit (BLE 5.0 + DLE) for hardware prototype, or skip to Day 9 if using old Android phone as relay. Set up PlatformIO (ESP32) or Raspbian + BlueZ (Pi). | Dev environment working; firmware compiles and flashes to board; serial monitor shows boot messages. | ESP32-S3 board, USB cable, PlatformIO installed |
| 2 | ESP32: BLE GATT server boots, advertises relay service UUID. Phone (nRF Connect app) can discover device and see service. | GATT server with three characteristic stubs registered; device visible in BLE scan with correct service UUID. | Day 1 |
| 3 | RELAY_WRITE characteristic working — phone writes bytes, ESP32 receives and logs to serial. | Any BluetoothGatt.writeCharacteristic() from phone is received and printed via Serial.println() on ESP32. |
Day 2 |
| 4 | RELAY_NOTIFY characteristic working — ESP32 pushes bytes to phone on demand. | Enable notifications from phone; send test payload from ESP32; phone receives and logs. | Day 2 |
| 5 | Message framing — implement RelayWritePacket and RelayRetrieveResponse structs. Chunking/reassembly on both sides. |
Unit test: deposit a 2KB payload → relay receives all chunks in order → relay reassembles to original bytes. | Days 3, 4 |
| 6 | LittleFS storage — persist reassembled messages to flash. Survive reboot. | Power cycle ESP32 after deposit → serial shows stored message IDs on boot. | Day 5 |
| 7 | Recipient hash routing — recipient_hash[8] computed on phone, embedded in packet, used by relay to route to correct inbox. LIST_INBOX via RELAY_CONTROL returns correct IDs. |
Two inboxes with different recipient hashes; LIST_INBOX returns only the correct IDs for each. | Days 5, 6 |
| 8 | TTL expiry — relay deletes messages older than 72 hours. ACK_RECEIVED deletes immediately. | Unit test with synthetic timestamps: 73h-old message deleted; 48h-old message retained. ACK test: message gone after ACK command. | Day 6 |
| 9 | Phone app deposit flow — Tink AEAD encrypt on phone → chunk → write to relay via RELAY_WRITE. BleRelayClient.scan() + connect() + deposit() implemented. |
Phone app scans, connects to relay, deposits a test ciphertext. Relay serial log shows received chunks and stores message. | Days 5, 7 |
| 10 | Phone app retrieve flow — listInbox() → retrieve() (reassemble NOTIFY chunks) → acknowledge(). Decryption on phone after retrieve. |
Phone retrieves and decrypts a message deposited in Day 9. Content matches original. | Days 8, 9 |
| 11 | Two-phone dead drop test — Phone A deposits message for Phone B. Phone A disconnects. Phone B connects and retrieves without Phone A present. | Message content on Phone B matches what Phone A deposited. Relay deletes after ACK. | Two physical phones + relay hardware, Day 10 |
| 12 | Range and throughput measurements — deposit and retrieve at 5m, 15m, 30m. Measure time at 20-byte MTU (standard ESP32) vs 244-byte MTU (S3 with DLE). | findings/throughput.md with KB/s figures at each MTU. findings/range.md with RSSI and success rate at each distance. |
Day 11 |
| 13 | Power consumption measurement — attach USB power meter; log current draw over 8-hour period of BLE advertising with intermittent connections. Flash-storage no-plaintext verification. | Power log CSV committed to findings/power.md. SQLite/LittleFS dump verified free of plaintext test strings. |
Day 11 |
| 14 | Bug fixes from Days 11–13. Document comparison to Prototype 5 (WiFi relay): latency, power, concurrency, cost, complexity. Tag v0.1.0. |
All success criteria checked and recorded. findings/p6-vs-p5.md with comparison table. Prototype tagged. |
Day 13 |