feat(cellular): unified modem control — Phase-B architecture, operator truth, modem-stack migration - #289
Open
andrescera wants to merge 14 commits into
Open
feat(cellular): unified modem control — Phase-B architecture, operator truth, modem-stack migration#289andrescera wants to merge 14 commits into
andrescera wants to merge 14 commits into
Conversation
…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.
Contributor
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
andrescera
force-pushed
the
feat/modem-phase-c
branch
from
August 20, 2026 08:31
fdd3099 to
e584965
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Affected repo & language:
CeraUI— TypeScript (Bun), Svelte frontend + backendWhat
Rebuilds the device's cellular control surface, and migrates its backend onto
@ceralive/modem-controlinstead of a second copy of the same rules.Six commits, in story order:
setUsbModecontract and typedinit/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 eligibilitysplit; a modem-transition admission interlock; router-dongle classification by VID:PID;
the rebuilt Cellular section and modem dialog; and the seven gated capability modules.
no_simfrom real ModemManager SIM-presence evidence, certification keyed on firmwarerevision 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.
modem-control-compat.ts.outcomes with screen-reader regions, and responsive/touch/locale/a11y hardening.
Why
The cellular story was a set of
mmclishell-outs behind a Network page that could nottell 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.0registry floor and againstthe
1.1candidate, and that equivalence is pinned by the skew matrix rather than assumed.Two of the projections —
vidPidOfandhilinkConnectionBody— resolve in neithercolumn. Those fallbacks are the implementation, not a transition; deleting them at the
shim-removal step would delete the feature.
How to verify
End-to-end (desktop project):
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 packagedfunction 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 flakyand@visual87 pass / 26–27 fail, identicalin 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
@visualset 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-controlback to0.2.0restores thepre-migration code path without a revert.
Checklist