feat: unified modem control — companion package, capability modules, v1.1 published surface - #12
Merged
Conversation
Non-mutating bench capture of the SIMCom SIM7600G-H (1e0e:9001) and the M.2->USB carrier-mounted Fibocom FM350-GL (0e8d:7127): descriptors, driver bindings, firmware revisions, and the read-back state of each vendor's USB-mode command. Only bare-execute, READ (?) and TEST (=?) AT forms were sent; no SET form, and both units' VID:PID were unchanged across the session. Certifies nothing. The SIMCom's PID->composition mapping is unproven, so its target modes stay uncertified and hidden; the FM350 gains no classifier entry for its carrier id and FM350-DECISION.md is unchanged. The pass also re-verified the RB-11..15 blocker table on real hardware: B1 (usbutils absent) is cleared, B3 is downgraded (socat present, so a manual query-only AT session works), B2 is promoted from a code read to a hardware-proven failure, and two new blockers are recorded -- the redactor does not mask IMEI, and skuOf keys firmwarePrefix on the USB bcdDevice rather than the modem firmware revision. B2 and B6 are pinned by a new test.
Phase A shipped nothing to devices: the bench CLI installed from CI artifacts, so modem-stack had no .deb for the image to fetch. The modem runtime the device actually needs — udev rules, usb_modeswitch entries, and the FCC-unlock reconciler — lived nowhere a device image could install from. Adds the first-party `ceralive-modem-support` companion, `Architecture: all`. It is ONE immutable release asset with TWO index memberships: built exactly once and indexed into both binary-arm64 and binary-amd64. The manifest records its BUILD architecture (`all`) and the publisher DERIVES index membership from it. Building it per-arch would put two byte-different files under a single package/version key and break the publisher's immutable-key rule. The udev rule deliberately does not reuse an image-owned /etc/udev/rules.d/ basename. udev resolves by basename, so an /etc file silently shadows the packaged one in a way `dpkg -S` cannot detect; the legacy v1 basename is retained as a checksummed override list and postinst/postrm reconcile against it rather than clobbering operator state. packaging/ci carries the build, the expected-package inventory, the release manifest generator, and a chroot install/remove test that runs the real maintainer scripts. The apt-dispatch preflight workflow refuses to dispatch a publish whose asset set does not match the inventory.
…nomy Every capability this effort adds — band locking, SMS, 5G preference, FCC unlock, GPS, USSD — is real on some modems and absent on others, and the failure mode that matters to an operator is a control that looks available and then refuses. The package had no shared way to answer "can THIS modem do this?", so each surface would have invented its own guess. `capability/detect.ts` derives a module's availability from evidence the modem itself reported, never from a model-name heuristic. `capability/support-claim.ts` gives that answer a taxonomy: a claim is supported, unsupported, or unknown-for-this-modem, and unknown is a first-class answer rather than a degenerate "no". A consumer can therefore render an honest third state instead of hiding a control that might work or offering one that cannot. Nothing consumes the framework yet; the modules land on top of it.
…S, USSD, MSISDN Seven capability modules on top of the feature-gate framework. Each is gated by evidence the modem reported, each is certified per modem where certification is required, and each answers unknown-for-this-modem rather than pretending a refusal is a "no". - band locking, certified per modem, with the primary-surface contract - a read-only SMS port, so CeraUI stops shelling out to raw mmcli-sms - 5G mode preference, gated - opt-in FCC auto-unlock through ModemManager dispatcher hooks, plus its coverage matrix and a reconcile test that runs the shipped script rather than a fake - GPS/location, gated - USSD, gated - the SIM's own number (MSISDN), folded onto ModemIdentity.ownNumbers ModemManager publishes the SIM's own number as Modem.OwnNumbers, and the package read nothing from it — so a consumer that wants to show an operator which SIM is in which slot had no source for it. mapping.ts now folds the property onto ModemIdentity.ownNumbers, branded SubscriberNumber for the same reason SubscriptionId is: it identifies a subscriber. Absent, empty and whitespace-only lists all read as not-reported rather than as an empty list, because most SIMs genuinely carry no MSISDN and an empty array invites a consumer to render silence as a finding. A multi-number SIM keeps every number in order. redact.ts gains its own whole-key own-number class. It cannot join SENSITIVE_KEYS, which matches a leaf name: 'number'/'numbers' there would blank a slot index and a band count across the package. PolicyBindingKey enumerates its fields, so the new identity field cannot reach a durable key. Board evidence: Quectel RM530N-GL reports own: +573115422359. eSIM, by contrast, closes blocked rather than shipping: The 2026-08-13 eSIM deferral was reversed, and eSIM re-entered scope as a hardware-gated adoption spike. It closes `blocked`: no modem on the bench exposes an eUICC. The only SIM on the whole fleet reports no `eid` under ModemManager 1.24.2 — positive evidence of a classic removable UICC, not missing data — and RM530N-GL eUICC capability is unproven for that unit. `blocked` is deliberately not a NO-GO. A NO-GO would be a judgment that lpac cannot work here, and nothing measurable supports one: the spike's three hardware steps could not start. No verdict exists and none may be inferred, so none of the GO-path delivery ships — no ceralive-lpac deb, no closure row, no manifest entry, no apt publication, no image pin. closure_version 2 and its frozen matrix are untouched. The licensing half of the spike is pure research, so it did run, re-verified against upstream rather than carried over: - lpac's program logic is AGPL-3.0-only, so it may only ever be spawned as an external binary over its CLI — never linked into modem-control, cerastream, or CeraUI. - Redistribution obliges Corresponding Source from the same place (AGPL-3.0 §6(d)). apt.ceralive.tv publishes binary indexes only, so a source channel would have to exist before any lpac upload. Recording it now keeps a future GO from discovering it late. - §13 attaches to modified versions only, so the rule is ship unmodified or not at all — which is what POLICY.md's no-fork rule already requires. - lpac is now in Debian main (2.3.0-1) but in testing/unstable only, absent from bookworm and trixie, so the preferred stock-distro shipping form is ruled out by suite rather than by licence. Also records the unblock path: a removable eUICC card in the Quectel's SIM slot would give the bench a real EID with no new modem, and lpac's default pcsc backend reaches such a card outside ModemManager entirely.
Some modules' USB-mode switch command only writes non-volatile configuration: it answers OK, reads back the new value, and leaves the device enumerated in the OLD composition. Measured on the bench RM530N-GL, `AT+QCFG="usbnet",<n>` behaves exactly that way in both directions. Undeclared, such a SKU exhausts the port-drop budget and reports failure AFTER the NV write already succeeded — then lands in the new composition at the next unrelated reboot. That is a fail-OPEN outcome from a fail-closed design. `applyCommand` is optional (a SKU that re-enumerates on its own declares none) and `min(1)` when present, because an unsendable empty string is not the same fact as "no commit command". `applyCommand` is dispatched between `at-command` and `await-port-drop`, through the SAME allowlisted `AtCommandLease` as the switch command — the allowlist now unions both fields, so a commit command is as tightly constrained as the switch it completes. It is its own step (`apply-command`) rather than a retry of the first, so a transcript distinguishes the two and a reviewer can see the order. A commit the AT layer rejects fails the transaction degraded and never reactivates, which is the point: a half-applied NV write must not be reported as a switch. The tests drive a LOCAL fixture catalog, deliberately. What is under test is the engine's behaviour when an entry declares a commit command; asserting it through the shipped catalog would turn an engine test into a certification claim and couple it to a review decision.
…r registry The frozen v1.1 domain surface this package publishes: physical and subscriber identity, generation, observation envelopes, and operation results, each with the branded types that keep an identifier from being passed where a different identifier is expected. On top of it, a provider registry with an evidence-scored matcher. A provider is selected by how well the evidence a device actually reported matches the provider's declared discriminators, not by a first-match-wins ordering that makes adding a provider a reordering problem. DOMAIN-CONTRACTS.md and PROVIDER-MATCHING.md document both, and a conformance fixture gives every provider the same shape of test.
@ceralive/modem-control shipped raw TypeScript through v1.0.0 - exports pointed at ./src/index.ts and files was ["src"], with no build tool in the repo at all. It now publishes built ESM plus declarations from dist/, across seven entry points and no more: ., ./transport, ./domain, ./providers, ./capabilities, ./hardware, ./testing. Internal barrels stay reachable only through the root entry, so reorganising them is not a breaking change. ./testing is the public contract-fakes surface. A consumer testing against this package needs valid instances of the frozen v1.1 domain and provider contracts, and hand-rolling them is how a consumer's fixtures come to disagree with the package - a hand-written OperationResult literal quietly stops matching what classifyOperationCompletion returns. Every fake is built through the package's own constructors and classifiers, so it cannot express a shape the domain refuses. control/test-support/ is deliberately NOT this: it needs a private session bus and a stateful nmcli harness, and stays unpublished. dist/ is a 1:1 tsc emit rather than a bundle. Bun.build --splitting emitted an entry whose export list named symbols the file never imported, which Bun's loader accepts and Node rejects with "Export 'BigIntRequiredError' is not defined in module" - the Node fixture is what caught it. Bundling without splitting instead gives each subpath its own copy of the shared modules, which breaks instanceof across two subpaths of one package. Because tsc never rewrites a specifier and these sources are written for bundler resolution, the build rewrites each emitted ./x into ./x.js or ./x/index.js, resolved against the emit itself, and fails if one extensionless specifier survives. The repo-root tsconfig now maps @ceralive/modem-control* back to control/src. That is required, not a convenience: with exports pointing at dist, the workspace cli resolved the package to dist while control/test-support resolved the same modules relatively, and Brand's unique symbol turned every branded value crossing that boundary into a hard type error. The mapping is never published, so consumers still resolve to dist. The built artifact is therefore proven by things that ignore that mapping. The shape gate packs with bun pm pack and runs six rules over the extracted tarball: no raw source, dist really carries .js and .d.ts, every declared entry both exported and packed, no undeclared subpath, nothing pointing outside ./dist/, and the library-only proof - no bin, no systemd unit, no shebang, no listening-socket construct. Each detector has a non-vacuity test that trips it with a synthetic artifact. Two standalone consumer projects then install the real tarball and import all seven specifiers; the Node one refuses any major but 26, so a green result cannot come from an older Node on PATH. Incidental but required: biome's "!test-results" only excluded a root-level directory, so the new control/test-results/ made lint red; it is now "!**/test-results". Both workflows move to Node 26, and ci-bun.yml gains the consumer-fixture step.
Pins node-version to "26" across the workflows and brings them onto the shared CI canon: concurrency blocks with the correct cancel-in-progress polarity, push triggers constrained by branches so a PR-branch push does not duplicate the PR run, top-level least-privilege permissions with per-job escalation, major-pinned actions, and dependency caching.
The layer CeraUI migrates onto: portable physical identity, normalized ModemManager enums, presentation helpers, shadow-divergence folding, USB network-device classification, surfaceable-module selection, and the router telemetry parsers. Every function here is pure. It takes evidence and returns a verdict, with no D-Bus handle, no process spawn, and no clock — which is what makes it testable without hardware and what lets CeraUI become a thin consumer instead of carrying a second copy of the same rules. docs/ records the boundary so a later change does not quietly reintroduce I/O into it.
… engine The impure half, behind ports. Providers: a typed D-Bus ModemManager provider, a NetworkManager desired/applied bearer adapter, evidence-selected Huawei HiLink profiles with readback, firmware-scoped ZTE goform profiles plus the MF79U diagnosis harness, and a read-only UFI provider with explicit Qualcomm prohibition fences. Each one is matched by evidence and conformance-tested against the same fixture corpus, with sanitized transcripts committed so the matrix is reproducible without the rack. Underneath them: provenance-aware normalized observation envelopes, an admission port with cross-process exclusive ownership so two processes cannot drive one modem, a descriptor-gated operations engine that reconciles unknown outcomes rather than guessing, and a path-parameterized journal engine and store so a killed process replays instead of losing the transaction. Radio truth is verbatim: the mode and band a modem reports are surfaced as the modem said them, with signal and SIM evidence normalized around them. Per-repo AGENTS/docs record the resulting behavior.
Overlaying the 1.1 candidate onto CeraUI's own tree proved `parseZteDetails`
could not replace the reader it exists to retire: it was narrower than that
reader and disagreed with it about a key name.
`band` and `network_band` are two different readings and are no longer folded
onto one key. `lte_band` is the serving cell's band; `wan_active_band` is the
band the WAN leg is active on, and the two disagree the moment carrier
aggregation is up, so publishing either under the other's name reports a band
the device never claimed for that leg.
The carrier composition and the dongle's own counters are now carried:
`lte_ca_{p,s}cell_*`, the monthly triple plus `date_month`, and the five
`realtime_*` counters. The `realtime_*` to `session_*` rename is deliberate —
three of those five are cumulative counters, and the vendor's prefix reads as
"live rate" for all five.
`stated()` drops every vendor placeholder rather than only the single dash;
`--`, `n/a` and `N/A` are unset markers on these firmwares too, and echoing one
puts a value on screen that reads like a reading. `parseUfiDetails` shares the
helper and inherits the widening.
Also recorded: `parseUfiDetails` remains narrower than CeraUI's UFI reader and
takes a different input shape. No consumer probes for it, so nothing is broken
today, but a cutover must reconcile it first.
Also removes a stale CeraUI-only symbol reference (`withCapabilityModuleMutation`)
from the carryover section of AGENTS.md: it names a symbol that lives in CeraUI,
not in this repo, so a reader following it finds nothing here.
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:
modem-stack— TypeScript (Bun) + Debian packaging + shellWhat
Turns
modem-stackfrom a bench-only CLI into the device's cellular control plane andits first shipped package.
Three things land together:
ceralive-modem-support— the first-party companion.deb(Architecture: all)carrying the udev rules,
usb_modeswitchentries and the FCC-unlock reconciler thedevice runtime needs, with a chroot test that runs the real maintainer scripts.
capability modules on top of it: band locking, read-only SMS, 5G mode preference,
opt-in FCC auto-unlock, GPS/location, USSD, and the SIM's own number (MSISDN).
registry, typed providers for ModemManager (D-Bus), NetworkManager, Huawei HiLink,
ZTE and UFI, an admission port with cross-process exclusive ownership, a
descriptor-gated operations engine, and a path-parameterized journal — published as
built ESM from
dist/across seven entry points instead of raw TypeScript.Also: catalog transitions may now declare the command that COMMITS them; the CI moves
to the Node 26 baseline and the workflow canon; eSIM closes
blocked(not NO-GO) on theeUICC gap.
Why
CeraUI carried its own copy of 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 premise is that the device and the
platform agree about what a modem is. That only works if there is one implementation,
published, versioned, and testable without a rack — which is what the pure migration
layer and the
./testingcontract-fakes entry point are for.The companion package exists because Phase A shipped nothing to devices: the bench CLI
installed from CI artifacts, so the modem runtime the device actually needs lived
nowhere a device image could install from.
Two decisions worth reading rather than re-deriving:
unknown-for-this-modem.
unknownis a first-class answer, so a consumer can render anhonest third state instead of hiding a control that might work or offering one that
cannot.
/etc/udev/rules.d/basename. udev resolves by basename, so an
/etcfile silently shadows the packagedone in a way
dpkg -Scannot detect. The legacy v1 basename is retained as achecksummed override list, and
postinst/postrmreconcile against it.How to verify
The companion's install/remove behaviour is exercised for real, not mocked:
bash packaging/ci/build-companion.sh sudo bash packaging/ci/test-companion-chroot.sh # runs the shipped maintainer scripts bash packaging/ci/test-fcc-reconcile.shProvider matching is reproducible without hardware — the conformance matrix drives the
committed sanitized transcript corpus. See
docs/PROVIDER-MATCHING.mdanddocs/DOMAIN-CONTRACTS.md.Risks
Hardware certification is NOT claimed by this PR. The bench drills that would certify
provider behaviour against real modem silicon are recorded BLOCKED-ON-OWNER — no bench
board was reachable. Everything here is proven against fixtures, transcripts and a
chroot; nothing asserts a hardware verdict it did not measure, and the ingestion seam
refuses synthetic-backed catalog entries by design rather than by convention.
Known open blockers, already recorded in
docs/BENCH.mdand unchanged by this PR:B2 (
certifymatches its target byifname, which the production USB enumeratornever populates, so no bundle this pipeline produces can currently be promoted) and
B6 (
skuOfderivesfirmwarePrefixfrom udevID_REVISION, the USB bcdDevice, notthe modem firmware revision). Both are software-fixable and gate stage 1 for every SKU.
Packaging risk is contained: the companion is a new package, so nothing existing changes
version or content. The CI Node flip is 24 → 26 per the workspace canon; this PR's run
is the first CI execution of the edited
ci-bun.yml/release.yml.Rollback: the companion is additive and can be withheld from the closure by publishing a
closure_version: 1manifest.Checklist