Where this file and the code disagree, the code is right and this file has a
bug — fix it in the same change.
## 1. What imsd is
imsd is a userspace IMS/VoLTE client for mainline Linux phones. The modem
firmware on such phones brings up the IMS PDN but cannot carry call audio, so
imsd does the whole IMS client itself: SIP registration with USIM AKA
authentication over kernel IPsec (ESP), call signalling for outgoing and
incoming calls, and the RTP media plane (AMR-WB, AMR, G.711) wired into
PipeWire. A stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh
and GNOME Mobile — places and receives real VoLTE calls on a commercial
network through it.
It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR
(the USIM AKA run goes through a UIM logical channel), installs its security
associations with `ip xfrm`, and plays and records audio with the PipeWire
tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a
mainline kernel with working WWAN/ModemManager integration, kernel ESP, and
PipeWire. The verified device/carrier combinations are in the README.
## 2. Repository layout
| Path | What it is |
|---|---|
| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. |
| `Makefile` | Distro-packaging mirror of `project.cpp`: the same clang module pipeline, the same flags. Keep the two in sync when products, module partitions or link flags change. |
| `lint-rules.h` | The house-style lint rules (§6), registered from `project.cpp` through `AddProjectLintRules`. Project-local on purpose. |
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. |
| `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-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. |
| `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-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). |
| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. |
| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. |
| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. |
| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). |
| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, the two APKBUILDs and the CI helpers (§9). |
| `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). |
| `bin/`, `build/` | Build outputs (crafter-build and make respectively). Ignored. |
Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`,
`*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry
the subscriber's IMSI/MSISDN and network addresses.
## 3. Build products
Six products come out of the tree. C++26 modules throughout, `import std;`
in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld.
| Product | Kind | Built from | Links | Notes |
|---|---|---|---|---|
| `imsd-core` | static library | the `Imsd` module and its seven partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. |
| `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. |
| `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. |
| `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. |
| `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. |
The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so
that every decision in it has a unit test.** New protocol logic goes into a
partition with a test. The shell (`main.cpp`) holds protocol decisions no
test reaches — some older than 0.3.10, some added by it: the reg-event
subscription schedule (§7.4: its retry
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`:
which message is a REGISTER's final, the dispatch of requests arriving
meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the
12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the
inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local
refusals and network silence, and the catch sites that sort a failed refresh
(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt
(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the
status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a
refusal's Retry-After fed as a hold, the old registration written off after
any failed resume), the `OnLink` read that writes the old registration off
when its address has left the netdev (`FreshRecover` after a failed move,
`AddressGone`), and `PacketServiceUp`'s read of
ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY
terminating our binding (the flow closed at once, or kept under a call and
closed when the call ends, a reconnect cycle's or the bring-up resume's wait
ended by it — the reducer's verdict is pinned, the reaction is not); and the
holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the
pdn/boot match, defaults for absent keys — proven on the phone only, captures
45- and 70- of 2026-09-25-polkit-voice). For the schedule, a reducer next to
`RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by
`tests/Sip`, is the follow-up that moves it; a pure format/parse pair in
`crafter-build test` and `make check` run the same seven suites. Each suite
is a plain `main()` with a `Check(bool, message)` counter: failures print
`FAIL: …`, success prints `<Suite>: all tests passed`, and the exit status is
the failure count for six suites — non-zero short of a multiple of 256 — and
1 for `Engine`. Seven `all tests passed` lines from `make check`, on every
commit of a range (§11), is the project's definition of green.
What the suites pin:
-`Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5.
-`Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
text parsers, the AUTHENTICATE APDU and its response parser.
-`Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
Security-Server mechanism selection, the SA reader.
-`Messages` — every outgoing SIP message byte-for-byte, cross-generated from
the Python reference stack imsd replaced.
-`Sip` — the text layer's edge cases (empty header values, line anchoring,
keepalive discards), `ResponseOf`, refused finals with reason headers,
reginfo documents. A change that flips one of those tests is a behaviour
change, not a cleanup.
-`Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's
own payload types and octet-align mode, the `CODECS` override.
-`Engine` — the call state machine driven through recorded network dialogs
(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,
G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the
emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle
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
network terminated).
Rules:
- A decision in `imsd-core` gets a test in the suite that owns the partition.
Fixtures are recorded network traffic where any exists, inline fixtures
only where no recording exists.
- Behaviour that lives in the shell (`main.cpp`: timers, the reg-event
subscription schedule, the audio-session resolution, the hand-over scripts)
is proven on a phone where a bench can reach it; the commit message says
in one sentence that it was tested, and the arms no bench has reached are
named here. The known gaps: the reg-event subscription schedule (the
arming and KPN's
`noresource` closure have run on hardware, the 60 s retry only on a
pre-fix build that renewed that closure; the retry on this code, the
renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the
renew arm and the back-off's survival across a refresh have run nowhere —
§3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery
step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the
`Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine`
only); a refused initial or fresh
REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or
stayed silent — pinned in `tests/Engine` only); the write-off of the old
registration by a recovery's fresh attempt that fails past the SA setup
(such attempts are on record only as a bring-up's fatal exit, which does
not pass that arm — pinned in `tests/Engine` only); a refused
refresh counted
towards the escalation, and the escalation by two of them (KPN refuses a
refresh only with the 403 that says the registration is gone — pinned in
`tests/Engine` only); of the throttled-attempt chain in recovery (a
throttled fresh attempt, the old SA retried once meanwhile with the stored
binding's Contact and refused 403, the deferred attempt accepted at the
hold's end — run 2026-10-03 on the series as it then stood) the deferred
attempt made by the
same process, since a restart inside the hold made it from the holds file
instead (the same process made it on 2026-09-24, on the build before the
policy's rework); the address moves
writing `old_gone` to the holds file (the move benches predate that
field); a SUBSCRIBE final read inside a REGISTER wait (KPN answers within
the second); the shell's handling of a NOTIFY that terminates our own
binding (§3; every captured termination is a previous contact's O2
`deactivated`, and a deregistration during a call cannot be staged);
`SipFlow::Connect`'s errno arms — socket() failing, a UDP connect()
error, a bind() refused while the address is still on the link — which
no bench reaches without fault injection (§3); a coverage gap on the air
(no bench stages a radio gap without an RF shield) — the packet-service
read's rule has run through a fake `mmcli` only (2026-10-03: `searching`
and `detached` not counted, `connecting` with `unknown` counted), its
other states, ModemManager not answering and its second site (a refresh
unanswered over a reconnected flow) not at all, and `tests/Engine` pin
nothing of it; the REGISTER wait's diverting arms — a datagram skipped on
the init port, a response to another transaction ignored, a provisional
restarting the wait, a request (an INVITE among them) dispatched and
answered under the wait; an incoming call answered from a session's
dialer since the polkit gate — imsd-dialerd's `Accept` by the active
session, or GNOME Calls' `Answer` through imsd-ofonod's gate (imsd's own
`Accept` gate has run: refused for an account without a seat session and
for nobody, accepted for root, 2026-10-04); a refused
final carrying reason headers (KPN's 403s carry none — the rendering is pinned
in `tests/Sip` only); an `AUDIO_USER` pin in imsd's own environment that
names no account (the logind fallback) or an account without a PipeWire
socket (a silent call) — a pin naming an account with one ran, 2026-10-04.
`DetectLocal`'s three-tier address ranking for a run without `LOCAL`
(§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the
netdev and two cut-down ones).
-`imsd-media --selftest` round-trips the RFC 4867 payload packing (both
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams
for driving the leg against a synthetic RTP peer without a phone.
-`imsd --session` and `imsd-ofonod --session` own their names on the session
bus for development without root, polkit or a phone (§8.3).
## 6. Code style and lint
`crafter-build lint` reports the rules of `lint-rules.h`; `crafter-build format`
applies the transform rules. The rules, by name:
| Rule | Meaning |
|---|---|
| `spdx-header` | Line 1 `// SPDX-License-Identifier: …`, line 2 `// SPDX-FileCopyrightText: …`, line 3 blank. Line 4 may carry `// lint-disable-file <rule> …` where a file needs one. |
| `no-tabs` | Spaces only. |
| `naming` | Types, aliases, functions, statics, namespace-scope globals and `constexpr` constants are PascalCase; locals and parameters camelCase; one trailing underscore marks a member or shadows a keyword (`label_`, `requires_`). Heuristic, report-only. |
| `enum-class` | `enum class`, never a plain `enum`. |
| `no-iostream-print` | `std::println` (and `std::println(std::cerr, …)`), never `std::cout` / `<<` to `std::cerr`. |
| `format-concat` | `std::format` instead of `+` with a string literal; simple single-line chains are rewritten automatically, anything else is reported. |
| `fixed-width-types` | `std::int32_t` and friends instead of `short`/`int`/`long` (transform); lines with `main`/`argc`/`argv` or `extern "C"` keep C ints. Nearly every source file disables this one on line 4. |
| `single-declaration` | One declaration per statement; `Type a = x, b = y;` is auto-split, declarators with pointers, references, parens or templates are reported. |
| `brace-style` | K&R: a `{` alone on a line after a `)`/`else`/`do`/`try` header joins the header line. Standalone scope blocks stay. |
| `if-single-line` | A single-statement `if` body joins the `if` line when the result stays within 250 columns and neither line carries a comment. |
| `wrap-join` | Wrapped call arguments and `&&`/`||`/`|` continuations join back onto one line when the expression fits in 250 columns. |
| `paren-spacing` | No padding inside parentheses. |
| `trim-trailing-ws` | No trailing whitespace. |
| `final-newline` | The file ends with a newline: one is appended when it is missing. |
Beyond the rules:
- Comments explain constraints the code cannot show — a network's observed
behaviour, a spec clause, the reason a shape was chosen — and name their
evidence (a dated bench, a capture) when they rest on one. They do not
narrate what the code does.
- Log lines are the operator's interface (§7.8). A new state the daemon can
be in gets a journal line that names it; existing lines are grepped for by
scripts (`imsd-handover.sh` reads a pre-0.3.10 daemon's `bring-up deferred: …
next fresh registration in N min`), so changing one is an interface change.
- GLib only in `main.cpp`, `dialerd.cpp`, `ofonod.cpp` and `ImsdBus.cppm`.
`media.cpp` is std + POSIX. The partitions are std only.
## 7. How the daemon works
### 7.1 Processes and buses
`imsd` runs as root on the system bus as `net.catcrafts.IMS1` (object
`/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`; the frozen ABI, §8).
The bus policy `packaging/net.catcrafts.IMS1.conf` lets any local account
reach it; placing, answering and ending a call is polkit's
`net.catcrafts.IMS1.Voice` (`packaging/net.catcrafts.IMS1.policy`, shaped like
ModemManager's Voice action: the active local session and root — not an ssh
login, an inactive session or a system service account). Reads (`GetStatus`,
`GetCalls`) stay open, as ModemManager's are to every local account: the call
signals reach them all anyway, the status (the address, the P-CSCF, the
phone number) travels in no signal.
Two threads: a dedicated engine thread runs the SIP loop (blocking framed
receive on the protected client flow, the protected-port listeners for
terminating requests, media reaping, the keepalive refresh); the GLib main
loop owns the bus. D-Bus method calls push commands to the engine queue;
engine events reach the bus through `g_idle_add`, where the calls table and
the status snapshot live (main thread only).
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
payload type as arguments and the codec and audio knobs in its environment
(§7.6, §10.2).
The desktop daemons are translation layers: `imsd-dialerd` runs as the
session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in
place of plasma-dialer's `modem-daemon`, whose autostart the package hides
through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`);
`imsd-ofonod` runs as root on the system bus (`imsd-ofonod.service`,
`Conflicts=ofono.service`). Both consume the frozen ABI and add nothing to it.
### 7.2 Bring-up
`imsd.service` runs two `ExecStartPre=` steps before the daemon:
1.`/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager
(§7.3) and writes `/run/imsd.env` with `DEV=` (the connected netdev) and
What the daemon does when the registration stops working after bring-up, as
a pure reducer the shell feeds with the clock and the network's answers. Two
kinds of failure reach it: a **refusal** (the network answered a refresh or a
fresh REGISTER with a final other than 200/401) and **silence** (the flow died
and reconnecting through the SA, or the refresh over it, got no answer).
Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and
RFC 5626 have the UE register anew). Before this reducer existed the daemon
looped reconnect + re-REGISTER on one Call-ID and SA forever (O2 UK,
2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect cycles over
2 h 39 min).
The policy, with its constants:
| Step | Rule |
|---|---|
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). |
| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). |
| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. |
| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). |
| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). |
| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). |
| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. |
| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. |
| Success | Resets everything. |
The network's waits are **holds**, and a restart of imsd (an upgrade restarts
it) does not cut them short: `PersistHolds` writes them to the holds file
(`HOLDS_FILE`, default `/run/imsd.holds`) as JSON — `pdn` (the PDN's own
address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`,
`fresh_hold`, `throttle_minutes` (the schedule's place), `old_gone` (the old
registration is gone: a 403 once a fresh registration is due, an attempt past
the SA setup, a binding the network terminated, an older address that left
the netdev, and every bring-up that goes on to register fresh — its resume
failed or there was nothing to resume) — and `LoadHolds` takes them
over at the next start for the same `pdn` and boot id, waiting them out
before the resume (a refresh's `Retry-After`) or before the fresh
registration (a fresh attempt's schedule or `Retry-After`, a terminated
binding's wait); with `old_gone` set the start does not resume over the old SAs at
all, it waits the fresh hold out and registers fresh — also after a start
during an outage, whose resume and fresh attempt both fail without an answer:
the next start then registers fresh while the network still holds the old
registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where
0.3.9's retried resume would have recovered at once). A file without the key
reads as not gone; the 0.3.9 hand-over (`imsd-handover.sh`) writes the key as
true, since the wait it hands over follows a throttled initial REGISTER, at
which KPN drops the old registration (§13). A reboot or another PDN address
starts clean. The schedule's place is kept
even after the holds run out, so a restart does not start it over at 21 min.
When nothing is held the file is removed — when it is a regular file;
`/dev/null` switches the knob off and anything that is not a regular file is
emptied, never unlinked. The recovery policy's waits and the holds are
measured on the boot clock (`CLOCK_BOOTTIME`), so a suspend counts; the
keepalive refresh gate, the CRLF keepalive and the reg-event retry and
renewal timers run on the steady clock and count awake seconds only.
The **reg-event subscription** (RFC 3680; the network's own view of the
binding) is one live dialog per registration, subscribed anew ahead of the
lifetime the S-CSCF granted. Refused or unanswered, it is retried after 60 s
doubling to 10 min (a `Retry-After` honoured up to an hour; a registration
refresh neither brings a scheduled retry forward nor starts its back-off
over — the back-off resets when a subscription starts afresh or is
accepted). When the S-CSCF ends it
for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`,
`giveup`, `probation`) it is renewed; after any other reason — `noresource`,
`rejected`, a reason-less end — the next registration refresh subscribes anew. An
earlier subscription's NOTIFY is still read for the binding's state, but its
end is not ours to renew (KPN closes a registration's previous subscription
with `noresource` when a new one replaces it). A NOTIFY that **terminates the
binding** triggers a fresh registration — at once, or after the network's
`retry-after` (up to an hour); after the call, when one is up (one exception:
a REGISTER wait that still gets its 200 after such a NOTIFY, a call having
kept the flow, leaves that registration standing). After
`rejected` and `unregistered` it waits on the refusal schedule, a wait no
`Dial` cuts short: the binding is gone, so nothing is retried meanwhile. One
termination counts once, however many NOTIFYs report it (a repeated
`probation` report may extend its hold from its own `retry-after`). The schedule is
shell code in `main.cpp`, not a reducer (§3); its hardware coverage per arm
is in §5.
A registration resumed on an older address (a pre-0.3.10 temporary one)
moves to the PDN's own address in place: with its next fresh registration, or
when the older address leaves the netdev first, which takes that registration
with it.
### 7.5 Upgrades and the 0.3.9 hand-over
An apk upgrade (or an `apk add` of a package whose daemon an `apk del` left
running) restarts `imsd` and `imsd-ofonod` once the transaction is done: the
packages' `post-upgrade`/`post-install` scripts mark the units
`needs-restart` (`systemctl set-property … Markers=+needs-restart`, after a
`daemon-reload` when the unit is not-found because its file was just
reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in
progress ends. The restart resumes the registration over its existing SAs,
or goes on waiting out a hold the network had set (§7.4). A stopped unit
keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd
262~rc3, 2026-10-03).
A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so
the restart registered fresh at once instead of waiting the schedule out.
For that one hop: `imsd.post-upgrade` records the running
daemon's `InvocationID` in `/run/imsd.handover` (when no holds file exists);
at the next start `imsd-handover.sh` reads that finished invocation's last
bring-up line from the journal and, when it is the throttle's (`bring-up
deferred: fresh-SA throttle active (spi-s=0) — … next fresh registration in
N min`; the old daemon's other deferrals, after a 401 it could not use, are
not handed over), ages it on the monotonic clock (`__MONOTONIC_TIMESTAMP` against
`/proc/timer_list`; the phone's wall clock can read 1970 until its first NTP
sync, and the monotonic clock stops in a suspend, so the wait can only come
out long, never short of the network's window), and writes what is left to
the holds file as 0.3.10 keeps it — for the address this start registers on
when that is the address the wait was earned on (the old daemon logged its
`LOCAL=` once at start); on another address — the normal hop, 0.3.9 on the
kernel's temporary address and 0.3.10 on the PDN's own, which holds no
registration (§13) — only the verdict that the old registration is gone is
written and the new start registers fresh at once (dev phone 2026-10-03:
accepted, registered 16 s after 0.3.9's refused attempt) —
`LOCAL` as systemd hands it to the unit's commands (the `/etc/imsd.env` pin
if there is one, else what `ims-pdn-up.sh` just wrote), unquoted the way imsd
itself gets it. Reading at the start rather than at the upgrade means a wait
that ended in between — the old daemon making another attempt — is
read as it ended. When the old address cannot be read the wait is handed
over anyway; when the wait cannot be timed, or this start has no `LOCAL`, the
journal says it was not handed over.
### 7.6 Calls and the media plane
`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
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
this deadline). States and reasons are the frozen D-Bus vocabulary:
- Outgoing: INVITE with the SDP offer (AMR-WB + AMR + telephone-event by
default; `CODECS` restricts and orders; `PRECOND=1` adds IR.92
preconditions), reliable 18x with PRACK, 2xx ACK and media start, CANCEL
before answer, BYE after. Busy and error finals end the call with
`refused-or-busy` or `error`.
- 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
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
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
payload types.
- Emergency: a number `IsEmergency` classifies (112, 911, plus
`EMERGENCY_NUMBERS`; SIM `EF_ECC` is not read yet) is tried as
`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
re-attempted as `urn:service:sos`.
-`SendDtmf` is a stub (RFC 4733 not implemented).
- 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
`+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives —
a network/modem-integration matter, not a daemon defect.
`imsd-media` is one process per call: `imsd-media <local-ip> <rtp-port>
<remote-ip> <remote-port> <pt> <secs> <out-base>`. It binds the advertised
RTP port (`RTP_PORT`, default 50004), sends uplink frames from `pw-record`
through the encoder, captures the downlink, and with `PLAY=1` reconstructs
the media clock from RTP timestamps so `pw-play` stays real-time-paced
through far-end DTX silence. Codecs: AMR-WB (16 kHz, the default), AMR
narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR
frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient
(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd
(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB);
G.711 is a table. **Exit code 3 means the downlink dried up** for
`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on
carriers whose BYE never reaches our SAs is the reliable teardown trigger.
Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1`
captures the raw downlink next to them.
**Audio session.** Call audio goes through the PipeWire of one account.
imsd resolves it for each call from logind: the user session active on
seat0 (`loginctl show-seat seat0`; a greeter does not count) when it has a
`/run/user/<uid>/pipewire-0` socket, and passes it to `imsd-media` as
`AUDIO_USER`. `AUDIO_USER` set in imsd's own environment pins an account
instead; one that is not an account falls back to logind. imsd as packaged
(root, on the system bus) logs the resolution at start and when a call's
media starts (`audio session: …`), `imsd-media` once per call. An `imsd-media` started without the variable at all (by hand, or
by an imsd older than 0.3.10 while an upgrade replaces it) tries the
account `user`, its old default.
### 7.7 Runtime files
| Path | Written by | Content |
|---|---|---|
| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit |
| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN |
| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) |
| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` |
| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot |
| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume |
| `/var/lib/imsd/*.stats`, `*.rtp`, `*.raw` | imsd, imsd-media | per-call media stats, raw RTP (`RTP_DUMP`), SIP dumps (`DUMP_SIP`; mode 0600 — they carry the IMSI/MSISDN) |
| `$XDG_STATE_HOME/imsd/` (else `~/.local/state/imsd/`), `$XDG_RUNTIME_DIR/imsd.holds` (else `/tmp/imsd.holds`) | imsd | the same files for a non-root (`--session`) run |
### 7.8 Journal vocabulary
Lines the operator (and the scripts) look for, all prefixed `imsd: `: