Skip to content

feat(cellular): unified modem control — Phase-B architecture, operator truth, modem-stack migration - #289

Open
andrescera wants to merge 14 commits into
mainfrom
feat/modem-phase-c
Open

feat(cellular): unified modem control — Phase-B architecture, operator truth, modem-stack migration#289
andrescera wants to merge 14 commits into
mainfrom
feat/modem-phase-c

Conversation

@andrescera

Copy link
Copy Markdown
Member

HELD — do not merge yet. This PR is opened for review and CI only. It merges after
the effort's released pins land (@ceralive/modem-control 0.2.0 → 1.1.0), per the
unified-modem-control-quality merge order.

Affected repo & language: CeraUI — TypeScript (Bun), Svelte frontend + backend

What

Rebuilds the device's cellular control surface, and migrates its backend onto
@ceralive/modem-control instead of a second copy of the same rules.

Six commits, in story order:

  1. Phase-B modem architecture — additive RPC schema, a setUsbMode contract and typed
    init/transition errors; a cellular composition root with an opt-in D-Bus backend and a
    mutation-free shadow mode that records redacted divergence as durable
    retirement-gate evidence; (ip,iface) bond mapping with a duplicate-IP eligibility
    split; a modem-transition admission interlock; router-dongle classification by VID:PID;
    the rebuilt Cellular section and modem dialog; and the seven gated capability modules.
  2. Operator-truth pass — every cellular reading is now either true or visibly unknown:
    no_sim from real ModemManager SIM-presence evidence, certification keyed on firmware
    revision rather than USB bcdDevice, one shared SIM-less-bond rule applied identically
    in RPC/backend/UI, refusals that reach the operator as reasons, the device-bound dongle
    admin-UI proxy, and AT-port handling that waits for evidence instead of a settle delay.
  3. The migration — the fourteen MIGRATE modules route through modem-control-compat.ts.
  4. Capability-truth UI — a shared render resolver, honest wait states, persistent
    outcomes with screen-reader regions, and responsive/touch/locale/a11y hardening.
  5. Tests — the visual QA matrix and the mixed-version skew matrix.
  6. Release metadata.

Why

The cellular story was a set of mmcli shell-outs behind a Network page that could not
tell two identical modems apart, could not say why a link was not bonding, and offered
edits that silently did nothing. Each of those is a lost stream for an operator in the
field, and none of them announced itself.

The migration exists because two copies of the same rule diverge. Identity derivation,
enum normalization, USB classification and the router parsers now live once, in
@ceralive/modem-control.

The seam is what makes the migration landable before the package is published: each
projection resolves the packaged implementation through one structural namespace probe
and falls back to CeraUI's own implementation when the installed package does not export
it. The backend therefore runs identically against the 0.2.0 registry floor and against
the 1.1 candidate, and that equivalence is pinned by the skew matrix rather than assumed.

Two of the projections — vidPidOf and hilinkConnectionBody — resolve in neither
column. Those fallbacks are the implementation, not a transition; deleting them at the
shim-removal step would delete the feature.

How to verify

cd CeraUI
bun install --frozen-lockfile
bun run lint && bun run check:tech-debt
bun run --filter frontend test          # frontend unit
bun run --filter @ceraui/i18n test      # 10-catalog parity
(cd apps/backend && bun test)           # backend, incl. modem-control-skew-matrix

End-to-end (desktop project):

bun run --filter frontend test:e2e -- --project=desktop truthfulness.spec.ts
bun run --filter frontend test:e2e -- --project=desktop modem-a11y.spec.ts
bun run --filter frontend test:e2e -- --project=desktop visual/modem-pass4.visual.spec.ts

The skew matrix is the load-bearing one for the migration: it asserts returned values,
not types, because modemControlFunction<T> casts the runtime export — a packaged
function that shares a name but not a signature type-checks clean and misbehaves only when
executed. It must pass identically in both columns.

Risks

Playwright has pre-existing reds that this PR does not introduce and does not fix.
Functional 317 pass / 13 fail / 1 flaky and @visual 87 pass / 26–27 fail, identical
in both the floor and the candidate column
. Verified pre-existing by stashing against
pristine HEAD; two of them are already on the recorded functional list, and the @visual
set is ~1 % pixel drift against CI-generated baselines. Column-equality is what the skew
matrix needs, so the skew verdict is sound — but "full gate green" would be an overclaim
and is not made here.

Hardware drills are BLOCKED-ON-OWNER. No bench board was reachable, so the fleet
walkthrough, the MF79U diagnosis, the Huawei duplicate-IP live read, the GPS
capability/enable/fix/disable drill, the FM350 native-PCIe HIL and the Quectel
QMI→MBIM→QMI round-trip did not run. Nothing in this PR asserts a hardware verdict.

Fourteen new production files exceed the 250-pure-LOC ceiling without a formal
exemption (recorded, non-blocking).

Rollback: the compat seam falls back to CeraUI's own implementations whenever the packaged
export is absent, so pinning @ceralive/modem-control back to 0.2.0 restores the
pre-migration code path without a revert.


Checklist

  • Docs updated if behavior or structure changed (Rule A: AGENTS.md, README, docs/, ARCHITECTURE.md, versions.yaml)
  • Started from updated main; branch rebased on latest canonical branch (Rule B)
  • Rule D local-scratch reference check passes for tracked files
  • Tests pass; QA evidence attached or linked

…aces

The device's cellular story was a set of mmcli shell-outs behind a Network page
that could not tell two identical modems apart, could not say why a link was not
bonding, and offered edits that silently did nothing. This is the architecture
that replaces it.

Wire contracts. An additive Phase-B modem schema, a `setUsbMode` contract, and
typed init/transition errors, so a refusal reaches the operator as a reason rather
than as a spinner that never resolves.

Backend. A cellular composition root with an opt-in D-Bus backend and a
fail-closed audit transport; a mutation-free shadow mode that compares the D-Bus
and mmcli readings and records redacted divergence as durable retirement-gate
evidence, so the old path is retired on measurement rather than on confidence.
Modem projections and adapters stay legacy-compatible and dongle-aware. A canonical
physical-device identity module, event-driven state from a ModemManager D-Bus
observer, optimistic modem-detected rows from udev, and interface-bound
connectivity and gateway probing replacing source-IP inference.

Bonding. (ip,iface) bond mapping with a duplicate-IP eligibility split, so twin
HiLink modems presenting one address are two bondable links, and a
modem-transition admission interlock locks both race orders between a USB-mode
switch and a running stream.

Router dongles. VID:PID-based classification that does not depend on a device
name, a normalized signal model across HiLink/ZTE/UFI, a read-only UFI (HiMI)
dialect for the 05c6:9024 Qualcomm sticks, expanded read-only ZTE/UFI admin
telemetry, and HiLink capability discovery with a config surface derived from it.

Surfaces. The Cellular section is rebuilt around progressive disclosure and honest
class bands; the modem dialog carries cell detail, usage cycles, a USB-mode card
and an Auto-APN default; SIM PIN/PIN2 unlock is reachable from the modem's own row
instead of a global prompt; a read-only SMS inbox; operator-settable data-usage
policy; per-interface bonded-link identity with twin disambiguation.

Capability modules. On top of modem-stack's feature-gate framework and support-claim
taxonomy: band locking, read-only SMS (CeraUI off raw mmcli-sms), gated 5G mode
preference, opt-in FCC auto-unlock, gated GPS and USSD, and the SIM's own number
behind an explicit reveal — each wired through RPC to a dialog surface, with the
contracts documented.

Covered by a modem-truthfulness e2e extension across ten locales and a visual and
a11y gate.
The architecture was in place; what it reported was still frequently wrong in the
one direction that costs an operator a stream — it looked like an answer. This is
the pass that made every cellular reading either true or visibly unknown.

Identity and presence. `no_sim` derives from real ModemManager SIM-presence
evidence rather than from the absence of a NetworkManager profile; a modem's
ID_PATH resolves from udev net records instead of the USB enumerator; a USB device
is matched by its stable key rather than by a field udev never sets; certification
is keyed on the modem's firmware revision rather than on the USB bcdDevice, which
is not a firmware revision at all. A modem that appears after boot is discovered
rather than ignored, and the reason presence is polled instead of event-driven is
written down.

Bonding honesty. One shared rule decides whether a SIM-less link may bond, applied
identically in RPC, backend and UI; a SIM-less router dongle stays out of the SRTLA
bond; the bond toggle refuses on the dongle's own no-SIM verdict; and a degraded
SRTLA entry is emitted instead of an invented `lnk_<ifname>` identity.

Refusals that speak. A modem save always lands somewhere the operator can read; a
refused radio-mode write says so; a registered modem is described as resting, not
stuck; a dongle's "Up" is a link, not a connection; the PIN2/PUK2 lock is no longer
surfaced as if it were actionable; the 3GPP scan is no longer killed by CeraUI
itself, and reports what it did.

The dongle's own admin UI. A wire contract, a pure core, a single-use token
exchanged for a scoped cookie, a proxy target resolved by interface and never by
address, and the proxy served on the authenticated origin — reachable from both the
row and the dongle dialog, in ten locales, with the device-bound contract documented.

Ports and processes. NetworkManager is asked about the modem's device rather than
its netdev; the AT port is opened by path rather than by ModemManager's port name;
the AT tty never becomes the backend's controlling terminal; and the code waits for
evidence the port is free instead of a fixed settle delay. USB marker scans are
generation-fenced and single-flight. The WiFi selector no longer storms nmcli, and
Forget removes the network.

Presentation. One signal language and one signal column whatever kind of device is
in the port; a collapsed disclosure is hidden rather than merely unpainted; a
cellular row is keyed on its roster id and never on its interface name; a dongle's
admin address is stated as a fact; the ICCID is plainly shown and copyable. The
dongle diagnostic fields the firmware in the rack actually answers are read and
surfaced, and the dongle writes this session found but did not build are registered
as tech debt rather than left implied.

DESIGN.md gains the capability-truth and bounded-pass gate this pass was measured
against.
CeraUI carried its own copy of rules that now live in `@ceralive/modem-control`:
identity derivation, ModemManager enum normalization, USB-device classification,
capability selection and the router telemetry parsers. Two copies of the same rule
diverge — and this effort's whole point is that the device and the platform agree
about what a modem is.

The migrated modules route through `modem-control-compat.ts`, a projection seam
rather than a rewrite. Each projection resolves the packaged implementation
through one structural namespace probe and falls back to CeraUI's own
implementation when the installed package does not export it, so the backend runs
identically against the 0.2.0 registry floor and against the 1.1 candidate. That
is what makes the migration landable before the package is published: nothing here
depends on a version that does not exist yet.

The seam is deliberately temporary for the modules that MIGRATE and permanent for
the two projections that resolve in neither column, `vidPidOf` and
`hilinkConnectionBody` — those fallbacks are the implementation, not a transition.
`TRANSPORT_KEEP_ALLOWLIST` records what stays behind, matching the frozen KEEP
ledger.

Also makes the USB-mode switch honest about its own outcome: it awaits the
post-reenumeration interface instead of declaring success on the AT reply, so a
switch that wrote non-volatile configuration and left the device in the old
composition is reported as what it was.
… wait states

DESIGN.md pass 1 (shape / hierarchy) across the modem, cellular and
router-dongle surfaces. No new capability, no new RPC, no visual-world change.

The capability-truth matrix (§1) is now one shared resolver,
`resolveCapabilityRender`, that both gated modules delegate to instead of
re-deriving the five-state ladder. It answers four render states, and the two
that render nothing are not the same fact: a positively-unsupported module
contributes zero DOM nodes, while a module nobody has established anything
about renders a distinct role="status" diagnostic and NO control. A disabled
control now exists only at claim >= capable, where the device is refusing right
now, and it always carries its reason on screen — the shipped kiosk touchscreen
cannot hover to reveal a tooltip.

That last rule turned up a defect. ModemGpsSection was imported by
ModemConfigDialog but never mounted, and loadGps() was never called, so the
whole GPS surface was dead code: a capable GNSS receiver rendered exactly as
many nodes as a modem with no receiver at all. Both are wired now, using only
the component and RPC that already existed.

Operator labels (§3) stop printing engine tokens. `rndis` names a Microsoft
protocol and `eutran-3` names a 3GPP table row; neither says anything an
operator can act on, so compositions read as behaviours and bands read as a
generation plus a number, both through the catalog. A band this build cannot
name gets honest generic copy plus a pointer rather than the raw token. Nothing
is deleted: every suppressed value is relocated verbatim into a marked
diagnostics block that sits behind the Advanced disclosure, so a field engineer
comparing against a vendor table loses nothing.

Hierarchy (§2) moves the slot badge after the state, carrier and signal cluster.
It is a hardware tag, and it led the badge line — so the first pill an operator's
eye and a screen reader both reached was the one fact on the row nothing can be
done about, ahead of whether the modem had registered at all.

Gates: frontend 3675 tests green across 274 files (+57 / +3), lint clean, all
ten catalogs in parity, and five new capability-truth tests green in a real
browser at the desktop viewport. The truthfulness e2e was extended, not forked.

DESIGN.md pass 2 over the modem/cellular/router-dongle surfaces. Seven findings
fixed; three deferred to todo 33 with the evidence that they predate this change
(the full ledger is in .omo/evidence/task-32-unified-modem-control-quality.md).

An outcome the operator cannot see is an outcome that did not happen, and three
mutations were failing that in three different ways. Router-dongle writes
answered with a toast, which on a pessimistic surface was the only thing
separating "refused" from "never attempted" and expired in seconds. The GPS and
FCC toggles announced nothing at all: a screen-reader operator flipped a switch
whose position they could not see change and received silence. And the wait for
the confirming observation had no bound, so a broadcast that never arrived left
a stopped spinner and an untouched-looking dialog.

MutationOutcomeBand renders the persistent band AND the two always-mounted
sr-only regions from one call site, because shipping either half alone is the
defect being removed. The band itself carries no live role - the regions already
announce it, and a second one announces every outcome twice. Politeness follows
the kind, resolved once: success polite, refusal and unknown assertive.

router-write-flow bounds the wait and produces the honest third answer. The
observation confirms, never the reply: the backend's read-back rides the RPC
result, but consuming it would let the band claim applied while the switch still
showed the old value. GPS and FCC deliberately get no window - their replies
carry the device's own re-read, so there is nothing left to bound.

A stale reading is marked rather than presented as fresh, an absent one gets a
band rather than a blank panel, and neither is ever a spinner. The dongle's raw
vendor and 3GPP tokens - the band family, serving-cell ids, ARFCNs, the numeric
network-mode index - moved into a marked, collapsed diagnostics block in both
consumers, verbatim and with their testids unchanged.

The modem dialog's form snapshot already survived an incoming observation;
nothing pinned it, and now five tests do, driving the edit through the rendered
input and asserting the mirror property so the suite cannot pass on a dialog
that has stopped reading the device.

Also fixes three stale assertions in the modem a11y keyboard leg, red since
pass 1 changed the USB switch to name a behaviour instead of a wire token.

How to verify:
  bun run --filter frontend test          279 files / 3743 tests, 0 fail
  bun run lint                            exit 0
  bun run --filter @ceraui/i18n test      702 pass
  bun run --filter frontend test:e2e -- modem-a11y.spec.ts --project=desktop
                                          axe/keyboard/touch legs pass

Risks: the `ar` RTL row-overflow leg and the two truthfulness legs stay red;
all three are pre-existing, proven so, and owned by todo 33.

DESIGN.md Pass 3 (harden/adapt) for the Network/Cellular/modem surfaces.

Two layout defects, one root cause: a `shrink-0` flex item is pinned at its
max-content width, so when a row could no longer fit it, it had no way left to
fit and simply overflowed.

- CellularSection: the control cluster hung off the row's edge — measured in
  `ar` at 390px as `open-modem-config-dialog` at 20..91 against a row of
  25..350. It was never really Arabic-specific: with the fix reverted, `es` at
  375px overflows the document by 46px on four rows. RTL mirroring just moved
  the overflow to the edge someone happened to look at.
- BondedLinksSection: every instrument in the row is `shrink-0` and the
  identity column was `flex-1` (basis 0), making it the only element able to
  absorb a squeeze — so at 375px it absorbed all of it and the device name
  measured 0px. This closes the deferral recorded in apps/frontend/AGENTS.md.

Both fixes are to the SHARED row (wrap instead of overflow, a real basis for
the identity column, instruments grouped as one wrapping unit). No component is
forked per locale and no i18n key was added, which is what makes them hold in
all ten catalogs at once.

The gate grows from four legs to seven: base-locale breakpoints at
375/768/1280 and the 1024x600 kiosk for rows and dialog (BP-1..BP-3), the eight
non-base catalogs at 375 (LO-1..LO-5), reduced motion read from computed style
on the live tree (RM-1..RM-4), plus >=8px touch separation and
disabled-keeps-its-size (TT-2/TT-4). The `ar` leg now sweeps every mandatory
width.

Two measurement rules in that spec are load-bearing. Every probe settles first:
NavigationRenderer flies the destination in with a delay, so a probe taken
right after navigateTo catches it translated ~287px and reports the whole page
as overflowing — and awaiting document.getAnimations() alone does not cover it,
because Svelte registers that animation after the element is already in the DOM
at its translated start. Touch separation compares only co-reachable pairs,
since a control scrolled under the sticky dialog header still reports a box
there while the header owns those pixels.

Every new leg is falsifiable and was falsified: reverting each component
reproduces its exact failure, and flipping the reduced-motion emulation to
no-preference reddens that leg.

Verify:
  bun run --filter frontend test                       # 3743 pass / 279 files
  bunx playwright test tests/e2e/modem-a11y.spec.ts --project=desktop   # 7/7
  bunx playwright test tests/e2e/a11y.spec.ts --project=desktop         # 3/3
  bun run lint && bun run check:tech-debt
DESIGN.md pass 4 confirms passes 1-3 in a real browser. It introduces no rule
and changes nothing under apps/frontend/src/.

Full DOM truthfulness matrix. The §1 capability matrix was proven on one
fixture modem and four of six operation states. It now runs every operation
state - the five-rung ladder plus a backend that publishes no matrix at all,
which folds to `absent` fail-closed - against every fleet family: the four
mm-managed transports plus the pre-Phase-B wire that carries no device_class,
and both gated modules at every cell. 60 cells, plus router-ethernet and an
unrecognised class asserted to contribute zero capability nodes while claiming
`certified`.

Two inherited red legs closed. The debt-marker leg required
TD-modem-usage-policy-write in the DOM; that marker was retired when the
usage-policy write landed and its register entry is resolved. The leg now
asserts the emptiness as a positive claim and proves non-vacuity by planting a
known orphan inside the open dialog, which a collector scoped to the
destination subtree would miss - the dialog is a portal. The SIM-lock leg
expected an auto-prompt that was deliberately deleted; it is driven through the
row's own Unlock control and asserts first that nothing opens on the broadcast
alone.

Screenshot set. A new @visual spec walks eight state classes at 375/768/1280 in
en, ar and ja, plus the 1024x600 kiosk in touch layout - 62 PNGs into the
gitignored test-results/. The PNGs are evidence, never the check: every capture
is gated by the containment probe, IH-1 in pass 1's own selector vocabulary,
row reachability, dialog containment, and a raw-token scan with diagnostics
removed by selector. Both halves were falsified by deliberately breaking them.

The containment probe moved from modem-a11y.spec.ts into a shared helper so
both gates measure the same contract; that spec re-runs 7/7 unchanged.

Round two reported five occluded rows. Both causes were defects in the new
probe, not the product: the shadcn button base carries
disabled:pointer-events-none, so a disabled control cannot answer a hit test
and falls through to its own row, and scrollIntoViewIfNeeded counts an element
under the sticky header as visible. The probe now scrolls to the viewport
centre, accepts any hit inside the same row, and carries a permanent
non-vacuity proof - on the dialog surfaces it asserts the modal scrim does
occlude the rows.

Verify: bun run lint; node scripts/check-tech-debt.mjs;
bun run --filter frontend test; (cd apps/backend && bun test);
bun run --filter frontend test:e2e -- --project=desktop truthfulness.spec.ts;
bun run --filter frontend test:e2e -- --project=desktop modem-a11y.spec.ts;
bun run --filter frontend test:e2e -- --project=desktop visual/modem-pass4.visual.spec.ts

The fourteen frozen modem-control projections resolve through one structural
namespace probe, so which implementation answers is decided at runtime by the
installed package version. `modemControlFunction<T>` CASTS the runtime export,
so a packaged function that shares a name but not a signature type-checks clean
and misbehaves only when executed — which is why this matrix asserts returned
values rather than types, and why it must pass identically against the 0.2.0
registry floor and the 1.1 candidate overlay.

Fixes the one CeraUI-side skew defect: `fiveGPreferenceEvidence` is handed
mmcli's mode catalog while the packaged twin takes an already-decoded RAT set,
so the bare delegation threw `supportedRats.has is not a function` under the
overlay. It now adapts the vocabulary before delegating, following the adapter
`sim-presence.ts` already uses for the same reason. The floor path is
behaviourally unchanged.

The remaining three defects were parser-side and are fixed in modem-stack's
ZTE detail-field correction.

`modem-control-skew-matrix.test.ts` pins, in both columns: the probe roster
resolves to exactly one of two frozen sets; `vidPidOf` and
`hilinkConnectionBody` resolve in neither, so their fallbacks are permanent;
every probed projection returns identical values; the SRTLA `link_id` digest is
byte-identical, so an upgrade cannot re-key an operator's telemetry rows onto a
different uplink; and no capability module becomes surfaceable or claimable
merely because the package is installed.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75d99aba-f3e6-4be0-a9c1-4ed17b6c15db

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The band-lock and GPS controls told operators to turn a feature on "in
settings" and pointed at nothing. `config.modem_capabilities` was
default-absent with no write RPC and no UI anywhere, so a board sweep of
#settings matched zero testids against modem|cellular|location|gps|band|capab.
Both controls were unreachable on every board regardless of hardware.

Adds the write path and the surface:

- `modems.getCapabilities` / `modems.setCapabilities`. Both are authedProcedure,
  not modemProcedure: the gates belong to the device, so they must answer while
  the cellular stack is initializing or with no modem attached — exactly when an
  operator opens the surface. `getCapabilities` also reports which modules this
  build ships, the only way a surface can tell "not built" from "this hardware
  lacks it" (a modem row resolves both to `unavailable`).
- Settings -> System -> Cellular Features, a lazy dialog. Device-wide gates make
  this a Settings surface: a per-modem section would claim a scope the write does
  not have. Only implemented modules get a row (CT-1); the rest render zero nodes
  and their writes are refused rather than persisting a key nothing reads.
- A change-gated, default-inert re-publication seam so a probe that first proves
  a capability does not leave the claim stale until the 30s poll — the window an
  operator lands in right after enabling the gate.

The gate is a precondition, never a claim: it feeds resolveSupportClaim as one of
four inputs, so it cannot promote a module past `enabled` on an unprobed modem,
cannot reach `certified` at all, and leaves band-lock's certification floor
refusing as before. Asserted, not assumed.

24 i18n keys across all 10 locales, covering all seven modules so an unimplemented
one cannot print a raw dotted key the day it lands.

Tests: 23 backend, 13 dialog, 24 copy-completeness, 8 e2e. Rule-E proven in four
directions (dropping the refusal, the re-broadcast, the change-gate, or the CT-1
filter each redden). Not hardware-validated — see
.omo/evidence/task-50-capability-settings-ui.md §7.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant