Cellular modem control for CeraLive: the frozen v1.1 domain contracts, the provider registry and evidence-scored matcher, the ModemManager D-Bus backend, the NetworkManager adapter, the desired-state reconciler, the USB composition-mode model, the data-usage sampler, and the gated capability modules.
This package is a LIBRARY. It ships no bin, no systemd unit, no shebang, and
nothing that opens a listening socket — it is imported by a controller, it is not one.
That claim is checked against the real bun pm pack output, not against the source
tree; see Shape gate.
npm install @ceralive/modem-control # or: bun add @ceralive/modem-controlESM only ("type": "module"). Node 26 and Bun 1.4 are the runtimes the published
tarball is exercised against on every CI run.
Seven specifiers, and nothing else. Every other module is internal and reachable only through the root entry, so an internal reorganisation is not a breaking change.
| Specifier | What it carries |
|---|---|
@ceralive/modem-control |
Everything below plus the ports, backend, reconciler, redaction, SMS, USSD, location and FCC modules |
@ceralive/modem-control/domain |
Frozen v1.1 contracts: PhysicalModemId, DeviceGeneration, ObservationEnvelope, OperationDescriptor / OperationResult |
@ceralive/modem-control/providers |
ProviderDefinition, the registry, and the evidence-scored matcher |
@ceralive/modem-control/capabilities |
The five-state support-claim taxonomy and per-modem capability detection |
@ceralive/modem-control/hardware |
The per-SKU hardware model: USB composition modes + certified catalog, and the band vocabulary + band-lock certification |
@ceralive/modem-control/transport |
The D-Bus transport seam (no underlying-library type is re-exported) |
@ceralive/modem-control/testing |
Public contract fakes for consumers' own tests |
The ./hardware surface also owns the transport-free response parsers migrated
from CeraUI: SIM-presence evidence plus normalized Huawei HiLink, ZTE goform,
and Qualcomm UFI/HIMI signal, detail, and capability reads. They accept response
bodies only; HTTP sessions, interface binding, retries, caches, and writes remain
consumer-owned.
The root entry also carries the observation layer built on those parsers. It turns a raw
per-vendor payload into one ObservationEnvelope<NormalizedModemObservation> in which every
metric records which source produced it and when, and in which a missing value carries a
reason that says whether the source cannot report it (unsupported) or merely did not
on this read. fresh, stale, unavailable and unknown are four distinct shapes rather
than a value plus a flag: stale keeps its value, unavailable carries none and never ages into
stale, and unknown says which field is missing and why. Nothing the provider sent is
discarded — every provider-native field is retained verbatim in a typed diagnostics block,
with unmapped derived rather than declared. Desired, applied and observed state stay in
three separate slots.
The migration surface is broader than response parsing but remains pure: the root and
existing ./domain, ./capabilities, and ./hardware entries expose portable physical
identity/link-id derivation, modem presentation rules, ModemManager enum decoding,
USB-network classification and labels, capability-module selection, and shadow-backend
divergence folding. Every helper consumes caller-supplied values or snapshots; none discovers
devices, opens a transport, persists state, or performs a modem write.
USB snapshots retain the udev P: record as an absolute sysfsPath, allowing consumers to
correlate ModemManager Device/Physdev paths to the most-specific USB parent without relying
on a network-interface name.
createModemManagerProvider({ transport }) returns the concrete ModemManagerProvider and its
provider-registry definition. Matching is based on the live ObjectManager tree, not a certified
model allowlist, so unknown future modems retain generic mode, signal, SIM, and power controls when
their runtime interfaces/properties advertise them. Its normalized observe result uses the root
observation envelope; its lifecycle start / observe / stop methods reuse the epoch-scoped
signal observer.
createHuaweiHiLinkDefinition() exposes exact E3372H firmware profiles for password types 3 and
4. Firmware plus SesTokInfo evidence chooses one profile, and state-login must confirm that
profile's password type before the provider makes its only login attempt. Every request is bound to
the injected network interface with redirects disabled. Mode and mobile-data writes acquire
router-session, probe their own capability, and require a new authenticated readback before they
can report applied; no Wi-Fi write exists. Credentials and session material remain private to the
provider runtime. See ../docs/HUAWEI-HILINK-PROVIDER.md.
createZteGoformDefinition() exposes three incompatible authentication profiles without
fallback between them: MF79U legacy base64 under LOGIN, MF79U LD-salted SHA-256 under
the same bare LOGIN, and MF266 salted SHA-256 under LOGIN_MULTI_USER. One batched
pre-auth evidence GET selects the exact shape and refuses a reported lockout before any
credential POST. MF266 derives AD from the probed version data and RD. Session material stays
in memory. Unknown ZTE firmware is fingerprinted into a read-only telemetry profile, and
Wi-Fi writes are absent from every operation surface. See
../docs/MF79U-DIAGNOSIS.md.
createUfiHimiDefinition() normalizes the Qualcomm UFI/HIMI telemetry over the vendor's
single POST /himiapi/json endpoint. Because that API puts its verb in the request
body's cmdid rather than in the HTTP method, read-only is enforced as a frozen
command vocabulary — seven get* reads plus login — so a write command cannot be
expressed at all. operations() returns ProviderReadOperations entries verbatim and
exposes zero write descriptors.
The prohibited Qualcomm operations — NV, EFS, identity and calibration writes, firmware
flashing, EDL automation, blind driver/interface retries, DIAG writes, the DIAG info
probe, and shell transport fallback — are inert table entries with no implementation
anywhere. planUfiOperation() answers each with a typed reason and takes no transport
parameter, so the refusal provably precedes any device contact; the same ids driven
through OperationEngine are refused before execution too.
05c6:9024 is evidence of an RNDIS+ADB composition, not a permission. 05c6:9091 is a
firmware-chosen product id and is not proof of DIAG — only an interface descriptor is,
and production access stays prohibited regardless. The supervised, read-only, bench-only
probe is documented in ../docs/UFI-DIAG-PROBE.md, together
with the read-only descriptor capture (scripts/ufi-himi-capture.sh) and its bundle
schema, interface-role classifier and redaction sweep (scripts/ufi-himi-evidence.ts).
Neither ships in the package: files: ["dist"], and bench tooling is not a public surface.
new NetworkManagerAdapter({ port }) is the bearer/APN authority, and the only one. It holds
three separate slots per NM connection: the desired profile (what an operator asked for,
recorded from the request), the applied bearer (what NM actually put into force, recorded
from NM's readback, together with the interface it landed on), and the observed device
state. observe() folds one complete NM readout and reports a typed applied-state LOSS —
interface-absent, interface-detached, connection-replaced, or activation-failed — while
leaving the desired profile untouched, so a modem that re-enumerates costs you the bearer and
never the configuration. A device still settling is reported pending rather than lost, and a
readout from a superseded generation is refused rather than folded late.
It composes the existing NmcliNmPort rather than replacing it, performs no radio, band, SIM
or power operation, keys every slot by NM's connection UUID rather than by a physical modem
identity, mirrors no credential into a state slot, and has no profile-delete path.
The operation surface composes the existing radio/band backend, GPS location adapter and bounded
fix-state machine, read-only SMS port, USSD session adapter, and FCC coverage catalog. Generic band
reads are always runtime-driven; disruptive band writes additionally require a certification
catalog entry for the device's SKU. It contains no bearer/APN authority and no command-line
fallback. The provider never invokes mmcli, qmicli, or mbimcli—those remain operator
diagnostics only.
The root export also carries the mode/band capability truth layer. SupportedModes
and CurrentModes are decoded without loss: a combination whose preferred mask is 0
reads preferred: 'none' — a value, not a missing field — and reaches
descriptor.constraints.values exactly as the modem stated it. A mode bit this build
does not name round-trips as mode-bit-<n>, its combination is classified
unknown-combination, and it stays offered; a catalog member that is not a (uu)
pair is retained in undecodable rather than dropped, so decoded plus undecodable is
always the member count the modem sent. A selection the modem never advertised is
refused, never rounded to the nearest one.
modes and bands are typed write operations with required readback: the daemon
accepting SetCurrentModes / SetCurrentBands only proves the call was accepted, so
the applied value is re-read and compared before either reports success. bands
additionally carries mutationImpact: 'disruptive', a band-certification-present
live precondition, and an availability that is refused with
band-certification-required unless the device's SKU resolves to an entry in the
band-lock certification catalog — which ships empty, so that is today's answer for every
device. Supply bandSku to createModemManagerProvider to resolve one;
ModemManager exposes no USB vid:pid, so the package cannot build a BandSku alone.
Both operations expose describe(context) alongside their static descriptor, because
a static descriptor cannot carry a device's own catalog or its certification state.
operations().usbComposition asks a known vendor for its current and enumerated USB
composition modes and offers only targets from that reply after the reply also proves a
represented return path. Its suppression vocabulary is unknown-vendor,
no-return-path, blocked-by-state, and provisioning-disabled; suppressed states expose
no targets. Unknown/disabled/blocked decisions happen before transport contact, and a
capability read sends only the named READ/TEST forms, never a SET.
The current and enumeration replies must both carry a successful AT result before their raw
bodies are parsed. If either query fails, the operation returns the existing no-return-path
suppression with an empty offer; parseable-looking data followed by ERROR cannot make a
disruptive target writable.
A reviewed catalog transition still provides the strongest success proof: canonical mode
and USB descriptors must both match. Without one, the weaker fallback proof is the
re-enumerated device's own post-switch READ reporting the target. AT OK is never success.
The write remains disruptive and requires admission, journal, rollback, and readback hooks.
Band writes do not share this policy and remain behind their four-proof certification gate.
readSimPresence returns the presence together with the SimPresenceEvidence that
decided it, and absent is reachable through exactly one evidence kind — ModemManager's
own StateFailedReason: sim-missing. A blank Sim object path proves nothing (MM
reports / while a modem initializes and while a slot switch is in flight) and reads
unknown. ModemManagerSimState.present is positive evidence only: false is not a
claim of absence. The Huawei, ZTE and UFI sources still claim no presence at all and now
NAME the vendor code they left undecoded, which stays verbatim in the diagnostics block.
Modem.CurrentModes and Modem.SignalQuality are retained as the D-Bus structs they
are, so the preferred mode and the measurement-recency flag survive normalization;
NormalizedSignal.qualityRecent claims that flag, and the router sources answer
unsupported for it.
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.
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.
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.
The root export includes MutationAdmissionPort, ResourceOwnershipPort,
ModemManagerInhibitPort, and UhubctlPort. Admission remains consumer-owned: a required
mutation without an injected admission port is refused as admission-port-missing; this
package does not know or infer why the consumer refused it.
File stores, router sessions, and USB-hub access use acquire-or-refuse exclusive ownership.
createFlockResourceOwnershipPort({ lockPath }) is the Linux adapter: non-blocking flock,
holder PID/start-time metadata, and clean release when the holder process dies. The lock path
is mandatory input; DEFAULT_MODEM_CONTROL_LOCK_PATH is only a conventional value callers
may select. There is no pass-through ownership implementation.
The adapter holds the lock with an external /bin/cat whose pipe round-trip acknowledges
successful acquisition. It never launches process.execPath -e: a compiled Bun executable's
process.execPath points back to the application, so re-executing it would parse -e as an
application option and misreport the resulting exit as contention.
One createModemControlCompositionRoot() may be live per process. A second construction
throws, and actorFor(physicalModemId) shares one actor for that modem across all callers in
the root. UhubctlPort has no control-package implementation; an embedding process must
inject one and own its executable policy.
createOperationEngine() executes OperationDescriptor contracts through that composition
root. Every mutation enters the root's shared physical-modem actor before its live preconditions
and admission are checked. Writes are therefore single-flight per physical modem, and a mutation
that waited in the queue cannot reuse facts checked before it waited. Reads do not occupy the
write queue; the engine retries only a failed read whose descriptor explicitly says
idempotent-read, once.
A stale-generation completion or a timed-out/dropped write reply is classified by the frozen
domain helper as unknown-outcome. The engine then closes a per-PhysicalModemId mutation gate:
subsequent mutations are refused as reconciliation-required without calling the provider.
engine.reconcile() uses the same actor and reopens the gate only when reconciliation finishes in
the requested current generation. Required readback, rollback, and journal hooks are checked before
execution and fired according to the descriptor; an unknown outcome is never treated as a definite
failure that is safe to roll back.
createFileJournalStore({ path }) and createJournalEngine({ store }) are the durable
half of that reconciliation gate. The engine satisfies the operation engine's
OperationJournalHook, so it can be handed straight to an OperationExecution as its
journal, and engine.recover() reads the file back after a restart and reports which
operations were still pending and which ended unknown-outcome — the set a controller
must reconcile before it mutates those modems again.
The path is required and this package has no default for it. Where a journal lives is
a property of the system embedding this library, not of the library, so there is no
fallback location to accidentally write to. An empty path is refused with
JournalPathError.
The store is append-only and never truncates itself. A record it cannot decode is returned
as typed damage alongside every record that did decode — including the ones after it — so
a single corrupt line can never take the rest of the journal with it. Call
assertJournalIntact(recovery) to escalate that damage to a JournalRecoveryError once
you have seen what survived. Neither an operation's input nor its returned value is ever
written to disk.
readLegacyCeraUiJournal({ dir }) reads an older per-modem snapshot journal into the same
recovery model, so a consumer migrating onto this package can enumerate outstanding work
from files written before it existed. It only ever reads.
A consumer writing tests against this package needs valid instances of the domain and
provider contracts. 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 actually returns. Every fake in ./testing is built
through this package's own constructors and classifiers, so it cannot express a shape
the domain refuses.
import { createProviderMatcher, createProviderRegistry } from '@ceralive/modem-control/providers';
import { fakeProviderDefinition, fakeProviderMatchRequest } from '@ceralive/modem-control/testing';
const registry = createProviderRegistry();
registry.register(fakeProviderDefinition({ observation: { registered: true } }));
const result = await createProviderMatcher(registry).match(fakeProviderMatchRequest());./testing is pure data and functions — no bus, no daemon, no process, no filesystem.
It is not the repository's test-support/ directory, which holds the heavy
internals this package's own tests use (an MM-faithful fake D-Bus service on a private
session bus, a stateful nmcli harness, and the provider-matching conformance corpus).
Those are unpublished and are not a reusable surface.
src/providers/conformance-matrix.test.ts runs 20 cases — nine fleet profiles plus
eleven ambiguity / malformed / auth-expired / lockout / unknown-firmware /
wrong-interface / wrong-transport cases — with the ModemManager, Huawei HiLink, ZTE
goform and UFI/HIMI providers all registered at once, asserting the exact provider,
profile, writability and evidence score per case. A companion suite asserts the exact
per-firmware HTTP transcript (method, path, form/JSON/XML body, header order, cookie,
and request count), and a third is a software upper-bound fixture at 16 concurrently
attached modems — a fixture result, not a hardware claim; the bench-verified fleet
size remains 8. See ../docs/PROVIDER-MATCHING.md.
bun run build # tsc -> dist/ (ESM + .d.ts), then fully specify every relative specifier
bun run verify:tarball # pack and assert the published artifact's shape
bun run verify:consumers # install the tarball into standalone Node 26 + Bun projects and import every subpathdist/ mirrors src/ one-to-one rather than being bundled. Bundling with code
splitting produced an entry whose export { … } list named symbols the file never
imported — accepted by one loader, a SyntaxError in another. Bundling without
splitting instead gives each subpath its own copy of the shared modules, which silently
breaks instanceof across two subpaths of the same package. A 1:1 emit has exactly one
instance of every module.
Because tsc never rewrites a specifier and this package's sources are written for
bundler resolution, scripts/build.ts rewrites each emitted ./x into ./x.js or
./x/index.js — resolved against the emit itself — and fails the build if one
extensionless specifier survives.
prepack runs the build, so npm pack / bun pm pack can never publish a stale dist/.
scripts/tarball-shape.ts runs six rules over the extracted tarball, driven from
bun test (scripts/tarball-shape.test.ts) and from the CLI
(scripts/assert-tarball-shape.ts):
- no raw source ships — the published surface is built output;
dist/actually contains JavaScript and declarations;- every declared public entry is in the exports map and its files are packed;
- no subpath beyond the declared set — internal barrels stay internal;
- no export target,
mainortypespoints outside./dist/; - the library-only proof: no
bin, no systemd unit, no shebang, no listening-socket construct.
The declared entries live in scripts/entries.ts and the test additionally spells the
seven specifiers out as a literal, so a subpath cannot be dropped without a reviewable
change to the public contract.
AGPL-3.0