Spec: Local WiFi Relay Server

Status: Draft v0.1 — 2026-04-23


1. Overview

This document is the full technical specification for Prototype 5: Local WiFi Relay Server, whose plain-language motivation is told in Story 5: The Community Space. The prototype replaces phone-to-phone peer discovery with a small dedicated server: a Raspberry Pi boots, creates its own WiFi hotspot (no internet connection required), and runs a message relay daemon. Phones in range connect to the Pi's WiFi network and communicate through the relay. Every message is end-to-end encrypted on the sender's device before it leaves; the relay stores and forwards opaque ciphertext blobs it cannot read. The server is a dumb mailbox with labelled slots — it knows who a parcel is addressed to (a public-key hash) and when it arrived, but the contents are sealed. Optional feed-replication endpoints let phones also exchange signed social-feed envelopes through the same server, using the format defined in the Feed Format Spec.


2. API Surface / Key Interfaces

2.1 HTTP REST Endpoints

All endpoints run on the Pi at http://192.168.4.1:8080 (or https:// with a self-signed cert — see Section 4.2). Requests and responses are JSON. Authentication uses a Bearer token returned at registration.


POST /register

Register a new identity on this relay. Called once per device per relay. The server assigns a stable feed_id derived from the public key and issues a session token.

Request body

{
  "pubkey_ed25519": "<base64url-encoded 32-byte Ed25519 public key>",
  "pubkey_x25519": "<base64url-encoded 32-byte X25519 public key>"
}

Response 200 OK

{
  "feed_id": "<base64url — SHA-256(pubkey_ed25519) truncated to 16 bytes>",
  "token":   "<JWT-like session token — see Section 2.3>",
  "expires_at": 1745500000
}

Error codes

Code Meaning
400 Malformed request (missing or invalid fields)
409 feed_id already registered (same pubkey, second registration) — returns existing token
422 Public key fails length / encoding validation

Notes: The server does not validate the Ed25519 key against a CA or any external authority. feed_id is public and can be shared with contacts. The server stores both public keys to allow senders to address messages; it never holds private keys.


POST /messages/send

Send an encrypted message to another registered feed. The server stores the ciphertext and nonce without inspecting them.

Auth: Authorization: Bearer <token>

Request body

{
  "recipient_feed_id": "<base64url>",
  "ciphertext":        "<base64url — XChaCha20-Poly1305 AEAD output>",
  "nonce":             "<base64url — 24-byte nonce>"
}

The ciphertext is produced by the sender's phone using the shared secret derived from an X25519 DH between the sender's private key and the recipient's pubkey_x25519. The server receives only the opaque bytes.

Response 201 Created

{
  "message_id":  "<UUID v4>",
  "stored_at":   1745490000
}

Error codes

Code Meaning
401 Missing or expired token
404 recipient_feed_id not registered on this relay
413 Ciphertext exceeds 64 KB limit
429 Rate limit exceeded (max 60 sends per token per minute)

GET /messages/inbox

Retrieve all pending messages addressed to the authenticated feed. The client is responsible for decryption. After pickup, messages should be deleted with DELETE /messages/{id}.

Auth: Authorization: Bearer <token>

Response 200 OK

[
  {
    "message_id":      "<UUID v4>",
    "sender_feed_id":  "<base64url>",
    "ciphertext":      "<base64url>",
    "nonce":           "<base64url>",
    "stored_at":       1745490000
  }
]

Empty array [] when inbox is empty.

Query parameters

Parameter Type Default Description
since unix timestamp 0 Return only messages stored after this time
limit int 100 Max messages per response

Error codes

Code Meaning
401 Missing or expired token

DELETE /messages/{message_id}

Delete a message after the recipient has picked it up and successfully decrypted it. This is the expected flow — inbox is not automatically cleared.

Auth: Authorization: Bearer <token>

Response 204 No Content

Error codes

Code Meaning
401 Missing or expired token
403 Message does not belong to this feed
404 Message not found (already deleted or never existed)

POST /feeds/publish (optional — social feed replication)

Publish a signed feed envelope (per the Feed Format Spec) to the relay for distribution to other clients. The server stores the raw envelope JSON string without parsing it; it is the sender's responsibility to produce a valid, signed envelope.

Auth: Authorization: Bearer <token>

Request body

{
  "envelope_json": "<string — the full signed envelope JSON from the feed-format spec>"
}

Response 201 Created

{
  "sequence": 42,
  "stored_at": 1745490000
}

Error codes

Code Meaning
400 envelope_json is not valid JSON
401 Missing or expired token
413 Envelope exceeds 128 KB
409 Duplicate sequence number for this feed

GET /feeds/{feed_id}/since/{sequence}

Fetch all envelopes published by feed_id with sequence number greater than {sequence}. Used for delta sync — the client passes the last sequence it has locally.

Response 200 OK

[
  "<envelope_json string>",
  "<envelope_json string>"
]

Ordered by ascending sequence number. Empty array when the client is up to date.

Error codes

Code Meaning
404 feed_id unknown on this relay
416 sequence out of range

2.2 WebSocket: ws://192.168.4.1:8080/stream

A persistent WebSocket connection for push notifications. The client authenticates by sending its token as the first message after the handshake opens.

Auth handshake (client → server)

{ "type": "auth", "token": "<Bearer token>" }

Server response on success

{ "type": "auth_ok", "feed_id": "<base64url>" }

Push event: new message

When a message arrives in the authenticated feed's inbox, the server pushes:

{
  "type":          "new_message",
  "message_id":    "<UUID v4>",
  "sender_feed_id": "<base64url>",
  "stored_at":     1745490000
}

The push event does not include the ciphertext — the client must call GET /messages/inbox (or GET /messages/inbox?since=<ts>) to retrieve the payload. This keeps the WebSocket channel lightweight and avoids re-implementing message delivery over two paths.

Keepalive: Server sends {"type":"ping"} every 30 seconds; client should respond {"type":"pong"}. Connections idle for more than 90 seconds without a pong are closed.


2.3 Session Token Format

The token is a compact signed payload, not a full JWT (no JOSE overhead). Structure:

base64url( feed_id || expires_at_uint32_be || nonce_8 ) || "." || base64url( HMAC-SHA256(server_secret, above) )

Clients present the full opaque string as a Bearer token. The server verifies the HMAC and checks expiry. There is no token refresh endpoint in v0.1 — clients re-register when the token expires.


2.4 Phone App SDK Interfaces

These are the public interface contracts the phone-side SDK must expose, regardless of underlying HTTP/WebSocket implementation.

// Kotlin (Android) — iOS mirror in Swift uses async/await equivalents

interface RelayClient {

    /**
     * Register this device's key bundle with the relay.
     * Generates a session token stored locally; call once per relay.
     */
    suspend fun register(myKeys: KeyBundle): SessionToken

    /**
     * Encrypt [plaintext] for [recipientFeedId] and send to relay.
     * Returns the relay-assigned message ID on success.
     * Queues locally and retries if relay is unreachable.
     */
    suspend fun sendMessage(
        recipientFeedId: FeedId,
        plaintext: ByteArray
    ): MessageId

    /**
     * Poll inbox once. Returns list of encrypted messages.
     * Caller is responsible for decryption and calling deleteMessage().
     */
    suspend fun pollInbox(): List<EncryptedMessage>

    /**
     * Open a WebSocket and invoke [callback] for each new push event.
     * Reconnects automatically on network drop.
     */
    fun subscribeInbox(callback: (EncryptedMessage) -> Unit): Subscription

    /**
     * Delete a message from the relay after successful decryption.
     */
    suspend fun deleteMessage(messageId: MessageId)

    /**
     * Publish a signed feed envelope for social feed replication.
     */
    suspend fun publishFeedEnvelope(envelopeJson: String)

    /**
     * Fetch feed envelopes from [feedId] newer than [sinceSequence].
     */
    suspend fun fetchFeedSince(feedId: FeedId, sinceSequence: Int): List<String>
}

data class KeyBundle(
    val ed25519PublicKey: ByteArray,   // 32 bytes
    val ed25519PrivateKey: ByteArray,  // 64 bytes (seed+public)
    val x25519PublicKey: ByteArray,    // 32 bytes
    val x25519PrivateKey: ByteArray    // 32 bytes
)

data class EncryptedMessage(
    val messageId: String,
    val senderFeedId: String,
    val ciphertext: ByteArray,
    val nonce: ByteArray,
    val storedAt: Long
)

3. Dependencies & Libraries

3.1 Server (Raspberry Pi, Raspbian Bookworm, ARM64)

Recommended language: Go 1.22+

Rationale: Go compiles to a single static binary with no runtime dependency, cross-compiles trivially from a dev machine to ARM64 (GOARCH=arm64 GOOS=linux), and net/http plus gorilla/websocket are enough for the entire server. The pure-Go SQLite driver avoids CGo, which simplifies cross-compilation further. Python is faster to prototype but slower at runtime and requires a venv on the Pi; Rust is excellent but longer to compile (especially cross-compile) and higher initial complexity for limited benefit at this scale.

Package Version Purpose
go 1.22+ Language runtime + stdlib (net/http, crypto/hmac, encoding/base64)
gorilla/websocket ^1.5 WebSocket server (github.com/gorilla/websocket)
modernc.org/sqlite ^1.29 Pure-Go SQLite driver — no CGo, single binary cross-compile (modernc.org/sqlite)
google/uuid ^1.6 UUID v4 generation (github.com/google/uuid)
golang.org/x/crypto latest chacha20poly1305, ed25519, curve25519
hostapd 2.10 (apt) WiFi access point daemon (system package)
dnsmasq 2.89 (apt) DHCP server for hotspot clients (system package)
systemd OS default Service management (auto-start, restart on failure)

Python alternative (if faster iteration is needed)

Package Version Purpose
python 3.12+ Runtime
fastapi ^0.111 HTTP framework
uvicorn ^0.30 ASGI server
aiosqlite ^0.20 Async SQLite
python-jose[cryptography] ^3.3 Token generation/validation
PyNaCl ^1.5 libsodium bindings (key validation)

3.2 Phone App

Android

Library Version Purpose
ktor-client-android ^2.3 HTTP client (KMP-ready, coroutine-native; preferred over OkHttp for KMP compatibility)
ktor-client-websockets ^2.3 WebSocket support
tink-android ^1.13 E2E encryption — XChaCha20-Poly1305 AEAD, X25519 key agreement (com.google.crypto.tink:tink-android)
androidx.room ^2.6 Local message cache + outbound queue (androidx.room:room-ktx)
kotlinx-coroutines-android ^1.8 Async / suspend
androidx.security:security-crypto ^1.1.0-alpha06 Android Keystore-backed key storage

iOS

Library Version Purpose
URLSession iOS 13+ (built-in) HTTP client
URLSessionWebSocketTask iOS 13+ (built-in) WebSocket client
CryptoKit iOS 13+ (built-in) Curve25519, ChaCha20-Poly1305, HMAC (Apple's hardware-backed crypto)
SwiftData / CoreData iOS 17+ / iOS 3+ (built-in) Local message cache

iOS uses only system frameworks — no external dependencies required, which eliminates supply-chain risk and simplifies App Store review.


4. Platform Constraints

4.1 Server Side (Raspberry Pi 4 / 5)

Hardware

WiFi hotspot setup

hostapd runs in AP mode on wlan0. The Pi issues DHCP leases on the 192.168.4.0/24 subnet via dnsmasq. If an ethernet uplink (eth0) is available, iptables NAT bridges it to the hotspot — but no internet uplink is required. The relay is fully air-gapped.

Phones (192.168.4.2–254)
        │  WiFi 802.11ac
        ▼
Pi wlan0 (192.168.4.1)   ← relay server listens here
        │  (optional)
        ▼
Pi eth0 → internet uplink (if present)

SD card wear

SQLite write-ahead logging (WAL mode) reduces random writes. For high-throughput scenarios consider a USB SSD or a tmpfs mount for the SQLite WAL file. Messages older than 72 h are purged by a background goroutine running every 15 minutes, capping database growth.

TLS / HTTPS

Two options with different trade-offs (see also Section 7 — Risks):

Option Trade-off
Plain HTTP on 192.168.4.1:8080 Zero setup friction. Acceptable on a local air-gapped WiFi network the operator controls. No certificate trust issues on phones. Vulnerable to passive eavesdropping by other phones on the same hotspot.
HTTPS with self-signed cert Encrypts traffic between phone and relay. Both Android and iOS reject self-signed certs by default — requires either a custom trust store in the app, certificate pinning, or user-visible trust prompt. Adds implementation complexity.

v0.1 recommendation: Use plain HTTP for the prototype, with a clear note that production deployments should use HTTPS (possibly via a small local CA or mDNS + Let's Encrypt over a temporary internet uplink).


4.2 Phone Side

Android — "No internet" WiFi problem

When a phone connects to a WiFi network without an internet uplink (which is always the case for the Pi's hotspot), Android automatically routes traffic over mobile data and ignores the WiFi network. This breaks the relay entirely.

Two mitigations:

  1. User instruction: Disable "Switch to mobile data when WiFi has no internet" in Android WiFi settings. Simple but requires manual user action.
  2. NetworkRequest API (programmatic): Request the specific WiFi network in the app:
val networkRequest = NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
    .build()

connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
    override fun onAvailable(network: Network) {
        // Bind all HTTP traffic to this network
        connectivityManager.bindProcessToNetwork(network)
        // OR use OkHttpClient / Ktor with a custom socket factory bound to `network`
    }
})

The NetworkRequest approach is the right long-term solution — it works without user settings changes and is available from Android 5.0+.

iOS — Joining the hotspot

iOS shows a system prompt asking the user to confirm joining a WiFi network without internet. The NEHotspotConfiguration API can automate this:

let config = NEHotspotConfiguration(ssid: "connect-relay", passphrase: "passphrase", isWEP: false)
config.joinOnce = false  // persist across sessions

NEHotspotConfigurationManager.shared.apply(config) { error in
    if let error { /* handle */ }
}

Requires the com.apple.developer.networking.HotspotConfiguration entitlement. iOS 11+.

No BLE dependency

WiFi has none of BLE's background restrictions on either platform. The phone app requires no special permissions beyond ACCESS_WIFI_STATE and CHANGE_WIFI_STATE on Android; no special capability on iOS beyond the hotspot entitlement.

Offline queue

The phone app must queue outbound messages in Room / CoreData when the relay is unreachable and retry on reconnect. Retry strategy: exponential backoff starting at 2 s, cap at 60 s, unlimited retries until the message is delivered or the user is outside WiFi range.


5. Build & Test Instructions

5.1 Server Setup (Raspberry Pi)

# 1. Install OS packages (Raspbian Bookworm)
sudo apt update && sudo apt install -y hostapd dnsmasq git

# 2. Configure hostapd
sudo tee /etc/hostapd/hostapd.conf > /dev/null <<'EOF'
interface=wlan0
driver=nl80211
ssid=connect-relay
hw_mode=g
channel=6
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=changeme1234
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
EOF
echo 'DAEMON_CONF="/etc/hostapd/hostapd.conf"' | sudo tee -a /etc/default/hostapd

# 3. Configure dnsmasq
sudo tee /etc/dnsmasq.conf > /dev/null <<'EOF'
interface=wlan0
dhcp-range=192.168.4.2,192.168.4.254,255.255.255.0,24h
EOF

# 4. Assign static IP to wlan0
sudo tee -a /etc/dhcpcd.conf > /dev/null <<'EOF'
interface wlan0
    static ip_address=192.168.4.1/24
    nohook wpa_supplicant
EOF

# 5. Enable IP forwarding (optional — only needed for internet uplink bridging)
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

# 6. Enable and start services
sudo systemctl unmask hostapd
sudo systemctl enable hostapd dnsmasq
sudo systemctl start hostapd dnsmasq

Build and deploy the relay server (Go)

# Cross-compile from macOS/Linux dev machine:
GOARCH=arm64 GOOS=linux go build -o relay-arm64 ./cmd/relay

# Copy to Pi:
scp relay-arm64 pi@raspberrypi.local:/usr/local/bin/relay

# On the Pi — install systemd unit:
sudo tee /etc/systemd/system/relay.service > /dev/null <<'EOF'
[Unit]
Description=Connect Relay Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/relay --db /var/lib/relay/messages.db --port 8080
Restart=always
RestartSec=5
User=relay
WorkingDirectory=/var/lib/relay

[Install]
WantedBy=multi-user.target
EOF

sudo useradd -r -s /bin/false relay
sudo mkdir -p /var/lib/relay && sudo chown relay:relay /var/lib/relay
sudo systemctl daemon-reload
sudo systemctl enable relay && sudo systemctl start relay

# Verify:
sudo systemctl status relay

Smoke test the server

# Register a fake identity:
curl -s -X POST http://192.168.4.1:8080/register \
  -H "Content-Type: application/json" \
  -d '{"pubkey_ed25519":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","pubkey_x25519":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}' \
  | jq .

# Expected: { "feed_id": "...", "token": "...", "expires_at": ... }

# Check inbox (with token from previous step):
curl -s http://192.168.4.1:8080/messages/inbox \
  -H "Authorization: Bearer <token>" | jq .

5.2 Phone App Build

Android

# Clone and build:
./gradlew assembleDebug

# Install on connected device:
adb install app/build/outputs/apk/debug/app-debug.apk

# Run unit tests:
./gradlew testDebugUnitTest

# Run instrumented tests (device connected):
./gradlew connectedDebugAndroidTest

iOS

# Open in Xcode:
open ConnectRelay.xcodeproj

# Build + run on simulator:
xcodebuild -scheme ConnectRelay -sdk iphonesimulator -destination \
  'platform=iOS Simulator,name=iPhone 15' build

# Run unit tests:
xcodebuild test -scheme ConnectRelayTests -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 15'

5.3 Test Matrix

Layer Test Pass Criterion
Unit E2E encrypt/decrypt roundtrip Phone A encrypts; relay stores ciphertext; Phone B decrypts original plaintext
Unit Message TTL expiry Messages inserted 73 h ago absent from GET /messages/inbox; background cleanup deletes from DB
Unit Token validation Tampered HMAC → 401; expired timestamp → 401
Unit Rate limiting 61st send in 60 s → 429
Integration Two-phone message delivery Phone A registers, Phone B registers, A sends to B, B polls inbox, message delivered, B decrypts OK
Integration Relay survives reboot Insert messages, reboot Pi, GET /messages/inbox returns same messages
Integration WebSocket push A sends message to B; B's open WebSocket receives new_message event within 2 s
Integration Offline queue Relay unreachable; Phone A queues message locally; relay restarts; message delivered within retry window
Integration Feed replication A publishes 5 envelopes; B calls GET /feeds/{a_feed_id}/since/0; receives all 5
Security Server cannot decrypt Tamper single byte of ciphertext; client decryption → AEAD authentication failure (Poly1305 tag mismatch)
Security Cross-feed access Phone A calls DELETE /messages/{id_belonging_to_B}403
Load 20 concurrent phones 20 devices simultaneously sending 100 messages each; no 5xx errors; p99 latency < 2 s
Measurement Boot-to-ready time From Pi power-on to first successful POST /register < 90 s
Measurement WiFi range Successful message exchange at 20 m without external antenna; 50 m with USB WiFi dongle

6. Success Criteria

  1. Pi powers on, hostapd creates the WiFi hotspot, and the relay service starts and accepts connections — all within 90 seconds of power-on.
  2. A phone discovers the hotspot in the WiFi list, connects, and completes POST /register in under 10 seconds from the moment the user taps "Connect."
  3. A message sent by Phone A is available in Phone B's GET /messages/inbox within 2 seconds, both phones connected simultaneously.
  4. A message sent to an offline recipient persists in the relay and is delivered when the recipient reconnects, up to 72 hours after sending.
  5. The relay cannot decrypt any stored message — verified by the AEAD tamper test: modified ciphertext produces a client-side decryption error, not corrupted plaintext.
  6. The relay handles 20 simultaneous phone connections with 100 messages each with zero server errors and p99 latency under 2 seconds.
  7. The phone app queues messages locally when the relay is unreachable and delivers them automatically on reconnect, with no user action required.
  8. A phone connected to the Pi's hotspot can exchange messages with another phone at a minimum 20 metres from the Pi without an external antenna, and 50 metres with a USB WiFi dongle with an external antenna.

7. Risks & Unknowns

Risk Likelihood Impact Mitigation
Android routes traffic over mobile data instead of the local WiFi (no-internet detection) High High Implement NetworkRequest + bindProcessToNetwork on Android; document user setting as fallback
Self-signed TLS certificate rejected by default on Android and iOS High Medium Use plain HTTP for v0.1 prototype; document upgrade path to HTTPS with trust store or cert pinning for later iterations
Pi SD card failure from frequent SQLite writes (flash wear) Medium High Enable WAL mode; run cleanup job every 15 min to cap DB size; consider tmpfs for WAL file; document backup to USB SSD for production use
Session token replay — token stolen on local network could impersonate feed Medium Medium Add stored_at timestamp to token payload and reject tokens presented more than 24 h after issue; add per-token nonce to prevent identical-token collisions
Multiple phones register the same feed_id (malicious or key reuse) Low Medium POST /register is idempotent per pubkey; different pubkey producing the same SHA-256 truncation is SHA-256 collision — computationally infeasible; document key rotation flow
Relay stores metadata (sender feed_id, recipient feed_id, timestamps) even if not message content — creates a social graph Medium High Document explicitly in threat model; for prototype scope acceptable; production path: use ephemeral pairwise identifiers per relay session (mirroring SimpleX queue model)
Pi power supply unreliability at events (voltage drop under USB-C power bank load) Medium Medium Recommend official Pi USB-C PSU or quality power bank; systemd Restart=always recovers relay after brownout reboot
iOS hotspot entitlement adds App Store review complexity Low Low For prototype, handle manually in TestFlight build; document production entitlement request process

8. Two-Week Day-by-Day Build Plan

Day Goal Deliverable Dependencies
1 Pi hardware setup; WiFi hotspot verified Phone connects to Pi hotspot; DHCP lease received; ping 192.168.4.1 succeeds Raspberry Pi 4/5, SD card, USB-C power, hostapd + dnsmasq installed
2 Relay server skeleton + SQLite schema POST /register endpoint working; feeds, messages, tokens tables created in SQLite Go toolchain on dev machine; cross-compile to ARM64 verified
3 Message send + inbox endpoints POST /messages/send stores ciphertext; GET /messages/inbox returns it; DELETE removes it Day 2
4 E2E encryption on phone side KeyBundle generated; XChaCha20-Poly1305 encrypt/decrypt roundtrip unit test passing on Android + iOS tink-android integrated (Android); CryptoKit usage confirmed (iOS)
5 Phone app end-to-end flow Two simulator/device instances: Phone A registers, sends encrypted message, Phone B picks it up and decrypts successfully Days 3 + 4
6 WebSocket push notifications Server pushes new_message event to connected clients; phone SDK subscribeInbox callback fires; end-to-end latency measured gorilla/websocket on server; URLSessionWebSocketTask / Ktor WS on phone
7 Message TTL expiry Background goroutine deletes messages older than 72 h; unit test confirms expiry; DB size stays bounded after synthetic load Day 3
8 HTTPS with self-signed cert; cert trust Self-signed cert generated; Go server serves HTTPS; Android NetworkSecurityConfig trusts cert; iOS custom URLSession delegate trusts cert openssl or mkcert for cert generation
9 Android NetworkRequest — force local WiFi App binds HTTP client to local WiFi network even when Android detects no internet; regression test: mobile data off, relay reachable Android ConnectivityManager API; Day 5
10 Social feed replication endpoints POST /feeds/publish and GET /feeds/{feed_id}/since/{seq} working; integration test: A publishes 10 envelopes, B fetches all 10 Day 3; feed-format spec for envelope structure
11 systemd service; auto-start on boot relay.service unit installed; Pi rebooted; relay starts automatically; integration test: messages persist across reboot Day 2
12 Load test — 20 phones, 100 messages each Load test script (Go or Python) simulating 20 concurrent clients; metrics recorded: p50/p99 latency, error rate, CPU/memory on Pi Day 3; server metrics endpoint (GET /metrics or pprof)
13 Measurement run Boot-to-ready time; message latency (A→relay→B); WiFi range test at 10 m, 20 m, 30 m, 50 m (with + without USB antenna) Days 1, 5, 6
14 Documentation + comparison write-up Updated prototype findings section in this spec; side-by-side comparison of development complexity vs Prototype 2 (BLE messenger); "what comes next" notes All previous days