CAMDOM -- BLE Packet Protocol
Custom binary protocol for BLE mesh communication: connection, session, disconnection, alarm, migration, sound, liveness packets.
CAMDOM -- BLE Packet Protocol Reference
Protocol Overview
CAMDOM uses a custom text-based packet protocol over BLE (Bluetooth Low Energy) GATT characteristics for inter-device communication. The protocol enables a server-client architecture where one device acts as the GATT peripheral (server) and all others connect as clients.
Design Rationale
- No internet dependency: All state is peer-to-peer over BLE. Works offline, in clubs, parties, anywhere.
- Minimal packet size: Packets are Base64-encoded strings sent over a 53-byte MTU. Each packet is
{type}:{payload}to minimize overhead. - Server-authoritative model: The server is the source of truth. Clients never act unilaterally -- they request, the server decides.
- Hold-to-disconnect: Disconnect and alarm-stop require a press-and-hold gesture. This prevents accidental disconnection and ensures all parties agree before separation.
- Platform encoding in advertisement: iOS devices advertise values 1-124, Android devices advertise 126-249. Value 125/251 means "becoming server". This avoids explicit role negotiation -- the device that has been a server longer wins.
BLE Characteristics
| Characteristic | UUID | Role |
|---|---|---|
| Service | 25AE1441-05D3-4C5B-8281-93D4E07420CF | GATT Service |
| Read | 25AE1442-05D3-4C5B-8281-93D4E07420CF | Client reads |
| Write | 25AE1443-05D3-4C5B-8281-93D4E07420CF | Client writes to server |
| Indicate | 25AE1444-05D3-4C5B-8281-93D4E07420CF | Server indicates to client |
Transport Encoding
All packets are Base64-encoded (btoa/atob). The raw text format is {type}:{payload} or {type}:{action}|{data}.
Packet Format Reference
Connection Handshake -- c:
Purpose: First packet sent by client to server after BLE connection. The server echoes it back to confirm the round-trip.
Format: c:{platform}:{advertisedValue}
| Field | Values | Meaning |
|---|---|---|
| platform | 0 | iOS |
| platform | 1 | Android |
| advertisedValue | 1-249 | The random value this device was advertising |
Examples:
c:0:87 # iOS device, was advertising value 87
c:1:132 # Android device, was advertising value 132
Flow:
- Client connects to server over BLE
- Client sends
c:{platform}:{value}to server via CHAR_FOR_WRITE_UUID - Server echoes the exact same packet back to the client via CHAR_FOR_INDICATE_UUID
- Client receives the echo, confirms round-trip complete, transitions to
"connected"state - Client then sends session request
s:r|{fireOnDisconnect}
Edge case: If the received echo does NOT match connectionPacketSent (the original packet), it is ignored silently. This prevents cross-client echo confusion.
Session Sync -- s:
Purpose: Establishes the session. Server generates a UUID-based session ID and distributes it to all clients. Also synchronizes the "fire alarm on disconnect" preference.
Format: s:r|{fireOnDisconnect} (request) / s:a|{sessionId} (answer)
Request (s:r|{fireOnDisconnect}) -- Client to Server:
| Field | Values | Meaning |
|---|---|---|
| fireOnDisconnect | 0 or 1 | Whether client wants alarm to fire if this client disconnects |
Example:
s:r|1 # Client wants alarm to fire on disconnect
s:r|0 # Client does NOT want alarm to fire on disconnect
Answer (s:a|{sessionId}) -- Server to Client:
| Field | Values | Meaning |
|---|---|---|
| sessionId | 8-char UUID string | Unique session identifier, generated on first client connection |
Example:
s:a|a3f2b8c1 # Session ID assigned
Flow:
- Client receives
cecho, transitions to"connected" - Client sends
s:r|{0|1}with its fireOnDisconnect preference - Server receives, stores the preference (if any client wants fire,
hasAnyoneRequestedToFireWhenDisconnect = true) - Server sends
s:a|{sessionId}to the requesting client only (unicast, not broadcast) - Client receives session ID, transitions to
"synced"state, begins RSSI monitoring
Server-side behavior: If connectedClients.length >= 1, server transitions to "synced" state and starts the proximity sensor timeout (3.5s delay before enabling RSSI monitoring).
Disconnection Protocol -- d:
Purpose: Negotiated disconnection requiring mutual consent. The "hold-to-disconnect" gesture means both parties must agree before the BLE connection is severed.
Format: d:{type}|{action}
Disconnection Request -- d:r|
| Packet | Direction | Meaning |
|---|---|---|
d:r|s | Client to Server | Start -- Client has started holding the disconnect button |
d:r|f | Client to Server | Finish -- Client has released the disconnect button (held long enough) |
d:r|1 | Client to Server | Confirm -- Client confirms it is ready to disconnect (after server sends d:a|a) |
Disconnection Answer -- d:a|
| Packet | Direction | Meaning |
|---|---|---|
d:a|a | Server to Client | Accepted -- Server accepts the disconnection request, client should proceed |
d:a|r | Server to Client | Rejected -- Server rejects the disconnection (e.g., during alarm negotiation) |
d:a|a1 | Server to Client(s) | Final Accept -- Broadcast to ALL clients: disconnect now, session is over |
Edge Case Packet -- d:r|ff
| Packet | Direction | Meaning |
|---|---|---|
d:r|ff | Client to Server | Force Finish -- Used when client is in the isAmIClientAllowedToDisconnectEdgeCase state (e.g., during alarm with hold-to-disconnect shortcut) |
Full Disconnection Flow:
sequenceDiagram
participant C as Client
participant S as Server
Note over C: User presses disconnect
C->>S: d:r|s
Note over S: Client added to askedDisconnectionDevices
Note over S: Polling: waiting for all clients to ask
Note over C: User releases disconnect
C->>S: d:r|f
Note over S: Remove client from askedDisconnectionDevices
Note over S: Check if fireOnDisconnect applies
alt All clients requested AND server also wants disconnect
Note over S: askedDisconnectionDevices.length == connectedClients.length
S->>C: d:a|a (unicast to each client)
C->>S: d:r|1
S->>C: d:a|a1 (broadcast to all)
Note over C: Client disconnects from server
else Server not ready yet
Note over S: Wait for more clients
end
Server-side disconnection logic (500ms polling interval):
- Server checks every 500ms if
askedDisconnectionDevices.length === connectedClients.length - If ALL clients have requested AND server button is still held (
isDisconnectButtonPressed):- Sets
serverAlreadyShouldDisconnect = true - Sends
d:a|ato each client that requested - Clears alarm state
- Sets
- If
connectedClients.length === 0(all already disconnected):- Plays "unpair" sound, haptic feedback
- Transitions to
"disconnected"state - Stops advertising, clears all state
Edge case -- orphaned server: If the server releases before all clients respond, and some clients have already disconnected, the server checks connectedClients.length === 0 and cleans up immediately.
Alarm System -- a:
Purpose: Security alarm that fires when a connected device is taken away or goes to background. Requires consensus to stop -- "everyone must agree."
Format: a:{type}|{action}|{data}
Alarm Fire -- a:f|
| Packet | Direction | Meaning |
|---|---|---|
a:f|1|u | Server to Client(s) | Fire alarm -- user-triggered (server went to background) |
a:f|1|a | Server to Client(s) | Fire alarm -- disconnect-triggered (disconnection while fireOnDisconnect is active) |
a:f|0|a | Server to Client(s) | Stop alarm -- all clients agreed to stop |
Alarm Request -- a:r|
| Packet | Direction | Meaning |
|---|---|---|
a:r|s | Client to Server | Start -- Client wants to stop alarm (started holding button) |
a:r|f | Client to Server | Finish -- Client released alarm-stop button |
a:r|i | Client to Server | Initiate alarm -- Client went to background (triggers alarm on server) |
a:r|ff | Client to Server | Force finish -- Edge case: client in isAmIClientAllowedToDisconnectEdgeCase |
Alarm Update -- a:u|
| Packet | Direction | Meaning |
|---|---|---|
a:u|i | Client to Server | Initiate -- Client went to background, server should broadcast alarm |
a:u|rssi|{value}|{sense} | Client to Server | RSSI proximity data (see RSSI section) |
Alarm Trigger Sources
- App backgrounding (server goes to background): Server broadcasts
a:f|1|u. On Android, also fires alarm sound locally. - App backgrounding (client goes to background): Client sends
a:r|ito server. Server broadcastsa:f|1|uto all. - Disconnection with fireOnDisconnect: When server processes
d:r|fandhasAnyoneRequestedToFireWhenDisconnectorStorageKeys.fireAlarmOnDisconnectis true, server broadcastsa:f|1|a. - Disconnection edge case: When
d:r|1is received but client was NOT inaskedDisconnectionDevices, alarm fires witha:f|1|a.
Stop Alarm Flow:
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant S as Server
Note over S: Alarm is firing (a:f|1|a broadcast)
C1->>S: a:r|s (start holding alarm button)
Note over S: Client 1 added to askedToStopAlarmClients
C2->>S: a:r|s (start holding alarm button)
Note over S: Client 2 added to askedToStopAlarmClients
Note over S: Check: askedToStopAlarmClients.length == connectedClients.length?
Note over S: AND isAlarmButtonPressed?
alt All clients agreed AND server also holding
S->>C1: a:f|0|a (broadcast stop)
S->>C2: a:f|0|a (broadcast stop)
Note over S: Alarm stops, state -> "disconnected"
else Not all clients agreed yet
Note over S: Keep waiting
end
Critical behavior: The server itself must ALSO be holding the alarm-stop button (isAlarmButtonPressed). If only clients want to stop but the server does not, the alarm continues. This is the "everyone must agree" model -- the server is a participant, not just a relay.
Connection Migration -- m:
Purpose: When a new server with a higher advertised value appears, the current server tells all clients to disconnect and reconnect to the new server. This handles the case where a more senior device (longer-serving server) returns.
Format: m:d|{targetValue} or m:d|0
| Packet | Direction | Meaning |
|---|---|---|
m:d|{targetValue} | Server to Client(s) | Migrate -- Disconnect and reconnect to device advertising targetValue |
m:d|0 | Server to Client(s) | Abort migration -- The migration target is no longer valid |
Migration Trigger: During scanning, if a client discovers a device with a higher advertised value AND the client is already a server with connected clients:
// Pseudo-code from scanning logic
if (remoteAdvertisedValue > localAdvertisedValue) {
if (this.isServer && remoteAdvertisedValue >= 126) {
this.module.broadcastPacket(`m:d|${remoteAdvertisedValue}`);
this.isMigratingConnection = true;
this.connectedClients = [];
}
}
Migration Flow:
sequenceDiagram
participant C as Client
participant OldS as Old Server
participant NewS as New Server
Note over OldS: Discovers NewS with higher advertised value
OldS->>C: m:d|231 (migrate to value 231)
Note over C: Client receives migration packet
C->>C: Disconnect from OldS (no update sound)
Note over C: state = "migrating"
C->>C: Begin scanning for new server
C->>NewS: Connects as client
NewS->>C: c:1:231 (connection handshake)
C->>NewS: s:r|1 (session request)
NewS->>C: s:a|{sessionId} (session established)
Note over C: state = "synced"
Edge case -- m:d|0: If the migration target disappears or the migration is aborted, server sends m:d|0. Client receives this, sets isMigratingConnection = false, clears session, and enters "scanning" state instead of "migrating".
Server-side state cleanup on migration: When a server triggers migration, it clears:
connectedClientsaskedDisconnectionDevicesaskedToStopAlarmClientsrssiUpdatesFromClients
Sound Synchronization -- sound:
Purpose: Server broadcasts sound events to all connected clients so they play sounds in sync.
Format: sound:{action}|{soundId}
| Field | Values | Meaning |
|---|---|---|
| action | 1 | Play sound |
| action | 0 | Stop sound |
| soundId | 0 | "open" sound |
| soundId | 1 | "pair" sound |
| soundId | 2 | "unpair" sound |
| soundId | 3 | "alarm" sound |
| soundId | 4 | "ping" sound |
Examples:
sound:1|1 # Play "pair" sound on all clients
sound:0|3 # Stop "alarm" sound on all clients
sound:1|4 # Play "ping" sound on all clients
Behavior: Only the server broadcasts sound packets. When playSound() is called on the server, it sends sound:1|{id} to all clients. Clients receive the packet, look up the sound ID via PacketToSoundsMapper, and play/stop accordingly.
Exception: Alarm sounds (soundId=3) are never broadcast as sound: packets -- they are included in the a:f|1|* alarm fire packets and handled separately.
Liveness Check -- ping: / pong:
Purpose: Keepalive mechanism to verify the connection is alive and to sync state after reconnection.
Format: ping:{sessionId} / pong:{sessionId}
| Packet | Direction | Meaning |
|---|---|---|
ping:{sessionId} | Client to Server | Ping -- Client verifying connection |
pong:{sessionId} | Server to Client | Pong -- Server confirms connection alive, includes session ID |
Flow:
sequenceDiagram
participant C as Client
participant S as Server
Note over C: Client receives pong from server
C->>C: Set state to "synced" if was "connecting"/"scanning"
C->>C: Clear isAmIClientAllowedToDisconnectEdgeCase
C->>S: discq:{sessionId} (queue disconnect ack)
Note over C: Wait 1800ms
C->>S: ping:{sessionId}
S->>C: pong:{sessionId}
Edge case: The pong handler on the client also includes a 1800ms delayed ping after the initial discq acknowledgment. This creates a round-trip verification cycle. If the session ID is undefined when pong is received, the client extracts it from the pong packet content.
Disconnect Queue Acknowledgment -- discq: / disca:
Purpose: Simple request-acknowledgment pair used to confirm the server received a client's state change before proceeding.
Format: discq:{sessionId} / disca:1
| Packet | Direction | Meaning |
|---|---|---|
discq:{sessionId} | Client to Server | Request -- Client acknowledges a state change |
disca:1 | Server to Client | Answer -- Server confirms receipt |
Flow:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: discq:a3f2b8c1
Note over S: Server receives, sends answer
S->>C: disca:1
Note over C: Sets isAmIClientAllowedToDisconnectEdgeCase = true
Note over C: After 600ms, sends another discq
Note over C: Clears pingPongTimeout
Usage contexts:
- After receiving
a:f|1|u(alarm fire with user trigger): Client sendsdiscqand setsisAmIClientAllowedToDisconnectEdgeCase = true - After receiving
pong: Client sendsdiscqas part of reconnection handshake - During alarm stop (
onStopAlarmPressFinish): Client sendsdiscqbeforea:r|f
Notification Packets -- n:
Purpose: Proximity sensor notifications (designed but currently commented out in production).
Format: n:{type}|{value}
| Packet | Status | Meaning |
|---|---|---|
n:rssi|1 | Commented out | Enable RSSI proximity sensors on server |
n:rss1|1 | Commented out | Enable RSSI proximity sensors (alternate format, also commented out) |
Note: These packets were part of the planned RSSI-based proximity alarm system. The server would broadcast n:rssi|1 to all clients after 3.5 seconds of sync, enabling proximity monitoring. This was disabled for production but the infrastructure remains in the code.
Server-Side Packet Handler -- handlePacketFromClient
The server receives all client packets via the onPacketFromClient event. The handler returns true to broadcast the packet to all clients, or false to handle it privately (unicast or no relay).
Handler Return Values by Packet Type
| Packet | Return | Broadcast? | Behavior |
|---|---|---|---|
c:* | false | No | Echoes packet back to sender only |
s:* | false | No | Sends s:a|{sessionId} to sender only |
d:* | false | No | Manages disconnection state per-client |
discq:* | false | No | Sends disca:1 to sender only |
a:* | false | No | Manages alarm state per-client |
ping:* | false | No | Sends pong:{sessionId} to sender only |
Important: The server NEVER broadcasts client-originating packets. All relay behavior is explicit -- the server constructs new packets and sends/broadcasts them itself. The broadcastPacket call at the end of onPacketFromClient is only reached if handlePacketFromClient returns true, which it currently never does for any standard packet type.
Detailed Server Handler Logic
c: -- Connection Handshake
- Logs the received connection packet
- Echoes the exact packet back to the sender (
sendPacketToClient) - Resets UI positions (ball and alarm button to center)
- Returns
false(no broadcast)
s: -- Session Request
- Extracts
fireOnDisconnectfrom the request (non-zero = true) - If any client wants fire, sets
hasAnyoneRequestedToFireWhenDisconnect = true - Sends
s:a|{sessionId}to the requesting client - If
connectedClients.length >= 1, transitions server to"synced"state - Starts proximity sensor timeout (3.5s delay)
- Returns
false(no broadcast)
d: -- Disconnection Request
Handles 4 sub-types:
| Sub-type | Action |
|---|---|
d:r|ff | Broadcasts d:a|a1 to ALL clients (force disconnect) |
d:r|s | Adds client to askedDisconnectionDevices list |
d:r|f | Removes client from askedDisconnectionDevices. If fireOnDisconnect applies, broadcasts a:f|1|a (alarm) |
d:r|1 | If client is in askedDisconnectionDevices: adds to allowedClientsToDisconnect, sends d:a|a1 (broadcast if all asked, else unicast). If NOT in list: triggers alarm with a:f|1|a |
discq: -- Disconnect Queue
- Sends
disca:1to the requesting client - Returns
false
a: -- Alarm Request
Handles multiple sub-types:
| Sub-type | Action |
|---|---|
a:r|s | Adds client to askedToStopAlarmClients. If ALL clients have asked AND all are in askedDisconnectionDevices, sends d:a|a to each (emergency disconnect during alarm) |
a:r|f | Removes client from askedToStopAlarmClients |
a:u|i | Client went to background. Broadcasts a:f|1|u to all, sets alarm state |
a:u|rssi|{value}|{sense} | RSSI proximity data from client (see RSSI section) |
ping: -- Liveness Check
- If server state is
"connecting"or"scanning", transitions to"synced" - Sends
pong:{sessionId}to the requesting client - Returns
false(no broadcast)
Client-Side Packet Handler -- handlePacketFromServer
The client receives all server packets via the monitorCharacteristicForService callback on the indicate characteristic. Packets are Base64-decoded via atob.
Detailed Client Handler Logic
c: -- Connection Echo
- If the received packet matches
connectionPacketSent(the original handshake):- Logs "roundtrip"
- Sets
isConnecting = false,isServer = false - Transitions to
"connected"state - Sends
s:r|{fireOnDisconnect}with the local preference
- Clears scanning timeout regardless
s: -- Session Assignment
- Extracts session ID from
s:a|{sessionId} - Stores as
this.sessionId - Transitions to
"synced"state - Resets UI positions (alarm button and ball to center)
- Starts client-side RSSI timeout
d: -- Disconnection Response
Handles d:a|* sub-types:
| Sub-type | Action |
|---|---|
d:a|a | Server accepted. Clears pingPongTimeout, sends d:r|1 (confirm) back to server |
d:a|r | Server rejected. Clears pingPongTimeout. No action (client stays connected) |
d:a|a1 | Final accept. Sets hasReceivedDisconnectFromServer = true, clears session, disconnects from server |
m: -- Migration
Handles two cases:
| Sub-type | Action |
|---|---|
m:d|0 | Migration aborted. Sets hasReceivedDisconnectFromServer = true, isMigratingConnection = false |
m:d|{value} | Migration active. Sets isMigratingConnection = true, disconnects from server (no update sound), clears session, transitions to "migrating" or "scanning" state |
a: -- Alarm Fire/Stop
Handles a:f|* sub-types:
| Sub-type | Action |
|---|---|
a:f|1|u | Alarm fired (user trigger). Sends discq:{sessionId}, sets isAmIClientAllowedToDisconnectEdgeCase = true, transitions to "alarm" |
a:f|1|a | Alarm fired (disconnect trigger). Transitions to "alarm" state |
a:f|0|a | Alarm stopped. Sets hasReceivedDisconnectFromServer = true, transitions to "disconnected", stops alarm sound, shows connection error |
sound: -- Sound Sync
- Extracts action (
0= stop,1= play) and sound ID - Maps sound ID to sound name via
PacketToSoundsMapper - Plays or stops the sound accordingly
n: -- Notification
- Currently a no-op (commented out logic would show proximity sensor enabled snackbar)
pong: -- Liveness Response
- If state is
"connecting"or"scanning", transitions to"synced" - Clears
isAmIClientAllowedToDisconnectEdgeCase - Sends
discq:{sessionId} - After 1800ms delay, sends
ping:{sessionId}back to server
disca: -- Disconnect Queue Ack
- Clears
isAmIClientAllowedToDisconnectEdgeCase - After 600ms delay, sends
discq:{sessionId}and setsisAmIClientAllowedToDisconnectEdgeCase = true - Clears
pingPongTimeout
RSSI Proximity System
The protocol includes a designed but currently disabled proximity-based alarm system that would trigger alarms when devices drift apart.
Architecture
Client reads RSSI every ~15ms
|
v
a:u|rssi|{rssiValue}|{senseValue} --> Server
|
v
Server maintains sliding window of last 5 RSSI readings per client
|
v
Server computes mean per client
|
v
If mean >= threshold (lowest sensitivity across all participants)
|
v
Alarm fires: a:f|1 (broadcast)
RSSI Packet Format
Client to Server: a:u|rssi|{rssi}|{sense}
| Field | Type | Meaning |
|---|---|---|
| rssi | integer (negative) | Raw RSSI value from BLE read |
| sense | integer or null | User's configured sensitivity threshold |
Example:
a:u|rssi|-65|60 # RSSI -65dBm, sensitivity threshold 60
a:u|rssi|-42|null # RSSI -42dBm, no sensitivity configured
Server-Side Processing
- Sliding window: Each client's last 5 RSSI readings are stored in
rssiUpdatesFromClientsmap - Mean calculation: Server computes mean of absolute RSSI values per client
- Threshold comparison: The lowest sensitivity across all participants is used (
hasAnyoneHigherSensibility) - Alarm trigger: If any client's mean exceeds the threshold, alarm fires
- Auto-disconnect: A 1-second timeout after alarm fire would force disconnect (currently commented out)
Sensitivity Negotiation
When a client sends a:u|rssi|{value}|{sense}:
- If
senseis notnull, the server compares it with its own sensitivity (StorageKeys.rssiSense) - If the client's sense is lower (more sensitive), it becomes the new
hasAnyoneHigherSensibility - This means the MOST sensitive participant's threshold wins -- the system becomes as sensitive as the least distant threshold
Current Status
All RSSI code is commented out in production. The infrastructure exists:
configureClientRSSITimeout()-- Client reads RSSI every 15ms (loop disabled)configureServerRSSITimeout()-- Server processes RSSI data (loop disabled)handleRssiUpdateFromClient()-- Stores RSSI in sliding window (function exists but callers are commented out)n:rssi|1/n:rss1|1-- Notification packets to enable sensors (not sent)
The system was designed for ~45fps client read rate and ~30fps server processing rate. The 3.5-second delay before enabling proximity sensors after sync prevents false positives during connection establishment.
App State Machine
The protocol drives the following UI states:
stateDiagram-v2
[*] --> disconnected
disconnected --> scanning : startScanning()
disconnected --> synced : became server with client
scanning --> connecting : found server
scanning --> synced : became server (no higher value found)
connecting --> connected : c: handshake roundtrip
connected --> synced : s:a sessionId received
synced --> alarm : a:f|1|* received
synced --> migrating : m:d|{value} received
synced --> disconnected : d:a|a1 received
alarm --> disconnected : a:f|0|a received
migrating --> scanning : disconnected, searching for new server
migrating --> synced : connected to new server
State Descriptions
| State | Meaning |
|---|---|
disconnected | No active BLE connections. Idle state. |
scanning | Actively scanning for nearby servers. |
connecting | Found a server, establishing BLE connection. |
connected | BLE connected, handshake complete, waiting for session. |
synced | Fully operational. Session active, alarm monitoring enabled. |
alarm | Alarm is firing. Sound looping. Waiting for all parties to agree to stop. |
migrating | Disconnected from old server, scanning for migration target. |
Advertising Value Ranges
The advertised BLE value encodes platform and server priority:
| Range | Platform | Role |
|---|---|---|
| 1-124 | iOS | Client candidate (lower value = longer serving) |
| 125 | iOS | Becoming server |
| 126-249 | Android | Client candidate |
| 251 | Android | Becoming server |
Server election logic: During scanning, if a device finds another device with a higher advertised value, it becomes a client. If it finds a lower value, it becomes the server. This creates an implicit priority queue where:
- Android devices always become servers over iOS devices (126+ vs 1-124)
- Among same-platform devices, the one that has been advertising longer (higher value in the current scheme) wins
Exception: iOS scanning retries up to 10 times for devices with value <= 125 before accepting, to avoid connecting to another iOS client that hasn't become a server yet.
UUID Sound Mapping
| Packet ID | Sound Name | Audio File | Description |
|---|---|---|---|
0 | open | BillyBoy_Camdom_02_Open.mp3 | App open/startup |
1 | pair | BillyBoy_Camdom_02_Pair.mp3 | Successful connection |
2 | unpair | BillyBoy_Camdom_02_Unpair.mp3 | Disconnection |
3 | alarm | BillyBoy_Camdom_Alarm_2.mp3 | Security alarm (loops) |
4 | ping | ping.mp3 | Proximity ping |
Connection Lifecycle Summary
sequenceDiagram
participant U as User
participant C as Client Device
participant S as Server Device
Note over S: Advertising (125/251)
Note over C: Scanning
C->>S: BLE Connect
C->>S: c:0:87 (connection handshake)
S->>C: c:0:87 (echo)
Note over C: state = "connected"
C->>S: s:r|1 (session request, fire=1)
S->>C: s:a|a3f2b8c1 (session ID)
Note over C: state = "synced"
Note over C,S: Active session -- alarm monitoring, proximity sensors
U->>C: Hold disconnect button
C->>S: d:r|s (start disconnect)
Note over S: Client in askedDisconnectionDevices
U->>S: Hold disconnect button
Note over S: Server also wants disconnect
U->>C: Release disconnect
C->>S: d:r|f (finish disconnect)
Note over S: All clients asked, server pressed
S->>C: d:a|a (accepted)
C->>S: d:r|1 (confirm)
S->>C: d:a|a1 (final -- all disconnect)
Note over C: state = "disconnected"
Note over S: state = "disconnected"
Note over S: Stops advertising
Edge Cases and Defensive Behaviors
1. Cross-Client Echo Prevention
Server echoes c: packets only to the sender (sendPacketToClient), never broadcasts. This prevents Client A from receiving Client B's handshake echo.
2. Duplicate Connection Guard
onDeviceConnected checks connectedClients.includes(e.value) before adding. If a device reconnects rapidly, it is not added twice.
3. Orphaned State Cleanup
BLE state changes (PoweredOff, Unauthorized, etc.) trigger a full cleanup: clear all arrays, maps, timeouts, and reset UI state.
4. Background Alarm Trigger
Both AppState.addEventListener("change") and Android-specific AppState.addEventListener("blur") trigger alarm if the app goes to background during a synced session. Server broadcasts a:f|1|u, client sends a:u|i.
5. Scanning Timeout Recovery
If scanning finds nothing for 7.5 seconds and the device is not connecting/synced, it resets: disconnects, stops scanning, stops advertising, restarts scanning. iOS has an extended retry count (scanCountRetriesIos) for slow discovery.
6. Server Kill Timeout
When a client disconnects, shouldKillServerTimeout is set. If the client reconnects before the timeout, the kill is cancelled (clearTimeout).
7. Migration Race Condition
If a server discovers a higher-value device while already serving clients, it broadcasts m:d|{value} and clears all client state. Clients receive the migration, disconnect, and rescan.
8. isAmIClientAllowedToDisconnectEdgeCase
A special flag that allows a client to bypass the normal disconnect flow during alarm states. When true, the client can send d:r|ff or a:r|ff to force disconnection. Set to true after receiving a:f|1|u or disca:1. Cleared on pong: receipt.
9. MTU Negotiation
Client requests 53-byte MTU after connection (requestMTU(53)). This limits the maximum Base64-encoded packet size. The protocol is designed to stay well within this limit.
10. Audio Mode Override
playSound() forces volume to 100% and sets DoNotMix interruption mode on both platforms. This ensures alarm sounds are always audible even if the user has the phone on silent or low volume.
Source: modules/ble-manager/src/BleManager.ts (CAMDOM condom-app repository)