中文English

HeimLink App 蓝牙层架构

App 内蓝牙层的职责、模块与规则 · 定稿 · 2026-09-26

HeimLink 用手机蓝牙管理多台设备:随时知道每台设备是否在附近,按需连接其中一台完成控制、配网或 OTA,并在设备重启、换地址、断链后可靠地找回它。蓝牙层是 App 里夹在原生蓝牙库与业务代码之间的那一层,只仲裁射频资源,止于链路可用,不认识任何协议报文。它上面是按协议族实现的设备会话层,把链路变成可控制的会话;再上面是业务层,只声明需求。本页描述定稿的目标架构;已经实现的部分和迁移顺序见第十节。

一 · 在 App 里的位置

App 分四层,每层只依赖它下面的层。蓝牙层和设备会话层都属于基础库,蓝牙层是原生蓝牙库的唯一调用方。

路由与屏幕文件式路由,屏文件只挂一个功能模块的屏组件功能模块devices:设备列表 · 配网 · OTA · 控制页 · 设备设置logs · settings基础库蓝牙层(本页)设备会话层rtitek-bridgeloggerstorage平台react-native-ble-managerMMKVWebViewExpo 模块唯一调用方每层只依赖它下面的层;业务代码经设备会话层与蓝牙层使用蓝牙,不直接触碰原生库。

业务代码指设备列表、控制页、配网、OTA 和 WebView Bridge。它们不直接操作原生扫描和连接,只声明需求:要找哪台设备、要连接哪台设备、要独占哪台设备,然后拿到控制会话去用。协议内容(广播格式、AUTH、OTA 帧)以 HeimLink 协议规范为准,由设备会话层实现;为建链的每一步计时、超时后取消原生操作、记录链路状态,才是蓝牙层的事。

二 · 手机蓝牙的硬约束

来自平台文档、原生库源码、协议规范或真机日志,设计必须满足。

#约束对设计的影响
F1手机只有一个 BLE 射频;原生库同一时刻只维护一个扫描。多个业务的扫描需求必须合并到一个原生扫描上。
F2Android 7.0 起,一个 App 在 30 s 内最多启动 5 次扫描,按最近 5 次已停止扫描的启动时间判定。较早版本超出后不报错也收不到结果;较新版本回调 SCAN_FAILED_SCANNING_TOO_FREQUENTLY(值 6,Android 13 起公开),原生库把它放在停扫事件的 status 字段里。扫描额度由一处计数;系统回调是权威信号;扫描要长期保持,不能反复启停。
F3Android 的 connectGatt 没有超时参数,协议栈约 30 s 后才报失败;JS 侧停止等待不会取消原生连接。每一步超时后必须主动断开挂起的 GATT,不能只停止等待。
F4Android 保留 App 建立的每一条 GATT 连接;被连接的设备通常停止广播。每条链路都要记录在 App 状态里,否则设备会在扫描中"消失"。
F5使用随机静态地址的设备每次启动都可能换地址。地址不是身份,只用来挑候选。
F6设备重启时,手机要等监督超时才发现链路断开;重启前设备可能仍用旧地址广播。找回设备只接受需求提交之后收到的广播。
F7部分 Android 厂商的省电服务会强制停止后台 App,连接随之断开。只保证前台。
F8Android 息屏后、iOS 进入后台后,不带服务 UUID 过滤的扫描会被限制或停止。后台需要服务 UUID,见 F13。
F9设备的应用层请求可能收到链路层确认,却得不到应答。事务超时要覆盖写入与等待应答的全程。
F10Android 会把持续超过 30 min 的无过滤扫描降级为 opportunistic 模式,之后只在其它 App 扫描时才有结果,且不产生任何事件(待真机验证)。调度器自己计时,在期限前主动重启一次。
F11iOS 的断开事件只带 CoreBluetooth 错误码,没有 HCI 原因码。失败分类要有 iOS 一列。
F12原生库在 Android 上已按外设串行执行 GATT 操作。蓝牙层的事务定义在往返级别,不重做 GATT 队列。
F13现行 19 B 广播只有 Flags、本地名和厂商数据,没有服务 UUID;Company ID 是开发暂用的 0xFFFF。系统的服务 UUID 过滤匹配不到设备;按占位 ID 过滤不能识别设备。两端都不过滤,后台留待协议改动。

三 · 为什么要有这一层

从 0.4.12 到 2026-09-24,扫描、连接和重连相关的修复有 14 次,每次都修在出问题的业务路径上:配网和 OTA 各自处理扫描次数限制,设备匹配规则在四处各写一份,连接超时只保护传了参数的调用方,等待设备应答的编排没有总超时。同类问题在另一条路径上重复出现,因为规则由调用方遵守,而不是由共享层强制执行;各业务路径的测试也覆盖不到"配网刚结束、OTA 开始、列表页仍在扫描"这类组合。

一句话

把"何时启动扫描、何时建链、多久算超时、哪台设备是同一台、谁在独占"这些决定从业务代码收回到蓝牙层,让每条规则只有一份实现、一套测试;协议报文一律留在设备会话层。

四 · 三层与模块

蓝牙层内部分七个模块。上面两个回答"设备在哪、是谁",中间四个管理射频资源,最下面一个负责可观测。

业务代码设备列表 · 控制页 · 配网 · OTA · Bridge声明需求:扫描需求 · 建链 · 独占 · 使用控制会话设备会话层按协议族各一份:SecureLink 应用帧信道 · ShimLink 安全帧 · 按特征值的数据点认证 → 控制会话 · 自动重连 · 截止时间向下传递acquireLink · link.transaction · subscribe · acquireDevice蓝牙层在场信息每台已保存设备最近一次被看到的事实设备匹配广播 → 候选(唯一、多个或无),唯一实现扫描调度合并需求 · 扫描额度结果分发 · 暂停与重启连接管理链路状态机 · 建链超时取消 · 断开分类链路事务按链路串行往返事务超时 · 取消独占管理配网 / OTA 持有令牌每台设备一个持有者诊断只读快照 · 事件列表 · 日志 · 调试工具台 BLE 标签页止于链路可用,不认识任何协议报文:何时扫描、何时建链、往返怎样排队、谁在独占只有扫描调度与连接管理可以调用原生适配调用原生库、转发事件(发现、扫描停止、断开、通知、蓝牙开关)状态码原样转发,不做解释react-native-ble-managerAndroid / iOS 系统蓝牙栈
模块职责对上暴露什么
原生适配调用原生库、转发原生事件(发现、扫描停止、断开、通知、蓝牙开关)。状态码原样转发,不做解释。不暴露;只有扫描调度与连接管理可以调用。
扫描调度合并五种扫描需求到一个原生扫描上,执行扫描额度,分发候选,处理暂停与重启。requestScan(need),返回可释放的句柄。
在场信息只记事实:每台已保存设备最近一次被看到的时间、信号强度和地址。"在不在场"的判定归设备列表。只读。
设备匹配把一条广播对应到候选:唯一、多个或无。SecureLink 按协议标识、绑定标志、品类和 discriminator 匹配,ShimLink 按其协议规定的字段匹配。全 App 只有这一份实现。候选随广播一起投递。
连接管理链路状态机,止于 connected;每一步有默认超时,超时即取消原生操作;建链原语 acquireLink;断开原因分类上报,自己不重连。acquireLink(need, { deadline }),返回已 connected 的链路和候选。
链路事务同一链路同时只有一个出站往返,超时取消,调用方放弃即释放;设备推送的通知不受限制。link.transaction(fn, { timeoutMs })、link.subscribe。
独占管理配网、OTA 持有一台设备的令牌,每台设备一个持有者;自动重连和状态同步动作前先查询。acquireDevice(target, owner)、isHeld(target)。
诊断维护只读快照和最近事件列表,每个决定都有日志。调试工具台的 BLE 标签页订阅快照。

设备会话层在蓝牙层之上,按协议族各一份:SecureLink 的应用帧信道、ShimLink 的安全帧、按特征值建模的数据点。它在 connected 的链路上用事务完成认证、订阅通知、发首个 GET_STATE,然后发布控制会话;它把业务或 Bridge 给的截止时间传给建链和每个事务,Bridge 契约的默认值不变;它还提供自动重连这一共用行为,控制页和 OTA 使用,设备列表不使用。

五 · 十条原则

  1. 蓝牙层止于链路,不认识协议。 状态机止于 connected,不解析任何报文;会话层用它的原语实现协议。
  2. 业务代码不直接操作原生扫描和连接。 用 ESLint 限制原生适配的导入方,靠工具而不是约定。
  3. 原生扫描只有一个,业务需求合并到它上面,不过滤,没有优先级。
  4. Android 扫描次数限制由蓝牙层统一执行。 系统的限频回调是权威信号,JS 侧计数只用于预判。
  5. 找设备时扫描持续到任务结束,只在建链暂停和 30 min 期限两种情况下重启。
  6. 同一时间只有一条链路,每一步都有默认超时,超时即取消原生操作;截止时间自上而下传递。
  7. 设备身份只由认证确定。 候选可能不止一个,凭据只在候选唯一或全部被拒时才标为可疑。
  8. 同一条链路上的事务串行,每个事务都有超时;设备推送的通知不受限制。
  9. 独占由蓝牙层管理,持有令牌每台设备一个,自动重连先问它。
  10. 每个决定都能从日志和调试工具台看到。

六 · 扫描调度:一个原生扫描

设备列表、控制页、配网、OTA 的需求合并到同一个原生扫描上;各需求按自己的种类收候选。

设备列表在场:全部已保存设备控制页 · OTA 重连找一台已保存设备配网可配对候选 · 按 discriminator· 按定位标识requestScan(need)onCandidate(candidate, seenAt)扫描调度· 不过滤,没有优先级· 扫描额度:30 s 内最多 5 次启动· 每条广播先匹配,再按需求种类投递· 只投递需求提交之后收到的广播· 建链期间暂停,链路建立后恢复· Android 上 30 min 期限前重启一次· 最后一个需求释放后延迟几秒再停scan / stopScan广播 · 停止事件一个原生扫描连续扫描,不切片参数固定,无过滤停止只由调度器发出在场信息每条广播都经设备匹配后更新五种需求合并到一个原生扫描上;增减需求不重启它,只有建链暂停和 30 min 期限会重启。

七 · 链路:状态机、建链与事务

蓝牙层的状态机到 connected 为止;认证和控制会话是会话层在它之上做的事。

idle初始 · 断开完成connecting调用连接 · 扫描暂停超时 10 sdiscovering服务发现 · MTU 协商每步 10 sconnected链路可用,尚未认证蓝牙层到此为止设备会话层,在 connected 之上认证AUTH / REJOIN事务 · 15 s控制会话订阅通知 · GET_STATE可按 dataPoint 读写任一步超时或失败:取消原生操作(Android 上断开挂起的 GATT),回到 idle;截止时间由调用方自上而下传递disconnectingApp 请求断开或要连另一台设备 · 5 s断开完成,或 5 s 后以系统连接列表为准清理断开原因只分类上报,蓝牙层不重连同一时间只有一条链路。建链前先释放当前链路和其它设备的挂起连接;断开事件只影响所属设备的连接记录。
状态进入条件超时超时或失败后
idle初始;断开完成——
connecting调用连接;调度器同时暂停原生扫描10 s取消原生连接,回到 idle
discovering链路建立;包含服务发现、MTU 协商两步每步 10 s断开,回到 idle
connected链路可用:服务已发现、MTU 已知。会话层从这里开始认证或配对,通知订阅也在此之后由会话层完成——
disconnectingApp 请求断开,或另一台设备要建链5 s以系统连接列表为准清理状态,回到 idle

八 · 找回设备

设备重启或链路断开后找回设备,由会话层的自动重连执行;OTA、控制页、配网后的首次连接共用。

业务方设备会话层蓝牙层设备链路断开,业务仍持有会话 → 自动重连1 确认旧链路已断(OTA:等设备断开,最长 rebootInMs 加余量)2 isHeld?被独占则不动3 acquireLink({ kind: saved-device }, { deadline })广播(可能是新地址),只算提交之后的候选逐个建链,失败按 1、2、4 s 重试 → connected4 事务:AUTH / REJOIN(15 s)认证结果凭据被拒:候选唯一或全部候选都被拒 → 标记凭据可能失效,不再重试还有未尝试的候选 → reject() 换下一个;其它失败 → 回到第 3 步,直到截止时间5 更新地址与身份记录 · 订阅通知 · GET_STATE发布控制会话OTA 重启后、控制页断链后、配网结束后的首次连接都走这条流程;设备列表不使用自动重连。
  1. 确认旧链路已断。 可预期的重启(OTA 收到 REBOOT_SCHEDULED)先等设备断开链路,最长 rebootInMs 加余量,再由 App 主动断开。
  2. 查询独占。 目标设备被别的持有者独占时不动。
  3. 调用建链原语。 蓝牙层提交扫描需求、只接受提交之后的广播、按发现顺序逐个建链、建链失败按 1、2、4 s 重试,直到截止时间。
  4. 认证。 在事务里发 AUTH 或 REJOIN。凭据被拒时,候选唯一或全部候选都已被拒才标记凭据可能失效;还有未尝试的候选就换下一个。其它失败回到第 3 步。
  5. 认证成功后更新地址和身份记录,订阅通知,发首个 GET_STATE,发布控制会话。

九 · 失败怎么分类

断开原因:蓝牙层只分类上报,不重连

原因Android statusiOS CBError典型来源上报为
对端断开0x13 Remote User Terminated Connection7 peripheralDisconnected设备主动断开、重启可重连
链路超时0x08 Connection Timeout6 connectionTimeout超出范围、设备重启、连续两个事务超时可重连
建链失败0x3E Connection Failed to be Established、133 GATT_ERROR10 connectionFailed建立链路失败在建链原语内重试,最多 3 次后作为建链失败返回
App 发起按连接管理记录的"断开由 App 请求"标记判定,不看原因码释放链路、超时取消不可重连
蓝牙关闭、权限被拒由蓝牙状态与权限事件判定,不看原因码系统不可重连;清空扫描和连接状态,恢复后按在册需求恢复

未列入上表的原因码记录名称后按"对端断开"上报。要不要找回设备,由会话层的自动重连按业务是否仍持有会话决定。

事务失败

情况处理
单个事务超时调用方收到超时错误,链路释放
调用方先于超时放弃事务事务取消并释放链路,后续事务不排在它后面
同一链路连续两个事务超时断开链路并上报"链路超时";被独占时由持有者决定
设备返回协议错误交给会话层处理,蓝牙层不重试

十 · 现状与迁移

目标架构分十一步落地,每步单独提交、单独验证,改到扫描或链路的步骤各跑一次 TRV901Z 和 BK7238 的真机回归。截至 2026-09-26 尚未开始迁移;已具备的部分列在右栏。

步骤内容完成标志现已具备
0GATT 写入加默认超时;连接未传超时时使用默认值"写入永不回调""连接永不回调"的测试通过连接超时只在调用方传参时生效;等应答已有 10 s 超时
1扫描调度与扫描额度统一;旧的扫描接口转接为带自动释放的需求;迁移 OTA 重连和配网扫描;诊断快照随本步起步原则 3、4 的核对测试通过额度计数已在原生适配中,只有配网和 OTA 使用
2迁移其余扫描调用,五种需求类型定型,ESLint 限制按文件列表写原则 2 中关于扫描的核对通过—
3设备匹配合并为一份实现原则 7 的核对通过四处各有一份
4链路状态机止于 connected,默认超时,断开分类,建链前暂停扫描原则 6 的核对测试通过连接新设备前释放旧连接、断开只影响所属设备已实现;三条路径已是先停扫再连接
5acquireLink;自动重连、OTA 重连、配网后首连改用它两个同 discriminator 候选的场景测试通过—
6link.transaction;安全信道、ShimLink 帧、数据点读写改用它,删各自的互斥标志"请求永不应答""调用方先放弃"的测试通过—
7独占令牌;自动重连先查询令牌,删手写的暂停判断自动重连中不再有针对 OTA 的特判OTA 期间暂停自动重连是手写判断
8在场只记事实,设备列表自己判定设备列表不再直接解释扫描结果—
9会话层代码搬到独立目录,ESLint 改按目录蓝牙层目录不再导入协议常量—
10调试工具台 BLE 标签页收口快照里的每类事件都能在标签页看到—

待确认事项

HeimLink App Bluetooth Layer Architecture

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.

Routes & screensFile-based routing; each screen file only mounts one feature screenFeaturesdevices: device list · pairing · OTA · control · device settingslogs · settingsLibrariesBLE layer (this page)device sessionrtitek-bridgeloggerstoragePlatformreact-native-ble-managerMMKVWebViewExpo modulessole callerEach layer depends only on the layer below. Feature code reaches Bluetooth through the session layer and the BLE layer, never the native 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.

#ConstraintEffect on the design
F1The 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.
F2Since 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.
F3Android'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.
F4Android 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.
F5A device using a random static address may change it on every boot.The address is not the identity; it only selects candidates.
F6When 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.
F7Some Android vendors' power-saving services force-stop background apps, dropping the connection.Only the foreground is guaranteed.
F8With 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.
F9A 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.
F10Android 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.
F11iOS disconnect events carry only CoreBluetooth error codes, not HCI reason codes.Failure classification needs an iOS column.
F12On 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.
F13The 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.

Feature codeDevice list · control screen · pairing · OTA · BridgeDeclares needs: scan · link · exclusive access · uses a control sessionDevice session layerOne per protocol family: SecureLink frame channel · ShimLink secure frames · per-characteristic data pointsAuthentication → control session · auto-connect · deadlines passed downacquireLink · link.transaction · subscribe · acquireDeviceBLE layerPresenceWhen each saved device was last seen: time, RSSI, addressDevice matchingAdvertisement → candidates; one implementationScan schedulingMerges requests · quotaDispatch · pause & restartConnection managementLink state machineacquireLink · timeoutsLink transactionsOne round trip at a timeper link · timeout · cancelExclusive accessPairing / OTA hold a tokenone holder per deviceDiagnosticsRead-only snapshot · event list · logs · BLE tab in the debug consoleStops at a usable link and knows no protocol messages: when to scan, when to link, how round trips queue, who holds a deviceOnly scan scheduling and connection management may call itNative adapterCalls the native library and forwards its events (discovery, scan stop, disconnect,notifications, adapter state); status codes pass through untouchedreact-native-ble-managerAndroid / iOS Bluetooth stacks
ModuleResponsibilityWhat it exposes upward
Native adapterCalls 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 schedulingMerges 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.
PresenceFacts 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 matchingMaps 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 managementLink 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 transactionsOne outbound round trip at a time per link, cancelled on timeout, released when the caller gives up; device notifications are not affected.link.transaction(fn, { timeoutMs }), link.subscribe.
Exclusive accessPairing 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).
DiagnosticsKeeps 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

  1. 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.
  2. Feature code never drives the native scan or connection directly. An ESLint rule restricts who may import the native adapter: tooling, not convention.
  3. There is one native scan, feature needs are merged onto it, with no filter and no priority.
  4. 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.
  5. 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.
  6. One link at a time; every step has a default timeout that cancels the native operation; deadlines are passed down from the caller.
  7. 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.
  8. Transactions on one link run one at a time, each with a timeout; device notifications are not affected.
  9. Exclusive access is managed by the BLE layer: one token holder per device, and auto-connect asks it first.
  10. 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.

Device listpresence: all saved devicesControl screen · OTAfind one saved devicePairingcommissionable · by discriminator· by locatorrequestScan(need)onCandidate(candidate, seenAt)Scan scheduling· No filter, no priority· Quota: at most 5 starts per 30 s· Match first, then deliver by need kind· Only advertisements received after the request was made· Paused while a link is being set up· Android: restart once before 30 min· Lingers a few seconds after the last request is releasedscan / stopScanadverts · stop eventOne native scanContinuous, not slicedFixed params, unfilteredOnly the scheduler stops itPresenceUpdated by every advertisement after matchingFive kinds of need share one native scan; adding or removing a need never restarts it, only the link-setup pause and the 30 min limit do.

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.

idleinitial · after disconnectconnectingconnect called · scan pausedtimeout 10 sdiscoveringservices · MTU10 s per stepconnectedlink up, unauthenticatedthe BLE layer ends hereDevice session layer, on top of connectedauthenticateAUTH / REJOINtransaction · 15 scontrol sessionnotify · GET_STATEdataPoint reads/writesAny step times out or fails: cancel the native operation (on Android, disconnect the pending GATT), back to idle; deadlines come from the callerdisconnectingapp-requested disconnector switching device · 5 sdisconnect completes, or after 5 s reconcile with the OS connection listdisconnects are classified and reported; the BLE layer never reconnectsOne link at a time. Release the current link and any pending connection before linking another device; a disconnect event only affects its own device.
StateEntered whenTimeoutOn timeout or failure
idleinitial; a disconnect has completed——
connectingconnect is called; the scheduler pauses the native scan at the same time10 scancel the native connection, back to idle
discoveringlink established; covers service discovery and MTU negotiation10 s per stepdisconnect, back to idle
connectedlink usable: services discovered, MTU known. The session layer starts authentication or pairing here; notification subscriptions are also its job from here on——
disconnectingthe app requests a disconnect, or another device is about to be linked5 sreconcile state with the OS connection list, back to idle

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.

CallerSession layerBLE layerDevicelink dropped while the feature still holds the session → auto-connect1 confirm the old link is down (OTA: wait for the device to drop it, rebootInMs + margin)2 isHeld? do nothing while another holder owns the device3 acquireLink({ kind: saved-device }, { deadline })advertisement (possibly a new address), only after the requestlink candidates one by one, retry 1/2/4 s → connected4 transaction: AUTH / REJOIN (15 s)authentication resultCredentials rejected: only candidate, or all candidates rejected →flag the credentials as suspect and stop retrying.Untried candidates remain → reject() to try the next one.Any other failure → back to step 3 until the deadline.5 update address and identity record · subscribe · GET_STATEcontrol session publishedOTA reboots, control-screen link drops and the first connection after pairing all use this flow; the device list does not use auto-connect.
  1. 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.
  2. Ask the exclusive token. If another holder owns the device, do nothing.
  3. 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.
  4. 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.
  5. 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

ReasonAndroid statusiOS CBErrorTypical sourceReported as
Peer disconnected0x13 Remote User Terminated Connection7 peripheralDisconnectedthe device disconnected on purpose, or rebootedreconnectable
Link timeout0x08 Connection Timeout6 connectionTimeoutout of range, device rebooted, two consecutive transaction timeoutsreconnectable
Link setup failed0x3E Connection Failed to be Established, 133 GATT_ERROR10 connectionFailedthe link could not be establishedretried inside the link-setup primitive, returned as a link-setup failure after 3 attempts
App-initiateddecided by connection management's own "disconnect requested by the app" marker, not by reason codelink released, cancelled on timeoutnot reconnectable
Bluetooth off, permission denieddecided by adapter-state and permission events, not by reason codesystemnot 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

CaseHandling
A single transaction times outthe caller receives a timeout error and the link is released
The caller gives up before the timeoutthe transaction is cancelled and the link released; later transactions do not wait behind it
Two consecutive transactions on one link time outdisconnect the link and report "link timeout"; while the link is held exclusively, the holder decides
The device returns a protocol errorhanded 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.

StepContentDone whenAlready in place
0Default timeout for GATT writes; connect uses the default when the caller passes nonetests for "write never calls back" and "connect never calls back" passthe connect timeout only applies when the caller passes it; waiting for a reply already has a 10 s timeout
1Unify 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 snapshotverification tests for principles 3 and 4 passthe quota counter lives in the native adapter, but only pairing and OTA use it
2Migrate the remaining scan calls; fix the five kinds of need; write the ESLint restriction as a file listthe scan part of principle 2 passes—
3Merge device matching into one implementationprinciple 7 verification passesfour separate copies
4Link state machine ending at connected, default timeouts, disconnect classification, scan pause before link setupprinciple 6 verification tests passreleasing 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
5acquireLink; auto-connect, OTA reconnect and the first connection after pairing switch to itthe scenario with two candidates sharing a discriminator passes—
6link.transaction; the secure channel, ShimLink frames and data-point access switch to it and drop their own mutex flagstests for "request never answered" and "caller gives up first" pass—
7Exclusive token; auto-connect asks it first; hand-written pause checks removedauto-connect no longer special-cases OTApausing auto-connect during OTA is a hand-written check
8Presence records facts only; the device list decidesthe device list no longer interprets scan results—
9Session-layer code moves to its own directory; the ESLint rule becomes directory-basedthe BLE-layer directory imports no protocol constants—
10BLE tab in the debug consoleevery event kind in the snapshot is visible in the tab—

Open questions