sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg

SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.

Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
This commit is contained in:
Jorijn van der Graaf 2026-10-14 15:05:24 +02:00
commit 92fba40da0
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 365 additions and 73 deletions

109
AGENTS.md
View file

@ -40,7 +40,7 @@ PipeWire. The verified device/carrier combinations are in the README.
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. | | `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. |
| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | | `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). |
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | | `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. |
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | | `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). | | `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). |
| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | | `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. |
@ -282,13 +282,18 @@ What the suites pin:
keepalive discards), `ResponseOf`, refused finals with reason headers, keepalive discards), `ResponseOf`, refused finals with reason headers,
reginfo documents. A change that flips one of those tests is a behaviour reginfo documents. A change that flips one of those tests is a behaviour
change, not a cleanup. change, not a cleanup.
- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's - `Sdp` — the offer's exact bytes, the parser (the telephone-event payload
type at the chosen codec's clock among it), `BuildAnswer` at the offer's
own payload types and octet-align mode, the `CODECS` override. own payload types and octet-align mode, the `CODECS` override.
- `Engine` — the call state machine driven through recorded network dialogs - `Engine` — the call state machine driven through recorded network dialogs
(reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer
races, busy/error finals, the terminating side's 100/180/200/486/487/488, races, busy/error finals, the terminating side's 100/180/200/486/487/488,
G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the
emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle emergency chain, DTMF — sent to the leg on an active call that negotiated
telephone-event, dropped with the reason before the answer, after the
call, without the negotiation, for a character that is no tone or for a
string past `DtmfMaxTones`) and
`RegRecovery` (thresholds, backoffs, the throttle
schedule, Retry-After, which waits a Dial may cut short, when the old schedule, Retry-After, which waits a Dial may cut short, when the old
registration counts as gone, the holds a restart takes over, a binding the registration counts as gone, the holds a restart takes over, a binding the
network terminated). network terminated).
@ -427,7 +432,46 @@ Rules:
the script's keys ran on the host build (2026-10-12, exit 0), its own idle the script's keys ran on the host build (2026-10-12, exit 0), its own idle
(nothing to register to), the discovery-failed exit 1, `main()`'s removal (nothing to register to), the discovery-failed exit 1, `main()`'s removal
of a pair the holds wrote off and `imsd-ofonod` starting without waiting of a pair the holds wrote off and `imsd-ofonod` starting without waiting
for imsd on the dev phone (2026-10-12). for imsd on the dev phone (2026-10-12). DTMF ran on the dev phone (KPN,
2026-10-14 to 16, the fp6 journal's `2026-10-14-dtmf-bench-1` to
`2026-10-16-dtmf-bench-17-heard`): outgoing calls to voicemail over the bus
(AMR-WB, telephone-event 98 — three tones, a string with a character
that is no tone, a wrong call uni: the two drops' lines; benches 1, 5
to 9 and 11 to 16), 13 keys from GNOME Calls' keypad on a call to a stock phone the
network answered narrowband (AMR 99, telephone-event 100), heard there
(bench 2, the first sender; 28 keys from Plasma Dialer's keypad on two
such calls heard there on the one-sender shape, bench 17); on incoming
calls from that phone (the offer's 111 at 16 kHz)
10 and 4 keys sent from the keypad (benches 3 and 4); on Plasma Mobile,
11 keys from Plasma Dialer's keypad on an incoming call and 3 on an
outgoing narrowband one, through `imsd-dialerd` (bench 10). Every event of
ours in benches 1 to 3 and 5 to 17 is in the RFC 4733 shape on the wire
with the clock shared with the audio, and so are three of bench 4's
four: the third carried a mic frame after its first packet (seq 1761
between 1760 and 1762) — the race of the sender's first shape, a thread
of its own beside the mic's, which the sender's commit closes by having
the mic thread send the event packets in its own frames' slots (one
event of bench 4's four raced, none of the 23 live-mic events of benches
2 and 3; the loopback's mic is a file, paced like a microphone, and has
not raced it). That first shape also stamped the audio a slot early or
late per event against the capture clock on the AMR-WB calls measured —
there pw-record delivers frames in bursts of two (§13) and the thread's
140 ms window took six to eight of them: the two tones of `2#`, 10 slots
apart by construction, are 11, 9 and 10 slots apart in benches 5, 12 and
13, AMR-WB voicemail calls (`analysis-v2-bench-{5,12,13}.txt` beside
bench 14) — which the one-sender shape of bench 14 cannot (10); on a
narrowband call the first shape's stamping was not measured.
Not reached on hardware:
a network answering without telephone-event (KPN echoes it; an answer that
keeps the rtpmap off its m-line is pinned in `tests/Sdp`), the drops
before the answer and after the call and the `DtmfMaxTones` bound
(these three pinned in `tests/Engine`), the letters A–D and lower case
(pinned in `tests/Engine` and the selftest), an inbound offer without
telephone-event at the codec's clock (pinned in `tests/Sdp`), the shell's
refusal of a line over PIPE_BUF and its `not sent: no control pipe` line
on a call with no media leg (nothing pins these two), a `pipe()` failure, a write to a
dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, and
G.711 (the loopback only).
- `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims - `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims
bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall` bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall`
on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture, on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture,
@ -513,8 +557,9 @@ the status snapshot live (main thread only).
Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the
`imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and `imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and
payload type as arguments and the codec and audio knobs in its environment payload type as arguments, the codec and audio knobs in its environment
(§7.6, §10.2). (§7.6, §10.2) and its stdin on a pipe from imsd, the control pipe the DTMF
tones travel down (§7.6).
The desktop daemons are translation layers: `imsd-dialerd` runs as the The desktop daemons are translation layers: `imsd-dialerd` runs as the
session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in
@ -1002,8 +1047,8 @@ journal says it was not handed over.
`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never `CallMachine` owns one call's SIP dialog, outgoing or incoming, and never
touches a socket, a clock or a subprocess: each event returns `Action`s for touches a socket, a clock or a subprocess: each event returns `Action`s for
the shell (send this on the client flow, answer that request on the channel the shell (send this on the client flow, answer that request on the channel
it came in on, start/stop the media leg, emit this D-Bus state change, arm it came in on, start/stop the media leg, hand it the tones a key press
this deadline). States and reasons are the frozen D-Bus vocabulary: sent, emit this D-Bus state change, arm this deadline). States and reasons are the frozen D-Bus vocabulary:
`dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons `dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons
`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / `outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` /
`refused-or-busy` / `error`. `refused-or-busy` / `error`.
@ -1016,7 +1061,8 @@ this deadline). States and reasons are the frozen D-Bus vocabulary:
- Incoming: an INVITE on a protected-port listener is validated before it is - Incoming: an INVITE on a protected-port listener is validated before it is
announced to the UI; 100 and 180 (reliable when the caller requires announced to the UI; 100 and 180 (reliable when the caller requires
100rel); `Accept` sends the 200 with the SDP answer at the offer's own 100rel); `Accept` sends the 200 with the SDP answer at the offer's own
payload types and octet-align mode; a remote CANCEL gets 200 + 487; an payload types and octet-align mode, its telephone-event echoed when the
offer's m-line lists one at the codec's clock; a remote CANCEL gets 200 + 487; an
unusable offer 488 naming what was offered; ring timeout 480. A landline unusable offer 488 naming what was offered; ring timeout 480. A landline
gateway's G.711-only or AMR-NB offer rings and is answered at its own gateway's G.711-only or AMR-NB offer rings and is answered at its own
payload types. payload types.
@ -1025,7 +1071,20 @@ this deadline). States and reasons are the frozen D-Bus vocabulary:
`urn:service:sos` first, with one fallback to a plain INVITE of the digits; `urn:service:sos` first, with one fallback to a plain INVITE of the digits;
a plain INVITE answered `380 Alternative Service` with an emergency body is a plain INVITE answered `380 Alternative Service` with an emergency body is
re-attempted as `urn:service:sos`. re-attempted as `urn:service:sos`.
- `SendDtmf` is a stub. - DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets
on the call's RTP stream, at the payload type the SDP negotiated for the
codec's clock (`MediaLeg::dtmfPt`: the answer's for an outgoing call, the
offer's for an incoming one). The
engine's `OnDtmf` lets them through on an active call that negotiated
telephone-event and drops them otherwise, with a journal line that says
why (before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or a string past
`DtmfMaxTones` (128) — then
the whole string, never part of what was pressed; letters are
upper-cased, `a` and `A` are one event). There
is no second method: no SIP INFO, and no tone played into the microphone
path. The shell writes one `dtmf
<tones>` line per `SendDtmf` down the leg's control pipe.
- Incoming calls reach imsd only when the network's T-ADS routes the call to - Incoming calls reach imsd only when the network's T-ADS routes the call to
the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and
`+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives —
@ -1042,8 +1101,8 @@ frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient
(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd (`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd
(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); (`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB);
G.711 is a table. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their G.711 is a table. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their
payload type, unset = none; nothing in imsd supplies the lines or the payload type, unset = none; imsd sets it from the SDP and writes the lines
variable at this commit — a hand run or the loopback does): each `dtmf down the control pipe, §7.1): each `dtmf
<tones>` line on its stdin queues the tones, and a tone takes seven 20 ms <tones>` line on its stdin queues the tones, and a tone takes seven 20 ms
slots of the uplink in place of the audio frames — the event's five slots of the uplink in place of the audio frames — the event's five
(100 ms at -10 dBm0: the marker bit on the first packet, the duration (100 ms at -10 dBm0: the marker bit on the first packet, the duration
@ -1151,6 +1210,19 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
failed: … — the resume is given up (the engine's rule): the pair is failed: … — the resume is given up (the engine's rule): the pair is
removed`). removed`).
- `audio session: …` at start and at each call's media start; `CODECS override active: …`. - `audio session: …` at start and at each call's media start; `CODECS override active: …`.
- `media <codec> pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a
call's media start; `call X: DTMF '…' -> media leg (telephone-event pt
N)` for tones the engine lets through, `call X: DTMF '…' dropped: the call is <state>` /
`… dropped: no telephone-event negotiated for this call` / `… dropped:
'c' is not a tone` / `call X: DTMF of N tones dropped: more than 128` for
tones not sent, `SendDtmf(X, '…') ignored: no such call`; `DTMF '…' not
sent: no control pipe to the media leg` on a call whose control pipe
could not be made or that has no media leg, `DTMF '…' not sent: <error>`
when the control pipe's write fails (`EPIPE`: the leg died under the
action; `EAGAIN`: it is alive, stopped reading, its pipe full),
`DTMF of N tones not sent: longer than the control pipe's atomic
write` (a line the engine's bound should have stopped), `media control
pipe: …; this call has no DTMF` when the pipe could not be made.
- A refused REGISTER or SUBSCRIBE is logged with its status line and - A refused REGISTER or SUBSCRIBE is logged with its status line and
any `Retry-After` / `Warning` / `Reason` / `Server` headers; any `Retry-After` / `Warning` / `Reason` / `Server` headers;
`re-REGISTER challenged: 401 …; re-authentication asked, the next `re-REGISTER challenged: 401 …; re-authentication asked, the next
@ -1281,8 +1353,6 @@ Known gaps, all inherited rather than introduced:
`mm_sim_dup_emergency_numbers()`). An upstream fix would close this. `mm_sim_dup_emergency_numbers()`). An upstream fix would close this.
Dialling an emergency number from the dialpad is unaffected — that reaches Dialling an emergency number from the dialpad is unaffected — that reaches
imsd's own classifier. imsd's own classifier.
- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same
limitation Plasma has.
- Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's - Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's
`VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a
new origin but leaves the old origin's `CallAdded` handler connected, so new origin but leaves the old origin's `CallAdded` handler connected, so
@ -1427,9 +1497,8 @@ there:
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | | `CODEC`, `OCTET_ALIGN`, `DTMF_PT` | *(from the SDP)* | set by imsd from the negotiated answer (`DTMF_PT`: the telephone-event payload type at the codec's clock, empty when none was negotiated); not configuration |
| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) |
| `DTMF_PT` | *(unset)* | the telephone-event payload type the leg sends with (§7.6); nothing in imsd sets it — a hand run or the loopback does |
### 10.3 ims-pdn-up.sh and imsd-ofonod ### 10.3 ims-pdn-up.sh and imsd-ofonod
@ -1582,6 +1651,14 @@ the related code.
and an event's packets leave in those bursts: the audio's own spacing, and an event's packets leave in those bursts: the audio's own spacing,
which RFC 4733 §2.5.1.2 calls the natural interval for event updates which RFC 4733 §2.5.1.2 calls the natural interval for event updates
(its 50 ms recommendation is for a spacing of the sender's own). (its 50 ms recommendation is for a spacing of the sender's own).
- **DTMF on KPN (2026-10-14 to 16):** the answer to our offer echoes
telephone-event at our payload types — 98 at 16 kHz on an AMR-WB call to
voicemail, 100 at 8 kHz on a phone-to-phone call the network answered
narrowband AMR at 99 — and a stock phone's terminating INVITE offers
AMR-WB 107 with telephone-event 111; events of the sender's first shape
(100 ms, a packet every 20 ms, at least 100 ms apart) were heard on that
phone (bench 2 the first shape; bench 17 the one-sender shape — 28 keys
on two narrowband calls, every one heard there).
- **pmOS's default nftables** drops inbound on `qmapmux*`; without the - **pmOS's default nftables** drops inbound on `qmapmux*`; without the
protected-port rule every network-initiated request (NOTIFY, MT INVITE) protected-port rule every network-initiated request (NOTIFY, MT INVITE)
dies after ESP decapsulation and MT calls fall back to CS. dies after ESP decapsulation and MT calls fall back to CS.

View file

@ -31,6 +31,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live
#include <gio/gio.h> #include <gio/gio.h>
#include <glib-unix.h> #include <glib-unix.h>
#include <ifaddrs.h> #include <ifaddrs.h>
#include <limits.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <poll.h> #include <poll.h>
#include <pthread.h> #include <pthread.h>
@ -1052,6 +1053,10 @@ public:
std::scoped_lock g(cmdLock_); std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Accept, uni, {}}); cmds_.push_back({Cmd::Kind::Accept, uni, {}});
} }
void SendDtmf(const std::string& uni, const std::string& tones) {
std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Dtmf, uni, tones});
}
void Stop() { void Stop() {
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
quit_.store(true); quit_.store(true);
@ -1140,7 +1145,7 @@ public:
} }
private: private:
struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones
struct Stopping {}; // the engine was told to stop while a bring-up waited struct Stopping {}; // the engine was told to stop while a bring-up waited
// A bring-up the network deferred (a fresh-SA throttle) or refused: wait // A bring-up the network deferred (a fresh-SA throttle) or refused: wait
@ -1247,6 +1252,7 @@ private:
bool dropCall_ = false; bool dropCall_ = false;
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
pid_t mediaPid_ = -1; pid_t mediaPid_ = -1;
int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin)
double deadline_ = 0; double deadline_ = 0;
// reply_ answers the inbound request being dispatched right now; // reply_ answers the inbound request being dispatched right now;
// callReply_ is the call's durable response channel — refreshed from // callReply_ is the call's durable response channel — refreshed from
@ -2368,8 +2374,8 @@ private:
bool ClearForDial(const Cmd& c, bool emergency) { bool ClearForDial(const Cmd& c, bool emergency) {
if (!call_) return true; if (!call_) return true;
if (!emergency) { if (!emergency) {
Log(std::format("dial({}) refused: call in progress", c.number)); Log(std::format("dial({}) refused: call in progress", c.arg));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0};
PostAdded(bad); PostAdded(bad);
PostState(c.uni, "terminated", "error"); PostState(c.uni, "terminated", "error");
PostDeleted(c.uni); PostDeleted(c.uni);
@ -2385,7 +2391,7 @@ private:
void HandleCmd(const Cmd& c) { void HandleCmd(const Cmd& c) {
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
if (c.kind == Cmd::Kind::Dial) { if (c.kind == Cmd::Kind::Dial) {
bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); bool emergency = imsd::msg::IsEmergency(c.arg, emergencyExtra_);
if (!ClearForDial(c, emergency)) return; if (!ClearForDial(c, emergency)) return;
// a dead client flow fails the INVITE instantly — give the // a dead client flow fails the INVITE instantly — give the
// recovery path one immediate shot first: user action cuts the // recovery path one immediate shot first: user action cuts the
@ -2404,9 +2410,9 @@ private:
if (emergency) if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; " "fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number)); "carrier-side behavior UNVERIFIED", c.arg));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_); call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info); PostAdded(info);
bool ok = Execute(call_->Start()); bool ok = Execute(call_->Start());
if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); }
@ -2416,6 +2422,9 @@ private:
} else if (c.kind == Cmd::Kind::Accept) { } else if (c.kind == Cmd::Kind::Accept) {
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); }
else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); else Log(std::format("Accept({}) ignored: no such ringing call", c.uni));
} else if (c.kind == Cmd::Kind::Dtmf) {
if (call_ && call_->Uni() == c.uni) Execute(call_->OnDtmf(c.arg));
else Log(std::format("SendDtmf({}, '{}') ignored: no such call", c.uni, c.arg));
} }
} }
@ -2515,6 +2524,7 @@ private:
break; break;
case T::StartMedia: StartMedia(a.media); break; case T::StartMedia: StartMedia(a.media); break;
case T::StopMedia: StopMedia(); break; case T::StopMedia: StopMedia(); break;
case T::SendDtmf: SendDtmfToMedia(a.text); break;
case T::State: case T::State:
if (announce) PostState(call_->Uni(), a.state, a.reason); if (announce) PostState(call_->Uni(), a.state, a.reason);
break; break;
@ -2541,7 +2551,7 @@ private:
void StartMedia(const imsd::engine::MediaLeg& leg) { void StartMedia(const imsd::engine::MediaLeg& leg) {
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; }
Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort)); Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort, leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none")));
if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
else else
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
@ -2555,8 +2565,21 @@ private:
session = ResolveAudioSession(); session = ResolveAudioSession();
Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : ""));
} }
// the control pipe: imsd-media reads `dtmf <tones>` lines on its
// stdin (§7.6); our end closes with the leg, no other child inherits
// it, and it never blocks the engine thread — a leg that stopped
// reading fails the write (EAGAIN once the pipe is full) instead
int ctl[2] = {-1, -1};
if (pipe(ctl) != 0) {
Log(std::format("media control pipe: {}; this call has no DTMF", std::strerror(errno)));
} else {
fcntl(ctl[0], F_SETFD, FD_CLOEXEC);
fcntl(ctl[1], F_SETFD, FD_CLOEXEC);
fcntl(ctl[1], F_SETFL, O_NONBLOCK);
}
pid_t pid = fork(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec
if (geteuid() == 0) { if (geteuid() == 0) {
// no session is said with an empty value: imsd-media reads a // no session is said with an empty value: imsd-media reads a
// missing variable as a caller older than 0.3.10 // missing variable as a caller older than 0.3.10
@ -2570,16 +2593,42 @@ private:
setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
setenv("CODEC", leg.codec.c_str(), 1); setenv("CODEC", leg.codec.c_str(), 1);
setenv("DTMF_PT", leg.dtmfPt ? std::to_string(*leg.dtmfPt).c_str() : "", 1);
std::vector<char*> c; std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr); c.push_back(nullptr);
execvp(c[0], c.data()); execvp(c[0], c.data());
_exit(127); _exit(127);
} }
if (ctl[0] >= 0) close(ctl[0]);
if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; }
mediaPid_ = pid; mediaPid_ = pid;
mediaCtl_ = ctl[1];
}
// The engine's SendDtmf action: one `dtmf <tones>` line down the
// control pipe, whole or not at all — a write of at most PIPE_BUF bytes
// to a pipe is atomic, and the engine's DtmfMaxTones keeps the line
// under it (a longer line is refused here too, never half-written to
// be joined with the next). The leg may have died since the action was
// decided (its exit is reaped on this thread, after the actions ran):
// then the write fails with EPIPE — SIGPIPE is ignored in main() — and
// the tones are lost with the call, which the reap ends.
void SendDtmfToMedia(const std::string& tones) {
if (mediaCtl_ < 0) { Log(std::format("DTMF '{}' not sent: no control pipe to the media leg", tones)); return; }
const std::string line = std::format("dtmf {}\n", tones);
if (line.size() > PIPE_BUF) { Log(std::format("DTMF of {} tones not sent: longer than the control pipe's atomic write", tones.size())); return; }
const ssize_t n = write(mediaCtl_, line.data(), line.size());
if (n != static_cast<ssize_t>(line.size())) Log(std::format("DTMF '{}' not sent: {}", tones, n < 0 ? std::strerror(errno) : "short write to the media leg"));
}
void CloseMediaCtl() {
if (mediaCtl_ >= 0) close(mediaCtl_);
mediaCtl_ = -1;
} }
void StopMedia() { void StopMedia() {
CloseMediaCtl();
if (mediaPid_ <= 0) return; if (mediaPid_ <= 0) return;
kill(mediaPid_, SIGTERM); kill(mediaPid_, SIGTERM);
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
@ -2599,6 +2648,7 @@ private:
if (r == 0) return; // still running if (r == 0) return; // still running
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
mediaPid_ = -1; // reaped; don't re-terminate mediaPid_ = -1; // reaped; don't re-terminate
CloseMediaCtl();
Execute(call_->OnMediaExit(code)); Execute(call_->OnMediaExit(code));
MaybeDropCall(); MaybeDropCall();
} }
@ -2676,7 +2726,10 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*
return; return;
} }
if (m == "SendDtmf") { if (m == "SendDtmf") {
Log("SendDtmf accepted but not yet sent (TODO RFC4733)"); const gchar* uni = nullptr;
const gchar* tones = nullptr;
g_variant_get(params, "(&s&s)", &uni, &tones);
TheEngine->SendDtmf(uni ? uni : "", tones ? tones : "");
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
@ -2740,6 +2793,11 @@ int main(int argc, char** argv) {
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
return 1; return 1;
} }
// A write to the media leg's control pipe after the leg died must fail
// with EPIPE, not end the daemon (the client flow's sends carry
// MSG_NOSIGNAL; an inbound connection's reply is a plain write and is
// covered by this too).
signal(SIGPIPE, SIG_IGN);
// The bring-up found nothing to do (IMSD_IDLE= in /run/imsd.env — the // The bring-up found nothing to do (IMSD_IDLE= in /run/imsd.env — the
// script's arms: no SIM, a refused ims PDN): say so and // script's arms: no SIM, a refused ims PDN): say so and

View file

@ -18,8 +18,10 @@ That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
emergency attempt chain (urn:service:sos first for a classified number, one emergency attempt chain (urn:service:sos first for a classified number, one
fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE, the other way), the terminating (UAS) side: 100/180 on an inbound INVITE,
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — 200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — and
DTMF, which reaches the media leg only on an active call whose SDP
negotiated telephone-event, as the tones a keypad has —
unit-testable against recorded network dialogs with no I/O (tests/Engine). unit-testable against recorded network dialogs with no I/O (tests/Engine).
State/reason strings are the frozen D-Bus vocabulary the dialer maps on State/reason strings are the frozen D-Bus vocabulary the dialer maps on
@ -280,12 +282,15 @@ export namespace imsd::engine {
// Parsed SDP answer needed to bring up the media leg. `codec` is one of // Parsed SDP answer needed to bring up the media leg. `codec` is one of
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
// `dtmfPt` is the telephone-event payload type at the codec's clock
// (RFC 4733); nullopt = none negotiated, no DTMF on this call.
struct MediaLeg { struct MediaLeg {
std::string remoteIp; std::string remoteIp;
int remotePort = 0; int remotePort = 0;
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; std::string codec = "AMR-WB";
std::optional<int> dtmfPt;
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
}; };
@ -295,6 +300,7 @@ export namespace imsd::engine {
SendResponse, // answer the just-handled inbound request on its origin channel SendResponse, // answer the just-handled inbound request on its origin channel
StartMedia, // bring up the media leg described by `media` StartMedia, // bring up the media leg described by `media`
StopMedia, // tear the media leg down StopMedia, // tear the media leg down
SendDtmf, // hand `text` (the tones) to the running media leg
State, // emit CallStateChanged(uni, `state`, `reason`) State, // emit CallStateChanged(uni, `state`, `reason`)
Deleted, // emit CallDeleted(uni); drop this machine Deleted, // emit CallDeleted(uni); drop this machine
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
@ -308,6 +314,11 @@ export namespace imsd::engine {
double seconds = 0; double seconds = 0;
}; };
// The most tones one SendDtmf may carry (a keypad sends one; a dialer's
// string is a short sequence): the leg's `dtmf <tones>\n` line stays
// within one atomic pipe write, POSIX's 512-byte PIPE_BUF floor.
inline constexpr std::size_t DtmfMaxTones = 128;
// ring/answer budget, and how long to wait for a 487 after CANCEL — the // ring/answer budget, and how long to wait for a 487 after CANCEL — the
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
inline constexpr double InviteTimeout = 90.0; inline constexpr double InviteTimeout = 90.0;
@ -435,7 +446,7 @@ export namespace imsd::engine {
spec.payloadType = leg.payloadType; spec.payloadType = leg.payloadType;
spec.octetAlign = leg.octetAlign; spec.octetAlign = leg.octetAlign;
spec.codec = leg.codec; spec.codec = leg.codec;
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); spec.dtmfPt = leg.dtmfPt;
localSdp_ = imsd::sdp::BuildAnswer(spec); localSdp_ = imsd::sdp::BuildAnswer(spec);
// session timers: echo the caller's Session-Expires; with no // session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its // refresher stated, make the caller (uac) the refresher — its
@ -608,6 +619,45 @@ export namespace imsd::engine {
return a; return a;
} }
// The user pressed keys: the tones go to the media leg as RFC 4733
// events on an active call whose SDP negotiated telephone-event at
// the codec's clock; otherwise they are dropped with a line that
// says why — there is no second method (no SIP INFO).
// Upper and lower case letters are one event; anything but 0-9, *,
// # and A-D drops the whole string rather than sending part of
// what was pressed, and so does a string past DtmfMaxTones — a
// keypad sends one key, a dialer's string a short sequence; the
// bound keeps the leg's control line within one atomic pipe write
// (PIPE_BUF), so a line is sent whole or not at all.
std::vector<Action> OnDtmf(std::string_view tones) {
std::vector<Action> a;
if (tones.size() > DtmfMaxTones) {
a.push_back(LogAct(std::format("call {}: DTMF of {} tones dropped: more than {}", uni_, tones.size(), DtmfMaxTones)));
return a;
}
std::string sent;
for (char c : tones) {
const char u = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!((u >= '0' && u <= '9') || u == '*' || u == '#' || (u >= 'A' && u <= 'D'))) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: '{}' is not a tone", uni_, tones, c)));
return a;
}
sent += u;
}
if (sent.empty()) return a;
if (state_ != CallState::Active) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: the call is {}", uni_, sent, engine::StateStr(state_))));
return a;
}
if (!dtmfPt_) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: no telephone-event negotiated for this call", uni_, sent)));
return a;
}
a.push_back(LogAct(std::format("call {}: DTMF '{}' -> media leg (telephone-event pt {})", uni_, sent, *dtmfPt_)));
a.push_back(Action{Action::Type::SendDtmf, sent});
return a;
}
// The shell is abandoning this machine (an emergency dial preempts // The shell is abandoning this machine (an emergency dial preempts
// it while it waits on a CANCEL's 487): terminate bookkeeping only — // it while it waits on a CANCEL's 487): terminate bookkeeping only —
// the CANCEL already sent stands, and the late final for this // the CANCEL already sent stands, and the late final for this
@ -687,6 +737,7 @@ export namespace imsd::engine {
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
std::set<std::string> pracked_; std::set<std::string> pracked_;
std::string answerSdp_; std::string answerSdp_;
std::optional<int> dtmfPt_; // the running leg's telephone-event pt, set at media start
// Fresh dialog identity for the current attempt. A fallback INVITE // Fresh dialog identity for the current attempt. A fallback INVITE
// after a final response is a new transaction AND a clean new dialog // after a final response is a new transaction AND a clean new dialog
@ -779,6 +830,7 @@ export namespace imsd::engine {
leg.payloadType = ans.payloadType; leg.payloadType = ans.payloadType;
leg.octetAlign = ans.octetAlign; leg.octetAlign = ans.octetAlign;
leg.codec = ans.codec; leg.codec = ans.codec;
leg.dtmfPt = ans.dtmfPt;
return leg; return leg;
} }
@ -820,6 +872,7 @@ export namespace imsd::engine {
return a; return a;
} }
Action StartMediaAct(const MediaLeg& leg) { Action StartMediaAct(const MediaLeg& leg) {
dtmfPt_ = leg.dtmfPt;
Action a{Action::Type::StartMedia}; Action a{Action::Type::StartMedia};
a.media = leg; a.media = leg;
return a; return a;

View file

@ -9,7 +9,9 @@ The offer is by default an AMR-WB (octet-aligned) + AMR + telephone-event
shape with an optional GSMA IR.92 QoS-precondition block; it is what shape with an optional GSMA IR.92 QoS-precondition block; it is what
commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by
tests/Sdp. The parser extracts only what the media plane needs: remote tests/Sdp. The parser extracts only what the media plane needs: remote
address/port, the negotiated payload type, the codec and octet-align mode — it address/port, the negotiated payload type, the codec and octet-align mode, and
the telephone-event payload type among the m-line's formats at that codec's
clock (DTMF, RFC 4733) — it
reads answers to our offers and inbound offers alike (same fields either way). reads answers to our offers and inbound offers alike (same fields either way).
The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband)
and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
@ -150,22 +152,68 @@ export namespace imsd::sdp {
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
std::optional<int> dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
constexpr std::string_view Avp = " RTP/AVP "; // after the m=audio port: the one profile we speak
// The m=audio line's payload types in its order; empty without the line,
// its port or the " RTP/AVP " literal.
std::vector<int> AudioFormats(std::string_view body) {
std::vector<int> pts;
auto m = Find(body, "m=audio ");
if (!m) return pts;
std::string_view portTok = TokenAt(body, *m + 8);
std::size_t afterPort = *m + 8 + portTok.size();
if (!ParseInt(portTok) || !body.substr(afterPort).starts_with(Avp)) return pts;
// digits + spaces up to CR/LF
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
return pts;
}
// A payload type's rtpmap encoding, `NAME/CLOCK[/CHANNELS]` upper-cased
// (RFC 4566 §6: encoding names are case-insensitive); empty without one.
std::string Rtpmap(std::string_view body, int pt) {
const std::string key = std::format("a=rtpmap:{} ", pt);
auto at = Find(body, key);
if (!at) return "";
std::size_t begin = *at + key.size();
std::size_t end = body.find_first_of("\r\n", begin);
std::string enc(body.substr(begin, end == std::string_view::npos ? std::string_view::npos : end - begin));
for (char& c : enc)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return enc;
}
// Payload type of `telephone-event/<clockRate>` among the m=audio line's
// formats (DTMF, RFC 4733); nullopt when the line lists none. An rtpmap
// the line does not list is not negotiated: nothing is sent with a format
// the answer does not carry (RFC 3264 §8.3.2). The clock is matched
// whole: `/8000` is not `/80000`.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
const std::string needle = std::format("TELEPHONE-EVENT/{}", clockRate);
for (int pt : AudioFormats(body)) {
const std::string enc = Rtpmap(body, pt);
const bool whole = enc.size() == needle.size() || (enc.size() > needle.size() && (enc[needle.size()] == '/' || enc[needle.size()] == ' '));
if (enc.starts_with(needle) && whole) return pt;
}
return std::nullopt;
}
// The encoding name an offer gives a payload type: its a=rtpmap line, // The encoding name an offer gives a payload type: its a=rtpmap line,
// upper-cased (RFC 4566 §6: encoding names are case-insensitive), else // upper-cased (RFC 4566 §6: encoding names are case-insensitive), else
// the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry // the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry
// no rtpmap at all). Empty when neither applies. // no rtpmap at all). Empty when neither applies.
std::string CodecName(std::string_view body, int pt) { std::string CodecName(std::string_view body, int pt) {
std::string key = std::format("a=rtpmap:{} ", pt); const std::string enc = Rtpmap(body, pt);
if (auto at = Find(body, key)) { if (!enc.empty()) return enc.substr(0, enc.find('/'));
std::size_t nameBegin = *at + key.size();
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
for (char& c : name)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return name;
}
if (pt == 0) return "PCMU"; if (pt == 0) return "PCMU";
if (pt == 8) return "PCMA"; if (pt == 8) return "PCMA";
return ""; return "";
@ -202,7 +250,10 @@ export namespace imsd::sdp {
// non-empty `prefs` replaces the codec order (and restricts acceptance // non-empty `prefs` replaces the codec order (and restricts acceptance
// to the codecs named) — the CODECS override. With no acceptable codec // to the codecs named) — the CODECS override. With no acceptable codec
// at all, the first pt is used and codec comes back EMPTY — pretending // at all, the first pt is used and codec comes back EMPTY — pretending
// otherwise would defeat codec checks upstream. // otherwise would defeat codec checks upstream. The telephone-event pt
// is the one at the chosen codec's clock (an event's rate is its rtpmap
// clock, RFC 4733 §2.4.1; an offer with two codec clocks lists
// telephone-event at each), so a tone goes out on the stream's own clock.
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) { Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
Answer a; Answer a;
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
@ -214,17 +265,10 @@ export namespace imsd::sdp {
auto port = ParseInt(portTok); auto port = ParseInt(portTok);
// both the port and the " RTP/AVP " literal are required // both the port and the " RTP/AVP " literal are required
std::size_t afterPort = *m + 8 + portTok.size(); std::size_t afterPort = *m + 8 + portTok.size();
constexpr std::string_view Avp = " RTP/AVP ";
if (!port || !body.substr(afterPort).starts_with(Avp)) return a; if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
a.port = *port; a.port = *port;
// payload-type list: digits + spaces up to CR/LF std::vector<int> pts = AudioFormats(body);
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
std::vector<int> pts;
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
if (pts.empty()) return a; if (pts.empty()) return a;
auto hasOctetAlign = [&](int p) { auto hasOctetAlign = [&](int p) {
@ -261,28 +305,10 @@ export namespace imsd::sdp {
a.codec.clear(); a.codec.clear();
} }
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
return a; return a;
} }
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
std::string needle = std::format("telephone-event/{}", clockRate);
std::size_t pos = 0;
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
std::size_t ptBegin = pos + 9;
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
std::size_t sp = body.find(' ', ptBegin);
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
if (pt && name.starts_with(needle)) return pt;
}
pos = ptBegin;
}
return std::nullopt;
}
struct AnswerSpec { struct AnswerSpec {
std::string local; // connection address (v4 or v6) std::string local; // connection address (v4 or v6)
int rtpPort = 0; int rtpPort = 0;
@ -293,14 +319,11 @@ export namespace imsd::sdp {
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
// SDP answer to an inbound audio offer: single codec at the offer's own // SDP answer to an inbound audio offer: single codec at the offer's own
// payload type, octet-align echoed only when the offer used it (absence // payload type, octet-align echoed only when the offer used it (absence
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all), // = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
// telephone-event kept when offered. b=AS is the codec's peak payload // telephone-event kept when the offer's m-line lists one at the codec's
// clock (ParseAnswer's rule). b=AS is the codec's peak payload
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp. // rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
std::string BuildAnswer(const AnswerSpec& s) { std::string BuildAnswer(const AnswerSpec& s) {
std::string ipver = Is6(s.local) ? "IP6" : "IP4"; std::string ipver = Is6(s.local) ? "IP6" : "IP4";

View file

@ -11,7 +11,10 @@
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
// what was offered), a landline gateway's G.711-only and AMR-NB offers // what was offered), a landline gateway's G.711-only and AMR-NB offers
// ring and are answered at the offer's own payload types, ring timeout -> // ring and are answered at the offer's own payload types, ring timeout ->
// 480, BYE both ways. Then RegRecovery, the registration recovery policy: // 480, BYE both ways. DTMF: the tones reach the media leg on an active
// call that negotiated telephone-event, and are dropped — with the reason
// — before the answer, after the call, without the negotiation or with a
// character that is no tone. Then RegRecovery, the registration recovery policy:
// the refusal and silence thresholds, local refusals, the reconnect and // the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial // fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, the holds a // may cut short, when the old registration counts as gone, the holds a
@ -90,6 +93,21 @@ namespace {
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n" "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid); "t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
} }
// A 200 OK around an arbitrary SDP answer, Content-Length from it — a
// message the shell's framing would deliver whole.
std::string R200Sdp(std::string_view callid, std::string_view sdp) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}", callid, sdp.size(), sdp);
}
constexpr std::string_view SdpHead = "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\nt=0 0\r\n";
std::string Final(std::string_view callid, int code, std::string_view text) { std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format( return std::format(
"SIP/2.0 {} {}\r\n" "SIP/2.0 {} {}\r\n"
@ -209,6 +227,7 @@ int main() {
Check(media && media->media.remotePort == 6004, "media remote port"); Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt"); Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec"); Check(media && media->media.codec == "AMR-WB", "media codec");
Check(media && !media->media.dtmfPt, "an answer without telephone-event: the leg has no DTMF pt");
const Action* active = Find(a200, Action::Type::State); const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted"); Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active"); Check(m.State() == CallState::Active, "state active");
@ -220,6 +239,12 @@ int main() {
Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action"); Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action");
Check(m.State() == CallState::Active, "state still active after the retransmit"); Check(m.State() == CallState::Active, "state still active after the retransmit");
// DTMF on a call whose answer negotiated no telephone-event: dropped, said why
auto adt = m.OnDtmf("1");
Check(!Has(adt, Action::Type::SendDtmf), "no telephone-event negotiated: no SendDtmf");
const Action* dtLog = Find(adt, Action::Type::Log);
Check(dtLog && dtLog->text == "call ims-call-1: DTMF '1' dropped: no telephone-event negotiated for this call", "...and the line says so");
// media leg exits code 3 (downlink dried up) -> BYE + terminated // media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3); auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE"); Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
@ -230,6 +255,43 @@ int main() {
Check(m.Terminated(), "terminated"); Check(m.Terminated(), "terminated");
} }
// ---- DTMF, outgoing: the answer echoes our telephone-event/16000 at pt
// 98; the tones go to the leg once active, in upper case, and are
// dropped before the answer, with a character that is no tone,
// and after the call
{
imsd::util::Rng rng(43);
CallMachine m(c, rng, "ims-call-1d", "1233", 50004, false);
m.Start();
auto early = m.OnDtmf("1");
Check(!Has(early, Action::Type::SendDtmf), "DTMF while dialing: not sent");
const Action* earlyLog = Find(early, Action::Type::Log);
Check(earlyLog && earlyLog->text == "call ims-call-1d: DTMF '1' dropped: the call is dialing", "...the line names the state");
auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 98 100\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:98 telephone-event/16000\r\na=rtpmap:100 telephone-event/8000\r\n", SdpHead)));
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media && media->media.dtmfPt == 98, "the leg carries the answer's telephone-event pt at the codec's clock (98, not the narrowband 100)");
Check(m.State() == CallState::Active, "active");
auto sent = m.OnDtmf("12#*a");
const Action* dt = Find(sent, Action::Type::SendDtmf);
Check(dt && dt->text == "12#*A", "active + negotiated: SendDtmf with the tones, letters upper-cased");
const Action* dtLog = Find(sent, Action::Type::Log);
Check(dtLog && dtLog->text == "call ims-call-1d: DTMF '12#*A' -> media leg (telephone-event pt 98)", "...and the line names the pt");
auto bad = m.OnDtmf("1x");
Check(!Has(bad, Action::Type::SendDtmf), "a character that is no tone: nothing sent, not even the '1'");
const Action* badLog = Find(bad, Action::Type::Log);
Check(badLog && badLog->text == "call ims-call-1d: DTMF '1x' dropped: 'x' is not a tone", "...the line names the character");
Check(m.OnDtmf("").empty(), "an empty string: nothing, silently");
const std::string atMost(DtmfMaxTones, '1');
auto most = m.OnDtmf(atMost);
const Action* maxDt = Find(most, Action::Type::SendDtmf);
Check(maxDt && maxDt->text == atMost, "a string of DtmfMaxTones tones goes");
auto tooMany = m.OnDtmf(std::format("{}1", atMost));
Check(!Has(tooMany, Action::Type::SendDtmf) && Find(tooMany, Action::Type::Log) && Find(tooMany, Action::Type::Log)->text == std::format("call ims-call-1d: DTMF of {} tones dropped: more than {}", DtmfMaxTones + 1, DtmfMaxTones), "one more: dropped, the line names the bound");
m.OnHangup();
auto late = m.OnDtmf("5");
Check(!Has(late, Action::Type::SendDtmf) && Find(late, Action::Type::Log) && Find(late, Action::Type::Log)->text.ends_with("the call is terminated"), "DTMF after the call: dropped, the line names the state");
}
// ---- remote BYE mid-call // ---- remote BYE mid-call
{ {
imsd::util::Rng rng(7); imsd::util::Rng rng(7);
@ -368,6 +430,11 @@ int main() {
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt"); Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align"); Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event"); Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
const Action* mtMedia = Find(aa, Action::Type::StartMedia);
Check(mtMedia && mtMedia->media.dtmfPt == 105, "incoming: the leg carries the offer's telephone-event pt");
auto mtDtmf = m.OnDtmf("9");
const Action* mtDt = Find(mtDtmf, Action::Type::SendDtmf);
Check(mtDt && mtDt->text == "9", "incoming, accepted: the tones go to the leg");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes"); Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact"); Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog) // the 180 and 200 carry the SAME To tag (one dialog)

View file

@ -207,7 +207,21 @@ int main() {
"a=rtpmap:105 telephone-event/16000\r\n" "a=rtpmap:105 telephone-event/16000\r\n"
"a=rtpmap:106 telephone-event/8000\r\n"; "a=rtpmap:106 telephone-event/8000\r\n";
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate"); Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt"); Check(!TelephoneEventPt(offer, 8000), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated");
Check(ParseAnswer(offer).dtmfPt == 105, "the answer's dtmfPt is the one at the chosen codec's clock (AMR-WB: 16000)");
std::string both = std::format("{}a=rtpmap:99 AMR/8000/1\r\n", offer);
both.replace(both.find("RTP/AVP 104 105"), 15, "RTP/AVP 104 99 105 106");
std::array<std::string, 1> nb = {"AMR"};
Check(ParseAnswer(both, nb).dtmfPt == 106, "...and the narrowband one when AMR is chosen");
Check(TelephoneEventPt(both, 8000) == 106, "listed on the m-line, the narrowband telephone-event pt is found");
Check(!ParseAnswer(offer, nb).dtmfPt, "no codec we play chosen: no dtmfPt either");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n").dtmfPt, "no telephone-event in the answer: dtmfPt empty");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt, "the answer dropped telephone-event from its m-line and kept the rtpmap: not negotiated (RFC 3264 §8.3.2: nothing is sent with a format the answer does not list)");
Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt == 101, "G.711 answer with telephone-event/8000: dtmfPt 101");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/16000\r\n").dtmfPt, "telephone-event at the wrong clock for the codec is not the call's");
Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 TELEPHONE-EVENT/8000\r\n").dtmfPt == 101, "the name in another case (RFC 4566: case-insensitive)");
Check(TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 Telephone-Event/16000/1\r\n", 16000) == 98, "mixed case, with a channel count");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 telephone-event/80000\r\n", 8000), "the clock is matched whole: /80000 is not /8000");
Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt"); Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt"); Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
} }