Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions components/sip_client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions components/sip_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CONF_TRIGGER_ID,
)
from .const import (
CONF_AUTH_USERNAME,
CONF_SERVER,
CONF_DOMAIN,
CONF_CALLER_ID,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]))
Expand Down
1 change: 1 addition & 0 deletions components/sip_client/const.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
CONF_AUTH_USERNAME = "auth_username"
CONF_SERVER = "server"
CONF_DOMAIN = "domain"
CONF_CALLER_ID = "caller_id"
Expand Down
72 changes: 72 additions & 0 deletions components/sip_client/dtmf.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#include "dtmf.h"

#include <cctype>
#include <cstdlib>

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=<token>" 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
60 changes: 60 additions & 0 deletions components/sip_client/dtmf.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#pragma once
#include <cstdint>
#include <string>

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
18 changes: 9 additions & 9 deletions components/sip_client/rtp_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions components/sip_client/rtp_session.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include "codec.h"
#include "dtmf.h"
#include "esphome/components/socket/socket.h"

namespace esphome {
Expand Down Expand Up @@ -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<void(const int16_t *, size_t)> on_audio_{};
std::function<void(char)> on_dtmf_{};
Expand Down
26 changes: 22 additions & 4 deletions components/sip_client/sip_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -250,15 +253,16 @@ 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_++;
this->reg_branch_ = gen_branch();
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 + "\"";
Expand Down Expand Up @@ -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 + "\"";
Expand Down Expand Up @@ -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));
}
Expand Down
8 changes: 8 additions & 0 deletions components/sip_client/sip_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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};
Expand Down
1 change: 1 addition & 0 deletions tests/components/sip_client/test.esp32-idf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading