尚未崩坏 发表于 2025-6-26 10:40:03

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

1. 背景

本日上报了一例, 手机是毗连了 蓝牙的。 但此时来电时,车机侧不表现来电。可以在手机侧看到来电。
这里简单分享一下这个题目。 借着这个题目, 我们讲解一下 :


[*]Sent AT+CLCC
[*]Rcvd +CLCC: 1,1,4,0,0,“173xxxxxxx7”,129," 173 xxxxxx7 "
2. 案例分析

1. 题目情况日志:

https://i-blog.csdnimg.cn/direct/91053be12ba841e08495d00f0770d42c.png


[*]从 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.正常的日志

https://i-blog.csdnimg.cn/direct/e7d7a417ea9e46c48ec7d8d49a167645.png
同样是正常的来电,车机下发 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 中我们是怎样在来电的时候触发 查询当前的 电话列表的呢?
https://i-blog.csdnimg.cn/direct/8cfb43e4917846fa89eb8a215cd82783.png
// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
      public 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;
上面的已经说明了触发流程:

[*]每次来电都会触发 setup 回调
[*]发送 QUERY_CURRENT_CALLS 变乱
[*]处置惩罚 QUERY_CURRENT_CALLS 变乱
[*]这里会通过调用 queryCallsStart 触发向 手机查询 当前 的通话列表
1. queryCallsStart 讲解

// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
    private boolean queryCallsStart() {
      logD("queryCallsStart");
      clearPendingAction();
      mNativeInterface.queryCurrentCalls(mCurrentDevice);
      addQueuedAction(QUERY_CURRENT_CALLS, 0);
      return true;
    }

// android/app/src/com/android/bluetooth/hfpclient/NativeInterface.java
    public 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;

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.java
      public 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. 计算三类通话 ID
                                Added:本轮新出现的通话。
                                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();
                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 intent
            if (!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 关键功能:

[*]从手机剖析 +CLCC 返回;
[*]对比新旧通话状态;
[*]发送呼叫变革变乱到上层应用(如车机的通话 UI);
[*]处置惩罚异常情况,如手机未回应新呼叫;
[*]继续轮询,确保状态同步。
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 功能的实现非常关键,尤其在实现通话管理(多通话、呼叫等候、集会通话)时。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 【android bluetooth 协议分析 14】【HFP详解 1】【案例一: 手机侧表现来电,但车机侧没有表现来电: 讲解AT+CLCC下令】