信道发现 SDK 对外接入文档
SDK Version: 1.0.0
适用范围:首次安装后第一次进入设备,以及用户后续在设置中点击“重新搜索信道”。 V1 通过“好友抽样 probe / 无好友 generic Mesh probe + mesh ACK”判断当前信道在本机当前射频范围内是否有人;不枚举全量设备,不统计真实人数,不处理网络分裂。
能力概览
信道发现 SDK 接收一组显式、有顺序的 ChannelPlan 并逐个切信道。当前产品 App 必须把 Mesh SDK 的 AiTalkMeshRadioProfile.channels 映射为计划,不能再用可配置起点或等差公式生成正式表。这里的 channel_id=1..16 是正式 RF/FSK 表的 CH 行号;它与 MeshWire 加密域的 publicChannel、二维码配对/KDF 输入的 pairChannel 是三个彼此独立的概念。
正式表如下,BCNID 与 CH 一一对应:
| RF CH | 频率 | BCNID |
|---|---|---|
| 1 | 470.250 MHz | 1 |
| 2 | 475.250 MHz | 2 |
| 3 | 480.250 MHz | 3 |
| 4 | 485.250 MHz | 4 |
| 5 | 490.250 MHz | 5 |
| 6 | 495.250 MHz | 6 |
| 7 | 500.250 MHz | 7 |
| 8 | 505.250 MHz | 8 |
| 9 | 440.200 MHz | 9 |
| 10 | 440.600 MHz | 10 |
| 11 | 441.000 MHz | 11 |
| 12 | 441.400 MHz | 12 |
| 13 | 441.800 MHz | 13 |
| 14 | 442.200 MHz | 14 |
| 15 | 442.600 MHz | 15 |
| 16 | 443.000 MHz | 16 |
扫描严格按表行/CH 顺序,不按频率排序;CH8 后会跳到频率更低的 CH9。AiTalkMeshRadioProfile.PROFILE_ID/profileId = 11_498 是正式表的稳定标识。数值 11_498 只是保留旧 channelPlanId(470.250MHz) 的 wire 值,正式表已不再由该起点派生。ChannelPresenceChannelPlan.channels(startFrequencyHz) 的 start + index * 2MHz 行为只保留给 caller-defined generic/custom/legacy 候选计划,不是当前产品规范。
每次进入某个消息信道,App host 必须先配置频点,再配置该信道对应的 BCNID:AT+FREQ=<frequency_hz> 成功后发送 AT+BCNID=<bcn_id>;两步都必须等待设备返回成功 AT_OK,不能只确认 BLE/UART 写入完成;两步都成功后才允许发 probe、等待 ACK 或常驻。配置普通 Mesh 消息信道表之外的频点时,必须配置 AT+BCNID=0。
每到一个信道,如果本地有好友,SDK 从本地好友里抽 3~8 个候选好友发送静默 probe;如果本轮本地还没有好友候选,SDK 在每个信道发送 1 个 generic Mesh presence probe。只要本机听到 probe 被 mesh 转发、收到匹配 packet ID 且同信道的 ACK,或 App host 在当前 dwell 窗口内实际收到 Mesh RF 帧,就认为该信道在当前范围内有人;只有 ACK 命中目标好友时,才把该信道标记为 有好友。
顺序开机场景必须成立:A 已经完成扫描并常驻在信道 Y,B 后来扫描到信道 Y 时,B 发出的 generic probe 会被 A 按 Mesh 规则转发;B 听到自己的 packet 被转发后记录 MeshImplicitAck,并自动切到 Y。为了覆盖这段转发时间,App 传给 SDK 的无好友 dwellMs 必须用 Mesh flood settle 估算:estimateFloodSettleMs(rfPayloadBytes=42, hopLimit=currentHopLimit) + 500ms,最小 1500ms。40~260ms DID 错峰只用于增强多台设备同时扫描时的表现。
| 场景 | SDK 行为 | UI 行为 |
|---|---|---|
| 首次安装 | 自动扫描当前信道表一次;无好友时每信道发 1 个 generic Mesh probe | 有 ACK 时自动切到推荐信道;全程无 ACK 时继续扫频并切到 RSSI 最优信道 |
| 设置页重新搜索 | 记录扫描前信道,逐信道抽样探测;没有好友候选时也发送 generic probe | 有 ACK 时自动切到推荐信道;全程无 ACK 时继续扫频并切到 RSSI 最优信道 |
| 未收到任何 ACK | 返回空列表或该信道不入列表 | 只代表本机当前范围、本轮扫描窗口未探到 |
| 扫到多个信道 | 按好友命中优先;没有好友命中时选择本轮扫描顺序中后扫到的可达信道 | 弹窗展示探测到的信道,并标记 有好友 |
V1 边界
必须做:
- Android 和 iOS 都以 SDK API 形式提供同一套能力。
- 使用 Mesh SDK 统一的
AiTalkMeshRadioProfile构造当前正式ChannelPlan;App 不复制表,Channel Presence SDK 也不从起点推导正式表。 ChannelPresenceChannel必须同时包含channelId、frequencyHz、bcnId;普通 Mesh 消息信道channelId=1..16、bcnId=channelId,非消息频点bcnId=0。- App host 切到消息信道时必须按
AT+FREQ->AT+BCNID的顺序配置,并等待设备返回AT_OK后才允许发送 probe;配置消息信道表之外的频点时必须写AT+BCNID=0。 - 有好友候选时,每个信道默认最多抽
8个好友,允许配置为3..8。 - 没有好友候选时,每个信道发送 1 个 generic Mesh presence probe,收到 packet ID 匹配的 Mesh 转发/ACK,或当前 dwell 窗口内可匹配的 Mesh RF 帧时,只标记
当前范围有人,不标记有好友。 - generic Mesh presence probe 发包前必须按
local_did + channel_id计算40~260ms确定性错峰;Android/iOS 常量和 hash 公式必须一致。 - 无好友的每信道停留窗口必须由 App 按 Mesh flood settle 计算,最小
1500ms,不能固定800ms。 - ACK 必须携带或附带真实
channelId;SDK 保存结果前必须校验ack.channelId == sentProbe.channelId,不一致时丢弃,避免异频接收污染结果。App host 不得用“当前正在扫描的信道”补写普通无真实信道来源的 Mesh ACK。 - App host 可以实现当前信道 RX 窗口:成功切到某信道并且 probe 发送成功后,在 dwell 超时前收到的 Mesh RF 帧可转成
implicit=true的ChannelPresenceMeshAck,channelId使用当前 dwell 信道;切信道、超时或扫描结束必须清空窗口。 - 当前信道 RX 窗口必须解析 Mesh 包头里的
fromNode/packetId/relayNode。fromNode是原始发送者,不是回复者;fromNode == localNodeId时,只有packetId等于当前已发送 probe 且relayNode是远端短 ID,才说明有其他节点转发了本机 probe,可创建普通可达证据。 - probe 是静默业务,不进入聊天 UI,不生成聊天消息,不触发通知。
- 收到 mesh implicit ACK 时,标记该信道
当前范围有人。 - 收到目标好友 routing ACK 或好友加密 app ACK 时,标记该信道
有好友。 - 扫到活跃信道后,App 自动切到推荐信道:优先
有好友,否则使用本轮扫描顺序中后扫到的可达信道。例如正式表同时探到CH4=485.250MHz和CH16=443.000MHz且都没有好友标记时,应选择后扫到的CH16;不能改成按频率高低选择。 - 首次安装和设置页重新搜索全程无 ACK 时,App 继续执行本地 RSSI 扫频,选择 RSSI 最优信道并常驻。
- 设置页重新搜索前记录当前/上次信道;只有扫描失败或用户取消时才保持原信道。
明确不做:
- 不解决网络分裂。
- 不同步完整成员表。
- 不统计信道内实际设备总数。
- 不广播询问所有设备都回复。
- 不协商 network_id、leader、epoch 或常驻信道。
- 不创建网络;首次安装无可达设备时只做本机信道选择。
术语
| 名称 | 含义 |
|---|---|
AiTalkMeshRadioProfile | Mesh SDK 拥有的正式 16 行 RF/FSK 频点与 BCNID 表。 |
meshInitialFrequencyHz | 仅供 generic/custom/legacy 连续候选计划工厂使用的起点;当前正式表不使用它。 |
ChannelPlan | 调用方显式传给扫描 SDK 的有序 RF 候选行;当前产品由 AiTalkMeshRadioProfile.channels 映射得到。 |
channel_plan_id | 正式表固定为 11_498;custom/legacy 计划才可使用起点派生辅助函数。 |
channel_id | 本文扫描结果中的正式 RF/FSK CH 行号 1..16,不是 publicChannel 或 pairChannel。 |
bcn_id | 固件 BCNID 参数。普通 Mesh 消息信道为 bcn_id=channel_id,即 1..16;0 保留给默认/非消息频点。 |
publicChannel | MeshWire 逻辑/加密域字段,不决定 RF 频率。 |
pairChannel | 二维码/业务配对值,只参与 MeshPairingPsk 派生,不决定 RF 频率。 |
scan_id | 单次扫描随机 ID,用于匹配本次 probe 和 ACK。 |
| Probe 好友 | 本信道被 SDK 抽中发送静默 probe 的本地好友。 |
| Generic Mesh presence probe | 没有好友候选时发送的广播 Mesh 探测包,用于快速判断当前范围内是否有 Mesh 节点。 |
| Generic probe 错峰窗口 | generic probe 发包前的 40~260ms 确定性等待窗口,由 local_did + channel_id 派生,用于让同时扫描的新设备在同一信道内错开发包。 |
| Generic probe dwell 窗口 | 无好友每个信道等待 Mesh ACK 的窗口,由 Mesh flood settle 估算,最小 1500ms。 |
| 当前范围有人 | 本机在当前信道、当前射频范围、当前扫描窗口内收到 mesh implicit ACK、routing ACK、好友 app ACK,或当前 dwell 窗口内 Mesh RF 帧。 |
| 有好友 | 本信道收到目标好友 routing ACK 或好友加密 app ACK。 |
| 未探测到 | 当前信道在本机当前范围内没有收到任何 ACK;按产品逻辑认为当前范围无人可达。 |
| RSSI 最优信道 | 首次安装或设置页重新搜索全程无 ACK 后,App 通过本地 AT+LISTEN 扫频得到的信号质量最佳信道。 |
| 上次信道 | 扫描前正在使用的信道,或用户上次选择进入的信道。 |
Android API
Android 独立模块为 Channel Presence SDK 1.0.0,App 通过 host adapter 调用,不直接修改 Mesh SDK 内部实现。
val officialChannels =
AiTalkMeshRadioProfile.channels.map { row ->
ChannelPresenceChannel(
channelId = row.channelId,
frequencyHz = row.frequencyHz,
bcnId = row.bcnId,
)
}
val result =
ChannelPresenceScanSdk(host)
.scan(
ChannelPresenceScanRequest(
localNodeId = localNodeId,
scanId = scanId,
channels = officialChannels,
candidates = friendCandidates,
trigger = ChannelPresenceScanTrigger.FirstInstall,
channelPlanId = AiTalkMeshRadioProfile.PROFILE_ID,
),
)
interface ChannelPresenceScanSdk.Host {
fun switchToChannel(channel: ChannelPresenceChannel)
fun sendProbe(probe: ChannelPresenceBuiltProbe): Boolean
fun waitForMeshAck(packetIds: Set<Int>, timeoutMs: Long): ChannelPresenceMeshAck?
}
data class ChannelPresenceScanRequest(
val localNodeId: Int,
val scanId: Int,
val channelPlanId: Int,
val channels: List<ChannelPresenceChannel>,
val candidates: List<ChannelPresenceFriendCandidate>,
val trigger: ChannelPresenceScanTrigger,
val requestedProbeCount: Int = 8,
val dwellMs: Long = 800L,
val hopLimit: Int = 3,
)
enum class ChannelPresenceScanTrigger {
FirstInstall,
SettingsRescan,
}
data class ChannelPresenceMeshAck(
val packetId: Int,
val ok: Boolean,
val implicit: Boolean,
val fromNode: Int?,
val relayNode: Int?,
val channelId: Int,
val rssiDbm: Int? = null,
)
data class ChannelPresenceChannel(
val channelId: Int,
val frequencyHz: Long,
val bcnId: Int,
)
ChannelPresenceChannelPlan.channels(startFrequencyHz) 仍可生成 16 个、2MHz 间隔且 bcnId=channelId 的 caller-defined generic/custom/legacy 候选计划;它不代表正式产品表。正式请求必须使用 AiTalkMeshRadioProfile.channels 的显式行和 AiTalkMeshRadioProfile.PROFILE_ID。如果 App host 临时配置调试频点或其他不属于消息信道表的频点,必须使用 ChannelPresenceChannelPlan.nonMessageChannel(...) 或等价逻辑把 bcnId 设为 0。
普通 ACK 的 channelId 必须来自底层/射频元数据报告的真实接收或回复逻辑信道;不得用 host 当前正在扫描并等待 ACK 的逻辑信道补写。SDK reducer 会用 packetId 找到发出的 probe,再校验 ack.channelId == sentProbe.channelId;不一致的 ACK 视为异频接收或跨信道残留 ACK,直接丢弃。底层 ACK 事件没有真实 channelId 时,host 不得把它直接传入 SDK 作为具体信道命中。
当前信道 RX 窗口是单独规则:host 在 switchToChannel 成功后打开窗口,在 sendProbe 成功后 arm 当前 probe packet ID;如果 dwell 超时前收到 Mesh RF 帧,host 可以合成 implicit=true 的 ChannelPresenceMeshAck(packetId=armedPacketId, channelId=currentDwellChannel)。该证据来自本机在当前信道物理收到的帧,只能标记普通 可达信道,不能标记 有好友。host 合成前必须校验包头:fromNode == localNodeId 时,packetId 必须等于当前 armed probe,且 relayNode 必须是远端短 ID。例如本机 DID 0xC1810528 在 CH9 dwell 内收到 from=0xC1810528 relay=0xA6 packetId=当前probe,表示 0xA6 转发了本机 probe,可以记录 CH9 普通可达;但这不是对方显式返回 CH9,不能标记好友,也不能当成对方常驻信道声明。
rssiDbm 来自收到该 Mesh RF 帧时的 transport/DI metadata,payload 内不强制携带。host 必须使用与 RSSI 扫频一致的机型/固件 offset 校准后再传入 SDK:display_rssi = raw_rx_rssi - rssi_offset_db。例如设备上报 raw_rx_rssi=-60、当前 TurMass offset 为 19dB,SDK 和 UI 记录/显示 -79 dBm。如果 transport 没有真实 RSSI metadata,不传 rssiDbm;不要把 0 这类缺省值显示成信号强度。SDK 对同一信道多次命中时保留最强的校准值,也就是数值更大的 dBm(如 -79 强于 -88)。该 RSSI 只用于结果展示和现场判断异频接收点,不单独决定可达性,也不替代 ack.channelId == sentProbe.channelId 校验。
host 层实现必须把“隐式 Mesh 转发”和“显式业务 ACK”分开:前者只说明当前 dwell 信道内有人参与转发;后者由 CHANNEL_PROBE_ACK 携带 src_did + channel_id,表示接收方明确告诉扫描方“我的当前逻辑信道”。如果产品希望无好友 generic probe 也拿到对方 DID 和信道,需要新增 generic app ACK;V1 generic path 不从 fromNode=本机DID 推断这些信息。
dwellMs 的默认值只适用于常规好友抽样扫描。candidates 为空时,App 必须显式传入按当前 Mesh 参数计算的 dwell:max(1500, estimateFloodSettleMs(42, hopLimit) + 500)。
data class ChannelPresenceFriendCandidate(
val nodeId: Int,
val displayName: String?,
val key16: ByteArray,
val lastKnownChannelId: Int?
)
候选好友由 App 注入,SDK 不直接读取 App 数据库。Android 可以用 FriendKeyStore、聊天数据库、最近会话和好友资料实现。如果 candidates 为空,SDK 会进入 generic Mesh presence probe 路径;设置页重新搜索也不能提前空返回。
data class ChannelPresenceScanResult(
val activeChannels: List<ChannelPresenceChannelResult>,
val bestScannedChannel: ChannelPresenceScannedChannel? = null,
)
data class ChannelPresenceChannelResult(
val channelId: Int,
val friendMarkers: List<ChannelPresenceFriendMarker>,
val evidenceKinds: List<ChannelPresenceAckKind>,
val rssiDbm: Int? = null,
)
data class ChannelPresenceScannedChannel(
val channelId: Int,
val frequencyHz: Long,
val rssiDbm: Int,
)
enum class ChannelPresenceAckKind {
MeshImplicitAck,
TargetRoutingAck,
FriendAppAck,
}
探测判定
每个信道的判定口径:
- 收到
MeshImplicitAck:本机当前范围内有人转发了 probe,该信道可用。 - 收到
TargetRoutingAck:目标好友 DID 收到了 probe,该信道可用并标记有好友。 - 收到
FriendAppAck:目标好友 SDK 解密并处理了 probe,该信道可用并标记有好友。 - 无好友 generic probe 收到 ACK、收到其他节点转发本机 probe,或当前 dwell 窗口内听到其他节点 Mesh RF RX:只标记该信道
当前范围有人,friendMarkers为空。 - app ACK 的
channelId必须等于发出 probe 的channelId才能保存;否则丢弃。 - 未收到任何 ACK:当前信道在本机当前范围内未探测到可达设备。
未收到任何 ACK 是产品扫描结果,不表达该信道在其他位置、其他距离、其他天线状态下没有设备。
首次安装流程
if (!prefs.channelPresenceFirstInstallDone) {
val officialChannels = AiTalkMeshRadioProfile.channels.map(::toChannelPresenceChannel)
val result =
ChannelPresenceScanSdk(host).scan(
ChannelPresenceScanRequest(
localNodeId = localNodeId,
scanId = scanId,
channels = officialChannels,
candidates = friendProbeCandidateProvider.readAll(), // 可以为空
trigger = ChannelPresenceScanTrigger.FirstInstall,
channelPlanId = AiTalkMeshRadioProfile.PROFILE_ID,
),
)
val selected =
selectActiveChannel(result.activeChannels)
?: selectBestScannedChannelForChannelPresence()
selected?.let { radio.setResidentMessageChannel(it.channelId, it.frequencyHz, it.bcnId) }
prefs.channelPresenceFirstInstallDone = true
prefs.lastChannelPresenceScan = result.copy(bestScannedChannel = selected?.scannedChannel)
showChannelPresenceResult(result)
}
UI 规则:
- 扫描中展示进度:
信道搜索 / 正在搜索信道 xx%。iOS 和 Android 设置页入口必须显示相同百分比口径。 - 首次安装且无好友时,每个信道切换后先进入 generic probe 错峰窗口,再发送静默 generic Mesh probe,并使用 Mesh flood ACK dwell 窗口等待转发回包。
- 有活跃信道:自动切到推荐信道,推荐顺序是
有好友> 本轮扫描顺序中后扫到的可达信道。 - 活跃信道如果带
rssiDbm,必须在信道标记后显示校准值,例如可达信道 · RSSI -79 dBm或有好友的信道 · RSSI -79 dBm;iOS 和 Android 文案/数值口径保持一致。 - 无活跃信道:执行本地 RSSI 扫频,切到 RSSI 最优信道;最终结果不显示“当前未可达设备”作为结束状态。
- 首次安装或设置页重新搜索无 ACK 后切到 RSSI 最优信道时,结果中填充
bestScannedChannel,弹窗显示已切到的信道号和 RSSI。
设置页重新搜索流程
设置页增加一个操作项:
重新搜索信道
它是按钮/操作项,不是布尔开关。点击后进入扫描流程。
val result =
ChannelPresenceScanSdk(host)
.scan(
ChannelPresenceScanRequest(
localNodeId = localNodeId,
scanId = scanId,
channels = AiTalkMeshRadioProfile.channels.map(::toChannelPresenceChannel),
candidates = friendProbeCandidateProvider.readAll(),
trigger = ChannelPresenceScanTrigger.SettingsRescan,
channelPlanId = AiTalkMeshRadioProfile.PROFILE_ID,
),
)
showChannelSearchResultDialog(
activeChannels = result.activeChannels,
bestScannedChannel = result.bestScannedChannel,
)
弹窗规则:
- 标题:
信道搜索 - 列表项展示:信道号、是否有好友、可点击切换动作。
- 收到好友 ACK 的信道显示
有好友,可附好友昵称。 - 普通可达信道显示
可达信道。 - 如果该可达信道带
rssiDbm,在可达信道或有好友后追加RSSI xx dBm。 - 设置页手动重搜全程无 ACK 时继续执行本地 RSSI 扫频,并切到
bestScannedChannel。 - 如果已选出
bestScannedChannel,弹窗显示已根据信号质量切到信道 X(RSSI xx dBm),不显示“当前未可达设备”。 - 设置页手动重搜没有好友候选时,也要逐信道发送 generic Mesh probe;不能直接返回空结果。
- 首次安装无 ACK 后如果已扫频选出信道,显示同一套
已根据信号质量切到信道 X文案。 - 操作按钮:
确定。
候选好友抽样
每个信道最多抽 3..8 个好友,默认 8 个。推荐排序:
- 最近已知在该信道或相邻配置中出现过的好友。
- 最近聊天、最近连接或最近互动的好友。
- 其他好友按
hash(scan_id, channel_id, friend_did)的 u32 无符号值升序做随机排序。
一个扫描周期内同一信道不要重复 probe 同一好友。重复点击重新搜索时允许重新抽样。
数据持久化
App 侧保存:
channel_presence_first_install_done: bool
last_channel_presence_scan_at: timestamp
last_channel_presence_scan_result: ActiveChannel[]
last_best_scanned_channel_id: int?
last_best_scanned_frequency_hz: long?
last_best_scanned_rssi_dbm: int?
last_selected_channel_plan_id: int?
last_selected_channel_id: int?
last_selected_frequency_hz: long?
正式 profile 变化后,如果 last_selected_channel_plan_id 与当前不同,不能直接按旧 channel_id 切信道,必须重新通过当前 ChannelPlan 确认映射。
SDK 仍保留显式候选计划、推荐结果、App 发起切换和缓存恢复接口,方便未来实现“切到其他频点仍能找到并继续聊天”。当前版本没有联系人跨频自动发现/漫游、联系人跨频自动切换/恢复、跨频聊天或跨频通话恢复;现有失败/取消缓存恢复和通话结束恢复 message/default 状态不在这项未实现范围内。不能把这些接口写成已完成能力。