Pour l'architecture de chat plus large qui consomme ce transport, voir Architecture du Chat. Pour la façon dont deux appareils obtiennent la clé ChaCha20 partagée utilisée pour chiffrer chaque charge utile BLE, voir Flux de jumelage.
Table des matières
- Pourquoi un transport BLE ?
- Disposition du service GATT
- Identification des pairs : shortId, pas MAC
- Découpage à deux couches
- La primitive RPC :
BleDeviceApi.requestAsync - Format de l'enveloppe filaire
- Chemin d'envoi de message (de bout en bout)
- Chemin de téléchargement de fichier (de bout en bout)
- Priorisation : comment le chat bat les fichiers en pratique
- Contrôle de concurrence et la file GATT statique
- Cycle de vie de la connexion et négociation MTU
- Contrôle de flux pour les notifications
- Gestion des erreurs : TransportUnavailable vs échec réel
- Référence des constantes clés
- Récapitulatif des compromis de conception
Pourquoi un transport BLE ? {#why-a-ble-transport-at-all}
PlainApp est serverless et offline-first. La couche de transport est une
chaîne de repli ordonnée : LAN → Wi-Fi Aware → BLE. LAN est le chemin
nominal (HTTPS sur Wi-Fi, ~10 ms aller-retour). Wi-Fi Aware couvre les pairs
sous-réseaux croisés (SSID différents, VLAN invité vs IoT). Les deux
exigent une connectivité IP quelconque. BLE est le seul transport qui
fonctionne :
- Lorsque les appareils ne sont pas du tout sur le même réseau IP.
- Lorsque le Wi-Fi est éteint ou en mode avion (la radio BLE est séparée).
- Lorsque Wi-Fi Aware n'est pas supporté (Android < 13, toutes les variantes iOS de PlainApp).
BLE est lent — des dizaines de Ko/s, des secondes de latence par requête —
mais il est garanti pour tout pair jumelé, car la seule chose dont il a
besoin est le clientId du pair, qui est toujours diffusé dans la scan
response BLE.
Disposition du service GATT {#gatt-service-layout}
PlainApp annonce un service GATT personnalisé unique avec deux
caractéristiques. Il n'y a pas d'UUID 16 bits enregistré — le service utilise
un UUID 128 bits dont les octets de fin se décodent en ASCII comme plpai\x01 :
Pourquoi deux caractéristiques ?
Les deux protocoles ont des modèles de confiance et des formes de charge utile complètement différents :
- NEARBY transporte les messages de jumelage. Ils arrivent avant que le pair ne soit jumelé (pas encore de clé partagée), ils utilisent donc leurs propres charges utiles JSON signées Ed25519 avec leur propre routage par préfixe. Le corps est une simple chaîne.
- HTTP transporte tout le trafic post-jumelage (chat, fichiers,
présence). Il est toujours chiffré ChaCha20 avec la clé partagée et utilise
le même
HttpRouteRegistryque le serveur Ktor LAN, de sorte que les gestionnaires de route (/peer_graphql,/fs,/peer_status) sont écrits une fois et réutilisés pour les deux transports.
Pourquoi des notifications plutôt que des lectures ?
Le protocole ATT BLE limite une lecture d'attribut unique à 512 octets.
Une réponse GraphQL ou un morceau de fichier de 16 Ko peut être bien plus
volumineux. PlainApp contourne cela en n'utilisant jamais
readCharacteristic pour les données réelles — le
onCharacteristicReadRequest du serveur renvoie une charge utile vide avec
GATT_SUCCESS. À la place, le client écrit sa requête dans la
caractéristique, et le serveur répond en envoyant une séquence de
notifications découpées que le client réassemble. C'est documenté dans
BleDeviceApi.requestAsync, BleServerProtocol.handleWrite et
AndroidBleGattServer.sendChunkedResponse.
Identification des pairs : shortId, pas MAC {#peer-identification-shortid-not-mac}
Les paquets d'advertisement BLE sont minuscules (31 octets) et l'adresse MAC
BLE est randomisée par Android toutes les ~15 minutes — elle ne peut donc
pas servir d'identifiant stable. PlainApp diffuse à la place une charge utile
serviceData de 9 octets dans la scan response :
Pourquoi un hachage tronqué plutôt que le clientId complet ?
Un clientId de 13 caractères tiendrait en 13 octets, mais PlainApp opte pour un SHA-256 tronqué de 8 octets pour deux raisons :
- Budget d'octets stable. 9 octets au total tiennent confortablement dans la charge utile d'advertisement de 31 octets à côté de l'UUID du service (16 octets), des champs de longueur et de type (~27 octets utilisés, 4 octets de marge).
- Confidentialité. Un observateur passif scannant BLE ne peut pas retrouver le clientId à partir du shortId (le préfixe de 8 octets d'un hachage SHA-256 est irréversible en pratique). Il ne peut que reconnaître un pair qu'il a déjà vu diffuser le même shortId — il ne peut pas énumérer les utilisateurs de PlainApp.
Le clientId complet n'est révélé qu'à un pair qui s'est réellement connecté
via GATT et a échangé un DDiscoverReply — c.-à-d. un pair avec lequel
l'utilisateur a déjà choisi d'interagir.
Découpage à deux couches {#two-layer-chunking-design}
C'est la partie la plus subtile du transport BLE, et il est essentiel de comprendre les deux couches car elles ont des tailles et des objectifs complètement différents :
Pourquoi 380 caractères ?
Le MTU ATT négocié est de 517 octets sur Android (requestMtu(517) — le
maximum autorisé par la spécification BLE) et ~185+ sur iOS (auto-négocié par
CoreBluetooth). En soustrayant l'en-tête ATT (~3 octets) et l'enveloppe JSON
de BleSegmentData ({"d":"...","s":N} ajoute ~12 octets), 380 caractères
de charge utile tiennent confortablement dans un seul MTU ATT sur les deux
plateformes. La valeur est symétrique (tant les fragments de requête
client que les fragments de notification serveur utilisent 380), ce qui garde
le code simple.
Pourquoi 16 Kio pour les morceaux de fichier ?
Un morceau de fichier de 16 Kio encode en base64 en ~22 Kio de JSON, ce qui
se fragmente en ~58 segments de notification GATT. Chaque aller-retour
requestAsync prend des secondes sur BLE, donc moins de morceaux mais plus
gros réduit le surcoût par morceau. Aller beaucoup plus gros risquerait de
heurter les délais d'attente RPC BLE et produire un mauvais retour de
progression (l'utilisateur ne voit la progression se mettre à jour qu'une
fois par morceau). 16 Kio est le point d'équilibre empiriquement réglé — assez
grand pour le débit, assez petit pour une UI de progression réactive.
La primitive RPC : BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Chaque message de chat BLE et chaque morceau de fichier est un appel à
BleDeviceApi.requestAsync(service, requestData) — une fonction suspend
qui renvoie un BleResult. Elle est synchrone du point de vue de l'appelant :
une requête → une réponse entièrement réassemblée, pas de pipelining.
Invariants clés
- Une requête → une réponse.
requestAsyncest synchrone du point de vue de l'appelant — elle ne renvoie qu'après que la réponse complète a été réassemblée. Il n'y a pas de pipelining. - Notifications activées par appel. Le client écrit le CCCD au début de
chaque
requestAsyncet le désactive à la fin. C'est coûteux (deux écritures GATT supplémentaires par appel) mais garde le protocole sans état — le serveur n'a pas à suivre quels clients « écoutent ». - Pas de réessai dans un RPC. Si un seul
writeCharacteristicdépasse le délai (5 s), tout le RPC avorte. SeulensureConnectedréessaie (3 tentatives en cas d'échec de connexion). Le backoff grossier au niveau transport est fourni parPeerCircuitBreaker, pas par la couche RPC.
Format de l'enveloppe filaire {#wire-envelope-format}
La charge utile à l'intérieur des segments de la couche A est une enveloppe JSON imbriquée. En ôtant la fragmentation, la structure logique est :
Forme de la réponse
La réponse circule dans la direction opposée à travers la même fragmentation
de la couche A, mais le JSON interne est un BleHttpResponse avec trois
champs : s (code de statut HTTP), h (map des en-têtes de réponse) et b
(corps). Le corps est toujours encodé en base64 par
BleHttpCall.encodeResponse(), même lorsqu'il est vide — la réponse peut
être binaire (octets GraphQL chiffrés, octets de fichier /fs bruts) et le
transport BLE est chaîne uniquement, donc la même enveloppe JSON transporte
des charges utiles texte et binaires.
Chemin d'envoi de message (de bout en bout) {#message-send-path-end-to-end}
En assemblant tout — ce qui se passe quand un message de chat est envoyé via BLE :
Choix de conception notables
- Même clé que LAN. La clé partagée ChaCha20 du jumelage est réutilisée
pour BLE — il n'y a pas de clé BLE distincte. L'intercepteur crypto OkHttp
utilisé par
LanTransportet lechaCha20Encrypt/chaCha20Decryptmanuel dansBleTransportsont la même primitive, simplement invoqués différemment. - Mêmes gestionnaires de route que LAN.
BleHttpRequestest dispatché viaHttpRouteRegistry.matchRoute(path), qui est le même registre que celui utilisé par le serveur Ktor LAN. Donc/peer_graphql,/fs,/peer_statusetc. sont implémentés exactement une fois et fonctionnent à l'identique sur les deux transports. - Pas de réutilisation de connexion. Le bloc
finally { scanner.teardownConnection(client) }s'exécute toujours. Chaque message paie le coût complet connect→discoverServices→MTU (~secondes). C'est un compromis délibéré — voir Compromis de conception.
Chemin de téléchargement de fichier (de bout en bout) {#file-download-path-end-to-end}
Les téléchargements via BLE sont en streaming — le fichier est lu par
morceaux de 16 Kio et écrit dans un fichier temporaire à mesure qu'il arrive,
donc un fichier de 10 Mo n'a pas besoin de 10 Mo de RAM. L'astuce est que
le RPC de chaque morceau est un appel requestAsync séparé, et les morceaux
sont poussés dans un ByteChannel que le consommateur lit en parallèle.
Pourquoi du streaming plutôt qu'un seul gros RPC ?
Un fichier de 10 Mo envoyé en un seul RPC signifierait ~280 000 segments de notification, tous conservés en mémoire des deux côtés avant même que la réponse ne puisse commencer — et le transfert entier devrait réussir avant qu'aucune progression ne soit rapportée. Pire, une notification perdue au milieu corromprait tout.
La conception par morceaux a trois avantages :
- Mémoire constante. Un seul morceau de 16 Kio est en vol à la fois.
- Progression en direct.
DownloadQueue.notifyProgressUpdate()se déclenche chaque seconde, et l'UI affiche une barre de téléchargement. - Résilience. Un morceau échoué peut être réessayé indépendamment (le
DownloadQueuesupporte pause/reprise/réessai au niveau de la tâche ; un échec en milieu de flux laisse le fichier temporaire partiel, bien que actuellement le téléchargeur le supprime en cas d'échec — voir les compromis).
Pourquoi onClose annule la tâche de téléchargement
Le callback DownloadedResponse.onClose appelle downloadJob.cancel(). C'est
essentiel car la boucle de téléchargement tourne dans une coroutine enfant qui
sans cela continuerait à tourner pour toujours si le consommateur abandonnait
le canal prématurément (par ex. l'utilisateur a appuyé sur Pause). Le
contrat AutoCloseable sur DownloadedResponse signifie que le bloc
use { ... } du consommateur invoque automatiquement onClose à la sortie,
annulant la coroutine de téléchargement BLE et démontant la connexion GATT
dans le bloc finally de la coroutine.
Priorisation : comment le chat bat les fichiers en pratique {#prioritization-how-chat-beats-files-in-practice}
C'est la question la plus importante pour toute application de chat : lorsqu'un téléchargement de fichier BLE lent est en cours, un nouveau message de chat peut-il le devancer ?
La réponse honnête : il n'y a pas de schéma de priorité explicite
Il n'y a ni champ de priorité, ni file de priorité, ni préemption nulle
part dans le code BLE ou la file de téléchargement. Je l'ai vérifié par grep
exhaustif — les seules occurrences de priority dans shared/src sont des
niveaux de priorité de log et des métadonnées EXIF, rien lié à l'ordre
messages-vs-téléchargements.
Ce qui existe à la place est un ensemble de séparations architecturales qui produisent le comportement souhaité comme propriété émergente :
Pourquoi ça marche en pratique
La séparation qui fait que le chat « semble priorisé » est structurelle :
- Les envois de chat ne passent pas par
DownloadQueue. Ils sont émis directement parPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Donc un message de chat ne se retrouve jamais derrière une file de téléchargements de fichiers. - Chaque appel
BleTransportouvre sa propre connexion GATT. Un téléchargement longue durée tenant une connexion n'empêche pas un envoi de chat d'ouvrir une seconde connexion vers le même pair. Android supporte plusieurs connexions GATT simultanées. - Les RPC de chat sont courts. Un seul message de chat est un aller-
retour
requestAsync(~1 s après connexion). Même si la radio est occupée avec un téléchargement, l'envoi du chat se termine en quelques secondes.
Où la conception pèche
Les compromis de « pas de priorité explicite » :
- Latence de connexion. Le chat et le téléchargement paient tous deux le coût connect→discover→MTU (~secondes) à chaque fois, car les connexions ne sont pas réutilisées. Un message de chat arrivant pendant un téléchargement ne peut pas profiter de la connexion existante du téléchargement — il en ouvre une nouvelle.
- File statique sur Android. La
operationQueueà l'échelle du processus dansAndroidBleGattClientsérialise les opérations GATT à travers tous les pairs et toutes les connexions. Donc bien que deux connexions GATT puissent coexister, leurs opérations write/read/notify sont entrelacées au niveau de la file. En pratique c'est correct (chaque opération ~ms) mais c'est un goulet d'étranglement global subtil sous forte concurrence. - Pas de préemption. Un téléchargement en cours ne peut pas être mis en pause pour laisser passer un message de chat. L'envoi du chat tourne simplement en parallèle et entre en compétition pour le temps radio.
Une amélioration future pourrait être un Mutex par pair autour de
BleTransport.send et downloadFile, plus un champ de priorité sur la file
— mais la conception actuelle repose sur le fait que les RPC de chat sont
assez courts pour que la contention soit rarement visible par l'utilisateur.
Contrôle de concurrence et la file GATT statique {#concurrency-control--the-static-gatt-queue}
Cela mérite sa propre section car c'est l'aspect le plus subtil de l'implémentation BLE Android.
Pourquoi statique (à l'échelle du processus) ?
La pile BLE Android n'autorise pas les opérations GATT concurrentes sur une
même instance BluetoothGatt — appeler writeCharacteristic pendant
qu'une autre écriture est en cours renvoie false et abandonne silencieusement
la seconde écriture. La solution standard est une file par BluetoothGatt.
PlainApp va plus loin et utilise une file à l'échelle du processus (dans
le companion object), ce qui est trop conservateur mais correct : cela
garantit qu'aucune opération GATT, nulle part dans l'application, ne tourne
simultanément.
Le coût est que les opérations write/read/notify d'un long téléchargement de fichier BLE se mettent derrière (et sont mises derrière) celles de tout autre pair. Comme chaque opération individuelle dure ~ms, c'est rarement un goulet visible — mais sous un trafic BLE concurrent lourd vers plusieurs pairs, ça pourrait le devenir.
Pas de verrou par pair à la couche transport
BleDeviceApi.requestAsync est une simple suspend fun sans mutex, sans
file, aucune sérialisation par pair. Deux appels concurrents à
BleTransport.send pour le même pair ouvriront chacun leur propre connexion
GATT et procéderont indépendamment. La sérialisation se fait implicitement au
niveau des opérations GATT (via la file statique sur Android, ou via des
await séquentiels sur iOS).
Cycle de vie de la connexion et négociation MTU {#connection-lifecycle--mtu-negotiation}
Pourquoi requestMtu(517) ?
Le MTU ATT par défaut est de 23 octets (seulement 20 octets de charge utile après l'en-tête ATT de 3 octets). Avec le MTU par défaut, chaque segment de 380 caractères nécessiterait ~19 écritures GATT au lieu de 1 — un ralentissement de 19×. Demander le MTU maximum autorisé par la spec BLE (517 octets) permet aux segments de 380 caractères de tenir dans une seule opération ATT, améliorant considérablement le débit.
iOS n'expose pas d'API explicite de demande de MTU — CoreBluetooth le négocie automatiquement avec le périphérique lors de la connexion. Les appareils iOS modernes négocient généralement ~185 octets, ce qui tient encore confortablement les segments de 380 caractères (après soustraction de l'en-tête ATT et de l'enveloppe JSON).
Contrôle de flux pour les notifications {#flow-control-for-notifications}
Le serveur envoie les fragments de réponse sous forme de notifications, mais les notifications BLE n'ont aucun contrôle de flux intégré — si le serveur envoie des notifications plus vite que le contrôleur ne peut les transmettre, elles sont silencieusement abandonnées. PlainApp implémente un contrôle de flux explicite basé sur des acks :
Sans ce contrôle de flux, des notifications consécutives seraient
silencieusement abandonnées par le contrôleur BLE lorsque sa file d'envoi
interne se remplit — un problème BLE Android bien connu documenté dans les
commentaires de l'interface BleGattServer. La règle d'un seul envoi en vol
par appareil garantit que chaque notification est soit transmise, soit
déclenche un délai d'attente (ensuite traité comme une défaillance de
transport).
Gestion des erreurs : TransportUnavailable vs échec réel {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable est le signal qui indique à PeerTransportRouter de
passer au transport suivant. Tout autre échec est une défaillance réelle
renvoyée à l'appelant.
La subtilité de l'échec de téléchargement
BleTransport.downloadFile renvoie
DownloadedResponse(200, channel, onClose) immédiatement — la boucle de
téléchargement par morceaux tourne dans une coroutine en arrière-plan qui
écrit dans le canal. Si un RPC de morceau échoue en cours de flux, la boucle
appelle channel.close(TransportUnavailable(...)), ce qui fait que le
consommateur (PeerFileDownloader.downloadAsync) voit l'erreur comme une
exception levée depuis channel.readAvailable(buf).
Cela signifie que l'appel PeerTransportRouter.downloadFile lui-même a réussi
(renvoyé un DownloadedResponse), donc le disjoncteur n'enregistre pas
d'échec pour les erreurs de téléchargement en cours de flux. Seuls les échecs
à la connexion et au scan sont interceptés par le routeur. C'est un choix de
conception délibéré — un échec en cours de flux ne devrait pas désactiver
définitivement BLE pour ce pair (le pair a peut-être juste temporairement
quitté la portée).
Référence des constantes clés {#key-constants-reference}
| Constante | Valeur | Où | Rôle |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | Fragmentation des segments GATT | Taille de chaque BleSegmentData.data (tient dans le MTU ATT après l'enveloppe JSON) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Plage d'octets de téléchargement | Taille de chaque requête de morceau /fs |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | Scan BLE | Délai d'attente pour scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | Réponse RPC | Attente par notification dans requestAsync |
AndroidBleGattClient MTU | 517 | Configuration de connexion | requestMtu(517) — maximum autorisé par la spec BLE |
AndroidBleGattClient délai connexion | 10 000 | Configuration de connexion | Attente de STATE_CONNECTED |
AndroidBleGattClient délai MTU | 5 000 | Configuration de connexion | Attente de onMtuChanged |
AndroidBleGattClient délai écriture | 5 000 | Écriture GATT | Attente de onCharacteristicWrite |
AndroidBleGattClient délai lecture | 10 000 | Lecture GATT | Attente de onCharacteristicRead (inutilisé pour les données réelles) |
AndroidBleGattClient délai état notify | 5 000 | Écriture CCCD | Attente de l'écriture du descripteur CCCD |
ensureConnected réessais | 3 | Configuration de connexion | Jusqu'à 4 tentatives au total (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Contrôle de flux des notifications | Attente de onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Fragmentation de réponse | Identique à BleDeviceApi.CHUNK_SIZE |
IosBleGattServer plafond de réessai | 10 | Contrôle de flux des notifications | Max updateValue réessais avant abandon |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Disjoncteur de transport | Durée d'ouverture après seuil |
PeerCircuitBreaker.MAX_FAILURES | 2 | Disjoncteur de transport | Échecs dans la fenêtre pour ouvrir |
DownloadQueue.MAX_CONCURRENT | 3 | Pool de workers de téléchargement | Coroutines de téléchargement concurrentes |
BleServiceData.SHORT_ID_BYTES | 8 | Identification des pairs | Octets du préfixe SHA256 tronqué |
BleServiceData.PAYLOAD_BYTES | 9 | Identification des pairs | 1 octet de drapeaux + 8 octets shortId |
BleSegmentData.STATE_START_BIT | 1 | Signalisation EOF couche A | Premier segment d'un message multi-segments |
BleSegmentData.STATE_END_BIT | 2 | Signalisation EOF couche A | Dernier segment (ou segment unique) |
Récapitulatif des compromis de conception {#design-trade-offs-recap}
Pour aller plus loin
- Architecture du Chat — comment
BleTransports'intègre dans la chaîne de repliLAN → Aware → BLEet le pipeline plus large d'envoi/réception de chat. - Flux de jumelage — comment la clé ChaCha20 partagée utilisée par chaque charge utile BLE est établie, et comment la caractéristique NEARBY est utilisée pour la poignée de main de jumelage.