当前位置: 首页 > news >正文

【android bluetooth 协议分析 14】【HFP详解 1】【案例一: 手机侧显示来电,但车机侧没有显示来电: 讲解AT+CLCC命令】

1. 背景

今天上报了一例, 手机是连接了 蓝牙的。 但此时来电时,车机侧不显示来电。可以在手机侧看到来电。

这里简单分享一下这个问题。 借着这个问题, 我们讲解一下 :

  • Sent AT+CLCC
  • Rcvd +CLCC: 1,1,4,0,0,“173xxxxxxx7”,129," 173 xxxxxx7 "

2. 案例分析

1. 问题情况日志:

在这里插入图片描述

  • 从 btsnoop 文件中很清楚的看到
  • 我们车机下发了 Sent AT+CLCC
145585	2025-06-03 15:07:17.284728	22:22:29:ba:87:e9 (xxx_87e9)	e4:aa:e4:6b:c9:22 (xxx)	HFP	22	Sent AT+CLCC 
  • 手机只回复了 ok
145590	2025-06-03 15:07:17.293452	e4:aa:e4:6b:c9:22 (xxx)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	20	Rcvd   OK  

并没有回复 对应的 来电信息。

06-03 15:07:17.294828 14402 14468 D HeadsetClientStateMachine: Connected: command result: 0 queuedAction: 50
06-03 15:07:17.294838 14402 14468 D HeadsetClientStateMachine: queryCallsDone
06-03 15:07:17.294857 14402 14468 D HeadsetClientStateMachine: currCallIdSet [] newCallIdSet [] callAddedIds [] callRemovedIds [] callRetainedIds []
06-03 15:07:17.294867 14402 14468 D HeadsetClientStateMachine: ADJUST: currCallIdSet [] newCallIdSet [] callAddedIds [] callRemovedIds [] callRetainedIds []
  • 在协议栈中, 查询当前电话的状态, 可以看到, 啥也没有 check 到。所以没有给 telecom 上报电话。此时车机也就看不到 来电信息。

2.正常的日志

在这里插入图片描述

同样是正常的来电,车机下发 Sent AT+CLCC 手机是有对应的回应的。

174913	2025-06-03 15:09:58.108299	22:22:29:ba:87:e9 (xxx_87e9)	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	HFP	22	Sent AT+CLCC 174915	2025-06-03 15:09:58.259760	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	69	Rcvd   +CLCC: 1,1,4,0,0,"173xxxxxxx7",129," 173 xxxxxx7 " 174916	2025-06-03 15:09:58.305326	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	19	Rcvd   OK  

3. AT+CLCC+CLCC 讲解

在蓝牙电话(Hands-Free Profile, HFP)中,AT+CLCC+CLCC 是用于查询当前通话列表状态的重要命令。下面将结合 HFP 协议规范(Bluetooth HFP 1.8 或更高版本)对这两个命令进行详细分析,并结合车机(Hands-Free unit, HF)与手机(Audio Gateway, AG)之间的交互流程。


1. 命令交互背景说明

动作主体命令方向命令内容含义
车机(HF)发送AT+CLCC查询当前所有通话(Call List)
手机(AG)响应+CLCC: ...返回一个或多个通话状态详情

这个命令用于实现类似于“显示当前所有正在进行的通话(包括通话状态、是否是拨出、是否是会议等)”的功能。


2.命令详解

1. 车机发送的命令:AT+CLCC

  • 定义:AT 命令,表示“List Current Calls”

  • 格式
    AT+CLCC

  • 含义
    请求手机(AG)返回当前处于活跃状态的通话(包括呼出、呼入、保持、挂起等),每一条通话信息会以 +CLCC: 开头返回。

  • 常见触发场景

    • 用户按下车机上的“通话列表”按钮;
    • 通话状态发生变化后自动触发(如接听、拨出等);
    • HF 想同步 AG 的通话状态时主动发起。

2. 手机响应的命令:+CLCC:

  • 格式
    +CLCC: <idx>,<dir>,<status>,<mode>,<mpty>[,<number>,<type>]

    • idx:通话索引(1~7)

    • dir:方向(0=手机发起,1=手机接收)

    • status:状态(详见下表)

    • mode:音频模式(0=语音)

    • mpty:是否为会议通话(0=否,1=是)

    • number:可选字段,电话号码

    • type:电话号码类型(见 GSM 07.07)

1. 通话状态码说明(status):
status 值含义
0活跃(active)
1保持(held)
2拨号中(dialing)
3振铃中(alerting)
4呼入(incoming)
5呼叫等待(waiting)
2. 示例响应:
+CLCC: 1,0,0,0,0,"1234567890",129
+CLCC: 2,1,4,0,0,"9876543210",129

表示:

  • 通话1是手机拨出的,已经处于活跃状态;
  • 通话2是呼入电话,尚未接听。

3. 实际交互流程图(车机为 HF,手机为 AG)


HF (车机)                          AG (手机)|                                 ||---> AT+CLCC ------------------> |   // 请求通话状态|                                 ||<--- +CLCC: 1,... -------------- |   // 返回通话1信息|<--- +CLCC: 2,... -------------- |   // 返回通话2信息(若有)|<--- OK ------------------------ |   // 结束响应

4. 典型使用场景分析

场景 1:车机显示当前通话列表

  • 用户在车机上查看通话状态
  • 车机发送 AT+CLCC
  • 手机返回当前通话信息(如当前通话是拨号中、振铃中等)

场景 2:通话状态同步

  • 手机接到电话,但车机未收到 RING
  • 车机可通过轮询 AT+CLCC 获得当前呼入状态并在屏幕上提示用户

场景 3:多通话处理

  • 手机有多个通话(一个保持、一个活跃)
  • +CLCC: 会返回多个条目,车机可选择切换

5. HFP 协议相关规范出处

  • 参考规范
    • Bluetooth HFP 1.8+ Specification
    • GSM AT command set (3GPP TS 27.007)

6. aosp 中源码分享

在 aosp 中我们是如何在来电的时候触发 查询当前的 电话列表的呢?

在这里插入图片描述

// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.javapublic synchronized boolean processMessage(Message message) {logD("Connected process message: " + message.what);switch (message.what) {
...case QUERY_CURRENT_CALLS: // 3. 处理 QUERY_CURRENT_CALLS 事件removeMessages(QUERY_CURRENT_CALLS);if (DBG) {Log.d(TAG, "mClccPollDuringCall=" + mClccPollDuringCall);}// If there are ongoing calls periodically check their status.if (mCalls.size() > 1&& mClccPollDuringCall) {sendMessageDelayed(QUERY_CURRENT_CALLS,mService.getResources().getInteger(R.integer.hfp_clcc_poll_interval_during_call));} else if (mCalls.size() > 0) {sendMessageDelayed(QUERY_CURRENT_CALLS,QUERY_CURRENT_CALLS_WAIT_MILLIS);}queryCallsStart(); // 4. 这里会触发向 手机查询 当前 的通话列表break;...case StackEvent.STACK_EVENT:Intent intent = null;StackEvent event = (StackEvent) message.obj;logD("Connected: event type: " + event.type);switch (event.type) {                        case StackEvent.EVENT_TYPE_CALL:case StackEvent.EVENT_TYPE_CALLSETUP: // 1. 每次来电都会触发 setup 回调case StackEvent.EVENT_TYPE_CALLHELD:case StackEvent.EVENT_TYPE_RESP_AND_HOLD:case StackEvent.EVENT_TYPE_CLIP:case StackEvent.EVENT_TYPE_CALL_WAITING:sendMessage(QUERY_CURRENT_CALLS); // 2. 发送 QUERY_CURRENT_CALLS 事件break;

上面的已经说明了触发流程:

  1. 每次来电都会触发 setup 回调
  2. 发送 QUERY_CURRENT_CALLS 事件
  3. 处理 QUERY_CURRENT_CALLS 事件
  4. 这里会通过调用 queryCallsStart 触发向 手机查询 当前 的通话列表

1. queryCallsStart 讲解

// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.javaprivate boolean queryCallsStart() {logD("queryCallsStart");clearPendingAction();mNativeInterface.queryCurrentCalls(mCurrentDevice);addQueuedAction(QUERY_CURRENT_CALLS, 0);return true;}// android/app/src/com/android/bluetooth/hfpclient/NativeInterface.javapublic boolean queryCurrentCalls(BluetoothDevice device) {return queryCurrentCallsNative(getByteAddress(device));}// android/app/jni/com_android_bluetooth_hfpclient.cpp
static jboolean queryCurrentCallsNative(JNIEnv* env, jobject object,jbyteArray address) {bt_status_t status = sBluetoothHfpClientInterface->query_current_calls((const RawAddress*)addr);}static const bthf_client_interface_t bthfClientInterface = {.query_current_calls = query_current_calls,};#define BTA_HF_CLIENT_AT_CMD_CLCC 12// system/btif/src/btif_hf_client.cc
/********************************************************************************* Function         query_current_calls** Description      query list of current calls** Returns          bt_status_t*******************************************************************************/
static bt_status_t query_current_calls(UNUSED_ATTR const RawAddress* bd_addr) {if (cb->peer_feat & BTA_HF_CLIENT_PEER_ECS) {BTA_HfClientSendAT(cb->handle, BTA_HF_CLIENT_AT_CMD_CLCC/*这里下发了 这个命令 12*/, 0, 0, NULL);return BT_STATUS_SUCCESS;}return BT_STATUS_UNSUPPORTED;
}// 最终会在  bta_hf_client_send_at_cmd 中处理 BTA_HF_CLIENT_AT_CMD_CLCC 该命令
// system/bta/hf_client/bta_hf_client_at.cc
void bta_hf_client_send_at_cmd(tBTA_HF_CLIENT_DATA* p_data) {...tBTA_HF_CLIENT_DATA_VAL* p_val = (tBTA_HF_CLIENT_DATA_VAL*)p_data;char buf[BTA_HF_CLIENT_AT_MAX_LEN];APPL_TRACE_DEBUG("%s: at cmd: %d", __func__, p_val->uint8_val);switch (p_val->uint8_val) {...case BTA_HF_CLIENT_AT_CMD_CLCC:bta_hf_client_send_at_clcc(client_cb);break;...}}// system/bta/hf_client/bta_hf_client_at.cc
void bta_hf_client_send_at_clcc(tBTA_HF_CLIENT_CB* client_cb) {const char* buf;APPL_TRACE_DEBUG("%s", __func__);buf = "AT+CLCC\r";// 最终调用 bta_hf_client_send_at 下发 AT+CLCC 命令bta_hf_client_send_at(client_cb, BTA_HF_CLIENT_AT_CLCC, buf, strlen(buf));
}

2. +CLCC

当我们 收到 AG 侧的 电话列表如何解析?

// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.javapublic synchronized boolean processMessage(Message message) {logD("Connected process message: " + message.what);switch (message.what) {case StackEvent.STACK_EVENT:Intent intent = null;StackEvent event = (StackEvent) message.obj;logD("Connected: event type: " + event.type);switch (event.type) {switch (queuedAction.first) {case QUERY_CURRENT_CALLS:queryCallsDone();break;
  • 当我们收到 手机侧(AG) 上报的电话列表事件时,就会触发 queryCallsDone 函数调用。
1. queryCallsDone 详解

函数 queryCallsDone() 是 AOSP 中 HFP (Hands-Free Profile) 客户端的一个核心函数,位于 HfpClientService 的呼叫状态同步流程中,用于处理 车机(HF)查询手机(AG)呼叫状态的响应(即 CLCC 命令的返回)

  • AT+CLCC:车机(HF)发送此 AT 命令到手机(AG),请求当前通话列表。
  • +CLCC:手机返回当前呼叫信息,每一个通话对应一个 +CLCC:
  • 本函数在收到所有 +CLCC 响应后调用,用于更新车机端的通话状态映射表 mCalls

先解释一下 如下几个变量在 HeadsetClientStateMachine 中的含义:

变量名含义
mCalls当前车机端缓存的呼叫状态
mCallsUpdate本轮 CLCC 响应中得到的新状态
HF_ORIGINATED_CALL_ID车机主动发起的通话 ID(临时设定为 -1)
    private void queryCallsDone() {logD("queryCallsDone");/*1. 复制当前 ID 集合(去除 -1):生成旧通话 ID 集合,排除掉车机发起但未匹配成功的呼叫(ID = -1)。*/Set<Integer> currCallIdSet = new HashSet<Integer>();currCallIdSet.addAll(mCalls.keySet());// Remove the entry for unassigned call.currCallIdSet.remove(HF_ORIGINATED_CALL_ID);/*2. 获取新通话 ID 集合:从 CLCC 响应中收集新一轮的通话 ID。*/Set<Integer> newCallIdSet = new HashSet<Integer>();newCallIdSet.addAll(mCallsUpdate.keySet());/*3. 计算三类通话 IDAdded:本轮新出现的通话。Removed:旧有但不再出现的通话(说明已结束)。Retained:两轮都有的,可能有字段更新。*/// Added.Set<Integer> callAddedIds = new HashSet<Integer>();callAddedIds.addAll(newCallIdSet);callAddedIds.removeAll(currCallIdSet);// Removed.Set<Integer> callRemovedIds = new HashSet<Integer>();callRemovedIds.addAll(currCallIdSet);callRemovedIds.removeAll(newCallIdSet);// Retained.Set<Integer> callRetainedIds = new HashSet<Integer>();callRetainedIds.addAll(currCallIdSet);callRetainedIds.retainAll(newCallIdSet);// 打印当前对比状态, 打印三个集合,便于开发者追踪通话状态变化。logD("currCallIdSet " + mCalls.keySet() + " newCallIdSet " + newCallIdSet+ " callAddedIds " + callAddedIds + " callRemovedIds " + callRemovedIds+ " callRetainedIds " + callRetainedIds);/*4. 尝试将 HF_ORIGINATED_CALL_ID 匹配到手机返回的一个真实通话*/// First thing is to try to associate the outgoing HF with a valid call.Integer hfOriginatedAssoc = -1;if (mCalls.containsKey(HF_ORIGINATED_CALL_ID)) {HfpClientCall c = mCalls.get(HF_ORIGINATED_CALL_ID);long cCreationElapsed = c.getCreationElapsedMilli();if (callAddedIds.size() > 0) {//  匹配第一通新增通话logD("Associating the first call with HF originated call");hfOriginatedAssoc = (Integer) callAddedIds.toArray()[0];mCalls.put(hfOriginatedAssoc, mCalls.get(HF_ORIGINATED_CALL_ID));mCalls.remove(HF_ORIGINATED_CALL_ID);// Adjust this call in above sets.// 调整集合状态callAddedIds.remove(hfOriginatedAssoc);callRetainedIds.add(hfOriginatedAssoc);/*说明:HF 发出呼叫后手机可能返回一条 +CLCC(呼叫状态)作为回应,这里尝试将其匹配起来,避免重复。*/} else if (SystemClock.elapsedRealtime() - cCreationElapsed > OUTGOING_TIMEOUT_MILLI) {/*如果没有匹配上任何新通话,且超时了:异常处理:超时未收到回应,说明手机没有处理成功,发出 AT+CHUP 结束呼叫。*/Log.w(TAG, "Outgoing call did not see a response, clear the calls and send CHUP");// We send a terminate because we are in a bad state and trying to// recover.terminateCall();// Clean out the state for outgoing call.for (Integer idx : mCalls.keySet()) {HfpClientCall c1 = mCalls.get(idx);c1.setState(HfpClientCall.CALL_STATE_TERMINATED);sendCallChangedIntent(c1);}mCalls.clear();// We return here, if there's any update to the phone we should get a// follow up by getting some call indicators and hence update the calls.return;}}logD("ADJUST: currCallIdSet " + mCalls.keySet() + " newCallIdSet " + newCallIdSet+ " callAddedIds " + callAddedIds + " callRemovedIds " + callRemovedIds+ " callRetainedIds " + callRetainedIds);/*5. 终止并移除已结束通话*/// Terminate & remove the calls that are done.for (Integer idx : callRemovedIds) {HfpClientCall c = mCalls.remove(idx);c.setState(HfpClientCall.CALL_STATE_TERMINATED);sendCallChangedIntent(c); // 发送广播,通知 telecom。 电话状态发生改变}/*6. 添加新增通话*/// Add the new calls.for (Integer idx : callAddedIds) {HfpClientCall c = mCallsUpdate.get(idx);mCalls.put(idx, c);sendCallChangedIntent(c); // 发送广播,通知 telecom。 电话状态发生改变}/*7. 更新保留的通话(如状态、号码变化)*/// Update the existing calls.for (Integer idx : callRetainedIds) {HfpClientCall cOrig = mCalls.get(idx);HfpClientCall cUpdate = mCallsUpdate.get(idx);// If any of the fields differs, update and send intentif (!cOrig.getNumber().equals(cUpdate.getNumber())|| cOrig.getState() != cUpdate.getState()|| cOrig.isMultiParty() != cUpdate.isMultiParty()) {// Update the necessary fields.cOrig.setNumber(cUpdate.getNumber());cOrig.setState(cUpdate.getState());cOrig.setMultiParty(cUpdate.isMultiParty());// Send update with original object (UUID, idx).sendCallChangedIntent(cOrig); // 发送广播,通知 telecom。 电话状态发生改变}}/*8. 是否继续轮询 CLCC*/if (mCalls.size() > 0) {// 如通话还未完成,继续轮询 AT+CLCC。防止漏掉状态变更。// Continue polling even if not enabled until the new outgoing call is associated with// a valid call on the phone. The polling would at most continue until// OUTGOING_TIMEOUT_MILLI. This handles the potential scenario where the phone creates// and terminates a call before the first QUERY_CURRENT_CALLS completes.if (mClccPollDuringCall|| (mCalls.containsKey(HF_ORIGINATED_CALL_ID))) {sendMessageDelayed(QUERY_CURRENT_CALLS,mService.getResources().getInteger(R.integer.hfp_clcc_poll_interval_during_call));} else {if (getCall(HfpClientCall.CALL_STATE_INCOMING) != null) {logD("Still have incoming call; polling");sendMessageDelayed(QUERY_CURRENT_CALLS, QUERY_CURRENT_CALLS_WAIT_MILLIS);} else {removeMessages(QUERY_CURRENT_CALLS);}}}/*9. 清空本轮临时状态*/mCallsUpdate.clear();}

总结:queryCallsDone 的作用:

这段逻辑就是为了实现以下 HFP 关键功能:

  1. 从手机解析 +CLCC 返回
  2. 对比新旧通话状态
  3. 发送呼叫变化事件到上层应用(如车机的通话 UI);
  4. 处理异常情况,如手机未回应新呼叫
  5. 继续轮询,确保状态同步
2. sendCallChangedIntent

发送广播,通知 telecom。 电话状态发生改变

// HfpClientCall c: 当前通话对象,它封装了该通话的 ID、状态(如拨出、接听、挂断)、号码、多方标志等信息。private void sendCallChangedIntent(HfpClientCall c) {logD("sendCallChangedIntent " + c);/*构建一个 Intent,action 是 BluetoothHeadsetClient.ACTION_CALL_CHANGED,即 “蓝牙 HFP 客户端通话状态变更” 的广播标识。这是系统定义的标准广播,其他模块可以监听这个广播了解蓝牙通话状态。*/Intent intent = new Intent(BluetoothHeadsetClient.ACTION_CALL_CHANGED);/*设置该广播为 前台广播,即优先级较高,会被及时处理。避免因系统延迟或资源限制而延后处理该重要事件.*/intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);// 添加通话对象到广播, 这样接收方就可以获取通话对象,分析其 ID、状态、号码等具体内容。intent.putExtra(BluetoothHeadsetClient.EXTRA_CALL, c);Utils.sendBroadcast(mService, intent, BLUETOOTH_CONNECT/*发送广播需要的权限,只有持有此权限的广播接收器才能接收*/,Utils.getTempAllowlistBroadcastOptions()/*该方法设置了一个临时 allowlist 权限策略,允许在 Doze 模式或省电模式下依旧发送此广播; 保证通话相关状态不会被系统省电策略忽略。*/);// 通知连接服务更新, 这里会触发通知到 telecom.HfpClientConnectionService.onCallChanged(c.getDevice(), c);}

7.小结

命令主体含义功能
AT+CLCCHF请求当前通话列表发起查询
+CLCC: ...AG当前所有通话的状态信息列表返回每一通话状态

这个命令对 HFP 功能的实现非常关键,尤其在实现通话管理(多通话、呼叫等待、会议通话)时。

http://www.xdnf.cn/news/862435.html

相关文章:

  • 分析Web3下数据保护的创新模式
  • MVCC理解
  • vscode中无法使用npm node
  • Windows 12确认没了,Win11 重心偏移修Bug
  • 2025年- H65-Lc173--347.前k个高频元素(小根堆,堆顶元素是当前堆元素里面最小的)--Java版
  • HDU-2973 YAPTCHA
  • Maven 构建缓存与离线模式
  • 第二章 进程管理
  • 让音乐“看得见”:使用 HTML + JavaScript 实现酷炫的音频可视化播放器
  • 【论文阅读笔记】Text-to-SQL Empowered by Large Language Models: A Benchmark Evaluation
  • 【RAG优化】rag整体优化建议
  • AI全栈之路:Ubuntu云服务器部署Spring + Vue + MySQL实践指南
  • MySQL索引(index)
  • Mybatis入门到精通
  • Spark实战能力测评模拟题精析【模拟考】
  • 编程技能:格式化打印04,sprintf
  • Ubuntu 16.04 密码找回
  • 区块链安全攻防战:51% 攻击与 Sybil 攻击的应对策略
  • 目标检测任务的评估指标mAP50和mAP50-95
  • OpenCV计算机视觉实战(10)——形态学操作详解
  • 【从前端到后端导入excel文件实现批量导入-笔记模仿芋道源码的《系统管理-用户管理-导入-批量导入》】
  • 目标检测任务的评估指标P-R曲线
  • NPOI操作EXCEL文件 ——CAD C# 二次开发
  • LlamaIndex:解锁LLM潜力的数据编排利器
  • C++性能优化指南
  • Java Stream 高级实战:并行流、自定义收集器与性能优化
  • ODOO12
  • springboot--实战--大事件--文章分类接口开发详解
  • 微软的新系统Windows12未来有哪些新特性
  • 微软重磅发布Magentic UI,交互式AI Agent助手实测!