diff --git a/components/sip_client/README.md b/components/sip_client/README.md index 54be5345..212aef08 100644 --- a/components/sip_client/README.md +++ b/components/sip_client/README.md @@ -5,7 +5,8 @@ An ESPHome external component for ESP32. It registers to a SIP PBX delegated to ESPHome's standard `microphone` / `speaker` platforms (either may be omitted for send-only or receive-only endpoints). Codecs are G.711 (PCMU/PCMA, 8 kHz) by default, with optional wideband **G.722** (16 kHz PCM, -8 kHz RTP clock per RFC 3551). DTMF is sent via RFC 2833 (telephone-event). +8 kHz RTP clock per RFC 3551). DTMF is sent via RFC 2833 (telephone-event) and +received via RFC 2833 or SIP INFO. ## Installation @@ -50,6 +51,7 @@ sip_client: server: 192.168.0.10 # PBX address (IP recommended) port: 5060 # (default 5060) username: "1001" + auth_username: "auth-id" # (optional) Digest auth ID, when the PBX separates it password: "secret" domain: "192.168.0.10" # (default: server) caller_id: "ESP Doorbell" # (optional) @@ -100,6 +102,7 @@ sip_client: | `server` | ✓ | - | PBX address (IP recommended) | | `port` | | 5060 | SIP server port | | `username` | ✓ | - | SIP account (extension) | +| `auth_username` | | `username` | Digest authentication user, for PBXs that separate it from the extension number (3CX v20 calls it *Authentication ID*). `From` / `To` / `Contact` keep using `username`, so the device still presents its extension. Omit unless the PBX rejects REGISTER with *credentials don't match*. | | `password` | ✓ | - | SIP password | | `domain` | | server | SIP domain / realm | | `caller_id` | | username | Outgoing display name | @@ -188,7 +191,24 @@ binary_sensor: - `on_incoming_call` — incoming call (variable `caller`: `std::string`) - `on_call_connected` — call has been connected - `on_call_ended` — call has ended -- `on_dtmf` — DTMF received from the remote party (variable `digit`: `std::string`) +- `on_dtmf` — DTMF received from the remote party (variable `digit`: `std::string`). + See [DTMF reception](#dtmf-reception) for what is accepted. + +### DTMF reception + +`on_dtmf` fires for both of the out-of-band ways a phone can signal a digit: + +- **RFC 2833 / 4733 telephone-event** (preferred, negotiated in SDP). The digit + is reported once when the event starts, independent of the RTP marker bit — + Yealink DECT handsets (W70B/W73H) and the 3CX Android app send the event + without it. Repeat packets of the same event, including the retransmitted end + packets, do not re-fire the trigger. +- **SIP INFO** with `application/dtmf-relay` (`Signal=1`) or `application/dtmf`, + answered with `200 OK`. `*` and `#` are accepted both literally and as the + event codes `10` / `11`. INFO requests outside an established call are + answered but do not fire the trigger. + +In-band audio tones (no telephone-event, no INFO) are **not** decoded. ## Behavior / Limitations diff --git a/components/sip_client/__init__.py b/components/sip_client/__init__.py index 3ba5b28b..1c9971c5 100644 --- a/components/sip_client/__init__.py +++ b/components/sip_client/__init__.py @@ -12,6 +12,7 @@ CONF_TRIGGER_ID, ) from .const import ( + CONF_AUTH_USERNAME, CONF_SERVER, CONF_DOMAIN, CONF_CALLER_ID, @@ -94,6 +95,7 @@ def validate_audio(config): cv.Required(CONF_SERVER): cv.string, cv.Optional(CONF_PORT, default=5060): cv.port, cv.Required(CONF_USERNAME): cv.string, + cv.Optional(CONF_AUTH_USERNAME): cv.string, cv.Required(CONF_PASSWORD): cv.string, cv.Optional(CONF_DOMAIN): cv.string, cv.Optional(CONF_CALLER_ID): cv.string, @@ -141,6 +143,8 @@ async def to_code(config): cg.add(var.set_server(config[CONF_SERVER])) cg.add(var.set_port(config[CONF_PORT])) cg.add(var.set_username(config[CONF_USERNAME])) + if CONF_AUTH_USERNAME in config: + cg.add(var.set_auth_username(config[CONF_AUTH_USERNAME])) cg.add(var.set_password(config[CONF_PASSWORD])) if CONF_DOMAIN in config: cg.add(var.set_domain(config[CONF_DOMAIN])) diff --git a/components/sip_client/const.py b/components/sip_client/const.py index 94ba92d9..918f6a1a 100644 --- a/components/sip_client/const.py +++ b/components/sip_client/const.py @@ -1,3 +1,4 @@ +CONF_AUTH_USERNAME = "auth_username" CONF_SERVER = "server" CONF_DOMAIN = "domain" CONF_CALLER_ID = "caller_id" diff --git a/components/sip_client/dtmf.cpp b/components/sip_client/dtmf.cpp new file mode 100644 index 00000000..d87ff1f7 --- /dev/null +++ b/components/sip_client/dtmf.cpp @@ -0,0 +1,72 @@ +#include "dtmf.h" + +#include +#include + +namespace esphome { +namespace sip_client { + +namespace { + +std::string to_lower(const std::string &s) { + std::string out = s; + for (char &c : out) c = (char) std::tolower((unsigned char) c); + return out; +} + +std::string trim(const std::string &s) { + size_t begin = 0, end = s.size(); + while (begin < end && std::isspace((unsigned char) s[begin])) begin++; + while (end > begin && std::isspace((unsigned char) s[end - 1])) end--; + return s.substr(begin, end - begin); +} + +// "1" / "10" / "*" -> digit, 0 when the token is not a DTMF signal. +char signal_token_to_char(const std::string &token) { + if (token.empty()) return 0; + bool numeric = true; + for (char c : token) { + if (c < '0' || c > '9') { + numeric = false; + break; + } + } + if (numeric) { + if (token.size() > 2) return 0; + return dtmf_event_to_char((uint8_t) std::atoi(token.c_str())); + } + if (token.size() != 1) return 0; + char c = (char) std::toupper((unsigned char) token[0]); + return is_dtmf_char(c) ? c : 0; +} + +// Value of "Signal=" in a dtmf-relay body. The name is matched +// case-insensitively; "Duration=" and friends are left alone because the '=' +// must follow the name. +std::string signal_token(const std::string &body) { + std::string lower = to_lower(body); + for (size_t p = lower.find("signal"); p != std::string::npos; p = lower.find("signal", p + 6)) { + size_t i = p + 6; + while (i < body.size() && (body[i] == ' ' || body[i] == '\t')) i++; + if (i >= body.size() || body[i] != '=') continue; + i++; + while (i < body.size() && (body[i] == ' ' || body[i] == '\t')) i++; + std::string token; + while (i < body.size() && !std::isspace((unsigned char) body[i])) token += body[i++]; + return token; + } + return ""; +} + +} // namespace + +char parse_dtmf_info(const std::string &content_type, const std::string &body) { + // Content-Type may carry parameters, e.g. "application/dtmf-relay;charset=utf-8". + std::string type = to_lower(trim(content_type.substr(0, content_type.find(';')))); + if (type == "application/dtmf-relay") return signal_token_to_char(signal_token(body)); + if (type == "application/dtmf") return signal_token_to_char(trim(body)); + return 0; +} + +} // namespace sip_client +} // namespace esphome diff --git a/components/sip_client/dtmf.h b/components/sip_client/dtmf.h new file mode 100644 index 00000000..9395c711 --- /dev/null +++ b/components/sip_client/dtmf.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +namespace esphome { +namespace sip_client { + +// RFC 4733 event code -> DTMF character. Returns 0 for codes we do not expose +// (16 = flash and up), which callers drop. +inline char dtmf_event_to_char(uint8_t event) { + if (event <= 9) return (char) ('0' + event); + if (event == 10) return '*'; + if (event == 11) return '#'; + if (event <= 15) return (char) ('A' + (event - 12)); + return 0; +} + +inline bool is_dtmf_char(char c) { + return (c >= '0' && c <= '9') || c == '*' || c == '#' || (c >= 'A' && c <= 'D'); +} + +// De-duplication for received RFC 4733 telephone-event packets. Every packet of +// one digit repeats the same RTP timestamp, and the end packet is normally sent +// three times, so firing per packet would repeat the digit. Firing on the +// marker bit alone is not an option either: Yealink DECT handsets and the 3CX +// Android app send the event without it. Fire once per (event, timestamp). +class DtmfRxDedup { + public: + // Digit for a newly started event, or 0 for a repeat packet of the event + // already reported (or an event code we do not expose). + char feed(uint8_t event, uint32_t timestamp) { + if (this->started_ && event == this->last_event_ && timestamp == this->last_timestamp_) + return 0; + this->started_ = true; + this->last_event_ = event; + this->last_timestamp_ = timestamp; + return dtmf_event_to_char(event); + } + + void reset() { this->started_ = false; } + + protected: + bool started_{false}; + uint8_t last_event_{0}; + uint32_t last_timestamp_{0}; +}; + +// DTMF carried in a SIP INFO body, as sent by the 3CX Android app and by desk +// phones configured for "SIP INFO" DTMF. Returns the digit, or 0 when the INFO +// is not a DTMF INFO. +// +// application/dtmf-relay: "Signal=1\r\nDuration=160" +// application/dtmf: "1" +// +// Senders disagree on how * and # are written -- literally, or as the RFC 4733 +// event codes 10 and 11 -- so both spellings are accepted. +char parse_dtmf_info(const std::string &content_type, const std::string &body); + +} // namespace sip_client +} // namespace esphome diff --git a/components/sip_client/rtp_session.cpp b/components/sip_client/rtp_session.cpp index bfa0f952..dd85b41b 100644 --- a/components/sip_client/rtp_session.cpp +++ b/components/sip_client/rtp_session.cpp @@ -96,6 +96,7 @@ bool RtpSession::start(uint16_t local_port) { } this->dtmf_queue_.clear(); this->dtmf_active_ = false; + this->dtmf_rx_.reset(); this->last_tx_ms_ = millis(); // Treat the session start as "just sent audio" so the grace period applies // from here; a session with no microphone starts emitting silence right after. @@ -118,6 +119,7 @@ void RtpSession::stop() { } this->dtmf_queue_.clear(); this->dtmf_active_ = false; + this->dtmf_rx_.reset(); } void RtpSession::push_tx_audio(const int16_t *pcm, size_t samples) { @@ -280,19 +282,17 @@ void RtpSession::receive_() { ssize_t len = this->socket_->read(this->recv_buf_.data(), this->recv_buf_.size()); if (len < 12) return; // EAGAIN or runt packet uint8_t pt = this->recv_buf_[1] & 0x7F; - bool marker = (this->recv_buf_[1] & 0x80) != 0; size_t header_len = 12 + 4 * (this->recv_buf_[0] & 0x0F); // CSRC count if ((size_t) len <= header_len) continue; if (this->dtmf_pt_ >= 0 && pt == (uint8_t) this->dtmf_pt_) { - if (marker && this->on_dtmf_) { - int event = this->recv_buf_[header_len]; - char c = '?'; - if (event <= 9) c = '0' + event; - else if (event == 10) c = '*'; - else if (event == 11) c = '#'; - else if (event <= 15) c = 'A' + (event - 12); - this->on_dtmf_(c); + // RFC 4733 payload: event, E|R|volume, duration (16 bit). + if ((size_t) len >= header_len + 4 && this->on_dtmf_) { + uint32_t event_ts = ((uint32_t) this->recv_buf_[4] << 24) | + ((uint32_t) this->recv_buf_[5] << 16) | + ((uint32_t) this->recv_buf_[6] << 8) | this->recv_buf_[7]; + char c = this->dtmf_rx_.feed(this->recv_buf_[header_len], event_ts); + if (c != 0) this->on_dtmf_(c); } continue; } diff --git a/components/sip_client/rtp_session.h b/components/sip_client/rtp_session.h index 26b9abbe..cb0c6998 100644 --- a/components/sip_client/rtp_session.h +++ b/components/sip_client/rtp_session.h @@ -6,6 +6,7 @@ #include #include #include "codec.h" +#include "dtmf.h" #include "esphome/components/socket/socket.h" namespace esphome { @@ -78,6 +79,7 @@ class RtpSession { uint16_t dtmf_duration_{0}; uint32_t dtmf_timestamp_{0}; int dtmf_end_packets_{0}; + DtmfRxDedup dtmf_rx_{}; std::function on_audio_{}; std::function on_dtmf_{}; diff --git a/components/sip_client/sip_client.cpp b/components/sip_client/sip_client.cpp index b6710bb9..f742eb99 100644 --- a/components/sip_client/sip_client.cpp +++ b/components/sip_client/sip_client.cpp @@ -7,6 +7,7 @@ #include "esphome/components/network/util.h" #include "esphome/components/audio/audio.h" #include "audio_resampler.h" +#include "dtmf.h" #include "g711_codec.h" #include "g722_codec.h" #include "sdp_builder.h" @@ -70,6 +71,8 @@ void SipClient::dump_config() { ESP_LOGCONFIG(TAG, "SIP Client:"); ESP_LOGCONFIG(TAG, " Server: %s:%u", this->server_.c_str(), this->server_port_); ESP_LOGCONFIG(TAG, " Username: %s", this->username_.c_str()); + if (!this->auth_username_.empty()) + ESP_LOGCONFIG(TAG, " Auth username: %s", this->auth_username_.c_str()); ESP_LOGCONFIG(TAG, " Domain: %s", this->domain_.c_str()); ESP_LOGCONFIG(TAG, " Local RTP port: %u", this->local_rtp_port_); ESP_LOGCONFIG(TAG, " Channel: %s", this->channel_ == SIP_CH_MONO ? "mono" : "stereo"); @@ -250,7 +253,8 @@ void SipClient::handle_register_response_(const SipMessage &m) { std::string uri = "sip:" + this->domain_; std::string nc = "00000001"; std::string cnonce = gen_random_hex(8); - std::string resp = digest_response(this->username_, this->password_, realm, "REGISTER", uri, + const std::string &auth_user = this->auth_user_(); + std::string resp = digest_response(auth_user, this->password_, realm, "REGISTER", uri, nonce, qop.empty() ? "" : "auth", nc, cnonce); this->reg_cseq_++; @@ -258,7 +262,7 @@ void SipClient::handle_register_response_(const SipMessage &m) { std::string msg = this->build_register_(); // insert Authorization before Content-Length std::string auth = std::string(proxy ? "Proxy-Authorization: " : "Authorization: ") + - "Digest username=\"" + this->username_ + "\", realm=\"" + realm + + "Digest username=\"" + auth_user + "\", realm=\"" + realm + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\"" + resp + "\", algorithm=MD5"; if (!qop.empty()) auth += ", qop=auth, nc=" + nc + ", cnonce=\"" + cnonce + "\""; @@ -415,13 +419,14 @@ void SipClient::handle_invite_response_(const SipMessage &m, const std::string & std::string uri = this->d_remote_target_; std::string nc = "00000001"; std::string cnonce = gen_random_hex(8); - std::string resp = digest_response(this->username_, this->password_, realm, "INVITE", uri, + const std::string &auth_user = this->auth_user_(); + std::string resp = digest_response(auth_user, this->password_, realm, "INVITE", uri, nonce, qop.empty() ? "" : "auth", nc, cnonce); this->d_cseq_++; this->d_branch_ = gen_branch(); std::string msg = this->build_invite_(); std::string auth = std::string(proxy ? "Proxy-Authorization: " : "Authorization: ") + - "Digest username=\"" + this->username_ + "\", realm=\"" + realm + + "Digest username=\"" + auth_user + "\", realm=\"" + realm + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\"" + resp + "\", algorithm=MD5"; if (!qop.empty()) auth += ", qop=auth, nc=" + nc + ", cnonce=\"" + cnonce + "\""; @@ -610,6 +615,19 @@ void SipClient::handle_request_(const SipMessage &m, const std::string &raw) { return; } + if (method == "INFO") { + this->send_raw_(this->build_response_(m, 200, "OK", false)); + char digit = parse_dtmf_info(m.header("Content-Type"), m.body); + if (digit == 0) return; + if (this->state_ != SIP_IN_CALL) { + ESP_LOGW(TAG, "DTMF INFO '%c' ignored in state %d", digit, this->state_); + return; + } + ESP_LOGI(TAG, "DTMF via SIP INFO: %c", digit); + this->dtmf_cb_.call(std::string(1, digit)); + return; + } + // Unknown in-dialog request: acknowledge. this->send_raw_(this->build_response_(m, 200, "OK", false)); } diff --git a/components/sip_client/sip_client.h b/components/sip_client/sip_client.h index 880c4b5a..0e84b5cd 100644 --- a/components/sip_client/sip_client.h +++ b/components/sip_client/sip_client.h @@ -46,6 +46,9 @@ class SipClient : public Component { void set_server(const std::string &server) { this->server_ = server; } void set_port(uint16_t port) { this->server_port_ = port; } void set_username(const std::string &v) { this->username_ = v; } + // Digest authentication user, when the PBX separates it from the extension + // (3CX calls it the Authentication ID). Empty = authenticate as `username`. + void set_auth_username(const std::string &v) { this->auth_username_ = v; } void set_password(const std::string &v) { this->password_ = v; } void set_domain(const std::string &v) { this->domain_ = v; } void set_caller_id(const std::string &v) { this->caller_id_ = v; } @@ -153,9 +156,14 @@ class SipClient : public Component { std::string server_; uint16_t server_port_{5060}; std::string username_; + std::string auth_username_; std::string password_; std::string domain_; std::string caller_id_; + // From / To / Contact always stay on username_; only Digest uses this. + const std::string &auth_user_() const { + return this->auth_username_.empty() ? this->username_ : this->auth_username_; + } uint32_t expiration_{300}; uint16_t local_rtp_port_{7078}; SipAudioChannel channel_{SIP_CH_STEREO}; diff --git a/tests/components/sip_client/test.esp32-idf.yaml b/tests/components/sip_client/test.esp32-idf.yaml index df3bc47d..f33d8213 100644 --- a/tests/components/sip_client/test.esp32-idf.yaml +++ b/tests/components/sip_client/test.esp32-idf.yaml @@ -50,6 +50,7 @@ sip_client: server: 192.168.0.10 # PBX address (IP recommended) port: 5060 # (default 5060) username: "1001" + auth_username: "1001-auth" password: "secret" domain: "192.168.0.10" # (default: server) caller_id: "ESP Doorbell" # (optional) diff --git a/tests/native/sip_sdp/README.md b/tests/native/sip_sdp/README.md index 38fb620f..d700fe17 100644 --- a/tests/native/sip_sdp/README.md +++ b/tests/native/sip_sdp/README.md @@ -34,6 +34,21 @@ tests/native/sip_sdp/run.sh | `answer_single_codec_dynamic_pcma` | answer PT 97 + `PCMA/8000` (not PCMU) | | `answer_without_dtmf` | no telephone-event when dtmf_pt < 0 | +### DTMF receive (`test_dtmf.cpp`) + +| Test | Intent | +|------|--------| +| `digit_fires_once_per_event` | all packets of one RFC 4733 event (incl. the 3 end retransmits) → 1 digit | +| `marker_less_sender_still_fires` | Yealink DECT / 3CX Android send no marker bit | +| `same_digit_pressed_twice` | new RTP timestamp = new press | +| `star_hash_and_letters` | events 10/11/12/15 → `*` `#` `A` `D`; 16 (flash) dropped | +| `reset_between_calls` | `stop()`/`start()` clears dedup state | +| `info_dtmf_relay` | `Signal=1`, case/space tolerance, Content-Type params | +| `info_star_hash_spellings` | `Signal=*` and `Signal=10`/`11` both accepted | +| `info_plain_dtmf` | `application/dtmf` bare-digit body | +| `info_duration_is_not_a_digit` | `Duration=250` must not be read as DTMF `D` | +| `info_non_dtmf_is_ignored` | other INFO bodies / no Content-Type → no digit | + ### `G711Codec` (`test_g711_codec.cpp`) | Test | Intent | diff --git a/tests/native/sip_sdp/run.sh b/tests/native/sip_sdp/run.sh index aa2bad66..0d2a78c2 100644 --- a/tests/native/sip_sdp/run.sh +++ b/tests/native/sip_sdp/run.sh @@ -19,6 +19,12 @@ g++ "${CXXFLAGS[@]}" "${INC[@]}" \ -o "$OUT/sip_sdp_builder_test" "$OUT/sip_sdp_builder_test" +g++ "${CXXFLAGS[@]}" "${INC[@]}" \ + "$ROOT/components/sip_client/dtmf.cpp" \ + "$ROOT/tests/native/sip_sdp/test_dtmf.cpp" \ + -o "$OUT/sip_dtmf_test" +"$OUT/sip_dtmf_test" + g++ "${CXXFLAGS[@]}" "${INC[@]}" \ "$ROOT/tests/native/sip_sdp/test_g711_codec.cpp" \ -o "$OUT/sip_g711_codec_test" diff --git a/tests/native/sip_sdp/test_dtmf.cpp b/tests/native/sip_sdp/test_dtmf.cpp new file mode 100644 index 00000000..be137503 --- /dev/null +++ b/tests/native/sip_sdp/test_dtmf.cpp @@ -0,0 +1,130 @@ +#include "dtmf.h" + +#include +#include +#include + +using esphome::sip_client::DtmfRxDedup; +using esphome::sip_client::parse_dtmf_info; + +namespace { + +int g_failures = 0; + +void require_eq_char(char actual, char expected, const char *message) { + if (actual != expected) { + std::cerr << "FAIL: " << message << " (got '" << (actual == 0 ? '0' : actual) + << "', expected '" << (expected == 0 ? '0' : expected) << "')\n"; + g_failures++; + } +} + +// ---------------- RFC 4733 receive de-duplication ---------------- + +void test_digit_fires_once_per_event() { + // One digit = several packets sharing an RTP timestamp: the start packet, + // duration updates, then the end packet retransmitted three times. + DtmfRxDedup d; + require_eq_char(d.feed(1, 1000), '1', "first packet of the event reports the digit"); + require_eq_char(d.feed(1, 1000), 0, "duration update must not repeat the digit"); + require_eq_char(d.feed(1, 1000), 0, "end packet must not repeat the digit"); + require_eq_char(d.feed(1, 1000), 0, "end retransmit must not repeat the digit"); +} + +void test_marker_less_sender_still_fires() { + // Yealink DECT / 3CX Android send the event without the RTP marker bit. The + // decoder never looks at the marker, so the first packet is enough. + DtmfRxDedup d; + require_eq_char(d.feed(5, 4242), '5', "marker-less first packet reports the digit"); +} + +void test_same_digit_pressed_twice() { + // A second press carries a new RTP timestamp. + DtmfRxDedup d; + require_eq_char(d.feed(1, 1000), '1', "first press"); + require_eq_char(d.feed(1, 1000), 0, "repeat packet of the first press"); + require_eq_char(d.feed(1, 2600), '1', "second press of the same digit"); +} + +void test_star_hash_and_letters() { + DtmfRxDedup d; + require_eq_char(d.feed(10, 1), '*', "event 10 -> *"); + require_eq_char(d.feed(11, 2), '#', "event 11 -> #"); + require_eq_char(d.feed(12, 3), 'A', "event 12 -> A"); + require_eq_char(d.feed(15, 4), 'D', "event 15 -> D"); + require_eq_char(d.feed(16, 5), 0, "event 16 (flash) is dropped"); +} + +void test_reset_between_calls() { + // stop()/start() clears the state, so an identical packet in the next call + // (same event, same timestamp) is not swallowed as a duplicate. + DtmfRxDedup d; + require_eq_char(d.feed(7, 900), '7', "first call"); + d.reset(); + require_eq_char(d.feed(7, 900), '7', "next call reports the digit again"); +} + +// ---------------- SIP INFO ---------------- + +void test_info_dtmf_relay() { + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=1\r\nDuration=160\r\n"), '1', + "Signal= digit"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "signal = 4\r\nDuration=250"), '4', + "lowercase name and spaces around '='"); + require_eq_char(parse_dtmf_info("application/dtmf-relay;charset=utf-8", "Signal=9\r\n"), '9', + "Content-Type parameters are ignored"); +} + +void test_info_star_hash_spellings() { + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=*\r\nDuration=160"), '*', + "literal *"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=10\r\nDuration=160"), '*', + "event code 10 -> *"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=11\r\nDuration=160"), '#', + "event code 11 -> #"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=b\r\n"), 'B', + "lowercase letter digit is normalised to upper case"); +} + +void test_info_plain_dtmf() { + require_eq_char(parse_dtmf_info("application/dtmf", "1"), '1', "bare digit body"); + require_eq_char(parse_dtmf_info("application/dtmf", "#\r\n"), '#', "bare # with trailing CRLF"); +} + +void test_info_duration_is_not_a_digit() { + // The naive "first digit-ish character in the body" approach reads 'D' out of + // "Duration" and fires DTMF 'D'. + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Duration=250\r\n"), 0, + "Duration alone is not a signal"); +} + +void test_info_non_dtmf_is_ignored() { + require_eq_char(parse_dtmf_info("application/media_control+xml", ""), 0, + "video fast-update INFO must not fire DTMF"); + require_eq_char(parse_dtmf_info("", "Signal=1"), 0, "no Content-Type -> no DTMF"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=99\r\n"), 0, + "out-of-range event code"); + require_eq_char(parse_dtmf_info("application/dtmf-relay", "Signal=\r\n"), 0, "empty signal"); +} + +} // namespace + +int main() { + test_digit_fires_once_per_event(); + test_marker_less_sender_still_fires(); + test_same_digit_pressed_twice(); + test_star_hash_and_letters(); + test_reset_between_calls(); + test_info_dtmf_relay(); + test_info_star_hash_spellings(); + test_info_plain_dtmf(); + test_info_duration_is_not_a_digit(); + test_info_non_dtmf_is_ignored(); + + if (g_failures != 0) { + std::cerr << g_failures << " failure(s)\n"; + return 1; + } + std::cout << "All sip_dtmf tests passed\n"; + return 0; +}