[bt][hfp] Hold procedure for supplementary services.
Test: Added unit tests
Bug: 64558
Change-Id: Iba93d69dcb21c45288565e99472dea2ff4f2d24e
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/514046
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
Fuchsia-Auto-Submit: Jeff Belgum <belgum@google.com>
Reviewed-by: Matthew Kehrt <kehrt@google.com>
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
index 0397958..814e4b2 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
@@ -59,6 +59,7 @@
"src/procedure/dtmf.rs",
"src/procedure/extended_errors.rs",
"src/procedure/hang_up.rs",
+ "src/procedure/hold.rs",
"src/procedure/indicators_activation.rs",
"src/procedure/nrec.rs",
"src/procedure/phone_status.rs",
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
index b783229..f18973d 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
@@ -5,7 +5,7 @@
use {
crate::{
error::CallError,
- procedure::dtmf::DtmfCode,
+ procedure::{dtmf::DtmfCode, hold::CallHoldAction},
protocol::indicators::{CallIndicators, CallIndicatorsUpdates},
},
async_utils::{
@@ -338,10 +338,18 @@
self.current_calls.remove(index);
}
+ /// Iterator over all calls in `state`.
+ fn all_by_state(
+ &self,
+ state: CallState,
+ ) -> impl Iterator<Item = (CallIdx, &CallEntry)> + Clone {
+ self.calls().filter(move |c| c.1.state == state)
+ }
+
/// Operations that act on calls in a particular state should act on the call that was put into
/// that state first. `oldest_by_state` allows querying for a call that meets that criteria.
fn oldest_by_state(&self, state: CallState) -> Option<(CallIdx, &CallEntry)> {
- self.calls().filter(|c| c.1.state == state).min_by_key(|c| c.1.state_updated_at)
+ self.all_by_state(state).min_by_key(|c| c.1.state_updated_at)
}
/// Return the Call that has been in the IncomingRinging call state the longest if at least one
@@ -391,6 +399,55 @@
self.request_terminate(idx)
}
+ /// Perform the supplementary call service related to held or waiting calls specified by
+ /// `action`. See HFP v1.8, Section 4.22 for more information.
+ ///
+ /// Waiting calls are prioritized over held calls when an action operates on a specific call.
+ /// Per 4.34.2 AT+CHLD: "Where both a held and a waiting call exist, the above procedures shall
+ /// apply to the waiting call (i.e., not to the held call) in conflicting situation."
+ ///
+ /// Returns an error if the CallIdx associated with a specific action is invalid.
+ pub fn hold(&mut self, action: CallHoldAction) -> Result<(), CallError> {
+ use {CallHoldAction::*, CallState::*};
+ match action {
+ ReleaseAllHeld => {
+ let waiting_calls: Vec<_> =
+ self.all_by_state(IncomingWaiting).map(|c| c.0).collect();
+ let held_calls: Vec<_> = self.all_by_state(OngoingHeld).map(|c| c.0).collect();
+
+ // Terminated state on incoming waiting calls is prioritized over held calls.
+ let to_release = if !waiting_calls.is_empty() { waiting_calls } else { held_calls };
+
+ for idx in to_release {
+ let _ = self.request_terminate(idx);
+ }
+ }
+ ReleaseAllActive => {
+ // Active state on incoming waiting calls is prioritized over held calls.
+ let idx = self
+ .oldest_by_state(IncomingWaiting)
+ .or_else(|| self.oldest_by_state(OngoingHeld))
+ .ok_or(CallError::None(vec![IncomingWaiting, OngoingHeld]))?
+ .0;
+
+ self.request_active(idx, true)?;
+ }
+ ReleaseSpecified(idx) => self.request_terminate(idx)?,
+ HoldActiveAndAccept => {
+ // Active state on incoming waiting calls is prioritized over held calls.
+ let idx = self
+ .oldest_by_state(IncomingWaiting)
+ .or_else(|| self.oldest_by_state(OngoingHeld))
+ .ok_or(CallError::None(vec![IncomingWaiting, OngoingHeld]))?
+ .0;
+
+ self.request_active(idx, false)?;
+ }
+ HoldAllExceptSpecified(idx) => self.request_active(idx, false)?,
+ }
+ Ok(())
+ }
+
/// Returns true if the state of any calls requires ringing.
pub fn should_ring(&self) -> bool {
self.calls().any(|c| c.1.state == CallState::IncomingRinging)
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
index 436275a8..0a22c73 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
@@ -273,6 +273,12 @@
});
self.connection.receive_ag_request(marker, response(result)).await;
}
+ InformationRequest::Hold { command, response } => {
+ let result = self.calls.hold(command).map_err(|e| {
+ warn!("Unexpected Action {:?} from Hands Free: {}", command, e);
+ });
+ self.connection.receive_ag_request(marker, response(result)).await;
+ }
};
}
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
index 12df6cb..6a49c27 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
@@ -38,6 +38,9 @@
/// Defines the implementation of the Hang Up Procedure.
pub mod hang_up;
+/// Defines the implementation of the Hold Procedure.
+pub mod hold;
+
/// Defines the implementation of the SLC Initialization Procedure.
pub mod slc_initialization;
@@ -74,6 +77,7 @@
use dtmf::{DtmfCode, DtmfProcedure};
use extended_errors::ExtendedErrorsProcedure;
use hang_up::HangUpProcedure;
+use hold::{CallHoldAction, HoldProcedure};
use indicators_activation::IndicatorsActivationProcedure;
use nrec::NrecProcedure;
use phone_status::PhoneStatusProcedure;
@@ -85,7 +89,10 @@
use transfer_hf_indicator::TransferHfIndicatorProcedure;
use volume_synchronization::VolumeSynchronizationProcedure;
-const THREE_WAY_SUPPORT: &[&str] = &["0", "1", "1X", "2", "2X", "3", "4"];
+// TODO (fxbug.dev/74091): Add multiparty support.
+// TODO (fxbug.dev/74093): Add Explicit Call Transfer support.
+const THREE_WAY_SUPPORT: &[&str] = &["0", "1", "1X", "2", "2X"];
+
// TODO(fxb/71668) Stop using raw bytes.
const CIND_TEST_RESPONSE_BYTES: &[u8] = b"+CIND: \
(\"service\",(0,1)),\
@@ -178,6 +185,8 @@
HangUp,
/// The Transfer of HF Indicator Values procedure as defined in HFP v1.8 Section 4.36.1.5.
TransferHfIndicator,
+ /// The Call Hold procedure as defined in HFP v1.8 Section 4.22
+ Hold,
}
impl ProcedureMarker {
@@ -204,6 +213,7 @@
Self::Answer => Box::new(AnswerProcedure::new()),
Self::HangUp => Box::new(HangUpProcedure::new()),
Self::TransferHfIndicator => Box::new(TransferHfIndicatorProcedure::new()),
+ Self::Hold => Box::new(HoldProcedure::new()),
}
}
@@ -237,6 +247,7 @@
at::Command::Chup { .. } => Ok(Self::HangUp),
at::Command::Biev { .. } => Ok(Self::TransferHfIndicator),
at::Command::Bia { .. } => Ok(Self::Indicators),
+ at::Command::Chld { .. } => Ok(Self::Hold),
_ => Err(ProcedureError::NotImplemented),
}
}
@@ -268,6 +279,8 @@
Answer { response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
HangUp { response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
+
+ Hold { command: CallHoldAction, response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
}
impl From<&InformationRequest> for ProcedureMarker {
@@ -286,6 +299,7 @@
QueryCurrentCalls { .. } => Self::QueryCurrentCalls,
Answer { .. } => Self::Answer,
HangUp { .. } => Self::HangUp,
+ Hold { .. } => Self::Hold,
}
}
}
@@ -317,6 +331,7 @@
}
Self::Answer { .. } => "Answer",
Self::HangUp { .. } => "HangUp",
+ Self::Hold { .. } => "Hold",
}
.to_string();
write!(f, "{}", output)
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs
new file mode 100644
index 0000000..99743f5
--- /dev/null
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs
@@ -0,0 +1,293 @@
+// Copyright 2021 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use super::{
+ AgUpdate, InformationRequest, Procedure, ProcedureError, ProcedureMarker, ProcedureRequest,
+};
+
+use crate::peer::{calls::CallIdx, service_level_connection::SlcState};
+use {
+ at_commands as at,
+ core::convert::{TryFrom, TryInto},
+};
+
+#[derive(Debug, PartialEq, Clone, Copy)]
+enum State {
+ /// Initial state of the Hold Procedure.
+ Start,
+ /// A request has been received from the HF to hold, activate, or terminate calls.
+ HoldRequest,
+ /// The AG has responded to the HF's request to set the state.
+ Terminated,
+}
+
+impl State {
+ /// Transition to the next state in the Hold procedure.
+ fn transition(&mut self) {
+ match *self {
+ Self::Start => *self = Self::HoldRequest,
+ Self::HoldRequest => *self = Self::Terminated,
+ Self::Terminated => *self = Self::Terminated,
+ }
+ }
+}
+
+/// Action to perform a call related supplementary services. During a call, the following procedures
+/// shall be available for the subscriber to control the operation of Call Waiting or Call Hold;
+///
+/// See 3GPP TS 22.030 v16.0.0 / ETSI TS 122.030 v16.0.0
+#[derive(PartialEq, Clone, Copy, Debug)]
+pub enum CallHoldAction {
+ /// Releases all held calls or sets User Determined User Busy (UDUB) for a waiting call.
+ ReleaseAllHeld,
+ /// Releases all active calls (if any exist) and accepts the other (held or waiting) call.
+ ReleaseAllActive,
+ /// Releases call with specified CallIdx.
+ ReleaseSpecified(CallIdx),
+ /// Places all active calls (if any exist) on hold and accepts the other (held or waiting) call.
+ HoldActiveAndAccept,
+ /// Request private consultation mode with specified call (CallIdx). (Place all calls on hold
+ /// EXCEPT the call indicated by CallIdx.)
+ HoldAllExceptSpecified(CallIdx),
+}
+
+impl TryFrom<&str> for CallHoldAction {
+ type Error = ();
+ fn try_from(x: &str) -> Result<Self, Self::Error> {
+ let x = x.trim();
+ match x {
+ "0" => Ok(Self::ReleaseAllHeld),
+ "1" => Ok(Self::ReleaseAllActive),
+ "2" => Ok(Self::HoldActiveAndAccept),
+ cmd if cmd.starts_with("1") => {
+ let idx = cmd.strip_prefix("1").unwrap_or("").parse().map_err(|_| ())?;
+ Ok(Self::ReleaseSpecified(idx))
+ }
+ cmd if cmd.starts_with("2") => {
+ let idx = cmd.strip_prefix("2").unwrap_or("").parse().map_err(|_| ())?;
+ Ok(Self::HoldAllExceptSpecified(idx))
+ }
+ _ => Err(()),
+ }
+ }
+}
+
+/// The HF performs supplemental services on calls via this procedure. See HFP v1.8, Section 4.22.
+///
+/// This procedure is implemented from the perspective of the AG. Namely, outgoing `requests`
+/// typically request information about the current state of the AG, to be sent to the remote
+/// peer acting as the HF.
+#[derive(Debug)]
+pub struct HoldProcedure {
+ /// The current state of the procedure
+ state: State,
+}
+
+impl HoldProcedure {
+ /// Create a new Hold procedure in the Start state.
+ pub fn new() -> Self {
+ Self { state: State::Start }
+ }
+}
+
+impl Procedure for HoldProcedure {
+ fn marker(&self) -> ProcedureMarker {
+ ProcedureMarker::Hold
+ }
+
+ fn hf_update(&mut self, update: at::Command, _state: &mut SlcState) -> ProcedureRequest {
+ match (self.state, &update) {
+ (State::Start, at::Command::Chld { command }) => {
+ self.state.transition();
+ match command.as_str().try_into() {
+ Ok(command) => {
+ let response = Box::new(|res: Result<(), ()>| {
+ res.map(|()| AgUpdate::Ok).unwrap_or(AgUpdate::Error)
+ });
+ InformationRequest::Hold { command, response }.into()
+ }
+ Err(()) => ProcedureRequest::Error(ProcedureError::UnexpectedHf(update)),
+ }
+ }
+ _ => ProcedureRequest::Error(ProcedureError::UnexpectedHf(update)),
+ }
+ }
+
+ fn ag_update(&mut self, update: AgUpdate, _state: &mut SlcState) -> ProcedureRequest {
+ match (self.state, update) {
+ (State::HoldRequest, update @ AgUpdate::Ok)
+ | (State::HoldRequest, update @ AgUpdate::Error) => {
+ self.state.transition();
+ update.into()
+ }
+ (_, update) => ProcedureRequest::Error(ProcedureError::UnexpectedAg(update)),
+ }
+ }
+
+ fn is_terminated(&self) -> bool {
+ self.state == State::Terminated
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matches::assert_matches;
+
+ #[test]
+ fn state_transitions() {
+ let mut state = State::Start;
+ state.transition();
+ assert_eq!(state, State::HoldRequest);
+ state.transition();
+ assert_eq!(state, State::Terminated);
+ state.transition();
+ assert_eq!(state, State::Terminated);
+ }
+
+ #[test]
+ fn call_hold_action_valid_conversions() {
+ assert_eq!(CallHoldAction::try_from("0"), Ok(CallHoldAction::ReleaseAllHeld));
+ assert_eq!(CallHoldAction::try_from("1"), Ok(CallHoldAction::ReleaseAllActive));
+ assert_eq!(CallHoldAction::try_from("2"), Ok(CallHoldAction::HoldActiveAndAccept));
+ assert_eq!(CallHoldAction::try_from("11"), Ok(CallHoldAction::ReleaseSpecified(1)));
+ assert_eq!(CallHoldAction::try_from("21"), Ok(CallHoldAction::HoldAllExceptSpecified(1)));
+ // Two digit call index
+ assert_eq!(CallHoldAction::try_from("231"), Ok(CallHoldAction::HoldAllExceptSpecified(31)));
+ }
+
+ #[test]
+ fn call_hold_action_invalid_conversions() {
+ // Empty string
+ assert!(CallHoldAction::try_from("").is_err());
+ // Not a number
+ assert!(CallHoldAction::try_from("a").is_err());
+ // Number out of range
+ assert!(CallHoldAction::try_from("3").is_err());
+ // Unexpected second number
+ assert!(CallHoldAction::try_from("01").is_err());
+ // Invalid second character on "1"
+ assert!(CallHoldAction::try_from("1a").is_err());
+ // Invalid second character on "2"
+ assert!(CallHoldAction::try_from("2-").is_err());
+ }
+
+ #[test]
+ fn correct_marker() {
+ let marker = HoldProcedure::new().marker();
+ assert_eq!(marker, ProcedureMarker::Hold);
+ }
+
+ #[test]
+ fn is_terminated_in_terminated_state() {
+ let mut proc = HoldProcedure::new();
+ assert!(!proc.is_terminated());
+ proc.state = State::HoldRequest;
+ assert!(!proc.is_terminated());
+ proc.state = State::Terminated;
+ assert!(proc.is_terminated());
+ }
+
+ #[test]
+ fn unexpected_hf_update_returns_error() {
+ let mut proc = HoldProcedure::new();
+ let mut state = SlcState::default();
+
+ // SLCI AT command.
+ let random_hf = at::Command::CindRead {};
+ assert_matches!(
+ proc.hf_update(random_hf, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+ );
+ }
+
+ #[test]
+ fn unexpected_ag_update_returns_error() {
+ let mut proc = HoldProcedure::new();
+ let mut state = SlcState::default();
+ // SLCI AT command.
+ let random_ag = AgUpdate::ThreeWaySupport;
+ assert_matches!(
+ proc.ag_update(random_ag, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+ );
+ }
+
+ #[test]
+ fn hold_produces_ok_result() {
+ let mut proc = HoldProcedure::new();
+ let mut state = SlcState::default();
+
+ let req = proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state);
+ let update = match req {
+ ProcedureRequest::Info(InformationRequest::Hold { command, response }) => {
+ assert_eq!(command, CallHoldAction::ReleaseAllActive);
+ response(Ok(()))
+ }
+ x => panic!("Unexpected message: {:?}", x),
+ };
+ let req = proc.ag_update(update, &mut state);
+ assert_matches!(
+ req,
+ ProcedureRequest::SendMessages(resp) if resp == vec![at::Response::Ok]
+ );
+
+ // Check that the procedure is terminated and any new messages produce an error.
+ assert!(proc.is_terminated());
+ assert_matches!(
+ proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+ );
+ assert_matches!(
+ proc.ag_update(AgUpdate::Ok, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+ );
+ }
+
+ #[test]
+ fn hold_produces_err_result() {
+ let mut proc = HoldProcedure::new();
+ let mut state = SlcState::default();
+
+ let req = proc.hf_update(at::Command::Chld { command: String::from("22") }, &mut state);
+ let update = match req {
+ ProcedureRequest::Info(InformationRequest::Hold { command, response }) => {
+ assert_eq!(command, CallHoldAction::HoldAllExceptSpecified(2));
+ response(Err(()))
+ }
+ x => panic!("Unexpected message: {:?}", x),
+ };
+ let req = proc.ag_update(update, &mut state);
+ assert_matches!(
+ req,
+ ProcedureRequest::SendMessages(resp) if resp == vec![at::Response::Error]
+ );
+
+ // Check that the procedure is terminated and any new messages produce an error.
+ assert!(proc.is_terminated());
+ assert_matches!(
+ proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+ );
+ assert_matches!(
+ proc.ag_update(AgUpdate::Ok, &mut state),
+ ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+ );
+ }
+
+ #[test]
+ fn invalid_hold_command_string_produces_err() {
+ let mut proc = HoldProcedure::new();
+ let mut state = SlcState::default();
+
+ let req =
+ proc.hf_update(at::Command::Chld { command: String::from("invalid") }, &mut state);
+ assert_matches!(
+ req,
+ ProcedureRequest::Error(ProcedureError::UnexpectedHf(at::Command::Chld { command }))
+ if command == "invalid"
+ );
+ assert!(!proc.is_terminated());
+ }
+}
diff --git a/src/connectivity/lib/at-commands/definitions/hfp.at b/src/connectivity/lib/at-commands/definitions/hfp.at
index 508fad6..4084cd7 100644
--- a/src/connectivity/lib/at-commands/definitions/hfp.at
+++ b/src/connectivity/lib/at-commands/definitions/hfp.at
@@ -179,3 +179,7 @@
# Call Waiting Notification
# HFP 1.8 4.34.2
response { CCWA: number: String, ty: Integer }
+
+# Additional Call Services
+# HFP 1.8 4.34.2
+command { AT+CHLD=command: String }