Responsibilities, modules and rules of the BLE layer inside the app · final · 2026-09-26
HeimLink manages several devices over the phone's Bluetooth: it needs to know at any time whether each device is nearby, connect to one of them on demand for control, pairing or OTA, and reliably find a device again after it reboots, changes address or drops the link. The BLE layer is the part of the app that sits between the native Bluetooth library and the feature code. It only arbitrates the radio, stops at a usable link, and knows no protocol messages. Above it sits the device session layer, one implementation per protocol family, which turns a link into a controllable session; above that, feature code only declares what it needs. This page describes the agreed target architecture. What is already implemented, and the migration order, are in section 10.
1 · Where it sits in the app
The app has four layers; each depends only on the layer below. The BLE layer and the device session layer both belong to the libraries layer; the BLE layer is the sole caller of the native Bluetooth library.
Feature code means the device list, the control screen, pairing, OTA and the WebView bridge. None of them drive the native scan or connection directly. They declare needs: which device to find, which device to link to, which device to hold exclusively, and then use the control session they get back. Protocol content (advertising formats, AUTH, OTA frames) follows the HeimLink protocol specifications and is implemented by the device session layer. Timing every step of link setup, cancelling the native operation on timeout and recording link state are what the BLE layer does.
2 · Hard constraints of phone Bluetooth
From platform documentation, native-library source, the protocol specification or device logs. The design must satisfy all of them.
#
Constraint
Effect on the design
F1
The phone has a single BLE radio; the native library keeps at most one scan at a time.
Scan requests from several features must be merged into one native scan.
F2
Since Android 7.0 an app may start at most 5 scans within 30 s, judged by the start times of the last 5 scans that have stopped. Older versions report no error and simply deliver no results; newer versions call onScanFailed with SCAN_FAILED_SCANNING_TOO_FREQUENTLY (value 6, public since Android 13), which the native library exposes in the status field of the scan-stop event.
The quota is counted in one place; the system callback is the authoritative signal; scans stay up instead of restarting.
F3
Android's connectGatt takes no timeout; the stack reports failure only after about 30 s, and giving up on the JS side does not cancel the native connection.
After every step's timeout the pending GATT must be disconnected explicitly; ceasing to wait is not enough.
F4
Android keeps every GATT connection the app creates; a connected device usually stops advertising.
Every link must be recorded in app state, otherwise the device "disappears" from scans.
F5
A device using a random static address may change it on every boot.
The address is not the identity; it only selects candidates.
F6
When a device reboots, the phone only notices after the supervision timeout; before rebooting, the device may still advertise with the old address.
Finding a device again accepts only advertisements received after the request was made.
F7
Some Android vendors' power-saving services force-stop background apps, dropping the connection.
Only the foreground is guaranteed.
F8
With the screen off on Android, or in the background on iOS, scans without a service-UUID filter are throttled or stopped.
Background needs a service UUID; see F13.
F9
A device may acknowledge an application-layer request at the link layer and still never answer it.
Transaction timeouts cover both the write and the wait for a reply.
F10
Android downgrades an unfiltered scan that runs longer than 30 min to opportunistic mode, after which results arrive only while other apps are scanning, with no event of any kind (to be verified on real devices).
The scheduler keeps its own timer and restarts the scan once before the limit.
F11
iOS disconnect events carry only CoreBluetooth error codes, not HCI reason codes.
Failure classification needs an iOS column.
F12
On Android the native library already serialises GATT operations per peripheral.
The BLE layer's transactions work at the level of round trips; it does not re-implement a GATT queue.
F13
The current 19-byte advertisement carries only Flags, the local name and manufacturer-specific data, with no service UUID; the Company ID is the development placeholder 0xFFFF.
The OS service-UUID filter cannot match the device, and filtering on a placeholder ID identifies nothing. Neither platform filters; background work waits for a protocol change.
3 · Why this layer exists
Between 0.4.12 and 2026-09-24 there were 14 fixes related to scanning, connecting and reconnecting, each made in the feature path where the problem showed up: pairing and OTA each handle the Android scan limit on their own, the device-matching rule is written four times, the connection timeout only protects callers that pass a parameter, and the orchestration that waits for a device reply has no overall timeout. The same problem reappears on another path because the rules are followed by callers instead of being enforced by a shared layer, and each path's tests cannot cover combinations such as "pairing just finished, OTA starts, the list screen is still scanning".
In one sentence
Take the decisions "when to start scanning, when to set up a link, how long counts as a timeout, which device is the same device, who holds a device" out of feature code and back into the BLE layer, so that every rule has one implementation and one set of tests; protocol messages stay in the device session layer.
4 · Three layers and the modules
The BLE layer has seven modules. The top two answer "where is the device and which one is it", the middle four manage the radio, and the bottom one provides observability.
Module
Responsibility
What it exposes upward
Native adapter
Calls the native library and forwards its events (discovery, scan stop, disconnect, notifications, adapter state). Status codes pass through uninterpreted.
Nothing; only scan scheduling and connection management may call it.
Scan scheduling
Merges the five kinds of scan need onto one native scan, enforces the scan quota, dispatches candidates, handles pauses and restarts.
requestScan(need), returning a releasable handle.
Presence
Facts only: when each saved device was last seen, with RSSI and address. Whether a device counts as present is decided by the device list.
Read-only.
Device matching
Maps an advertisement to candidates: one, several or none. SecureLink matches on protocol identifier, bound flag, category and discriminator; ShimLink on the fields its protocol defines. There is exactly one implementation in the app.
Candidates travel with the advertisement.
Connection management
Link state machine ending at connected; every step has a default timeout that cancels the native operation; the acquireLink primitive; classifies and reports disconnects, never reconnects on its own.
acquireLink(need, { deadline }), returning a connected link and its candidate.
Link transactions
One outbound round trip at a time per link, cancelled on timeout, released when the caller gives up; device notifications are not affected.
Pairing and OTA hold a token for one device, one holder per device; auto-connect and state sync ask before acting.
acquireDevice(target, owner), isHeld(target).
Diagnostics
Keeps a read-only snapshot and a list of recent events; every decision is logged.
The BLE tab of the debug console subscribes to the snapshot.
The device session layer sits above the BLE layer, one implementation per protocol family: the SecureLink frame channel, ShimLink secure frames, and data points modelled as characteristics. On a connected link it uses transactions to authenticate, subscribe to notifications and send the first GET_STATE, then publishes a control session. It passes the deadline given by the feature or the bridge down to link setup and to every transaction, so the bridge contract's defaults stay unchanged. It also provides auto-connect as a shared behaviour, used by the control screen and OTA but not by the device list.
5 · Ten principles
The BLE layer stops at the link and knows no protocol. Its state machine ends at connected and parses no messages; the session layer implements the protocol with its primitives.
Feature code never drives the native scan or connection directly. An ESLint rule restricts who may import the native adapter: tooling, not convention.
There is one native scan, feature needs are merged onto it, with no filter and no priority.
The Android scan limit is enforced by the BLE layer alone. The system's throttle callback is the authoritative signal; the JS-side counter is only a prediction.
When looking for a device, the scan lasts until the task ends; it restarts only for the link-setup pause and the 30 min limit.
One link at a time; every step has a default timeout that cancels the native operation; deadlines are passed down from the caller.
Device identity is established only by authentication. There may be several candidates; credentials are flagged only when the candidate was the only one or all were rejected.
Transactions on one link run one at a time, each with a timeout; device notifications are not affected.
Exclusive access is managed by the BLE layer: one token holder per device, and auto-connect asks it first.
Every decision can be seen in the logs and the debug console.
6 · Scan scheduling: one native scan
The needs of the device list, the control screen, pairing and OTA are merged onto the same native scan; each need receives candidates by its own kind.
Five kinds of need, matched by the scheduler. Presence of all saved devices; find one saved device; the list of commissionable candidates; find one device to pair by the discriminator in its SetupCode; find one device again by its system connection identifier, used only inside a single pairing session. Callers write no matching logic.
No filter, no priority. Native parameters are fixed: unfiltered, duplicates allowed, LowLatency on Android. The current advertisement has no service UUID and the Company ID is a placeholder, so a filter could neither match nor identify the device.
Only advertisements received after the need was registered are delivered. Discoveries cached before that are not, so finding a device again never runs into the pre-reboot address (F6).
Restarts happen in two cases only. The scan pauses while a link is being set up and resumes once the link is up or has failed; on Android it restarts once when it has run for almost 30 min. Both count against the quota and are scheduled by the scheduler.
Restarting after the system stops the scan is conditional. A throttle callback waits for quota; other cases restart at the earliest moment the quota allows, but only while the app is in the foreground; on returning to the foreground the registered needs are resumed.
After the last need is released the scan lingers a few seconds. Switching screens no longer burns quota on repeated start/stop cycles.
7 · Links: state machine, link setup and transactions
The BLE layer's state machine ends at connected; authentication and the control session are the session layer's work on top of it.
State
Entered when
Timeout
On timeout or failure
idle
initial; a disconnect has completed
—
—
connecting
connect is called; the scheduler pauses the native scan at the same time
10 s
cancel the native connection, back to idle
discovering
link established; covers service discovery and MTU negotiation
10 s per step
disconnect, back to idle
connected
link usable: services discovered, MTU known. The session layer starts authentication or pairing here; notification subscriptions are also its job from here on
—
—
disconnecting
the app requests a disconnect, or another device is about to be linked
5 s
reconcile state with the OS connection list, back to idle
The link-setup primitive acquireLink(need, { deadline }). Internally it registers the scan need, takes candidates in discovery order, drives the state machine, retries link-setup failures after 1, 2 and 4 s at most 3 times, and gives up at the deadline. It neither authenticates nor judges whether a candidate is the right device; after authenticating, the caller may call reject() to move to the next candidate.
The link transaction link.transaction(fn, { timeoutMs }). A lease: the session layer writes and waits for replies inside fn, and the BLE layer guarantees that only one fn runs on a link at a time. Default 10 s; OTA data frames, VERIFY and APPLY get values from the exclusive holder. When the caller gives up first, the link is released and later transactions do not wait behind it; two consecutive timeouts on one link disconnect it and report "link timeout", a rule the exclusive holder may switch off.
Default timeouts are BLE-layer constants, equal on both platforms. The deadline given by the feature or the bridge is the overall cap and is passed down; every step takes the smaller of its default and the remaining time. The bridge contract's defaults, 15 s for connect and 8 s for reads and writes, stay as they are.
The connection record is keyed by device. Services, characteristics, MTU and the current state all belong to that record; there are no device-independent global fields any more.
8 · Finding a device again
After a reboot or a dropped link, the session layer's auto-connect finds the device again. OTA, the control screen and the first connection after pairing share this flow.
Confirm the old link is down. For an expected reboot (OTA received REBOOT_SCHEDULED), wait for the device to drop the link, at most rebootInMs plus a margin, then let the app disconnect.
Ask the exclusive token. If another holder owns the device, do nothing.
Call the link-setup primitive. The BLE layer registers the scan need, accepts only advertisements received afterwards, links candidates one by one in discovery order and retries link-setup failures after 1, 2 and 4 s, until the deadline.
Authenticate. Send AUTH or REJOIN inside a transaction. When the credentials are rejected, they are flagged as possibly invalid only if the candidate was the only one or all candidates have been rejected; otherwise move to the next candidate. Any other failure goes back to step 3.
After authentication succeeds, update the address and identity record, subscribe to notifications, send the first GET_STATE and publish the control session.
9 · Classifying failures
Disconnect reasons: the BLE layer classifies and reports, never reconnects
Reason
Android status
iOS CBError
Typical source
Reported as
Peer disconnected
0x13 Remote User Terminated Connection
7 peripheralDisconnected
the device disconnected on purpose, or rebooted
reconnectable
Link timeout
0x08 Connection Timeout
6 connectionTimeout
out of range, device rebooted, two consecutive transaction timeouts
reconnectable
Link setup failed
0x3E Connection Failed to be Established, 133 GATT_ERROR
10 connectionFailed
the link could not be established
retried inside the link-setup primitive, returned as a link-setup failure after 3 attempts
App-initiated
decided by connection management's own "disconnect requested by the app" marker, not by reason code
link released, cancelled on timeout
not reconnectable
Bluetooth off, permission denied
decided by adapter-state and permission events, not by reason code
system
not reconnectable; clear scan and link state, resume the registered needs once restored
Reason codes not listed above are logged by name and reported as "peer disconnected". Whether to find the device again is decided by the session layer's auto-connect, according to whether the feature still holds the session.
Transaction failures
Case
Handling
A single transaction times out
the caller receives a timeout error and the link is released
The caller gives up before the timeout
the transaction is cancelled and the link released; later transactions do not wait behind it
Two consecutive transactions on one link time out
disconnect the link and report "link timeout"; while the link is held exclusively, the holder decides
The device returns a protocol error
handed to the session layer; the BLE layer does not retry
10 · Current state and migration
The target architecture lands in eleven steps, each committed and verified on its own; steps that touch scanning or links each get a real-device run on TRV901Z and BK7238. As of 2026-09-26 the migration has not started; what already exists is listed in the right-hand column.
Step
Content
Done when
Already in place
0
Default timeout for GATT writes; connect uses the default when the caller passes none
tests for "write never calls back" and "connect never calls back" pass
the connect timeout only applies when the caller passes it; waiting for a reply already has a 10 s timeout
1
Unify scan scheduling and the quota; route the old scan calls through the scheduler as auto-released needs; migrate the OTA reconnect and pairing scans; start the diagnostics snapshot
verification tests for principles 3 and 4 pass
the quota counter lives in the native adapter, but only pairing and OTA use it
2
Migrate the remaining scan calls; fix the five kinds of need; write the ESLint restriction as a file list
the scan part of principle 2 passes
—
3
Merge device matching into one implementation
principle 7 verification passes
four separate copies
4
Link state machine ending at connected, default timeouts, disconnect classification, scan pause before link setup
principle 6 verification tests pass
releasing the old link before linking a new device, and disconnects affecting only their own device, are implemented; all three paths already stop scanning before connecting
5
acquireLink; auto-connect, OTA reconnect and the first connection after pairing switch to it
the scenario with two candidates sharing a discriminator passes
—
6
link.transaction; the secure channel, ShimLink frames and data-point access switch to it and drop their own mutex flags
tests for "request never answered" and "caller gives up first" pass
—
7
Exclusive token; auto-connect asks it first; hand-written pause checks removed
auto-connect no longer special-cases OTA
pausing auto-connect during OTA is a hand-written check
8
Presence records facts only; the device list decides
the device list no longer interprets scan results
—
9
Session-layer code moves to its own directory; the ESLint rule becomes directory-based
the BLE-layer directory imports no protocol constants
—
10
BLE tab in the debug console
every event kind in the snapshot is visible in the tab
—
Open questions
iOS defaults: Core Bluetooth connection requests never time out on their own; the default timeouts apply to iOS as well, and the values need verification on real iOS devices without changing the structure.
F10 on the target phones: unverified. If no downgrade happens, the 30 min restart is harmless and the rule stays.
Protocol side: a service UUID in the advertisement: the shared precondition for background scanning, screen-off scanning and iOS background reconnection, an over-the-air protocol change. Once it lands, filtering is introduced on both platforms as a fixed scheduler policy.
Advertising interval in the product contract: the basis for the device list's presence window, pending the firmware team's TRV901Z intervals with the screen on and off; the BLE layer is unaffected.