Terug naar blog
Transport15 min read

BLE-transportontwerp — Berichten en bestandsdownloads

Dit artikel legt uit hoe PlainApp chatberichten pusht en bestanden downloadt via Bluetooth Low Energy wanneer noch LAN, noch Wi-Fi Aware beschikbaar is. BLE is de gegarandeerde fallback: traag, maar het werkt zonder enige IP-connectiviteit. Het artikel behandelt het wire-formaat, het tweelagige chunking-ontwerp, hoe gelijktijdig verkeer wel en niet wordt geprioriteerd, en waarom elke verbinding na elk verzoek wordt afgebroken.

Voor de bredere chatarchitectuur die dit transport gebruikt, zie Chatarchitectuur. Voor hoe twee apparaten de gedeelde ChaCha20-sleutel verkrijgen die elke BLE-payload versleutelt, zie het Koppelingsproces.

Inhoudsopgave

Waarom een BLE-transport? {#why-a-ble-transport-at-all}

PlainApp is serverless en offline-first. De transportlaag is een geordende fallback-chain: LAN → Wi-Fi Aware → BLE. LAN is het happy-path (HTTPS over Wi-Fi, ~10 ms round trips). Wi-Fi Aware dekt peers in andere subnetten (verschillende SSID's, gast versus IoT-VLAN's). Beide vereisen IP-connectiviteit van een of andere soort. BLE is het enige transport dat werkt:

  • Wanneer de apparaten helemaal niet op hetzelfde IP-netwerk zitten.
  • Wanneer Wi-Fi uit staat of in vliegtuigmodus staat (de BLE-radio is gescheiden).
  • Wanneer Wi-Fi Aware niet wordt ondersteund (Android < 13, alle iOS- varianten van PlainApp).

BLE is traag — tientallen KB/s, seconden latentie per verzoek — maar het is gegarandeerd voor elke gekoppelde peer, omdat het enige wat het nodig heeft de clientId van de peer is, die altijd wordt uitgezonden in de BLE-scanrespons.

Diagram 1
1

GATT-service-indeling {#gatt-service-layout}

PlainApp adverteert een enkele aangepaste GATT-service met twee characteristics. Er is geen geregistreerde 16-bit UUID — de service gebruikt een 128-bit UUID waarvan de trailing bytes ASCII-decoderen naar plpai\x01:

Diagram 2
2

Waarom twee characteristics?

De twee protocollen hebben volledig verschillende vertrouwensmodellen en payload-vormen:

  • NEARBY vervoert koppelingsberichten. Ze komen aan voordat de peer is gekoppeld (nog geen gedeelde sleutel), dus gebruiken ze hun eigen Ed25519-ondertekende JSON-payloads met hun eigen prefix-routing. De body is een gewone string.
  • HTTP vervoert al het verkeer na koppeling (chat, bestanden, presence). Het is altijd ChaCha20-versleuteld met de gedeelde sleutel en gebruikt dezelfde HttpRouteRegistry als de LAN-Ktor-server, zodat de route-handlers (/peer_graphql, /fs, /peer_status) één keer worden geschreven en voor beide transports worden hergebruikt.

Waarom notificaties in plaats van reads?

Het BLE ATT-protocol beperkt een enkele attribute-read tot 512 bytes. Een GraphQL-respons of een 16 KB-bestandschunk kan veel groter zijn. PlainApp omzeilt dit door readCharacteristic nooit voor echte data te gebruiken — de onCharacteristicReadRequest van de server retourneert een lege payload met GATT_SUCCESS. In plaats daarvan schrijft de client zijn verzoek naar de characteristic, en de server reageert door een reeks gechunkte notificaties te sturen die de client weer in elkaar zet. Dit is gedocumenteerd in BleDeviceApi.requestAsync, BleServerProtocol.handleWrite en AndroidBleGattServer.sendChunkedResponse.

Peer-identificatie: shortId, niet MAC {#peer-identification-shortid-not-mac}

BLE-advertisingpakketjes zijn klein (31 bytes) en het BLE MAC-adres wordt door Android ongeveer elke ~15 minuten gewijzigd — dus het kan niet als stabiele identifier worden gebruikt. PlainApp zendt in plaats daarvan een 9-byte serviceData-payload uit in de scanrespons:

Diagram 3
3

Waarom een afgekappe hash in plaats van de volledige clientId?

Een 13-tekens clientId past in 13 bytes, maar PlainApp kiest voor een 8-byte afgekappe SHA-256 om twee redenen:

  1. Stabiele byte-budget. 9 bytes totaal past comfortabel in de 31-byte advertising-payload naast de service-UUID (16 bytes), lengte- en typevelden (~27 bytes gebruikt, 4 bytes marge).
  2. Privacy. Een passieve waarnemer die BLE scant kan de clientId niet terugkrijgen uit de shortId (de 8-byte prefix van een SHA-256-hash is in de praktijk onomkeerbaar). Ze kunnen alleen een peer herkennen die ze dezelfde shortId al hebben zien adverteren — ze kunnen geen PlainApp-gebruikers inventariseren.

De volledige clientId wordt alleen onthuld aan een peer die daadwerkelijk via GATT heeft verbonden en een DDiscoverReply heeft uitgewisseld — d.w.z. een peer waarmee de gebruiker al heeft gekozen om mee te communiceren.

Tweelagige chunking-ontwerp {#two-layer-chunking-design}

Dit is het meest subtiele deel van het BLE-transport, en het is essentieel om beide lagen te begrijpen omdat ze volledig verschillende grootten en doelen hebben:

Diagram 4
4

Waarom 380 tekens?

De onderhandelde ATT MTU is 517 bytes op Android (requestMtu(517) — het maximum dat door de BLE-specificatie is toegestaan) en ~185+ op iOS (auto-onderhandeld door CoreBluetooth). Na aftrek van de ATT-header (~3 bytes) en de JSON-wrapper-overhead van BleSegmentData ({"d":"...","s":N} voegt ~12 bytes toe), passen 380 tekens payload comfortabel binnen één ATT MTU op beide platforms. De waarde is symmetrisch (zowel client-verzoekfragmenten als server-notificatiefragmenten gebruiken 380), wat de code eenvoudig houdt.

Waarom 16 KiB voor bestandschunks?

Een 16 KiB-bestandschunk base64-encodeert naar ~22 KiB JSON, wat fragmenteert in ~58 GATT-notificatiesegmenten. Elke requestAsync- round-trip duurt over BLE seconden, dus minder-maar-grotere chunks verminderen de per-chunk overhead. Veel groter zou het risico lopen BLE-RPC-timeouts te raken en slechte voortgangsfeedback te geven (de gebruiker ziet voortgang slechts één keer per chunk bijgewerkt). 16 KiB is de empirisch afgestemde sweet spot — groot genoeg voor doorvoer, klein genoeg voor responsieve voortgangs-UI.

De RPC-primitief: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

Elk BLE-chatbericht en elke bestandschunk is één aanroep naar BleDeviceApi.requestAsync(service, requestData) — een suspend-functie die een BleResult retourneert. Het is synchroon vanuit het perspectief van de aanroeper: één verzoek → één volledig weer in elkaar gezet respons, geen pipelining.

Diagram 5
5

Belangrijke invarianten

  1. Eén verzoek → één respons. requestAsync is synchroon vanuit het perspectief van de aanroeper — het retourneert pas nadat de volledige respons is samengesteld. Er is geen pipelining.
  2. Notificaties per aanroep ingeschakeld. De client schrijft de CCCD aan het begin van elke requestAsync en schakelt deze aan het einde uit. Dit is verspillend (twee extra GATT-writes per aanroep) maar houdt het protocol stateless — de server hoeft niet bij te houden welke clients aan het "luisteren" zijn.
  3. Geen hertry binnen een RPC. Als een enkele writeCharacteristic een timeout krijgt (5 s), wordt de hele RPC afgebroken. Alleen ensureConnected doet hertries (3 pogingen bij verbindingsfout). Grove transport-level backoff wordt geboden door PeerCircuitBreaker, niet door de RPC-laag.

Wire-envelope-formaat {#wire-envelope-format}

De payload binnen Layer A-segmenten is een geneste JSON-envelope. De fragmentatie weghalend, is de logische structuur:

Diagram 6
6

Vorm van de respons

De respons stroomt in de tegenovergestelde richting door dezelfde Layer A-fragmentatie, maar de binnenste JSON is een BleHttpResponse met drie velden: s (HTTP-statuscode), h (map van respons-headers) en b (body). De body wordt altijd base64-geëncodeerd door BleHttpCall.encodeResponse(), zelfs wanneer leeg — de respons kan binair zijn (versleutelde GraphQL-bytes, ruwe /fs-bestandsbytes) en het BLE-transport is uitsluitend string, zodat dezelfde JSON-envelope zowel tekst- als binaire payloads vervoert.

Verzendpad voor berichten (end-to-end) {#message-send-path-end-to-end}

Alles samenvoegend — wat gebeurt er wanneer een chatbericht via BLE wordt verzonden:

Diagram 7
7

Opmerkelijke ontwerpkeuzes

  • Dezelfde sleutel als LAN. De gedeelde ChaCha20-sleutel uit koppeling wordt hergebruikt voor BLE — er is geen aparte BLE-sleutel. De door LanTransport gebruikte OkHttp-crypto-interceptor en de handmatige chaCha20Encrypt/chaCha20Decrypt in BleTransport zijn dezelfde primitief, alleen verschillend aangeroepen.
  • Dezelfde route-handlers als LAN. BleHttpRequest wordt verzonden via HttpRouteRegistry.matchRoute(path), wat hetzelfde register is dat de Ktor-LAN-server gebruikt. Dus /peer_graphql, /fs, /peer_status enz. worden exact één keer geïmplementeerd en werken identiek over beide transports.
  • Geen hergebruik van verbindingen. Het finally { scanner.teardownConnection(client) }-blok draait altijd. Elk bericht betaalt de volledige connect→discoverServices→MTU-kosten (~seconden). Dit is een bewuste afweging — zie Ontwerpafwegingen.

Downloadpad voor bestanden (end-to-end) {#file-download-path-end-to-end}

Downloads via BLE zijn streaming — het bestand wordt in 16 KiB-chunks gelezen en bij aankomst naar een temp-bestand geschreven, zodat een 10 MB-bestand geen 10 MB RAM nodig heeft. De truc is dat de RPC van elke chunk een aparte requestAsync-aanroep is, en de chunks in een ByteChannel worden geduwd die de consument gelijktijdig uitleest.

Diagram 8
8

Waarom streaming in plaats van één grote RPC?

Een 10 MB-bestand verzonden als één RPC zou ~280 000 notificatiesegmenten betekenen, allemaal in memory gehouden aan beide kanten voordat de respons überhaupt kan beginnen — en de hele overdracht zou moeten slagen voordat enige voortgang wordt gemeld. Erger nog, een enkele verloren notificatie in het midden zou alles corrupt maken.

Het gechunkte ontwerp heeft drie voordelen:

  1. Constant memory. Er is slechts één 16 KiB-chunk tegelijk onderweg.
  2. Live voortgang. DownloadQueue.notifyProgressUpdate() vuurt elke seconde, en de UI toont een downloadbalk.
  3. Veerkracht. Een mislukte chunk kan onafhankelijk worden herprobeerd (de DownloadQueue ondersteunt pauze/hervat/hertry op taakniveau; een fout midden in de stream laat het gedeeltelijke temp-bestand achter, hoewel de downloader het momenteel bij fouten verwijdert — zie afwegingen).

Waarom onClose de download-job annuleert

De DownloadedResponse.onClose-callback roept downloadJob.cancel() aan. Dit is essentieel omdat de download-loop draait in een child-coroutine die anders voor altijd zou blijven draaien als de consument de channel vroegtijdig losliet (bijv. de gebruiker tikte op Pause). Het AutoCloseable- contract op DownloadedResponse betekent dat het use { ... }-blok van de consument automatisch onClose aanroept bij afsluiten, waardoor de BLE-download-coroutine wordt geannuleerd en de GATT-verbinding wordt afgebroken in het finally-blok van de coroutine.

Prioritering: hoe chat bestanden in de praktijk verslaat {#prioritization-how-chat-beats-files-in-practice}

Dit is de belangrijkste vraag voor elke chattoepassing: wanneer een trage BLE-bestandsdownload loopt, kan een nieuw chatbericht dan voorrang krijgen?

Het eerlijke antwoord: er is geen expliciet prioriteitsschema

Er is geen prioriteitsveld, geen prioriteitswachtrij, geen preemption waar dan ook in de BLE-code of de download-wachtrij. Ik verifieerde dit door uitputtend grep — de enige priority-matches in shared/src zijn log-prioriteitsniveaus en EXIF-metadata, niets gerelateerd aan bericht-vs-download-volgorde.

Wat er in plaats daarvan bestaat is een set architecturale scheidingen die het gewenste gedrag produceren als een opkomende eigenschap:

Diagram 9
9

Waarom het in de praktijk werkt

De scheiding die chat "geprioriteerd laat voelen" is structureel:

  1. Chat-verzendingen gaan niet door DownloadQueue. Ze worden direct uitgegeven door PeerGraphQLClient → PeerTransportRouter → BleTransport.send. Dus een chatbericht staat nooit achter een wachtrij van bestandsdownloads.
  2. Elke BleTransport-aanroep opent zijn eigen GATT-verbinding. Een langlopende download die één verbinding vasthoudt verhindert niet dat een chat-verzending een tweede verbinding met dezelfde peer opent. Android ondersteunt meerdere gelijktijdige GATT-verbindingen.
  3. Chat-RPC's zijn kort. Een enkel chatbericht is één requestAsync- round-trip (~1 s na connect). Zelfs als de radio bezig is met een download, voltooit de chat-verzending binnen enkele seconden.

Waar het ontwerp tekortschiet

De afwegingen van "geen expliciete prioriteit":

  • Verbindingslatentie. Zowel chat als download betalen elke keer de connect→discover→MTU-kosten (~seconden), omdat verbindingen niet worden hergebruikt. Een chatbericht dat tijdens een download aankomt kan niet meerijden op de bestaande verbinding van de download — het opent een nieuwe.
  • Statische wachtrij op Android. De procesbrede operationQueue in AndroidBleGattClient serialiseert GATT-operaties over alle peers en alle verbindingen. Dus terwijl twee GATT-verbindingen naast elkaar kunnen bestaan, worden hun write/read/notify-operaties op wachtrij-niveau interleaved. In de praktijk is dit prima (elke op is ~ms) maar het is een subtiel mondiaal knelpunt bij hoge gelijktijdigheid.
  • Geen preemption. Een lopende download kan niet worden gepauzeerd om een chatbericht door te laten. De chat-verzending draait gewoon gelijktijdig en concurreert om radiotijd.

Een toekomstige verbetering zou een per-peer Mutex rond BleTransport.send en downloadFile kunnen zijn, plus een prioriteitsveld op de wachtrij — maar het huidige ontwerp leunt op het feit dat chat-RPC's kort genoeg zijn dat contentie zelden zichtbaar is voor de gebruiker.

Gelijktijdigheidsbeheer en de statische GATT-wachtrij {#concurrency-control--the-static-gatt-queue}

Dit verdient een eigen sectie omdat het het meest subtiele aspect van de Android BLE-implementatie is.

Diagram 10
10

Waarom statisch (procesbreed)?

De Android BLE-stack staat geen gelijktijdige GATT-operaties toe op één BluetoothGatt-instantie — writeCharacteristic aanroepen terwijl een andere write in flight is retourneert false en negeert stilzwijgend de tweede write. De standaardoplossing is een per-BluetoothGatt-wachtrij. PlainApp gaat een stap verder en gebruikt een procesbrede wachtrij (in het companion object), wat overdreven conservatief maar correct is: het garandeert dat nergens in de app twee GATT-operaties gelijktijdig draaien.

De kosten zijn dat de write/read/notify-operaties van een lange BLE-bestandsdownload in de wachtrij staan achter (en achter worden gezet door) die van elke andere peer. Aangezien elke individuele op ~ms is, is dit zelden een voor de gebruiker zichtbaar knelpunt — maar onder zwaar gelijktijdig BLE-verkeer naar meerdere peers zou het er een kunnen worden.

Geen per-peer-lock op transportniveau

BleDeviceApi.requestAsync is een gewone suspend fun zonder mutex, zonder wachtrij, zonder per-peer-serialisatie. Twee gelijktijdige aanroepen naar BleTransport.send voor dezelfde peer openen elk hun eigen GATT-verbinding en gaan onafhankelijk verder. De serialisatie gebeurt impliciet op GATT-operatieniveau (via de statische wachtrij op Android, of via sequentiële await op iOS).

Verbindingslevenscyclus en MTU-onderhandeling {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Waarom requestMtu(517)?

De standaard ATT MTU is 23 bytes (slechts 20 bytes payload na de 3-byte ATT-header). Met de standaard-MTU zou elk 380-tekens-segment ~19 GATT-writes vereisen in plaats van 1 — een 19× vertraging. Het aanvragen van de maximale MTU die door de BLE-specificatie is toegestaan (517 bytes) laat de 380-tekens-segmenten in één ATT-operatie passen, waardoor de doorvoer dramatisch verbetert.

iOS ontsluit geen expliciete MTU-aanvraag-API — CoreBluetooth onderhandelt deze automatisch met de peripheral tijdens de verbinding. Moderne iOS- apparaten onderhandelen doorgaans ~185 bytes, wat nog steeds comfortabel biedt voor de 380-tekens-segmenten (na aftrek van ATT-header + JSON-wrapper-overhead).

Flow control voor notificaties {#flow-control-for-notifications}

De server verzendt responsfragmenten als notificaties, maar BLE- notificaties hebben geen ingebouwde flow control — als de server notificaties sneller verzendt dan de controller kan doorsturen, worden ze stilzwijgend gedropt. PlainApp implementeert expliciete op-ack gebaseerde flow control:

Diagram 12
12

Zonder deze flow control zouden achter elkaar gestuurde notificaties stilzwijgend worden gedropt door de BLE-controller wanneer diens interne zendwachtrij vol raakt — een bekend Android-BLE-probleem gedocumenteerd in de comments van de BleGattServer-interface. De per-apparaat- single-in-flight-regel garandeert dat elke notificatie of wordt verzonden of een timeout oplevert (die vervolgens wordt behandeld als een transportfout).

Foutafhandeling: TransportUnavailable versus echte fout {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable is het signaal dat PeerTransportRouter vertelt om door te vallen naar het volgende transport. Al het andere is een echte fout die wordt teruggegeven aan de aanroeper.

Diagram 13
13

De subtiliteit van download-fouten

BleTransport.downloadFile retourneert DownloadedResponse(200, channel, onClose) onmiddellijk — de gechunkte download-loop draait in een achtergrond-coroutine die naar de channel schrijft. Als een chunk-RPC midden in de stream faalt, roept de loop channel.close(TransportUnavailable(...)) aan, wat betekent dat de consument (PeerFileDownloader.downloadAsync) de fout ziet als een gegooide uitzondering vanuit channel.readAvailable(buf).

Dit betekent dat de PeerTransportRouter.downloadFile-aanroep zelf is geslaagd (een DownloadedResponse retourneerde), dus de circuit breaker recordt geen fout voor download-fouten midden in de stream. Alleen fouten op connect- en scan-tijd worden door de router gevangen. Dit is een bewuste ontwerpkeuze — een fout midden in de stream mag BLE niet permanent uitschakelen voor die peer (de peer kan bijvoorbeeld tijdelijk uit bereik zijn gegaan).

Referentie van sleutelconstanten {#key-constants-reference}

ConstanteWaardeWaarDoel
BleDeviceApi.CHUNK_SIZE380GATT-segment-fragmentatieGrootte van elke BleSegmentData.data (past binnen ATT MTU na JSON-overhead)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Bestandsdownload-byte-rangeGrootte van elke /fs-chunk-aanvraag
BleTransport.SCAN_TIMEOUT_MS10 000BLE-scanTimeout voor scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC-responsPer-notificatie-wachttijd in requestAsync
AndroidBleGattClient MTU517VerbindingssetuprequestMtu(517) — max toegestaan door BLE-spec
AndroidBleGattClient connect timeout10 000VerbindingssetupWachten op STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000VerbindingssetupWachten op onMtuChanged
AndroidBleGattClient write timeout5 000GATT-writeWachten op onCharacteristicWrite
AndroidBleGattClient read timeout10 000GATT-readWachten op onCharacteristicRead (ongebruikt voor echte data)
AndroidBleGattClient notify-state timeout5 000CCCD-writeWachten op CCCD-descriptor-write
ensureConnected retries3VerbindingssetupTot 4 totale pogingen (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Notificatie-flow-controlWachten op onNotificationSent
AndroidBleGattServer notifyChunkSize380Respons-fragmentatieHetzelfde als BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Notificatie-flow-controlMax updateValue-hertries voordat wordt opgegeven
PeerCircuitBreaker.WINDOW_MS30 000Transport-circuit-breakerOpen-duur na drempel
PeerCircuitBreaker.MAX_FAILURES2Transport-circuit-breakerFouten binnen venster om te openen
DownloadQueue.MAX_CONCURRENT3Download-worker-poolGelijktijdige download-coroutines
BleServiceData.SHORT_ID_BYTES8Peer-identificatieAfgekappe SHA256-prefix-bytes
BleServiceData.PAYLOAD_BYTES9Peer-identificatie1 flags-byte + 8 shortId-bytes
BleSegmentData.STATE_START_BIT1Layer A EOF-signaleringEerste segment van een bericht met meerdere segmenten
BleSegmentData.STATE_END_BIT2Layer A EOF-signaleringLaatste segment (of enige segment)

Samenvatting van ontwerpafwegingen {#design-trade-offs-recap}

Diagram 14
14

Verder lezen

  • Chatarchitectuur — hoe BleTransport past in de LAN → Aware → BLE-fallback-chain en de bredere chat-verzend/ontvang-pijplijn.
  • Koppelingsproces — hoe de gedeelde ChaCha20-sleutel die door elke BLE-payload wordt gebruikt, wordt opgebouwd, en hoe de NEARBY-characteristic wordt gebruikt voor de koppelings-handshake.