Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2ba9763
fix(cli): deterministic watch/io teardown on failure and EOF
andrescera Aug 25, 2026
f9205bd
fix(backend): injectable router-ethernet probe + timeouts + classifie…
andrescera Aug 25, 2026
29a6641
build: first-party version unity (three package.json + companion chan…
andrescera Aug 25, 2026
6f1095a
fix(safety): reap flock helper on failure; bound IO; startup deadline
andrescera Aug 25, 2026
807250d
test: directory-scoped safety-gate scan roots with count floors
andrescera Aug 25, 2026
3173ea9
fix(transport): cancellable backoff + observable teardown + unified t…
andrescera Aug 25, 2026
0ddbd87
style: fix biome formatting in version-unity.test.ts
andrescera Aug 25, 2026
b8a8179
style: fix biome formatting in journal-path-injection.test.ts
andrescera Aug 25, 2026
2e9d1f8
refactor: shared JSON boundary parser + unified bench AT sender
andrescera Aug 25, 2026
33577da
refactor: decompose five oversized modules (three-gate zero-behavior …
andrescera Aug 25, 2026
cac7e73
docs(adr): stay on TypeScript; modem-metrics idea provenance
andrescera Aug 25, 2026
c411d76
feat: Sierra classifier/FCC-coverage/fixtures + RB-18 bench capture
andrescera Aug 25, 2026
34e44f5
feat: Telit/u-blox/NETGEAR classifier + sourced vendor quirk table
andrescera Aug 25, 2026
16d1687
feat(provider): MM Signal-interface metric normalization + injectable…
andrescera Aug 25, 2026
312f768
docs: compatibility matrix (vendor × firmware × composition × operation)
andrescera Aug 25, 2026
655c195
feat: operator/cell registration context + counter-reset-aware usage …
andrescera Aug 25, 2026
98e48bf
release: finalize modem-stack v1.3.0 versions before tagging (STEP 0 …
andrescera Aug 25, 2026
dd58469
fix(ci): match the companion build by invocation, not by a `run:` scalar
andrescera Aug 25, 2026
26db4f9
docs: narrow Sierra's `AT!` fence claim to the surface it describes
andrescera Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,13 @@ jobs:
- name: "Build the first-party companion .deb (Architecture: all)"
env:
RELEASE_VERSION: ${{ github.event.inputs.tag }}
run: packaging/ci/build-companion.sh
EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }}
run: |
if [ "${RELEASE_VERSION#v}" != "$EXPECTED_VERSION" ]; then
echo "::error::packaging/ci/build-companion.sh RELEASE_VERSION ($RELEASE_VERSION) != release tag version ($EXPECTED_VERSION)"
exit 1
fi
packaging/ci/build-companion.sh

# Chroot-stage packaging contract for the companion: install / upgrade / downgrade /
# purge, /etc override precedence, the chroot guard, both /etc-override maintscript
Expand Down Expand Up @@ -376,19 +382,20 @@ jobs:
- name: Install pinned trusted-publishing npm
run: npm install -g npm@11.18.0

# npm side is VERIFIED (not injected): control/package.json version must equal the release
# tag's X.Y.Z, else fail closed before publish.
- name: Verify package version matches the release tag
working-directory: control
# First-party package versions are VERIFIED (not injected): all workspace package manifests
# must equal the release tag's X.Y.Z, else fail closed before publish.
- name: Verify first-party package versions match the release tag
env:
EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }}
run: |
pkg="$(node -p "require('./package.json').version")"
if [ "$pkg" != "$EXPECTED_VERSION" ]; then
echo "::error::control/package.json version ($pkg) != release tag ($EXPECTED_VERSION)"
exit 1
fi
echo "npm version provenance OK: $pkg"
for package_file in package.json control/package.json cli/package.json; do
pkg="$(node -p "require('./${package_file}').version")"
if [ "$pkg" != "$EXPECTED_VERSION" ]; then
echo "::error::${package_file} version ($pkg) != release tag ($EXPECTED_VERSION)"
exit 1
fi
done
echo "first-party version provenance OK: $EXPECTED_VERSION"

- name: Install workspace (frozen lockfile)
run: bun install --frozen-lockfile
Expand Down
252 changes: 249 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

67 changes: 61 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ helpers for portable modem identity, display naming, ModemManager enums, USB-net
classification, capability selection, and shadow-result comparison; these helpers perform
no discovery or transport and leave CeraUI integration to a separate cutover.

Sierra groundwork uses exact, evidence-tiered USB model rows for EM74xx, EM75xx, and
EM919x-class application PIDs across Sierra, HP, and Dell branding. These rows provide a
family label only; interface/driver evidence still decides whether a device is MM-managed,
and unknown Sierra PIDs remain unknown. The FCC classifier table separately mirrors the
complete ModemManager 1.24.2 available-tier mapping and does not install or activate links.
RB-18 in [`docs/BENCH.md`](docs/BENCH.md) records the real identity/composition capture gate;
the 2026-08-25 attempt is a named `device-not-present` skip with no fabricated bundle.

The same table now also carries exact Telit (`1bc7`) and u-blox (`1546`) module rows plus
one NETGEAR (`0846`) row for the LB1120, which is labelled a `router-webui` family — a
positive claim whose absence elsewhere means nothing, and which still decides no device
class. NETGEAR's vendor id is deliberately NOT treated as cellular evidence: its USB ID
Repository block is mostly Wi-Fi and Ethernet adapters, so a vendor-keyed rule there would
report a Wi-Fi dongle as an uplink. No Telit, u-blox, or NETGEAR provider exists.
[`docs/VENDOR-QUIRKS.md`](docs/VENDOR-QUIRKS.md) is the sourced per-vendor edge-case
reading list behind those rows — every claim carries a pinned citation, no claim sits above
`implemented` on the five-state support ladder, and nothing in it is on a write path.

[`docs/COMPAT-MATRIX.md`](docs/COMPAT-MATRIX.md) is the one tracked support matrix built on
those rows: 22 hardware rows against 18 operations, from first enumeration through a
sustained bonded uplink, with a hardware-free versus hardware-required split that says which
claims a green CI run establishes and which ones only a bench device can. Every cell is a
member of the same five-state ladder and there is no second status vocabulary, so no
combination is `certified` and none may be described as supported. Hardware evidence lives
in [`docs/BENCH.md`](docs/BENCH.md); the matrix links to it and restates none of it.

The ModemManager operation surface also exposes runtime USB-composition capability. Known
vendors are queried with exact reviewed READ/TEST forms, targets come from the device's own
enumeration only when it includes a return path, and writes retain the shared admission,
Expand All @@ -38,12 +64,12 @@ data before `ERROR`. Band certification remains catalog-gated and unchanged.

## Versioning at a glance

ONE unified **SemVer** tag `vX.Y.Z` releases **both** artifacts together: `v1.1.0` publishes
`@ceralive/modem-control@1.1.0` to npm **and** the `.deb` artifact set in the same release.
This repo deliberately does **not** use the CeraLive CalVer scheme. New upstream-source
rebuilds use per-source `<upstream>-<rev>~ceralive.N` counters; unchanged sources retain their
previous version and exact bytes. Legacy published releases keep their tag-shaped suffixes.
Full contract:
ONE unified **SemVer** tag `vX.Y.Z` requires the root, control, and CLI `package.json`
versions to all be `X.Y.Z`; it publishes `@ceralive/modem-control@X.Y.Z` to npm **and** the
`.deb` artifact set in the same release. This repo deliberately does **not** use the CeraLive
CalVer scheme. New upstream-source rebuilds use per-source `<upstream>-<rev>~ceralive.N`
counters; unchanged sources retain their previous version and exact bytes. Legacy published
releases keep their tag-shaped suffixes. Full contract:
[`docs/VERSIONING.md`](docs/VERSIONING.md).

## Layout
Expand All @@ -55,6 +81,7 @@ modem-stack/
├── packaging/ ModemManager-stack .deb rebuilds + provenance/verification CI
├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, COMPOSITION-EVIDENCE.md,
│ VERSIONING.md, FM350-DECISION.md, ESIM-DECISION.md
│ └── adr/ ADR-FM350-RNDIS-BEARER.md, ADR-STAY-TYPESCRIPT.md
├── AGENTS.md AI routing + repo contract (self-contained; see Rule D)
└── POLICY.md no-fork gate + upstream-contribution-first policy
```
Expand All @@ -64,6 +91,11 @@ Biome via `@ceralive/biome-config`). `packaging/` is built in a bookworm contain
The two AST-backed source-shape guard tests use the test-only TypeScript 6 compiler-API
compatibility package; workspace typechecking and package emit remain TypeScript 7.

That language choice is recorded, not incidental: a Rust migration was assessed and rejected
by the project owner on 2026-08-24, and the same record carries the MIT-licensed
`irlserver/modem-metrics` idea attribution (concepts adopted, no source code copied). See
[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md).

## Develop

```sh
Expand Down Expand Up @@ -142,6 +174,29 @@ ModemManager's own `StateFailedReason: sim-missing`, never from a blank `Sim` ob
`Modem.CurrentModes` and `Modem.SignalQuality` are retained as their D-Bus structs, so the
preferred mode and the measurement-recency flag survive normalization.

## Registration context + honest counter rates

An observation now also reports **who the modem is registered with and to which cell**.
`operatorName` / `operatorCode` come from `Modem3gpp` — the registered operator — and never
from `Sim.OperatorName`, which is the SIM's *home* operator and differs throughout roaming;
the code stays text because a two- versus three-digit MNC is a different network. An
additive `cell` block reports `cellId` and `tac`, decoded together out of the existing
`3gpp-lac-ci` source's single five-token value, hex preserved as written. That source is
**coarse cell context, not a GNSS fix**: it stays outside `GNSS_SOURCES`, `signal_location`
stays false, and nothing on the path enables a location source. No EARFCN is claimed
anywhere — ModemManager publishes none generically, only a per-cell `earfcn` (LTE) and
`nrarfcn` (5GNR), two keys for two quantities. `CellReading` gained `tac` and now reads
ModemManager's real `ci` key ahead of the older `cell-id` spelling.

The data-usage sampler reports throughput as `rateBytesPerSecond`, and **omits it rather
than reporting 0** whenever there was no interval to measure. A counter that goes BACKWARDS
— an interface re-created by a replug or a driver reload — yields no rate at all instead of
a clamped zero or a whole-total spike, and the baseline is rebased in the same pass so the
next interval is measured correctly. Rates are never persisted: a same-boot reload resumes
the cumulative baseline but restarts the rate unmeasured. Idea provenance for the
counter-reset rule: `irlserver/modem-metrics` (MIT), concepts adopted, no code copied — see
[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md).

## Provider-matching conformance matrix (Todo 27)

`control/src/providers/conformance-matrix.test.ts` registers all four providers at once and
Expand Down
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modem-control-cli",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"type": "module",
"description": "modem-control bench CLI — probe/watch/apply/set-usb-mode/usage/certify/hil-cycle against real modems (the bench iteration surface).",
Expand Down
10 changes: 10 additions & 0 deletions cli/src/bench-at-sender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { AtCommandSender, AtResponse } from '@ceralive/modem-control';
import { CertifyError } from './certify/errors';

export const benchAtSender: AtCommandSender = {
send(command: string): Promise<AtResponse> {
return Promise.reject(
new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`),
);
},
};
11 changes: 1 addition & 10 deletions cli/src/commands/certify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import {
readRevision,
type UsbDeviceSnapshot,
} from '@ceralive/modem-control';
import { benchAtSender } from '../bench-at-sender';
import { buildCertificationBundle } from '../certify/bundle';
import type { SignalRecord } from '../certify/bundle-schema';
import { captureBase } from '../certify/capture';
import { type CommandResult, SpawnCommandRunner } from '../certify/command-runner';
import { CertifyError } from '../certify/errors';
import {
createTransportSignalWindow,
DEFAULT_SIGNAL_WINDOW,
Expand Down Expand Up @@ -59,15 +59,6 @@ export interface CertifyDeps {
writeBundle(path: string, content: string): Promise<void>;
}

/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */
const benchAtSender: AtCommandSender = {
send(command: string) {
return Promise.reject(
new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`),
);
},
};

/** Build the production capture seams from a live stack context. */
export function certifyDepsFromContext(ctx: StackContext, args: CertifyArgs): CertifyDeps {
const bound: SignalWindowBound | undefined =
Expand Down
81 changes: 81 additions & 0 deletions cli/src/commands/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, expect, test } from 'bun:test';
import type { StackContext } from '../context';
import { capturingIo } from '../io';
import { runWatch } from './watch';

type Observer = (list: never) => void;

const context = (
start: () => Promise<void>,
observer: (callback: Observer) => () => void,
): StackContext => ({ backend: { start, observe: observer } }) as unknown as StackContext;

const baseline = (): number => process.listenerCount('SIGINT');

afterEach(() => {
expect(process.listenerCount('SIGINT')).toBe(0);
});

test('start rejection cleans every watch handle', async () => {
const before = baseline();
const failure = new Error('start failed');
const promise = runWatch(
context(
async () => {
throw failure;
},
() => () => undefined,
),
capturingIo(),
{ durationMs: 60_000 },
);

await expect(promise).rejects.toBe(failure);
expect(process.listenerCount('SIGINT')).toBe(before);
});

test('observer callback failure cleans every watch handle', async () => {
const before = baseline();
const failure = new Error('observer failed');
let callback: Observer | undefined;
const promise = runWatch(
context(
async () => {
callback?.(undefined as never);
},
(_next) => {
callback = () => {
throw failure;
};
return () => undefined;
},
),
capturingIo(),
{ durationMs: 60_000 },
);

await expect(promise).rejects.toThrow(failure.message);
expect(process.listenerCount('SIGINT')).toBe(before);
});

test('abort during pending start still removes SIGINT and timer handles', async () => {
const before = baseline();
const controller = new AbortController();
let releaseStart: (() => void) | undefined;
const promise = runWatch(
context(
() =>
new Promise<void>((resolve) => {
releaseStart = resolve;
}),
() => () => undefined,
),
capturingIo(),
{ signal: controller.signal, durationMs: 60_000 },
);

controller.abort();
releaseStart?.();
expect(await promise).toBe(0);
expect(process.listenerCount('SIGINT')).toBe(before);
});
24 changes: 16 additions & 8 deletions cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,22 @@ export async function runWatch(
? setTimeout(() => resolveDone(), options.durationMs)
: undefined;
const onSigint = (): void => resolveDone();
const onAbort = (): void => resolveDone();
let cleaned = false;
const cleanup = (): void => {
if (cleaned) return;
cleaned = true;
if (timer !== undefined) clearTimeout(timer);
process.off('SIGINT', onSigint);
options.signal?.removeEventListener('abort', onAbort);
unsubscribe();
};
process.on('SIGINT', onSigint);
if (options.signal !== undefined) {
if (options.signal.aborted) {
resolveDone();
} else {
options.signal.addEventListener('abort', () => resolveDone(), { once: true });
options.signal.addEventListener('abort', onAbort, { once: true });
}
}
if (
Expand All @@ -98,14 +108,12 @@ export async function runWatch(
io.err('watch: streaming changes — press Ctrl-C to stop');
}

await ctx.backend.start();
await done;

if (timer !== undefined) {
clearTimeout(timer);
try {
await ctx.backend.start();
await done;
} finally {
cleanup();
}
process.off('SIGINT', onSigint);
unsubscribe();
io.out(`WATCH DONE: events=${events}, unavailable=${unavailable}, removed=${removed}`);
return 0;
}
17 changes: 17 additions & 0 deletions cli/src/io.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, test } from 'bun:test';
import { PassThrough } from 'node:stream';
import { stdioIo } from './io';

test('EOF before newline removes the stdin data listener', async () => {
const original = process.stdin;
const stdin = new PassThrough();
Object.defineProperty(process, 'stdin', { configurable: true, value: stdin });
try {
const reading = stdioIo().promptSecret('');
stdin.end('partial');
expect(await reading).toBe('partial');
expect(stdin.listenerCount('data')).toBe(0);
} finally {
Object.defineProperty(process, 'stdin', { configurable: true, value: original });
}
});
5 changes: 4 additions & 1 deletion cli/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ function readLine(stdin: NodeJS.ReadStream): Promise<string> {
}
};
stdin.on('data', onData);
stdin.on('end', () => resolve(buffer.replace(/\r$/, '')));
stdin.on('end', () => {
stdin.off('data', onData);
resolve(buffer.replace(/\r$/, ''));
});
});
}

Expand Down
Loading