WikifitaGitHub live67e8de5
outro · camdom/camdom-packet-protocol

CAMDOM -- BLE Packet Protocol

Custom binary protocol for BLE mesh communication: connection, session, disconnection, alarm, migration, sound, liveness packets.

Baixar raw

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

CharacteristicUUIDRole
Service25AE1441-05D3-4C5B-8281-93D4E07420CFGATT Service
Read25AE1442-05D3-4C5B-8281-93D4E07420CFClient reads
Write25AE1443-05D3-4C5B-8281-93D4E07420CFClient writes to server
Indicate25AE1444-05D3-4C5B-8281-93D4E07420CFServer 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}

FieldValuesMeaning
platform0iOS
platform1Android
advertisedValue1-249The 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:

  1. Client connects to server over BLE
  2. Client sends c:{platform}:{value} to server via CHAR_FOR_WRITE_UUID
  3. Server echoes the exact same packet back to the client via CHAR_FOR_INDICATE_UUID
  4. Client receives the echo, confirms round-trip complete, transitions to "connected" state
  5. 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:

FieldValuesMeaning
fireOnDisconnect0 or 1Whether 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:

FieldValuesMeaning
sessionId8-char UUID stringUnique session identifier, generated on first client connection

Example:

s:a|a3f2b8c1   # Session ID assigned

Flow:

  1. Client receives c echo, transitions to "connected"
  2. Client sends s:r|{0|1} with its fireOnDisconnect preference
  3. Server receives, stores the preference (if any client wants fire, hasAnyoneRequestedToFireWhenDisconnect = true)
  4. Server sends s:a|{sessionId} to the requesting client only (unicast, not broadcast)
  5. 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|

PacketDirectionMeaning
d:r|sClient to ServerStart -- Client has started holding the disconnect button
d:r|fClient to ServerFinish -- Client has released the disconnect button (held long enough)
d:r|1Client to ServerConfirm -- Client confirms it is ready to disconnect (after server sends d:a|a)

Disconnection Answer -- d:a|

PacketDirectionMeaning
d:a|aServer to ClientAccepted -- Server accepts the disconnection request, client should proceed
d:a|rServer to ClientRejected -- Server rejects the disconnection (e.g., during alarm negotiation)
d:a|a1Server to Client(s)Final Accept -- Broadcast to ALL clients: disconnect now, session is over

Edge Case Packet -- d:r|ff

PacketDirectionMeaning
d:r|ffClient to ServerForce 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):

  1. Server checks every 500ms if askedDisconnectionDevices.length === connectedClients.length
  2. If ALL clients have requested AND server button is still held (isDisconnectButtonPressed):
    • Sets serverAlreadyShouldDisconnect = true
    • Sends d:a|a to each client that requested
    • Clears alarm state
  3. 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|

PacketDirectionMeaning
a:f|1|uServer to Client(s)Fire alarm -- user-triggered (server went to background)
a:f|1|aServer to Client(s)Fire alarm -- disconnect-triggered (disconnection while fireOnDisconnect is active)
a:f|0|aServer to Client(s)Stop alarm -- all clients agreed to stop

Alarm Request -- a:r|

PacketDirectionMeaning
a:r|sClient to ServerStart -- Client wants to stop alarm (started holding button)
a:r|fClient to ServerFinish -- Client released alarm-stop button
a:r|iClient to ServerInitiate alarm -- Client went to background (triggers alarm on server)
a:r|ffClient to ServerForce finish -- Edge case: client in isAmIClientAllowedToDisconnectEdgeCase

Alarm Update -- a:u|

PacketDirectionMeaning
a:u|iClient to ServerInitiate -- Client went to background, server should broadcast alarm
a:u|rssi|{value}|{sense}Client to ServerRSSI proximity data (see RSSI section)

Alarm Trigger Sources

  1. App backgrounding (server goes to background): Server broadcasts a:f|1|u. On Android, also fires alarm sound locally.
  2. App backgrounding (client goes to background): Client sends a:r|i to server. Server broadcasts a:f|1|u to all.
  3. Disconnection with fireOnDisconnect: When server processes d:r|f and hasAnyoneRequestedToFireWhenDisconnect or StorageKeys.fireAlarmOnDisconnect is true, server broadcasts a:f|1|a.
  4. Disconnection edge case: When d:r|1 is received but client was NOT in askedDisconnectionDevices, alarm fires with a: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

PacketDirectionMeaning
m:d|{targetValue}Server to Client(s)Migrate -- Disconnect and reconnect to device advertising targetValue
m:d|0Server 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:

  • connectedClients
  • askedDisconnectionDevices
  • askedToStopAlarmClients
  • rssiUpdatesFromClients

Sound Synchronization -- sound:

Purpose: Server broadcasts sound events to all connected clients so they play sounds in sync.

Format: sound:{action}|{soundId}

FieldValuesMeaning
action1Play sound
action0Stop sound
soundId0"open" sound
soundId1"pair" sound
soundId2"unpair" sound
soundId3"alarm" sound
soundId4"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}

PacketDirectionMeaning
ping:{sessionId}Client to ServerPing -- Client verifying connection
pong:{sessionId}Server to ClientPong -- 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

PacketDirectionMeaning
discq:{sessionId}Client to ServerRequest -- Client acknowledges a state change
disca:1Server to ClientAnswer -- 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:

  1. After receiving a:f|1|u (alarm fire with user trigger): Client sends discq and sets isAmIClientAllowedToDisconnectEdgeCase = true
  2. After receiving pong: Client sends discq as part of reconnection handshake
  3. During alarm stop (onStopAlarmPressFinish): Client sends discq before a:r|f

Notification Packets -- n:

Purpose: Proximity sensor notifications (designed but currently commented out in production).

Format: n:{type}|{value}

PacketStatusMeaning
n:rssi|1Commented outEnable RSSI proximity sensors on server
n:rss1|1Commented outEnable 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

PacketReturnBroadcast?Behavior
c:*falseNoEchoes packet back to sender only
s:*falseNoSends s:a|{sessionId} to sender only
d:*falseNoManages disconnection state per-client
discq:*falseNoSends disca:1 to sender only
a:*falseNoManages alarm state per-client
ping:*falseNoSends 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 fireOnDisconnect from 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-typeAction
d:r|ffBroadcasts d:a|a1 to ALL clients (force disconnect)
d:r|sAdds client to askedDisconnectionDevices list
d:r|fRemoves client from askedDisconnectionDevices. If fireOnDisconnect applies, broadcasts a:f|1|a (alarm)
d:r|1If 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:1 to the requesting client
  • Returns false

a: -- Alarm Request

Handles multiple sub-types:

Sub-typeAction
a:r|sAdds 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|fRemoves client from askedToStopAlarmClients
a:u|iClient 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-typeAction
d:a|aServer accepted. Clears pingPongTimeout, sends d:r|1 (confirm) back to server
d:a|rServer rejected. Clears pingPongTimeout. No action (client stays connected)
d:a|a1Final accept. Sets hasReceivedDisconnectFromServer = true, clears session, disconnects from server

m: -- Migration

Handles two cases:

Sub-typeAction
m:d|0Migration 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-typeAction
a:f|1|uAlarm fired (user trigger). Sends discq:{sessionId}, sets isAmIClientAllowedToDisconnectEdgeCase = true, transitions to "alarm"
a:f|1|aAlarm fired (disconnect trigger). Transitions to "alarm" state
a:f|0|aAlarm 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 sets isAmIClientAllowedToDisconnectEdgeCase = 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}

FieldTypeMeaning
rssiinteger (negative)Raw RSSI value from BLE read
senseinteger or nullUser'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

  1. Sliding window: Each client's last 5 RSSI readings are stored in rssiUpdatesFromClients map
  2. Mean calculation: Server computes mean of absolute RSSI values per client
  3. Threshold comparison: The lowest sensitivity across all participants is used (hasAnyoneHigherSensibility)
  4. Alarm trigger: If any client's mean exceeds the threshold, alarm fires
  5. 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 sense is not null, 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

StateMeaning
disconnectedNo active BLE connections. Idle state.
scanningActively scanning for nearby servers.
connectingFound a server, establishing BLE connection.
connectedBLE connected, handshake complete, waiting for session.
syncedFully operational. Session active, alarm monitoring enabled.
alarmAlarm is firing. Sound looping. Waiting for all parties to agree to stop.
migratingDisconnected from old server, scanning for migration target.

Advertising Value Ranges

The advertised BLE value encodes platform and server priority:

RangePlatformRole
1-124iOSClient candidate (lower value = longer serving)
125iOSBecoming server
126-249AndroidClient candidate
251AndroidBecoming 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 IDSound NameAudio FileDescription
0openBillyBoy_Camdom_02_Open.mp3App open/startup
1pairBillyBoy_Camdom_02_Pair.mp3Successful connection
2unpairBillyBoy_Camdom_02_Unpair.mp3Disconnection
3alarmBillyBoy_Camdom_Alarm_2.mp3Security alarm (loops)
4pingping.mp3Proximity 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)