Emergency calling, stage 1: urn:service:sos with a digits fallback
Classify 112/911 (plus EMERGENCY_NUMBERS) at Dial. A classified call INVITEs urn:service:sos over the existing registration and, on any non-2xx final not caused by the user hanging up (including the setup-timeout CANCEL paths), retries once as a plain INVITE of the dialled digits — the pre-0.3.0 behavior, so classification can never place a call worse than the status quo. The reverse edge: a 380 Alternative Service whose body carries the emergency indication upgrades an unclassified call to the sos URN; a bare 380 stays an error, since promoting an arbitrary redirect would put a non-emergency call through to a PSAP. An emergency dial preempts an in-progress call, and an answer racing a deadline-initiated CANCEL is taken instead of BYE'd (user-initiated CANCEL races still BYE). No emergency registration, no emergency PDN, no CS fallback, no SIM-less calling, no AML — and no carrier has confirmed the sos path end-to-end. The README warning states exactly that. Assisted-by: Claude:claude-fable-5
This commit is contained in:
parent
6e77823933
commit
f2a404855f
8 changed files with 365 additions and 42 deletions
|
|
@ -100,6 +100,24 @@ namespace {
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
// A 380 Alternative Service final; `body` empty = no emergency
|
||||
// indication (a bare redirect).
|
||||
std::string Alt380(std::string_view callid, std::string_view body) {
|
||||
return std::format(
|
||||
"SIP/2.0 380 Alternative Service\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\nCSeq: 1 INVITE\r\n"
|
||||
"Content-Type: application/3gpp-ims+xml\r\n"
|
||||
"Content-Length: {}\r\n\r\n{}",
|
||||
callid, body.size(), body);
|
||||
}
|
||||
constexpr std::string_view EmergencyXml =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<ims-3gpp version=\"1\"><alternative-service><type><emergency/></type>"
|
||||
"<reason>emergency call detected</reason></alternative-service></ims-3gpp>";
|
||||
|
||||
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
|
||||
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
|
||||
// at non-default payload types (104/105) with telephone-event.
|
||||
|
|
@ -525,6 +543,150 @@ int main() {
|
|||
Check(m.Terminated(), "terminated");
|
||||
}
|
||||
|
||||
// ================= emergency (stage 1) ================================
|
||||
|
||||
// ---- emergency dial: the INVITE targets the sos URN over the normal
|
||||
// registration; a 200 is answered like any call
|
||||
{
|
||||
imsd::util::Rng rng(41);
|
||||
CallMachine m(c, rng, "ims-call-15", "112", 50004, false, /*emergency=*/true);
|
||||
auto start = m.Start();
|
||||
const Action* inv = Sent(start, "INVITE ");
|
||||
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency R-URI is the sos URN");
|
||||
Check(inv && inv->text.contains("To: <urn:service:sos>\r\n"), "emergency To carries the URN");
|
||||
Check(inv && inv->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "emergency INVITE rides the registration route");
|
||||
Check(inv && inv->text.contains("m=audio"), "emergency INVITE carries an SDP offer");
|
||||
Check(Has(start, Action::Type::SetDeadline), "emergency dial arms the timeout");
|
||||
auto a200 = m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(Sent(a200, "ACK ") != nullptr, "sos 200 is ACKed");
|
||||
Check(Has(a200, Action::Type::StartMedia), "sos answer starts media");
|
||||
Check(m.State() == CallState::Active, "sos call active");
|
||||
}
|
||||
|
||||
// ---- sos rejected -> one fallback: a fresh-dialog plain INVITE of the
|
||||
// digits, whose answer is then taken normally
|
||||
{
|
||||
imsd::util::Rng rng(43);
|
||||
CallMachine m(c, rng, "ims-call-16", "112", 50004, false, true);
|
||||
m.Start();
|
||||
std::string firstId = m.CallId();
|
||||
auto a = m.OnInviteResponse(Final(m.CallId(), 500, "Server Internal Error"));
|
||||
Check(Sent(a, "ACK ") != nullptr, "sos final is ACKed");
|
||||
const Action* inv2 = Sent(a, "INVITE ");
|
||||
Check(inv2 && inv2->text.starts_with("INVITE sip:112;phone-context="), "fallback INVITE dials the digits");
|
||||
Check(m.CallId() != firstId, "fallback is a fresh dialog");
|
||||
Check(inv2 && inv2->text.contains(std::format("Call-ID: {}\r\n", m.CallId())), "fallback INVITE carries the new Call-ID");
|
||||
Check(inv2 && inv2->text.contains("CSeq: 1 INVITE\r\n"), "fallback INVITE restarts CSeq");
|
||||
Check(Has(a, Action::Type::SetDeadline), "fallback re-arms the timeout");
|
||||
Check(!m.Terminated(), "machine survives into the fallback");
|
||||
auto a2 = m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(Has(a2, Action::Type::StartMedia), "fallback answer starts media");
|
||||
Check(m.State() == CallState::Active, "active on the fallback leg");
|
||||
}
|
||||
|
||||
// ---- chain exhausted: the fallback leg's final terminates, no 3rd try
|
||||
{
|
||||
imsd::util::Rng rng(45);
|
||||
CallMachine m(c, rng, "ims-call-17", "112", 50004, false, true);
|
||||
m.Start();
|
||||
m.OnInviteResponse(Final(m.CallId(), 500, "x")); // sos fails
|
||||
auto a = m.OnInviteResponse(Final(m.CallId(), 486, "Busy Here"));
|
||||
Check(Sent(a, "INVITE ") == nullptr, "no third attempt");
|
||||
const Action* t = Find(a, Action::Type::State);
|
||||
Check(t && t->reason == "refused-or-busy", "fallback final keeps its own reason");
|
||||
Check(m.Terminated(), "terminated when the chain is exhausted");
|
||||
}
|
||||
|
||||
// ---- user hangup during the sos attempt kills the chain: 487 ends it
|
||||
{
|
||||
imsd::util::Rng rng(47);
|
||||
CallMachine m(c, rng, "ims-call-18", "112", 50004, false, true);
|
||||
m.Start();
|
||||
auto ah = m.OnHangup();
|
||||
Check(Sent(ah, "CANCEL ") != nullptr, "user hangup CANCELs the sos attempt");
|
||||
auto a = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
|
||||
Check(Sent(a, "INVITE ") == nullptr, "user cancel suppresses the fallback");
|
||||
const Action* t = Find(a, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "user-cancelled sos -> local-hangup");
|
||||
Check(m.Terminated(), "terminated");
|
||||
}
|
||||
|
||||
// ---- setup timeout on the sos attempt: CANCEL, then the 487 falls back
|
||||
{
|
||||
imsd::util::Rng rng(49);
|
||||
CallMachine m(c, rng, "ims-call-19", "112", 50004, false, true);
|
||||
m.Start();
|
||||
auto a1 = m.OnDeadline();
|
||||
Check(Sent(a1, "CANCEL ") != nullptr, "sos timeout CANCELs");
|
||||
auto a2 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
|
||||
Check(Sent(a2, "INVITE sip:112;phone-context=") != nullptr, "timeout-cancelled sos falls back to the digits");
|
||||
Check(!m.Terminated(), "chain continues after the timeout");
|
||||
}
|
||||
|
||||
// ---- timeout CANCEL whose 487 never comes: second deadline falls back
|
||||
{
|
||||
imsd::util::Rng rng(51);
|
||||
CallMachine m(c, rng, "ims-call-20", "112", 50004, false, true);
|
||||
m.Start();
|
||||
m.OnDeadline(); // CANCEL
|
||||
auto a = m.OnDeadline(); // no 487 arrived
|
||||
Check(Sent(a, "INVITE sip:112;phone-context=") != nullptr, "silent sos attempt still gets its fallback");
|
||||
Check(!m.Terminated(), "not terminated while the chain has a step left");
|
||||
}
|
||||
|
||||
// ---- answered during a DEADLINE-initiated CANCEL race: taken, not BYEd
|
||||
{
|
||||
imsd::util::Rng rng(53);
|
||||
CallMachine m(c, rng, "ims-call-21", "112", 50004, false, true);
|
||||
m.Start();
|
||||
m.OnDeadline(); // CANCEL (not the user)
|
||||
auto ar = m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
|
||||
Check(Sent(ar, "BYE ") == nullptr, "deadline race keeps the answered call");
|
||||
Check(Has(ar, Action::Type::StartMedia), "deadline race starts media");
|
||||
Check(m.State() == CallState::Active, "active after the deadline race");
|
||||
}
|
||||
|
||||
// ---- network 380 with the emergency indication upgrades a plain call
|
||||
// to sos; if that also fails the chain ends (plain already tried)
|
||||
{
|
||||
imsd::util::Rng rng(55);
|
||||
CallMachine m(c, rng, "ims-call-22", "0612345678", 50004, false); // NOT classified
|
||||
m.Start();
|
||||
auto a = m.OnInviteResponse(Alt380(m.CallId(), EmergencyXml));
|
||||
Check(Sent(a, "ACK ") != nullptr, "380 is ACKed");
|
||||
const Action* inv = Sent(a, "INVITE ");
|
||||
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency 380 upgrades to the sos URN");
|
||||
Check(!m.Terminated(), "call survives into the sos attempt");
|
||||
auto a2 = m.OnInviteResponse(Final(m.CallId(), 500, "x"));
|
||||
Check(Sent(a2, "INVITE ") == nullptr, "no loop back to the already-tried plain leg");
|
||||
Check(m.Terminated(), "terminated after the upgraded attempt fails");
|
||||
}
|
||||
|
||||
// ---- a bare 380 (no emergency indication) is an error, never sos
|
||||
{
|
||||
imsd::util::Rng rng(57);
|
||||
CallMachine m(c, rng, "ims-call-23", "0612345678", 50004, false);
|
||||
m.Start();
|
||||
auto a = m.OnInviteResponse(Alt380(m.CallId(), ""));
|
||||
Check(Sent(a, "INVITE ") == nullptr, "bare 380 places no emergency call");
|
||||
const Action* t = Find(a, Action::Type::State);
|
||||
Check(t && t->reason == "error", "bare 380 -> error");
|
||||
Check(m.Terminated(), "terminated on bare 380");
|
||||
}
|
||||
|
||||
// ---- Abandon (emergency preemption): terminate bookkeeping only
|
||||
{
|
||||
imsd::util::Rng rng(59);
|
||||
CallMachine m(c, rng, "ims-call-24", "1233", 50004, false);
|
||||
m.Start();
|
||||
m.OnHangup(); // CANCEL sent, awaiting 487
|
||||
auto a = m.Abandon("preempted by emergency dial");
|
||||
Check(Sent(a, "BYE ") == nullptr && Sent(a, "CANCEL ") == nullptr, "Abandon sends nothing further");
|
||||
Check(Has(a, Action::Type::Deleted), "Abandon deletes the call");
|
||||
Check(m.Terminated(), "abandoned machine is terminated");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Engine: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue