PJSUA2 media plane — audio finally reaches the classifier #8

Merged
r merged 5 commits from feat/pjsua2-media-tap into main 2026-07-29 21:34:10 +00:00
3 changed files with 145 additions and 5 deletions
Showing only changes of commit c516f659cc - Show all commits

View File

@@ -63,6 +63,72 @@ GATEWAY_SIP_PORT=21062 # must differ from the Asterisk port
> engine is assigned — `main.py`'s lifespan calls `build_sip_engine()` after
> construction. A harness that skips that step silently tests the mock.
## The softphone (transfer target)
**Asterisk is the registrar for devices, not Hold Slayer.** The gateway reaches
a desk phone by dialling extension `2001`, which Asterisk routes to whatever
has registered as `softphone`. This deliberately avoids Hold Slayer's own SIP
listener, which answers `200 OK` to any REGISTER with no digest challenge.
The `pjsua` CLI built alongside the Python bindings is the test device — same
library stack as the gateway, no extra dependency. It needs an RPATH patch like
the bindings did:
```bash
cp ~/src/pjproject/pjsip-apps/bin/pjsua-x86_64-pc-linux-gnu ~/.local/bin/pjsua
patchelf --set-rpath $HOME/.local/lib ~/.local/bin/pjsua
```
Register it (config file avoids shell-quoting pain):
```bash
cat > softphone.cfg <<'EOF'
--null-audio
--auto-answer=200
--max-calls=4
--local-port=21070
--id=sip:softphone@127.0.0.1
--registrar=sip:127.0.0.1:21061
--realm=asterisk
--username=softphone
--password=labphone
--log-level=3
EOF
# pjsua is an interactive console app: it exits ~8s after start if stdin is
# closed or /dev/null. Hold a fifo open on stdin — `script -qfc` and
# `setsid </dev/null` both look like they work (registration succeeds) and
# then the process dies, leaving a stale contact in Asterisk that routes
# INVITEs to a port nobody is listening on.
mkfifo sp.fifo
setsid sh -c 'exec 3<>sp.fifo; pjsua --config-file softphone.cfg <&3 >softphone.log 2>&1' &
```
Verify — **check the port is actually bound**, not just that Asterisk holds a
contact, since a stale registration outlives the process:
```bash
ss -lnup | grep 21070 # must be listening
docker compose -f docker-compose.lab.yml exec asterisk \
asterisk -rx "pjsip show contacts" # must show softphone
```
Then place a call to `2001`. Both legs should show `Up` under one bridge id:
```bash
docker compose -f docker-compose.lab.yml exec asterisk \
asterisk -rx "core show channels concise"
```
> **`--realm=asterisk`, not `--realm='*'`** — the wildcard fails with
> `PJSIP_EFAILEDCREDENTIAL` against Asterisk's digest challenge.
> **Qualify is off** for this AOR (`qualify_frequency = 0`): the pjsua console
> does not answer `OPTIONS`, so polling marks a working softphone `Unavail` and
> the dialplan refuses to ring it. The `2001` guard therefore tests
> `PJSIP_AOR(softphone,contact)` rather than `DEVICE_STATE`. A real hardphone
> answers OPTIONS and can have qualify re-enabled.
## Useful commands
```bash

View File

@@ -128,6 +128,23 @@ exten => 1008,1,NoOp(LAB 1008: silence)
same => n,Wait(40)
same => n,Hangup()
; --- 2001: ring the registered softphone ----------------------------------
; The transfer target. Asterisk is the registrar for devices, so the gateway
; reaches a desk phone by dialling this rather than by registering it itself.
; Fails fast when nothing is registered — a silent 30s ring would look like a
; gateway bug rather than an absent softphone.
exten => 2001,1,NoOp(LAB 2001: ring softphone)
; Count registered contacts rather than DEVICE_STATE: device state follows
; the OPTIONS qualify, which is off for this AOR (the pjsua CLI does not
; answer OPTIONS), so a registered softphone would still read UNAVAILABLE.
same => n,GotoIf($[${PJSIP_AOR(softphone,contact)} = ""]?nodevice)
same => n,Dial(PJSIP/softphone,30)
same => n,Hangup()
same => n(nodevice),NoOp(LAB 2001: no softphone registered)
same => n,Answer()
same => n,Playback(lab-speech)
same => n,Hangup()
; --- echo test ------------------------------------------------------------
; Not a scenario — a debugging aid. Echoes audio back so you can confirm
; bidirectional RTP by ear when something looks wrong.

View File

@@ -34,14 +34,21 @@ local_net = {{ asterisk_local_net }}
; ---------------------------------------------------------------------------
; Hold Slayer authenticates as this endpoint to place calls into the lab.
; Identify the endpoint by source address. Asterisk's default matching uses
; the From-header domain, which Hold Slayer populates from its SIP bind
; address (0.0.0.0 on a wildcard bind) — never a value Asterisk can match.
; Matching on where the packet actually came from sidesteps that.
; Identify the endpoint by source address *and port*. Asterisk's default
; matching uses the From-header domain, which Hold Slayer populates from its
; SIP bind address (0.0.0.0 on a wildcard bind) — never a value Asterisk can
; match. Matching on where the packet came from sidesteps that.
;
; The port is essential when the softphone runs on the same host: a
; host-only match claims *every* packet from that address, so the
; softphone's REGISTER would be attributed to this endpoint and checked
; against the gateway's password ("Failed to authenticate", confusingly).
; Endpoints that authenticate by username (the softphone) must not be
; covered by an identify block.
[hold-slayer]
type = identify
endpoint = hold-slayer
match = {{ asterisk_match_host }}
match = {{ asterisk_match_host }}:{{ asterisk_gateway_port }}
[hold-slayer]
type = endpoint
@@ -68,6 +75,56 @@ auth_type = userpass
username = {{ asterisk_sip_username }}
password = {{ asterisk_sip_password }}
; ---------------------------------------------------------------------------
; Softphone endpoint — the transfer target
; ---------------------------------------------------------------------------
; Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs
; here and the gateway transfers a live call to it by dialling extension 2001.
;
; Deliberate: Hold Slayer's own SIP listener answers 200 OK to any REGISTER
; with no digest challenge, so anything on the network could register as a
; device and receive transferred calls. Keeping registration in Asterisk means
; the lab does not depend on that path, and the softphone is authenticated.
;
; Test with the pjsua CLI built alongside the Python bindings:
; pjsua --null-audio --auto-answer=200 \
; --id=sip:softphone@<asterisk-host> \
; --registrar=sip:<asterisk-host>:21061 \
; --realm='*' --username=softphone --password=<pw> \
; --local-port=<free port>
[softphone]
type = endpoint
context = hold-slayer-lab
disallow = all
allow = ulaw
allow = alaw
auth = softphone-auth
aors = softphone
dtmf_mode = rfc4733
direct_media = no
force_rport = yes
rewrite_contact = yes
rtp_symmetric = yes
[softphone-auth]
type = auth
auth_type = userpass
username = {{ asterisk_softphone_username }}
password = {{ asterisk_softphone_password }}
[softphone]
type = aor
; The device's contact is learned from its REGISTER rather than configured —
; a softphone's port is not known in advance.
max_contacts = 1
remove_existing = yes
; No qualify: the pjsua CLI does not answer OPTIONS while sitting at its
; console prompt, so polling marks a perfectly working softphone Unavail and
; the dialplan refuses to ring it. Registration itself is the liveness signal
; here. A real hardphone answers OPTIONS and can have qualify re-enabled.
qualify_frequency = 0
[hold-slayer]
type = aor
max_contacts = 2