diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 290c9f5..6302582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -310,7 +310,13 @@ jobs: - name: "Build the first-party companion .deb (Architecture: all)" env: RELEASE_VERSION: ${{ github.event.inputs.tag }} - run: packaging/ci/build-companion.sh + EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }} + run: | + if [ "${RELEASE_VERSION#v}" != "$EXPECTED_VERSION" ]; then + echo "::error::packaging/ci/build-companion.sh RELEASE_VERSION ($RELEASE_VERSION) != release tag version ($EXPECTED_VERSION)" + exit 1 + fi + packaging/ci/build-companion.sh # Chroot-stage packaging contract for the companion: install / upgrade / downgrade / # purge, /etc override precedence, the chroot guard, both /etc-override maintscript @@ -376,19 +382,20 @@ jobs: - name: Install pinned trusted-publishing npm run: npm install -g npm@11.18.0 - # npm side is VERIFIED (not injected): control/package.json version must equal the release - # tag's X.Y.Z, else fail closed before publish. - - name: Verify package version matches the release tag - working-directory: control + # First-party package versions are VERIFIED (not injected): all workspace package manifests + # must equal the release tag's X.Y.Z, else fail closed before publish. + - name: Verify first-party package versions match the release tag env: EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }} run: | - pkg="$(node -p "require('./package.json').version")" - if [ "$pkg" != "$EXPECTED_VERSION" ]; then - echo "::error::control/package.json version ($pkg) != release tag ($EXPECTED_VERSION)" - exit 1 - fi - echo "npm version provenance OK: $pkg" + for package_file in package.json control/package.json cli/package.json; do + pkg="$(node -p "require('./${package_file}').version")" + if [ "$pkg" != "$EXPECTED_VERSION" ]; then + echo "::error::${package_file} version ($pkg) != release tag ($EXPECTED_VERSION)" + exit 1 + fi + done + echo "first-party version provenance OK: $EXPECTED_VERSION" - name: Install workspace (frozen lockfile) run: bun install --frozen-lockfile diff --git a/AGENTS.md b/AGENTS.md index c8b66c0..7c43031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -481,6 +481,47 @@ near it. `SlotUsageSnapshot` gained an additive `cycleDay` so the read side reports the policy in force, not only its consequences. +### COUNTER-RESET-AWARE THROUGHPUT — AN ABSENT RATE IS A REAL ANSWER + +`SlotUsageSnapshot` also gained an additive `rateBytesPerSecond`, measured in +`backend/usage/sampling.ts` (`SlotRate`) and projected by `policy.ts`. The whole design is +one rule: **a rate exists only when this process observed BOTH ends of one sampling +interval over one unchanged counter.** Absent is not zero, and `projectUsageSnapshot` +OMITS the key rather than emitting `0` — an operator cannot tell an idle link from an +unmeasured one if both render as zero. + +- **A BACKWARDS COUNTER PRODUCES NO RATE AND REBASELINES.** `/proc/net/dev` counters + restart at zero when an interface is re-created — a replug, a `wwan0` teardown, a driver + reload. Both obvious repairs report something untrue: clamping the negative delta to 0 + shows an idle link that was carrying traffic, and dividing the raw post-reset value by + the interval shows every byte since the interface came up as if it had all moved inside + that one interval. Reporting nothing is the honest answer, and `applySample` rebases the + baseline in the SAME pass so the next interval measures correctly instead of inheriting + the gap. Both halves are pinned together — the rollback fixture asserts the absent rate + AND the correct attribution on the following sample. +- **Five other cases are equally unmeasured**, each for its own reason: the first sample + and a resume-from-pause are zero-delta rebaselines; a low-confidence slot attributes + nothing at all; a remap or reboot means the two values are two different counters + (`sameBaselineKey` is now exported from `accounting.ts` precisely so the rate and the + reducer cannot drift about what "same counter" means); an interface missing from the + counter table drops the rate sample, which deliberately costs the RETURN pass its rate + too; and a clock that did not advance yields no interval rather than `Infinity`. +- **NO RATE IS PERSISTED, and the negative is pinned by a test.** A throughput is a + measurement over an interval whose two ends one process observed; a restart observed + neither. Persisting the last rate would republish a pre-gap figure as current, and + persisting the baseline's sample TIME would invite the next sample to divide a whole + downtime's bytes by one interval. So the counter BASELINE resumes across a same-boot + reload — it is a cumulative total and still true — while the rate restarts unmeasured. +- **No history verb was added.** The sampler still reports the CURRENT window and the last + interval; there is no series, no retention and no export, and none may be added. + +Idea provenance: `irlserver/modem-metrics` (MIT) — concepts adopted, no source code +copied, recorded in [`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + +Coverage: `control/src/backend/usage/sampling.test.ts` (the rollback fixture plus every +unmeasured case) and `control/src/backend/usage/persistence.test.ts` (the persisted +negative, from both the bytes on disk and the behaviour after a reload). + ## CERTIFICATION EVIDENCE → CATALOG (evidence-gated, human-reviewed) A SKU reaches `control/src/usb-mode/certified-catalog.json` only through a captured @@ -519,10 +560,96 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse nothing: the SIMCom's PID→composition mapping is unproven so its target modes stay UNCERTIFIED and HIDDEN, and the FM350 gains **no** classifier entry for its `0e8d:7127` carrier id — `docs/FM350-DECISION.md` is unchanged. -- The full bench-runbook ladder, RB-1 through RB-17, lives in `docs/BENCH.md`: RB-9 is the +- The full bench-runbook ladder, RB-1 through RB-18, lives in `docs/BENCH.md`: RB-9 is the fleet-inventory capture (one identity bundle per acquired physical unit), RB-10 is the hub VBUS port-cycle verification backing the PowerHook above, RB-11..15/17 are the - per-SKU/flap-resilience captures documented above, RB-16 is the FM350 probe. + per-SKU/flap-resilience captures documented above, RB-16 is the FM350 probe, and RB-18 is + the Sierra identity/composition capture. Its 2026-08-25 run is an honest + `device-not-present` skip: no Sierra VID was attached, `certify` was not invoked, and no + bundle or certification claim exists. + +### Sierra classifier groundwork — exact evidence, never a support claim + +`backend/device-classifier.ts` carries exact application-mode Sierra family rows for +EM74xx, EM75xx, and EM919x-class devices, including Sierra's `1199` VID and the HP/Dell +rebrands represented in ModemManager's pinned FCC mapping. Rows are evidence-tiered: +`modemmanager-1.24.2-fcc` means the VID:PID occurs in the pinned release's available-tier +mapping; `mainline-kernel` means Linux's qmi_wwan/qcserial tables name that family. A row can +label a known family but cannot make a device `mm-managed` — live control-interface/driver +evidence remains the only authority for that class, and an unknown Sierra PID remains +unknown. Every classifier fixture is stamped `synthetic: true`, so it cannot cross the +catalog-promotion gate. + +### Telit / u-blox / NETGEAR groundwork — and the mixed-VID trap + +The same table carries ten exact Telit (`1bc7`) rows, six u-blox (`1546`) LARA-R6/LARA-L6 +rows, and ONE NETGEAR (`0846`) row. `CELLULAR_MODEL_EVIDENCE_SOURCES` now pins each tier's +provenance so a reviewer can re-derive any row; a test asserts every tier a row uses has a +pin. **No provider was added for any of these vendors** — this is classifier and doc +groundwork only. + +- **A third evidence tier exists: `usb-ids-registry`,** the weakest of the three. It is used + ONLY where no kernel modem driver claims the id at all — which is itself the evidence + that the device is a router appliance rather than a controllable module. The NETGEAR + LB1120 (`0846:68e1`) is the sole row at that tier. +- **`CellularModelEvidence.familyKind` is an OPTIONAL, positive `router-webui` claim, and + its ABSENCE IS NOT A CLAIM.** Silence does not mean "modem module"; it means nothing here + asserts otherwise — the same tri-state discipline `fcc/coverage.ts` uses for `unknown`. A + test pins that exactly one row carries it. It still decides no device class: NETGEAR + classifies `router-mode` because its interfaces say so, and a test proves the class is + unchanged when the same composition is presented under an unlisted PID. +- **`0846` is deliberately ABSENT from `CELLULAR_USB_VENDOR_IDS`, and that absence is + load-bearing.** The USB ID Repository's `0846` block is dominated by NETGEAR Wi-Fi and + Ethernet adapters — `68e1` is the only cellular entry — so a vendor-keyed rule would + report a Wi-Fi dongle as a cellular uplink. `1546` has the same shape (u-blox GNSS + receivers share it with cellular modules) but predates this work and stays; treat a + vendor-only match on it as WEAK evidence. `1bc7` IS added, because that range is Telit + cellular modules end to end. Consequence worth knowing: the LB1120 tether currently + reads `wired-ethernet` from `classifyUsbNetDevice`, which is honest — a known gap, not a + guess. + +### `docs/VENDOR-QUIRKS.md` — sourced, and capped at `implemented` + +[`docs/VENDOR-QUIRKS.md`](docs/VENDOR-QUIRKS.md) records the per-vendor behaviours that make +one module behave differently from another on Linux, with a citation for every claim +(pinned to MM `1.24.2`, a named Linux commit, `usb.ids` `2026.06.26`, OpenWrt's `qmi.sh`, +`usb-modeswitch-data`, and the BELABOX tutorial wiki). Two properties are the whole point: + +- **No row sits above `implemented` on the five-state ladder, and none may.** `capable` + needs a live probe and `certified` needs a hardware drill; a document can produce + neither. Rows for surfaces this repo ships no code for read `unavailable`, which is + BELOW `implemented`, not above it. +- **Nothing in it is on a write path, and nothing in it may be put on one.** A quirk is a + description of somebody else's firmware. It may inform a classifier LABEL, a diagnostic + READ, or a doc — never an AT command, a QMI/MBIM write, a composition switch, or a band + lock. An unsourced operator report is recorded AS unsourced and claims nothing. + +### `docs/COMPAT-MATRIX.md` — the one tracked support matrix + +[`docs/COMPAT-MATRIX.md`](docs/COMPAT-MATRIX.md) is the single vendor × firmware × +composition × operation matrix: 22 hardware rows (the six long-standing vendor families plus +the Sierra, Telit, u-blox and NETGEAR groundwork rows) against 18 operations spanning first +enumeration through a sustained bonded uplink. + +- **Every claim cell is a member of the five-state ladder in `capability/support-claim.ts`, + and there is no second status vocabulary.** No "partial", no "works", no tick-and-cross. + 198 cells, all of them `implemented` or `unavailable`; `enabled` / `capable` / `certified` + appear in the matrix nowhere, for exactly the reason `VENDOR-QUIRKS.md` is capped the same + way. It follows that **no combination in the repository is `certified`, so none may be + described as supported.** +- **It states which claims are hardware-free and which are hardware-required**, and links + each hardware-required operation to its RB runbook. `BENCH.md` remains the sole owner of + per-runbook status; the matrix links and restates none of it. A cell is raised by a bench + capture plus a reviewed commit, never by an edit to the matrix. +- **The NETGEAR LB1120 gap is recorded rather than smoothed over**: the row labels the + family `router-webui`, but with no positive cellular evidence the tether classifies + `wired-ethernet`, so no operation in this stack reaches it. Its column is mostly + `unavailable` as a consequence, and the phone-tether row reads the same way for the same + reason. +- It also records one discrepancy in `VENDOR-QUIRKS.md`: the Sierra row's "no `AT!` form + exists anywhere in this repository" is true of the `providers/ufi-himi` gate it cites, but + `usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP` forms, which is + what makes the composition-switch operation `implemented` for Sierra. ## USB-COMPOSITION SWITCH — RUNTIME OFFER, TIERED PROOF @@ -963,6 +1090,13 @@ that record on every boot. The unlocking is ModemManager's dispatcher's job, sta to finish. Full model, matrix and certification status: [`docs/FCC-UNLOCK-COVERAGE.md`](docs/FCC-UNLOCK-COVERAGE.md). +The coverage mirror names its exact provenance in `MM_FCC_UNLOCK_SOURCE`: ModemManager +1.24.2 commit `f2b9ab1ad78d322f32134a444b5b54c6e8160e19`, +`data/dispatcher-fcc-unlock/meson.build`, installed into the inert +`fcc-unlock.available.d` tier. Its four Sierra entries are `03f0:4e1d`, `1199:9079`, +`413c:81a3`, and `413c:81a8`; another well-formed Sierra PID is positively `absent`, not +guessed covered. This classifier-side mirror does not create or own any packaging link. + - **`:` is the ONLY correct key.** `mm-dispatcher-fcc-unlock.c` builds exactly `g_strdup_printf("%04x:%04x", vid, pid)` and opens no other name, so a vendor-only file is never a dispatcher target — it exists only as what the @@ -1163,8 +1297,112 @@ The observation layer's SIM and signal halves are finalized on top of todo 18. (when WE last read). The router APIs have no such flag and answer `unsupported` — a capability claim, the `bars` / `maxBars` precedent. +### `Modem.Signal`'s per-RAT dicts — the detail the router dongles already carried + +`NormalizedSignal.rsrp` / `rsrq` / `snr` / `sinr` are now claimed for MM-managed modems +too, from the `Modem.Signal` interface's own `a{sv}` properties. Five facts about that +path are load-bearing, and each is pinned by a test: + +- **A dict member arrives WRAPPED, and used to be dropped.** An `a{sv}` decodes to + `[key, variant][]`, so `snapshot.ts`'s `rawValue` kept the key and discarded the reading + — `Signal.Lte` retained as `[['rsrp'], ['rsrq'], …]`. It now unwraps the variant, which + is why the extended metrics are reachable at all. `signal-richness.test.ts` fails three + ways with that unwrap removed, so the fix is not a silent one. +- **The dict is read by MEMBER, never flattened at retention.** `raw.ts`'s + `rawDictMember` / `rawDictNumber` / `hasRawDictMember` read one member by name, so + `error-rate`, `ecio`, `io` and `rscp` — every key the normalized model has no slot for — + stay verbatim in the diagnostics block instead of being lost to make four metrics fit. +- **The RAT ladder is NEWEST FIRST, and provenance names which rung answered.** On an NSA + attach `Nr5g` and `Lte` are both populated with genuinely different measurements (the NR + leg and the LTE anchor), so one has to be the reported reading. `rsrp`/`rsrq`/`snr` take + `Nr5g` then `Lte`; `dbm` takes `Lte → Umts → Gsm → Evdo → Cdma`. Nothing is merged and + nothing is averaged — `MetricProvenance.rawFields` carries `Signal.Nr5g.rsrp` rather than + a bare `Signal.rsrp`, and the unchosen dict is still in `raw`. +- **SINR comes from `Evdo` and from NOWHERE ELSE.** Checked against MM 1.24.2's own + introspection rather than recalled: `sinr` is a member of the `Evdo` dict only — `Lte` + and `Nr5g` publish `snr`, which is a different quantity and must never populate it (the + same rule `backend/cell-info.ts` already enforces in the other direction). So an LTE/NR + modem reporting no SINR answers **`not-reported`, not `unsupported`**: the source CAN + express it, this modem did not. The former blanket `unsupported` was a false capability + claim and is gone. The NR SINR a device may publish through `Modem.GetCellInfo` is a + different call on a different interface and is deliberately NOT folded in here. +- **A dict-sourced metric consumes the whole property.** `consumed` must name a real raw + key or `createObservationDiagnostics` drops it, so the entry is `Signal.Nr5g` while + `rawFields` stays member-precise. An exported-but-silent `Modem.Signal` therefore yields + five READ-class `not-reported` metrics carrying no `value` field at all, and a modem with + no `Modem.Signal` interface yields `not-observed` — never a zero, in either case. + +**The `Signal.Setup` rate is injected at three levels and defaults at all three.** +`SignalSetupManagerOptions.intervalSeconds` → `MmDbusBackendOptions.signalIntervalSeconds` +→ `ModemManagerProviderOptions.signalIntervalSeconds`, each resolving to +`DEFAULT_SIGNAL_INTERVAL_SECONDS` (5) when absent. The provider seam is the one this work +added: the backend had accepted the option since it was written, and the provider — which +is what an embedder actually constructs — had no way to pass it. `Setup` takes a `u`, so a +fractional or non-positive rate is REFUSED at construction rather than marshalled; a modem +silently polling at the wrong cadence is a defect nothing downstream can see. The +once-per-(epoch, modem) issue semantics are the conformance-scale pin and are untouched. + Coverage: `control/src/observations/sim-evidence.test.ts`, whose control case is a modem -with the identical blank fields and NO failure reason, asserting `unknown`. +with the identical blank fields and NO failure reason, asserting `unknown`; +`control/src/observations/normalization.test.ts` for the per-metric claims and the +absent-dict unknowns; `control/src/providers/modem-manager/signal-richness.test.ts` for +the same claims over the real provider wire; `control/src/backend/signal-setup-rate.test.ts` +for the injected rate and the once-per-(epoch, modem) issue count. + +### REGISTRATION AND CELL CONTEXT — WHO WE ARE ATTACHED TO, AND TO WHICH CELL + +`NormalizedRadio` gained `operatorName` / `operatorCode`, and `NormalizedModemObservation` +gained an additive `cell` block (`NormalizedCell`: `cellId` + `tac`). Every slot is a +`NormalizedMetric`, so the four-state model and the capability-vs-read reason split apply +unchanged. Five facts are load-bearing: + +- **The operator comes from `Modem3gpp`, NEVER from `Sim`.** `Modem3gpp.OperatorName` is + the operator the modem is REGISTERED with; `Sim.OperatorName` is the HOME operator + written into the SIM. They agree on a home network and disagree for the entire time a + device is roaming — which is exactly when an operator reads the field. `MM_FIXTURE` + carries two DIFFERENT strings on purpose so reading the wrong interface fails a test + rather than looking right. +- **`operatorCode` is TEXT and is never derived from parts.** The MNC is two OR three + digits and the width is significant (`31001` ≠ `310001`). MM emits the code as one + fixed-width string and splits it back apart at exactly three characters. The ZTE + payload has `rmcc` and `rmnc` as separate UNPADDED fields, so joining them would name a + different network whenever the leading zero matters — every router source therefore + answers `not-reported` for the code while ZTE does claim the NAME. +- **TAC/CID come from the EXISTING `3gpp-lac-ci` source, and the GNSS fence is untouched.** + `decode3gppLacCi` (`domain/mm-enums.ts`) parses MM's own five-token string — verified + against 1.24.2's `libmm-glib/mm-location-3gpp.c`, whose serializer is + `g_strdup_printf ("%.3s,%s,%lX,%lX,%lX", …)`: MCC, MNC, then LAC/CI/TAC in uppercase + HEX. Values stay as that text; parsing the hex to decimal would render an identifier + matching nothing `mmcli` shows. A token count other than five decodes to NOTHING rather + than a partial record, and both `cellId` and `tac` fail together. `signal_location` + stays `false`, `3gpp-lac-ci` stays OUTSIDE `GNSS_SOURCES`, coarse cell context stays + outside the GNSS redaction class, and nothing on this path enables a location source. +- **Nothing supplies `location` today, and that is the fence rather than an oversight.** + MM masks the `Location` PROPERTY unless `Location.Setup` was called with + `signal_location = true` — permanently forbidden here — so the value must come from an + explicit `GetLocation()` call, which the provider's snapshot path does not make. An MM + observation therefore reads `not-observed` for `cell`: nobody looked. Cell identity that + IS wired runs through `Modem.GetCellInfo` → `backend/cell-info.ts`, a different method + needing no location source. `CellReading` gained `tac` there, and its cell identifier now + reads MM's REAL key `ci` first (`PROPERTY_CI` in `mm-cell-info-{lte,nr5g}.c`) with the + older `cell-id` spelling kept behind it as a fallback. +- **NO EARFCN IS CLAIMED ANYWHERE, and none may be added from these sources.** MM + publishes no generic ARFCN on `Modem`, `Modem3gpp` or `Location`; the only occurrences + are inside PER-CELL `GetCellInfo` dicts under two DIFFERENT keys for two different + quantities — `earfcn` (LTE) and `nrarfcn` (5GNR). A single normalized slot would have to + merge them or silently pick a RAT, so `NormalizedCell` and `CellReading` both make no + ARFCN claim and the raw keys stay available to a caller that needs them. + +Router sources claim only what a MIGRATED parser already decoded: ZTE claims the operator +name and `cell_id`, UFI claims `cell_id`, and HiLink claims neither — its `` tag +is retained verbatim in the diagnostics block's `unmapped` set rather than lifted into a +claim, the same rule that keeps `SimStatus` out of `sim.presence`. `tac` reads +`not-reported` for all three, never `unsupported`: no migrated parser decodes one, which +is a fact about this package, not a claim about the vendor's firmware. + +Coverage: `control/src/observations/registration-context.test.ts` (the decoder's token +rules, the Modem3gpp-vs-Sim distinction, the four unknown reasons, and the per-source +claims) plus the `ci` / `tac` / no-ARFCN cases in `control/src/backend/cell-info.test.ts`. ## GPS / LOCATION — A LIVE FIX, AND DELIBERATELY NO HISTORY @@ -1362,6 +1600,14 @@ rationale, source cites, and the open gates are recorded in `docs/FM350-DECISION ## WORKSPACE / TOOLCHAIN +**The language is a decided question, not a default.** A Rust migration (a `zbus` daemon +fronted by a thin TS client, the `srtla-send-rs` shape) was assessed and **rejected by the +project owner on 2026-08-24**. TypeScript on Bun stays, and the decision is final until one +of the named revisit triggers fires. That record also carries the idea-attribution for +`irlserver/modem-metrics`: MIT-licensed, concepts adopted, **no source code copied**. Read it +before proposing a rewrite or extending the telemetry surface: +[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + - **Bun 1.4.0** (`.bun-version`, `packageManager` in `package.json`). `control/` + `cli/` are Bun workspace members. - **Strict TypeScript 7.0.2** incl. `exactOptionalPropertyTypes` — the repo-root diff --git a/README.md b/README.md index 8d3b39b..62b4269 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,32 @@ helpers for portable modem identity, display naming, ModemManager enums, USB-net classification, capability selection, and shadow-result comparison; these helpers perform no discovery or transport and leave CeraUI integration to a separate cutover. +Sierra groundwork uses exact, evidence-tiered USB model rows for EM74xx, EM75xx, and +EM919x-class application PIDs across Sierra, HP, and Dell branding. These rows provide a +family label only; interface/driver evidence still decides whether a device is MM-managed, +and unknown Sierra PIDs remain unknown. The FCC classifier table separately mirrors the +complete ModemManager 1.24.2 available-tier mapping and does not install or activate links. +RB-18 in [`docs/BENCH.md`](docs/BENCH.md) records the real identity/composition capture gate; +the 2026-08-25 attempt is a named `device-not-present` skip with no fabricated bundle. + +The same table now also carries exact Telit (`1bc7`) and u-blox (`1546`) module rows plus +one NETGEAR (`0846`) row for the LB1120, which is labelled a `router-webui` family — a +positive claim whose absence elsewhere means nothing, and which still decides no device +class. NETGEAR's vendor id is deliberately NOT treated as cellular evidence: its USB ID +Repository block is mostly Wi-Fi and Ethernet adapters, so a vendor-keyed rule there would +report a Wi-Fi dongle as an uplink. No Telit, u-blox, or NETGEAR provider exists. +[`docs/VENDOR-QUIRKS.md`](docs/VENDOR-QUIRKS.md) is the sourced per-vendor edge-case +reading list behind those rows — every claim carries a pinned citation, no claim sits above +`implemented` on the five-state support ladder, and nothing in it is on a write path. + +[`docs/COMPAT-MATRIX.md`](docs/COMPAT-MATRIX.md) is the one tracked support matrix built on +those rows: 22 hardware rows against 18 operations, from first enumeration through a +sustained bonded uplink, with a hardware-free versus hardware-required split that says which +claims a green CI run establishes and which ones only a bench device can. Every cell is a +member of the same five-state ladder and there is no second status vocabulary, so no +combination is `certified` and none may be described as supported. Hardware evidence lives +in [`docs/BENCH.md`](docs/BENCH.md); the matrix links to it and restates none of it. + The ModemManager operation surface also exposes runtime USB-composition capability. Known vendors are queried with exact reviewed READ/TEST forms, targets come from the device's own enumeration only when it includes a return path, and writes retain the shared admission, @@ -38,12 +64,12 @@ data before `ERROR`. Band certification remains catalog-gated and unchanged. ## Versioning at a glance -ONE unified **SemVer** tag `vX.Y.Z` releases **both** artifacts together: `v1.1.0` publishes -`@ceralive/modem-control@1.1.0` to npm **and** the `.deb` artifact set in the same release. -This repo deliberately does **not** use the CeraLive CalVer scheme. New upstream-source -rebuilds use per-source `-~ceralive.N` counters; unchanged sources retain their -previous version and exact bytes. Legacy published releases keep their tag-shaped suffixes. -Full contract: +ONE unified **SemVer** tag `vX.Y.Z` requires the root, control, and CLI `package.json` +versions to all be `X.Y.Z`; it publishes `@ceralive/modem-control@X.Y.Z` to npm **and** the +`.deb` artifact set in the same release. This repo deliberately does **not** use the CeraLive +CalVer scheme. New upstream-source rebuilds use per-source `-~ceralive.N` +counters; unchanged sources retain their previous version and exact bytes. Legacy published +releases keep their tag-shaped suffixes. Full contract: [`docs/VERSIONING.md`](docs/VERSIONING.md). ## Layout @@ -55,6 +81,7 @@ modem-stack/ ├── packaging/ ModemManager-stack .deb rebuilds + provenance/verification CI ├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, COMPOSITION-EVIDENCE.md, │ VERSIONING.md, FM350-DECISION.md, ESIM-DECISION.md +│ └── adr/ ADR-FM350-RNDIS-BEARER.md, ADR-STAY-TYPESCRIPT.md ├── AGENTS.md AI routing + repo contract (self-contained; see Rule D) └── POLICY.md no-fork gate + upstream-contribution-first policy ``` @@ -64,6 +91,11 @@ Biome via `@ceralive/biome-config`). `packaging/` is built in a bookworm contain The two AST-backed source-shape guard tests use the test-only TypeScript 6 compiler-API compatibility package; workspace typechecking and package emit remain TypeScript 7. +That language choice is recorded, not incidental: a Rust migration was assessed and rejected +by the project owner on 2026-08-24, and the same record carries the MIT-licensed +`irlserver/modem-metrics` idea attribution (concepts adopted, no source code copied). See +[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + ## Develop ```sh @@ -142,6 +174,29 @@ ModemManager's own `StateFailedReason: sim-missing`, never from a blank `Sim` ob `Modem.CurrentModes` and `Modem.SignalQuality` are retained as their D-Bus structs, so the preferred mode and the measurement-recency flag survive normalization. +## Registration context + honest counter rates + +An observation now also reports **who the modem is registered with and to which cell**. +`operatorName` / `operatorCode` come from `Modem3gpp` — the registered operator — and never +from `Sim.OperatorName`, which is the SIM's *home* operator and differs throughout roaming; +the code stays text because a two- versus three-digit MNC is a different network. An +additive `cell` block reports `cellId` and `tac`, decoded together out of the existing +`3gpp-lac-ci` source's single five-token value, hex preserved as written. That source is +**coarse cell context, not a GNSS fix**: it stays outside `GNSS_SOURCES`, `signal_location` +stays false, and nothing on the path enables a location source. No EARFCN is claimed +anywhere — ModemManager publishes none generically, only a per-cell `earfcn` (LTE) and +`nrarfcn` (5GNR), two keys for two quantities. `CellReading` gained `tac` and now reads +ModemManager's real `ci` key ahead of the older `cell-id` spelling. + +The data-usage sampler reports throughput as `rateBytesPerSecond`, and **omits it rather +than reporting 0** whenever there was no interval to measure. A counter that goes BACKWARDS +— an interface re-created by a replug or a driver reload — yields no rate at all instead of +a clamped zero or a whole-total spike, and the baseline is rebased in the same pass so the +next interval is measured correctly. Rates are never persisted: a same-boot reload resumes +the cumulative baseline but restarts the rate unmeasured. Idea provenance for the +counter-reset rule: `irlserver/modem-metrics` (MIT), concepts adopted, no code copied — see +[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + ## Provider-matching conformance matrix (Todo 27) `control/src/providers/conformance-matrix.test.ts` registers all four providers at once and diff --git a/bun.lock b/bun.lock index 073336d..3b2a7d4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -14,7 +14,7 @@ }, "cli": { "name": "modem-control-cli", - "version": "1.0.0", + "version": "1.3.0", "bin": { "modem-control": "./src/index.ts", }, @@ -25,7 +25,7 @@ }, "control": { "name": "@ceralive/modem-control", - "version": "1.1.0", + "version": "1.3.0", "dependencies": { "@httptoolkit/dbus-native": "0.1.5", "zod": "4.4.3", @@ -61,7 +61,7 @@ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], "@typescript/old": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/cli/package.json b/cli/package.json index 22369d4..5df92b4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "modem-control-cli", - "version": "1.0.0", + "version": "1.3.0", "private": true, "type": "module", "description": "modem-control bench CLI — probe/watch/apply/set-usb-mode/usage/certify/hil-cycle against real modems (the bench iteration surface).", diff --git a/cli/src/bench-at-sender.ts b/cli/src/bench-at-sender.ts new file mode 100644 index 0000000..e952c44 --- /dev/null +++ b/cli/src/bench-at-sender.ts @@ -0,0 +1,10 @@ +import type { AtCommandSender, AtResponse } from '@ceralive/modem-control'; +import { CertifyError } from './certify/errors'; + +export const benchAtSender: AtCommandSender = { + send(command: string): Promise { + return Promise.reject( + new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`), + ); + }, +}; diff --git a/cli/src/commands/certify.ts b/cli/src/commands/certify.ts index 8db5979..d946e4d 100644 --- a/cli/src/commands/certify.ts +++ b/cli/src/commands/certify.ts @@ -18,11 +18,11 @@ import { readRevision, type UsbDeviceSnapshot, } from '@ceralive/modem-control'; +import { benchAtSender } from '../bench-at-sender'; import { buildCertificationBundle } from '../certify/bundle'; import type { SignalRecord } from '../certify/bundle-schema'; import { captureBase } from '../certify/capture'; import { type CommandResult, SpawnCommandRunner } from '../certify/command-runner'; -import { CertifyError } from '../certify/errors'; import { createTransportSignalWindow, DEFAULT_SIGNAL_WINDOW, @@ -59,15 +59,6 @@ export interface CertifyDeps { writeBundle(path: string, content: string): Promise; } -/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */ -const benchAtSender: AtCommandSender = { - send(command: string) { - return Promise.reject( - new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`), - ); - }, -}; - /** Build the production capture seams from a live stack context. */ export function certifyDepsFromContext(ctx: StackContext, args: CertifyArgs): CertifyDeps { const bound: SignalWindowBound | undefined = diff --git a/cli/src/commands/watch.test.ts b/cli/src/commands/watch.test.ts new file mode 100644 index 0000000..9f9a0cb --- /dev/null +++ b/cli/src/commands/watch.test.ts @@ -0,0 +1,81 @@ +import { afterEach, expect, test } from 'bun:test'; +import type { StackContext } from '../context'; +import { capturingIo } from '../io'; +import { runWatch } from './watch'; + +type Observer = (list: never) => void; + +const context = ( + start: () => Promise, + observer: (callback: Observer) => () => void, +): StackContext => ({ backend: { start, observe: observer } }) as unknown as StackContext; + +const baseline = (): number => process.listenerCount('SIGINT'); + +afterEach(() => { + expect(process.listenerCount('SIGINT')).toBe(0); +}); + +test('start rejection cleans every watch handle', async () => { + const before = baseline(); + const failure = new Error('start failed'); + const promise = runWatch( + context( + async () => { + throw failure; + }, + () => () => undefined, + ), + capturingIo(), + { durationMs: 60_000 }, + ); + + await expect(promise).rejects.toBe(failure); + expect(process.listenerCount('SIGINT')).toBe(before); +}); + +test('observer callback failure cleans every watch handle', async () => { + const before = baseline(); + const failure = new Error('observer failed'); + let callback: Observer | undefined; + const promise = runWatch( + context( + async () => { + callback?.(undefined as never); + }, + (_next) => { + callback = () => { + throw failure; + }; + return () => undefined; + }, + ), + capturingIo(), + { durationMs: 60_000 }, + ); + + await expect(promise).rejects.toThrow(failure.message); + expect(process.listenerCount('SIGINT')).toBe(before); +}); + +test('abort during pending start still removes SIGINT and timer handles', async () => { + const before = baseline(); + const controller = new AbortController(); + let releaseStart: (() => void) | undefined; + const promise = runWatch( + context( + () => + new Promise((resolve) => { + releaseStart = resolve; + }), + () => () => undefined, + ), + capturingIo(), + { signal: controller.signal, durationMs: 60_000 }, + ); + + controller.abort(); + releaseStart?.(); + expect(await promise).toBe(0); + expect(process.listenerCount('SIGINT')).toBe(before); +}); diff --git a/cli/src/commands/watch.ts b/cli/src/commands/watch.ts index 3bc34a4..ec4438f 100644 --- a/cli/src/commands/watch.ts +++ b/cli/src/commands/watch.ts @@ -82,12 +82,22 @@ export async function runWatch( ? setTimeout(() => resolveDone(), options.durationMs) : undefined; const onSigint = (): void => resolveDone(); + const onAbort = (): void => resolveDone(); + let cleaned = false; + const cleanup = (): void => { + if (cleaned) return; + cleaned = true; + if (timer !== undefined) clearTimeout(timer); + process.off('SIGINT', onSigint); + options.signal?.removeEventListener('abort', onAbort); + unsubscribe(); + }; process.on('SIGINT', onSigint); if (options.signal !== undefined) { if (options.signal.aborted) { resolveDone(); } else { - options.signal.addEventListener('abort', () => resolveDone(), { once: true }); + options.signal.addEventListener('abort', onAbort, { once: true }); } } if ( @@ -98,14 +108,12 @@ export async function runWatch( io.err('watch: streaming changes — press Ctrl-C to stop'); } - await ctx.backend.start(); - await done; - - if (timer !== undefined) { - clearTimeout(timer); + try { + await ctx.backend.start(); + await done; + } finally { + cleanup(); } - process.off('SIGINT', onSigint); - unsubscribe(); io.out(`WATCH DONE: events=${events}, unavailable=${unavailable}, removed=${removed}`); return 0; } diff --git a/cli/src/io.test.ts b/cli/src/io.test.ts new file mode 100644 index 0000000..7c64472 --- /dev/null +++ b/cli/src/io.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test'; +import { PassThrough } from 'node:stream'; +import { stdioIo } from './io'; + +test('EOF before newline removes the stdin data listener', async () => { + const original = process.stdin; + const stdin = new PassThrough(); + Object.defineProperty(process, 'stdin', { configurable: true, value: stdin }); + try { + const reading = stdioIo().promptSecret(''); + stdin.end('partial'); + expect(await reading).toBe('partial'); + expect(stdin.listenerCount('data')).toBe(0); + } finally { + Object.defineProperty(process, 'stdin', { configurable: true, value: original }); + } +}); diff --git a/cli/src/io.ts b/cli/src/io.ts index 2ab78b1..519900d 100644 --- a/cli/src/io.ts +++ b/cli/src/io.ts @@ -79,7 +79,10 @@ function readLine(stdin: NodeJS.ReadStream): Promise { } }; stdin.on('data', onData); - stdin.on('end', () => resolve(buffer.replace(/\r$/, ''))); + stdin.on('end', () => { + stdin.off('data', onData); + resolve(buffer.replace(/\r$/, '')); + }); }); } diff --git a/cli/src/wiring.ts b/cli/src/wiring.ts index d715a94..2fc1241 100644 --- a/cli/src/wiring.ts +++ b/cli/src/wiring.ts @@ -6,8 +6,6 @@ // error rather than pretending — full wiring lands with the Phase-B composition root. import { - type AtCommandSender, - type AtResponse, createUsageFileStore, createUsageSampler, fetchManagedObjects, @@ -23,6 +21,7 @@ import { type UsageSampler, UsbModeTransition, } from '@ceralive/modem-control'; +import { benchAtSender } from './bench-at-sender'; import type { RequestResolver, UsbModeArgs } from './commands/set-usb-mode'; import type { StackContext } from './context'; import { selectModem } from './select'; @@ -32,15 +31,6 @@ import { matchUsbDevice } from './usb-device-match'; const USAGE_STORE_PATH = process.env.MODEM_CONTROL_USAGE_STORE ?? '/var/lib/modem-control/usage.json'; -/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */ -const benchAtSender: AtCommandSender = { - send(command: string): Promise { - return Promise.reject( - new Error(`no AT serial transport on the bench (hardware-gated): '${command}'`), - ); - }, -}; - /** Build the real USB-mode transaction over the live NM + MM ports. */ export function buildUsbModeTransition(ctx: StackContext): UsbModeTransition { return new UsbModeTransition({ diff --git a/control/README.md b/control/README.md index 6092a32..d1ecab3 100644 --- a/control/README.md +++ b/control/README.md @@ -199,6 +199,85 @@ are, so the preferred mode and the measurement-recency flag survive normalizatio `NormalizedSignal.qualityRecent` claims that flag, and the router sources answer `unsupported` for it. +### Extended signal — the `Modem.Signal` per-RAT dicts + +`rsrp`, `rsrq`, `snr` and `sinr` are claimed for MM-managed modems from the +`Modem.Signal` interface's own `a{sv}` properties, so a ModemManager device reports the +same detail a Huawei or ZTE dongle always did. `rsrp` / `rsrq` / `snr` read `Nr5g` first +and `Lte` second — on a 5G NSA attach both are populated with different measurements, so +`MetricProvenance.rawFields` names the dict that answered (`Signal.Nr5g.rsrp`) rather than +merging the two. `dbm` reads `rssi` across `Lte → Umts → Gsm → Evdo → Cdma`. + +`sinr` comes from the `Evdo` dict and from no other, because that is the only dict +ModemManager defines it on; `Lte` and `Nr5g` publish `snr`, a different quantity that +never populates it. An LTE/5G modem reporting no SINR therefore answers `not-reported` — +a claim about this read — and not `unsupported`, which would be a false claim about the +source. An exported-but-silent `Modem.Signal` yields `not-reported` for every extended +metric and a modem without the interface yields `not-observed`; neither ever yields a +zero. Every member the normalized model has no slot for (`error-rate`, `ecio`, `io`, +`rscp`) stays verbatim in the diagnostics block. + +The `Signal.Setup` reporting rate is injectable — `signalIntervalSeconds` on +`createModemManagerProvider`, on `createMmDbusBackend`, or `intervalSeconds` on +`SignalSetupManager` — and defaults to `DEFAULT_SIGNAL_INTERVAL_SECONDS` (5 seconds) at +each. `Setup` takes an unsigned integer, so a fractional or non-positive rate is refused +at construction rather than marshalled. + +### Registration and cell context + +`NormalizedRadio` carries `operatorName` and `operatorCode`, and every observation carries +an additive `cell` block (`cellId` + `tac`). Both operator fields come from +`Modem3gpp` — the operator the modem is **registered with** — and never from +`Sim.OperatorName`, which is the *home* operator written into the SIM and differs for the +whole time a device is roaming. `operatorCode` stays text: the MNC is two or three digits +and the width is significant, so `31001` and `310001` are different networks. + +`cellId` and `tac` come from the existing `3gpp-lac-ci` location source, whose value is one +five-token string (`MCC,MNC,LAC,CI,TAC`, the last three in uppercase hex). Both fields are +read out of that single value, so they always describe the same reported cell; a value in +any other shape reads `malformed` for both rather than being partially decoded, and the hex +is kept as text so an identifier matches what `mmcli` shows. This is **coarse cell context, +not a GNSS fix** — it carries no coordinate, `3gpp-lac-ci` is not a member of +`GNSS_SOURCES`, and nothing on this path enables a location source or sets +`Location.Setup`'s `signal_location`. + +Because ModemManager masks the `Location` property unless `signal_location` is true — which +this package never sets — nothing populates the optional `location` input today, and an MM +observation honestly reads `not-observed` for `cell`. Cell identity that is wired comes from +`Modem.GetCellInfo`, where `CellReading` now also reports `tac` and reads ModemManager's real +`ci` key (with the older `cell-id` spelling kept as a fallback). + +**No EARFCN is claimed.** ModemManager exposes no generic ARFCN; the only occurrences are +inside per-cell `GetCellInfo` dicts under two different keys for two different quantities — +`earfcn` for LTE and `nrarfcn` for 5GNR. One normalized field would have to merge them or +pick a RAT, so neither `NormalizedCell` nor `CellReading` claims one and the raw keys stay +available. + +Router sources claim only what their migrated parsers already decode: ZTE reports the +operator name and its cell id, UFI reports its cell id, HiLink reports neither. No router +source derives an operator code from separate unpadded MCC/MNC fields, and `tac` reads +`not-reported` rather than `unsupported` — nothing here decodes one, which says nothing +about what the firmware could report. + +### Data-usage throughput — absent is not zero + +`SlotUsageSnapshot.rateBytesPerSecond` reports throughput over the last **measured** +sampling interval, and the key is **omitted** rather than set to `0` whenever there was no +interval to measure. That happens on a first sample, a rebaseline, a paused (ambiguous +identity) slot, a remap or reboot, an interface missing from `/proc/net/dev`, a clock that +did not advance — and, most importantly, on a **counter reset**. + +Interface counters restart at zero when the interface is re-created. Clamping the resulting +negative delta to zero would show an idle link that was in fact carrying traffic, and using +the raw post-reset value would show every byte since the interface came up as if it moved +inside one interval. So a backwards counter reports **no rate at all**, and the baseline is +rebased in the same pass so the *next* interval is measured correctly rather than inheriting +the gap. + +Rates are never persisted. A same-boot reload resumes the counter baseline — a cumulative +total is still true after a restart — but the rate restarts unmeasured, because this process +did not observe the start of that interval. + ### Mutation safety ports The root export includes `MutationAdmissionPort`, `ResourceOwnershipPort`, diff --git a/control/package.json b/control/package.json index 939024a..2505c7f 100644 --- a/control/package.json +++ b/control/package.json @@ -1,6 +1,6 @@ { "name": "@ceralive/modem-control", - "version": "1.2.1", + "version": "1.3.0", "type": "module", "description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.", "license": "AGPL-3.0", diff --git a/control/src/backend/cell-info.test.ts b/control/src/backend/cell-info.test.ts index 3fbb176..d09fbe9 100644 --- a/control/src/backend/cell-info.test.ts +++ b/control/src/backend/cell-info.test.ts @@ -83,6 +83,33 @@ describe('normalizeCellReading — pinned key mappings', () => { expect(read({ 'cell-type': 'lte-serving' }).serving).toBe(true); expect(read({ 'cell-type': 'lte-neighbor' }).serving).toBe(false); }); + + // MM 1.24.2 spells the cell identifier `ci` (`PROPERTY_CI` in both + // `libmm-glib/mm-cell-info-lte.c` and `mm-cell-info-nr5g.c`). `cell-id` is the + // flattened spelling this module read first, and it stays as the fallback so a + // source using it keeps decoding. + test('`ci` is the real MM key, and `cell-id` still decodes behind it', () => { + expect(read({ ci: '0A1B2C3D' }).cellId).toBe('0A1B2C3D'); + expect(read({ 'cell-id': 'LEGACY' }).cellId).toBe('LEGACY'); + expect(read({ ci: 'REAL', 'cell-id': 'LEGACY' }).cellId).toBe('REAL'); + }); + + test('`tac` is surfaced when the RAT reports one, and never derived otherwise', () => { + expect(read({ ci: '0A1B2C3D', tac: '4E5F' }).tac).toBe('4E5F'); + // A GSM/UMTS cell reports no tracking area; nothing is invented from `ci`. + expect(read({ ci: '0A1B2C3D' }).tac).toBeUndefined(); + }); + + // EARFCN is NOT read: MM publishes `earfcn` for LTE and `nrarfcn` for 5GNR — two + // keys for two quantities, and no generic property anywhere. One field here would + // have to merge them or pick a RAT, so the reading claims neither and both stay + // available to a caller that needs the raw dict. + test('an ARFCN key is retained by the source but claimed by no field', () => { + const reading = read({ ci: '0A1B2C3D', earfcn: 1850, nrarfcn: 630000 }); + expect(Object.keys(reading)).not.toContain('earfcn'); + expect(Object.keys(reading)).not.toContain('nrarfcn'); + expect(reading.cellId).toBe('0A1B2C3D'); + }); }); describe('selectServingCell — TOTAL order', () => { diff --git a/control/src/backend/cell-info.ts b/control/src/backend/cell-info.ts index a50709e..7b76d0b 100644 --- a/control/src/backend/cell-info.ts +++ b/control/src/backend/cell-info.ts @@ -8,11 +8,24 @@ // - `rsrp`, `rsrq` pass through as numbers // - `sinr` the REAL NR SINR key. A dict carrying `snr` (the // WRONG name) is IGNORED — `sinr` stays undefined. -// - `cell-id` the cell identifier (serving-cell tiebreak) +// - `ci`, else `cell-id` the cell identifier (serving-cell tiebreak) +// - `tac` the tracking-area code, when the RAT reports one // - `band` surfaced ONLY when the source supplies it directly; // never inferred from earfcn / frequency / anything. // - `serving` / `cell-type` whether this is the serving cell. // +// `ci` IS THE REAL MM KEY and `cell-id` is the fallback, not the other way round. +// ModemManager 1.24.2 spells it `PROPERTY_CI "ci"` in both `libmm-glib/mm-cell-info-lte.c` +// and `mm-cell-info-nr5g.c`; `cell-id` is kept behind it so a source that already +// flattened to that older spelling still decodes rather than silently losing its id. +// +// NO ARFCN IS READ HERE, and that is deliberate rather than pending. MM does publish +// one — but under TWO different keys for two different quantities: `earfcn` in an LTE +// cell dict and `nrarfcn` in a 5GNR one. There is no generic ARFCN property anywhere on +// the `Modem`, `Modem3gpp` or `Location` interfaces, so a single `earfcn` field on this +// reading would have to either merge the two or quietly pick a RAT. `CellReading` makes +// no ARFCN claim instead, and an unread key stays available to a caller that needs it. +// // Every reading also carries `source` + `observedAt` provenance, so a consumer can // tell a fresh reading from a cached one and know where it came from. Pure — no I/O. @@ -33,6 +46,8 @@ export interface CellReading { readonly serving: boolean; /** The cell identifier, when supplied (serving-cell tiebreak key). */ readonly cellId?: string; + /** Tracking-area code — from the `tac` key ONLY, never derived from the cell id. */ + readonly tac?: string; /** Physical cell id — from the `physical-ci` key ONLY. */ readonly pci?: number; readonly rsrp?: number; @@ -60,7 +75,8 @@ export function normalizeCellReading( cell: DecodedProps, provenance: CellInfoProvenance, ): CellReading { - const cellId = stringProp(cell, 'cell-id'); + const cellId = stringProp(cell, 'ci') ?? stringProp(cell, 'cell-id'); + const tac = stringProp(cell, 'tac'); const pci = numberProp(cell, 'physical-ci'); const rsrp = numberProp(cell, 'rsrp'); const rsrq = numberProp(cell, 'rsrq'); @@ -71,6 +87,7 @@ export function normalizeCellReading( return { serving: readServing(cell), ...(cellId !== undefined ? { cellId } : {}), + ...(tac !== undefined ? { tac } : {}), ...(pci !== undefined ? { pci } : {}), ...(rsrp !== undefined ? { rsrp } : {}), ...(rsrq !== undefined ? { rsrq } : {}), @@ -140,6 +157,7 @@ function stableTag(reading: CellReading): string { return JSON.stringify([ reading.serving, reading.cellId ?? null, + reading.tac ?? null, reading.pci ?? null, reading.rsrp ?? null, reading.rsrq ?? null, diff --git a/control/src/backend/device-classifier.test.ts b/control/src/backend/device-classifier.test.ts index 00cfdea..8049cb5 100644 --- a/control/src/backend/device-classifier.test.ts +++ b/control/src/backend/device-classifier.test.ts @@ -4,6 +4,10 @@ import { describe, expect, test } from 'bun:test'; import { + CELLULAR_MODEL_EVIDENCE_SOURCES, + CELLULAR_USB_MODEL_ROWS, + cellularModelEvidence, + cellularVendorName, classifyDevice, classifyUsbNetDevice, detectUsbMode, @@ -13,6 +17,18 @@ import { vendorLabel, } from './device-classifier'; +const SIERRA_SYNTHETIC_FIXTURES = [ + ['1199', '9071', 'EM74xx', 'mainline-kernel'], + ['1199', '9079', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['1199', '907b', 'EM74xx', 'mainline-kernel'], + ['03f0', '4e1d', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['413c', '81a3', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['413c', '81a8', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['1199', '9091', 'EM75xx', 'mainline-kernel'], + ['1199', 'c081', 'EM75xx', 'mainline-kernel'], + ['1199', '90d3', 'EM919x', 'mainline-kernel'], +] as const satisfies readonly (readonly [string, string, string, string])[]; + /** A Quectel-style QMI composition: a `qmi_wwan` control port (+ AT serials). */ const QMI: UsbDeviceSnapshot = { vendorId: '2c7c', @@ -175,6 +191,172 @@ describe('classifyDevice — honesty guards', () => { }); }); +describe('Sierra model evidence — exact VID:PID rows only', () => { + for (const [vendorId, productId, family, evidenceTier] of SIERRA_SYNTHETIC_FIXTURES) { + test(`Given synthetic ${vendorId}:${productId}, When model evidence is resolved, Then it names ${family} at its evidence tier`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + family, + evidenceTier, + vendor: 'Sierra Wireless', + }); + }); + } + + test('Given an unlisted Sierra PID, When model evidence is resolved, Then it stays unknown', () => { + const fixture = { + snapshot: { vendorId: '1199', productId: 'ffff', bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toBeUndefined(); + }); +}); + +const MODULE_SYNTHETIC_FIXTURES = [ + ['1bc7', '1031', 'Telit', 'LE910C1-EUX'], + ['1bc7', '1034', 'Telit', 'LE910C4-WWX'], + ['1bc7', '1040', 'Telit', 'LE922A'], + ['1bc7', '1050', 'Telit', 'FN980'], + ['1bc7', '1060', 'Telit', 'LN920'], + ['1bc7', '1070', 'Telit', 'FN990A'], + ['1bc7', '1080', 'Telit', 'FE990A'], + ['1bc7', '10a0', 'Telit', 'FN920C04'], + ['1bc7', '1100', 'Telit', 'ME910'], + ['1bc7', '1200', 'Telit', 'LE920'], + ['1546', '1311', 'u-blox', 'LARA-R6'], + ['1546', '1312', 'u-blox', 'LARA-R6'], + ['1546', '1313', 'u-blox', 'LARA-R6'], + ['1546', '1341', 'u-blox', 'LARA-L6'], + ['1546', '1342', 'u-blox', 'LARA-L6'], + ['1546', '1343', 'u-blox', 'LARA-L6'], +] as const satisfies readonly (readonly [string, string, string, string])[]; + +const UNLISTED_SYNTHETIC_FIXTURES = [ + ['1bc7', 'ffff'], + ['1546', 'ffff'], + ['0846', '9052'], +] as const satisfies readonly (readonly [string, string])[]; + +/** + * NETGEAR's LB1120 as it actually enumerates: an RNDIS tether and nothing else. No + * kernel modem driver claims `0846:68e1` and ModemManager 1.24.2 ships no NETGEAR + * plugin, so there is no control port for one to bind — the vendor web UI is the + * whole control surface. + */ +const NETGEAR_ROUTER_WEBUI: UsbDeviceSnapshot = { + vendorId: '0846', + productId: '68e1', + bDeviceClass: 0x00, + interfaces: [ + { interfaceClass: 0xe0, interfaceSubClass: 0x01, interfaceProtocol: 0x03 }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x00 }, + ], +}; + +describe('Telit / u-blox model evidence — exact VID:PID rows only', () => { + for (const [vendorId, productId, vendor, family] of MODULE_SYNTHETIC_FIXTURES) { + test(`Given synthetic ${vendorId}:${productId}, When model evidence is resolved, Then it names ${family} from the pinned kernel tables`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + vendor, + family, + evidenceTier: 'mainline-kernel', + }); + }); + } + + for (const [vendorId, productId] of UNLISTED_SYNTHETIC_FIXTURES) { + test(`Given an unlisted ${vendorId}:${productId}, When model evidence is resolved, Then it stays unknown rather than inferring the vendor range`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toBeUndefined(); + }); + } +}); + +describe('NETGEAR 0846 — a router/WebUI family, never an MM-managed modem', () => { + test('Given the LB1120 row, When model evidence is resolved, Then it is labelled router-webui from the USB ID Repository', () => { + const fixture = { snapshot: NETGEAR_ROUTER_WEBUI, provenance: { synthetic: true } } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + vendor: 'NETGEAR', + family: 'LB1120', + evidenceTier: 'usb-ids-registry', + familyKind: 'router-webui', + }); + }); + + test('Given the LB1120 composition, When it is classified, Then it is router-mode and never mm-managed', () => { + const result = classifyDevice(NETGEAR_ROUTER_WEBUI); + + expect(result.deviceClass).toBe('router-mode'); + expect(result.deviceClass).not.toBe('mm-managed'); + expect(detectUsbMode(NETGEAR_ROUTER_WEBUI)).toBe('rndis'); + }); + + test('Given the same composition under an unlisted PID, When it is classified, Then the class is unchanged — interfaces decide it, not the family row', () => { + const unlisted: UsbDeviceSnapshot = { ...NETGEAR_ROUTER_WEBUI, productId: '9052' }; + + expect(cellularModelEvidence(unlisted)).toBeUndefined(); + expect(classifyDevice(unlisted).deviceClass).toBe( + classifyDevice(NETGEAR_ROUTER_WEBUI).deviceClass, + ); + }); + + test('Given a Telit module row on a bare tether, When it is classified, Then a modem-module label still cannot promote it to mm-managed', () => { + const telitTether: UsbDeviceSnapshot = { + ...NETGEAR_ROUTER_WEBUI, + vendorId: '1bc7', + productId: '1070', + }; + + expect(cellularModelEvidence(telitTether)?.family).toBe('FN990A'); + expect(classifyDevice(telitTether).deviceClass).toBe('router-mode'); + }); + + test('Given NETGEAR 0846, When the vendor range is consulted, Then it is NOT a cellular vendor id — the range carries Wi-Fi and Ethernet adapters too', () => { + expect(cellularVendorName('0846')).toBeUndefined(); + expect(cellularVendorName('1bc7')).toBe('Telit'); + }); + + test('Given the LB1120 tether, When it is USB-net classified, Then it reads wired-ethernet — there is no positive cellular evidence to claim', () => { + expect(classifyUsbNetDevice(NETGEAR_ROUTER_WEBUI).deviceClass).toBe('wired-ethernet'); + }); +}); + +describe('model-evidence provenance', () => { + test('Given every row in the table, When its tier is looked up, Then a pinned source exists for it', () => { + for (const evidence of CELLULAR_USB_MODEL_ROWS.values()) { + expect(CELLULAR_MODEL_EVIDENCE_SOURCES[evidence.evidenceTier].pin).not.toBe(''); + } + }); + + test('Given the whole table, When router-webui claims are counted, Then only rows with that evidence carry one — absence is not a modem-module claim', () => { + const routerRows = [...CELLULAR_USB_MODEL_ROWS.entries()].filter( + ([, evidence]) => evidence.familyKind === 'router-webui', + ); + + expect(routerRows.map(([key]) => key)).toEqual(['0846:68e1']); + }); +}); + describe('CeraUI USB-net classification parity', () => { test('requires positive cellular evidence before naming a tether cellular', () => { const plainNic: UsbDeviceSnapshot = { diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts index 554cdc8..8f27de6 100644 --- a/control/src/backend/device-classifier.ts +++ b/control/src/backend/device-classifier.ts @@ -58,6 +58,116 @@ const ECM_NCM_DRIVERS: ReadonlySet = new Set(['cdc_ether', 'cdc_ncm']); const RNDIS_DRIVERS: ReadonlySet = new Set(['rndis_host']); const STORAGE_DRIVERS: ReadonlySet = new Set(['usb-storage', 'uas']); +export type CellularModelEvidenceTier = + | 'modemmanager-1.24.2-fcc' + | 'mainline-kernel' + | 'usb-ids-registry'; + +/** + * A POSITIVE, sourced claim that a labelled family is a router appliance whose only + * control surface is a vendor web UI — no ModemManager plugin, no kernel modem driver + * claiming its application PID. ABSENCE OF THIS FIELD IS NOT A CLAIM: it does not say + * a family is a modem module, only that nothing here asserts otherwise. Same tri-state + * discipline as `fcc/coverage.ts` — `unknown` is never folded into `absent`. + * + * It also does not, and must not, decide a DEVICE CLASS. `classifyDevice` reads live + * interfaces and bound drivers; this label is model-family evidence sitting beside it. + */ +export type CellularModelFamilyKind = 'router-webui'; + +export interface CellularModelEvidence { + readonly vendor: string; + readonly family: string; + readonly evidenceTier: CellularModelEvidenceTier; + readonly familyKind?: CellularModelFamilyKind; +} + +/** Where each evidence tier's rows were read from, so a reviewer can re-check them. */ +export const CELLULAR_MODEL_EVIDENCE_SOURCES = { + 'modemmanager-1.24.2-fcc': { + source: 'ModemManager 1.24.2 data/dispatcher-fcc-unlock/meson.build', + pin: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + }, + 'mainline-kernel': { + source: 'Linux drivers/net/usb/qmi_wwan.c + drivers/usb/serial/option.c', + pin: '45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229', + }, + 'usb-ids-registry': { + source: 'The USB ID Repository (linux-usb.org/usb.ids)', + pin: '2026.06.26', + }, +} as const satisfies Record; + +/** + * Exact model-family evidence. This table may label a known VID:PID, but it never + * decides whether the device is MM-managed: live interface/driver evidence below + * remains authoritative. The pinned ModemManager FCC map is the strongest tier; + * `mainline-kernel` rows are application-mode PIDs named by Linux's own qmi_wwan / + * qcserial / option device tables; `usb-ids-registry` is the weakest and is used only + * where NO kernel modem driver claims the id at all — which is itself the evidence + * that the device is a router appliance rather than a controllable module. + * + * EVERY ROW IS AN EXACT VID:PID. A vendor range is never inferred, and that is not a + * stylistic preference: NETGEAR's `0846` and u-blox's `1546` both carry non-cellular + * products (Wi-Fi/Ethernet adapters and GNSS receivers respectively), so a + * vendor-keyed rule on either would label hardware that has no radio. + */ +export const CELLULAR_USB_MODEL_ROWS: ReadonlyMap = new Map([ + [ + '03f0:4e1d', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + ['1199:9071', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }], + [ + '1199:9079', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + ['1199:907b', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }], + ['1199:9091', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }], + ['1199:90d3', { vendor: 'Sierra Wireless', family: 'EM919x', evidenceTier: 'mainline-kernel' }], + ['1199:c081', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }], + [ + '413c:81a3', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + [ + '413c:81a8', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + ['1bc7:1031', { vendor: 'Telit', family: 'LE910C1-EUX', evidenceTier: 'mainline-kernel' }], + ['1bc7:1034', { vendor: 'Telit', family: 'LE910C4-WWX', evidenceTier: 'mainline-kernel' }], + ['1bc7:1040', { vendor: 'Telit', family: 'LE922A', evidenceTier: 'mainline-kernel' }], + ['1bc7:1050', { vendor: 'Telit', family: 'FN980', evidenceTier: 'mainline-kernel' }], + ['1bc7:1060', { vendor: 'Telit', family: 'LN920', evidenceTier: 'mainline-kernel' }], + ['1bc7:1070', { vendor: 'Telit', family: 'FN990A', evidenceTier: 'mainline-kernel' }], + ['1bc7:1080', { vendor: 'Telit', family: 'FE990A', evidenceTier: 'mainline-kernel' }], + ['1bc7:10a0', { vendor: 'Telit', family: 'FN920C04', evidenceTier: 'mainline-kernel' }], + ['1bc7:1100', { vendor: 'Telit', family: 'ME910', evidenceTier: 'mainline-kernel' }], + ['1bc7:1200', { vendor: 'Telit', family: 'LE920', evidenceTier: 'mainline-kernel' }], + ['1546:1311', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1312', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1313', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1341', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + ['1546:1342', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + ['1546:1343', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + [ + '0846:68e1', + { + vendor: 'NETGEAR', + family: 'LB1120', + evidenceTier: 'usb-ids-registry', + familyKind: 'router-webui', + }, + ], +]); + +/** + * Vendor ids whose ENTIRE USB range is cellular, so the vendor id alone is honest + * evidence of a radio. Membership is a claim about the whole range, which is why + * NETGEAR's `0846` is deliberately ABSENT despite having a row above: the USB ID + * Repository lists that vendor's Wi-Fi and Ethernet adapters under it, so a + * vendor-keyed rule there would report a Wi-Fi dongle as a cellular uplink. + */ export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ ['05c6', 'Qualcomm'], ['0af0', 'Option'], @@ -66,6 +176,7 @@ export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ ['1546', 'u-blox'], ['19d2', 'ZTE'], ['1bbb', 'TCL/Alcatel'], + ['1bc7', 'Telit'], ['1c9e', 'Longcheer'], ['1e0e', 'SIMCom'], ['2c7c', 'Quectel'], @@ -202,6 +313,12 @@ export function cellularVendorName(vendorId: string): string | undefined { return CELLULAR_USB_VENDOR_IDS.get(vendorId.toLowerCase()); } +export function cellularModelEvidence( + device: Pick, +): CellularModelEvidence | undefined { + return CELLULAR_USB_MODEL_ROWS.get(`${device.vendorId}:${device.productId}`.toLowerCase()); +} + export function cellularEvidence(device: UsbDeviceSnapshot): string | undefined { const vendor = cellularVendorName(device.vendorId); if (vendor !== undefined) diff --git a/control/src/backend/mm-mutations.ts b/control/src/backend/mm-mutations.ts index 7da7333..9ee61ab 100644 --- a/control/src/backend/mm-mutations.ts +++ b/control/src/backend/mm-mutations.ts @@ -1,319 +1,87 @@ -// The ModemManager mutations — the disruptive-and-SIM half of `ModemManagerPort`. -// -// Every disruptive op runs through the shared per-modem `ModemActor` (serialized on -// the STABLE key, so two ops on one modem never interleave and a replug keeps the -// same queue). Mode and slot changes additionally run QUIESCED (NM briefly stands -// down first, via the actor's quiesce hook). PIN / PUK / scan serialize too but do -// NOT quiesce — they don't touch the bearer. NONE of these methods can reach a bearer -// or connect verb: the port has none, and the fake's tripwire proves it at test time. - -import { decodeBandList, encodeBandList, isResetSelection } from '../band'; -import type { DesiredRadio, RadioAccessTechnology } from '../domain'; -import { epochMillis } from '../domain'; +import type { DesiredRadio } from '../domain'; import type { BandReadResult, InhibitLease, ModemRef, NetworkScanResult, Receipt, - ScannedNetwork, SimPukUnlockResult, SimUnlockResult, } from '../ports'; -import { receipt } from '../ports'; import type { DbusTransport } from '../transport'; -import { - MM_BUS_NAME, - MM_MANAGER_IFACE, - MM_ROOT_PATH, - MODEM_IFACE, - MODEM3GPP_IFACE, -} from './constants'; -import { - type DecodedProps, - fetchManagedObjects, - findInterface, - numberProp, - propValue, - stringProp, -} from './managed-objects'; +import { MM_BUS_NAME } from './constants'; +import { readBands, setCurrentBands } from './mm-mutations/bands'; +import type { MmMutationContext } from './mm-mutations/context'; +import { inhibitDevice, uninhibitDevice } from './mm-mutations/inhibit'; +import { setModeCombination, setRadioModes } from './mm-mutations/modes'; +import { scanNetworks } from './mm-mutations/scan'; +import { setPrimarySimSlot, unlockWithPin, unlockWithPuk } from './mm-mutations/sim'; import type { ModemActor } from './modem-actor'; -import { sendSimPin, sendSimPuk } from './sim-unlock'; - -/** MMModemMode bit per RAT family (2G/3G/4G/5G). */ -const MODE_BIT: Record = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 }; -/** MMModem3gppNetworkAvailability → the port's availability. */ -const AVAILABILITY: Record = { - 0: 'unknown', - 1: 'available', - 2: 'current', - 3: 'forbidden', -}; - -/** A network scan can take a long time — MM's own default is not enough. */ const DEFAULT_SCAN_TIMEOUT_MS = 300_000; export interface MmMutationsDeps { readonly transport: DbusTransport; readonly actor: ModemActor; readonly destination?: string; - /** Map a live modem path to its stable actor key (survives replug). */ readonly resolveStableKey: (modem: ModemRef) => string; readonly scanTimeoutMs?: number; readonly now?: () => number; } -/** The disruptive + SIM mutations of `ModemManagerPort`, serialized per modem. */ export class MmMutations { - readonly #transport: DbusTransport; - readonly #actor: ModemActor; - readonly #destination: string; - readonly #resolveStableKey: (modem: ModemRef) => string; + readonly #context: MmMutationContext; readonly #scanTimeoutMs: number; readonly #now: () => number; constructor(deps: MmMutationsDeps) { - this.#transport = deps.transport; - this.#actor = deps.actor; - this.#destination = deps.destination ?? MM_BUS_NAME; - this.#resolveStableKey = deps.resolveStableKey; + this.#context = { + transport: deps.transport, + actor: deps.actor, + destination: deps.destination ?? MM_BUS_NAME, + resolveStableKey: deps.resolveStableKey, + }; this.#scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS; this.#now = deps.now ?? Date.now; } setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise { - const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered)); - const preferred = preference.preferenceOrdered[0]; - const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0; - if (allowed === 0) { - return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentModes', - signature: '(uu)', - args: [[allowed, preferredMask]], - }); - return receipt('radio', 'applied', 'radio mode preference applied'); - } catch (error) { - return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); - } - }); + return setRadioModes(this.#context, modem, preference); } - /** - * `SetCurrentModes` over the RAW `(uu)` masks the modem itself advertised. - * - * `setRadioModes` above speaks the reconciler's vocabulary — an ordered RAT - * preference — and structurally cannot express `MM_MODEM_MODE_NONE`: its preferred - * mask is derived from `preferenceOrdered[0]`, so "allow this set and prefer - * nothing within it" has no spelling. That is the combination the bench FM350-GL - * actually advertises, so a caller selecting an advertised combination verbatim - * needs this entry point. It quiesces for the same reason `setRadioModes` does: a - * mode change re-registers the radio and drops the bearer underneath NM. - * - * An `allowed` mask of 0 is refused — that is not a selection, it is a radio with - * nothing switched on — while a `preferred` mask of 0 is passed through untouched. - */ setModeCombination(modem: ModemRef, allowed: number, preferred: number): Promise { - if (allowed === 0) { - return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentModes', - signature: '(uu)', - args: [[allowed, preferred]], - }); - return receipt('radio', 'applied', 'radio mode combination applied'); - } catch (error) { - return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); - } - }); + return setModeCombination(this.#context, modem, allowed, preferred); } - async readBands(modem: ModemRef): Promise { - try { - const tree = await fetchManagedObjects(this.#transport, this.#destination); - const props = findInterface(tree, modem, MODEM_IFACE); - if (props === undefined) { - return { ok: false, reason: 'the modem exports no Modem interface' }; - } - return { - ok: true, - bands: { - supported: decodeBandList(propValue(props, 'SupportedBands')), - current: decodeBandList(propValue(props, 'CurrentBands')), - }, - }; - } catch (error) { - return { ok: false, reason: `reading bands failed: ${describe(error)}` }; - } + readBands(modem: ModemRef): Promise { + return readBands(this.#context, modem); } - // Quiesced like `setRadioModes`, and for the same reason: a band change - // re-registers the radio, so NM must stand down before the bearer drops - // underneath it rather than after. setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise { - if (bands.length === 0) { - return Promise.resolve(receipt('band', 'failed', 'no bands were requested')); - } - const encoded = encodeBandList(bands); - if (!encoded.ok) { - return Promise.resolve( - receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`), - ); - } - const values = encoded.values; - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentBands', - signature: 'au', - args: [values], - }); - return receipt( - 'band', - 'applied', - isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`, - ); - } catch (error) { - return receipt('band', 'failed', `SetCurrentBands failed: ${describe(error)}`); - } - }); + return setCurrentBands(this.#context, modem, bands); } - async setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { - const slots = await this.#readSlotCount(modem); - if (slots === undefined) { - return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list'); - } - if (slots <= 1) { - return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select'); - } - if (slotIndex < 1 || slotIndex > slots) { - return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetPrimarySimSlot', - signature: 'u', - args: [slotIndex], - }); - return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`); - } catch (error) { - return receipt('simSlot', 'failed', `SetPrimarySimSlot failed: ${describe(error)}`); - } - }); + setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { + return setPrimarySimSlot(this.#context, modem, slotIndex); } sendPin(modem: ModemRef, pin: string): Promise { - return this.#actor.run(this.#resolveStableKey(modem), () => - sendSimPin(this.#transport, this.#destination, modem, pin), - ); + return unlockWithPin(this.#context, modem, pin); } sendPuk(modem: ModemRef, puk: string, newPin: string): Promise { - return this.#actor.run(this.#resolveStableKey(modem), () => - sendSimPuk(this.#transport, this.#destination, modem, puk, newPin), - ); + return unlockWithPuk(this.#context, modem, puk, newPin); } scanNetworks(modem: ModemRef): Promise { - return this.#actor.run(this.#resolveStableKey(modem), async () => { - try { - const reply = await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM3GPP_IFACE, - member: 'Scan', - timeoutMs: this.#scanTimeoutMs, - }); - return { ok: true, networks: parseScan(reply.body[0]) }; - } catch (error) { - return { ok: false, reason: `network scan failed: ${describe(error)}` }; - } - }); - } - - async inhibit(uid: string): Promise { - await this.#inhibitDevice(uid, true); - return { uid, acquiredAt: epochMillis(this.#now()) }; - } - - async uninhibit(lease: InhibitLease): Promise { - await this.#inhibitDevice(lease.uid, false); - } - - #inhibitDevice(uid: string, inhibit: boolean): Promise { - return this.#transport.callMethod({ - destination: this.#destination, - path: MM_ROOT_PATH, - interface: MM_MANAGER_IFACE, - member: 'InhibitDevice', - signature: 'sb', - args: [uid, inhibit], - }); - } - - async #readSlotCount(modem: ModemRef): Promise { - try { - const tree = await fetchManagedObjects(this.#transport, this.#destination); - const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots'); - return Array.isArray(slots) ? slots.length : undefined; - } catch { - return undefined; - } + return scanNetworks(this.#context, modem, this.#scanTimeoutMs); } -} -/** OR of the MMModemMode bits for a set of RATs. */ -function maskOf(rats: ReadonlySet): number { - let mask = 0; - for (const rat of rats) { - mask |= MODE_BIT[rat]; + inhibit(uid: string): Promise { + return inhibitDevice(this.#context, uid, this.#now); } - return mask; -} -/** Parse a `Modem3gpp.Scan` reply (`aa{sv}` → dicts) into scanned networks. */ -function parseScan(value: unknown): readonly ScannedNetwork[] { - if (!Array.isArray(value)) { - return []; - } - const networks: ScannedNetwork[] = []; - for (const entry of value as DecodedProps[]) { - const operatorCode = stringProp(entry, 'operator-code'); - if (operatorCode === undefined) { - continue; - } - const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short'); - const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown'; - networks.push({ - operatorCode, - ...(name !== undefined ? { operatorName: name } : {}), - availability, - }); + uninhibit(lease: InhibitLease): Promise { + return uninhibitDevice(this.#context, lease); } - return networks; -} - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); } diff --git a/control/src/backend/mm-mutations/bands.ts b/control/src/backend/mm-mutations/bands.ts new file mode 100644 index 0000000..f7a7ffd --- /dev/null +++ b/control/src/backend/mm-mutations/bands.ts @@ -0,0 +1,64 @@ +import { decodeBandList, encodeBandList, isResetSelection } from '../../band'; +import type { BandReadResult, ModemRef, Receipt } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import { fetchManagedObjects, findInterface, propValue } from '../managed-objects'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +export async function readBands( + context: MmMutationContext, + modem: ModemRef, +): Promise { + try { + const tree = await fetchManagedObjects(context.transport, context.destination); + const props = findInterface(tree, modem, MODEM_IFACE); + if (props === undefined) { + return { ok: false, reason: 'the modem exports no Modem interface' }; + } + return { + ok: true, + bands: { + supported: decodeBandList(propValue(props, 'SupportedBands')), + current: decodeBandList(propValue(props, 'CurrentBands')), + }, + }; + } catch (error) { + return { ok: false, reason: `reading bands failed: ${describeMutationError(error)}` }; + } +} + +export function setCurrentBands( + context: MmMutationContext, + modem: ModemRef, + bands: readonly string[], +): Promise { + if (bands.length === 0) { + return Promise.resolve(receipt('band', 'failed', 'no bands were requested')); + } + const encoded = encodeBandList(bands); + if (!encoded.ok) { + return Promise.resolve( + receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`), + ); + } + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentBands', + signature: 'au', + args: [encoded.values], + }); + return receipt( + 'band', + 'applied', + isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`, + ); + } catch (error) { + return receipt('band', 'failed', `SetCurrentBands failed: ${describeMutationError(error)}`); + } + }); +} diff --git a/control/src/backend/mm-mutations/context.ts b/control/src/backend/mm-mutations/context.ts new file mode 100644 index 0000000..db01fbe --- /dev/null +++ b/control/src/backend/mm-mutations/context.ts @@ -0,0 +1,14 @@ +import type { ModemRef } from '../../ports'; +import type { DbusTransport } from '../../transport'; +import type { ModemActor } from '../modem-actor'; + +export interface MmMutationContext { + readonly transport: DbusTransport; + readonly actor: ModemActor; + readonly destination: string; + readonly resolveStableKey: (modem: ModemRef) => string; +} + +export function describeMutationError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/control/src/backend/mm-mutations/inhibit.ts b/control/src/backend/mm-mutations/inhibit.ts new file mode 100644 index 0000000..140b2ee --- /dev/null +++ b/control/src/backend/mm-mutations/inhibit.ts @@ -0,0 +1,31 @@ +import { epochMillis } from '../../domain'; +import type { InhibitLease } from '../../ports'; +import { MM_MANAGER_IFACE, MM_ROOT_PATH } from '../constants'; +import type { MmMutationContext } from './context'; + +export async function inhibitDevice( + context: MmMutationContext, + uid: string, + now: () => number, +): Promise { + await setInhibited(context, uid, true); + return { uid, acquiredAt: epochMillis(now()) }; +} + +export async function uninhibitDevice( + context: MmMutationContext, + lease: InhibitLease, +): Promise { + await setInhibited(context, lease.uid, false); +} + +function setInhibited(context: MmMutationContext, uid: string, inhibit: boolean): Promise { + return context.transport.callMethod({ + destination: context.destination, + path: MM_ROOT_PATH, + interface: MM_MANAGER_IFACE, + member: 'InhibitDevice', + signature: 'sb', + args: [uid, inhibit], + }); +} diff --git a/control/src/backend/mm-mutations/modes.ts b/control/src/backend/mm-mutations/modes.ts new file mode 100644 index 0000000..e9f2f34 --- /dev/null +++ b/control/src/backend/mm-mutations/modes.ts @@ -0,0 +1,66 @@ +import type { DesiredRadio, RadioAccessTechnology } from '../../domain'; +import type { ModemRef, Receipt } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +const MODE_BIT: Record = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 }; + +export function setRadioModes( + context: MmMutationContext, + modem: ModemRef, + preference: DesiredRadio, +): Promise { + const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered)); + const preferred = preference.preferenceOrdered[0]; + const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0; + if (allowed === 0) { + return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); + } + return setModeMasks(context, modem, allowed, preferredMask, 'radio mode preference applied'); +} + +export function setModeCombination( + context: MmMutationContext, + modem: ModemRef, + allowed: number, + preferred: number, +): Promise { + if (allowed === 0) { + return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); + } + return setModeMasks(context, modem, allowed, preferred, 'radio mode combination applied'); +} + +function setModeMasks( + context: MmMutationContext, + modem: ModemRef, + allowed: number, + preferred: number, + successMessage: string, +): Promise { + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentModes', + signature: '(uu)', + args: [[allowed, preferred]], + }); + return receipt('radio', 'applied', successMessage); + } catch (error) { + return receipt('radio', 'failed', `SetCurrentModes failed: ${describeMutationError(error)}`); + } + }); +} + +function maskOf(rats: ReadonlySet): number { + let mask = 0; + for (const rat of rats) { + mask |= MODE_BIT[rat]; + } + return mask; +} diff --git a/control/src/backend/mm-mutations/scan.ts b/control/src/backend/mm-mutations/scan.ts new file mode 100644 index 0000000..c5666d7 --- /dev/null +++ b/control/src/backend/mm-mutations/scan.ts @@ -0,0 +1,55 @@ +import type { ModemRef, NetworkScanResult, ScannedNetwork } from '../../ports'; +import { MODEM3GPP_IFACE } from '../constants'; +import type { DecodedProps } from '../managed-objects'; +import { numberProp, stringProp } from '../managed-objects'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +const AVAILABILITY: Record = { + 0: 'unknown', + 1: 'available', + 2: 'current', + 3: 'forbidden', +}; + +export function scanNetworks( + context: MmMutationContext, + modem: ModemRef, + timeoutMs: number, +): Promise { + return context.actor.run(context.resolveStableKey(modem), async () => { + try { + const reply = await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM3GPP_IFACE, + member: 'Scan', + timeoutMs, + }); + return { ok: true, networks: parseScan(reply.body[0]) }; + } catch (error) { + return { ok: false, reason: `network scan failed: ${describeMutationError(error)}` }; + } + }); +} + +function parseScan(value: unknown): readonly ScannedNetwork[] { + if (!Array.isArray(value)) { + return []; + } + const networks: ScannedNetwork[] = []; + for (const entry of value as DecodedProps[]) { + const operatorCode = stringProp(entry, 'operator-code'); + if (operatorCode === undefined) { + continue; + } + const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short'); + const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown'; + networks.push({ + operatorCode, + ...(name !== undefined ? { operatorName: name } : {}), + availability, + }); + } + return networks; +} diff --git a/control/src/backend/mm-mutations/sim.ts b/control/src/backend/mm-mutations/sim.ts new file mode 100644 index 0000000..f918ae2 --- /dev/null +++ b/control/src/backend/mm-mutations/sim.ts @@ -0,0 +1,77 @@ +import type { ModemRef, Receipt, SimPukUnlockResult, SimUnlockResult } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import { fetchManagedObjects, findInterface, propValue } from '../managed-objects'; +import { sendSimPin, sendSimPuk } from '../sim-unlock'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +export async function setPrimarySimSlot( + context: MmMutationContext, + modem: ModemRef, + slotIndex: number, +): Promise { + const slots = await readSlotCount(context, modem); + if (slots === undefined) { + return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list'); + } + if (slots <= 1) { + return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select'); + } + if (slotIndex < 1 || slotIndex > slots) { + return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`); + } + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetPrimarySimSlot', + signature: 'u', + args: [slotIndex], + }); + return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`); + } catch (error) { + return receipt( + 'simSlot', + 'failed', + `SetPrimarySimSlot failed: ${describeMutationError(error)}`, + ); + } + }); +} + +export function unlockWithPin( + context: MmMutationContext, + modem: ModemRef, + pin: string, +): Promise { + return context.actor.run(context.resolveStableKey(modem), () => + sendSimPin(context.transport, context.destination, modem, pin), + ); +} + +export function unlockWithPuk( + context: MmMutationContext, + modem: ModemRef, + puk: string, + newPin: string, +): Promise { + return context.actor.run(context.resolveStableKey(modem), () => + sendSimPuk(context.transport, context.destination, modem, puk, newPin), + ); +} + +async function readSlotCount( + context: MmMutationContext, + modem: ModemRef, +): Promise { + try { + const tree = await fetchManagedObjects(context.transport, context.destination); + const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots'); + return Array.isArray(slots) ? slots.length : undefined; + } catch { + return undefined; + } +} diff --git a/control/src/backend/observer.ts b/control/src/backend/observer.ts index e1df1a0..b2674a4 100644 --- a/control/src/backend/observer.ts +++ b/control/src/backend/observer.ts @@ -22,16 +22,11 @@ import type { Unsubscribe, } from '../ports'; import type { DbusTransport, SignalEvent, Subscription } from '../transport'; -import { - DBUS_DESTINATION, - DBUS_IFACE, - DBUS_PATH, - MM_BUS_NAME, - MM_ROOT_PATH, - OBJECT_MANAGER_IFACE, - PROPERTIES_IFACE, -} from './constants'; -import { asManagedObjects, type DecodedManagedObjects } from './managed-objects'; +import { MM_BUS_NAME } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { subscribeObserverSignals, unsubscribeObserverSignals } from './observer/bus-lifecycle'; +import { queryModemManagerOwner, readAuthoritativeTree } from './observer/epoch-reconcile'; +import { isCurrentOwnerSignal, routeOwnerSignal } from './observer/signal-routing'; import { ObservationRowStore } from './row-store'; /** @@ -121,30 +116,19 @@ export class MmDbusObserver implements ModemObservationPort { this.#stopped = true; this.#transport.off('disconnected', this.#onDisconnected); this.#transport.off('reconnected', this.#onReconnected); - const subs = this.#subscriptions.splice(0); - await Promise.all(subs.map((sub) => sub.unsubscribe().catch(() => undefined))); + const subscriptions = this.#subscriptions.splice(0); + await unsubscribeObserverSignals(subscriptions); this.#listeners.clear(); } // ── signal subscription ──────────────────────────────────────────────────────── async #subscribeAll(): Promise { - const om = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH } as const; this.#subscriptions.push( - await this.#transport.subscribeSignal({ ...om, member: 'InterfacesAdded' }, (event) => - this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal({ ...om, member: 'InterfacesRemoved' }, (event) => - this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal( - { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, - (event) => this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal( - { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, - (event) => this.#onNameOwnerChanged(event), - ), + ...(await subscribeObserverSignals(this.#transport, { + onObjectSignal: (event) => this.#onObjectSignal(event), + onNameOwnerChanged: (event) => this.#onNameOwnerChanged(event), + })), ); } @@ -161,48 +145,34 @@ export class MmDbusObserver implements ModemObservationPort { } async #queryOwner(): Promise { - try { - const reply = await this.#transport.callMethod({ - destination: DBUS_DESTINATION, - path: DBUS_PATH, - interface: DBUS_IFACE, - member: 'GetNameOwner', - signature: 's', - args: [MM_BUS_NAME], - }); - const owner = reply.body[0]; - return typeof owner === 'string' && owner.length > 0 ? owner : undefined; - } catch { - // NameHasNoOwner (or a transient failure) → no current epoch yet. - return undefined; - } + return queryModemManagerOwner(this.#transport); } #onNameOwnerChanged(event: SignalEvent): void { - if (event.body[0] !== MM_BUS_NAME) { + const routed = routeOwnerSignal(event); + if (routed.kind === 'unrelated') { return; } - const newOwner = typeof event.body[2] === 'string' ? event.body[2] : ''; - if (newOwner.length === 0) { + if (routed.kind === 'lost') { // Owner lost — stale, never a removal. this.#handleSourceGone('source-unavailable'); return; } - if (newOwner === this.#currentOwner) { + if (routed.owner === this.#currentOwner) { return; } // New epoch: everything goes stale until the fresh snapshot restores it. if (this.#store.markUnavailable('source-unavailable')) { this.#emit(); } - this.#currentOwner = newOwner; + this.#currentOwner = routed.owner; this.#scheduleRefresh(); } #onObjectSignal(event: SignalEvent): void { // Epoch guard: a signal from anyone but the current owner is an OLD-epoch // straggler and must never drive a removal (draft §Oracle round-3 #5). - if (this.#currentOwner === undefined || event.sender !== this.#currentOwner) { + if (!isCurrentOwnerSignal(event, this.#currentOwner)) { return; } if (this.#priming) { @@ -229,17 +199,11 @@ export class MmDbusObserver implements ModemObservationPort { async #runRefresh(epochOwner: string): Promise { this.#refreshing = true; try { - const reply = await this.#transport.callMethod({ - destination: this.#destination, - path: MM_ROOT_PATH, - interface: OBJECT_MANAGER_IFACE, - member: 'GetManagedObjects', - }); + const tree = await readAuthoritativeTree(this.#transport, this.#destination); // Late reply from a superseded epoch — discard (draft §Oracle round-3 #5). if (this.#currentOwner !== epochOwner || this.#stopped) { return; } - const tree = asManagedObjects(reply.body[0]); const rowsChanged = this.#store.reconcile(tree); const healthChanged = this.#store.markHealthy(); this.#notifyEpochRefresh(epochOwner, tree); diff --git a/control/src/backend/observer/bus-lifecycle.ts b/control/src/backend/observer/bus-lifecycle.ts new file mode 100644 index 0000000..0e8fa63 --- /dev/null +++ b/control/src/backend/observer/bus-lifecycle.ts @@ -0,0 +1,39 @@ +import type { DbusTransport, SignalEvent, Subscription } from '../../transport'; +import { DBUS_IFACE, MM_ROOT_PATH, OBJECT_MANAGER_IFACE, PROPERTIES_IFACE } from '../constants'; + +export interface ObserverSignalHandlers { + readonly onObjectSignal: (event: SignalEvent) => void; + readonly onNameOwnerChanged: (event: SignalEvent) => void; +} + +export async function subscribeObserverSignals( + transport: DbusTransport, + handlers: ObserverSignalHandlers, +): Promise { + const objectManager = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH } as const; + const interfacesAdded = await transport.subscribeSignal( + { ...objectManager, member: 'InterfacesAdded' }, + handlers.onObjectSignal, + ); + const interfacesRemoved = await transport.subscribeSignal( + { ...objectManager, member: 'InterfacesRemoved' }, + handlers.onObjectSignal, + ); + const propertiesChanged = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, + handlers.onObjectSignal, + ); + const nameOwnerChanged = await transport.subscribeSignal( + { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, + handlers.onNameOwnerChanged, + ); + return [interfacesAdded, interfacesRemoved, propertiesChanged, nameOwnerChanged]; +} + +export async function unsubscribeObserverSignals( + subscriptions: readonly Subscription[], +): Promise { + await Promise.all( + subscriptions.map((subscription) => subscription.unsubscribe().catch(() => undefined)), + ); +} diff --git a/control/src/backend/observer/epoch-reconcile.ts b/control/src/backend/observer/epoch-reconcile.ts new file mode 100644 index 0000000..5b7fb5f --- /dev/null +++ b/control/src/backend/observer/epoch-reconcile.ts @@ -0,0 +1,42 @@ +import type { DbusTransport } from '../../transport'; +import { + DBUS_DESTINATION, + DBUS_IFACE, + DBUS_PATH, + MM_BUS_NAME, + MM_ROOT_PATH, + OBJECT_MANAGER_IFACE, +} from '../constants'; +import { asManagedObjects, type DecodedManagedObjects } from '../managed-objects'; + +export async function queryModemManagerOwner( + transport: DbusTransport, +): Promise { + try { + const reply = await transport.callMethod({ + destination: DBUS_DESTINATION, + path: DBUS_PATH, + interface: DBUS_IFACE, + member: 'GetNameOwner', + signature: 's', + args: [MM_BUS_NAME], + }); + const owner = reply.body[0]; + return typeof owner === 'string' && owner.length > 0 ? owner : undefined; + } catch { + return undefined; + } +} + +export async function readAuthoritativeTree( + transport: DbusTransport, + destination: string, +): Promise { + const reply = await transport.callMethod({ + destination, + path: MM_ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'GetManagedObjects', + }); + return asManagedObjects(reply.body[0]); +} diff --git a/control/src/backend/observer/signal-routing.ts b/control/src/backend/observer/signal-routing.ts new file mode 100644 index 0000000..dd98743 --- /dev/null +++ b/control/src/backend/observer/signal-routing.ts @@ -0,0 +1,22 @@ +import type { SignalEvent } from '../../transport'; +import { MM_BUS_NAME } from '../constants'; + +export type OwnerSignal = + | { readonly kind: 'unrelated' } + | { readonly kind: 'lost' } + | { readonly kind: 'owned'; readonly owner: string }; + +export function routeOwnerSignal(event: SignalEvent): OwnerSignal { + if (event.body[0] !== MM_BUS_NAME) { + return { kind: 'unrelated' }; + } + const newOwner = typeof event.body[2] === 'string' ? event.body[2] : ''; + return newOwner.length === 0 ? { kind: 'lost' } : { kind: 'owned', owner: newOwner }; +} + +export function isCurrentOwnerSignal( + event: SignalEvent, + currentOwner: string | undefined, +): boolean { + return currentOwner !== undefined && event.sender === currentOwner; +} diff --git a/control/src/backend/router-ethernet.test.ts b/control/src/backend/router-ethernet.test.ts index 982bcfb..840dc6f 100644 --- a/control/src/backend/router-ethernet.test.ts +++ b/control/src/backend/router-ethernet.test.ts @@ -4,7 +4,11 @@ import { describe, expect, test } from 'bun:test'; import { deviceIfname } from '../ports'; -import { createRouterEthernetProbe, type RouterEthernetProbeDeps } from './router-ethernet'; +import { + classifyRouterProbeFailure, + createRouterEthernetProbe, + type RouterEthernetProbeDeps, +} from './router-ethernet'; const IFNAME = deviceIfname('eth1'); @@ -68,4 +72,45 @@ describe('createRouterEthernetProbe — advisory health', () => { expect(health.gatewayReachable).toBe(false); expect(health.egressHealthy).toBe(false); }); + + test('uses the injected egress host', async () => { + const hosts: string[] = []; + const probe = probeWith({ + probeHost: '198.51.100.7', + checkLinkUp: () => Promise.resolve(true), + resolveGateway: () => Promise.resolve('192.168.8.1'), + ping: (host) => { + hosts.push(host); + return Promise.resolve(true); + }, + }); + await probe.checkHealth(IFNAME); + expect(hosts).toEqual(['192.168.8.1', '198.51.100.7']); + }); + + test('classifies command missing, timeout, nonzero exit, and unreachable failures', () => { + expect( + classifyRouterProbeFailure(Object.assign(new Error('missing'), { code: 'ENOENT' })), + ).toBe('command-missing'); + expect( + classifyRouterProbeFailure(Object.assign(new Error('hung'), { name: 'TimeoutError' })), + ).toBe('timeout'); + expect(classifyRouterProbeFailure(new Error('exit 1'))).toBe('nonzero-exit'); + expect( + classifyRouterProbeFailure( + Object.assign(new Error('unreachable'), { name: 'UnreachableError' }), + ), + ).toBe('unreachable'); + }); + + test('a fake hung subprocess is bounded by the configured timeout', async () => { + const started = performance.now(); + const probe = probeWith({ + subprocessTimeoutMs: 5, + checkLinkUp: () => new Promise(() => undefined), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.presence).toBe('absent'); + expect(performance.now() - started).toBeLessThan(1000); + }); }); diff --git a/control/src/backend/router-ethernet.ts b/control/src/backend/router-ethernet.ts index 3174001..8851695 100644 --- a/control/src/backend/router-ethernet.ts +++ b/control/src/backend/router-ethernet.ts @@ -20,43 +20,98 @@ export interface RouterEthernetProbeDeps { readonly resolveGateway?: (ifname: DeviceIfname) => Promise; /** Whether `host` is reachable via `ifname` (ICMP). */ readonly ping?: (host: string, ifname: DeviceIfname) => Promise; + /** Override the public DNS target used by the egress probe. */ + readonly probeHost?: string; + /** Bound applied to every default subprocess, including `ip`. */ + readonly subprocessTimeoutMs?: number; +} + +export const ROUTER_PROBE_FAILURES = [ + 'command-missing', + 'timeout', + 'nonzero-exit', + 'unreachable', +] as const; +export type RouterProbeFailureKind = (typeof ROUTER_PROBE_FAILURES)[number]; + +export function classifyRouterProbeFailure(error: unknown): RouterProbeFailureKind { + if (error instanceof Error && error.name === 'TimeoutError') return 'timeout'; + if (error instanceof Error && error.name === 'UnreachableError') return 'unreachable'; + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ENOENT' + ) { + return 'command-missing'; + } + return 'nonzero-exit'; } /** The host used for the basic egress-health probe (public DNS anycast). */ const EGRESS_PROBE_HOST = '1.1.1.1'; +const DEFAULT_SUBPROCESS_TIMEOUT_MS = 5_000; + +function bounded(operation: Promise, timeoutMs: number): Promise { + return Promise.race([ + operation, + new Promise((_, reject) => + setTimeout( + () => reject(Object.assign(new Error('probe timed out'), { name: 'TimeoutError' })), + timeoutMs, + ), + ), + ]); +} -async function spawnSucceeds(command: readonly string[]): Promise { +async function spawnSucceeds(command: readonly string[], timeoutMs: number): Promise { try { const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); - return (await proc.exited) === 0; + return ( + (await Promise.race([ + proc.exited, + new Promise((resolve) => setTimeout(() => resolve(124), timeoutMs)), + ])) === 0 + ); } catch { return false; } } -async function spawnOutput(command: readonly string[]): Promise { +async function spawnOutput(command: readonly string[], timeoutMs: number): Promise { try { const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); - const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const [out, code] = await Promise.race([ + Promise.all([new Response(proc.stdout).text(), proc.exited]), + new Promise((resolve) => + setTimeout(() => resolve(['', 124]), timeoutMs), + ), + ]); return code === 0 ? out : ''; } catch { return ''; } } -function defaultCheckLinkUp(ifname: DeviceIfname): Promise { - return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)]); +function defaultCheckLinkUp(ifname: DeviceIfname, timeoutMs: number): Promise { + return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)], timeoutMs); } -async function defaultResolveGateway(ifname: DeviceIfname): Promise { +async function defaultResolveGateway( + ifname: DeviceIfname, + timeoutMs: number, +): Promise { // `ip -o route show default dev ` → "default via 192.168.8.1 dev …". - const out = await spawnOutput(['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)]); + const out = await spawnOutput( + ['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)], + timeoutMs, + ); const match = out.match(/default via (\S+)/); return match?.[1]; } -function defaultPing(host: string, ifname: DeviceIfname): Promise { - return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host]); +function defaultPing(host: string, ifname: DeviceIfname, timeoutMs: number): Promise { + return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host], timeoutMs); } /** @@ -67,23 +122,46 @@ function defaultPing(host: string, ifname: DeviceIfname): Promise { */ export function createRouterEthernetProbe(deps: RouterEthernetProbeDeps = {}): RouterPort { const now = deps.now ?? ((): EpochMillis => epochMillis(Date.now())); - const checkLinkUp = deps.checkLinkUp ?? defaultCheckLinkUp; - const resolveGateway = deps.resolveGateway ?? defaultResolveGateway; - const ping = deps.ping ?? defaultPing; + const timeoutMs = deps.subprocessTimeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS; + const checkLinkUp = + deps.checkLinkUp ?? ((ifname: DeviceIfname) => defaultCheckLinkUp(ifname, timeoutMs)); + const resolveGateway = + deps.resolveGateway ?? ((ifname: DeviceIfname) => defaultResolveGateway(ifname, timeoutMs)); + const ping = + deps.ping ?? ((host: string, ifname: DeviceIfname) => defaultPing(host, ifname, timeoutMs)); + const probeHost = deps.probeHost ?? EGRESS_PROBE_HOST; async function probePresence(ifname: DeviceIfname): Promise { - return (await checkLinkUp(ifname).catch(() => false)) ? 'present' : 'absent'; + return (await bounded(checkLinkUp(ifname), timeoutMs).catch(() => false)) + ? 'present' + : 'absent'; } async function checkHealth(ifname: DeviceIfname): Promise { const presence = await probePresence(ifname); + const failureKinds: RouterProbeFailureKind[] = []; const gateway = - presence === 'present' ? await resolveGateway(ifname).catch(() => undefined) : undefined; + presence === 'present' + ? await bounded(resolveGateway(ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return undefined; + }) + : undefined; const gatewayReachable = - gateway !== undefined && (await ping(gateway, ifname).catch(() => false)); + gateway !== undefined && + (await bounded(ping(gateway, ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return false; + })); const egressHealthy = - gatewayReachable && (await ping(EGRESS_PROBE_HOST, ifname).catch(() => false)); - return { presence, gatewayReachable, egressHealthy, observedAt: now() }; + gatewayReachable && + (await bounded(ping(probeHost, ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return false; + })); + const health = { presence, gatewayReachable, egressHealthy, observedAt: now() }; + Object.defineProperty(health, 'failureKinds', { value: failureKinds, enumerable: true }); + return health; } return { probePresence, checkHealth }; diff --git a/control/src/backend/signal-setup-rate.test.ts b/control/src/backend/signal-setup-rate.test.ts new file mode 100644 index 0000000..58af0dd --- /dev/null +++ b/control/src/backend/signal-setup-rate.test.ts @@ -0,0 +1,101 @@ +// The `Signal.Setup` reporting rate is INJECTED, and 5s is only what an absent +// injection resolves to. +// +// These run without a bus on purpose: the epoch lifecycle is proven against the real +// fake MM service in `test-support/signal-setup.test.ts`, and what is asserted here is +// the ARGUMENT the call carries, which a recording transport answers exactly. The +// once-per-(epoch, modem) semantics are the conformance-scale pin and are untouched. + +import { describe, expect, test } from 'bun:test'; +import type { DbusTransport, MethodCall, MethodReply, SignalSpec } from '../transport'; +import { MODEM_IFACE } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { + DEFAULT_SIGNAL_INTERVAL_SECONDS, + resolveSignalInterval, + SignalSetupManager, +} from './signal-setup'; + +const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`; +const MODEM_PATH = '/org/freedesktop/ModemManager1/Modem/0'; + +const TREE: DecodedManagedObjects = [ + [ + MODEM_PATH, + [ + [MODEM_IFACE, []], + [SIGNAL_IFACE, []], + ], + ], +]; + +function recordingTransport(): { transport: DbusTransport; calls: MethodCall[] } { + const calls: MethodCall[] = []; + const transport: DbusTransport = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + isConnected: () => true, + callMethod: (call: MethodCall): Promise => { + calls.push(call); + return Promise.resolve({ signature: '', body: [] }); + }, + subscribeSignal: (_spec: SignalSpec) => + Promise.resolve({ unsubscribe: () => Promise.resolve() }), + on: () => {}, + off: () => {}, + subscriptionCount: () => 0, + }; + return { transport, calls }; +} + +async function setupArgs(intervalSeconds?: number): Promise { + const { transport, calls } = recordingTransport(); + const manager = new SignalSetupManager({ + transport, + ...(intervalSeconds === undefined ? {} : { intervalSeconds }), + }); + manager.applyForEpoch('epoch-1', TREE); + await Promise.resolve(); + const setup = calls.find((call) => call.member === 'Setup'); + if (setup === undefined) { + throw new Error('no Signal.Setup call was issued'); + } + return setup.args ?? []; +} + +describe('Signal.Setup rate injection', () => { + test('Given no injected rate, when setup runs, then the call carries the 5s default', async () => { + expect(DEFAULT_SIGNAL_INTERVAL_SECONDS).toBe(5); + expect(await setupArgs()).toEqual([5]); + }); + + test('Given an injected rate, when setup runs, then the call carries THAT rate', async () => { + expect(await setupArgs(1)).toEqual([1]); + expect(await setupArgs(30)).toEqual([30]); + }); + + test('Given an injected rate, when the manager is constructed, then it reports the rate it will send', () => { + const { transport } = recordingTransport(); + + expect(new SignalSetupManager({ transport }).intervalSeconds).toBe(5); + expect(new SignalSetupManager({ transport, intervalSeconds: 2 }).intervalSeconds).toBe(2); + }); + + test('Given a rate `Setup(u)` cannot carry, when resolved, then it is refused rather than marshalled', () => { + for (const rate of [0, -1, 2.5, Number.NaN]) { + expect(() => resolveSignalInterval(rate)).toThrow(RangeError); + } + expect(resolveSignalInterval(undefined)).toBe(DEFAULT_SIGNAL_INTERVAL_SECONDS); + }); + + test('Given one epoch, when applied twice, then Setup is issued ONCE per (epoch, modem)', async () => { + const { transport, calls } = recordingTransport(); + const manager = new SignalSetupManager({ transport, intervalSeconds: 3 }); + + manager.applyForEpoch('epoch-1', TREE); + manager.applyForEpoch('epoch-1', TREE); + await Promise.resolve(); + + expect(calls.filter((call) => call.member === 'Setup')).toHaveLength(1); + }); +}); diff --git a/control/src/backend/signal-setup.ts b/control/src/backend/signal-setup.ts index 3c26a96..c48892b 100644 --- a/control/src/backend/signal-setup.ts +++ b/control/src/backend/signal-setup.ts @@ -27,17 +27,45 @@ export type SignalCadence = 'active' | 'unsupported' | 'unknown'; /** The `Modem.Signal` interface — absent means signal cadence is unsupported. */ const SIGNAL_IFACE = 'org.freedesktop.ModemManager1.Modem.Signal'; -/** MM's default reporting rate is seconds; callers pass whole seconds. */ +/** + * MM's default reporting rate is seconds; callers pass whole seconds. + * + * It is a DEFAULT and not a constant: extended signal costs a modem-side poll per tick, + * so an embedding process running a bonded uplink may want it faster than a bench tool + * does. The rate is therefore injectable end to end — `SignalSetupManagerOptions`, + * `MmDbusBackendOptions.signalIntervalSeconds` and + * `ModemManagerProviderOptions.signalIntervalSeconds` are the same seam at three levels + * — and this value is what an absent injection resolves to, at every one of them. + */ export const DEFAULT_SIGNAL_INTERVAL_SECONDS = 5; export interface SignalSetupManagerOptions { readonly transport: DbusTransport; /** MM bus name override (defaults to `org.freedesktop.ModemManager1`). */ readonly destination?: string; - /** Reporting interval in seconds passed to `Signal.Setup`. */ + /** + * Reporting interval in seconds passed to `Signal.Setup`. + * + * `Setup` takes a `u`, so a fractional or negative rate has no wire representation + * and is refused HERE rather than marshalled into whatever the codec makes of it — + * a modem silently polling at the wrong cadence is a defect nothing downstream can + * see. An absent value is not a refusal; it takes the default. + */ readonly intervalSeconds?: number; } +export function resolveSignalInterval(intervalSeconds: number | undefined): number { + if (intervalSeconds === undefined) { + return DEFAULT_SIGNAL_INTERVAL_SECONDS; + } + if (!Number.isInteger(intervalSeconds) || intervalSeconds <= 0) { + throw new RangeError( + `Signal.Setup interval must be a positive whole number of seconds, got ${intervalSeconds}`, + ); + } + return intervalSeconds; +} + /** * Drives `Signal.Setup` across the modem fleet, keyed to the observer's epochs. Feed * it every `onEpochRefresh` event; it applies setup to each modem exactly once per @@ -55,7 +83,12 @@ export class SignalSetupManager { constructor(options: SignalSetupManagerOptions) { this.#transport = options.transport; this.#destination = options.destination ?? MM_BUS_NAME; - this.#interval = options.intervalSeconds ?? DEFAULT_SIGNAL_INTERVAL_SECONDS; + this.#interval = resolveSignalInterval(options.intervalSeconds); + } + + /** The rate every `Signal.Setup` on this manager carries — injected, or the default. */ + get intervalSeconds(): number { + return this.#interval; } /** The last-known cadence for a modem path (`'unknown'` until first applied). */ diff --git a/control/src/backend/usage/accounting.ts b/control/src/backend/usage/accounting.ts index bd8c03d..41f4a49 100644 --- a/control/src/backend/usage/accounting.ts +++ b/control/src/backend/usage/accounting.ts @@ -58,7 +58,13 @@ export function initialAccount(cycleStartMs: number): SlotAccount { return { cycleBytes: 0, cycleStartMs, paused: false }; } -function sameKey(a: BaselineKey, b: BaselineKey): boolean { +/** + * Whether two baseline keys name the SAME counter — a difference in any field is a + * remap. Exported because `sampling.ts` must ask the identical question before it + * divides a delta by an interval; two spellings of it would eventually disagree, and + * the rate is the half that would then be wrong silently. + */ +export function sameBaselineKey(a: BaselineKey, b: BaselineKey): boolean { return ( a.logicalSlotId === b.logicalSlotId && a.mappingGeneration === b.mappingGeneration && @@ -92,7 +98,7 @@ export function applySample(prior: SlotAccount | undefined, input: SampleInput): if (base.paused || base.key === undefined || base.lastObserved === undefined) { return { ...base, paused: false, key: input.key, lastObserved: input.current }; } - if (!sameKey(base.key, input.key)) { + if (!sameBaselineKey(base.key, input.key)) { return { ...base, paused: false, key: input.key, lastObserved: input.current }; } diff --git a/control/src/backend/usage/persistence.test.ts b/control/src/backend/usage/persistence.test.ts new file mode 100644 index 0000000..b4e8c6c --- /dev/null +++ b/control/src/backend/usage/persistence.test.ts @@ -0,0 +1,141 @@ +// What the persisted usage document carries — and the one thing it must not. +// +// The counter BASELINE resumes across a same-boot reload because a cumulative total is +// still true after a restart. A THROUGHPUT is not: it is a measurement over an interval +// whose two ends one process observed, and a restart observed neither. This suite pins +// that negative from both sides — the bytes on disk, and the behaviour after a reload. + +import { describe, expect, test } from 'bun:test'; +import { logicalSlotId } from '../../domain'; +import { persistedUsageState } from './persistence'; +import type { CounterSource } from './proc-net-dev'; +import { createUsageSampler, type UsageObservation } from './sampler'; +import { type PersistedUsage, USAGE_SCHEMA_VERSION, type UsageStore } from './store'; + +const SLOT_A = logicalSlotId('slot-a'); +const RATE_TOKENS = ['rate', 'bytesPerSecond', 'sampledAt', 'throughput']; + +class FakeCounters implements CounterSource { + readonly #map = new Map(); + set(ifname: string, value: number): void { + this.#map.set(ifname, value); + } + async read(): Promise> { + return new Map(this.#map); + } +} + +class MemStore implements UsageStore { + doc: PersistedUsage | null = null; + async load(bootId: string, nowMs: number): Promise { + return this.doc ?? { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; + } + async save(state: PersistedUsage): Promise { + this.doc = state; + } +} + +const obs = (ifname: string): UsageObservation => ({ + logicalSlotId: SLOT_A, + mappingGeneration: 0, + ifname, + confidence: 'high', + usage: {}, +}); + +describe('persisted usage state — no throughput reaches the disk', () => { + test('a slot that WAS measuring a rate serializes only the cumulative facts', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + let clock = 1_000_000; + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => clock, + }); + + counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + clock += 10_000; + counters.set('wwan0', 21_000); + await sampler.sample([obs('wwan0')]); + // Non-vacuity: there IS a live rate to leak at the moment of the flush. + expect(sampler.snapshot().slots[0]?.rateBytesPerSecond).toBe(2000); + + await sampler.flush(); + + const written = store.doc; + expect(written?.slots[0]?.cycleBytes).toBe(20_000); + expect(written?.slots[0]?.lastObserved).toBe(21_000); + const serialized = JSON.stringify(written); + for (const token of RATE_TOKENS) { + expect(serialized.toLowerCase()).not.toContain(token.toLowerCase()); + } + }); + + test('a same-boot reload resumes the baseline but restarts unmeasured', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + let clock = 1_000_000; + const options = { bootId: 'boot-1', source: counters, store, now: () => clock }; + + const first = await createUsageSampler(options); + counters.set('wwan0', 1000); + await first.sample([obs('wwan0')]); + clock += 10_000; + counters.set('wwan0', 21_000); + await first.sample([obs('wwan0')]); + await first.flush(); + + const reloaded = await createUsageSampler(options); + clock += 10_000; + counters.set('wwan0', 41_000); + await reloaded.sample([obs('wwan0')]); + + const resumed = reloaded.snapshot().slots[0]; + // The baseline survived: 41_000 - 21_000 is attributed, not the whole counter. + expect(resumed?.cycleBytes).toBe(40_000); + // The rate did not: this process never observed the start of that interval. + expect(resumed?.rateBytesPerSecond).toBeUndefined(); + + // And the pass after it — both ends now observed here — measures normally. + clock += 10_000; + counters.set('wwan0', 51_000); + await reloaded.sample([obs('wwan0')]); + expect(reloaded.snapshot().slots[0]?.rateBytesPerSecond).toBe(1000); + }); + + test('persistedUsageState emits no rate field even for a fully-populated account', () => { + const state = persistedUsageState( + 'boot-1', + 5000, + new Map([ + [ + 'slot-a', + { + cycleBytes: 42, + cycleStartMs: 1, + paused: false, + key: { + logicalSlotId: 'slot-a', + mappingGeneration: 0, + ifname: 'wwan0', + bootId: 'boot-1', + }, + lastObserved: 99, + }, + ], + ]), + ); + + expect(Object.keys(state.slots[0] ?? {}).sort()).toEqual([ + 'cycleBytes', + 'cycleStartMs', + 'ifname', + 'lastObserved', + 'logicalSlotId', + 'mappingGeneration', + ]); + }); +}); diff --git a/control/src/backend/usage/persistence.ts b/control/src/backend/usage/persistence.ts new file mode 100644 index 0000000..fa9f0aa --- /dev/null +++ b/control/src/backend/usage/persistence.ts @@ -0,0 +1,71 @@ +// The persisted usage document — what survives a restart, and what deliberately does not. +// +// THROUGHPUT IS NOT PERSISTED, and its absence from `PersistedSlot` is a decision +// rather than an omission. A rate is a measurement over an interval whose two ends this +// process observed; a restart observed neither. Writing the last rate down would +// republish a figure measured before the gap as though it described now, and writing +// the baseline's sample TIME down would invite the next sample to divide a whole +// downtime's bytes by one sampling interval — the same invented spike `sampling.ts` +// refuses for a missing interface. So the counter BASELINE resumes across a same-boot +// reload (that is a cumulative total, and it is still true) while the rate restarts +// unmeasured. `persistence.test.ts` pins the negative. + +import type { SlotAccount } from './accounting'; +import type { PersistedSlot, PersistedUsage } from './store'; +import { USAGE_SCHEMA_VERSION } from './store'; + +export function hydrateUsageAccounts( + initial: PersistedUsage, + bootId: string, +): Map { + const accounts = new Map(); + const sameBoot = initial.bootId === bootId; + for (const slot of initial.slots) { + const canResume = + sameBoot && + slot.ifname !== undefined && + slot.mappingGeneration !== undefined && + slot.lastObserved !== undefined; + if (canResume) { + accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + key: { + logicalSlotId: slot.logicalSlotId, + mappingGeneration: slot.mappingGeneration, + ifname: slot.ifname, + bootId, + }, + lastObserved: slot.lastObserved, + }); + } else { + accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + }); + } + } + return accounts; +} + +export function persistedUsageState( + bootId: string, + savedAtMs: number, + accounts: ReadonlyMap, +): PersistedUsage { + const slots: PersistedSlot[] = []; + for (const [logicalSlotId, account] of accounts) { + slots.push({ + logicalSlotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + ...(account.key !== undefined + ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname } + : {}), + ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}), + }); + } + return { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs, slots }; +} diff --git a/control/src/backend/usage/policy.ts b/control/src/backend/usage/policy.ts new file mode 100644 index 0000000..a773420 --- /dev/null +++ b/control/src/backend/usage/policy.ts @@ -0,0 +1,76 @@ +import type { DesiredUsage } from '../../domain'; +import { epochMillis } from '../../domain'; +import type { SlotAccount } from './accounting'; +import { initialAccount } from './accounting'; +import { cycleStart } from './billing-cycle'; +import type { SlotUsageSnapshot, UsageSnapshot } from './sampler'; +import type { SlotRate } from './sampling'; + +export interface UsagePolicyState { + readonly bootId: string; + readonly defaultCycleDay: number; + readonly accounts: Map; + readonly policies: Map; + readonly policyOverrides: Map; +} + +export interface UsagePolicyApplicationResult { + readonly cycleStartMs: number; + readonly cycleReset: boolean; + readonly dirty: boolean; +} + +export function applyPolicy( + state: UsagePolicyState, + logicalSlotId: string, + usage: DesiredUsage, + now: number, +): UsagePolicyApplicationResult { + state.policyOverrides.set(logicalSlotId, usage); + state.policies.set(logicalSlotId, usage); + const cycleStartMs = cycleStart( + epochMillis(now), + usage.cycleDay ?? state.defaultCycleDay, + ) as number; + const account = state.accounts.get(logicalSlotId); + if (account === undefined) { + state.accounts.set(logicalSlotId, initialAccount(cycleStartMs)); + return { cycleStartMs, cycleReset: false, dirty: true }; + } + if (account.cycleStartMs === cycleStartMs) { + return { cycleStartMs, cycleReset: false, dirty: false }; + } + state.accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs }); + return { cycleStartMs, cycleReset: true, dirty: true }; +} + +export interface UsageProjectionState { + readonly bootId: string; + readonly accounts: ReadonlyMap; + readonly policies: ReadonlyMap; + readonly rates: ReadonlyMap; +} + +export function projectUsageSnapshot( + state: UsageProjectionState, + generatedAtMs: number, +): UsageSnapshot { + const slots: SlotUsageSnapshot[] = []; + for (const [slotId, account] of state.accounts) { + const policy = state.policies.get(slotId); + const thresholdBytes = policy?.thresholdBytes; + // An unmeasured interval OMITS the key rather than reporting 0 — see `SlotRate`. + const bytesPerSecond = state.rates.get(slotId)?.bytesPerSecond; + slots.push({ + logicalSlotId: slotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + paused: account.paused, + ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}), + ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), + thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, + ...(bytesPerSecond !== undefined ? { rateBytesPerSecond: bytesPerSecond } : {}), + }); + } + return { bootId: state.bootId, generatedAtMs, slots }; +} diff --git a/control/src/backend/usage/sampler.ts b/control/src/backend/usage/sampler.ts index 3ef9dad..8b526fa 100644 --- a/control/src/backend/usage/sampler.ts +++ b/control/src/backend/usage/sampler.ts @@ -11,12 +11,13 @@ // deltas (the window since the last rate-limited write); a clean shutdown calls // `flush()` and loses effectively nothing. -import { type DesiredUsage, epochMillis, type LogicalSlotId } from '../../domain'; -import { applySample, type BaselineKey, initialAccount, type SlotAccount } from './accounting'; -import { cycleStart } from './billing-cycle'; +import type { DesiredUsage, LogicalSlotId } from '../../domain'; +import type { SlotAccount } from './accounting'; +import { hydrateUsageAccounts, persistedUsageState } from './persistence'; +import { applyPolicy, projectUsageSnapshot } from './policy'; import type { CounterSource } from './proc-net-dev'; -import type { PersistedSlot, PersistedUsage, UsageStore } from './store'; -import { USAGE_SCHEMA_VERSION } from './store'; +import { applyUsageSamples, type SlotRate } from './sampling'; +import type { PersistedUsage, UsageStore } from './store'; /** One slot's observation for a sampling pass — identity + mapping + local policy. */ export interface UsageObservation { @@ -41,6 +42,12 @@ export interface SlotUsageSnapshot { readonly thresholdBytes?: number; /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */ readonly thresholdExceeded: boolean; + /** + * Throughput over the last measured sampling interval. ABSENT — never 0 — when + * this pass had no interval to measure: a first sample, a rebaseline, a paused + * slot, a missing interface, or a counter that went BACKWARDS. See `SlotRate`. + */ + readonly rateBytesPerSecond?: number; } /** The sampler's current state, per slot, at a point in time. */ @@ -66,18 +73,6 @@ export interface UsageSamplerOptions { const DEFAULT_PERSIST_INTERVAL_MS = 60_000; const DEFAULT_CYCLE_DAY = 1; -function toPersistedSlot(logicalSlotId: string, account: SlotAccount): PersistedSlot { - return { - logicalSlotId, - cycleBytes: account.cycleBytes, - cycleStartMs: account.cycleStartMs, - ...(account.key !== undefined - ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname } - : {}), - ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}), - }; -} - export class UsageSampler { readonly #bootId: string; readonly #source: CounterSource; @@ -85,7 +80,7 @@ export class UsageSampler { readonly #now: () => number; readonly #persistIntervalMs: number; readonly #defaultCycleDay: number; - readonly #accounts = new Map(); + readonly #accounts: Map; readonly #policies = new Map(); // Policies written through `applyUsagePolicy` OUTRANK whatever an observation // carries, for the life of the process. Without this, the next `sample()` would @@ -94,6 +89,10 @@ export class UsageSampler { // revert. The durable store is the source of truth for both, so an override and // an observation can only ever disagree inside that window. readonly #policyOverrides = new Map(); + // Rates are IN-MEMORY ONLY and start empty on every construction — deliberately, + // see `persistence.ts`. A throughput is a measurement over an interval this + // process observed both ends of; a restart has observed neither. + readonly #rates = new Map(); #lastPersistMs: number; #dirty = false; @@ -105,7 +104,7 @@ export class UsageSampler { this.#persistIntervalMs = options.persistIntervalMs ?? DEFAULT_PERSIST_INTERVAL_MS; this.#defaultCycleDay = options.defaultCycleDay ?? DEFAULT_CYCLE_DAY; this.#lastPersistMs = this.#now(); - this.#hydrate(initial); + this.#accounts = hydrateUsageAccounts(initial, this.#bootId); } /** Load persisted state (recreating a fresh file if absent/corrupt) then build the sampler. */ @@ -115,93 +114,38 @@ export class UsageSampler { return new UsageSampler(options, initial); } - /** Rebuild in-memory accounts. A reboot (differing boot id) drops the baselines. */ - #hydrate(initial: PersistedUsage): void { - const sameBoot = initial.bootId === this.#bootId; - for (const slot of initial.slots) { - const canResume = - sameBoot && - slot.ifname !== undefined && - slot.mappingGeneration !== undefined && - slot.lastObserved !== undefined; - if (canResume) { - const key: BaselineKey = { - logicalSlotId: slot.logicalSlotId, - mappingGeneration: slot.mappingGeneration as number, - ifname: slot.ifname as string, - bootId: this.#bootId, - }; - this.#accounts.set(slot.logicalSlotId, { - cycleBytes: slot.cycleBytes, - cycleStartMs: slot.cycleStartMs, - paused: false, - key, - lastObserved: slot.lastObserved as number, - }); - } else { - this.#accounts.set(slot.logicalSlotId, { - cycleBytes: slot.cycleBytes, - cycleStartMs: slot.cycleStartMs, - paused: false, - }); - } - } - } - /** Take one sampling pass over the current counters for the given observations. */ async sample(observations: readonly UsageObservation[]): Promise { const counters = await this.#source.read(); const now = this.#now(); - for (const obs of observations) { - const slotId = obs.logicalSlotId as string; - const usage = this.#policyOverrides.get(slotId) ?? obs.usage; - this.#policies.set(slotId, usage); - const cycleDay = usage.cycleDay ?? this.#defaultCycleDay; - const cycleStartMs = cycleStart(epochMillis(now), cycleDay); - const current = counters.get(obs.ifname); - if (current === undefined) { - // No reading for this interface — ensure the slot exists, attribute nothing. - if (!this.#accounts.has(slotId)) { - this.#accounts.set(slotId, initialAccount(cycleStartMs)); - } - continue; - } - const key: BaselineKey = { - logicalSlotId: slotId, - mappingGeneration: obs.mappingGeneration, - ifname: obs.ifname, + applyUsageSamples( + { bootId: this.#bootId, - }; - const next = applySample(this.#accounts.get(slotId), { - key, - current, - confidence: obs.confidence, - cycleStartMs, - }); - this.#accounts.set(slotId, next); - } + defaultCycleDay: this.#defaultCycleDay, + accounts: this.#accounts, + policies: this.#policies, + policyOverrides: this.#policyOverrides, + rates: this.#rates, + }, + observations, + counters, + now, + ); this.#dirty = true; await this.#maybePersist(now); } /** Current per-slot usage — the queryable snapshot the CLI and platform read. */ snapshot(): UsageSnapshot { - const generatedAtMs = this.#now(); - const slots: SlotUsageSnapshot[] = []; - for (const [slotId, account] of this.#accounts) { - const policy = this.#policies.get(slotId); - const thresholdBytes = policy?.thresholdBytes; - slots.push({ - logicalSlotId: slotId, - cycleBytes: account.cycleBytes, - cycleStartMs: account.cycleStartMs, - paused: account.paused, - ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}), - ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), - thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, - }); - } - return { bootId: this.#bootId, generatedAtMs, slots }; + return projectUsageSnapshot( + { + bootId: this.#bootId, + accounts: this.#accounts, + policies: this.#policies, + rates: this.#rates, + }, + this.#now(), + ); } /** @@ -225,25 +169,20 @@ export class UsageSampler { cycleStartMs: number; cycleReset: boolean; } { - const now = atMs ?? this.#now(); - this.#policyOverrides.set(logicalSlotId, usage); - this.#policies.set(logicalSlotId, usage); - const cycleStartMs = cycleStart( - epochMillis(now), - usage.cycleDay ?? this.#defaultCycleDay, - ) as number; - const account = this.#accounts.get(logicalSlotId); - if (account === undefined) { - this.#accounts.set(logicalSlotId, initialAccount(cycleStartMs)); - this.#dirty = true; - return { cycleStartMs, cycleReset: false }; - } - if (account.cycleStartMs === cycleStartMs) { - return { cycleStartMs, cycleReset: false }; - } - this.#accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs }); - this.#dirty = true; - return { cycleStartMs, cycleReset: true }; + const result = applyPolicy( + { + bootId: this.#bootId, + defaultCycleDay: this.#defaultCycleDay, + accounts: this.#accounts, + policies: this.#policies, + policyOverrides: this.#policyOverrides, + }, + logicalSlotId, + usage, + atMs ?? this.#now(), + ); + this.#dirty ||= result.dirty; + return { cycleStartMs: result.cycleStartMs, cycleReset: result.cycleReset }; } /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */ @@ -260,16 +199,7 @@ export class UsageSampler { } async #persist(now: number): Promise { - const slots: PersistedSlot[] = []; - for (const [slotId, account] of this.#accounts) { - slots.push(toPersistedSlot(slotId, account)); - } - const state: PersistedUsage = { - schemaVersion: USAGE_SCHEMA_VERSION, - bootId: this.#bootId, - savedAtMs: now, - slots, - }; + const state = persistedUsageState(this.#bootId, now, this.#accounts); await this.#store.save(state); this.#lastPersistMs = now; this.#dirty = false; diff --git a/control/src/backend/usage/sampling.test.ts b/control/src/backend/usage/sampling.test.ts new file mode 100644 index 0000000..88846d8 --- /dev/null +++ b/control/src/backend/usage/sampling.test.ts @@ -0,0 +1,218 @@ +// Counter-reset-aware throughput — the rules that decide when a rate exists at all. +// +// Driven through the real `UsageSampler` rather than against `measureRate` directly, +// because the property under test spans three modules: `sampling.ts` decides there is +// no interval, `accounting.ts` rebases the baseline in the same pass, and `policy.ts` +// omits the key from the projected snapshot. A unit test of any one of them would pass +// while an operator still saw an invented spike. + +import { describe, expect, test } from 'bun:test'; +import { type DesiredUsage, logicalSlotId } from '../../domain'; +import type { CounterSource } from './proc-net-dev'; +import { createUsageSampler, type UsageObservation, type UsageSnapshot } from './sampler'; +import { type PersistedUsage, USAGE_SCHEMA_VERSION, type UsageStore } from './store'; + +const SLOT_A = logicalSlotId('slot-a'); + +class FakeCounters implements CounterSource { + readonly #map = new Map(); + set(ifname: string, value: number): void { + this.#map.set(ifname, value); + } + clear(ifname: string): void { + this.#map.delete(ifname); + } + async read(): Promise> { + return new Map(this.#map); + } +} + +class MemStore implements UsageStore { + doc: PersistedUsage | null = null; + async load(bootId: string, nowMs: number): Promise { + return this.doc ?? { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; + } + async save(state: PersistedUsage): Promise { + this.doc = state; + } +} + +function obs( + ifname: string, + confidence: 'high' | 'medium' | 'low' = 'high', + mappingGeneration = 0, + usage: DesiredUsage = {}, +): UsageObservation { + return { logicalSlotId: SLOT_A, mappingGeneration, ifname, confidence, usage }; +} + +function harness(store: UsageStore = new MemStore()) { + const counters = new FakeCounters(); + let clock = 1_000_000; + const build = () => + createUsageSampler({ bootId: 'boot-1', source: counters, store, now: () => clock }); + return { + counters, + store, + build, + advance: (ms: number) => { + clock += ms; + }, + at: () => clock, + }; +} + +const slot = (snapshot: UsageSnapshot) => snapshot.slots[0]; + +describe('usage throughput — a measured interval produces a rate', () => { + test('a steady climb over a known interval reports bytes per second', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 21_000); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(2000); + }); + + test('the FIRST sample reports no rate — a rebaseline is not a measurement', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + + const first = slot(sampler.snapshot()); + expect(first?.rateBytesPerSecond).toBeUndefined(); + expect(first?.cycleBytes).toBe(0); + }); + + test('a clock that did not advance yields no rate rather than Infinity', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.counters.set('wwan0', 5000); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); +}); + +describe('usage throughput — A COUNTER RESET (the rollback fixture)', () => { + test('a backwards counter reports NO rate, and the next interval is measured correctly', async () => { + const h = harness(); + const sampler = await h.build(); + + // Two honest intervals first, so the assertions below cannot pass vacuously. + h.counters.set('wwan0', 1_000_000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 1_050_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(5000); + expect(slot(sampler.snapshot())?.cycleBytes).toBe(50_000); + + // THE RESET: the interface was re-created and its counter restarted near zero. + h.advance(10_000); + h.counters.set('wwan0', 4000); + await sampler.sample([obs('wwan0')]); + + const reset = slot(sampler.snapshot()); + // No rate at all — not a negative one, and not 4000/10s either, which would + // report the whole post-reset total as if it had moved in this one interval. + expect(reset?.rateBytesPerSecond).toBeUndefined(); + // The cycle total is untouched: the reset attributed nothing. + expect(reset?.cycleBytes).toBe(50_000); + + // THE REBASELINE IS THE OTHER HALF. The next interval must be measured from + // the post-reset value (4000), never from the stale pre-reset one. + h.advance(10_000); + h.counters.set('wwan0', 34_000); + await sampler.sample([obs('wwan0')]); + + const recovered = slot(sampler.snapshot()); + expect(recovered?.rateBytesPerSecond).toBe(3000); + expect(recovered?.cycleBytes).toBe(80_000); + }); + + test('a counter resetting to exactly zero is still a reset, not a full-total rate', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 900_000); + await sampler.sample([obs('wwan0')]); + h.advance(5000); + h.counters.set('wwan0', 0); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); +}); + +describe('usage throughput — every other unmeasurable interval', () => { + test('an ambiguous-identity pause reports no rate, and neither does the resume', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + h.counters.set('wwan0', 99_000); + await sampler.sample([obs('wwan0', 'low')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + // The resume is a zero-delta rebaseline, so it has no interval either. + h.advance(10_000); + h.counters.set('wwan0', 100_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); + + test('a remap reports no rate — the two counters are not one counter', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + await sampler.sample([obs('wwan0', 'high', 1)]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); + + test('an interface missing from the counter table costs the RETURN sample its rate too', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + h.counters.clear('wwan0'); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + // Whatever moved while the interface was gone did not move inside ONE + // interval, so the pass that finds it again still reports nothing. + h.advance(10_000); + h.counters.set('wwan0', 500_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + h.advance(10_000); + h.counters.set('wwan0', 510_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(1000); + }); +}); diff --git a/control/src/backend/usage/sampling.ts b/control/src/backend/usage/sampling.ts new file mode 100644 index 0000000..c6613a4 --- /dev/null +++ b/control/src/backend/usage/sampling.ts @@ -0,0 +1,136 @@ +import type { DesiredUsage } from '../../domain'; +import { epochMillis } from '../../domain'; +import { + applySample, + type BaselineKey, + initialAccount, + type SlotAccount, + sameBaselineKey, +} from './accounting'; +import { cycleStart } from './billing-cycle'; +import type { UsageObservation } from './sampler'; + +/** + * One slot's throughput measurement. + * + * `sampledAtMs` is when the counter behind the current baseline was read; without it + * there is no interval to divide a delta by. `bytesPerSecond` is ABSENT — never zero — + * whenever this pass had no measurable interval, and that is a frequent, real answer: + * the first sample, a rebaseline, a paused slot, an interface missing from the counter + * table, and a counter that went BACKWARDS each produce one. + * + * The backwards case is the one worth spelling out. `/proc/net/dev` counters are + * per-interface and cumulative, and they restart at zero when the interface is + * re-created — a modem replug, a `wwan0` teardown, a driver reload. Subtracting across + * that boundary yields a negative number, and both obvious repairs report something + * untrue: clamping to zero shows an idle link that was in fact carrying traffic, while + * taking the raw post-reset value shows every byte since the interface came up as if it + * had all moved inside one sampling interval. Reporting NOTHING is the honest answer, + * and `applySample` rebases the baseline in the same pass so the NEXT interval measures + * correctly rather than inheriting the gap. + * + * Idea provenance: `irlserver/modem-metrics` (MIT) — concepts adopted, no source code + * copied. See `docs/adr/ADR-STAY-TYPESCRIPT.md`. + */ +export interface SlotRate { + readonly sampledAtMs: number; + readonly bytesPerSecond?: number; +} + +export interface UsageSamplingState { + readonly bootId: string; + readonly defaultCycleDay: number; + readonly accounts: Map; + readonly policies: Map; + readonly policyOverrides: ReadonlyMap; + readonly rates: Map; +} + +interface RateInput { + readonly key: BaselineKey; + readonly current: number; + readonly confidence: UsageObservation['confidence']; +} + +/** + * Measure one interval, or state that there was none. Pure — the caller supplies the + * prior account, the prior rate sample and `now`. Every early return is a case where a + * number could be produced but would not be a measurement; see `SlotRate`. + */ +function measureRate( + prior: SlotAccount | undefined, + priorRate: SlotRate | undefined, + input: RateInput, + now: number, +): SlotRate { + const unmeasured: SlotRate = { sampledAtMs: now }; + if (input.confidence === 'low') { + return unmeasured; + } + if ( + prior === undefined || + prior.paused || + prior.key === undefined || + prior.lastObserved === undefined + ) { + return unmeasured; + } + if (!sameBaselineKey(prior.key, input.key)) { + return unmeasured; + } + // No prior rate sample means no interval was ever measured under this baseline — + // it was restored from disk, or the interface was absent from the last pass. The + // elapsed wall time is then a gap, not a sampling interval. + if (priorRate === undefined) { + return unmeasured; + } + if (input.current < prior.lastObserved) { + return unmeasured; + } + const elapsedMs = now - priorRate.sampledAtMs; + if (elapsedMs <= 0) { + return unmeasured; + } + return { + sampledAtMs: now, + bytesPerSecond: ((input.current - prior.lastObserved) * 1000) / elapsedMs, + }; +} + +export function applyUsageSamples( + state: UsageSamplingState, + observations: readonly UsageObservation[], + counters: ReadonlyMap, + now: number, +): void { + for (const observation of observations) { + const slotId = observation.logicalSlotId as string; + const usage = state.policyOverrides.get(slotId) ?? observation.usage; + state.policies.set(slotId, usage); + const cycleDay = usage.cycleDay ?? state.defaultCycleDay; + const cycleStartMs = cycleStart(epochMillis(now), cycleDay); + const current = counters.get(observation.ifname); + if (current === undefined) { + if (!state.accounts.has(slotId)) { + state.accounts.set(slotId, initialAccount(cycleStartMs)); + } + // The counter was not readable this pass. Dropping the rate sample costs + // the NEXT pass its rate too, which is the point: whatever moved while the + // interface was missing did not move inside one sampling interval, and + // dividing it by one would render an invented spike. + state.rates.delete(slotId); + continue; + } + const key: BaselineKey = { + logicalSlotId: slotId, + mappingGeneration: observation.mappingGeneration, + ifname: observation.ifname, + bootId: state.bootId, + }; + const prior = state.accounts.get(slotId); + const rateInput: RateInput = { key, current, confidence: observation.confidence }; + state.rates.set(slotId, measureRate(prior, state.rates.get(slotId), rateInput, now)); + const next = applySample(prior, { ...rateInput, cycleStartMs }); + state.accounts.set(slotId, next); + } +} diff --git a/control/src/backend/usb-mode-transition.ts b/control/src/backend/usb-mode-transition.ts index 616ad68..48616a7 100644 --- a/control/src/backend/usb-mode-transition.ts +++ b/control/src/backend/usb-mode-transition.ts @@ -22,29 +22,21 @@ import type { DeviceIfname, InhibitLease, ModemManagerPort, NetworkManagerPort } from '../ports'; import { deviceIfname } from '../ports'; -import { - CERTIFIED_CATALOG, - type CertifiedCatalog, - readRuntimeCompositionCurrent, -} from '../usb-mode'; -import { - type AtAuditSink, - AtCommandLease, - type AtCommandSender, - computeAtAllowlist, -} from './at-lease'; -import { descriptorsMatch, detectUsbMode, type UsbDeviceSnapshot } from './device-classifier'; +import { CERTIFIED_CATALOG, type CertifiedCatalog } from '../usb-mode'; +import type { AtAuditSink, AtCommandSender } from './at-lease'; +import type { UsbDeviceSnapshot } from './device-classifier'; import type { ModemActor } from './modem-actor'; import { ALLOW_ALL_TRANSITION_INTERLOCK, - checkTransitionPreconditions, type TransitionInterlock, type UsbModeTransitionOutcome, type UsbModeTransitionPlan, type UsbModeTransitionRequest, } from './transition-preconditions'; - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +import { checkTransitionAdmission, releaseInhibit } from './usb-mode-transition/admission'; +import { createTransitionAtLease } from './usb-mode-transition/at'; +import { transitionPostconditionFailure } from './usb-mode-transition/outcome'; +import { ReenumerationWaiter } from './usb-mode-transition/reenumeration'; const DEFAULT_WATCHDOG_MS = 30_000; const DEFAULT_REENUM_TIMEOUT_MS = 60_000; @@ -87,6 +79,7 @@ export class UsbModeTransition { readonly #watchdogMs: number; readonly #reenumMs: number; readonly #pollMs: number; + readonly #waiter: ReenumerationWaiter; constructor(deps: UsbModeTransitionDeps) { this.#actor = deps.actor; @@ -101,13 +94,14 @@ export class UsbModeTransition { this.#watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS; this.#reenumMs = deps.reenumerationTimeoutMs ?? DEFAULT_REENUM_TIMEOUT_MS; this.#pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.#waiter = new ReenumerationWaiter(this.#enumerate, this.#reenumMs, this.#pollMs); } /** Run one transition. Preconditions are checked at entry, then again in-actor. */ async execute(request: UsbModeTransitionRequest): Promise { const steps: string[] = []; // ENTRY check — a doomed request NEVER enters the actor (TIER A: zero calls). - const entry = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + const entry = await checkTransitionAdmission(request, this.#catalog, this.#interlock); if (!entry.ok) { return { status: 'refused', stage: 'entry', reason: entry.reason, steps }; } @@ -120,7 +114,7 @@ export class UsbModeTransition { ): Promise { steps.push('actor-enter'); // IN-ACTOR re-check — catches a race that closed a gate while queued (TIER B). - const recheck = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + const recheck = await checkTransitionAdmission(request, this.#catalog, this.#interlock); if (!recheck.ok) { return { status: 'refused', stage: 'in-actor', reason: recheck.reason, steps }; } @@ -142,19 +136,20 @@ export class UsbModeTransition { let inhibit: InhibitLease | undefined; let reactivated = false; const forceUninhibit = async (): Promise => { - if (inhibit === undefined) { - return; - } const held = inhibit; inhibit = undefined; - steps.push('force-uninhibit'); - await this.#modemManager.uninhibit(held).catch(() => undefined); + await releaseInhibit(this.#modemManager, held, steps); }; - const lease = new AtCommandLease({ + const lease = createTransitionAtLease({ sender: this.#atSender, - allowlist: computeAtAllowlist(allowlistedCommands), + allowlistedCommands, timeoutMs: this.#watchdogMs, - onWatchdog: forceUninhibit, + modemManager: this.#modemManager, + currentInhibit: () => inhibit, + clearInhibit: () => { + inhibit = undefined; + }, + steps, ...(this.#audit !== undefined ? { audit: this.#audit } : {}), }); @@ -174,7 +169,7 @@ export class UsbModeTransition { } steps.push('await-port-drop'); - await this.#awaitPortDrop(request.cachedPhysicalUid); + await this.#waiter.awaitPortDrop(request.cachedPhysicalUid); steps.push('uninhibit'); if (inhibit !== undefined) { @@ -184,34 +179,18 @@ export class UsbModeTransition { } steps.push('await-reenumeration'); - const device = await this.#awaitReenumeration(request.cachedPhysicalUid); + const device = await this.#waiter.awaitDevice(request.cachedPhysicalUid); steps.push('postcondition'); - if (plan.proof.tier === 'catalog-descriptors') { - const observedMode = detectUsbMode(device); - const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors); - if (observedMode !== plan.proof.transition.to || !descriptorsOk) { - return { - status: 'failed', - degraded: true, - reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`, - steps, - }; - } - } else { - steps.push('postcondition-runtime-read'); - const response = await lease.run(plan.proof.currentQuery, { - inhibitUid: request.inhibitUid, - }); - const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw); - if (!Object.is(observed, plan.proof.target)) { - return { - status: 'failed', - degraded: true, - reason: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`, - steps, - }; - } + const postconditionFailure = await transitionPostconditionFailure( + plan, + device, + lease, + request.inhibitUid, + steps, + ); + if (postconditionFailure !== undefined) { + return { status: 'failed', degraded: true, reason: postconditionFailure, steps }; } steps.push('resolve-ifname'); @@ -226,7 +205,7 @@ export class UsbModeTransition { return { status: 'succeeded', newIfname, steps }; } catch (error) { await forceUninhibit(); - await this.#reprobe(); + await this.#waiter.reprobe(); return { status: 'failed', degraded: true, @@ -243,37 +222,4 @@ export class UsbModeTransition { } } } - - async #awaitPortDrop(uid: string): Promise { - const deadline = Date.now() + this.#reenumMs; - while (Date.now() < deadline) { - const devices = await this.#enumerate(); - if (!devices.some((d) => d.physicalUid === uid)) { - return; - } - await sleep(this.#pollMs); - } - throw new Error(`control port did not drop within ${this.#reenumMs}ms (uid ${uid})`); - } - - async #awaitReenumeration(uid: string): Promise { - const deadline = Date.now() + this.#reenumMs; - while (Date.now() < deadline) { - const devices = await this.#enumerate(); - const device = devices.find((d) => d.physicalUid === uid); - if (device !== undefined) { - return device; - } - await sleep(this.#pollMs); - } - throw new Error(`device did not re-enumerate within ${this.#reenumMs}ms (uid ${uid})`); - } - - /** Best-effort state re-read after a crash — the transaction still fails degraded. */ - async #reprobe(): Promise { - await this.#enumerate().then( - () => undefined, - () => undefined, - ); - } } diff --git a/control/src/backend/usb-mode-transition/admission.ts b/control/src/backend/usb-mode-transition/admission.ts new file mode 100644 index 0000000..5f87672 --- /dev/null +++ b/control/src/backend/usb-mode-transition/admission.ts @@ -0,0 +1,28 @@ +import type { InhibitLease, ModemManagerPort } from '../../ports'; +import type { CertifiedCatalog } from '../../usb-mode'; +import { + checkTransitionPreconditions, + type TransitionInterlock, + type UsbModeTransitionRequest, +} from '../transition-preconditions'; + +export function checkTransitionAdmission( + request: UsbModeTransitionRequest, + catalog: CertifiedCatalog, + interlock: TransitionInterlock, +) { + return checkTransitionPreconditions(request, catalog, interlock); +} + +export async function releaseInhibit( + modemManager: Pick, + lease: InhibitLease | undefined, + steps: string[], +): Promise { + if (lease === undefined) { + return undefined; + } + steps.push('force-uninhibit'); + await modemManager.uninhibit(lease).catch(() => undefined); + return undefined; +} diff --git a/control/src/backend/usb-mode-transition/at.ts b/control/src/backend/usb-mode-transition/at.ts new file mode 100644 index 0000000..78657d6 --- /dev/null +++ b/control/src/backend/usb-mode-transition/at.ts @@ -0,0 +1,33 @@ +import type { InhibitLease, ModemManagerPort } from '../../ports'; +import { + type AtAuditSink, + AtCommandLease, + type AtCommandSender, + computeAtAllowlist, +} from '../at-lease'; +import { releaseInhibit } from './admission'; + +export interface TransitionAtLeaseOptions { + readonly sender: AtCommandSender; + readonly allowlistedCommands: readonly string[]; + readonly timeoutMs: number; + readonly modemManager: Pick; + readonly currentInhibit: () => InhibitLease | undefined; + readonly clearInhibit: () => void; + readonly steps: string[]; + readonly audit?: AtAuditSink; +} + +export function createTransitionAtLease(options: TransitionAtLeaseOptions): AtCommandLease { + return new AtCommandLease({ + sender: options.sender, + allowlist: computeAtAllowlist(options.allowlistedCommands), + timeoutMs: options.timeoutMs, + onWatchdog: async () => { + const held = options.currentInhibit(); + options.clearInhibit(); + await releaseInhibit(options.modemManager, held, options.steps); + }, + ...(options.audit !== undefined ? { audit: options.audit } : {}), + }); +} diff --git a/control/src/backend/usb-mode-transition/outcome.ts b/control/src/backend/usb-mode-transition/outcome.ts new file mode 100644 index 0000000..16c3a4e --- /dev/null +++ b/control/src/backend/usb-mode-transition/outcome.ts @@ -0,0 +1,26 @@ +import { readRuntimeCompositionCurrent } from '../../usb-mode'; +import type { AtCommandLease } from '../at-lease'; +import { descriptorsMatch, detectUsbMode, type UsbDeviceSnapshot } from '../device-classifier'; +import type { UsbModeTransitionPlan } from '../transition-preconditions'; + +export async function transitionPostconditionFailure( + plan: UsbModeTransitionPlan, + device: UsbDeviceSnapshot, + lease: AtCommandLease, + inhibitUid: string, + steps: string[], +): Promise { + if (plan.proof.tier === 'catalog-descriptors') { + const observedMode = detectUsbMode(device); + const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors); + return observedMode === plan.proof.transition.to && descriptorsOk + ? undefined + : `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`; + } + steps.push('postcondition-runtime-read'); + const response = await lease.run(plan.proof.currentQuery, { inhibitUid }); + const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw); + return Object.is(observed, plan.proof.target) + ? undefined + : `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`; +} diff --git a/control/src/backend/usb-mode-transition/reenumeration.ts b/control/src/backend/usb-mode-transition/reenumeration.ts new file mode 100644 index 0000000..c5aa302 --- /dev/null +++ b/control/src/backend/usb-mode-transition/reenumeration.ts @@ -0,0 +1,51 @@ +import type { UsbDeviceSnapshot } from '../device-classifier'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export class ReenumerationWaiter { + readonly #enumerate: () => Promise; + readonly #timeoutMs: number; + readonly #pollMs: number; + + constructor( + enumerate: () => Promise, + timeoutMs: number, + pollMs: number, + ) { + this.#enumerate = enumerate; + this.#timeoutMs = timeoutMs; + this.#pollMs = pollMs; + } + + async awaitPortDrop(uid: string): Promise { + const deadline = Date.now() + this.#timeoutMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + if (!devices.some((device) => device.physicalUid === uid)) { + return; + } + await sleep(this.#pollMs); + } + throw new Error(`control port did not drop within ${this.#timeoutMs}ms (uid ${uid})`); + } + + async awaitDevice(uid: string): Promise { + const deadline = Date.now() + this.#timeoutMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + const device = devices.find((candidate) => candidate.physicalUid === uid); + if (device !== undefined) { + return device; + } + await sleep(this.#pollMs); + } + throw new Error(`device did not re-enumerate within ${this.#timeoutMs}ms (uid ${uid})`); + } + + async reprobe(): Promise { + await this.#enumerate().then( + () => undefined, + () => undefined, + ); + } +} diff --git a/control/src/domain/mm-enums.ts b/control/src/domain/mm-enums.ts index 9c33722..4cc1576 100644 --- a/control/src/domain/mm-enums.ts +++ b/control/src/domain/mm-enums.ts @@ -141,3 +141,61 @@ export function runtimeIdFromPath(path: string): number | undefined { const value = /\/(\d+)$/.exec(path)?.[1]; return value === undefined ? undefined : Number.parseInt(value, 10); } + +/** + * The coarse registration context ModemManager's `3gpp-lac-ci` location source reports. + * + * Every field is kept as the SOURCE'S OWN TEXT. `locationAreaCode` / `cellId` / + * `trackingAreaCode` arrive as uppercase hexadecimal and a consumer that parsed them to + * a decimal number would render a different identifier than every other tool on the + * device shows; `mnc` is two OR three digits and its width is significant, so a numeric + * round-trip loses a leading zero that distinguishes two real operators. + * + * This is COARSE CELL location and is deliberately NOT a GNSS fix: it names a cell, not + * a position. It carries no coordinate, so it is outside the GNSS redaction class and + * outside `GNSS_SOURCES`, and decoding it enables nothing — `Location.Setup`'s + * `signal_location` argument stays false and this decoder never touches a mask. + */ +export interface Mm3gppLacCi { + readonly mcc: string; + readonly mnc: string; + readonly locationAreaCode: string; + readonly cellId: string; + readonly trackingAreaCode: string; +} + +// ModemManager 1.24.2 `libmm-glib/mm-location-3gpp.c` builds this value with +// g_strdup_printf ("%.3s,%s,%lX,%lX,%lX", operator_code, operator_code + 3, lac, ci, tac) +// so it is a STRING of exactly five comma-separated tokens — MCC, MNC, then LAC, CI and +// TAC in uppercase hex. Its own parser reads exactly `split[0]`..`split[4]`, so a value +// with a different token count is not a shape this build should guess at. +const LAC_CI_TOKENS = 5; + +/** + * Decode the `3gpp-lac-ci` location string, or `undefined` when it is not that shape. + * + * A token count other than five, or an empty MCC/MNC/CI, decodes to nothing rather than + * to a partially-populated record: a cell identifier that is wrong is worse than one + * that is missing, because only the second is visible as missing. + */ +export function decode3gppLacCi(value: string | undefined): Mm3gppLacCi | undefined { + if (value === undefined) return undefined; + const tokens = value.trim().split(','); + if (tokens.length !== LAC_CI_TOKENS) return undefined; + const [mcc, mnc, locationAreaCode, cellId, trackingAreaCode] = tokens.map((token) => + token.trim(), + ) as [string, string, string, string, string]; + if (mcc === '' || mnc === '' || cellId === '') return undefined; + return { mcc, mnc, locationAreaCode, cellId, trackingAreaCode }; +} + +/** + * The PLMN id (`Modem3gpp.OperatorCode`'s spelling) for a decoded `3gpp-lac-ci` value. + * + * MCC and MNC are concatenated with no separator and no padding — MM splits the operator + * code back apart at exactly three characters, so this is the inverse of its own + * serialization rather than a guess about the MNC's width. + */ +export function lacCiOperatorCode(decoded: Mm3gppLacCi): string { + return `${decoded.mcc}${decoded.mnc}`; +} diff --git a/control/src/fcc/coverage.test.ts b/control/src/fcc/coverage.test.ts index e49a1aa..6f4910d 100644 --- a/control/src/fcc/coverage.test.ts +++ b/control/src/fcc/coverage.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'bun:test'; import { fccUnlockRuntimeBinary, - fccUnlockVendorScript, isFccUnlockKey, MM_FCC_UNLOCK_COVERAGE, + MM_FCC_UNLOCK_SOURCE, MM_FCC_UNLOCK_VENDOR_SCRIPTS, normalizeVidPid, resolveFccUnlockCoverage, @@ -33,9 +33,23 @@ describe('the ModemManager 1.24.2 coverage catalog', () => { // The whole reason the key is :: one silicon vendor, three USB vendor // ids. A vendor-keyed rule would miss the HP and Dell rebrands entirely. it('Given Sierra silicon under three vendor ids, When resolved, Then all three map to the 1199 script', () => { - expect(fccUnlockVendorScript('1199:9079')).toBe('1199'); - expect(fccUnlockVendorScript('03f0:4e1d')).toBe('1199'); - expect(fccUnlockVendorScript('413c:81a3')).toBe('1199'); + expect( + Object.entries(MM_FCC_UNLOCK_COVERAGE).filter(([, script]) => script === '1199'), + ).toEqual([ + ['03f0:4e1d', '1199'], + ['1199:9079', '1199'], + ['413c:81a3', '1199'], + ['413c:81a8', '1199'], + ]); + }); + + it('Given the coverage mirror, When provenance is inspected, Then it names the pinned MM source and available-tier mapping', () => { + expect(MM_FCC_UNLOCK_SOURCE).toEqual({ + version: '1.24.2', + commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + path: 'data/dispatcher-fcc-unlock/meson.build', + installedTier: 'fcc-unlock.available.d', + }); }); it('Given a covered key, When its interpreter is asked, Then it names the packaged binary', () => { @@ -82,6 +96,10 @@ describe('resolveFccUnlockCoverage — three answers, none interchangeable', () expect(resolveFccUnlockCoverage('14c3', '4d75')).toBe('present'); }); + it('Given an unlisted but well-formed Sierra PID, When coverage is resolved, Then it is absent rather than guessed present', () => { + expect(resolveFccUnlockCoverage('1199', '90d3')).toBe('absent'); + }); + // A statement about the READ is not a statement about the DEVICE. Folding this // into `absent` would hide the module on hardware that may well be covered. it.each([ diff --git a/control/src/fcc/coverage.ts b/control/src/fcc/coverage.ts index 4b3542d..e9dc55a 100644 --- a/control/src/fcc/coverage.ts +++ b/control/src/fcc/coverage.ts @@ -28,6 +28,14 @@ export const MM_FCC_UNLOCK_VENDOR_SCRIPTS = { export type MmFccUnlockVendorScript = keyof typeof MM_FCC_UNLOCK_VENDOR_SCRIPTS; +/** Provenance for the exact available-tier mapping mirrored below. */ +export const MM_FCC_UNLOCK_SOURCE = { + version: '1.24.2', + commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + path: 'data/dispatcher-fcc-unlock/meson.build', + installedTier: 'fcc-unlock.available.d', +} as const; + /** The interpreters those scripts invoke, and the packages that provide them. */ export const MM_FCC_UNLOCK_RUNTIME_PACKAGES = { qmicli: 'libqmi-utils', diff --git a/control/src/hardware/router-parsers.ts b/control/src/hardware/router-parsers.ts index 97f334a..2d5e5c1 100644 --- a/control/src/hardware/router-parsers.ts +++ b/control/src/hardware/router-parsers.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseJsonWith } from '../json-boundary'; import { parseHilinkXmlValue } from './hilink-protocol'; export * from './hilink-protocol'; @@ -122,37 +123,11 @@ const ufiBodySchema = z.object({ }); function parseFlatRecord(body: string): Readonly> | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(flatRecordSchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(flatRecordSchema, body); } function parseUfiBody(body: string): z.infer | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(ufiBodySchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(ufiBodySchema, body); } export function parseHilinkSignal(input: { diff --git a/control/src/journal/journal-path-injection.test.ts b/control/src/journal/journal-path-injection.test.ts index 8604cb4..7d90d18 100644 --- a/control/src/journal/journal-path-injection.test.ts +++ b/control/src/journal/journal-path-injection.test.ts @@ -12,7 +12,7 @@ // count-literal gate and CeraUI's link-id authority gate.) import { describe, expect, test } from 'bun:test'; -import { readdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -40,9 +40,9 @@ const ABSOLUTE_PATH_LITERAL = /(['"`])\/[A-Za-z][\w./${}-]*\1/g; const CERAUI_PATH_TOKENS = /\/data\b|ceralive\/modem-mutations|CERALIVE_MODEM_MUTATION_DIR/; async function shippedSources(): Promise { - const names = (await readdir(JOURNAL_DIR)).filter( - (name) => name.endsWith('.ts') && !name.endsWith('.test.ts'), - ); + const names = ( + await Array.fromAsync(new Bun.Glob('**/*.ts').scan({ cwd: JOURNAL_DIR, onlyFiles: true })) + ).filter((name) => !name.endsWith('.test.ts')); return Promise.all( names.map(async (name) => [name, await readFile(join(JOURNAL_DIR, name), 'utf8')] as const), ); @@ -51,7 +51,8 @@ async function shippedSources(): Promise describe('no journal source hardcodes a path', () => { test('the gate actually has files to scan', async () => { const sources = await shippedSources(); - expect(sources.length).toBeGreaterThanOrEqual(5); + // Scanned 7 files before directory-glob widening. + expect(sources.length).toBeGreaterThanOrEqual(7); expect(sources.map(([name]) => name)).toContain('store.ts'); expect(sources.map(([name]) => name)).toContain('legacy-ceraui.ts'); }); diff --git a/control/src/json-boundary.ts b/control/src/json-boundary.ts new file mode 100644 index 0000000..cdc1e54 --- /dev/null +++ b/control/src/json-boundary.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +type JsonSchema = z.ZodType; + +export function parseJsonWith(schema: JsonSchema, body: string): T | undefined { + const parsed = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(schema) + .safeParse(body); + return parsed.success ? parsed.data : undefined; +} diff --git a/control/src/observations/model.ts b/control/src/observations/model.ts index 398d40e..f942677 100644 --- a/control/src/observations/model.ts +++ b/control/src/observations/model.ts @@ -30,6 +30,51 @@ export type NormalizedRadio = { readonly registration: NormalizedMetric; readonly accessTechnologies: NormalizedMetric; readonly modeLabel: NormalizedMetric; + /** + * The operator the modem is REGISTERED WITH right now — `Modem3gpp.OperatorName`. + * + * Deliberately NOT `Sim.OperatorName`, which is the HOME operator written into the + * SIM. The two agree on a home network and disagree the entire time a device is + * roaming, which is exactly when an operator is looking at this field. + */ + readonly operatorName: NormalizedMetric; + /** + * The registered operator's PLMN id (MCC+MNC) — `Modem3gpp.OperatorCode`. + * + * Kept as text, not a number: the MNC is two OR three digits and the width is + * significant, so `732101` and `73201` are different networks and a numeric + * round-trip would lose the leading zero that separates them. + */ + readonly operatorCode: NormalizedMetric; +}; + +/** + * Coarse registration context — WHICH CELL, not where the device is. + * + * This is the `3gpp-lac-ci` location source's output, and it is a different class of + * datum from a GNSS fix: it names a cell in the operator's network and carries no + * coordinate. That is why it lives here rather than behind the GPS module's privacy + * fence, why `3gpp-lac-ci` stays outside `GNSS_SOURCES`, and why nothing on this path + * enables a location source or flips `Location.Setup`'s `signal_location`. + * + * Both fields are the SOURCE'S OWN TEXT. ModemManager emits them as uppercase hex, the + * ZTE and UFI admin APIs emit their own vendor spellings, and no radix is common to all + * three — so parsing to a number here would render an identifier that matches nothing + * an operator sees in `mmcli` or in the vendor's own web UI. + * + * EARFCN IS ABSENT ON PURPOSE and cannot be added from these sources. ModemManager + * publishes no generic ARFCN anywhere on the `Modem`, `Modem3gpp` or `Location` + * interfaces (checked against 1.24.2's introspection, not recalled); the only place one + * appears is inside a PER-CELL `GetCellInfo` dict, under two DIFFERENT keys — `earfcn` + * for LTE (`libmm-glib/mm-cell-info-lte.c`) and `nrarfcn` for 5GNR + * (`libmm-glib/mm-cell-info-nr5g.c`). A single normalized slot would therefore have to + * either merge two different quantities or silently pick a RAT, so this model makes no + * ARFCN claim at all and `backend/cell-info.ts` keeps the per-cell reading where it + * belongs. + */ +export type NormalizedCell = { + readonly cellId: NormalizedMetric; + readonly tac: NormalizedMetric; }; /** @@ -92,6 +137,7 @@ export type NormalizedModemObservation = { readonly radio: NormalizedRadio; readonly signal: NormalizedSignal; readonly sim: NormalizedSim; + readonly cell: NormalizedCell; /** Everything the provider said, verbatim, plus what was and was not claimed. */ readonly diagnostics: ObservationDiagnostics; }; diff --git a/control/src/observations/normalization.test.ts b/control/src/observations/normalization.test.ts index a3f632b..22bc054 100644 --- a/control/src/observations/normalization.test.ts +++ b/control/src/observations/normalization.test.ts @@ -7,7 +7,9 @@ import { fixtureContext, HILINK_AUTH_EXPIRED_FIXTURE, HILINK_FIXTURE, + MM_EVDO_SIGNAL_FIXTURE, MM_FIXTURE, + MM_SIGNAL_SILENT_FIXTURE, UFI_AUTH_EXPIRED_FIXTURE, UFI_FIXTURE, ZTE_FIXTURE, @@ -15,7 +17,7 @@ import { } from '../../test-support/observation-fixtures'; import { REDACTED } from '../redact'; import { viewEnvelope } from './envelope'; -import type { NormalizedMetric } from './metric'; +import { metricUnknownClass, type NormalizedMetric } from './metric'; import type { NormalizedModemObservation } from './model'; import { redactObservationDiagnostics } from './provenance'; import { normalizeHilinkObservation } from './sources/hilink'; @@ -107,14 +109,62 @@ describe('ModemManager normalization', () => { sourceEpoch: FIXTURE_SOURCE_EPOCH, observedAt: FIXTURE_OBSERVED_AT, authority: 'authoritative', - rawFields: ['Signal.rsrp'], + rawFields: ['Signal.Nr5g.rsrp'], }); }); + const EVDO = observation(normalizeModemManagerObservation(MM_EVDO_SIGNAL_FIXTURE, CONTEXT)); + const extended = [ + ['rsrp', MM.signal.rsrp, -98.5, 'Signal.Nr5g.rsrp'], + ['rsrq', MM.signal.rsrq, -11, 'Signal.Nr5g.rsrq'], + ['snr', MM.signal.snr, 6.5, 'Signal.Nr5g.snr'], + ['sinr', EVDO.signal.sinr, 9.5, 'Signal.Evdo.sinr'], + ] as const; + + test.each(extended.map(([name]) => name))( + 'Given a Signal RAT dict carrying %s, when normalized, then it is fresh, authoritative and field-attributed', + (name) => { + const entry = extended.find(([id]) => id === name); + if (entry === undefined) { + throw new Error(`missing extended metric ${name}`); + } + const [, metric, value, rawField] = entry; + + expect(metric).toMatchObject({ state: 'known', value }); + expect(metric.provenance.authority).toBe('authoritative'); + expect(metric.provenance.rawFields).toEqual([rawField]); + expect(metric.provenance.observedAt).toBe(FIXTURE_OBSERVED_AT); + }, + ); + + test('Given an NSA attach, when normalized, then the LTE anchor is retained rather than merged', () => { + expect(MM.diagnostics.raw['Signal.Lte']).toContainEqual(['rsrp', -104]); + expect(MM.diagnostics.consumed).toContain('Signal.Nr5g'); + }); + + test('Given every RAT dict empty, when normalized, then each metric is a READ-class unknown and never a zero', () => { + const silent = observation(normalizeModemManagerObservation(MM_SIGNAL_SILENT_FIXTURE, CONTEXT)); + + for (const metric of [ + silent.signal.dbm, + silent.signal.rsrp, + silent.signal.rsrq, + silent.signal.snr, + silent.signal.sinr, + ]) { + expect(metric).toMatchObject({ state: 'unknown', reason: 'not-reported' }); + expect(metric).not.toHaveProperty('value'); + } + }); + + test('Given an LTE/NR modem reporting no SINR, when normalized, then it is not-reported and NOT unsupported', () => { + expect(reason(MM.signal.sinr)).toBe('not-reported'); + expect(metricUnknownClass('not-reported')).toBe('read'); + }); + test('Given a metric ModemManager cannot express, when normalized, then it is a capability claim', () => { expect(reason(MM.signal.bars)).toBe('unsupported'); expect(reason(MM.signal.maxBars)).toBe('unsupported'); - expect(reason(MM.signal.sinr)).toBe('unsupported'); }); test('Given a field present but undecodable, when normalized, then it is malformed and noted', () => { @@ -252,7 +302,7 @@ describe('no raw vendor field is dropped during normalization', () => { ['modemmanager', MM, 'Modem.Ports', ['ttyUSB0', 'wwan0']], ['modemmanager', MM, 'Modem3gpp.Pco', 'dns-primary=10.0.0.1'], ['modemmanager', MM, 'Sim.OperatorName', 'CLARO COL'], - ['modemmanager', MM, 'Signal.refresh_rate', 5], + ['modemmanager', MM, 'Signal.Rate', 5], ['huawei-hilink', HILINK, 'monitoring-status.CurrentNetworkTypeEx', '101'], ['huawei-hilink', HILINK, 'device-signal.TotalDownload', '987654321'], ['huawei-hilink', HILINK, 'monitoring-status.ConnectionStatus', '901'], diff --git a/control/src/observations/raw.ts b/control/src/observations/raw.ts index 0226f37..e51e0f6 100644 --- a/control/src/observations/raw.ts +++ b/control/src/observations/raw.ts @@ -102,6 +102,44 @@ export function rawBooleanAt( return typeof member === 'boolean' ? member : undefined; } +/** + * One member of a D-Bus DICT (`a{sv}`) retained verbatim. + * + * A dict decodes to `[key, value][]`, so a retained `Modem.Signal.Lte` is a nested pair + * array rather than a flat scalar. Reading it by member name here — instead of flattening + * it at retention time — is what keeps `error-rate` and every future MM key in the + * diagnostics block while a metric claims only the member it names. + */ +export function rawDictMember( + record: RawFieldRecord, + key: string, + member: string, +): RawFieldValue | undefined { + const dict = record[key]; + if (!Array.isArray(dict)) return undefined; + for (const entry of dict) { + if (Array.isArray(entry) && entry.length >= 2 && entry[0] === member) return entry[1]; + } + return undefined; +} + +/** Whether a retained dict CARRIES a member — absent and undecodable stay separable. */ +export function hasRawDictMember(record: RawFieldRecord, key: string, member: string): boolean { + return rawDictMember(record, key, member) !== undefined; +} + +export function rawDictNumber( + record: RawFieldRecord, + key: string, + member: string, +): number | undefined { + const value = rawDictMember(record, key, member); + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value !== 'string' || value.trim() === '') return undefined; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + const XML_LEAF = /<([A-Za-z_][\w.-]*)>([^<]*)<\/\1>/g; /** diff --git a/control/src/observations/registration-context.test.ts b/control/src/observations/registration-context.test.ts new file mode 100644 index 0000000..6c3befc --- /dev/null +++ b/control/src/observations/registration-context.test.ts @@ -0,0 +1,216 @@ +// Registration + coarse-cell context — who we are attached to, and to which cell. +// +// Two confusions are what this suite exists to catch, because both produce a +// plausible-looking value rather than a visible failure: +// +// 1. `Sim.OperatorName` (the HOME operator burned into the SIM) standing in for +// `Modem3gpp.OperatorName` (the operator we are REGISTERED with). They agree on a +// home network and disagree for the whole time a device is roaming. +// 2. A partially-decoded `3gpp-lac-ci` string. It carries five tokens in one value, +// so a decoder that tolerated a short one would report a TAC read out of the cell +// id's position — a well-formed identifier naming the wrong thing. + +import { describe, expect, test } from 'bun:test'; +import { + fixtureContext, + HILINK_FIXTURE, + MM_FIXTURE, + UFI_FIXTURE, + ZTE_FIXTURE, +} from '../../test-support/observation-fixtures'; +import { decode3gppLacCi, lacCiOperatorCode } from '../domain'; +import { viewEnvelope } from './envelope'; +import { metricUnknownClass, type NormalizedMetric } from './metric'; +import type { NormalizedModemObservation } from './model'; +import { normalizeHilinkObservation } from './sources/hilink'; +import { + type ModemManagerObservationInput, + normalizeModemManagerObservation, +} from './sources/modemmanager'; +import { normalizeUfiObservation } from './sources/ufi'; +import { normalizeZteObservation } from './sources/zte'; + +const CONTEXT = fixtureContext(); + +function observation( + envelope: ReturnType, +): NormalizedModemObservation { + const view = viewEnvelope(envelope); + if (view.kind === 'unavailable') { + throw new Error('fixture normalization must not produce an unavailable envelope'); + } + return view.value; +} + +const mm = (input: ModemManagerObservationInput) => + observation(normalizeModemManagerObservation(input, CONTEXT)); + +function known(metric: NormalizedMetric): T { + if (metric.state !== 'known') { + throw new Error(`expected a known metric, got unknown:${metric.reason}`); + } + return metric.value; +} + +function unknownReason(metric: NormalizedMetric): string { + if (metric.state !== 'unknown') { + throw new Error(`expected an unknown metric, got known:${String(metric.value)}`); + } + return metric.reason; +} + +describe('decode3gppLacCi — MM 1.24.2 emits exactly five comma-separated tokens', () => { + test('the five tokens decode in MM\u2019s own order, as text', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F')).toEqual({ + mcc: '732', + mnc: '101', + locationAreaCode: '2B1C', + cellId: '0A1B2C3D', + trackingAreaCode: '4E5F', + }); + }); + + test('hex stays hex — a cell id is never reinterpreted as decimal', () => { + // `0A1B2C3D` parsed as a number would render 169552957, which matches nothing + // `mmcli` or a vendor UI shows for this cell. + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F')?.cellId).toBe('0A1B2C3D'); + }); + + test('a two-digit MNC keeps its width', () => { + expect(decode3gppLacCi('310,01,1A,2B,3C')?.mnc).toBe('01'); + expect( + lacCiOperatorCode({ + mcc: '310', + mnc: '01', + locationAreaCode: '1A', + cellId: '2B', + trackingAreaCode: '3C', + }), + ).toBe('31001'); + }); + + test('a token count other than five decodes to NOTHING, never a partial record', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D')).toBeUndefined(); + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F,extra')).toBeUndefined(); + expect(decode3gppLacCi('')).toBeUndefined(); + expect(decode3gppLacCi(undefined)).toBeUndefined(); + }); + + test('an empty MCC, MNC or cell id is not a decode', () => { + expect(decode3gppLacCi(',101,2B1C,0A1B2C3D,4E5F')).toBeUndefined(); + expect(decode3gppLacCi('732,,2B1C,0A1B2C3D,4E5F')).toBeUndefined(); + expect(decode3gppLacCi('732,101,2B1C,,4E5F')).toBeUndefined(); + }); + + test('an EMPTY tracking-area code still decodes — a 2G/3G attach has none', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,')?.trackingAreaCode).toBe(''); + }); +}); + +describe('ModemManager registration context', () => { + test('the operator comes from Modem3gpp, NOT from the SIM\u2019s home operator', () => { + const radio = mm(MM_FIXTURE).radio; + expect(known(radio.operatorName)).toBe('Claro'); + expect(known(radio.operatorCode)).toBe('732101'); + // The fixture's SIM carries a DIFFERENT name on purpose; reading the wrong + // interface would surface it here. + expect(MM_FIXTURE.sim?.OperatorName).toBe('CLARO COL'); + }); + + test('provenance names the exact Modem3gpp field each value came from', () => { + const radio = mm(MM_FIXTURE).radio; + expect( + radio.operatorName.state === 'known' ? radio.operatorName.provenance.rawFields : [], + ).toEqual(['Modem3gpp.OperatorName']); + expect( + radio.operatorCode.state === 'known' ? radio.operatorCode.provenance.rawFields : [], + ).toEqual(['Modem3gpp.OperatorCode']); + }); + + test('an unregistered modem reports not-reported, never an empty string', () => { + const radio = mm({ modem: MM_FIXTURE.modem ?? {}, modem3gpp: { OperatorName: '' } }).radio; + expect(unknownReason(radio.operatorName)).toBe('not-reported'); + expect(unknownReason(radio.operatorCode)).toBe('not-reported'); + // A READ-class answer, so a consumer keeps showing the field as pending rather + // than hiding it as something ModemManager cannot express. + expect(metricUnknownClass('not-reported')).toBe('read'); + }); +}); + +describe('ModemManager coarse-cell context (3gpp-lac-ci)', () => { + test('TAC and CID are claimed from the one location string', () => { + const cell = mm(MM_FIXTURE).cell; + expect(known(cell.cellId)).toBe('0A1B2C3D'); + expect(known(cell.tac)).toBe('4E5F'); + expect(cell.cellId.state === 'known' ? cell.cellId.provenance.rawFields : []).toEqual([ + 'Location.3gpp-lac-ci', + ]); + }); + + test('NO location body read is `not-observed` — nobody looked', () => { + const { location: _location, ...withoutLocation } = MM_FIXTURE; + const cell = mm(withoutLocation).cell; + expect(unknownReason(cell.cellId)).toBe('not-observed'); + expect(unknownReason(cell.tac)).toBe('not-observed'); + }); + + test('a location body carrying no 3gpp-lac-ci entry is `not-reported`', () => { + const cell = mm({ ...MM_FIXTURE, location: {} }).cell; + expect(unknownReason(cell.cellId)).toBe('not-reported'); + expect(unknownReason(cell.tac)).toBe('not-reported'); + }); + + test('a malformed value fails BOTH fields together, never one of them', () => { + const cell = mm({ ...MM_FIXTURE, location: { '3gpp-lac-ci': '732,101,2B1C' } }).cell; + expect(unknownReason(cell.cellId)).toBe('malformed'); + expect(unknownReason(cell.tac)).toBe('malformed'); + }); + + test('an empty TAC on an attach that has none is not-reported, and the CID still reads', () => { + const cell = mm({ ...MM_FIXTURE, location: { '3gpp-lac-ci': '732,101,2B1C,00A1B2,' } }).cell; + expect(known(cell.cellId)).toBe('00A1B2'); + expect(unknownReason(cell.tac)).toBe('not-reported'); + }); + + test('the whole Location property stays in the diagnostics block', () => { + const diagnostics = mm(MM_FIXTURE).diagnostics; + expect(diagnostics.raw['Location.3gpp-lac-ci']).toBe('732,101,2B1C,0A1B2C3D,4E5F'); + }); +}); + +describe('router sources claim only what a migrated parser decoded', () => { + test('ZTE claims the operator NAME and the cell id it already parses', () => { + const zte = observation(normalizeZteObservation(ZTE_FIXTURE, CONTEXT)); + expect(known(zte.radio.operatorName)).toBe('Movistar'); + expect(known(zte.cell.cellId)).toBe('0A1B2C'); + // `rmcc` + `rmnc` are in the payload, but joining an unpadded MNC would name a + // different network — so no code is derived. + expect(unknownReason(zte.radio.operatorCode)).toBe('not-reported'); + expect(zte.diagnostics.raw['goform.rmcc']).toBe('732'); + expect(zte.diagnostics.raw['goform.rmnc']).toBe('123'); + }); + + test('UFI claims its cell id and no operator', () => { + const ufi = observation(normalizeUfiObservation(UFI_FIXTURE, CONTEXT)); + expect(known(ufi.cell.cellId)).toBe('3344'); + expect(unknownReason(ufi.radio.operatorName)).toBe('not-reported'); + }); + + test('HiLink ships a raw that is retained but NOT claimed', () => { + const hilink = observation(normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)); + expect(unknownReason(hilink.cell.cellId)).toBe('not-reported'); + expect(hilink.diagnostics.raw['device-signal.cell_id']).toBe('12345678'); + expect(hilink.diagnostics.unmapped).toContain('device-signal.cell_id'); + }); + + test('no router source claims `unsupported` for TAC — that would be a source claim', () => { + for (const observed of [ + observation(normalizeZteObservation(ZTE_FIXTURE, CONTEXT)), + observation(normalizeUfiObservation(UFI_FIXTURE, CONTEXT)), + observation(normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)), + ]) { + expect(unknownReason(observed.cell.tac)).toBe('not-reported'); + expect(metricUnknownClass(unknownReason(observed.cell.tac) as 'not-reported')).toBe('read'); + } + }); +}); diff --git a/control/src/observations/sources/hilink.ts b/control/src/observations/sources/hilink.ts index 1b285cb..e7c9737 100644 --- a/control/src/observations/sources/hilink.ts +++ b/control/src/observations/sources/hilink.ts @@ -28,7 +28,9 @@ import { createObservationDiagnostics, type ObservationDiagnosticNote } from '.. import { flattenXmlBody, hasRawField, mergeRawRecords, rawKey } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -93,6 +95,9 @@ export function normalizeHilinkObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: normalizeModeLabel(capabilities.net_mode, provenance), + // The registered operator lives on `/api/net/current-plmn`, a body this + // source does not read and no migrated parser decodes. + ...routerOperator(provenance, undefined), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -112,6 +117,9 @@ export function normalizeHilinkObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([rawKey(SIGNAL, 'sinr')])), }, sim: routerSim(provenance, hasRawField(raw, SIM_STATUS) ? SIM_STATUS : undefined), + // HiLink's `` is a raw tag no migrated parser reads; it stays verbatim + // in the diagnostics block rather than being lifted into a claim. + cell: routerCell(provenance, undefined), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/src/observations/sources/modemmanager.ts b/control/src/observations/sources/modemmanager.ts index 50288d8..f7da392 100644 --- a/control/src/observations/sources/modemmanager.ts +++ b/control/src/observations/sources/modemmanager.ts @@ -10,6 +10,7 @@ // those three into one absent value is precisely the loss this layer exists to stop. import { + decode3gppLacCi, decodeEsimStatus, decodeMmAccessTechnologies, decodeMmState, @@ -27,7 +28,7 @@ import { import { readSimPresence, type SimPresenceFacts } from '../../hardware/router-parsers'; import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; -import type { NormalizedModemObservation, SimPresenceValue } from '../model'; +import type { NormalizedCell, NormalizedModemObservation, SimPresenceValue } from '../model'; import { createObservationDiagnostics, type ObservationDiagnosticNote, @@ -35,10 +36,12 @@ import { type RawFieldValue, } from '../provenance'; import { + hasRawDictMember, hasRawField, mergeRawRecords, prefixRawRecord, rawBooleanAt, + rawDictNumber, rawKey, rawNumber, rawNumberAt, @@ -60,12 +63,38 @@ export type ModemManagerObservationInput = { readonly modem3gpp?: Readonly>; readonly sim?: Readonly>; readonly signal?: Readonly>; + /** + * The `Modem.Location` reading, keyed by DECODED SOURCE NAME (`3gpp-lac-ci`). + * + * On the wire that property is an `a{uv}` keyed by the `MMModemLocationSource` BIT, + * and the bit-to-name vocabulary lives in `backend/mm-location.ts`. Naming the + * source here instead keeps this layer reading a flat named field like every other + * body, and keeps exactly one copy of that vocabulary. + * + * Supplying it enables nothing. A location SOURCE is switched on by + * `Location.Setup`, which this layer never calls; reading a value that is already + * being reported is a normalization step. `3gpp-lac-ci` in particular is coarse cell + * context rather than a GNSS fix, so it stays outside `GNSS_SOURCES` and the GNSS + * enable/disable path is untouched by it. + * + * NOTHING SUPPLIES IT TODAY, and the reason is the fence rather than an oversight. + * ModemManager masks the `Location` PROPERTY unless `Location.Setup` was called with + * `signal_location = true` — which broadcasts the value over `PropertiesChanged` and + * is therefore permanently forbidden here (`backend/mm-location.ts`). The value has + * to come from an explicit `GetLocation()` call instead, and the provider's snapshot + * path makes no such call. So an MM observation reads `not-observed` for `cell`: + * nobody looked, which is the honest answer and is distinct from a modem that + * reported nothing. Cell identity that IS wired today comes from `Modem.GetCellInfo` + * through `backend/cell-info.ts`, a different method that needs no location source. + */ + readonly location?: Readonly>; }; const MODEM = 'Modem'; const MODEM3GPP = 'Modem3gpp'; const SIM = 'Sim'; const SIGNAL = 'Signal'; +const LOCATION = 'Location'; export function normalizeModemManagerObservation( input: ModemManagerObservationInput, @@ -76,6 +105,7 @@ export function normalizeModemManagerObservation( prefixRawRecord(MODEM3GPP, input.modem3gpp), prefixRawRecord(SIM, input.sim), prefixRawRecord(SIGNAL, input.signal), + prefixRawRecord(LOCATION, input.location), ); const notes: ObservationDiagnosticNote[] = []; const consumed: string[] = []; @@ -84,10 +114,25 @@ export function normalizeModemManagerObservation( return metricProvenance(SOURCE, context, fields); }; + // A dict-sourced metric CONSUMES the whole `a{sv}` property (that is the raw key it + // actually has) while NAMING the exact member it read, so provenance stays precise + // without `unmapped` gaining a key that was never in the payload. + const dictProvenance = (dict: string, member: string) => { + consumed.push(dict); + return metricProvenance(SOURCE, context, [`${dict}.${member}`]); + }; + const hardware = normalizeHardware(raw, provenance); const radio = normalizeRadio(raw, provenance, notes); - const signal = normalizeSignal(raw, input.signal !== undefined, provenance); + const signal = normalizeSignal( + raw, + input.signal !== undefined, + provenance, + dictProvenance, + notes, + ); const sim = normalizeSim(raw, input.sim !== undefined, provenance, notes); + const cell = normalizeCell(raw, input.location !== undefined, provenance, notes); return freshObservation(SOURCE, context, { source: SOURCE, @@ -95,11 +140,13 @@ export function normalizeModemManagerObservation( radio, signal, sim, + cell, diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } type Provenance = (...fields: readonly string[]) => ReturnType; +type DictProvenance = (dict: string, member: string) => ReturnType; const MODEL = rawKey(MODEM, 'Model'); const MANUFACTURER = rawKey(MODEM, 'Manufacturer'); @@ -114,12 +161,71 @@ const SIM_SLOTS = rawKey(MODEM, 'SimSlots'); const FAILED_REASON = rawKey(MODEM, 'StateFailedReason'); const UNLOCK_REQUIRED = rawKey(MODEM, 'UnlockRequired'); const REGISTRATION_STATE = rawKey(MODEM3GPP, 'RegistrationState'); +const OPERATOR_NAME = rawKey(MODEM3GPP, 'OperatorName'); +const OPERATOR_CODE = rawKey(MODEM3GPP, 'OperatorCode'); const SIM_TYPE = rawKey(SIM, 'SimType'); const ESIM_STATUS = rawKey(SIM, 'EsimStatus'); -const RSSI = rawKey(SIGNAL, 'rssi'); -const RSRP = rawKey(SIGNAL, 'rsrp'); -const RSRQ = rawKey(SIGNAL, 'rsrq'); -const SNR = rawKey(SIGNAL, 'snr'); +// The `Modem.Location` entry for MM's coarse-cell source. Named, not bit-keyed — see +// `ModemManagerObservationInput.location`. +const LAC_CI = rawKey(LOCATION, '3gpp-lac-ci'); + +// `Modem.Signal` publishes ONE `a{sv}` per RAT, and the member sets differ (MM 1.24.2 +// `org.freedesktop.ModemManager1.Modem.Signal.xml`): +// Cdma rssi/ecio/error-rate Evdo rssi/ecio/SINR/io/error-rate +// Gsm rssi/error-rate Umts rssi/rscp/ecio/error-rate +// Lte rssi/rsrq/rsrp/snr/error-rate Nr5g rsrq/rsrp/snr/error-rate +const SIGNAL_CDMA = rawKey(SIGNAL, 'Cdma'); +const SIGNAL_EVDO = rawKey(SIGNAL, 'Evdo'); +const SIGNAL_GSM = rawKey(SIGNAL, 'Gsm'); +const SIGNAL_UMTS = rawKey(SIGNAL, 'Umts'); +const SIGNAL_LTE = rawKey(SIGNAL, 'Lte'); +const SIGNAL_NR5G = rawKey(SIGNAL, 'Nr5g'); + +/** + * Where one extended metric may be claimed from, in order. + * + * `dicts` is the RAT ladder, NEWEST FIRST: on an NSA attach both `Nr5g` and `Lte` are + * populated with genuinely different measurements (the NR leg and the LTE anchor), so + * one of them has to be the reported reading — and `rawFields` names WHICH, rather than + * leaving a consumer to guess. Nothing is lost either way: every dict stays verbatim in + * the diagnostics block. `flat` is the mmcli-flattened spelling of the same datum, kept + * for the same reason `rawStructMember` answers at index 0 for a flattened struct. + */ +type ExtendedSignalMetric = { + readonly member: string; + readonly dicts: readonly string[]; + readonly flat: string; +}; + +const RSSI: ExtendedSignalMetric = { + member: 'rssi', + dicts: [SIGNAL_LTE, SIGNAL_UMTS, SIGNAL_GSM, SIGNAL_EVDO, SIGNAL_CDMA], + flat: rawKey(SIGNAL, 'rssi'), +}; +const RSRP: ExtendedSignalMetric = { + member: 'rsrp', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'rsrp'), +}; +const RSRQ: ExtendedSignalMetric = { + member: 'rsrq', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'rsrq'), +}; +const SNR: ExtendedSignalMetric = { + member: 'snr', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'snr'), +}; +// SINR is a member of the `Evdo` dict and of NO other, so an LTE/NR modem reporting no +// SINR is a READ-class `not-reported` — ModemManager CAN express it, this modem did not. +// The NR SINR a device may publish through `Modem.GetCellInfo` is a different call on a +// different interface and is deliberately not folded in here. +const SINR: ExtendedSignalMetric = { + member: 'sinr', + dicts: [SIGNAL_EVDO], + flat: rawKey(SIGNAL, 'sinr'), +}; function hardwareIdentity(raw: RawFieldRecord): ModemHardwareIdentity { const model = rawString(raw, MODEL); @@ -162,6 +268,65 @@ function normalizeRadio( registration: decodedLabel(raw, REGISTRATION_STATE, decodeRegistrationState, provenance, notes), accessTechnologies: normalizeAccessTechnologies(raw, provenance, notes), modeLabel: normalizeModeLabel(raw, provenance, notes), + // `Modem3gpp`, never `Sim` — the registered operator, not the SIM's home one. + // A modem that is not registered reports these empty, which is `not-reported`: + // the property exists and this read carried no value. + operatorName: registrationString(raw, OPERATOR_NAME, provenance), + operatorCode: registrationString(raw, OPERATOR_CODE, provenance), + }; +} + +/** A `Modem3gpp` registration string — present-and-empty is still no reading. */ +function registrationString( + raw: RawFieldRecord, + key: string, + provenance: Provenance, +): NormalizedMetric { + const source = provenance(key); + const value = rawString(raw, key); + return value === undefined + ? unknownMetric('not-reported', source) + : knownMetric(value, source); +} + +/** + * Coarse cell context from the `3gpp-lac-ci` location reading. + * + * The whole value is ONE string, so a `cellId` and a `tac` read out of it necessarily + * agree with each other — they came from the same reported cell, never from two reads + * that raced. A value in an unrecognized shape is `malformed` for both rather than + * partially decoded; see `decode3gppLacCi`. + */ +function normalizeCell( + raw: RawFieldRecord, + locationRead: boolean, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +): NormalizedCell { + const source = provenance(LAC_CI); + if (!hasRawField(raw, LAC_CI)) { + const reason = locationRead ? ('not-reported' as const) : ('not-observed' as const); + return { + cellId: unknownMetric(reason, source), + tac: unknownMetric(reason, source), + }; + } + const decoded = decode3gppLacCi(rawString(raw, LAC_CI)); + if (decoded === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: LAC_CI }); + return { + cellId: unknownMetric('malformed', source), + tac: unknownMetric('malformed', source), + }; + } + return { + cellId: knownMetric(decoded.cellId, source), + // MM emits an EMPTY tracking-area code on a device with no LTE/NR attach — the + // reading arrived, this field of it did not. + tac: + decoded.trackingAreaCode === '' + ? unknownMetric('not-reported', source) + : knownMetric(decoded.trackingAreaCode, source), }; } @@ -230,13 +395,29 @@ function normalizeModeLabel( return knownMetric(label, source); } -function normalizeSignal(raw: RawFieldRecord, signalRead: boolean, provenance: Provenance) { +function normalizeSignal( + raw: RawFieldRecord, + signalRead: boolean, + provenance: Provenance, + dictProvenance: DictProvenance, + notes: ObservationDiagnosticNote[], +) { const missing = signalRead ? ('not-reported' as const) : ('not-observed' as const); - const extended = (key: string): NormalizedMetric => { - const source = provenance(key); - const value = rawNumber(raw, key); + const extended = (metric: ExtendedSignalMetric): NormalizedMetric => { + for (const dict of metric.dicts) { + if (!hasRawDictMember(raw, dict, metric.member)) continue; + const source = dictProvenance(dict, metric.member); + const value = rawDictNumber(raw, dict, metric.member); + if (value === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: `${dict}.${metric.member}` }); + return unknownMetric('malformed', source); + } + return knownMetric(value, source); + } + const source = provenance(metric.flat); + const value = rawNumber(raw, metric.flat); return value === undefined - ? unknownMetric(hasRawField(raw, key) ? 'malformed' : missing, source) + ? unknownMetric(hasRawField(raw, metric.flat) ? 'malformed' : missing, source) : knownMetric(value, source); }; // `SignalQuality` is a `(ub)`: percentage at 0, "measured recently" at 1. Both are @@ -262,8 +443,7 @@ function normalizeSignal(raw: RawFieldRecord, signalRead: boolean, provenance: P rsrp: extended(RSRP), rsrq: extended(RSRQ), snr: extended(SNR), - // `Modem.Signal` exposes rssi/rsrp/rsrq/snr/ecio/io/rscp — there is no SINR member. - sinr: unknownMetric('unsupported', metricProvenanceEmpty(provenance)), + sinr: extended(SINR), }; } diff --git a/control/src/observations/sources/router-shared.ts b/control/src/observations/sources/router-shared.ts index 92d5eed..ff64f05 100644 --- a/control/src/observations/sources/router-shared.ts +++ b/control/src/observations/sources/router-shared.ts @@ -20,7 +20,7 @@ import { type ObservationAuthority, } from '../../domain'; import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; -import type { NormalizedHardware, NormalizedSim, SimPresenceValue } from '../model'; +import type { NormalizedCell, NormalizedHardware, NormalizedSim, SimPresenceValue } from '../model'; import type { MetricProvenance } from '../provenance'; export type RouterProvenance = ( @@ -60,6 +60,58 @@ export function routerSim( }; } +/** + * The cell block a router source produces. + * + * `cellId` is claimed only when a MIGRATED parser decoded one — `parseZteDetails` and + * `parseUfiDetails` both do — and the raw vendor key is named in provenance. HiLink + * ships a `` tag that no migrated parser reads, so it stays verbatim in the + * diagnostics block rather than being lifted here: the same rule that keeps `SimStatus` + * out of `sim.presence`. + * + * `tac` is always `not-reported`, never `unsupported`. None of the three migrated + * parsers decodes a tracking-area code, but that is a fact about what THIS package + * reads, not a claim that the vendor's firmware cannot report one — and `unsupported` + * would be the second kind of statement. + */ +export function routerCell( + provenance: RouterProvenance, + cell: { readonly id: string; readonly field: string } | undefined, +): NormalizedCell { + return { + cellId: + cell === undefined + ? unknownMetric('not-reported', provenance([])) + : knownMetric(cell.id, provenance([cell.field])), + tac: unknownMetric('not-reported', provenance([])), + }; +} + +/** + * A registered-operator NAME a migrated parser decoded, or an honest silence. + * + * The matching CODE is deliberately never derived. `parseZteDetails` reports `mcc` and + * `mnc` as separate unpadded vendor fields, while `Modem3gpp.OperatorCode` is a + * fixed-width concatenation that MM itself splits back apart at exactly three + * characters — joining an unpadded 2-vs-3-digit MNC would name a different network + * roughly whenever the leading zero matters. So it reads `not-reported`. + */ +export function routerOperator( + provenance: RouterProvenance, + operator: { readonly name: string; readonly field: string } | undefined, +): { + readonly operatorName: NormalizedMetric; + readonly operatorCode: NormalizedMetric; +} { + return { + operatorName: + operator === undefined + ? unknownMetric('not-reported', provenance([])) + : knownMetric(operator.name, provenance([operator.field])), + operatorCode: unknownMetric('not-reported', provenance([])), + }; +} + /** Router admin APIs report no measurement-recency flag; that is a source capability claim. */ export function unsupportedQualityRecent(provenance: RouterProvenance): NormalizedMetric { return unknownMetric('unsupported', provenance([])); diff --git a/control/src/observations/sources/ufi.ts b/control/src/observations/sources/ufi.ts index dd75baf..313a708 100644 --- a/control/src/observations/sources/ufi.ts +++ b/control/src/observations/sources/ufi.ts @@ -32,7 +32,9 @@ import { } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -111,6 +113,9 @@ export function normalizeUfiObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: unknownMetric('not-reported', provenance([])), + // `parseUfiDetails` decodes no operator field from any of the three + // endpoints, so neither half is claimed. + ...routerOperator(provenance, undefined), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -131,6 +136,12 @@ export function normalizeUfiObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), }, sim: routerSim(provenance, hasRawField(raw, SIM_STATE) ? SIM_STATE : undefined), + cell: routerCell( + provenance, + details?.cell_id === undefined + ? undefined + : { id: details.cell_id, field: rawKey(SYSINFO, 'cellid') }, + ), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/src/observations/sources/zte.ts b/control/src/observations/sources/zte.ts index 6f100fd..939e2fb 100644 --- a/control/src/observations/sources/zte.ts +++ b/control/src/observations/sources/zte.ts @@ -25,7 +25,9 @@ import { createObservationDiagnostics, type ObservationDiagnosticNote } from '.. import { hasRawField, parseJsonRecord, prefixRawRecord, rawKey } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -70,6 +72,12 @@ export function normalizeZteObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: normalizeModeLabel(details?.network_type, parsed !== undefined, provenance), + ...routerOperator( + provenance, + details?.provider === undefined + ? undefined + : { name: details.provider, field: rawKey(BODY, 'network_provider_fullname') }, + ), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -88,6 +96,12 @@ export function normalizeZteObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), }, sim: routerSim(provenance, hasRawField(raw, SIM_CARD_STATE) ? SIM_CARD_STATE : undefined), + cell: routerCell( + provenance, + details?.cell_id === undefined + ? undefined + : { id: details.cell_id, field: rawKey(BODY, 'cell_id') }, + ), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/src/providers/modem-manager/forbidden-subprocess.test.ts b/control/src/providers/modem-manager/forbidden-subprocess.test.ts index 6a2c371..8841393 100644 --- a/control/src/providers/modem-manager/forbidden-subprocess.test.ts +++ b/control/src/providers/modem-manager/forbidden-subprocess.test.ts @@ -27,7 +27,8 @@ describe('ModemManagerProvider subprocess fence', () => { test('no provider production source shells out to a modem diagnostic CLI', () => { const files = productionSources(import.meta.dir); - expect(files.length).toBeGreaterThan(0); + // Scanned 8 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(8); const violations = files.filter((path) => FORBIDDEN.test(readFileSync(path, 'utf8'))); expect(violations).toEqual([]); }); diff --git a/control/src/providers/modem-manager/provider.ts b/control/src/providers/modem-manager/provider.ts index 80c359d..1675d0f 100644 --- a/control/src/providers/modem-manager/provider.ts +++ b/control/src/providers/modem-manager/provider.ts @@ -47,6 +47,16 @@ export interface ModemManagerProviderOptions { readonly destination?: string; readonly quiesce?: QuiesceHook; readonly now?: () => number; + /** + * `Signal.Setup` reporting rate in whole seconds, defaulting to + * `DEFAULT_SIGNAL_INTERVAL_SECONDS` (5). + * + * The backend has accepted this since it was written; the provider had no way to pass + * it, so an embedder constructing a provider — which is every embedder — was pinned to + * the default with no seam to change it. That is the gap this closes, and it is why + * the option is threaded rather than re-implemented here. + */ + readonly signalIntervalSeconds?: number; /** * The band-lock certification catalog. Defaults to the one shipped in this package, * which is EMPTY — so a band write is refused on every device until a human-reviewed @@ -95,6 +105,9 @@ export class ModemManagerProvider implements ModemManagerProviderLifecycle { destination: this.#destination, actor, now: this.#now, + ...(options.signalIntervalSeconds === undefined + ? {} + : { signalIntervalSeconds: options.signalIntervalSeconds }), }); const location = new MmLocation({ transport: this.#transport, diff --git a/control/src/providers/modem-manager/signal-richness.test.ts b/control/src/providers/modem-manager/signal-richness.test.ts new file mode 100644 index 0000000..4aef344 --- /dev/null +++ b/control/src/providers/modem-manager/signal-richness.test.ts @@ -0,0 +1,147 @@ +// End-to-end extended-signal truth through the REAL ModemManager provider. +// +// `observations/normalization.test.ts` proves the normalizer against a fixture. This one +// proves the WIRE, and it exists because the wire is exactly where this used to break: +// a `Modem.Signal` RAT dict is an `a{sv}`, so every member value arrives WRAPPED in a +// variant, and the retention layer used to keep the key while dropping the reading. A +// normalizer test cannot see that — it is handed the record the provider built. +// +// It runs on the in-memory transport for the reason the conformance matrix does: a suite +// that SKIPS wherever no session bus exists answers nothing. + +import { describe, expect, test } from 'bun:test'; +import { FakeMmTransport } from '../../../test-support/conformance/mm-transport'; +import type { ModemSpec } from '../../../test-support/fake-mm/object-model'; +import { type DeviceGeneration, deviceGeneration, physicalModemId } from '../../domain'; +import type { NormalizedMetric, NormalizedSignal } from '../../observations'; +import type { ProviderExecutionContext } from '../contracts'; +import { createModemManagerProvider } from './provider'; + +const GENERATION: DeviceGeneration = deviceGeneration(1); +const PROFILE = 'generic-mm'; + +/** Quectel RM530N-GL attached 5G NSA: the NR leg and the LTE anchor are both live. */ +const NSA_SPEC: ModemSpec = { + index: 1, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + sims: [{ index: 1, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], + extendedSignal: { + Nr5g: { rsrp: -98.5, rsrq: -11, snr: 6.5, 'error-rate': 0 }, + Lte: { rssi: -71, rsrp: -104, rsrq: -13.5, snr: 4, 'error-rate': 0 }, + }, +}; + +/** Sierra MC7354 on EV-DO — the ONE `Modem.Signal` dict MM defines `sinr` on. */ +const EVDO_SPEC: ModemSpec = { + index: 2, + manufacturer: 'Sierra Wireless', + model: 'MC7354', + revision: 'SWI9X15C', + sims: [{ index: 2, iccid: '8900000000000000002', imsi: '001010000000002', active: true }], + extendedSignal: { Evdo: { rssi: -83, ecio: -2.5, sinr: 9.5, io: -95, 'error-rate': 0 } }, +}; + +/** The same modem with `Modem.Signal` exported and every RAT dict still empty. */ +const SILENT_SPEC: ModemSpec = { + index: 3, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + sims: [{ index: 3, iccid: '8900000000000000003', imsi: '001010000000003', active: true }], +}; + +function contextFor(spec: ModemSpec): ProviderExecutionContext { + return { + physicalModemId: physicalModemId(`serial:fake-device-${spec.index}`), + generation: GENERATION, + transport: 'modemmanager', + passiveFacts: [], + composition: 'generic', + profile: PROFILE, + }; +} + +async function signalOf(spec: ModemSpec): Promise { + const provider = createModemManagerProvider({ + transport: new FakeMmTransport({ modems: [spec] }), + }); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok) { + throw new Error('the fake modem must produce a snapshot'); + } + const observation = snapshot.observation.value; + if (observation === null) { + throw new Error('a served payload must produce a valued envelope'); + } + return observation.signal; +} + +async function rawOf(spec: ModemSpec): Promise> { + const provider = createModemManagerProvider({ + transport: new FakeMmTransport({ modems: [spec] }), + }); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok || snapshot.observation.value === null) { + throw new Error('the fake modem must produce a valued snapshot'); + } + return snapshot.observation.value.diagnostics.raw as Record; +} + +function reasonOf(metric: NormalizedMetric): string { + return metric.state === 'unknown' ? metric.reason : `known:${metric.value}`; +} + +describe('Modem.Signal RAT dicts reach NormalizedSignal through the provider', () => { + test('an NSA attach yields rsrp/rsrq/snr, each attributed to the dict it came from', async () => { + const signal = await signalOf(NSA_SPEC); + + expect(signal.rsrp).toMatchObject({ state: 'known', value: -98.5 }); + expect(signal.rsrq).toMatchObject({ state: 'known', value: -11 }); + expect(signal.snr).toMatchObject({ state: 'known', value: 6.5 }); + expect(signal.dbm).toMatchObject({ state: 'known', value: -71 }); + for (const metric of [signal.rsrp, signal.rsrq, signal.snr, signal.dbm]) { + expect(metric.provenance.authority).toBe('authoritative'); + } + expect(signal.rsrp.provenance.rawFields).toEqual(['Signal.Nr5g.rsrp']); + expect(signal.dbm.provenance.rawFields).toEqual(['Signal.Lte.rssi']); + }); + + test('an EV-DO attach yields SINR, the member no LTE/NR dict carries', async () => { + const signal = await signalOf(EVDO_SPEC); + + expect(signal.sinr).toMatchObject({ state: 'known', value: 9.5 }); + expect(signal.sinr.provenance.rawFields).toEqual(['Signal.Evdo.sinr']); + expect(signal.sinr.provenance.authority).toBe('authoritative'); + }); + + test('every dict member survives retention verbatim, values included', async () => { + const raw = await rawOf(NSA_SPEC); + + expect(raw['Signal.Nr5g']).toEqual([ + ['rsrp', -98.5], + ['rsrq', -11], + ['snr', 6.5], + ['error-rate', 0], + ]); + expect(raw['Signal.Lte']).toContainEqual(['rsrp', -104]); + }); + + test('an exported but silent Signal interface reports READ-class unknowns, never zeros', async () => { + const signal = await signalOf(SILENT_SPEC); + + for (const metric of [signal.dbm, signal.rsrp, signal.rsrq, signal.snr, signal.sinr]) { + expect(reasonOf(metric)).toBe('not-reported'); + expect(metric).not.toHaveProperty('value'); + } + }); + + test('a modem with no Modem.Signal interface at all reports not-observed', async () => { + const signal = await signalOf({ ...SILENT_SPEC, index: 4, hasSignal: false }); + + for (const metric of [signal.rsrp, signal.rsrq, signal.snr, signal.sinr]) { + expect(reasonOf(metric)).toBe('not-observed'); + } + }); +}); diff --git a/control/src/providers/modem-manager/snapshot.ts b/control/src/providers/modem-manager/snapshot.ts index f421eb0..d0e5b97 100644 --- a/control/src/providers/modem-manager/snapshot.ts +++ b/control/src/providers/modem-manager/snapshot.ts @@ -25,7 +25,7 @@ import { readSimPresence } from '../../hardware/router-parsers'; import type { NormalizationContext, RawFieldRecord, RawFieldValue } from '../../observations'; import { normalizeModemManagerObservation } from '../../observations'; import { describeBandWriteCertification, readRadioModeTruth } from '../../radio'; -import type { DbusValue } from '../../transport'; +import { type DbusValue, isVariant } from '../../transport'; import type { ProviderExecutionContext } from '../contracts'; import type { ModemManagerCapabilities, @@ -40,6 +40,10 @@ const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`; function rawValue(value: DbusValue): RawFieldValue | undefined { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + // An `a{sv}` decodes to `[key, variant][]`, so a dict member's value arrives WRAPPED. + // Without this unwrap a `Modem.Signal` RAT dict retained as `[['rsrp'], ['rsrq'], …]`: + // the key survived and the reading did not. + if (isVariant(value)) return rawValue(value.value); if (Array.isArray(value)) { const result: RawFieldValue[] = []; for (const item of value) { diff --git a/control/src/providers/network-manager/adapter.ts b/control/src/providers/network-manager/adapter.ts index 4127537..576ae49 100644 --- a/control/src/providers/network-manager/adapter.ts +++ b/control/src/providers/network-manager/adapter.ts @@ -20,7 +20,7 @@ // deactivate. There is deliberately no delete path here: profile removal is not in // that set, so this adapter cannot express it. -import type { DeviceGeneration, EpochMillis } from '../../domain'; +import type { DeviceGeneration } from '../../domain'; import { epochMillis } from '../../domain'; import type { AppliedConfiguration, @@ -30,14 +30,7 @@ import type { ObservedState, StateDivergence, } from '../../observations'; -import { - appliedConfiguration, - describeStateDivergence, - desiredProfile, - freshObservation, - observedState, - unavailableObservation, -} from '../../observations'; +import { appliedConfiguration, desiredProfile } from '../../observations'; import type { ConnectionId, DeviceIfname, @@ -46,39 +39,21 @@ import type { Receipt, } from '../../ports'; import { receipt } from '../../ports'; +import { projectDivergence } from './divergence'; +import { foldObservation } from './observe-fold'; +import { projectStateView } from './projection'; +import type { ConnectionSlots } from './state'; +import { slotsFor, targetIfname } from './state'; import type { NmAdapterRefusalReason, - NmAppliedLoss, - NmAppliedOutcome, NmApplyResult, NmBearerState, - NmConnectionOutcome, NmDesiredRequest, NmObservationInput, NmObservationResult, - NmObservedDevice, NmSaveResult, } from './types'; -import { boundBearer, nmBearerStateEquals, unboundBearer } from './types'; - -const SOURCE = 'networkmanager' as const; - -/** The device states in which NM is still settling an activation, not losing it. */ -const TRANSITIONAL_STATES: ReadonlySet = new Set([ - 'prepare', - 'config', - 'need-auth', - 'ip-config', - 'ip-check', - 'secondaries', - 'deactivating', -]); - -interface ConnectionSlots { - desired: DesiredProfile | null; - applied: AppliedConfiguration | null; - observed: ObservedState | null; -} +import { boundBearer, unboundBearer } from './types'; export interface NetworkManagerAdapterOptions { readonly port: NetworkManagerPort; @@ -143,7 +118,7 @@ export class NetworkManagerAdapter { epochMillis(this.#now()), request.requestedBy, ); - this.#slotsFor(id).desired = desired; + slotsFor(this.#slots, id).desired = desired; return { ok: true, connectionId: id, desired }; } @@ -165,7 +140,7 @@ export class NetworkManagerAdapter { }; } const desired = desiredProfile(unboundBearer(ifname), epochMillis(this.#now()), requestedBy); - this.#slotsFor(id).desired = desired; + slotsFor(this.#slots, id).desired = desired; return { ok: true, connectionId: id, desired }; } @@ -224,44 +199,9 @@ export class NetworkManagerAdapter { * was. */ observe(input: NmObservationInput): NmObservationResult { - const generation = input.context.generation; - if (this.#observedGeneration !== null && generation < this.#observedGeneration) { - return { - kind: 'refused', - reason: 'superseded-generation', - currentGeneration: this.#observedGeneration, - }; - } - this.#observedGeneration = generation; - const devices = new Map( - input.devices.map((device) => [device.ifname, device]), - ); - const outcomes: NmConnectionOutcome[] = []; - const losses: NmAppliedLoss[] = []; - for (const [id, slots] of this.#slots) { - const ifname = targetIfname(slots); - if (ifname === undefined) { - continue; - } - const device = devices.get(ifname); - slots.observed = observedState( - device === undefined - ? unavailableObservation(SOURCE, input.context, 'device-absent') - : freshObservation(SOURCE, input.context, observedBearer(device)), - ); - const outcome = this.#classify(id, slots, ifname, device, input.context.observedAt); - outcomes.push({ connectionId: id, outcome }); - if (outcome.status === 'lost') { - losses.push(outcome.loss); - } - } - return { - kind: 'accepted', - generation, - observedAt: input.context.observedAt, - outcomes, - losses, - }; + const folded = foldObservation(this.#slots, this.#observedGeneration, input); + this.#observedGeneration = folded.generation; + return folded.result; } observedFor(id: ConnectionId): ObservedState | null { @@ -277,25 +217,12 @@ export class NetworkManagerAdapter { id: ConnectionId, context: NormalizationContext, ): ModemStateView | null { - const slots = this.#slots.get(id); - if (slots === undefined) { - return null; - } - return { - desired: slots.desired, - applied: slots.applied, - observed: - slots.observed ?? - observedState( - unavailableObservation(SOURCE, context, 'provider-unavailable'), - ), - }; + return projectStateView(this.#slots, id, context); } /** `desiredVsApplied` ("did our write happen") and `appliedVsObserved` ("did it stick"). */ divergence(id: ConnectionId, context: NormalizationContext): StateDivergence | null { - const view = this.stateView(id, context); - return view === null ? null : describeStateDivergence(view, nmBearerStateEquals); + return projectDivergence(this.#slots, id, context); } // ── internals ────────────────────────────────────────────────────────────── @@ -334,7 +261,7 @@ export class NetworkManagerAdapter { generation: options.generation, operationId: options.operationId, }); - this.#slotsFor(id).applied = applied; + slotsFor(this.#slots, id).applied = applied; return { ok: true, applied, receipt: activation }; } @@ -358,92 +285,9 @@ export class NetworkManagerAdapter { generation: options.generation, operationId: options.operationId, }); - this.#slotsFor(id).applied = applied; + slotsFor(this.#slots, id).applied = applied; return { ok: true, applied, receipt: result }; } - - #classify( - id: ConnectionId, - slots: ConnectionSlots, - ifname: DeviceIfname, - device: NmObservedDevice | undefined, - observedAt: EpochMillis, - ): NmAppliedOutcome { - const applied = slots.applied; - if (applied === null) { - return { status: 'unapplied' }; - } - const lose = (reason: NmAppliedLoss['reason']): NmAppliedOutcome => { - slots.applied = null; - return { - status: 'lost', - loss: { - connectionId: id, - deviceIfname: ifname, - reason, - lostAt: observedAt, - generation: applied.generation, - previous: applied, - }, - }; - }; - if (device === undefined) { - return lose('interface-absent'); - } - if (device.state === 'failed') { - return lose('activation-failed'); - } - if (applied.configuration.kind === 'unbound') { - // We deliberately took the bearer down; anything active here is somebody else. - return device.activeConnection === undefined - ? { status: 'retained', applied } - : lose('connection-replaced'); - } - if (device.activeConnection === undefined) { - return lose('interface-detached'); - } - if (device.activeConnection.connectionId !== id) { - return lose('connection-replaced'); - } - if (device.state === 'activated') { - return { status: 'retained', applied }; - } - return TRANSITIONAL_STATES.has(device.state) - ? { status: 'pending', applied, deviceState: device.state } - : lose('interface-detached'); - } - - #slotsFor(id: ConnectionId): ConnectionSlots { - const existing = this.#slots.get(id); - if (existing !== undefined) { - return existing; - } - const created: ConnectionSlots = { desired: null, applied: null, observed: null }; - this.#slots.set(id, created); - return created; - } -} - -/** The device the slots are about: where the bearer IS, else where it was asked for. */ -function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined { - const state = slots.applied?.configuration ?? slots.desired?.profile; - if (state === undefined) { - return undefined; - } - return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname; -} - -function observedBearer(device: NmObservedDevice): NmBearerState { - const active = device.activeConnection; - return active === undefined - ? unboundBearer(device.ifname) - : boundBearer({ - connectionId: active.connectionId, - deviceIfname: device.ifname, - apn: active.apn, - autoConfig: active.autoConfig, - homeOnly: active.homeOnly, - }); } function refuse(reason: NmAdapterRefusalReason, message: string): NmApplyResult { diff --git a/control/src/providers/network-manager/divergence.ts b/control/src/providers/network-manager/divergence.ts new file mode 100644 index 0000000..6726dda --- /dev/null +++ b/control/src/providers/network-manager/divergence.ts @@ -0,0 +1,15 @@ +import type { NormalizationContext, StateDivergence } from '../../observations'; +import { describeStateDivergence } from '../../observations'; +import type { ConnectionId } from '../../ports'; +import { projectStateView } from './projection'; +import type { ConnectionSlots } from './state'; +import { nmBearerStateEquals } from './types'; + +export function projectDivergence( + slotsByConnection: ReadonlyMap, + id: ConnectionId, + context: NormalizationContext, +): StateDivergence | null { + const view = projectStateView(slotsByConnection, id, context); + return view === null ? null : describeStateDivergence(view, nmBearerStateEquals); +} diff --git a/control/src/providers/network-manager/network-manager-adapter.test.ts b/control/src/providers/network-manager/network-manager-adapter.test.ts index fd8f38b..827fbbd 100644 --- a/control/src/providers/network-manager/network-manager-adapter.test.ts +++ b/control/src/providers/network-manager/network-manager-adapter.test.ts @@ -632,12 +632,16 @@ describe('NetworkManagerAdapter — scope boundary', () => { 'physicalModemId', ]; - const sources = ['adapter.ts', 'types.ts', 'index.ts'].map((name) => ({ - name, - code: stripComments(readFileSync(new URL(name, import.meta.url), 'utf8')), - })); + const sources = [...new Bun.Glob('**/*.ts').scanSync({ cwd: import.meta.dir, onlyFiles: true })] + .filter((name) => !name.endsWith('.test.ts')) + .map((name) => ({ + name, + code: stripComments(readFileSync(new URL(name, import.meta.url), 'utf8')), + })); test('the comment strip is non-vacuous in both directions', () => { + // Scanned 3 files before directory-glob widening. + expect(sources.length).toBeGreaterThanOrEqual(3); expect( stripComments('const a = 1; // setRadioModes\n/* sendPin */\nconst b = 2;'), ).not.toContain('setRadioModes'); diff --git a/control/src/providers/network-manager/observe-fold.ts b/control/src/providers/network-manager/observe-fold.ts new file mode 100644 index 0000000..a1f79d6 --- /dev/null +++ b/control/src/providers/network-manager/observe-fold.ts @@ -0,0 +1,144 @@ +import type { DeviceGeneration, EpochMillis } from '../../domain'; +import { freshObservation, observedState, unavailableObservation } from '../../observations'; +import type { ConnectionId, DeviceIfname } from '../../ports'; +import type { ConnectionSlots } from './state'; +import { targetIfname } from './state'; +import type { + NmAppliedLoss, + NmAppliedOutcome, + NmBearerState, + NmConnectionOutcome, + NmObservationInput, + NmObservationResult, + NmObservedDevice, +} from './types'; +import { boundBearer, unboundBearer } from './types'; + +const SOURCE = 'networkmanager' as const; +const TRANSITIONAL_STATES: ReadonlySet = new Set([ + 'prepare', + 'config', + 'need-auth', + 'ip-config', + 'ip-check', + 'secondaries', + 'deactivating', +]); + +export interface ObservationFold { + readonly generation: DeviceGeneration; + readonly result: NmObservationResult; +} + +export function foldObservation( + slotsByConnection: Map, + observedGeneration: DeviceGeneration | null, + input: NmObservationInput, +): ObservationFold { + const generation = input.context.generation; + if (observedGeneration !== null && generation < observedGeneration) { + return { + generation: observedGeneration, + result: { + kind: 'refused', + reason: 'superseded-generation', + currentGeneration: observedGeneration, + }, + }; + } + const devices = new Map( + input.devices.map((device) => [device.ifname, device]), + ); + const outcomes: NmConnectionOutcome[] = []; + const losses: NmAppliedLoss[] = []; + for (const [id, slots] of slotsByConnection) { + const ifname = targetIfname(slots); + if (ifname === undefined) { + continue; + } + const device = devices.get(ifname); + slots.observed = observedState( + device === undefined + ? unavailableObservation(SOURCE, input.context, 'device-absent') + : freshObservation(SOURCE, input.context, observedBearer(device)), + ); + const outcome = classifyApplied(id, slots, ifname, device, input.context.observedAt); + outcomes.push({ connectionId: id, outcome }); + if (outcome.status === 'lost') { + losses.push(outcome.loss); + } + } + return { + generation, + result: { + kind: 'accepted', + generation, + observedAt: input.context.observedAt, + outcomes, + losses, + }, + }; +} + +function classifyApplied( + id: ConnectionId, + slots: ConnectionSlots, + ifname: DeviceIfname, + device: NmObservedDevice | undefined, + observedAt: EpochMillis, +): NmAppliedOutcome { + const applied = slots.applied; + if (applied === null) { + return { status: 'unapplied' }; + } + const lose = (reason: NmAppliedLoss['reason']): NmAppliedOutcome => { + slots.applied = null; + return { + status: 'lost', + loss: { + connectionId: id, + deviceIfname: ifname, + reason, + lostAt: observedAt, + generation: applied.generation, + previous: applied, + }, + }; + }; + if (device === undefined) { + return lose('interface-absent'); + } + if (device.state === 'failed') { + return lose('activation-failed'); + } + if (applied.configuration.kind === 'unbound') { + return device.activeConnection === undefined + ? { status: 'retained', applied } + : lose('connection-replaced'); + } + if (device.activeConnection === undefined) { + return lose('interface-detached'); + } + if (device.activeConnection.connectionId !== id) { + return lose('connection-replaced'); + } + if (device.state === 'activated') { + return { status: 'retained', applied }; + } + return TRANSITIONAL_STATES.has(device.state) + ? { status: 'pending', applied, deviceState: device.state } + : lose('interface-detached'); +} + +function observedBearer(device: NmObservedDevice): NmBearerState { + const active = device.activeConnection; + return active === undefined + ? unboundBearer(device.ifname) + : boundBearer({ + connectionId: active.connectionId, + deviceIfname: device.ifname, + apn: active.apn, + autoConfig: active.autoConfig, + homeOnly: active.homeOnly, + }); +} diff --git a/control/src/providers/network-manager/projection.ts b/control/src/providers/network-manager/projection.ts new file mode 100644 index 0000000..141f8e2 --- /dev/null +++ b/control/src/providers/network-manager/projection.ts @@ -0,0 +1,25 @@ +import type { ModemStateView, NormalizationContext } from '../../observations'; +import { observedState, unavailableObservation } from '../../observations'; +import type { ConnectionId } from '../../ports'; +import type { ConnectionSlots } from './state'; +import type { NmBearerState } from './types'; + +const SOURCE = 'networkmanager' as const; + +export function projectStateView( + slotsByConnection: ReadonlyMap, + id: ConnectionId, + context: NormalizationContext, +): ModemStateView | null { + const slots = slotsByConnection.get(id); + if (slots === undefined) { + return null; + } + return { + desired: slots.desired, + applied: slots.applied, + observed: + slots.observed ?? + observedState(unavailableObservation(SOURCE, context, 'provider-unavailable')), + }; +} diff --git a/control/src/providers/network-manager/state.ts b/control/src/providers/network-manager/state.ts new file mode 100644 index 0000000..7d382c9 --- /dev/null +++ b/control/src/providers/network-manager/state.ts @@ -0,0 +1,30 @@ +import type { AppliedConfiguration, DesiredProfile, ObservedState } from '../../observations'; +import type { ConnectionId, DeviceIfname } from '../../ports'; +import type { NmBearerState } from './types'; + +export interface ConnectionSlots { + desired: DesiredProfile | null; + applied: AppliedConfiguration | null; + observed: ObservedState | null; +} + +export function slotsFor( + slotsByConnection: Map, + id: ConnectionId, +): ConnectionSlots { + const existing = slotsByConnection.get(id); + if (existing !== undefined) { + return existing; + } + const created: ConnectionSlots = { desired: null, applied: null, observed: null }; + slotsByConnection.set(id, created); + return created; +} + +export function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined { + const state = slots.applied?.configuration ?? slots.desired?.profile; + if (state === undefined) { + return undefined; + } + return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname; +} diff --git a/control/src/providers/ufi-himi/credential-fence.test.ts b/control/src/providers/ufi-himi/credential-fence.test.ts index fe675af..48c3116 100644 --- a/control/src/providers/ufi-himi/credential-fence.test.ts +++ b/control/src/providers/ufi-himi/credential-fence.test.ts @@ -33,6 +33,8 @@ describe('UFI bench credential fence', () => { ]); expect(exitCode).toBe(0); const files = names.split('\0').filter((name) => name.length > 0); + // Scanned 605 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(605); const leaks: string[] = []; for (const file of files) { const content = await Bun.file(file).text(); diff --git a/control/src/providers/ufi-himi/session.ts b/control/src/providers/ufi-himi/session.ts index 63b2811..f02489f 100644 --- a/control/src/providers/ufi-himi/session.ts +++ b/control/src/providers/ufi-himi/session.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseJsonWith } from '../../json-boundary'; import type { ProviderMatchRequest } from '../contracts'; import { UFI_API_PATH, @@ -32,20 +33,7 @@ const ufiReplySchema = z.object({ export type UfiReply = z.infer; export function parseUfiReply(body: string): UfiReply | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(ufiReplySchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(ufiReplySchema, body); } /** diff --git a/control/src/providers/zte-goform/secret-fence.test.ts b/control/src/providers/zte-goform/secret-fence.test.ts index 3643797..4b4df19 100644 --- a/control/src/providers/zte-goform/secret-fence.test.ts +++ b/control/src/providers/zte-goform/secret-fence.test.ts @@ -26,6 +26,8 @@ describe('MF79U credential fence', () => { ]); expect(exitCode).toBe(0); const files = names.split('\0').filter((name) => name.length > 0); + // Scanned 605 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(605); const leaks: string[] = []; for (const file of files) { const content = await Bun.file(file).text(); diff --git a/control/src/providers/zte-goform/session.ts b/control/src/providers/zte-goform/session.ts index dbb665e..4fee0f2 100644 --- a/control/src/providers/zte-goform/session.ts +++ b/control/src/providers/zte-goform/session.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; +import { parseJsonWith } from '../../json-boundary'; import type { AuthenticatedProfileResult, ProviderMatchRequest } from '../contracts'; import { ZTE_EVIDENCE_CMD, @@ -25,20 +26,7 @@ const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])) export function parseZteRecord( body: string, ): Readonly> | undefined { - const result = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(flatRecordSchema) - .safeParse(body); - return result.success ? result.data : undefined; + return parseJsonWith(flatRecordSchema, body); } function stokCookie(response: ZteHttpResponse): string | undefined { diff --git a/control/src/safety/flock-resource-ownership.ts b/control/src/safety/flock-resource-ownership.ts index aafcb4e..9c7e34a 100644 --- a/control/src/safety/flock-resource-ownership.ts +++ b/control/src/safety/flock-resource-ownership.ts @@ -24,6 +24,9 @@ type ChildStart = | { readonly status: 'acquired'; readonly holder: ResourceOwnershipHolder } | { readonly status: 'closed'; readonly code: number | null; readonly stderr: string }; +const STARTUP_DEADLINE_MS = 1_000; +const STARTUP_OUTPUT_LIMIT_BYTES = 64 * 1024; + export function createFlockResourceOwnershipPort( options: FlockResourceOwnershipOptions, ): ResourceOwnershipPort { @@ -34,12 +37,19 @@ export function createFlockResourceOwnershipPort( ['--exclusive', '--nonblock', '--no-fork', options.lockPath, '/bin/cat'], { stdio: ['pipe', 'pipe', 'pipe'] }, ); + const closed = childClosed(child); if (child.pid === undefined) { + await reapChild(child, closed); throw new FlockResourceOwnershipError('helper started without a process id'); } const holder = { pid: child.pid, startedAtEpochMs: Date.now() }; - const closed = childClosed(child); - const started = await childStarted(child, options.lockPath, holder); + let started: ChildStart; + try { + started = await childStarted(child, options.lockPath, holder); + } catch (error) { + await reapChild(child, closed); + throw error; + } if (started.status === 'closed') { if (started.code === 1) { const holder = await readHolder(options.lockPath); @@ -86,6 +96,16 @@ function childClosed(child: ChildProcessWithoutNullStreams): Promise { return new Promise((resolve) => child.once('close', () => resolve())); } +async function reapChild( + child: ChildProcessWithoutNullStreams, + closed: Promise, +): Promise { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + await closed; +} + function childStarted( child: ChildProcessWithoutNullStreams, lockPath: string, @@ -94,32 +114,77 @@ function childStarted( return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let acknowledged = false; let settled = false; + const startupDeadline = setTimeout( + () => fail(new FlockResourceOwnershipError('helper startup deadline exceeded')), + STARTUP_DEADLINE_MS, + ); + const cleanup = (): void => { + clearTimeout(startupDeadline); + child.stdout.off('data', onStdout); + child.stderr.off('data', onStderr); + child.off('close', onClose); + child.off('error', fail); + child.stdin.off('error', fail); + }; const finish = (result: ChildStart): void => { if (settled) return; settled = true; + cleanup(); resolve(result); }; - child.stderr.on('data', (chunk: Buffer) => { + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onStderr = (chunk: Buffer): void => { + stderrBytes += chunk.length; + if (stderrBytes > STARTUP_OUTPUT_LIMIT_BYTES) { + fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stderr')); + return; + } stderr += chunk.toString('utf8'); - }); - child.stdout.on('data', (chunk: Buffer) => { + }; + const onStdout = (chunk: Buffer): void => { + stdoutBytes += chunk.length; + if (stdoutBytes > STARTUP_OUTPUT_LIMIT_BYTES) { + fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stdout')); + return; + } stdout += chunk.toString('utf8'); const newline = stdout.indexOf('\n'); if (newline < 0) return; const acquired = parseAcquiredLine(stdout.slice(0, newline)); - if (acquired === undefined || settled) return; - settled = true; + if (acquired === undefined || acknowledged) return; + acknowledged = true; void writeFile(lockPath, `${JSON.stringify(holder)}\n`, { mode: 0o600 }).then( - () => resolve({ status: 'acquired', holder }), - (error: unknown) => { - child.stdin.end(); - reject(error); - }, + () => finish({ status: 'acquired', holder }), + fail, ); - }); - child.once('close', (code) => finish({ status: 'closed', code, stderr: stderr.trim() })); - child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`); + }; + const onClose = (code: number | null): void => + finish({ status: 'closed', code, stderr: stderr.trim() }); + child.stderr.on('data', onStderr); + child.stdout.on('data', onStdout); + child.once('close', onClose); + child.once('error', fail); + child.stdin.once('error', fail); + try { + child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`, (error) => { + if (error !== null) fail(error); + }); + } catch (error) { + if (error instanceof Error) { + fail(error); + return; + } + fail(new FlockResourceOwnershipError('helper stdin failed with a non-error value')); + } }); } diff --git a/control/src/safety/resource-ownership.integration.test.ts b/control/src/safety/resource-ownership.integration.test.ts index 5c531f3..4b4533c 100644 --- a/control/src/safety/resource-ownership.integration.test.ts +++ b/control/src/safety/resource-ownership.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createFlockResourceOwnershipPort } from './flock-resource-ownership'; @@ -32,6 +32,55 @@ async function lockPath(): Promise { return join(directory, 'ownership.lock'); } +async function helperPid(pidPath: string): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + return Number.parseInt((await readFile(pidPath, 'utf8')).trim(), 10); + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('helper did not record its process id'); +} + +async function expectHelperReaped(pid: number): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`helper ${pid} was not reaped`); +} + +async function writeFlockHelper( + directory: string, + name: string, + body: string, +): Promise<{ readonly flockBinary: string; readonly pidPath: string }> { + const flockBinary = join(directory, name); + const pidPath = join(directory, `${name}.pid`); + await writeFile(flockBinary, `#!/bin/sh\necho $$ > ${pidPath}\n${body}\n`, { mode: 0o755 }); + await chmod(flockBinary, 0o755); + return { flockBinary, pidPath }; +} + +function acquisitionFailure(acquisition: Promise): Promise { + return acquisition.then( + () => new Error('expected acquisition to reject'), + (error: unknown) => + error instanceof Error ? error : new Error('acquisition rejected with a non-error value'), + ); +} + function spawnRoot(path: string): ChildProcessWithoutNullStreams { const child = spawn(process.execPath, ['control/test-support/ownership-root-fixture.ts', path], { cwd: process.cwd(), @@ -112,4 +161,64 @@ describe('flock resource ownership across composition roots', () => { const successor = spawnRoot(path); expect(await nextMessage(successor)).toMatchObject({ type: 'acquired' }); }); + + test('Given a holder-file write failure, When the helper stays alive, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-write-failure-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'write-failure-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nIFS= read -r line\nprintf \'%s\\n\' "$line"\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: directory, + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('EISDIR'); + await expectHelperReaped(pid); + }); + + test('Given a helper that never acknowledges the pipe round-trip, When startup reaches its deadline, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-hang-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'hanging-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: join(directory, 'ownership.lock'), + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('startup deadline'); + await expectHelperReaped(pid); + }); + + test('Given a helper that floods startup stdout, When output exceeds 64 KiB, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-flood-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'flooding-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nhead -c 65537 /dev/zero | tr \'\\000\' x\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: join(directory, 'ownership.lock'), + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('startup output limit'); + await expectHelperReaped(pid); + }); }); diff --git a/control/src/sms/readonly-gate.test.ts b/control/src/sms/readonly-gate.test.ts index fdd78a0..30e6f53 100644 --- a/control/src/sms/readonly-gate.test.ts +++ b/control/src/sms/readonly-gate.test.ts @@ -15,7 +15,6 @@ // interlock design. import { describe, expect, test } from 'bun:test'; -import { readdirSync } from 'node:fs'; import { join } from 'node:path'; const SMS_DIR = import.meta.dir; @@ -66,8 +65,8 @@ function stripComments(source: string): string { } function smsSourceFiles(): string[] { - const files = readdirSync(SMS_DIR) - .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts') && name !== SELF) + const files = [...new Bun.Glob('**/*.ts').scanSync({ cwd: SMS_DIR, onlyFiles: true })] + .filter((name) => !name.endsWith('.test.ts') && name !== SELF) .map((name) => join(SMS_DIR, name)); return [...files, PORT_FILE]; } @@ -81,7 +80,8 @@ describe('the SMS surface is read-only, and stays that way', () => { test('scans the whole SMS surface, port included', () => { // Guards the gate itself: a moved directory would otherwise make this // suite pass vacuously by scanning nothing. - expect(CODE.size).toBeGreaterThanOrEqual(5); + // Scanned 6 files before directory-glob widening. + expect(CODE.size).toBeGreaterThanOrEqual(6); for (const expected of [ 'ports/sms.ts', 'sms/normalize.ts', diff --git a/control/src/transport/lifecycle.test.ts b/control/src/transport/lifecycle.test.ts new file mode 100644 index 0000000..99d88a4 --- /dev/null +++ b/control/src/transport/lifecycle.test.ts @@ -0,0 +1,403 @@ +// Lifecycle tests for the transport seam: cancellable reconnect backoff, observable teardown +// failures, and the unified timing policy. +// +// Unlike reliability.test.ts and characterization.test.ts, these drive an INJECTED fake bus +// rather than a private `dbus-daemon`. The paths under test are exactly the ones a real daemon +// cannot be made to take on demand — a `bus.disconnect()` that rejects, a `connection.end()` +// that throws, a handshake that never completes — and a timing assertion measured against a +// live socket would be a flake generator. Nothing here needs `dbus-run-session`, so these run +// on every machine rather than skipping where the daemon is absent. + +import { expect, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; +import type { + CreateClientOptions, + RawBus, + RawConnection, + RawMessage, + ReplyCallback, +} from './dbus-native'; +import type { BusFactory } from './transport'; +import { + createDbusTransportForTest, + DEFAULT_TRANSPORT_TIMING, + TransportTeardownFailure, +} from './transport'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(5); + } + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); +} + +// `stall` never resolves the handshake either way, which is the only way to reach the connect +// timeout without waiting on a real socket. +type HandshakeOutcome = 'succeed' | 'fail' | 'stall'; + +interface FakeBusBehaviour { + readonly handshake: HandshakeOutcome; + readonly endThrows?: boolean; + readonly disconnectRejects?: boolean; +} + +// A real EventEmitter underneath, so `listenerCount` is a measurement rather than a fixture — +// that is what makes the leak assertions mean anything. +class FakeConnection implements RawConnection { + readonly #emitter = new EventEmitter(); + readonly #endThrows: boolean; + endCalls = 0; + + constructor(endThrows: boolean) { + this.#endThrows = endThrows; + this.#emitter.setMaxListeners(0); + } + + on(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.on(event, handler); + } + + once(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.once(event, handler); + } + + removeListener(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.removeListener(event, handler); + } + + removeAllListeners(event?: string): void { + this.#emitter.removeAllListeners(event); + } + + listenerCount(event: string): number { + return this.#emitter.listenerCount(event); + } + + end(): void { + this.endCalls += 1; + if (this.#endThrows) { + throw new Error('fake connection.end() failed'); + } + } + + emit(event: string, ...args: unknown[]): void { + this.#emitter.emit(event, ...args); + } +} + +class FakeBus implements RawBus { + readonly connection: FakeConnection; + readonly matches: string[] = []; + readonly #behaviour: FakeBusBehaviour; + disconnectCalls = 0; + invokes = 0; + + constructor(behaviour: FakeBusBehaviour) { + this.#behaviour = behaviour; + this.connection = new FakeConnection(behaviour.endThrows ?? false); + } + + // The transport attaches its handshake listeners synchronously in a Promise executor that + // runs after the factory returns, so the outcome must be deferred by at least a macrotask. + arm(): void { + setTimeout(() => { + if (this.#behaviour.handshake === 'succeed') { + this.connection.emit('connect'); + } else if (this.#behaviour.handshake === 'fail') { + this.connection.emit('error', new Error('fake handshake refused')); + } + }, 0); + } + + // A call that never gets a reply — the per-call timeout is what the policy test measures. + invoke(_message: RawMessage, _callback: ReplyCallback): void { + this.invokes += 1; + } + + async addMatch(rule: string): Promise { + this.matches.push(rule); + return undefined; + } + + async removeMatch(_rule: string): Promise { + return undefined; + } + + async disconnect(): Promise { + this.disconnectCalls += 1; + if (this.#behaviour.disconnectRejects === true) { + throw new Error('fake bus.disconnect() failed'); + } + } +} + +interface ScriptedBuses { + readonly factory: BusFactory; + readonly buses: FakeBus[]; +} + +// The last behaviour repeats, so a script's tail describes "and every reconnect attempt after +// that", which is what the backoff tests need. +function scriptedBuses(script: readonly FakeBusBehaviour[]): ScriptedBuses { + const buses: FakeBus[] = []; + const factory: BusFactory = (_options: CreateClientOptions): RawBus => { + const behaviour = script[Math.min(buses.length, script.length - 1)]; + if (behaviour === undefined) { + throw new Error('scriptedBuses called with an empty script'); + } + const bus = new FakeBus(behaviour); + buses.push(bus); + bus.arm(); + return bus; + }; + return { factory, buses }; +} + +function teardownReports(transport: { + on(event: 'error', handler: (payload?: unknown) => void): void; +}): TransportTeardownFailure[] { + const reports: TransportTeardownFailure[] = []; + transport.on('error', (payload) => { + if (payload instanceof TransportTeardownFailure) { + reports.push(payload); + } + }); + return reports; +} + +test('disconnect() during reconnect backoff resolves promptly instead of waiting the delay out', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed' }, { handshake: 'fail' }]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 30_000, maxDelayMs: 30_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let dropped = false; + transport.on('disconnected', () => { + dropped = true; + }); + + await transport.connect(); + expect(transport.isConnected()).toBe(true); + + // Drop the connection under the transport. Its first reconnect attempt fails, and the loop + // parks in a 30s backoff — an interval no honest close should ever have to sit through. + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + await waitFor(() => dropped && buses.length >= 2, 5_000, 'the first failed reconnect attempt'); + await sleep(50); + + const startedAt = Date.now(); + await transport.disconnect(); + const elapsedMs = Date.now() - startedAt; + expect(elapsedMs).toBeLessThan(100); + + // And the loop is genuinely gone rather than merely unawaited: no further attempt is made. + const attemptsAtClose = buses.length; + await sleep(200); + expect(buses.length).toBe(attemptsAtClose); +}, 15_000); + +test('a connection.end() that throws while abandoning a failed connect is reported', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'fail', endThrows: true }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { connectTimeoutMs: 50 } }, + factory, + ); + const reports = teardownReports(transport); + + await expect(transport.connect()).rejects.toThrow('fake handshake refused'); + + expect(reports).toHaveLength(1); + const report = reports[0]; + if (report === undefined) { + throw new Error('no teardown report was emitted'); + } + expect(report.phase).toBe('establish-abort'); + expect(report.step).toBe('connection-end'); + expect(report.name).toBe('TransportTeardownFailure'); + expect(report.cause).toBeInstanceOf(Error); + expect(buses[0]?.connection.endCalls).toBe(1); +}); + +test('a bus.disconnect() that rejects is reported and never rejects disconnect()', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed', disconnectRejects: true }]); + const transport = createDbusTransportForTest({ reconnect: { enabled: false } }, factory); + const reports = teardownReports(transport); + + await transport.connect(); + await transport.disconnect(); + + expect(transport.isConnected()).toBe(false); + expect(buses[0]?.disconnectCalls).toBe(1); + expect(reports).toHaveLength(1); + const report = reports[0]; + if (report === undefined) { + throw new Error('no teardown report was emitted'); + } + expect(report.phase).toBe('disconnect'); + expect(report.step).toBe('bus-disconnect'); +}); + +test('a teardown failure with no error listener attached does not throw', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed', disconnectRejects: true }]); + const transport = createDbusTransportForTest({ reconnect: { enabled: false } }, factory); + + await transport.connect(); + // Node re-throws an unobserved EventEmitter 'error'. A report about a failed teardown must + // not become the crash it is describing. + await transport.disconnect(); + expect(transport.isConnected()).toBe(false); +}); + +test('the new teardown failure paths leak no connection listener', async () => { + const { factory, buses } = scriptedBuses([ + { handshake: 'succeed', disconnectRejects: true }, + { handshake: 'fail', endThrows: true }, + ]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 20_000, maxDelayMs: 20_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let dropped = false; + transport.on('disconnected', () => { + dropped = true; + }); + + await transport.connect(); + const subscription = await transport.subscribeSignal( + { interface: 'tv.ceralive.Fake', member: 'Tick' }, + () => undefined, + ); + + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + await waitFor(() => dropped && buses.length >= 2, 5_000, 'the failed reconnect attempt'); + await sleep(50); + + await subscription.unsubscribe(); + await transport.disconnect(); + + expect(transport.subscriptionCount()).toBe(0); + expect(buses).toHaveLength(2); + for (const bus of buses) { + expect(bus.connection.listenerCount('message')).toBe(0); + expect(bus.connection.listenerCount('end')).toBe(0); + expect(bus.connection.listenerCount('connect')).toBe(0); + // Exactly one listener survives on each abandoned connection: the deliberate swallow the + // transport installs so a late socket error cannot crash the process. + expect(bus.connection.listenerCount('error')).toBe(1); + } +}); + +test('repeated drop signals produce one disconnected event and one reconnect loop', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed' }, { handshake: 'fail' }]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 20_000, maxDelayMs: 20_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let disconnects = 0; + transport.on('disconnected', () => { + disconnects += 1; + }); + + await transport.connect(); + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + first.connection.emit('error', new Error('a second drop signal')); + first.connection.emit('end'); + + await waitFor(() => buses.length >= 2, 5_000, 'the single reconnect attempt'); + await sleep(150); + + expect(disconnects).toBe(1); + // Two loops would each be establishing against the same script, so a second attempt would + // show up as a third bus well inside the 20s backoff. + expect(buses).toHaveLength(2); + + await transport.disconnect(); +}); + +test('the timing policy keeps the values this transport has always used', () => { + expect(DEFAULT_TRANSPORT_TIMING.connectTimeoutMs).toBe(2_000); + expect(DEFAULT_TRANSPORT_TIMING.callTimeoutMs).toBe(30_000); +}); + +test('an injected connect bound is the one the handshake enforces', async () => { + const { factory } = scriptedBuses([{ handshake: 'stall' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { connectTimeoutMs: 40 } }, + factory, + ); + transport.on('error', () => undefined); + + await expect(transport.connect()).rejects.toThrow('bus connect timed out after 40ms'); + + await transport.disconnect(); +}); + +test('an injected call bound reaches the call dispatcher', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { callTimeoutMs: 40 } }, + factory, + ); + + await transport.connect(); + await expect( + transport.callMethod({ + destination: 'tv.ceralive.Fake', + path: '/tv/ceralive/Fake', + interface: 'tv.ceralive.Fake', + member: 'Ping', + }), + ).rejects.toThrow('timed out after 40ms'); + + await transport.disconnect(); +}); + +test('the standalone callTimeoutMs option still applies when no timing policy is given', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, callTimeoutMs: 40 }, + factory, + ); + + await transport.connect(); + await expect( + transport.callMethod({ + destination: 'tv.ceralive.Fake', + path: '/tv/ceralive/Fake', + interface: 'tv.ceralive.Fake', + member: 'Ping', + }), + ).rejects.toThrow('timed out after 40ms'); + + await transport.disconnect(); +}); diff --git a/control/src/transport/transport.ts b/control/src/transport/transport.ts index 099dadf..ac4bb29 100644 --- a/control/src/transport/transport.ts +++ b/control/src/transport/transport.ts @@ -36,10 +36,6 @@ interface ResolvedReconnect { readonly maxAttempts: number; } -// Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect -// loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom -// while keeping reconnect responsive after a bus restart. -const CONNECT_TIMEOUT_MS = 2_000; const DEFAULT_RECONNECT: ResolvedReconnect = { enabled: true, initialDelayMs: 50, @@ -47,11 +43,74 @@ const DEFAULT_RECONNECT: ResolvedReconnect = { maxAttempts: 0, }; -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +// Every wall-clock bound this transport enforces, in ONE named object rather than scattered +// module constants — so a caller (or a test) moves them together and cannot leave the connect +// bound and the call bound disagreeing about how patient the transport is. +// +// The defaults are exactly the values this module has always used, unchanged. `connectTimeoutMs` +// bounds a single connect/auth attempt so a stalled handshake cannot freeze the reconnect loop: +// a local unix-socket D-Bus connect completes in milliseconds, so 2s is ample headroom while +// keeping reconnect responsive after a bus restart. `callTimeoutMs` is the per-call reply bound +// `./calls` has always defaulted to. +export interface TransportTimingPolicy { + readonly connectTimeoutMs: number; + readonly callTimeoutMs: number; +} + +export const DEFAULT_TRANSPORT_TIMING: TransportTimingPolicy = { + connectTimeoutMs: 2_000, + callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, +}; + +// `timing` outranks the standalone `callTimeoutMs`, which stays honoured so a caller that +// only cares about the reply bound never has to name the whole policy. +export interface DbusTransportSeamOptions extends DbusTransportOptions { + readonly timing?: Partial; +} + +function resolveTimingPolicy(options: DbusTransportSeamOptions): TransportTimingPolicy { + return { + connectTimeoutMs: options.timing?.connectTimeoutMs ?? DEFAULT_TRANSPORT_TIMING.connectTimeoutMs, + callTimeoutMs: + options.timing?.callTimeoutMs ?? + options.callTimeoutMs ?? + DEFAULT_TRANSPORT_TIMING.callTimeoutMs, + }; +} + +// Which teardown the transport was performing, and which step of it failed. Both are needed: +// the same step fails for different reasons depending on whether a caller asked to close or +// the transport is abandoning a half-open connection mid-reconnect. +export type TransportTeardownPhase = 'disconnect' | 'establish-abort'; +export type TransportTeardownStep = 'bus-disconnect' | 'connection-end'; + +// A teardown step failed. This is REPORTED on the transport's existing `error` event and is +// never thrown: by the time it happens the caller is already closing (or the establish error +// is already on its way up), so escalating would replace the failure a caller must act on with +// one they cannot. It is an `Error` subclass because everything else on that event is — +// consumers narrow with `instanceof` and branch on `phase` / `step`. +export class TransportTeardownFailure extends TransportError { + readonly phase: TransportTeardownPhase; + readonly step: TransportTeardownStep; + + constructor(phase: TransportTeardownPhase, step: TransportTeardownStep, cause: unknown) { + super(`D-Bus transport teardown step "${step}" failed during ${phase}`, { cause }); + this.name = 'TransportTeardownFailure'; + this.phase = phase; + this.step = step; + } +} + +// How the transport obtains a raw bus. Production is `createClient`; a test supplies its own +// through `createDbusTransportForTest`, so lifecycle failure paths are reachable without a +// real daemon to break. +export type BusFactory = (options: CreateClientOptions) => RawBus; class DbusTransportImpl implements DbusTransport { - readonly #options: DbusTransportOptions; + readonly #options: DbusTransportSeamOptions; readonly #reconnect: ResolvedReconnect; + readonly #timing: TransportTimingPolicy; + readonly #createBus: BusFactory; readonly #emitter = new EventEmitter(); readonly #calls: CallDispatcher; readonly #signals: SignalRegistry; @@ -59,6 +118,12 @@ class DbusTransportImpl implements DbusTransport { #bus: RawBus | null = null; #state: State = 'idle'; #closing = false; + // The single live reconnect loop, retained rather than fired and forgotten: a second drop + // cannot start a second loop, and `disconnect()` has something to await so it can promise + // that no loop outlives it. + #reconnectLoopPromise: Promise | null = null; + // Set only while that loop is parked in its backoff sleep; calling it cuts the sleep short. + #wakeBackoff: (() => void) | null = null; // Bound once so the same references can be detached from a dead connection. readonly #onMessage = (message: RawMessage): void => this.#signals.dispatch(message); @@ -67,9 +132,11 @@ class DbusTransportImpl implements DbusTransport { readonly #onConnectionEnd = (): void => this.#handleDrop(new DisconnectedError('bus connection ended')); - constructor(options: DbusTransportOptions) { + constructor(options: DbusTransportSeamOptions, createBus: BusFactory) { this.#options = options; - this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS); + this.#createBus = createBus; + this.#timing = resolveTimingPolicy(options); + this.#calls = new CallDispatcher(this.#timing.callTimeoutMs); this.#signals = new SignalRegistry({ currentBus: () => this.#bus, isConnected: () => this.#state === 'connected', @@ -106,10 +173,17 @@ class DbusTransportImpl implements DbusTransport { this.#state = 'closed'; const bus = this.#bus; this.#bus = null; + // Capture the loop BEFORE waking it: waking lets it run to completion, which clears the + // field, and a `disconnect()` that lost the handle could not wait for it. + const loop = this.#reconnectLoopPromise; + this.#cancelBackoff(); this.#calls.rejectAll(new DisconnectedError('transport closed')); if (bus) { this.#quiesce(bus); - await bus.disconnect().catch(() => undefined); + await this.#closeBus(bus, 'disconnect'); + } + if (loop) { + await loop; } } @@ -143,7 +217,8 @@ class DbusTransportImpl implements DbusTransport { options.busAddress = this.#options.busAddress; } - const bus = createClient(options); + const connectTimeoutMs = this.#timing.connectTimeoutMs; + const bus = this.#createBus(options); try { await new Promise((resolve, reject) => { const onConnect = (): void => { @@ -156,8 +231,8 @@ class DbusTransportImpl implements DbusTransport { }; const timer = setTimeout(() => { cleanup(); - reject(new TransportError(`bus connect timed out after ${CONNECT_TIMEOUT_MS}ms`)); - }, CONNECT_TIMEOUT_MS); + reject(new TransportError(`bus connect timed out after ${connectTimeoutMs}ms`)); + }, connectTimeoutMs); const cleanup = (): void => { clearTimeout(timer); bus.connection.removeListener('connect', onConnect); @@ -174,6 +249,13 @@ class DbusTransportImpl implements DbusTransport { // Re-issue every live match rule so a reconnect resubscribes transparently. await this.#signals.reissueRules(bus); + if (this.#closing) { + // `disconnect()` landed while this attempt was in flight. Fail the attempt so the + // shared catch below tears the fresh connection down — a closed transport must + // never publish a live bus that nobody is left to close. + throw new TransportError('Transport is closed'); + } + this.#bus = bus; this.#state = 'connected'; } catch (error) { @@ -182,8 +264,12 @@ class DbusTransportImpl implements DbusTransport { bus.connection.on('error', () => undefined); try { bus.connection.end(); - } catch { - // The half-open connection is already dead; nothing to close. + } catch (cause) { + // The half-open connection is usually already dead, so this ordinarily does + // nothing. When it does fail, that is a real teardown outcome and it is reported + // rather than discarded — but never rethrown, because `error` is the failure the + // caller actually needs to see. + this.#reportTeardownFailure('establish-abort', 'connection-end', cause); } throw error; } @@ -203,6 +289,33 @@ class DbusTransportImpl implements DbusTransport { bus.connection.on('error', () => undefined); } + // A rejected `bus.disconnect()` used to vanish into `.catch(() => undefined)`, leaving a + // consumer no way to learn that a socket it believed closed never actually was. + async #closeBus(bus: RawBus, phase: TransportTeardownPhase): Promise { + try { + await bus.disconnect(); + } catch (cause) { + this.#reportTeardownFailure(phase, 'bus-disconnect', cause); + } + } + + #reportTeardownFailure( + phase: TransportTeardownPhase, + step: TransportTeardownStep, + cause: unknown, + ): void { + this.#emitObservable('error', new TransportTeardownFailure(phase, step, cause)); + } + + // Node re-throws an unobserved EventEmitter 'error', which would turn a report ABOUT a + // failed teardown into a process crash on the path that is already unwinding. + #emitObservable(event: TransportEvent, payload: unknown): void { + if (event === 'error' && this.#emitter.listenerCount('error') === 0) { + return; + } + this.#emitter.emit(event, payload); + } + #handleDrop(cause: unknown): void { if (this.#closing) { return; @@ -218,10 +331,28 @@ class DbusTransportImpl implements DbusTransport { this.#calls.rejectAll(cause); this.#emitter.emit('disconnected', cause); if (this.#reconnect.enabled) { - void this.#reconnectLoop(); + this.#startReconnectLoop(); } } + // The state guard above already blocks the common double-drop, but holding the promise + // makes the single-loop property structural instead of a consequence of state ordering, + // and it is what lets `disconnect()` wait for the loop it just cancelled. + #startReconnectLoop(): void { + if (this.#reconnectLoopPromise !== null) { + return; + } + const loop = this.#reconnectLoop().catch((error: unknown) => { + this.#emitObservable('error', error); + }); + this.#reconnectLoopPromise = loop; + void loop.then(() => { + if (this.#reconnectLoopPromise === loop) { + this.#reconnectLoopPromise = null; + } + }); + } + async #reconnectLoop(): Promise { this.#state = 'reconnecting'; let delay = this.#reconnect.initialDelayMs; @@ -238,13 +369,48 @@ class DbusTransportImpl implements DbusTransport { this.#emitter.emit('error', error); return; } - await sleep(delay); + await this.#backoff(delay); delay = Math.min(delay * 2, this.#reconnect.maxDelayMs); } } } + + // A plain `setTimeout` promise holds the loop — and the event loop — for the whole + // remaining delay after a caller has already asked for teardown. At the default 2s ceiling + // that is a 2s stall on every close landing mid-backoff, and an unbounded one for a caller + // that raised the ceiling. + #backoff(ms: number): Promise { + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer); + if (this.#wakeBackoff === finish) { + this.#wakeBackoff = null; + } + resolve(); + }; + const timer = setTimeout(finish, ms); + this.#wakeBackoff = finish; + }); + } + + #cancelBackoff(): void { + const wake = this.#wakeBackoff; + this.#wakeBackoff = null; + wake?.(); + } +} + +export function createDbusTransport(options: DbusTransportSeamOptions = {}): DbusTransport { + return new DbusTransportImpl(options, createClient); } -export function createDbusTransport(options: DbusTransportOptions = {}): DbusTransport { - return new DbusTransportImpl(options); +// Deliberately NOT re-exported from `./index.ts`, whose public surface is pinned by +// `no-library-leak.test.ts`. It exists so lifecycle failure paths — a bus whose `disconnect()` +// rejects, a connection whose `end()` throws, a handshake that never completes — are reachable +// deterministically, without a real `dbus-daemon` to break. +export function createDbusTransportForTest( + options: DbusTransportSeamOptions, + busFactory: BusFactory, +): DbusTransport { + return new DbusTransportImpl(options, busFactory); } diff --git a/control/src/version-unity.test.ts b/control/src/version-unity.test.ts new file mode 100644 index 0000000..f5e58e8 --- /dev/null +++ b/control/src/version-unity.test.ts @@ -0,0 +1,42 @@ +import { test } from 'bun:test'; + +const FIRST_PARTY_PACKAGE_PATHS = [ + 'package.json', + 'control/package.json', + 'cli/package.json', +] as const; + +type PackageVersion = { + readonly path: string; + readonly version: string; +}; + +async function readPackageVersion(path: string): Promise { + const manifest: unknown = await Bun.file(path).json(); + if (typeof manifest !== 'object' || manifest === null) { + throw new Error(`${path} must contain a JSON object`); + } + + const version = Reflect.get(manifest, 'version'); + if (typeof version !== 'string') { + throw new Error(`${path} must contain a string version`); + } + + return { path, version }; +} + +test('first-party workspace package versions stay unified', async () => { + const packageVersions = await Promise.all(FIRST_PARTY_PACKAGE_PATHS.map(readPackageVersion)); + const root = packageVersions[0]; + if (root === undefined) { + throw new Error('first-party package version list is unexpectedly empty'); + } + + for (const packageVersion of packageVersions.slice(1)) { + if (packageVersion.version !== root.version) { + throw new Error( + `${packageVersion.path} version ${packageVersion.version} does not match ${root.path} version ${root.version}`, + ); + } + } +}); diff --git a/control/test-support/conformance/mm-transport.ts b/control/test-support/conformance/mm-transport.ts index c698b1a..e732895 100644 --- a/control/test-support/conformance/mm-transport.ts +++ b/control/test-support/conformance/mm-transport.ts @@ -48,8 +48,18 @@ export type RecordedCall = { readonly member: string; }; +/** + * An `a{sv}` member is ITSELF a variant on the wire, so its decode form is + * `[key, {signature, value}][]` — not `[key, [signature, value]][]`. Recursing here is + * what keeps this transport symmetric with the real codec; without it a `Modem.Signal` + * RAT dict decodes into a shape no daemon ever sends and the suite proves nothing. + */ function decodeVariant(variant: EncodeVariant): DbusValue { - return { signature: variant[0], value: variant[1] as DbusValue }; + const [signature, value] = variant; + if (signature.startsWith('a{s') && Array.isArray(value)) { + return { signature, value: decodeProps(value as readonly PropEntry[]) }; + } + return { signature, value: value as DbusValue }; } function decodeProps(props: readonly PropEntry[]): DbusValue { diff --git a/control/test-support/fake-mm/object-model.ts b/control/test-support/fake-mm/object-model.ts index 306f995..ebb6e2c 100644 --- a/control/test-support/fake-mm/object-model.ts +++ b/control/test-support/fake-mm/object-model.ts @@ -49,6 +49,10 @@ export type ManagedObject = readonly [string, readonly InterfaceEntry[]]; /** The full `a{oa{sa{sv}}}` GetManagedObjects payload. */ export type ManagedObjects = readonly ManagedObject[]; +/** The six per-RAT `a{sv}` properties of `Modem.Signal`, in introspection order. */ +export const MM_SIGNAL_RATS = ['Cdma', 'Evdo', 'Gsm', 'Umts', 'Lte', 'Nr5g'] as const; +export type MmSignalRat = (typeof MM_SIGNAL_RATS)[number]; + /** A visible network row returned by `Modem3gpp.Scan` (`a{sv}`). */ export interface ScannedNetworkEntry { readonly operatorCode: string; @@ -102,6 +106,17 @@ export interface ModemSpec { readonly bearerIndex?: number; /** Whether this modem exposes the `Modem.Signal` interface (default true). */ readonly hasSignal?: boolean; + /** + * Extended-signal readings per RAT, as `Modem.Signal` publishes them. + * + * MM exports all six `a{sv}` properties on every `Modem.Signal` interface and leaves + * the ones the modem is not attached on EMPTY, so a RAT omitted here is served as an + * empty dict rather than as a missing property — the difference the observation layer + * reads as "the modem did not say" instead of "nobody asked". + */ + readonly extendedSignal?: Readonly< + Partial>>> + >; /** MMModemLock currently required (`Modem.UnlockRequired`); default NONE. */ readonly unlockRequired?: number; /** Remaining attempts per lock (`Modem.UnlockRetries`, `a(uu)`). */ @@ -205,8 +220,20 @@ export function simProps(sim: SimSpec): readonly PropEntry[] { /** The `Modem.Signal` interface property set — its mere presence is what the * Signal.Setup manager gates on (absent ⇒ `signalCadence: unsupported`). */ -export function signalProps(): readonly PropEntry[] { - return [['Rate', ['u', 0]]]; +export function signalProps(spec?: ModemSpec): readonly PropEntry[] { + const readings = spec?.extendedSignal ?? {}; + return [ + ['Rate', ['u', 0]], + ...MM_SIGNAL_RATS.map( + (rat): PropEntry => [ + rat, + [ + 'a{sv}', + Object.entries(readings[rat] ?? {}).map(([member, value]) => [member, ['d', value]]), + ], + ], + ), + ]; } export function locationProps(spec: ModemSpec): readonly PropEntry[] { @@ -237,7 +264,7 @@ export function modemObject(spec: ModemSpec, shape: MmShape): ManagedObject { [MODEM3GPP_IFACE, modem3gppProps(spec)], ]; if (spec.hasSignal !== false) { - interfaces.push([SIGNAL_IFACE, signalProps()]); + interfaces.push([SIGNAL_IFACE, signalProps(spec)]); } if (spec.location !== undefined) interfaces.push([LOCATION_IFACE, locationProps(spec)]); if (spec.messaging === true) interfaces.push([MESSAGING_IFACE, []]); diff --git a/control/test-support/observation-fixtures.ts b/control/test-support/observation-fixtures.ts index e26b83b..942e594 100644 --- a/control/test-support/observation-fixtures.ts +++ b/control/test-support/observation-fixtures.ts @@ -24,6 +24,7 @@ import type { HilinkObservationInput, ModemManagerObservationInput, NormalizationContext, + RawFieldValue, UfiObservationInput, ZteObservationInput, } from '../src/observations'; @@ -49,10 +50,25 @@ export function fixtureContext( } /** - * A registered ModemManager modem. + * One `Modem.Signal` RAT dict in the shape the retention layer keeps it. * - * `Modem.Ports` and `Modem3gpp.Pco` are the vendor-specific extras here: real MM + * A D-Bus `a{sv}` decodes to `[key, value][]`, never to a flat object, so a fixture that + * spelled these as objects would be testing a payload ModemManager never sends. + */ +function signalDict(members: Readonly>): RawFieldValue { + return Object.entries(members); +} + +/** + * A registered ModemManager modem, attached 5G NSA. + * + * `Modem.Ports`, `Modem3gpp.Pco` and `Signal.Rate` are the extras here: real MM * properties that this layer's normalized model has no slot for. + * + * `Signal` carries the REAL interface shape — one `a{sv}` per RAT. `Nr5g` and `Lte` are + * both populated because an NSA attach populates both, which is exactly the case where + * the RAT ladder has to choose and provenance has to say which it chose. Every remaining + * dict is present-and-empty, as MM exports them for RATs the modem is not on. */ export const MM_FIXTURE: ModemManagerObservationInput = { modem: { @@ -70,22 +86,69 @@ export const MM_FIXTURE: ModemManagerObservationInput = { }, modem3gpp: { RegistrationState: 1, + // The REGISTERED operator. `sim.OperatorName` below is the SIM's HOME operator + // and is deliberately a different string, so a normalizer that read the wrong + // interface is visible rather than accidentally correct. + OperatorName: 'Claro', + OperatorCode: '732101', Pco: 'dns-primary=10.0.0.1', }, + // `Modem.Location`, keyed by DECODED SOURCE NAME. The value is MM's own five-token + // `3gpp-lac-ci` string: MCC, MNC, then LAC / CI / TAC in uppercase hex. + location: { + '3gpp-lac-ci': '732,101,2B1C,0A1B2C3D,4E5F', + }, sim: { SimType: 1, EsimStatus: 0, OperatorName: 'CLARO COL', }, signal: { - rssi: -71, - rsrp: -98.5, - rsrq: -11, - snr: 6.5, - refresh_rate: 5, + Rate: 5, + Nr5g: signalDict({ rsrp: -98.5, rsrq: -11, snr: 6.5, 'error-rate': 0 }), + Lte: signalDict({ rssi: -71, rsrp: -104, rsrq: -13.5, snr: 4, 'error-rate': 0 }), + Cdma: [], + Evdo: [], + Gsm: [], + Umts: [], + }, +}; + +/** + * A CDMA/EV-DO reading — `Evdo` is the ONE `Modem.Signal` dict MM defines `sinr` on. + * + * It exists so the SINR claim is proven against the dict that really carries it rather + * than asserted against an LTE payload that never could. + */ +export const MM_EVDO_SIGNAL_FIXTURE: ModemManagerObservationInput = { + modem: { + Model: 'MC7354', + Manufacturer: 'Sierra Wireless', + State: 8, + SignalQuality: [44, true], + }, + signal: { + Rate: 5, + Evdo: signalDict({ rssi: -83, ecio: -2.5, sinr: 9.5, io: -95, 'error-rate': 0 }), + Cdma: signalDict({ rssi: -83, ecio: -2.5, 'error-rate': 0 }), + Gsm: [], + Umts: [], + Lte: [], + Nr5g: [], }, }; +/** + * The `Modem.Signal` interface READ, with every RAT dict empty. + * + * This is the honest shape of a modem that has not reported extended signal yet — the + * interface exists, the reading does not. It must never normalize to a zero. + */ +export const MM_SIGNAL_SILENT_FIXTURE: ModemManagerObservationInput = { + modem: { Model: 'RM530N-GL', Manufacturer: 'Quectel', State: 8 }, + signal: { Rate: 5, Cdma: [], Evdo: [], Gsm: [], Umts: [], Lte: [], Nr5g: [] }, +}; + /** * A HiLink dongle answering both bodies. * diff --git a/docs/BENCH.md b/docs/BENCH.md index 34c917d..c960c90 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -1577,6 +1577,101 @@ guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed o --- +## RB-18 — Sierra identity + composition capture `[PARTIAL]` + +Capture a real Sierra module's redacted stage-1 identity bundle and its observed USB +composition. This is groundwork only: it certifies no SKU, adds no catalog entry, performs +no FCC unlock, and sends no AT command. + +**Preconditions** + +- A Sierra module physically attached under Sierra's own VID `1199`, HP's `03f0`, or Dell's + `413c`, and visible in `mmcli -L`. +- The compiled `modem-control` binary and the existing `certify` prerequisites from the + shared RB-11…RB-15 contract. + +**Commands** + +```sh +OUT=test-results/modem-control/RB-18; mkdir -p "$OUT" + +# 1) Presence gate. An absent device is a named SKIP and certify is not invoked. +SIERRA_USB=$(lsusb | grep -Ei ' ID (1199|03f0|413c):' || true) +if [ -z "$SIERRA_USB" ]; then + { + echo 'runbook: RB-18' + echo 'outcome: SKIP' + echo 'reason: device-not-present' + echo 'certify_invoked: false' + echo 'bundle: none' + } | tee "$OUT/status.txt" + exit 0 +fi + +# 2) Resolve the Sierra MM slot from a fresh inventory; never reuse a recorded index. +mmcli -L | tee "$OUT/mmcli-list.txt" +SLOT=${SIERRA_MM_SLOT:?set from the Sierra row in the fresh mmcli listing} + +# 3) Existing redacted identity capture. No --transition and no AT/FCC action. +./modem-control certify "$SLOT" --output "$OUT/bundle.json" \ + 2>&1 | tee "$OUT/certify.txt" + +# 4) Record the descriptors and bound drivers that determined the composition. +usb-devices | tee "$OUT/usb-devices.txt" +{ + echo 'runbook: RB-18' + echo 'outcome: EXECUTED' + echo 'reason: none' + echo 'certify_invoked: true' + echo 'bundle: bundle.json' +} | tee "$OUT/status.txt" +``` + +**Expected output** + +With the module attached, `certify` ends with the real-capture gate: + +``` +CERTIFY OK: sha256=<64 hex> synthetic=false transition=none slot= +``` + +Without it, `status.txt` names the skip rather than simulating a bundle: + +``` +outcome: SKIP +reason: device-not-present +certify_invoked: false +bundle: none +``` + +**Machine check** + +```sh +OUT=test-results/modem-control/RB-18 +if grep -Fqx 'outcome: EXECUTED' "$OUT/status.txt"; then + grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/certify.txt" \ + && grep -Eq '"vidPid": *"(1199|03f0|413c):[0-9a-f]{4}"' "$OUT/bundle.json" \ + && echo 'RB-18 PASS' || echo 'RB-18 FAIL' +elif grep -Fqx 'outcome: SKIP' "$OUT/status.txt" \ + && grep -Fqx 'reason: device-not-present' "$OUT/status.txt" \ + && grep -Fqx 'certify_invoked: false' "$OUT/status.txt"; then + echo 'RB-18 SKIP reason=device-not-present' +else + echo 'RB-18 FAIL (neither executed bundle nor named skip)' +fi +``` + +**Status (2026-08-25):** `[PARTIAL]` — `SKIP`, reason `device-not-present`. The local USB +inventory contained no `1199:*`, `03f0:*`, or `413c:*` device, so `certify` was not invoked +and no bundle exists. This is a recorded hardware absence, not a passing capture. + +**Evidence:** `test-results/modem-control/RB-18/status.txt` (repo-local, gitignored); on an +executed run the same directory also carries `{mmcli-list,certify,usb-devices}.txt` and +`bundle.json`. + +--- + ## Evidence index | Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | @@ -1598,6 +1693,7 @@ guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed o | RB-15 | ZTE MF79U router-mode capture | `[PARTIAL]` | `test-results/modem-phase-b/08/zte-mf79u/{class-evidence.txt,lan-dhcp.txt,web-ui.txt}` | | RB-16 | Fibocom FM350 USB-vs-PCIe probe | `[PARTIAL]` | `test-results/modem-phase-b/09/{usb-sweep,driver-binding,pcie-sweep,mmcli-list,mm-version,bearer-connect,hil-cycle-fm350}.txt` | | RB-17 | Modem-flap resilience under a live bonded stream | `[PARTIAL]` | `test-results/modem-phase-b/08/flap/{baseline.txt,flap-x5.txt,final-groups.txt,hub-map.json}` | +| RB-18 | Sierra identity + composition capture | `[PARTIAL]` — `SKIP: device-not-present` | `RB-18/status.txt` (+ `{mmcli-list,certify,usb-devices}.txt`, `bundle.json` when executed) | Every row stays `[PARTIAL]` until its evidence artifact is captured on a real bench device and its machine check prints `PASS`. No row may be claimed `[EXISTS]` on the strength of the diff --git a/docs/COMPAT-MATRIX.md b/docs/COMPAT-MATRIX.md new file mode 100644 index 0000000..551d622 --- /dev/null +++ b/docs/COMPAT-MATRIX.md @@ -0,0 +1,257 @@ +# Compatibility matrix — vendor × firmware × composition × operation + +One tracked matrix for the whole modem stack: which hardware rows this repository knows +about, which operations it has code for, and, for every cell, exactly how much is actually +known. It is the answer to "does CeraLive work with this modem", stated in the only +vocabulary that cannot overstate itself. + +Two documents sit either side of this one and are not duplicated here: + +- [`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md) is the sourced per-vendor reading list, with a + pinned citation for every behavioural claim. When you want to know *why* a cell below + reads the way it does, that is the document, organised as one `Quirk | Evidence | Posture` + table per vendor behind a "how to read the posture column" preamble, a pinned six-key + SOURCES table, and a closing "what this does NOT license". Do not re-derive its content + into this file. +- [`BENCH.md`](BENCH.md) is the runbook ladder RB-1 … RB-18 and the per-SKU status ledger. + **Every hardware evidence claim lives there and only there.** This matrix links to it and + deliberately restates none of its per-runbook status. + +## The only vocabulary in use + +Every claim cell below is one member of the five-state support-claim ladder in +[`control/src/capability/support-claim.ts`](../control/src/capability/support-claim.ts): + +``` +unavailable → implemented → enabled → capable → certified +``` + +- **`unavailable`** — not shipped in this build, or the device positively lacks it. It is + the rung *below* `implemented`, never above it. +- **`implemented`** — code exists in this repository, gate OFF, unproven on the device. +- **`enabled`** — gate ON, capability unknown. +- **`capable`** — gate ON, device advertises it. The floor for offering a control. +- **`certified`** — proven on this exact model and firmware. The only rung a support matrix + may claim as support. + +**No cell in this document exceeds `implemented`, and `enabled` / `capable` / `certified` +appear nowhere in the matrix at all.** That is not modesty. `enabled` is a statement about a +deployment's gate settings, which a document does not have; `capable` needs a live probe +against one device; `certified` needs a completed hardware drill. A document produces none +of the three. The rung a row reaches is raised by a bench capture under +[`BENCH.md`](BENCH.md), never by an edit here. + +**There is no second status vocabulary.** No "partial", no "works", no "untested", no +tick-and-cross. Those words are how a matrix comes to promise a combination nobody ever +ran, which is the exact failure the ladder was built to prevent. If a cell needs more +nuance than the ladder carries, it gets a footnote, not a new word. + +One column below does use a different vocabulary, and it is labelled as such: the FCC state +in Table A is `fcc/coverage.ts`'s coverage tri-state (`present` / `absent` / `unknown`), +which answers "does ModemManager ship an unlock procedure keyed on these ids". It is a fact +about MM's catalog, not a support claim, and it is deliberately kept out of Table B. + +--- + +## Table A — the hardware rows + +Vendor and model, firmware/SKU including FCC state, and protocol/composition. The evidence +column names the classifier tier that placed the row; `family` is the Table B column the row +answers under. + +| # | Vendor / model | Firmware / SKU, FCC state | Protocol / composition | Classifier evidence | Family | +|---|---|---|---|---|---| +| 1 | Quectel RM530N-GL | `RM530NGLAAR05A01M4G`; FCC coverage `absent` | QMI raw-IP (`2c7c`) | vendor id, whole range cellular | Quectel | +| 2 | SIMCom SIM7600G-H | firmware unrecorded on the bench; FCC coverage `absent` | QMI raw-IP (`1e0e`); one Zero-CD id `1e0e:f000` | vendor id | SIMCom | +| 3 | Fibocom FM350-GL, carrier-mounted | carrier id `0e8d:7127`; FCC coverage `unknown` | RNDIS over the carrier's USB composition | no classifier row, deliberately | Fibocom | +| 4 | Fibocom FM350-GL, native | PCI `14c3:4d75` under `mtk_t7xx`; FCC coverage `unknown` | PCIe, not USB | none; documented-deferred | see note **F** | +| 5 | Sierra EM74xx | `1199:9071`, `1199:907b`; FCC coverage `absent` for these ids | MBIM / QMI | `mainline-kernel` | Sierra | +| 6 | Sierra EM74xx, FCC-locked ids | `1199:9079`, `03f0:4e1d` (HP), `413c:81a3` / `413c:81a8` (Dell); FCC coverage `present` | MBIM / QMI | `modemmanager-1.24.2-fcc` | Sierra | +| 7 | Sierra EM75xx | `1199:9091`, `1199:c081`; FCC coverage `absent` | MBIM / QMI | `mainline-kernel` | Sierra | +| 8 | Sierra EM919x | `1199:90d3`; FCC coverage `absent` | MBIM / QMI | `mainline-kernel` | Sierra | +| 9 | Telit module families | `1bc7:` `1031` LE910C1-EUX, `1034` LE910C4-WWX, `1040` LE922A, `1050` FN980, `1060` LN920, `1070` FN990A, `1080` FE990A, `10a0` FN920C04, `1100` ME910, `1200` LE920; FCC coverage `absent` | one application PID per composition: QMI rmnet, MBIM, RNDIS, ECM | `mainline-kernel` | Telit | +| 10 | u-blox LARA-R6 | `1546:1311` / `1312` / `1313`; FCC coverage `absent` | per-PID composition | `mainline-kernel` | u-blox | +| 11 | u-blox LARA-L6 | `1546:1341` / `1342` / `1343`; FCC coverage `absent` | per-PID composition | `mainline-kernel` | u-blox | +| 12 | NETGEAR LB1120 | `0846:68e1`, `familyKind: 'router-webui'`; FCC coverage `absent` | Ethernet-over-USB tether plus a vendor web UI | `usb-ids-registry` | NETGEAR | +| 13 | Huawei E3372H HiLink | firmware `22.200.05.00.1080`, password type 3; FCC coverage `absent` | CDC-ECM plus HTTP web UI, no control port | vendor id `12d1` | Huawei | +| 14 | Huawei E3372H HiLink | firmware `22.333.01.00.00`, password type 4; FCC coverage `absent` | CDC-ECM plus HTTP web UI, no control port | vendor id `12d1` | Huawei | +| 15 | Huawei Zero-CD installer personality | e.g. `12d1:1f01`, target `14db`; FCC coverage `absent` | mass-storage installer until mode-switched | vendor id plus a `usb_modeswitch` trigger | Huawei | +| 16 | ZTE MF79U | legacy base64 under `LOGIN`; FCC coverage `absent` | router web UI (`goform`), Zero-CD lineage | vendor id `19d2` | ZTE | +| 17 | ZTE MF79U | `LD`-salted SHA-256 under the same bare `LOGIN`; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 18 | ZTE MF266 | salted SHA-256 under `LOGIN_MULTI_USER`, `stok` + `RD` + derived `AD`; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 19 | ZTE, unrecognised firmware | fingerprinted, read-only telemetry profile; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 20 | Qualcomm UFI / HIMI | `05c6:9091`, measured four-interface composition; FCC coverage `absent` | QMI plus an ADB-class interface; HIMI HTTP telemetry | vendor id `05c6` | UFI/HIMI | +| 21 | Qualcomm UFI / HIMI | `05c6:9024`; FCC coverage `absent` | RNDIS plus ADB | vendor id `05c6` | UFI/HIMI | +| 22 | Phone tether, any vendor | not applicable; FCC coverage `absent` | RNDIS or ECM/NCM data interface, no control interface | none; no positive cellular evidence | Tether | + +**Note F — the FM350-GL is two rows because it is two devices.** Row 3 is the carrier board +that presents it over USB; row 4 is the native PCIe part under `mtk_t7xx`, which has no USB +VID:PID at all and therefore no classifier row, on purpose. Every Table B claim for row 4 is +`unavailable`: this stack's enumeration, classification and identity derivation are all USB +snapshot driven, so a PCIe part reaches none of them. That is documented-deferred rather than +broken; see [`FM350-DECISION.md`](FM350-DECISION.md). + +**Note N — the NETGEAR row is a family label, and its device class is an open gap.** The row +labels the LB1120 `router-webui`, which is a positive claim about the family and nothing +more. The device class is decided by interfaces, not by the family row, and `0846` is +deliberately absent from `CELLULAR_USB_VENDOR_IDS` (its USB ID Repository block is mostly +Wi-Fi and Ethernet adapters, so a vendor-keyed rule there would report a Wi-Fi dongle as a +cellular uplink). With no kernel modem driver claiming `68e1` and no modeswitch trigger, +`classifyUsbNetDevice` finds no positive cellular evidence and answers **`wired-ethernet`, +not `router-cellular`**. So an attached LB1120 is presently not recognised as a cellular +uplink at all. This is recorded as a known gap rather than papered over, and it is why the +NETGEAR column below is mostly `unavailable`: no operation in this stack reaches a device it +does not classify as cellular. Row 22 reads the same way for the same reason, and that shared +reason is the point: a phone's RNDIS tether and a USB Ethernet dongle are identical on the +wire. + +--- + +## Table B — operation × family + +Eighteen operations, from first enumeration through a sustained bonded uplink. Columns are +the Table A families. Read every cell as a claim about *this repository's code*, at the rung +the ladder allows a document to state. + +| # | Operation | Quectel | SIMCom | Fibocom | Sierra | Telit | u-blox | NETGEAR | Huawei | ZTE | UFI/HIMI | Tether | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | Enumerate: USB descriptors, `/sys` composition, udev properties | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 2 | Classify: device class from interfaces and drivers | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 3 | Identity: `PhysicalModemId` stable across replug | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 4 | Control surface: an MM control port, or a vendor HTTP session | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 5 | SIM presence as evidence | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 6 | SIM unlock: PIN and PUK | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 7 | Registration and cell context read | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 8 | Signal read, including the per-RAT extended metrics | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 9 | Radio capability read: mode and band catalogs | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 10 | Mode write, including the 5G preference postures | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 11 | Band lock write, certification-gated | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 12 | USB composition switch | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | +| 13 | FCC auto-unlock policy reconciliation | unavailable | unavailable | unavailable | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | +| 14 | SMS list and read, never send or delete | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 15 | USSD session | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 16 | GNSS fix, live only, no history | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 17 | Bearer and APN activation | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 18 | Sustained bonded uplink: flap, re-enumeration, applied-state recovery | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | + +### Reading the cells that are easy to misread + +- **Operation 11 is `implemented`, not `unavailable`, and the shipped band catalog is + empty.** The code exists, gate off, and a band write is refused today on every device for + want of a four-proof catalog entry. `unavailable` would say "not shipped", which is false; + `capable` would say a modem advertised it, which no document may say. Band lock is also the + one module that requires `certified` rather than `capable` to be OFFERED at all, a + deliberate deviation from the framework floor, because a band the SIM's network does not + operate on registers nowhere and a modem that ignores a reset leaves an operator with no + way back short of a replug. +- **Operation 12 is `implemented` for exactly four vendors** — Fibocom, Quectel, SIMCom and + Sierra — because `RUNTIME_COMPOSITION_VENDORS` names those four and no others. A vendor + outside that set has no reviewed READ/TEST/SET form, so there is no code to claim. Targets + still come from the device's own enumeration and only when that enumeration proves a return + path. +- **Operation 13's `unavailable` is a positive statement, not a shrug.** `fcc/coverage.ts` + answers `absent` when the ids are well formed and are not in ModemManager's pinned mapping, + which is knowledge about the device. It answers `unknown` only when the ids could not be + read. Sierra is `implemented` because four Sierra-silicon ids are covered (under Sierra's + own, HP's and Dell's vendor ids), and because this repo records an opt-in policy and + re-derives MM's symlink; it ships no unlock script and performs no unlock. +- **Operations 5 and 6 are `unavailable` for every router family on purpose.** Huawei, ZTE + and the UFI each report their own SIM code (`SimStatus`, `simcard_state`, `simstate`) with + vendor semantics that no decoder here covers. The code stays verbatim in the diagnostics + block for a per-vendor provider to claim later with evidence. Guessing one would invent a + reading, which is the failure the observation layer exists to prevent. +- **Operation 18 is about this stack surviving a flap, not about bonding.** No bonding code + lives in this repository; SRTLA is elsewhere in the workspace. What is claimed here is + generation fencing, applied-state loss reporting, and re-promotion after a re-enumeration + on a leg this stack tracks. A family it classifies `wired-ethernet` is not a tracked + cellular leg, hence `unavailable` for NETGEAR and Tether. +- **A cell says nothing about a firmware that is not in Table A.** Family-level code is + generic and runtime discovered, so an unlisted PID within a listed vendor still reaches the + generic controls, but it gets no family label and no row here. Absence from Table A is + absence of a claim, never a claim of absence. + +### What the operation axis deliberately omits + +Data-usage metering is not an operation column, because it is measured from +`/proc/net/dev` and a local policy file and is therefore identical for every family that has +a tracked interface at all. Making it a column would add eleven cells that all say the same +thing for a non-vendor reason. Its accuracy gate is RB-6 in [`BENCH.md`](BENCH.md). + +### Why Sierra's composition switch is `implemented` even though `AT!` is fenced + +`control/src/usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP?` / +`AT!USBCOMP=?` / `AT!USBCOMP=` forms in the composition registries, which is what makes +operation 12 `implemented` for Sierra. The forms are reviewed, allowlisted by name, and +gated by the same admission, journal, rollback and readback fences as every other +composition write. + +That coexists with the `AT!` fence rather than contradicting it, because the two speak +about different surfaces. [`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md)'s Sierra `AT!` row is +about the **password-gated** `AT!BAND` / `AT!ENTERCND` surface, which is absent from this +repository and stays `unavailable`; the static gate it cites (`providers/ufi-himi`) scans +that provider's directory, not the repository. An earlier revision of that row claimed a +repo-wide absence of every `AT!` form, which was not true — it has been narrowed to the +surface it actually describes. + +--- + +## Hardware-free versus hardware-required + +The split below is the whole reason this matrix can be trusted: it says exactly which claims +a green CI run establishes, and which ones only a bench device can. + +### Hardware-free — provable in CI, no device attached + +These are the claims Table B's `implemented` rungs actually rest on, and every one is +exercised by the workspace suite on any machine: + +- **Every Table A row's identity.** Vendor and model rows are exact VID:PID map entries with + a pinned evidence tier; a test asserts every tier a row uses has provenance recorded. No + vendor range is ever inferred, and an unlisted PID returns nothing. +- **Classification from a snapshot.** Interface-and-driver precedence, the `router-mode` and + `pending-modeswitch` and `wired-ethernet` branches, and the proof that a family label + decides no device class. Note N's LB1120 gap is a CI-provable fact, not a bench finding. +- **Physical identity derivation** across the serial → udev `ID_PATH` → bounded-fallback + ladder, including the constructors' refusal of interface names, addresses and subscriber + identifiers. +- **Response parsing and normalization** for every router family, over the canonical fixture + corpus, including the malformed, auth-expired and lockout variants. +- **Every refusal path.** Band-write certification refusal, composition suppression states, + the read-only SMS and UFI fences, the USSD illegal-verb refusals, the prohibited Qualcomm + operations answering before any transport contact. +- **FCC coverage lookups**, which are a table read against MM's pinned mapping. +- **The provider-matching conformance matrix**, including its 16-modem scale case, which is a + fixture upper bound and never a hardware claim. +- **The vocabulary cap itself**: that this document and the matrix above use only ladder + members. See the grep in the evidence note below. + +Nothing in this list can raise a cell above `implemented`, by construction. A fixture proves +that code behaves as written; it cannot prove a radio did anything. + +### Hardware-required — only a bench device can raise these + +Every operation from Table B row 5 onward needs an attached device, a live SIM, or both, +before its cell can move past `implemented`. The runbook that captures each one is in +[`BENCH.md`](BENCH.md) (RB-1 … RB-18), and that document owns the per-runbook status; this +matrix links and does not restate it. + +| Table B operations | What hardware is required | Where the evidence is captured | +|---|---|---| +| 1-3 | A device attached, for a real descriptor and udev capture; the derivations themselves are hardware-free | RB-9 fleet inventory, RB-2 slot-UID stability, RB-18 Sierra identity and composition | +| 4 | The device's control interface actually claimed by a driver and, for routers, a reachable web UI | RB-1 system-bus probe, RB-14 Huawei personality, RB-15 ZTE, RB-16 FM350 | +| 5-7 | A physical SIM, and a network the SIM registers on | RB-4 PIN/PUK, and the per-SKU captures RB-11 … RB-15 | +| 8-9 | A registered modem reporting live measurements | RB-11 … RB-15 | +| 10-12 | A disruptive write, its readback, and a re-enumeration | RB-5 certified USB-mode transition, RB-11 … RB-15 stage 2 | +| 13 | A covered Sierra unit, and a boot to prove the reconciler re-materialises the link | RB-12 Sierra locked-state capture, RB-18 | +| 14-16 | A SIM that can receive a message, open a session, and a GNSS antenna | RB-11 … RB-15 | +| 17-18 | A live bearer and a deliberate flap under load | RB-17 modem-flap resilience, RB-10 hub VBUS cycle, RB-6 usage accuracy | + +Two consequences follow, and both are deliberate: + +1. **No row in this matrix is `certified`, so no combination in it may be described as + supported.** `mayClaimSupport` returns true for exactly one rung, and no cell reaches it. +2. **A bench run raises a cell; an edit here does not.** A cell moves when a capture under + [`BENCH.md`](BENCH.md) lands and a human-reviewed commit records it, exactly like a + certified-catalog entry. Editing a cell without that evidence is the same error as + promoting a catalog entry from a synthetic bundle, which the ingestion code refuses + outright. diff --git a/docs/FCC-UNLOCK-COVERAGE.md b/docs/FCC-UNLOCK-COVERAGE.md index 1675ffb..d952797 100644 --- a/docs/FCC-UNLOCK-COVERAGE.md +++ b/docs/FCC-UNLOCK-COVERAGE.md @@ -42,6 +42,9 @@ opened by the dispatcher; it is only ever the *target* of a `:` link. `data/dispatcher-fcc-unlock/` in ModemManager 1.24.2 contains four real scripts, one per vendor, and the build installs a `:` symlink onto the right one for every model the vendor script covers (`meson.build`, the `vidpids` dict). +The classifier mirror pins that source as upstream commit +`f2b9ab1ad78d322f32134a444b5b54c6e8160e19` in `MM_FCC_UNLOCK_SOURCE`; its test asserts +all 14 entries and all four Sierra-branded keys exactly. | Vendor script | Talks to the modem via | |---|---| @@ -126,6 +129,7 @@ the CeraLive toggle. | Quectel EM120R-GL | `2c7c:030a` | ✅ | | Quectel EM060K-GL | `2c7c:0314` | ✅ | | Sierra Wireless EM7455 / MC7455 | `1199:9079` | ✅ | +| Sierra Wireless EM9191 | `1199:90d3` | ❌ — known classifier family, no MM 1.24.2 FCC procedure | | Dell DW5811e / DW5816e | `413c:81a3` / `413c:81a8` | ✅ | | HP lt4120 / lt4132 | `03f0:4e1d` | ✅ | | Foxconn T77W968 / T99W175 | `105b:e0ab` / `105b:e0c3` | ✅ | diff --git a/docs/VENDOR-QUIRKS.md b/docs/VENDOR-QUIRKS.md new file mode 100644 index 0000000..6f026d4 --- /dev/null +++ b/docs/VENDOR-QUIRKS.md @@ -0,0 +1,147 @@ +# Vendor quirks — a sourced reading list, not a support matrix + +This document records the per-vendor behaviours that make one cellular module behave +differently from another on Linux, **with a citation for every claim**. It exists so that +the next person to write a provider, a classifier row, or a compatibility matrix starts +from evidence instead of from folklore. + +It is deliberately NOT a support matrix. Nothing here says CeraLive works with a device. + +## How to read the "posture" column + +Every row carries a CeraLive posture drawn from the five-state support-claim ladder in +[`control/src/capability/support-claim.ts`](../control/src/capability/support-claim.ts): + +``` +unavailable → implemented → enabled → capable → certified +``` + +**No row in this document may sit above `implemented`, and none does.** `capable` requires +a live probe against a specific modem; `certified` requires a proven drill on an exact +model and firmware. A document cannot produce either — only hardware can, and only through +the per-SKU capture runbooks in [`BENCH.md`](BENCH.md). Reading a quirk write-up is not +evidence about a device, so a doc that promoted itself past `implemented` would be +manufacturing exactly the false confidence the ladder exists to prevent. + +The two rungs that appear below mean: + +- **`unavailable`** — CeraLive ships no code for this at all. That is the honest answer for + every vendor-specific AT surface named here; the vendor's own behaviour is documented so + a future change starts from a source, not so anything is claimed today. +- **`implemented`** — code exists in this repository, gate OFF, unproven on the device. + +## The one rule that governs this whole file + +**Nothing here is on a write path, and nothing here may be put on one.** + +A quirk is a description of how somebody else's firmware behaves. Turning a description +into an automatic corrective action means writing to a radio on the strength of a document, +which is precisely what the evidence gates in +[`CATALOG-INGESTION.md`](CATALOG-INGESTION.md) and +[`control/src/band/certification.ts`](../control/src/band/certification.ts) refuse. A row +below may inform a classifier LABEL, a diagnostic READ, or a doc. It may never select an AT +command, a QMI/MBIM write, a composition switch, or a band lock. + +## Sources, pinned + +| Key | Source | Pin | +|-----|--------|-----| +| MM | ModemManager `src/plugins/` — [gitlab.freedesktop.org/mobile-broadband/ModemManager](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/tree/1.24.2/src/plugins) | tag `1.24.2` (the release [`packaging/upstream-pins.yaml`](../packaging/upstream-pins.yaml) rebuilds) | +| KERNEL | Linux `drivers/net/usb/qmi_wwan.c`, `drivers/usb/serial/option.c` — [github.com/torvalds/linux](https://github.com/torvalds/linux/blob/master/drivers/net/usb/qmi_wwan.c) | `45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229` | +| USBIDS | The USB ID Repository — [linux-usb.org/usb.ids](http://www.linux-usb.org/usb.ids) | version `2026.06.26` | +| UQMI | OpenWrt `uqmi` protocol handler `qmi.sh` — [github.com/openwrt/openwrt](https://github.com/openwrt/openwrt/blob/main/package/network/utils/uqmi/files/lib/netifd/proto/qmi.sh) | branch `main` | +| MODESWITCH | `usb-modeswitch-data` device data — upstream [draisberghof.de/usb_modeswitch](https://www.draisberghof.de/usb_modeswitch/), file listing read from the [Distrotech mirror](https://github.com/Distrotech/usb-modeswitch-data/tree/master/usb_modeswitch.d) | 297 device files | +| BELABOX | BELABOX tutorial wiki — [Peripherals & modems](https://github.com/BELABOX/tutorial/wiki/Peripherals,-accessories-and-power-banks), [M.2 initial setup](https://github.com/BELABOX/tutorial/wiki/Initial-setup-steps-for-various-M.2-modem-module-models), [README](https://github.com/BELABOX/tutorial) | wiki head | + +--- + +## Quectel (`2c7c`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **QMI data format is raw-IP, not 802.3, on modern modules.** The bearer will not carry traffic until the driver is told, and the switch is a `/sys` write on the netdev, not a QMI message. | UQMI — `qmi.sh` writes `Y` to `/sys/class/net/$ifname/qmi/raw_ip` and fails with `"Device only supports raw-ip mode but is missing this required driver attribute"` when the attribute is absent. | `implemented` — the device path is NetworkManager's; CeraLive owns no data-format write. | +| **AT is tunnelled over MBIM, through the firmware-update service.** On an MBIM composition there is no separate AT tty; MM reaches AT by wrapping it in a Quectel vendor CID. | MM — `quectel/mm-port-mbim-quectel.c` sends `MBIM_QUECTEL_COMMAND_TYPE_AT` inside `MBIM_SERVICE_QDU` / `MBIM_CID_QDU_COMMAND`, and probes `mm_port_mbim_supports_command` first. Copyright Quectel, 2024. | `unavailable` — CeraLive sends no AT over MBIM. | +| **Port roles are per-PID, not per-vendor.** Which `ttyUSB` is AT, which is GPS and which is QCDM differs between an EG06 and an EG91; MM ships a udev rule per product id. | MM — `quectel/77-mm-quectel-port-types.rules` enumerates `ATTRS{idProduct}` one model at a time (`0306`, `0191`, …), never a vendor-wide default. | `implemented` — this is why `CELLULAR_USB_MODEL_ROWS` is keyed on exact VID:PID. | +| **`+QNWPREFCFG` is a vendor AT command ModemManager does not implement.** The 5G SA/NSA preference operators look for is not reachable through MM's D-Bus surface on a Quectel. | MM — grep of all six `quectel/*.c` plugin files at `1.24.2` returns zero occurrences of `QNWPREFCFG`; the plugin's AT vocabulary is `+QGMR?`, `+QGPS*`, `+QUSIM`. | `unavailable` — matches the `not-exposed-by-modemmanager` reason `capability/five-g-preference.ts` already reports. | +| **QMI stalls and MBIM MTU lockups are reported by operators, not by a pinned source.** | BELABOX — RM520N-GL/RM530N-GL rows note instability on USB 2.0/3.0 ports with some carrier boards and a dependence on the SBC's current supply. No upstream source pins a QMI-stall or MBIM-MTU defect. | `unavailable` — recorded as an operator report, deliberately NOT as a device claim. | + +## Sierra Wireless (`1199`, plus HP `03f0` and Dell `413c`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **FCC lock: the radio stays disabled until an unlock procedure runs, and it is keyed per `:`.** OEM rebrands are separate keys, so covering `1199` alone misses two thirds of the fleet. | MM — `data/dispatcher-fcc-unlock/meson.build` at `1.24.2` names exactly `03f0:4e1d`, `1199:9079`, `413c:81a3`, `413c:81a8` for the Sierra script. Mirrored in [`control/src/fcc/coverage.ts`](../control/src/fcc/coverage.ts). | `implemented` — CeraLive records an opt-in policy and re-derives MM's own symlink. It ships no unlock script; see [`FCC-UNLOCK-COVERAGE.md`](FCC-UNLOCK-COVERAGE.md). | +| **`AT!`-prefixed commands (`AT!BAND`, `AT!ENTERCND`) are a password-gated vendor surface.** | MM — the `sierra` plugin is a first-class plugin at `1.24.2`. `providers/ufi-himi`'s static gate scans for `AT!` as a FORBIDDEN construct, but its scope is that provider's directory, not the repository. | `unavailable` — for this password-gated surface: no `AT!BAND` or `AT!ENTERCND` form exists anywhere in this repository. It is NOT a repo-wide absence of `AT!` — `usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP?` / `AT!USBCOMP=?` / `AT!USBCOMP=` composition forms, allowlisted by name and fenced like every other composition write. | +| EM7455/EM7565 are the widely-deployed bonding-rig parts. | BELABOX — EM7455 (a.k.a. Dell DW5811e) and EM7565 are listed as working on RK3588 and Jetson. | `unavailable` — no CeraLive hardware drill has run; [`BENCH.md`](BENCH.md) RB-18 is a recorded `device-not-present` skip. | + +## Fibocom (`2cb7`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **AT-over-MBIM again — with an upstream kill switch.** MM tunnels AT through a Fibocom vendor CID, and ships a udev opt-OUT because it does not work on every unit. | MM — `fibocom/mm-port-mbim-fibocom.c` uses `MBIM_SERVICE_FIBOCOM` / `MBIM_CID_FIBOCOM_AT_COMMAND` and bails when `ID_MM_FIBOCOM_AT_OVER_MBIM_DISABLED` is set. | `unavailable` — CeraLive sends no AT over MBIM. | +| **Some SKUs need a dedicated ECM bearer path rather than the generic one.** | MM — `fibocom/mm-broadband-bearer-fibocom-ecm.c` exists as its own bearer subclass and drives `+GTRNDIS?`. | `implemented` — CeraLive's bearer authority is the NetworkManager adapter; it has no vendor bearer subclass. | +| **Composition drifts with firmware, and one part is PCIe rather than USB.** The FM350-GL has no USB VID:PID at all. | Repo — [`FM350-DECISION.md`](FM350-DECISION.md) records PCI `14c3:4d75` under `mtk_t7xx`; BELABOX lists FM350-GL as experimental and RK3588-only. | `unavailable` — documented-deferred; it gets no classifier row on purpose. | + +## SIMCom (`1e0e`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **SIM hot-swap is claimed by the plugin, so SIM state can recover without a replug.** | MM — `simtech/mm-broadband-modem-qmi-simtech.c` sets `MM_IFACE_MODEM_SIM_HOT_SWAP_SUPPORTED, TRUE`. | `implemented` — CeraLive reads SIM presence as evidence only; `absent` is reachable through one evidence kind and never inferred from a blank object path. | +| **Coverage is uneven between the AT and QMI halves of the plugin.** The QMI modem is a separate, much smaller subclass than the generic one. | MM — `simtech/` ships `mm-broadband-modem-simtech.c` alongside a distinct `mm-broadband-modem-qmi-simtech.c`; the QMI subclass is ~6 kB against a far larger shared/AT implementation. | `implemented` — CeraLive's provider is generic and runtime-discovered; it makes no SIMCom-specific claim. | +| **A Zero-CD personality exists for at least one SIMCom id.** | MODESWITCH — exactly one `1e0e:*` device file (`1e0e:f000`). | `implemented` — the generic `pending-modeswitch` class covers it; no per-vendor rule. | +| Its USB-mode command (`AT+CUSBPIDSWITCH`) was captured non-mutatingly on the bench and its PID→composition mapping is unproven. | Repo — [`COMPOSITION-EVIDENCE.md`](COMPOSITION-EVIDENCE.md). | `unavailable` — target modes stay UNCERTIFIED and hidden. | + +## Telit (`1bc7`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **Band selection is `AT#BND`, and its argument arity changes with the generation.** A 2G-only part takes one field, a 4G part takes three or four, and the 4G mask has a second extended-mask spelling. | MM — `telit/mm-modem-helpers-telit.c` builds nine distinct `#BND=` forms (`#BND=%d`, `#BND=0,%…`, `#BND=0,0,%…x,%…x`, …) and parses both the `AT#BND=?` range reply and the `AT#BND?` current reply. | `unavailable` — CeraLive's band writes go through ModemManager's generic `SetCurrentBands` and are additionally refused until a four-proof catalog entry exists; the catalog ships EMPTY. | +| **One family occupies many application PIDs, one per composition.** FN990A alone is `1070` rmnet / `1071` MBIM / `1072` RNDIS / `1073` ECM. Reading the family from the PID therefore requires the exact PID. | KERNEL — `option.c` names each variant explicitly; `qmi_wwan.c` carries the rmnet ones with `QMI_QUIRK_SET_DTR`. | `implemented` — ten exact Telit rows added to `CELLULAR_USB_MODEL_ROWS`, all `mainline-kernel`. | +| **`QMI_QUIRK_SET_DTR` is a real per-device driver quirk**, not a formality: those PIDs need DTR asserted before the QMI control channel behaves. | KERNEL — the macro is used for `1bc7:1031`, `1034`, `1040`, `1050`, `1060`, `1070`, `1080`, `10a0`, `1200`+ and not for the `QMI_FIXED_INTF` entries (`1100`, `1101`, `1200`). | `implemented` — recorded so the classifier row's tier is checkable; CeraLive performs no DTR handling. | +| **No Zero-CD entry exists for Telit.** A Telit module enumerates in its application composition directly. | MODESWITCH — zero `1bc7:*` device files. | `implemented` — no modeswitch rule is needed, and none is shipped. | +| IPv6 bearer cleanup is an operator-reported rough edge. | No pinned source found. | `unavailable` — stated as unsourced and therefore claimed as nothing. | + +## u-blox (`1546`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **There are TWO band-configuration commands and which one works is per-model.** MM preloads a per-model support config and picks `+UBANDSEL?` or `+UACT?`; a model supporting neither gets an explicit "loading current bands is unsupported" error. | MM — `ublox/mm-broadband-modem-ublox.c` `preload_support_config` / `load_current_bands`. | `unavailable` — CeraLive issues neither command. | +| **Applying a band change may require dropping to low-power mode or explicitly unregistering.** MM models this as a per-model `SETTINGS_UPDATE_METHOD_CFUN` / `SETTINGS_UPDATE_METHOD_COPS` / `UNKNOWN`. | MM — same file, `preload_support_config`'s `support_config.method` switch. | `unavailable` — this is precisely the "a band lock can strand a radio" hazard behind CeraLive's four-proof certification gate. | +| **`1546` is a MIXED vendor id.** It carries u-blox GNSS receivers (Antaris 4, u-blox 5/6/7/8) as well as cellular modules. | USBIDS — the `1546` block lists `01a4`–`01a8` GNSS parts beside `1102` LISA-U2. | `implemented` — six exact LARA-R6/LARA-L6 rows added. The vendor id remains in `CELLULAR_USB_VENDOR_IDS` as inherited behaviour; treat a vendor-only match there as WEAK evidence. | +| **No Zero-CD entry exists for u-blox.** | MODESWITCH — zero `1546:*` device files. | `implemented` — none shipped. | + +## NETGEAR (`0846`) — a router/WebUI family + +| Quirk | Evidence | Posture | +|---|---|---| +| **ModemManager ships no NETGEAR plugin.** There is no vendor plugin to bind, so there is no MM-managed control surface to expect. | MM — the `src/plugins/` tree at `1.24.2` contains 39 vendor plugins; NETGEAR is not among them. | `implemented` — the classifier labels the family `router-webui` and nothing more. | +| **No kernel modem driver claims `0846:68e1` (LB1120).** It is absent from both `qmi_wwan.c` and `option.c`, so it comes up as a plain Ethernet-over-USB tether. | KERNEL + USBIDS — `0846:68e1 LB1120-100NAS` is in `usb.ids`; grep of both driver tables returns nothing for it. | `implemented` — one exact row, tier `usb-ids-registry`, `familyKind: 'router-webui'`. | +| **`0846` is a MIXED vendor id, and heavily so.** Its `usb.ids` block is dominated by Wi-Fi (`9050`–`9055`, `4110`–`4301`, …) and Ethernet (`1001`, `1040`) adapters; `68e1` is the only cellular entry. | USBIDS — the `0846` block. | `implemented` — `0846` is deliberately **absent** from `CELLULAR_USB_VENDOR_IDS`. A vendor-keyed rule would report a Wi-Fi dongle as a cellular uplink. | +| Two NETGEAR AirCard ids DO appear in `qmi_wwan.c` (`0846:68a2`, `0846:68d3` "Aircard 779S"). | KERNEL — `qmi_wwan.c`. | `unavailable` — a different family from the router/WebUI one this section covers; no row is added for them here. Note `68a2` is reused across four unrelated vendor ids in the same table, which is why a PID is never a key on its own. | + +## Huawei (`12d1`) and ZTE (`19d2`) — Zero-CD and HiLink + +| Quirk | Evidence | Posture | +|---|---|---| +| **Zero-CD: the device enumerates as a CD-ROM first and must be mode-switched.** The switch is a SCSI command sequence, per device, and the target PID differs from the installer PID. | MODESWITCH — 38 `12d1:*` and 45 `19d2:*` device files. `12d1:1f01` carries `TargetProduct= 0x14db`, a 31-byte `MessageContent`, and `NoDriverLoading=1`. | `implemented` — `classifyDevice` returns `pending-modeswitch` as a DISTINCT class, never folded into `unmanaged`. | +| **HiLink is a firmware personality, not a mode switch.** A HiLink stick presents CDC-ECM plus a web UI and has no modem control port at all; no amount of `usb_modeswitch` produces one. | Repo — [`HUAWEI-HILINK-PROVIDER.md`](HUAWEI-HILINK-PROVIDER.md); the classifier's `HILINK_ECM` fixture is `12d1:14db` with `cdc_ether` only. | `implemented` — a dedicated read/limited-write HTTP provider for two exact E3372H firmwares, gate off. | +| **Several Huawei models present the SAME MAC address**, which breaks per-interface configuration that keys on it. | BELABOX — the tutorial README states `/etc/network/interfaces` "brings up all the modems even when they use the same MAC address (which is the case for several Huawei models), unlike NetworkManager". | `implemented` — CeraLive derives physical identity from serial → udev `ID_PATH` → bounded fallback, and `PhysicalModemId` REFUSES interface names and addresses by construction. | +| **ZTE authentication differs per firmware within one model.** MF79U alone has a base64 dialect and an `LD`-salted SHA-256 dialect under the same `LOGIN` verb. | Repo — [`MF79U-DIAGNOSIS.md`](MF79U-DIAGNOSIS.md) and `providers/zte-goform/`. | `implemented` — three evidence-selected profiles, one bounded attempt, no fallback between them. | + +## Phone tethering — an interface, not a modem + +| Quirk | Evidence | Posture | +|---|---|---| +| **A tethering phone exposes a network interface and nothing else.** There is no control port, no band surface, no SIM surface and no signal reading; the phone's own OS owns all of it. | KERNEL/classifier — an RNDIS or ECM/NCM data interface with no MBIM/QMI/AT control interface is exactly `classifyDevice`'s `router-mode` branch. | `implemented` — classified `router-mode` with an honest reason; no capability module is offered. | +| **Cellular-ness cannot be inferred from the descriptors.** A phone's RNDIS tether and a USB Ethernet dongle are the same shape on the wire. | Classifier — `classifyUsbNetDevice` requires POSITIVE cellular evidence (a known cellular vendor id, a modeswitch trigger, or a Zero-CD storage interface) before naming a tether `router-cellular`; absent that it is `wired-ethernet`. | `implemented` — and this is why the NETGEAR LB1120 currently reads `wired-ethernet`: honest, and a known gap rather than a guess. | + +--- + +## What this document does NOT license + +- It does not add a provider. No Telit, u-blox or NETGEAR provider exists in this + repository, and none may be inferred from a row above. +- It does not certify anything. Certification comes from a captured bundle and a + human-reviewed commit — [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). +- It does not authorize an AT command. The runtime AT allowlist is a closed set of exact + READ/TEST forms in `control/src/usb-mode/`; adding to it is a separate, reviewed change + with its own evidence. diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 1c4cf2e..8a99bf6 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -192,7 +192,10 @@ i.e. `-~ceralive0.0.0~dev`, which sorts below every real release ## npm version provenance Following the house OIDC pattern, the npm publish job is **not** version-injected: it -verifies that `control/package.json` `version` **equals** the tag's `X.Y.Z` and fails -closed on any mismatch before publishing. To cut a release, bump `control/package.json` -`version`, commit, then create the matching `vX.Y.Z` tag. The `.deb` side is injected -(above); the npm side is verified — both are driven by the same tag. +verifies that `package.json`, `control/package.json`, and `cli/package.json` all have a +`version` **equal** to the tag's `X.Y.Z`, naming the mismatched file and failing closed +before publishing. The companion build receives that same tag through `RELEASE_VERSION`, +which `build-companion.sh` converts to the bare companion version and validates from the +produced `.deb`. To cut a release, bump all three `package.json` versions, commit, then +create the matching `vX.Y.Z` tag. The four upstream `.deb` sources retain their independent +per-source counter versions; only first-party artifacts obey this tag-version unity rule. diff --git a/docs/adr/ADR-STAY-TYPESCRIPT.md b/docs/adr/ADR-STAY-TYPESCRIPT.md new file mode 100644 index 0000000..cf3d2d9 --- /dev/null +++ b/docs/adr/ADR-STAY-TYPESCRIPT.md @@ -0,0 +1,185 @@ +# ADR — Stay on TypeScript: no Rust migration for the modem control stack + +**Status:** ACCEPTED. The project owner reviewed the Rust migration option and rejected it on +2026-08-24. The decision is final until one of the named revisit triggers in §6 fires; it is +not a deferral and carries no scheduled re-evaluation. +**Date:** 2026-08-24 +**Deciders:** CeraLive project owner (decision); modem-stack maintainers (analysis and +recommendation). +**Supersedes:** nothing. **Amends:** nothing. It records the language decision that +[`AGENTS.md`](../../AGENTS.md) § WORKSPACE / TOOLCHAIN previously stated only as a fact +("Bun 1.4.0, strict TypeScript 7.0.2") with no rationale behind it. + +--- + +## 1. What this ADR is for + +Two questions arrived together while planning the `modem-stack-quality-compat` effort. The +first: should the ModemManager control path be rewritten in Rust, following the +`srtla-send-rs` precedent of a Rust core with a thin TypeScript client over a versioned +JSON-RPC socket? The second: what may this repository take from `irlserver/modem-metrics`, +an MIT-licensed Rust project covering some of the same ground? + +Both were answered in the same review, and both answers need to outlive the effort that +produced them. Without this record the next person reading `control/src/providers/` sees a +pure-JS D-Bus stack with no explanation of why it is not Rust, and sees telemetry fields +whose shape was clearly informed by somebody else's work with no statement of what was and +was not taken. This document is that explanation. + +--- + +## 2. Decision + +**No Rust migration.** `@ceralive/modem-control` and the `modem-control` bench CLI stay +TypeScript on Bun. No Rust daemon, no `zbus` core, no napi addon, no bounded prototype spike, +and no Rust source file enters this repository under this decision. + +The alternative that was on the table and is now closed: a Rust ModemManager daemon built on +hand-generated `zbus` proxies, fronted by a thin TypeScript client over a versioned JSON-RPC +Unix socket. It was assessed as a genuine reliability and ownership improvement in the +abstract. It was rejected because the specific improvement it offers is not the improvement +this stack needs, and the cost of getting it is paid in exactly the semantics that are +hardest to re-prove. + +--- + +## 3. Rationale + +### 3.1 The quality asymmetry runs the other way + +The comparison that prompted the question is `irlserver/modem-metrics` (read at commit +`bf15066`), roughly 5,300 lines of Rust: a `zbus` ModemManager provider, a serial AT +provider, NETGEAR and Huawei HTTP providers, a GPS crate, an interface-metrics module, and an +NDJSON probe CLI. + +It is a metrics project, and it is measurably narrower than this one on every axis that makes +modem control dangerous: + +| Property | `irlserver/modem-metrics` | `modem-stack` | +|---|---|---| +| Control operations | none; it reads and reports | descriptor-gated read and write surface | +| Safety model | none | mutation admission, exclusive ownership, durable journal, armed rollback, required readback | +| Refusal vocabulary | none; errors are strings | typed refusal reasons across every provider | +| Redaction | none | key-based redaction classes for PII, credentials, SMS bodies, coordinates, USSD text | +| Hardware-integration tests | none | bench runbooks RB-1..RB-17 plus a private-session-bus integration suite | +| Test surface | none found | ~190 test files, including grep gates that fail the build on forbidden constructs | + +Rust would not have produced any of that, and TypeScript did not prevent any of it. The +memory-safety and concurrency guarantees a Rust rewrite buys are real, but nothing in this +stack's defect history points at them: the fences that keep a modem from being stranded are +architectural, and they are already built. Rewriting them in a second language would put +every one of those guarantees back on the "unproven again" list to buy a class of safety this +codebase has not been failing on. + +### 3.2 No mature ModemManager Rust binding exists + +`zbus` 5.19 is the standard D-Bus crate and it is solid, but there is no maintained Rust +binding for ModemManager 1.24's interface surface. The realistic path is hand-generated +proxies via `zbus_xmlgen`, which means this repository would own and maintain the generated +binding for every MM interface it touches, and re-own it on every MM bump. That is a +permanent maintenance liability accepted in exchange for a benefit §3.1 already found to be +misaimed. + +### 3.3 Migrating proven semantics is where the regressions live + +The behaviours that would have to be re-implemented and re-proven are precisely the subtle +ones: epoch-scoped observer reconnection and row retention across daemon loss, device +generation fencing on async completion, the `unknown-outcome` uncertainty fence and its +durable journal counterpart, per-modem actor serialization with quiesce leases, the +one-bounded-attempt authentication rules in three router providers, and the redaction classes +that keep a voucher code or a coordinate out of a log line. + +Each of those is currently pinned by tests that would not survive a language change intact. +A rewrite does not carry a test suite across; it carries the intent and re-expresses it, and +every re-expression is a chance to lose a distinction that took a hardware drill to find. The +`preferred: none` case, the `absent` versus `unknown` SIM split, and the `armed` versus +`executing` journal mapping are three examples where the right answer is one line different +from a plausible wrong one. + +### 3.4 Pure-JS D-Bus is adequate under Bun + +The performance argument for Rust does not apply to this workload. The control path is +event-driven and low-rate: property changes, signal subscriptions, and a handful of method +calls per modem per minute. It is not a hot loop, not a codec, and not a packet path. The +typed pure-JavaScript D-Bus transport under Bun handles it with headroom, and the production +provider already spawns no subprocess at all (`forbidden-subprocess.test.ts` proves no path +can reach `mmcli`, `qmicli`, or `mbimcli`). + +`srtla-send-rs` is not a counter-precedent. That component sits in the media path, where +per-packet cost and jitter are the product. This one does not. + +--- + +## 4. What this decision does NOT do + +- It does not claim Rust is the wrong language in general, or that the daemon shape assessed + here is a bad design. It says this stack does not need it. +- It does not forbid Rust elsewhere in CeraLive. `srtla-send-rs` is unaffected. +- It does not authorize adopting any code from `irlserver/modem-metrics`. See §5. +- It does not schedule a re-evaluation. There is no "revisit in six months" clause here, and + a future change may not cite this document as evidence that a migration was planned. + +--- + +## 5. Attribution: `irlserver/modem-metrics` + +`irlserver/modem-metrics` (`github.com/irlserver/modem-metrics`, read at commit `bf15066`) is +**MIT-licensed**. Its license permits code reuse. **No source code from it was copied into +this repository, in any form, at any point.** No file, function, type, constant table, or +test fixture originates there. What was taken is a small set of CONCEPTS, re-expressed from +scratch against this package's own contracts: + +- **Signal telemetry breadth.** The idea that a directly-managed ModemManager modem should + report RF detail well beyond a quality percentage: RSRP, RSRQ, SINR, cell identity, tracking + area, operator identity, and serving band. This repository had that breadth only on the + router providers; the concept is what prompted extending it to the MM path. The + implementation is our own, through `ObservationEnvelope` with per-metric provenance and the + four-state `readMetric` vocabulary, neither of which exists in their model. +- **Counter-reset-aware rates.** The idea that a byte counter running backwards means the + counter was reset, so the correct response is to emit no rate for that interval and + rebaseline, rather than to publish a negative or wildly large figure. Applied in + `control/src/backend/usage/sampler.ts` under our existing window and anchor rules. +- **Availability framing.** The idea that "is this source reachable" and "is this source + healthy" are separate questions that must not collapse into one status. This reinforced the + existing separation between an unavailable observation and a fresh observation whose metrics + are unknown with a reason. + +Several parts of their design were examined and deliberately NOT adopted: the raw serial AT +provider (it competes with ModemManager for the port), shallow AT response parsing, an +unversioned NDJSON output contract, URL-based device identity, first-bearer-only byte +counters, and treating an untyped extras bag as part of the contract. + +Credit for the ideas above belongs to that project's author. Responsibility for everything in +this repository, including the way those ideas are expressed here, belongs to CeraLive. + +--- + +## 6. Revisit triggers + +The decision in §2 stands until one of these specific conditions is met. Each is a fact +somebody can check, not a judgement call: + +| # | Trigger | Why it would reopen the question | +|---|---|---| +| 1 | A maintained, third-party-owned Rust binding for ModemManager's current interface surface exists, with a release history and more than one maintainer. | It removes §3.2 entirely: the generated-proxy maintenance liability disappears. | +| 2 | A defect class attributable to the runtime, not the architecture, is measured on a board: a memory-safety fault, a data race, or a GC pause that misses a control deadline. | It would be the first evidence that §3.1's "misaimed benefit" reading is wrong. | +| 3 | The control path acquires a sustained high-rate workload, for example continuous per-packet or per-frame processing rather than event-driven property changes. | It invalidates §3.4's adequacy argument on its own terms. | +| 4 | Bun or the pure-JS D-Bus transport stops being viable for this workload: an upstream deprecation, an unfixed protocol defect, or a loss of maintenance. | The current runtime choice would no longer be available, so the comparison restarts from scratch. | +| 5 | The project owner reverses the decision. | It was an owner decision; it is theirs to change. | + +A trigger firing reopens the QUESTION. It does not pre-approve a migration, and it does not +make any part of §3 obsolete on its own. + +--- + +## 7. Evidence index + +| Source | What it carries | +|--------|-----------------| +| `github.com/irlserver/modem-metrics` @ `bf15066` | The Rust metrics project surveyed in §3.1 and credited in §5: workspace layout, provider set, absence of control operations, absence of tests, MIT license text. | +| `github.com/dbus2/zbus` | The D-Bus crate assessed in §3.2 (5.19 at review time), including `zbus_xmlgen` as the proxy-generation path. | +| `github.com/linux-mobile-broadband/ModemManager` | Read at tag `1.24.2` for the interface surface a hand-generated binding would have to cover. | +| [`AGENTS.md`](../../AGENTS.md) § WORKSPACE / TOOLCHAIN | The toolchain this decision preserves: Bun 1.4.0, strict TypeScript 7.0.2, Biome. | +| [`AGENTS.md`](../../AGENTS.md) §§ safety model | The admission, ownership, journal, rollback, readback and redaction fences §3.1 and §3.3 describe. | +| `control/src/providers/modem-manager/forbidden-subprocess.test.ts` | The proof cited in §3.4 that the production MM path spawns no CLI. | +| `srtla-send-rs/docs/adr/ADR-001-control-protocol.md` (workspace sibling) | The Rust-core-plus-thin-TS-client precedent §3.4 distinguishes this stack from. | diff --git a/package.json b/package.json index 1ac0d8e..199f6bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modem-stack", - "version": "1.1.0", + "version": "1.3.0", "private": true, "type": "module", "description": "Cellular modem control for CeraLive — @ceralive/modem-control library, bench CLI, and ModemManager-stack .deb packaging (Phase A, standalone).", diff --git a/packaging/ci/test-release-workflow-wiring.sh b/packaging/ci/test-release-workflow-wiring.sh index f2b072b..ae59711 100755 --- a/packaging/ci/test-release-workflow-wiring.sh +++ b/packaging/ci/test-release-workflow-wiring.sh @@ -48,6 +48,11 @@ echo " workflow: ${WORKFLOW#"$REPO_ROOT"/}" # First 1-based line number matching a fixed string; empty when absent. line_of() { grep -n -m1 -F -- "$1" "$WORKFLOW" | cut -d: -f1; } +# Same, for a command INVOCATION. The end-of-line anchor — not a `run:` prefix, which a step +# that guards its invocation inside `run: |` no longer carries — is what skips an earlier +# mention of the same path inside an `echo "::error::…"` string. +line_of_invocation() { grep -nE -m1 -- "$1"'[[:space:]]*$' "$WORKFLOW" | cut -d: -f1; } + # The comparator every ordering assertion goes through, so ONE non-vacuity control covers them all. assert_before() { # local a_label="$1" a="$2" b_label="$3" b="$4" @@ -97,7 +102,7 @@ DETECT_LINE="$(line_of '- name: Detect changed sources (per-source verdicts)')" STAGE_LINE="$(line_of '- name: Stage carry-forward debs (unchanged sources, sha256-verified)')" BUILD_STEP_LINE="$(line_of '- name: Build the MM 1.24 stack (.deb)')" BUILD_CALL_LINE="$(line_of 'packaging/ci/build-bookworm.sh amd64')" -COMPANION_LINE="$(line_of 'run: packaging/ci/build-companion.sh')" +COMPANION_LINE="$(line_of_invocation 'packaging/ci/build-companion\.sh')" MANIFEST_LINE="$(line_of 'run: bash packaging/ci/generate-release-manifest.sh')" UPLOAD_LINE="$(line_of '- name: Upload .deb artifacts + release manifest')"