Für die übergeordnete Chat-Architektur, die diesen Transport konsumiert, siehe Chat-Architektur. Wie zwei Geräte den gemeinsamen ChaCha20-Schlüssel erhalten, mit dem jede BLE-Nutzlast verschlüsselt wird, ist unter Pairing-Ablauf beschrieben.
Inhaltsverzeichnis
- Warum überhaupt ein BLE-Transport?
- GATT-Service-Layout
- Peer-Identifikation: shortId, nicht MAC
- Zweischichtiges Chunking-Design
- Das RPC-Primitiv:
BleDeviceApi.requestAsync - Wire-Envelope-Format
- Sende-Pfad für Nachrichten (End-to-End)
- Download-Pfad für Dateien (End-to-End)
- Priorisierung: Wie Chat Dateien in der Praxis schlägt
- Concurrency-Control & die statische GATT-Queue
- Verbindungslebenszyklus & MTU-Verhandlung
- Flow-Control für Notifications
- Fehlerbehandlung: TransportUnavailable vs. echter Fehler
- Referenz der Schlüsselkonstanten
- Zusammenfassung der Design-Trade-offs
Warum überhaupt ein BLE-Transport? {#why-a-ble-transport-at-all}
PlainApp ist serverlos und offline-first. Die Transportebene ist eine
geordnete Fallback-Kette: LAN → Wi-Fi Aware → BLE. LAN ist der Happy Path
(HTTPS über Wi-Fi, ~10 ms Round Trips). Wi-Fi Aware deckt Peers in
unterschiedlichen Subnetzen ab (verschiedene SSIDs, Guest vs. IoT-VLANs).
Beide erfordern irgendeine Form von IP-Konnektivität. BLE ist der einzige
Transport, der funktioniert:
- Wenn die Geräte überhaupt nicht im selben IP-Netzwerk sind.
- Wenn Wi-Fi aus ist oder sich im Flugmodus befindet (das BLE-Radio ist separat).
- Wenn Wi-Fi Aware nicht unterstützt wird (Android < 13, alle iOS-Varianten von PlainApp).
BLE ist langsam — Zehner-KB/s, Sekunden Latenz pro Anfrage — aber es ist
garantiert für jeden gepaarten Peer, da es als einziges nur die
clientId des Peers benötigt, die immer im BLE-Scan-Response gesendet wird.
GATT-Service-Layout {#gatt-service-layout}
PlainApp bewirbt einen einzigen benutzerdefinierten GATT-Service mit zwei
Characteristics. Es gibt keine registrierte 16-Bit-UUID — der Service verwendet
eine 128-Bit-UUID, deren niederwertige Bytes ASCII-dekodiert plpai\x01
ergeben:
Warum zwei Characteristics?
Die beiden Protokolle haben völlig unterschiedliche Vertrauensmodelle und Nutzlastformen:
- NEARBY transportiert Pairing-Nachrichten. Sie kommen an, bevor der Peer gepaart ist (noch kein gemeinsamer Schlüssel), also verwenden sie eigene Ed25519-signierte JSON-Nutzlasten mit eigenem Prefix-Routing. Der Body ist ein einfacher String.
- HTTP transportiert den gesamten Post-Pairing-Verkehr (Chat, Dateien,
Presence). Er ist immer mit dem gemeinsamen Schlüssel ChaCha20-verschlüsselt
und verwendet dieselbe
HttpRouteRegistrywie der LAN-Ktor-Server, sodass die Route-Handler (/peer_graphql,/fs,/peer_status) einmal geschrieben und für beide Transporte wiederverwendet werden.
Warum Notifications statt Reads?
Das BLE-ATT-Protokoll begrenzt das Lesen eines einzelnen Attributs auf
512 Bytes. Eine GraphQL-Antwort oder ein 16-KB-Datei-Chunk kann deutlich
größer sein. PlainApp umgeht das, indem readCharacteristic für echte
Daten nie verwendet wird — der onCharacteristicReadRequest des Servers
gibt eine leere Nutzlast mit GATT_SUCCESS zurück. Stattdessen schreibt der
Client seine Anfrage in die Characteristic, und der Server antwortet mit einer
Folge von chunked Notifications, die der Client wieder zusammensetzt. Das
ist in BleDeviceApi.requestAsync, BleServerProtocol.handleWrite und
AndroidBleGattServer.sendChunkedResponse dokumentiert.
Peer-Identifikation: shortId, nicht MAC {#peer-identification-shortid-not-mac}
BLE-Advertising-Pakete sind winzig (31 Bytes) und die BLE-MAC-Adresse wird
von Android etwa alle 15 Minuten randomisiert — sie kann also nicht als
stabiler Bezeichner dienen. PlainApp sendet stattdessen eine 9-Byte-serviceData-Nutzlast
im Scan-Response:
Warum ein abgeschnittener Hash statt der vollen clientId?
Eine 13-Zeichen-clientId würde in 13 Bytes passen, aber PlainApp entscheidet sich aus zwei Gründen für ein 8-Byte-abgeschnittenes SHA-256:
- Stabiles Byte-Budget. 9 Bytes insgesamt passen bequem in die 31-Byte- Advertising-Nutzlast neben der Service-UUID (16 Bytes), Längen- und Typfeldern (~27 Bytes belegt, 4 Bytes Reserve).
- Privacy. Ein passiver Beobachter, der BLE scannt, kann die clientId nicht aus dem shortId rekonstruieren (das 8-Byte-Präfix eines SHA-256-Hashes ist in der Praxis irreversibel). Er kann einen Peer, den er bereits angekündigt gesehen hat, nur wiedererkennen — er kann PlainApp-Nutzer nicht aufzählen.
Die volle clientId wird nur einem Peer offenbart, der tatsächlich über GATT
verbunden ist und einen DDiscoverReply ausgetauscht hat — also einem Peer,
mit dem der Nutzer bereits interagieren möchte.
Zweischichtiges Chunking-Design {#two-layer-chunking-design}
Das ist der subtilste Teil des BLE-Transports, und es ist wichtig, beide Schichten zu verstehen, da sie völlig unterschiedliche Größen und Zwecke haben:
Warum 380 Zeichen?
Die verhandelte ATT-MTU beträgt auf Android 517 Bytes (requestMtu(517) —
das Maximum laut BLE-Spezifikation) und ~185+ auf iOS (automatisch von
CoreBluetooth verhandelt). Nach Abzug des ATT-Headers (~3 Bytes) und des
JSON-Wrapper-Overheads von BleSegmentData ({"d":"...","s":N} addiert ~12
Bytes) passen 380 Zeichen Nutzlast bequem in eine einzelne ATT-MTU auf beiden
Plattformen. Der Wert ist symmetrisch (sowohl Client-Anfrage-Fragmente als
auch Server-Notification-Fragmente verwenden 380), was den Code einfach hält.
Warum 16 KiB für Datei-Chunks?
Ein 16-KiB-Datei-Chunk wird base64-kodiert zu ~22 KiB JSON, was in ~58
GATT-Notification-Segmente fragmentiert. Jeder requestAsync-Round-Trip
dauert über BLE Sekunden, daher verringern weniger-aber-größere Chunks den
Overhead pro Chunk. Viel größer würde riskieren, BLE-RPC-Timeouts zu treffen
und schlechtes Progress-Feedback zu erzeugen (der Nutzer sieht Progress nur
einmal pro Chunk aktualisiert). 16 KiB ist der empirisch ermittelte
Sweet Spot — groß genug für Durchsatz, klein genug für reaktionsfreudige
Progress-UI.
Das RPC-Primitiv: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Jede BLE-Chat-Nachricht und jeder Datei-Chunk ist ein Aufruf von
BleDeviceApi.requestAsync(service, requestData) — eine Suspend-Funktion, die
ein BleResult zurückgibt. Sie ist aus Sicht des Aufrufers synchron:
eine Anfrage → eine vollständig zusammengesetzte Antwort, kein Pipelining.
Wichtige Invarianten
- Eine Anfrage → eine Antwort.
requestAsyncist aus Sicht des Aufrufers synchron — sie kehrt erst zurück, nachdem die vollständige Antwort zusammengesetzt wurde. Es gibt kein Pipelining. - Notifications pro Aufruf aktiviert. Der Client schreibt das CCCD zu
Beginn jedes
requestAsyncund deaktiviert es am Ende. Das ist verschwenderisch (zwei zusätzliche GATT-Writes pro Aufruf), hält das Protokoll aber zustandslos — der Server muss nicht verfolgen, welche Clients „zuhören". - Kein Retry innerhalb eines RPC. Wenn ein einzelnes
writeCharacteristicein Timeout hat (5 s), bricht der gesamte RPC ab. NurensureConnectedunternimmt Retries (3 Versuche bei Verbindungsfehler). Grober Transport-Level-Backoff wird durchPeerCircuitBreakerbereitgestellt, nicht durch die RPC-Schicht.
Wire-Envelope-Format {#wire-envelope-format}
Die Nutzlast innerhalb der Layer-A-Segmente ist ein verschachtelter JSON-Umschlag. Streicht man die Fragmentierung, ist die logische Struktur:
Antwortform
Die Antwort fließt in die entgegengesetzte Richtung durch dieselbe Layer-A-
Fragmentierung, aber das innere JSON ist ein BleHttpResponse mit drei
Feldern: s (HTTP-Statuscode), h (Map der Antwort-Header) und b (Body).
Der Body wird immer base64-kodiert durch BleHttpCall.encodeResponse(),
auch wenn er leer ist — die Antwort könnte binär sein (verschlüsselte
GraphQL-Bytes, rohe /fs-Datei-Bytes) und der BLE-Transport ist
nur-String, sodass derselbe JSON-Umschlag sowohl Text- als auch
Binärnutzlasten transportiert.
Sende-Pfad für Nachrichten (End-to-End) {#message-send-path-end-to-end}
Alles zusammengeführt — was passiert, wenn eine Chat-Nachricht über BLE gesendet wird:
Beachtenswerte Designentscheidungen
- Derselbe Schlüssel wie LAN. Der gemeinsame ChaCha20-Schlüssel aus dem
Pairing wird für BLE wiederverwendet — es gibt keinen separaten BLE-Schlüssel.
Der OkHttp-Crypto-Interceptor, der von
LanTransportverwendet wird, und das manuellechaCha20Encrypt/chaCha20DecryptinBleTransportsind dasselbe Primitiv, nur unterschiedlich aufgerufen. - Dieselben Route-Handler wie LAN.
BleHttpRequestwird überHttpRouteRegistry.matchRoute(path)dispatched, also dieselbe Registry, die der Ktor-LAN-Server verwendet. So sind/peer_graphql,/fs,/peer_statususw. genau einmal implementiert und arbeiten identisch über beide Transporte. - Keine Wiederverwendung von Verbindungen. Der
finally { scanner.teardownConnection(client) }-Block läuft immer. Jede Nachricht zahlt die vollen connect→discoverServices→MTU-Kosten (~Sekunden). Das ist ein bewusster Trade-off — siehe Design-Trade-offs.
Download-Pfad für Dateien (End-to-End) {#file-download-path-end-to-end}
Downloads über BLE sind streaming — die Datei wird in 16-KiB-Chunks
gelesen und beim Eintreffen in eine Temp-Datei geschrieben, sodass eine 10-MB-
Datei nicht 10 MB RAM benötigt. Der Trick ist, dass der RPC jedes Chunks ein
separater requestAsync-Aufruf ist und die Chunks in einen ByteChannel
geschoben werden, den der Konsument nebenbei liest.
Warum Streaming statt eines großen RPC?
Eine 10-MB-Datei als einzelner RPC würde ~280 000 Notification-Segmente bedeuten, alle auf beiden Seiten im Speicher gehalten, bevor die Antwort überhaupt beginnen könnte — und die gesamte Übertragung müsste erfolgreich sein, bevor irgendein Progress gemeldet wird. Schlimmer noch, eine einzelne verlorene Notification in der Mitte würde das Ganze korrumpieren.
Das Chunking-Design hat drei Vorteile:
- Konstanter Speicher. Es ist immer nur ein 16-KiB-Chunk unterwegs.
- Live-Progress.
DownloadQueue.notifyProgressUpdate()feuert jede Sekunde, und die UI zeigt eine Download-Leiste. - Resilienz. Ein fehlgeschlagener Chunk kann unabhängig retried werden
(die
DownloadQueueunterstützt Pause/Resume/Retry auf Task-Ebene; ein Mid-Stream-Fehler belässt die partielle Temp-Datei, obwohl der Downloader sie bei Fehler derzeit löscht — siehe Trade-offs).
Warum onClose den Download-Job abbricht
Der DownloadedResponse.onClose-Callback ruft downloadJob.cancel() auf. Das
ist essenziell, weil die Download-Schleife in einer Child-Coroutine läuft, die
sonst für immer weiterlaufen würde, wenn der Konsument den Channel frühzeitig
aufgibt (z.B. Nutzer tippt auf Pause). Der AutoCloseable-Vertrag auf
DownloadedResponse bedeutet, dass der use { ... }-Block des Konsumenten
beim Verlassen automatisch onClose aufruft, die BLE-Download-Coroutine
abbricht und die GATT-Verbindung im finally-Block der Coroutine abbaut.
Priorisierung: Wie Chat Dateien in der Praxis schlägt {#prioritization-how-chat-beats-files-in-practice}
Das ist die wichtigste Frage für jede Chat-Anwendung: Wenn ein langsamer BLE-Datei-Download läuft, kann eine neue Chat-Nachricht vordrängeln?
Die ehrliche Antwort: Es gibt kein explizites Prioritätsschema
Es gibt kein Prioritätsfeld, keine Priority-Queue, keine Preemption
irgendwo im BLE-Code oder der Download-Queue. Ich habe das per erschöpfendem
Grep verifiziert — die einzigen priority-Treffer in shared/src sind
Log-Priority-Level und EXIF-Metadaten, nichts mit
Nachricht-vs-Download-Reihenfolge.
Was stattdessen existiert, ist eine Reihe architektonischer Trennungen, die das gewünschte Verhalten als emergente Eigenschaft erzeugen:
Warum es in der Praxis funktioniert
Die Trennung, die Chat „priorisiert" wirken lässt, ist strukturell:
- Chat-Sends gehen nicht durch
DownloadQueue. Sie werden direkt vonPeerGraphQLClient→PeerTransportRouter→BleTransport.sendabgesetzt. Daher sitzt eine Chat-Nachricht nie hinter einer Queue von Datei-Downloads. - Jeder
BleTransport-Aufruf öffnet seine eigene GATT-Verbindung. Ein lang laufender Download, der eine Verbindung hält, verhindert nicht, dass ein Chat-Send eine zweite Verbindung zum selben Peer öffnet. Android unterstützt mehrere simultane GATT-Verbindungen. - Chat-RPCs sind kurz. Eine einzelne Chat-Nachricht ist ein
requestAsync-Round-Trip (~1 s nach Connect). Selbst wenn das Radio mit einem Download beschäftigt ist, schließt der Chat-Send innerhalb weniger Sekunden ab.
Wo das Design Schwächen hat
Die Trade-offs von „keine explizite Priorität":
- Connect-Latenz. Sowohl Chat als auch Download zahlen die Connect→Discover→MTU-Kosten (~Sekunden) jedes Mal, da Verbindungen nicht wiederverwendet werden. Eine Chat-Nachricht, die während eines Downloads ankommt, kann nicht auf der bestehenden Verbindung des Downloads mitreiten — sie öffnet eine neue.
- Statische Queue auf Android. Die prozessweite
operationQueueimAndroidBleGattClientserialisiert GATT-Ops über alle Peers und alle Verbindungen hinweg. Während also zwei GATT-Verbindungen koexistieren können, werden ihre Write/Read/Notify-Operationen auf Queue-Ebene interleaved. In der Praxis ist das in Ordnung (jede Op ~ms), aber es ist ein subtiler globaler Flaschenhals bei hoher Nebenläufigkeit. - Keine Preemption. Ein laufender Download kann nicht pausiert werden, um eine Chat-Nachricht durchzulassen. Der Chat-Send läuft einfach gleichzeitig und konkurriert um Funkzeit.
Eine künftige Verbesserung könnte ein per-Peer-Mutex um BleTransport.send
und downloadFile sein, plus ein Prioritätsfeld auf der Queue — aber das
aktuelle Design verlässt sich darauf, dass Chat-RPCs kurz genug sind, dass
Contention für den Nutzer selten sichtbar wird.
Concurrency-Control & die statische GATT-Queue {#concurrency-control--the-static-gatt-queue}
Das verdient einen eigenen Abschnitt, weil es der subtilste Aspekt der Android-BLE-Implementierung ist.
Warum statisch (prozessweit)?
Der Android-BLE-Stack erlaubt keine gleichzeitigen GATT-Operationen auf
einer BluetoothGatt-Instanz — ein writeCharacteristic, während ein
anderes Write in flight ist, gibt false zurück und verwirft das zweite
Write stillschweigend. Der Standard-Workaround ist eine Queue pro BluetoothGatt.
PlainApp geht einen Schritt weiter und verwendet eine prozessweite Queue
(im companion object), was überkonservativ, aber korrekt ist: Sie
garantiert, dass keine zwei GATT-Operationen irgendwo in der App
gleichzeitig laufen.
Der Preis ist, dass die Write/Read/Notify-Operationen eines langen BLE-Datei-Downloads hinter (und werden queued hinter) den GATT-Operationen eines anderen Peers queue. Da jede einzelne Op ~ms dauert, ist das selten ein für den Nutzer sichtbarer Flaschenhals — aber unter schwerem gleichzeitigem BLE-Verkehr zu mehreren Peers könnte es einer werden.
Kein Per-Peer-Lock auf Transportebene
BleDeviceApi.requestAsync ist eine einfache suspend fun ohne Mutex, ohne
Queue, ohne Per-Peer-Serialisierung. Zwei gleichzeitige Aufrufe von
BleTransport.send für denselben Peer öffnen jeweils ihre eigene
GATT-Verbindung und gehen unabhängig voran. Die Serialisierung geschieht
implizit auf GATT-Operationsebene (über die statische Queue auf Android bzw.
über sequentielle await auf iOS).
Verbindungslebenszyklus & MTU-Verhandlung {#connection-lifecycle--mtu-negotiation}
Warum requestMtu(517)?
Die Standard-ATT-MTU beträgt 23 Bytes (nur 20 Bytes Nutzlast nach dem 3-Byte-ATT-Header). Mit der Standard-MTU würde jedes 380-Zeichen-Segment ~19 GATT-Writes statt 1 benötigen — eine 19-fache Verlangsamung. Das Anfordern der von der BLE-Spec erlaubten Maximal-MTU (517 Bytes) lässt die 380-Zeichen-Segmente in eine einzelne ATT-Operation passen, was den Durchsatz drastisch verbessert.
iOS stellt keine explizite MTU-Request-API bereit — CoreBluetooth verhandelt sie automatisch mit dem Peripheral während der Verbindung. Moderne iOS-Geräte verhandeln typischerweise ~185 Bytes, was die 380-Zeichen-Segmente weiterhin komfortabel aufnimmt (nach Abzug von ATT-Header + JSON-Wrapper-Overhead).
Flow-Control für Notifications {#flow-control-for-notifications}
Der Server sendet Antwort-Fragmente als Notifications, aber BLE-Notifications haben kein eingebautes Flow-Control — wenn der Server Notifications schneller sendet, als der Controller sie übertragen kann, werden sie stillschweigend verworfen. PlainApp implementiert explizites ack-basiertes Flow-Control:
Ohne dieses Flow-Control würden aufeinanderfolgende Notifications vom
BLE-Controller stillschweigend verworfen, wenn sich seine interne
Send-Queue füllt — ein bekanntes Android-BLE-Problem, das in den Kommentaren
des BleGattServer-Interface dokumentiert ist. Die Per-Device-Single-In-Flight-Regel
garantiert, dass jede Notification entweder übertragen wird oder ein
Timeout auslöst (das dann als Transportfehler behandelt wird).
Fehlerbehandlung: TransportUnavailable vs. echter Fehler {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable ist das Signal, das PeerTransportRouter sagt,
zum nächsten Transport zu fallen. Alles andere ist ein echter Fehler, der
an den Aufrufer zurückgegeben wird.
Die Subtilität beim Download-Versagen
BleTransport.downloadFile gibt
DownloadedResponse(200, channel, onClose) sofort zurück — die
Chunked-Download-Schleife läuft in einer Hintergrund-Coroutine, die auf den
Channel schreibt. Schlägt ein Chunk-RPC Mid-Stream fehl, ruft die Schleife
channel.close(TransportUnavailable(...)) auf, was bedeutet, dass der
Konsument (PeerFileDownloader.downloadAsync) den Fehler als geworfene
Exception aus channel.readAvailable(buf) sieht.
Das bedeutet, dass der PeerTransportRouter.downloadFile-Aufruf selbst
erfolgreich war (ein DownloadedResponse zurückgegeben hat), sodass der
Circuit Breaker für Mid-Stream-Download-Fehler keinen Fehler registriert.
Nur Connect-Time- und Scan-Time-Fehler werden vom Router erfasst. Das ist eine
bewusste Designentscheidung — ein Mid-Stream-Fehler sollte BLE für diesen
Peer nicht dauerhaft deaktivieren (der Peer ist vielleicht nur kurz außer
Reichweite geraten).
Referenz der Schlüsselkonstanten {#key-constants-reference}
| Konstante | Wert | Wo | Zweck |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | GATT-Segment-Fragmentierung | Größe jedes BleSegmentData.data (passt nach JSON-Overhead in ATT-MTU) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Datei-Download-Byte-Range | Größe jeder /fs-Chunk-Anfrage |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE-Scan | Timeout für scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC-Antwort | Per-Notification-Wartezeit in requestAsync |
AndroidBleGattClient MTU | 517 | Verbindungsaufbau | requestMtu(517) — Max laut BLE-Spec |
AndroidBleGattClient Connect-Timeout | 10 000 | Verbindungsaufbau | Warten auf STATE_CONNECTED |
AndroidBleGattClient MTU-Timeout | 5 000 | Verbindungsaufbau | Warten auf onMtuChanged |
AndroidBleGattClient Write-Timeout | 5 000 | GATT-Write | Warten auf onCharacteristicWrite |
AndroidBleGattClient Read-Timeout | 10 000 | GATT-Read | Warten auf onCharacteristicRead (für echte Daten ungenutzt) |
AndroidBleGattClient Notify-State-Timeout | 5 000 | CCCD-Write | Warten auf CCCD-Deskriptor-Write |
ensureConnected-Retries | 3 | Verbindungsaufbau | Bis zu 4 Versuche gesamt (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Notification-Flow-Control | Warten auf onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Antwort-Fragmentierung | Wie BleDeviceApi.CHUNK_SIZE |
IosBleGattServer Retry-Cap | 10 | Notification-Flow-Control | Max updateValue-Retries vor Aufgeben |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Transport-Circuit-Breaker | Open-Dauer nach Schwellwert |
PeerCircuitBreaker.MAX_FAILURES | 2 | Transport-Circuit-Breaker | Fehler im Fenster bis Open |
DownloadQueue.MAX_CONCURRENT | 3 | Download-Worker-Pool | Gleichzeitige Download-Coroutines |
BleServiceData.SHORT_ID_BYTES | 8 | Peer-Identifikation | Abgeschnittene SHA256-Präfix-Bytes |
BleServiceData.PAYLOAD_BYTES | 9 | Peer-Identifikation | 1 Flags-Byte + 8 shortId-Bytes |
BleSegmentData.STATE_START_BIT | 1 | Layer-A-EOF-Signalisierung | Erstes Segment einer Multi-Segment-Nachricht |
BleSegmentData.STATE_END_BIT | 2 | Layer-A-EOF-Signalisierung | Letztes Segment (oder Einzelsegment) |
Zusammenfassung der Design-Trade-offs {#design-trade-offs-recap}
Weiterführende Literatur
- Chat-Architektur — wie
BleTransportin dieLAN → Aware → BLE-Fallback-Kette und die übergeordnete Chat-Send/Empfangs-Pipeline passt. - Pairing-Ablauf — wie der gemeinsame ChaCha20-Schlüssel, den jede BLE-Nutzlast verwendet, etabliert wird, und wie die NEARBY-Characteristic für den Pairing-Handshake verwendet wird.