diff --git a/AGENTS.md b/AGENTS.md index e441aa2..0515675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ PipeWire. The verified device/carrier combinations are in the README. | 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=`. | -| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven 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-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. | @@ -42,12 +42,14 @@ PipeWire. The verified device/carrier combinations are in the README. | `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-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/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). | | `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/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (``), no GLib; never Start/Stop Network. | | `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | -| `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | +| `tests//main.cpp` | The eight unit-test suites: `Util`, `Aka`, `Ipsec`, `Qmi`, `Messages`, `Engine`, `Sip`, `Sdp`. | | `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, `APKBUILD.binary` 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/` | crafter-build's outputs and intermediates. Ignored. | @@ -58,21 +60,24 @@ the subscriber's IMSI/MSISDN and network addresses. ## 3. Build products -Six products come out of the tree. C++26 modules throughout, `import std;` +Seven 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-core` | static library | the `Imsd` module and its eight 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` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. | | `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`. | +| `imsd-pcscf` | executable | `implementations/pcscf.cpp` | `imsd-core` | The P-CSCF reader (standalone in this tree: nothing runs it yet): one QMI WDS client over a raw QRTR socket, bound to the bearer's mux data port, asking for the call's runtime settings. Prints the PCO's P-CSCF list; never sends Start Network or Stop Network (its writes are its own client's mux binding and IP family, §12), and the call stayed connected and the registration stood after it (§13). Built with `-- --product=pcscf`. | 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 +partition with a test. The shell (`main.cpp`; from 1.0.0 also `pcscf.cpp`'s +QRTR control lookup and its matching of answers by type, transaction and +message id) holds protocol decisions no test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` at a fresh registration, the move between them and back — §7.2); the reg-event @@ -146,7 +151,8 @@ crafter-build # bin/imsd----/ims crafter-build -- --product=media # bin/imsd-media-…/imsd-media crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod -crafter-build test # the seven unit-test suites +crafter-build -- --product=pcscf # bin/imsd-pcscf-…/imsd-pcscf +crafter-build test # the eight unit-test suites crafter-build lint # the house-style rules (§4.2, §6) ``` @@ -184,9 +190,9 @@ crafter-build format # apply the transform rules in place Six rules read the AST (`naming`, `no-char-pointer`, `const-local`, `constexpr-constant`, `fixed-width-types`, `single-declaration`) and need the -module PCMs: build the four programs and the tests first. With crafter-build -2.1.0 they still do not run on 2 of the 18 files, `implementations/main.cpp` -among them (`'type_traits' file not found`). +module PCMs: build the five programs and the tests first. With crafter-build +2.1.0 they still do not run on every file — `implementations/main.cpp` is +among the misses (`'type_traits' file not found`). ### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) @@ -202,7 +208,7 @@ crafter-build -- --target=aarch64-alpine-linux-musl \ `make-sysroot.sh` needs no root, qemu or device: it downloads `apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, libc++ with its std module source, glib and their headers, compiler-rt, -libunwind, gcc for its +libunwind, linux-headers for `imsd-pcscf`'s ``, gcc for its `crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links against). In a cross build `project.cpp` does not ask the host's pkg-config (it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` @@ -218,11 +224,11 @@ releases. This is the flow CI runs (§9.4). ## 5. Tests -`crafter-build test` runs the seven suites. Each suite +`crafter-build test` runs the eight suites. Each suite is a plain `main()` with a `Check(bool, message)` counter: failures print `FAIL: …`, success prints `: 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`. `crafter-build test` ending in `7 passed`, on every +the failure count for seven suites — non-zero short of a multiple of 256 — and +1 for `Engine`. `crafter-build test` ending in `8 passed`, on every commit of a range (§11), is the project's definition of green. What the suites pin: @@ -246,6 +252,16 @@ What the suites pin: untouched — its reqid-1 ESP state on the UE's address deleted, its AH state left —, a policy-less state on the UE's address deleted, one between other addresses left). +- `Qmi` — the request bytes `imsd-pcscf` sends (four of the five the probe's, + byte for byte; Get Profile Settings is the helper's own, answered by the + modem on the dev phone in the probe capture's `--profile 2 -v` run — the + `profile-pcscf-via-pco` flag line), + the parser on the modem's 163-byte Get Current Settings answer in its exact + TLV shape (addresses re-encoded to `2001:db8::/32`), the OutOfCall answer, + the IPv4/FQDN list readers on synthetic lists, the tolerance of a count + that disagrees with a length, short and overrunning buffers, the + packet-service-status and profile PCSCF-via-PCO readers (present and absent + TLVs) and the error names (OutOfCall, NoEffect, an unknown code as `?`). - `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, @@ -376,6 +392,16 @@ Rules: N`: an entry gone between the read and the delete); the holds file's `pdn` compare in canonical form (no capture stages a holds file whose `pdn` is spelled unlike `LOCAL`). +- `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` + on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture, + `04-…`). Not reached on hardware: exit 2 (a PCO without an IPv6 list), an + error answer to Bind Mux Data Port or Set IP Family, a lookup that finds no + WDS service, the answer timeout, skipped strays and indications, the + lookup's second send to a local node other than 1, and the `v4`/`fqdn` + output lines (`tests/Qmi` pins their parsing on synthetic TLVs only). The + QRTR control lookup and the matching of answers by type, transaction and + message id are shell code (`pcscf.cpp`) no test reaches. - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams @@ -394,11 +420,13 @@ suppress a rule where a file needs it; nearly every source file disables `fixed-width-types`. The tree was written under an earlier, project-local copy of the rules and -does not pass the shared ones yet: `crafter-build lint` reports 72 findings -(2026-10-06) — `naming` 44 (constants and statics are PascalCase here, the -shared rule wants camelCase), `const-local` 10 (a rule the copy did not -have), `single-declaration` 2 (not reported under the copy), `format-concat` -13 and `wrap-join` 3 (as under the copy). CI does not run lint. +does not pass the shared ones yet: `crafter-build lint` reports findings in +most files — `naming` first (constants and statics are PascalCase here, the +shared rule wants camelCase), then `format-concat`, `const-local`, +`wrap-join`, `single-declaration` and `no-char-pointer` (`const-local` and +`single-declaration` the copy did not report) — and the count moves (72 on +2026-10-06, before the Qmi files; 108 in 16 of 20 files on the tree that +added them, 2026-10-13). CI does not run lint. Beyond the rules: @@ -1133,7 +1161,7 @@ Crafter.Build Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net — the branch Crafter.Build publishes from its release tags, where `beta` follows every push to its master; the build log names its commit) and llvm/abuild, builds -the sysroot, cross-compiles the four products, runs the suite natively inside +the sysroot, cross-compiles the five products, runs the suite natively inside the Flatpak, wraps the result with `APKBUILD.binary`, and the publish step uploads the apks to the Forgejo Alpine registry (`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The @@ -1198,7 +1226,7 @@ only the imsd side of its bus choice. not override the identity from the environment. - **One change, one commit; a fix goes into the commit that introduced the problem** while a range is unpublished (rebase), never as a follow-up on - top. Every commit of a range builds and passes the seven suites on its own. + top. Every commit of a range builds and passes the eight suites on its own. - **Keep `make-bin-tarball.sh`'s file list and `APKBUILD.binary`'s `package()` in sync** when a packaged file is added. - **The README and this file follow the code in the same commit.** A new @@ -1251,6 +1279,13 @@ only the imsd side of its bus choice. - The fixed protected ports (45061/45062) are opened in nftables for `qmapmux*` only, by the bring-up script. +- **`imsd-pcscf` holds a QMI WDS client of its own on the modem** (nothing + in this tree runs it yet). It binds to the bearer's mux data port and reads + the call's runtime settings; it never sends Start Network or Stop Network. Its only writes + are its own client's mux binding and IP family; the call stayed connected + and the registration stood after it (§13). One client socket, closed on + exit (the service lookup's own socket closes when the lookup ends). + ## 13. Carrier and platform facts the code relies on Observed on real networks and encoded in the policy; cite them when touching @@ -1306,3 +1341,17 @@ the related code. every name the receiver owns (the `org.ofono.conf` detail). - **The phone's wall clock reads 1970 until the first NTP sync**; anything that ages a journal line uses the monotonic or boot clock. +- **A WDS client bound to the bearer's mux data port, with IP family 6 set, + reads the call's runtime settings; one that sent neither the binding nor Set IP Family got + `OutOfCall`.** ModemManager owns the ims + bearer's call and never asks the modem for the P-CSCF fields; a second + client that does Bind Mux Data Port (the netdev's `rmnet mux_id`, endpoint + embedded/1 for the `ipa` driver — ModemManager's own binding), Set IP + Family 6 and Get Current Settings gets them: TLV 0x22 = 1, the IPv6 list + in TLV 0x2E (two addresses on KPN), no IPv4 list, an empty FQDN list. Get Current Settings from a client that sent neither + Bind Mux Data Port nor Set IP Family answered `OutOfCall` (the control run), + as did a client bound to a mux with no call (`--mux 7`). The bearer stayed + connected and the registration stood afterwards (dev phone, + 2026-10-11; the fp6 journal's `2026-10-11-pcscf-pco-probe` capture). + libqmi does not model 0x2E and qmicli cannot print any of it, hence + `imsd-pcscf`. diff --git a/implementations/pcscf.cpp b/implementations/pcscf.cpp new file mode 100644 index 0000000..7282421 --- /dev/null +++ b/implementations/pcscf.cpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types no-char-pointer +/* +imsd-pcscf — prints the P-CSCF list the network delivered in the ims PDN's +Protocol Configuration Options, read from the modem over QMI. + +ModemManager owns the data call and never asks the modem for these fields; +a second WDS client gets them once it is bound to the same mux data port +(the binding ModemManager gives its own clients), so this is a reader on +the side: Bind Mux Data Port, Set IP Family 6, Get Current Settings, over +one AF_QIPCRTR socket, which is its own QMI client as far as the modem is +concerned — no proxy, no interference with ModemManager's. It never sends +Start Network or Stop Network: it changes nothing about the call. The +bytes are Imsd:Qmi's; this file is the socket, the service lookup and the +sequence. + +Usage: imsd-pcscf --mux [--ep-type 4] [--ep-iface 1] [--node 0] + [--profile ] [--timeout ] [-v] + --mux the rmnet mux id of the ims netdev (`ip -d link show `) + --ep-type, --ep-iface + the modem's data endpoint: embedded (4), interface 1 for an + IPA-attached modem (what ModemManager binds for the `ipa` + driver) + --node the QRTR node of the modem's WDS service (0) + --profile the ims profile's 3GPP index: adds the profile's "PCSCF Address + Using PCO" flag to the output (did the modem ask the network at + all?) + --timeout the whole run's budget in seconds (10) + -v every request and answer in hex, on stderr +Output, one item per line: + the P-CSCF list, PCO order + v4
an IPv4 P-CSCF (unusable on an IPv6 PDN) + fqdn a P-CSCF name + flag pco-requested <0|1> the call's TLV 0x22 + flag profile-pcscf-via-pco <0|1> the profile's flag (with --profile) +Exit: 0 at least one IPv6 address; 2 none; 1 an error (text on stderr — + `OutOfCall` means the bound client does not see a call on that mux). +*/ + +#include +#include +#include +#include +#include + +import std; +import Imsd; + +namespace { + bool Verbose = false; + + void Err(std::string_view s) { std::println(std::cerr, "imsd-pcscf: {}", s); } + + std::string Hex(std::span b) { + std::string s; + for (std::uint8_t x : b) s += std::format("{}{:02x}", s.empty() ? "" : " ", x); + return s; + } + + long NowMs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + } + + // Ask the control port for the WDS servers; the first one on `node`. + // (The lookup is sent to node 1's control port — the local node on this + // platform — and to our own node's when that differs, as the modem's + // control port answers for the whole bus.) + std::optional FindWds(std::uint32_t node, long deadlineMs) { + int s = socket(AF_QIPCRTR, SOCK_DGRAM, 0); + if (s < 0) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return std::nullopt; } + qrtr_ctrl_pkt pkt{}; + pkt.cmd = QRTR_TYPE_NEW_LOOKUP; + pkt.server.service = imsd::qmi::WdsService; + sockaddr_qrtr ctrl{}; + ctrl.sq_family = AF_QIPCRTR; + ctrl.sq_node = 1; + ctrl.sq_port = QRTR_PORT_CTRL; + (void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast(&ctrl), sizeof ctrl); + sockaddr_qrtr me{}; + socklen_t ml = sizeof me; + if (getsockname(s, reinterpret_cast(&me), &ml) == 0 && me.sq_node != 1) { + ctrl.sq_node = me.sq_node; + (void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast(&ctrl), sizeof ctrl); + } + std::optional found; + while (true) { + long left = deadlineMs - NowMs(); + if (left <= 0) break; + pollfd p{s, POLLIN, 0}; + if (poll(&p, 1, static_cast(std::min(left, 2000L))) <= 0) break; + qrtr_ctrl_pkt in{}; + sockaddr_qrtr from{}; + socklen_t fl = sizeof from; + ssize_t n = recvfrom(s, &in, sizeof in, 0, reinterpret_cast(&from), &fl); + if (n < static_cast(sizeof in)) continue; + if (in.cmd != QRTR_TYPE_NEW_SERVER) continue; + if (in.server.service == 0) break; // end of the dump + if (Verbose) Err(std::format("WDS server: instance {} at {}:{}", static_cast(in.server.instance), static_cast(in.server.node), static_cast(in.server.port))); + if (in.server.service == imsd::qmi::WdsService && in.server.node == node && !found) { + sockaddr_qrtr a{}; + a.sq_family = AF_QIPCRTR; + a.sq_node = in.server.node; + a.sq_port = in.server.port; + found = a; + } + } + close(s); + return found; + } + + // One QMI client: one socket, one transaction at a time. + class Wds { + public: + Wds(sockaddr_qrtr peer, long deadlineMs) : peer_(peer), deadline_(deadlineMs) { fd_ = socket(AF_QIPCRTR, SOCK_DGRAM, 0); } + ~Wds() { if (fd_ >= 0) close(fd_); } + bool Open() const { return fd_ >= 0; } + std::uint16_t NextTxn() { return ++txn_; } + + // Send a request and wait for its response (same transaction and + // message id); indications and strays are skipped. + std::optional Call(std::span req, std::uint16_t msg, std::string_view name) { + if (Verbose) Err(std::format("--> {}: {}", name, Hex(req))); + if (sendto(fd_, req.data(), req.size(), 0, reinterpret_cast(&peer_), sizeof peer_) < 0) { + Err(std::format("{}: sendto: {}", name, std::strerror(errno))); + return std::nullopt; + } + std::vector buf(65536); + while (true) { + long left = deadline_ - NowMs(); + if (left <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; } + pollfd p{fd_, POLLIN, 0}; + int pr = poll(&p, 1, static_cast(left)); + if (pr <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; } + ssize_t n = recv(fd_, buf.data(), buf.size(), 0); + if (n <= 0) continue; + auto r = imsd::qmi::ParseMessage(std::span(buf.data(), static_cast(n))); + if (!r) continue; + if (r->type != imsd::qmi::TypeResponse || r->txn != txn_ || r->msg != msg) { + if (Verbose) Err(std::format(" (skipped type {} txn {} msg 0x{:04x})", r->type, r->txn, r->msg)); + continue; + } + if (Verbose) Err(std::format("<-- {}: {}", name, Hex(std::span(buf.data(), static_cast(n))))); + return r; + } + } + + private: + int fd_ = -1; + sockaddr_qrtr peer_; + long deadline_; + std::uint16_t txn_ = 0; + }; + + void Usage() { + std::println(std::cerr, "usage: imsd-pcscf --mux [--ep-type 4] [--ep-iface 1] [--node 0] [--profile ] [--timeout ] [-v]"); + } +} + +int main(int argc, char** argv) { + std::optional mux; + std::optional profile; + std::uint32_t epType = imsd::qmi::EndpointEmbedded; + std::uint32_t epIface = imsd::qmi::EndpointInterfaceIpa; + std::uint32_t node = 0; + double timeout = 10.0; + auto num = [&](int& i, const char* what) -> std::optional { + if (i + 1 >= argc) { Err(std::format("{} needs a value", what)); return std::nullopt; } + std::string_view v(argv[++i]); + long out = 0; + auto [p, ec] = std::from_chars(v.data(), v.data() + v.size(), out); + if (ec != std::errc{} || p != v.data() + v.size()) { Err(std::format("{}: not a number: {}", what, v)); return std::nullopt; } + return out; + }; + for (int i = 1; i < argc; i++) { + std::string_view a(argv[i]); + std::optional v; + if (a == "--mux") { if (!(v = num(i, "--mux"))) return 1; mux = static_cast(*v); } + else if (a == "--profile") { if (!(v = num(i, "--profile"))) return 1; profile = static_cast(*v); } + else if (a == "--ep-type") { if (!(v = num(i, "--ep-type"))) return 1; epType = static_cast(*v); } + else if (a == "--ep-iface") { if (!(v = num(i, "--ep-iface"))) return 1; epIface = static_cast(*v); } + else if (a == "--node") { if (!(v = num(i, "--node"))) return 1; node = static_cast(*v); } + else if (a == "--timeout") { + if (i + 1 >= argc) { Err("--timeout needs a value"); return 1; } + timeout = std::atof(argv[++i]); + if (timeout <= 0) { Err("--timeout: not a positive number"); return 1; } + } + else if (a == "-v") Verbose = true; + else { Usage(); return 1; } + } + if (!mux || *mux < 0 || *mux > 255) { Usage(); return 1; } + if (profile && (*profile < 0 || *profile > 255)) { Err("--profile: 0..255"); return 1; } + + long deadline = NowMs() + static_cast(timeout * 1000); + auto peer = FindWds(node, deadline); + if (!peer) { Err(std::format("no WDS service on qrtr node {}", node)); return 1; } + Wds c(*peer, deadline); + if (!c.Open()) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return 1; } + + auto step = [&](std::vector req, std::uint16_t msg, std::string_view name) -> std::optional { + auto r = c.Call(req, msg, name); + if (!r) return std::nullopt; + if (!r->Ok()) { + Err(std::format("{}: error {} ({})", name, r->error, imsd::qmi::ErrorName(r->error))); + return std::nullopt; + } + return r; + }; + using namespace imsd::qmi; + if (!step(BindMuxDataPort(c.NextTxn(), epType, epIface, static_cast(*mux)), MsgBindMuxDataPort, "Bind Mux Data Port")) return 1; + if (!step(SetIpFamily(c.NextTxn(), IpFamilyV6), MsgSetIpFamily, "Set IP Family")) return 1; + if (Verbose) { + if (auto r = step(GetPacketServiceStatus(c.NextTxn()), MsgGetPacketServiceStatus, "Get Packet Service Status")) + Err(std::format("packet service status: {}", PacketServiceStatus(*r) ? std::to_string(*PacketServiceStatus(*r)) : "?")); + } + auto settings = c.Call(GetCurrentSettings(c.NextTxn(), RequestedSettingsAll), MsgGetCurrentSettings, "Get Current Settings"); + if (!settings) return 1; + if (!settings->Ok()) { + if (settings->error == ErrOutOfCall) Err(std::format("Get Current Settings: OutOfCall — the client bound to mux {} sees no call there (is that the ims netdev's mux id?)", *mux)); + else Err(std::format("Get Current Settings: error {} ({})", settings->error, ErrorName(settings->error))); + return 1; + } + PcscfLists lists = ParsePcscfLists(*settings); + for (const std::string& a : lists.ipv6) std::println("{}", a); + for (const std::string& a : lists.ipv4) std::println("v4 {}", a); + for (const std::string& n : lists.fqdn) std::println("fqdn {}", n); + std::println("flag pco-requested {}", lists.viaPco ? 1 : 0); + if (profile) { + auto r = c.Call(GetProfileSettings(c.NextTxn(), static_cast(*profile)), MsgGetProfileSettings, "Get Profile Settings"); + if (r && r->Ok()) { + if (auto f = ProfilePcscfViaPco(*r)) std::println("flag profile-pcscf-via-pco {}", *f ? 1 : 0); + else Err(std::format("Get Profile Settings: profile {} carries no PCSCF-via-PCO flag", *profile)); + } else if (r) { + Err(std::format("Get Profile Settings: error {} ({})", r->error, ErrorName(r->error))); + } + } + return lists.ipv6.empty() ? 2 : 0; +} diff --git a/interfaces/Imsd-Qmi.cppm b/interfaces/Imsd-Qmi.cppm new file mode 100644 index 0000000..f1c2e26 --- /dev/null +++ b/interfaces/Imsd-Qmi.cppm @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Imsd:Qmi — the bytes of the QMI WDS exchanges imsd-pcscf has with the modem +to learn the ims PDN's P-CSCF list, and the readers of the answers. + +Over QRTR a QMI message is {type u8, transaction u16, message u16, length +u16} followed by TLVs {type u8, length u16, value}, integers little endian. +The modem hands the P-CSCF addresses the network put in the PDN's Protocol +Configuration Options to a WDS client bound to the data port that carries the +call (Bind Mux Data Port, Set IP Family, Get Current Settings) — in the TLVs +libqmi models (0x22 the flag, 0x23 the IPv4 list, 0x24 the FQDN list) and in +one it does not: 0x2E, the IPv6 list, Qualcomm's layout as read off the +Fairphone 6 (a count byte, then 16 bytes per address, PCO order). The socket, +the service lookup and the sequence are the helper's +(implementations/pcscf.cpp); this partition only shapes bytes and reads +answers, pinned by tests/Qmi on a response in the exact shape the modem +returned, addresses re-encoded to the documentation prefix. +*/ + +export module Imsd:Qmi; +import std; +import :Util; + +export namespace imsd::qmi { + + constexpr std::uint32_t WdsService = 1; + constexpr std::uint8_t TypeRequest = 0x00; + constexpr std::uint8_t TypeResponse = 0x02; + constexpr std::uint8_t TypeIndication = 0x04; + + constexpr std::uint16_t MsgGetPacketServiceStatus = 0x0022; + constexpr std::uint16_t MsgGetProfileSettings = 0x002B; + constexpr std::uint16_t MsgGetCurrentSettings = 0x002D; + constexpr std::uint16_t MsgSetIpFamily = 0x004D; + constexpr std::uint16_t MsgBindMuxDataPort = 0x00A2; + + // Get Current Settings "Requested Settings": every bit libqmi defines + // (0..18). The P-CSCF ones are 10 (address), 11 (server address list) + // and 12 (domain name list); the whole mask is what the probe used. + constexpr std::uint32_t RequestedSettingsAll = 0x7FFFF; + // The data endpoint of an IPA-attached modem as ModemManager binds it: + // type embedded (4), interface number 1. + constexpr std::uint32_t EndpointEmbedded = 4; + constexpr std::uint32_t EndpointInterfaceIpa = 1; + constexpr std::uint8_t IpFamilyV6 = 6; + constexpr std::uint8_t ProfileType3gpp = 0; + constexpr std::uint8_t PacketServiceConnected = 2; + + constexpr std::uint16_t ErrOutOfCall = 15; + constexpr std::uint16_t ErrNoEffect = 26; + + struct Tlv { + std::uint8_t type = 0; + std::vector value; + }; + + namespace detail { + inline void Put16(std::vector& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast(v >> 8)); + } + inline void Put32(std::vector& b, std::uint32_t v) { + Put16(b, static_cast(v & 0xFFFF)); + Put16(b, static_cast(v >> 16)); + } + inline std::uint16_t Get16(std::span b, std::size_t at) { + return static_cast(b[at] | (b[at + 1] << 8)); + } + inline std::uint32_t Get32(std::span b, std::size_t at) { + return static_cast(Get16(b, at)) | (static_cast(Get16(b, at + 2)) << 16); + } + // RFC 5952 text of 16 address bytes (via the canonicaliser: the + // partition has no inet_ntop) + inline std::string Ip6Text(std::span a) { + std::string s; + for (std::size_t i = 0; i < 16; i += 2) s += std::format("{}{:x}", i ? ":" : "", (a[i] << 8) | a[i + 1]); + return imsd::util::CanonicalIp6(s).value_or(s); + } + } + + inline std::vector TlvBytes(std::uint8_t type, std::span value) { + std::vector b; + b.push_back(type); + detail::Put16(b, static_cast(value.size())); + b.insert(b.end(), value.begin(), value.end()); + return b; + } + inline std::vector Tlv8(std::uint8_t type, std::uint8_t v) { + std::array a{v}; + return TlvBytes(type, a); + } + inline std::vector Tlv32(std::uint8_t type, std::uint32_t v) { + std::vector a; + detail::Put32(a, v); + return TlvBytes(type, a); + } + + // A request: the header, then the TLVs as given. + inline std::vector Request(std::uint16_t txn, std::uint16_t msg, std::span tlvs) { + std::vector b; + b.push_back(TypeRequest); + detail::Put16(b, txn); + detail::Put16(b, msg); + detail::Put16(b, static_cast(tlvs.size())); + b.insert(b.end(), tlvs.begin(), tlvs.end()); + return b; + } + + // Bind Mux Data Port: TLV 0x10 {endpoint type u32, interface number + // u32}, TLV 0x11 mux id u8 — the binding ModemManager gives its own + // bearer clients, which is what lets this client read their call. + inline std::vector BindMuxDataPort(std::uint16_t txn, std::uint32_t epType, std::uint32_t epIface, std::uint8_t mux) { + std::vector ep; + detail::Put32(ep, epType); + detail::Put32(ep, epIface); + std::vector t = TlvBytes(0x10, ep); + std::vector m = Tlv8(0x11, mux); + t.insert(t.end(), m.begin(), m.end()); + return Request(txn, MsgBindMuxDataPort, t); + } + // Set IP Family: TLV 0x01 u8 (4 or 6) — which family's settings the + // client asks about. + inline std::vector SetIpFamily(std::uint16_t txn, std::uint8_t family) { + return Request(txn, MsgSetIpFamily, Tlv8(0x01, family)); + } + inline std::vector GetPacketServiceStatus(std::uint16_t txn) { + return Request(txn, MsgGetPacketServiceStatus, {}); + } + // Get Current Settings: TLV 0x10 the requested-settings mask u32. + inline std::vector GetCurrentSettings(std::uint16_t txn, std::uint32_t mask) { + return Request(txn, MsgGetCurrentSettings, Tlv32(0x10, mask)); + } + // Get Profile Settings: TLV 0x01 {profile type u8 (0 = 3GPP), index u8}. + inline std::vector GetProfileSettings(std::uint16_t txn, std::uint8_t index) { + std::array id{ProfileType3gpp, index}; + return Request(txn, MsgGetProfileSettings, TlvBytes(0x01, id)); + } + + struct Response { + std::uint8_t type = 0; + std::uint16_t txn = 0; + std::uint16_t msg = 0; + std::vector tlvs; + bool hasResult = false; // TLV 0x02 {result u16, error u16} present + std::uint16_t result = 0; + std::uint16_t error = 0; + bool Ok() const { return hasResult && result == 0; } + const Tlv* Find(std::uint8_t t) const { + for (const Tlv& x : tlvs) if (x.type == t) return &x; + return nullptr; + } + }; + + // A received message: the header and every TLV; nullopt when the buffer + // is shorter than its header says or a TLV overruns it. + inline std::optional ParseMessage(std::span b) { + if (b.size() < 7) return std::nullopt; + Response r; + r.type = b[0]; + r.txn = detail::Get16(b, 1); + r.msg = detail::Get16(b, 3); + std::size_t len = detail::Get16(b, 5); + if (7 + len > b.size()) return std::nullopt; + std::size_t off = 7; + std::size_t end = 7 + len; + while (off + 3 <= end) { + Tlv t; + t.type = b[off]; + std::size_t n = detail::Get16(b, off + 1); + if (off + 3 + n > end) return std::nullopt; + t.value.assign(b.begin() + static_cast(off + 3), b.begin() + static_cast(off + 3 + n)); + if (t.type == 0x02 && n == 4) { + r.hasResult = true; + r.result = detail::Get16(t.value, 0); + r.error = detail::Get16(t.value, 2); + } + r.tlvs.push_back(std::move(t)); + off += 3 + n; + } + if (off != end) return std::nullopt; + return r; + } + + // The QMI protocol error names imsd-pcscf can meet (libqmi's nicks). + inline std::string_view ErrorName(std::uint16_t e) { + switch (e) { + case 0: return "None"; + case 1: return "MalformedMessage"; + case 3: return "Internal"; + case 9: return "InvalidHandle"; + case 10: return "InvalidProfile"; + case 14: return "CallFailed"; + case 15: return "OutOfCall"; + case 17: return "MissingArgument"; + case 23: return "DeviceInUse"; + case 26: return "NoEffect"; + case 46: return "GeneralError"; + case 48: return "InvalidArgument"; + case 71: return "InvalidQmiCommand"; + case 82: return "AccessDenied"; + default: return "?"; + } + } + + // What a Get Current Settings answer says about the P-CSCF. + struct PcscfLists { + bool viaPco = false; // TLV 0x22: the modem asked for P-CSCF addresses in the PCO + std::vector ipv6; // TLV 0x2E, PCO order, canonical + std::vector ipv4; // TLV 0x23, dotted quads + std::vector fqdn; // TLV 0x24 + }; + + // Tolerant: a list whose count does not match its length is read as + // empty, never as garbage. + inline PcscfLists ParsePcscfLists(const Response& r) { + PcscfLists out; + if (const Tlv* f = r.Find(0x22); f && f->value.size() == 1) out.viaPco = f->value[0] != 0; + if (const Tlv* t = r.Find(0x2E); t && !t->value.empty() && t->value.size() == 1 + 16 * static_cast(t->value[0])) + for (std::size_t i = 0; i < t->value[0]; i++) + out.ipv6.push_back(detail::Ip6Text(std::span(t->value).subspan(1 + 16 * i, 16))); + if (const Tlv* t = r.Find(0x23); t && !t->value.empty() && t->value.size() == 1 + 4 * static_cast(t->value[0])) + for (std::size_t i = 0; i < t->value[0]; i++) { + std::uint32_t v = detail::Get32(t->value, 1 + 4 * i); // little-endian u32, a.b.c.d = the high byte first + out.ipv4.push_back(std::format("{}.{}.{}.{}", v >> 24, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF)); + } + if (const Tlv* t = r.Find(0x24); t && !t->value.empty()) { + std::vector names; + std::size_t off = 1; + bool good = true; + for (std::size_t i = 0; i < t->value[0] && good; i++) { + if (off + 2 > t->value.size()) { good = false; break; } + std::size_t n = detail::Get16(t->value, off); + if (off + 2 + n > t->value.size()) { good = false; break; } + names.emplace_back(reinterpret_cast(t->value.data() + off + 2), n); + off += 2 + n; + } + if (good && off == t->value.size()) out.fqdn = std::move(names); + } + return out; + } + + // Get Packet Service Status: TLV 0x01 u8 (2 = connected) — whether the + // bound client sees the port's call. + inline std::optional PacketServiceStatus(const Response& r) { + const Tlv* t = r.Find(0x01); + if (!t || t->value.size() != 1) return std::nullopt; + return t->value[0]; + } + + // Get Profile Settings: TLV 0x1F "PCSCF Address Using PCO" u8 — whether + // the profile has the modem ask the network at all. + inline std::optional ProfilePcscfViaPco(const Response& r) { + const Tlv* t = r.Find(0x1F); + if (!t || t->value.size() != 1) return std::nullopt; + return t->value[0] != 0; + } +} diff --git a/interfaces/Imsd.cppm b/interfaces/Imsd.cppm index a21c778..588b20b 100644 --- a/interfaces/Imsd.cppm +++ b/interfaces/Imsd.cppm @@ -9,6 +9,7 @@ export module Imsd; export import :Util; export import :Aka; export import :Ipsec; +export import :Qmi; export import :Sip; export import :Sdp; export import :Messages; diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 4abd25e..d9e09fa 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -45,6 +45,7 @@ package() { cd "$srcdir/imsd-$pkgver" install -Dm755 imsd "$pkgdir"/usr/bin/imsd install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media + install -Dm755 imsd-pcscf "$pkgdir"/usr/libexec/imsd-pcscf install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh diff --git a/packaging/build-package.sh b/packaging/build-package.sh index 53c6153..f412a29 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -91,6 +91,7 @@ crafter-build -- $XTARGET crafter-build -- --product=media $XTARGET crafter-build -- --product=dialerd $XTARGET crafter-build -- --product=ofonod $XTARGET +crafter-build -- --product=pcscf $XTARGET crafter-build test # --- bundle + package diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 3e695ec..d42df1d 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -12,10 +12,12 @@ BIN=$(ls -t bin/imsd-aarch64-*/imsd 2>/dev/null | head -n1) MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1) DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1) OFONOD=$(ls -t bin/imsd-ofonod-aarch64-*/imsd-ofonod 2>/dev/null | head -n1) +PCSCF=$(ls -t bin/imsd-pcscf-aarch64-*/imsd-pcscf 2>/dev/null | head -n1) [ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; } [ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; } [ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; } [ -n "$OFONOD" ] || { echo "no aarch64 imsd-ofonod build — cross-compile with --product=ofonod" >&2; exit 1; } +[ -n "$PCSCF" ] || { echo "no aarch64 imsd-pcscf build — cross-compile with --product=pcscf" >&2; exit 1; } stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT @@ -24,6 +26,7 @@ cp "$BIN" "$stage/imsd-$VER/imsd" cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" +cp "$PCSCF" "$stage/imsd-$VER/imsd-pcscf" cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ packaging/imsd-ofono-restart-calls.sh \ diff --git a/packaging/make-sysroot.sh b/packaging/make-sysroot.sh index 112b7e2..7cc5e41 100755 --- a/packaging/make-sysroot.sh +++ b/packaging/make-sysroot.sh @@ -4,7 +4,8 @@ # make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd. # # Downloads apk-tools-static + the Alpine signing keys from the CDN and -# populates a minimal rootfs (musl, libc++, glib and their headers) that +# populates a minimal rootfs (musl, libc++, glib and their headers, the Linux +# UAPI headers) that # clang can use via --sysroot. No root, no qemu, no device needed: # # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine @@ -22,8 +23,10 @@ ARCH=aarch64 # none): crafter-build compiles the sysroot's own copy first. --upgrade below # is for a run over an existing sysroot: added alone beside libc++ 22, # llvm-runtimes 23's source did not compile against its headers; upgraded -# with it, it did. -PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc" +# with it, it did. linux-headers is in it for , which +# imsd-pcscf's QRTR socket needs (the sysroot had it only as another +# package's dependency before). +PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc linux-headers" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT diff --git a/project.cpp b/project.cpp index 1bc9398..9689f45 100644 --- a/project.cpp +++ b/project.cpp @@ -77,7 +77,7 @@ static std::unique_ptr BusLibrary(std::span args) { - // Four executables come out of this repo; which one a build produces is + // Five executables come out of this repo; which one a build produces is // chosen by `--product=`: // daemon (default) — imsd, the control-plane daemon (GDBus + engine), // and the unit tests, which link imsd-core. @@ -91,13 +91,18 @@ extern "C" Configuration CrafterBuildProject(std::span a // (org.ofono.*) bridging to imsd, for Phosh and // GNOME Mobile (GDBus translation plus the imsd-bus // polkit gate on its callers, no core). + // pcscf — imsd-pcscf, the P-CSCF reader for the bring-up: one + // QMI WDS client over a raw QRTR socket (pure std + + // POSIX, links the core for Imsd:Qmi, no GLib). bool wantMedia = false; bool wantDialerd = false; bool wantOfonod = false; + bool wantPcscf = false; for (std::string_view a : args) { if (a == "--product=media") wantMedia = true; if (a == "--product=dialerd") wantDialerd = true; if (a == "--product=ofonod") wantOfonod = true; + if (a == "--product=pcscf") wantPcscf = true; } if (wantOfonod) { Configuration ofonod; @@ -161,11 +166,12 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Imsd", "interfaces/Imsd-Util", "interfaces/Imsd-Aka", "interfaces/Imsd-Ipsec", + "interfaces/Imsd-Qmi", "interfaces/Imsd-Sip", "interfaces/Imsd-Sdp", "interfaces/Imsd-Messages", @@ -175,6 +181,23 @@ extern "C" Configuration CrafterBuildProject(std::span a Core->GetInterfacesAndImplementations(ifaces, impls); } + if (wantPcscf) { + Configuration pcscf; + pcscf.path = "./"; + pcscf.name = "imsd-pcscf"; + pcscf.outputName = "imsd-pcscf"; + ApplyStandardArgs(pcscf, args); + pcscf.type = ConfigurationType::Executable; + pcscf.dependencies = { Core.get() }; + { + std::array ifaces = {}; + std::array impls = { "implementations/pcscf" }; + pcscf.GetInterfacesAndImplementations(ifaces, impls); + } + LintRules::AddRules(pcscf); + return pcscf; + } + // imsd — the daemon executable: GLib main loop + GDBus service // (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core. Configuration cfg; @@ -197,6 +220,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Util").Dependencies({ Core.get() }); cfg.AddTest("Aka").Dependencies({ Core.get() }); cfg.AddTest("Ipsec").Dependencies({ Core.get() }); + cfg.AddTest("Qmi").Dependencies({ Core.get() }); cfg.AddTest("Messages").Dependencies({ Core.get() }); cfg.AddTest("Engine").Dependencies({ Core.get() }); cfg.AddTest("Sip").Dependencies({ Core.get() }); diff --git a/tests/Qmi/main.cpp b/tests/Qmi/main.cpp new file mode 100644 index 0000000..19070d6 --- /dev/null +++ b/tests/Qmi/main.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +// Imsd:Qmi unit tests — the request bytes imsd-pcscf sends (pinned to the +// bytes the dev-phone probe sent, which are protocol only), a Get Current +// Settings answer in the exact TLV shape the Fairphone 6's modem returned +// for the ims PDN (163 bytes; the addresses re-encoded to 2001:db8::/32), +// the OutOfCall answer an unbound client gets, and the readers' tolerance. +import std; +import Imsd; + +using namespace imsd::qmi; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + std::vector Hex(std::string_view s) { return *imsd::util::FromHex(s); } + + // The phone's answer to Get Current Settings (mask 0x7FFFF) on the bound + // client, 2026-10-11: profile2 / ims / IPv6 / MTU 1280, TLV 0x22 = 1, + // 0x24 empty, 0x2E = two P-CSCFs (PCO order), 0x2F empty. UE and + // gateway and the two P-CSCFs re-encoded. + const std::vector Settings = Hex("02 04 00 2d 00 9c 00 02 04 00 00 00 00 00 10 08 00 70 72 6f 66 69 6c 65 32 11 01 00 02 14 03 00 69 6d 73 1d 01 00 00 1f 02 00 00 02 22 01 00 01 24 01 00 00 25 11 00 20 01 0d b8 11 11 22 22 33 33 44 44 55 55 db 43 40 26 11 00 20 01 0d b8 11 11 22 22 00 00 00 00 00 00 00 01 40 29 04 00 00 05 00 00 2a 01 00 00 2b 01 00 06 2c 01 00 00 2d 02 00 80 88 2e 21 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05 2f 08 00 00 00 00 00 00 00 00 00"); + // An unbound client's answer: result 1, error 15 (OutOfCall). + const std::vector OutOfCall = Hex("02 04 00 2d 00 07 00 02 04 00 01 00 0f 00"); + // Synthetic (no recording carries them): an IPv4 list of one and an FQDN + // list of one beside a one-entry IPv6 list. + const std::vector Synthetic = Hex("02 07 00 2d 00 3a 00 02 04 00 00 00 00 00 22 01 00 01 23 05 00 01 05 02 00 c0 24 10 00 01 0d 00 70 63 73 63 66 2e 65 78 61 6d 70 6c 65 2e 11 00 01 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05"); + // Synthetic: a 0x2E whose count says two but carries one. + const std::vector BadCount = Hex("02 08 00 2d 00 1b 00 02 04 00 00 00 00 00 2e 11 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23"); +} + +int main() { + // ---- requests, byte for byte what the probe sent + Check(BindMuxDataPort(1, EndpointEmbedded, EndpointInterfaceIpa, 1) == Hex("00 01 00 a2 00 0f 00 10 08 00 04 00 00 00 01 00 00 00 11 01 00 01"), "Bind Mux Data Port bytes"); + Check(SetIpFamily(2, IpFamilyV6) == Hex("00 02 00 4d 00 04 00 01 01 00 06"), "Set IP Family bytes"); + Check(GetPacketServiceStatus(3) == Hex("00 03 00 22 00 00 00"), "Get Packet Service Status bytes"); + Check(GetCurrentSettings(4, RequestedSettingsAll) == Hex("00 04 00 2d 00 07 00 10 04 00 ff ff 07 00"), "Get Current Settings bytes"); + Check(GetProfileSettings(5, 2) == Hex("00 05 00 2b 00 05 00 01 02 00 00 02"), "Get Profile Settings bytes"); + + // ---- the recorded answer + { + auto r = ParseMessage(Settings); + Check(r.has_value(), "settings parse"); + Check(r && r->type == TypeResponse && r->txn == 4 && r->msg == MsgGetCurrentSettings, "settings header"); + Check(r && r->Ok(), "settings result ok"); + Check(r && r->tlvs.size() == 17, "settings: 17 TLVs"); + Check(r && r->Find(0x2E) && r->Find(0x2E)->value.size() == 33, "settings: TLV 0x2E is 33 bytes"); + PcscfLists l = ParsePcscfLists(*r); + Check(l.viaPco, "settings: P-CSCF via PCO requested"); + Check(l.ipv6.size() == 2 && l.ipv6[0] == "2001:db8::23" && l.ipv6[1] == "2001:db8::105", "settings: the IPv6 list in PCO order, canonical"); + Check(l.ipv4.empty() && l.fqdn.empty(), "settings: no IPv4 list, an empty FQDN list"); + } + // ---- the unbound client's answer + { + auto r = ParseMessage(OutOfCall); + Check(r && !r->Ok() && r->error == ErrOutOfCall, "OutOfCall parsed"); + Check(r && ErrorName(r->error) == "OutOfCall", "OutOfCall named"); + Check(ErrorName(ErrNoEffect) == "NoEffect" && ErrorName(999) == "?", "error names"); + Check(r && ParsePcscfLists(*r).ipv6.empty(), "no lists in an error answer"); + } + // ---- the other lists, and tolerance + { + auto r = ParseMessage(Synthetic); + Check(r.has_value(), "synthetic parse"); + PcscfLists l = ParsePcscfLists(*r); + Check(l.ipv4.size() == 1 && l.ipv4[0] == "192.0.2.5", "IPv4 list: little-endian word to dotted quad"); + Check(l.fqdn.size() == 1 && l.fqdn[0] == "pcscf.example", "FQDN list: u16-length strings"); + Check(l.ipv6.size() == 1 && l.ipv6[0] == "2001:db8::105", "IPv6 list of one"); + auto b = ParseMessage(BadCount); + Check(b && ParsePcscfLists(*b).ipv6.empty(), "a 0x2E whose count disagrees with its length reads as empty"); + Check(!ParseMessage(Hex("02 04 00 2d 00")).has_value(), "short buffer -> nullopt"); + Check(!ParseMessage(Hex("02 04 00 2d 00 07 00 02 09 00 01 00 0f 00")).has_value(), "a TLV overrunning the message -> nullopt"); + Check(!ParseMessage(Hex("02 04 00 2d 00 20 00 02 04 00 01 00 0f 00")).has_value(), "a header length past the buffer -> nullopt"); + } + // ---- the two small readers + { + auto st = ParseMessage(Hex("02 03 00 22 00 0b 00 02 04 00 00 00 00 00 01 01 00 02")); + Check(st && PacketServiceStatus(*st) == PacketServiceConnected, "packet service status: connected (the probe's answer)"); + auto pf = ParseMessage(Hex("02 05 00 2b 00 0b 00 02 04 00 00 00 00 00 1f 01 00 01")); + Check(pf && ProfilePcscfViaPco(*pf) == true, "profile flag: PCSCF via PCO set"); + auto none = ParseMessage(Hex("02 05 00 2b 00 07 00 02 04 00 00 00 00 00")); + Check(none && !ProfilePcscfViaPco(*none).has_value() && !PacketServiceStatus(*none).has_value(), "absent TLVs -> nullopt"); + } + + if (Failures == 0) std::println("Qmi: all tests passed"); + return Failures; +}