Skip to content

Commit a91c492

Browse files
authored
Merge pull request #21 from CERALIVE/modem-stack-quality-compat
Modem quality & compatibility wave (retroactive review PR for the released v1.3.0)
2 parents 9279411 + 26db4f9 commit a91c492

91 files changed

Lines changed: 5301 additions & 925 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,13 @@ jobs:
310310
- name: "Build the first-party companion .deb (Architecture: all)"
311311
env:
312312
RELEASE_VERSION: ${{ github.event.inputs.tag }}
313-
run: packaging/ci/build-companion.sh
313+
EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }}
314+
run: |
315+
if [ "${RELEASE_VERSION#v}" != "$EXPECTED_VERSION" ]; then
316+
echo "::error::packaging/ci/build-companion.sh RELEASE_VERSION ($RELEASE_VERSION) != release tag version ($EXPECTED_VERSION)"
317+
exit 1
318+
fi
319+
packaging/ci/build-companion.sh
314320
315321
# Chroot-stage packaging contract for the companion: install / upgrade / downgrade /
316322
# purge, /etc override precedence, the chroot guard, both /etc-override maintscript
@@ -376,19 +382,20 @@ jobs:
376382
- name: Install pinned trusted-publishing npm
377383
run: npm install -g npm@11.18.0
378384

379-
# npm side is VERIFIED (not injected): control/package.json version must equal the release
380-
# tag's X.Y.Z, else fail closed before publish.
381-
- name: Verify package version matches the release tag
382-
working-directory: control
385+
# First-party package versions are VERIFIED (not injected): all workspace package manifests
386+
# must equal the release tag's X.Y.Z, else fail closed before publish.
387+
- name: Verify first-party package versions match the release tag
383388
env:
384389
EXPECTED_VERSION: ${{ needs.tag-guard.outputs.version }}
385390
run: |
386-
pkg="$(node -p "require('./package.json').version")"
387-
if [ "$pkg" != "$EXPECTED_VERSION" ]; then
388-
echo "::error::control/package.json version ($pkg) != release tag ($EXPECTED_VERSION)"
389-
exit 1
390-
fi
391-
echo "npm version provenance OK: $pkg"
391+
for package_file in package.json control/package.json cli/package.json; do
392+
pkg="$(node -p "require('./${package_file}').version")"
393+
if [ "$pkg" != "$EXPECTED_VERSION" ]; then
394+
echo "::error::${package_file} version ($pkg) != release tag ($EXPECTED_VERSION)"
395+
exit 1
396+
fi
397+
done
398+
echo "first-party version provenance OK: $EXPECTED_VERSION"
392399
393400
- name: Install workspace (frozen lockfile)
394401
run: bun install --frozen-lockfile

AGENTS.md

Lines changed: 249 additions & 3 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,32 @@ helpers for portable modem identity, display naming, ModemManager enums, USB-net
2727
classification, capability selection, and shadow-result comparison; these helpers perform
2828
no discovery or transport and leave CeraUI integration to a separate cutover.
2929

30+
Sierra groundwork uses exact, evidence-tiered USB model rows for EM74xx, EM75xx, and
31+
EM919x-class application PIDs across Sierra, HP, and Dell branding. These rows provide a
32+
family label only; interface/driver evidence still decides whether a device is MM-managed,
33+
and unknown Sierra PIDs remain unknown. The FCC classifier table separately mirrors the
34+
complete ModemManager 1.24.2 available-tier mapping and does not install or activate links.
35+
RB-18 in [`docs/BENCH.md`](docs/BENCH.md) records the real identity/composition capture gate;
36+
the 2026-08-25 attempt is a named `device-not-present` skip with no fabricated bundle.
37+
38+
The same table now also carries exact Telit (`1bc7`) and u-blox (`1546`) module rows plus
39+
one NETGEAR (`0846`) row for the LB1120, which is labelled a `router-webui` family — a
40+
positive claim whose absence elsewhere means nothing, and which still decides no device
41+
class. NETGEAR's vendor id is deliberately NOT treated as cellular evidence: its USB ID
42+
Repository block is mostly Wi-Fi and Ethernet adapters, so a vendor-keyed rule there would
43+
report a Wi-Fi dongle as an uplink. No Telit, u-blox, or NETGEAR provider exists.
44+
[`docs/VENDOR-QUIRKS.md`](docs/VENDOR-QUIRKS.md) is the sourced per-vendor edge-case
45+
reading list behind those rows — every claim carries a pinned citation, no claim sits above
46+
`implemented` on the five-state support ladder, and nothing in it is on a write path.
47+
48+
[`docs/COMPAT-MATRIX.md`](docs/COMPAT-MATRIX.md) is the one tracked support matrix built on
49+
those rows: 22 hardware rows against 18 operations, from first enumeration through a
50+
sustained bonded uplink, with a hardware-free versus hardware-required split that says which
51+
claims a green CI run establishes and which ones only a bench device can. Every cell is a
52+
member of the same five-state ladder and there is no second status vocabulary, so no
53+
combination is `certified` and none may be described as supported. Hardware evidence lives
54+
in [`docs/BENCH.md`](docs/BENCH.md); the matrix links to it and restates none of it.
55+
3056
The ModemManager operation surface also exposes runtime USB-composition capability. Known
3157
vendors are queried with exact reviewed READ/TEST forms, targets come from the device's own
3258
enumeration only when it includes a return path, and writes retain the shared admission,
@@ -38,12 +64,12 @@ data before `ERROR`. Band certification remains catalog-gated and unchanged.
3864

3965
## Versioning at a glance
4066

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

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

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

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

177+
## Registration context + honest counter rates
178+
179+
An observation now also reports **who the modem is registered with and to which cell**.
180+
`operatorName` / `operatorCode` come from `Modem3gpp` — the registered operator — and never
181+
from `Sim.OperatorName`, which is the SIM's *home* operator and differs throughout roaming;
182+
the code stays text because a two- versus three-digit MNC is a different network. An
183+
additive `cell` block reports `cellId` and `tac`, decoded together out of the existing
184+
`3gpp-lac-ci` source's single five-token value, hex preserved as written. That source is
185+
**coarse cell context, not a GNSS fix**: it stays outside `GNSS_SOURCES`, `signal_location`
186+
stays false, and nothing on the path enables a location source. No EARFCN is claimed
187+
anywhere — ModemManager publishes none generically, only a per-cell `earfcn` (LTE) and
188+
`nrarfcn` (5GNR), two keys for two quantities. `CellReading` gained `tac` and now reads
189+
ModemManager's real `ci` key ahead of the older `cell-id` spelling.
190+
191+
The data-usage sampler reports throughput as `rateBytesPerSecond`, and **omits it rather
192+
than reporting 0** whenever there was no interval to measure. A counter that goes BACKWARDS
193+
— an interface re-created by a replug or a driver reload — yields no rate at all instead of
194+
a clamped zero or a whole-total spike, and the baseline is rebased in the same pass so the
195+
next interval is measured correctly. Rates are never persisted: a same-boot reload resumes
196+
the cumulative baseline but restarts the rate unmeasured. Idea provenance for the
197+
counter-reset rule: `irlserver/modem-metrics` (MIT), concepts adopted, no code copied — see
198+
[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md).
199+
145200
## Provider-matching conformance matrix (Todo 27)
146201

147202
`control/src/providers/conformance-matrix.test.ts` registers all four providers at once and

bun.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "modem-control-cli",
3-
"version": "1.0.0",
3+
"version": "1.3.0",
44
"private": true,
55
"type": "module",
66
"description": "modem-control bench CLI — probe/watch/apply/set-usb-mode/usage/certify/hil-cycle against real modems (the bench iteration surface).",

cli/src/bench-at-sender.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { AtCommandSender, AtResponse } from '@ceralive/modem-control';
2+
import { CertifyError } from './certify/errors';
3+
4+
export const benchAtSender: AtCommandSender = {
5+
send(command: string): Promise<AtResponse> {
6+
return Promise.reject(
7+
new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`),
8+
);
9+
},
10+
};

cli/src/commands/certify.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ import {
1818
readRevision,
1919
type UsbDeviceSnapshot,
2020
} from '@ceralive/modem-control';
21+
import { benchAtSender } from '../bench-at-sender';
2122
import { buildCertificationBundle } from '../certify/bundle';
2223
import type { SignalRecord } from '../certify/bundle-schema';
2324
import { captureBase } from '../certify/capture';
2425
import { type CommandResult, SpawnCommandRunner } from '../certify/command-runner';
25-
import { CertifyError } from '../certify/errors';
2626
import {
2727
createTransportSignalWindow,
2828
DEFAULT_SIGNAL_WINDOW,
@@ -59,15 +59,6 @@ export interface CertifyDeps {
5959
writeBundle(path: string, content: string): Promise<void>;
6060
}
6161

62-
/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */
63-
const benchAtSender: AtCommandSender = {
64-
send(command: string) {
65-
return Promise.reject(
66-
new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`),
67-
);
68-
},
69-
};
70-
7162
/** Build the production capture seams from a live stack context. */
7263
export function certifyDepsFromContext(ctx: StackContext, args: CertifyArgs): CertifyDeps {
7364
const bound: SignalWindowBound | undefined =

cli/src/commands/watch.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { afterEach, expect, test } from 'bun:test';
2+
import type { StackContext } from '../context';
3+
import { capturingIo } from '../io';
4+
import { runWatch } from './watch';
5+
6+
type Observer = (list: never) => void;
7+
8+
const context = (
9+
start: () => Promise<void>,
10+
observer: (callback: Observer) => () => void,
11+
): StackContext => ({ backend: { start, observe: observer } }) as unknown as StackContext;
12+
13+
const baseline = (): number => process.listenerCount('SIGINT');
14+
15+
afterEach(() => {
16+
expect(process.listenerCount('SIGINT')).toBe(0);
17+
});
18+
19+
test('start rejection cleans every watch handle', async () => {
20+
const before = baseline();
21+
const failure = new Error('start failed');
22+
const promise = runWatch(
23+
context(
24+
async () => {
25+
throw failure;
26+
},
27+
() => () => undefined,
28+
),
29+
capturingIo(),
30+
{ durationMs: 60_000 },
31+
);
32+
33+
await expect(promise).rejects.toBe(failure);
34+
expect(process.listenerCount('SIGINT')).toBe(before);
35+
});
36+
37+
test('observer callback failure cleans every watch handle', async () => {
38+
const before = baseline();
39+
const failure = new Error('observer failed');
40+
let callback: Observer | undefined;
41+
const promise = runWatch(
42+
context(
43+
async () => {
44+
callback?.(undefined as never);
45+
},
46+
(_next) => {
47+
callback = () => {
48+
throw failure;
49+
};
50+
return () => undefined;
51+
},
52+
),
53+
capturingIo(),
54+
{ durationMs: 60_000 },
55+
);
56+
57+
await expect(promise).rejects.toThrow(failure.message);
58+
expect(process.listenerCount('SIGINT')).toBe(before);
59+
});
60+
61+
test('abort during pending start still removes SIGINT and timer handles', async () => {
62+
const before = baseline();
63+
const controller = new AbortController();
64+
let releaseStart: (() => void) | undefined;
65+
const promise = runWatch(
66+
context(
67+
() =>
68+
new Promise<void>((resolve) => {
69+
releaseStart = resolve;
70+
}),
71+
() => () => undefined,
72+
),
73+
capturingIo(),
74+
{ signal: controller.signal, durationMs: 60_000 },
75+
);
76+
77+
controller.abort();
78+
releaseStart?.();
79+
expect(await promise).toBe(0);
80+
expect(process.listenerCount('SIGINT')).toBe(before);
81+
});

cli/src/commands/watch.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,22 @@ export async function runWatch(
8282
? setTimeout(() => resolveDone(), options.durationMs)
8383
: undefined;
8484
const onSigint = (): void => resolveDone();
85+
const onAbort = (): void => resolveDone();
86+
let cleaned = false;
87+
const cleanup = (): void => {
88+
if (cleaned) return;
89+
cleaned = true;
90+
if (timer !== undefined) clearTimeout(timer);
91+
process.off('SIGINT', onSigint);
92+
options.signal?.removeEventListener('abort', onAbort);
93+
unsubscribe();
94+
};
8595
process.on('SIGINT', onSigint);
8696
if (options.signal !== undefined) {
8797
if (options.signal.aborted) {
8898
resolveDone();
8999
} else {
90-
options.signal.addEventListener('abort', () => resolveDone(), { once: true });
100+
options.signal.addEventListener('abort', onAbort, { once: true });
91101
}
92102
}
93103
if (
@@ -98,14 +108,12 @@ export async function runWatch(
98108
io.err('watch: streaming changes — press Ctrl-C to stop');
99109
}
100110

101-
await ctx.backend.start();
102-
await done;
103-
104-
if (timer !== undefined) {
105-
clearTimeout(timer);
111+
try {
112+
await ctx.backend.start();
113+
await done;
114+
} finally {
115+
cleanup();
106116
}
107-
process.off('SIGINT', onSigint);
108-
unsubscribe();
109117
io.out(`WATCH DONE: events=${events}, unavailable=${unavailable}, removed=${removed}`);
110118
return 0;
111119
}

cli/src/io.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect, test } from 'bun:test';
2+
import { PassThrough } from 'node:stream';
3+
import { stdioIo } from './io';
4+
5+
test('EOF before newline removes the stdin data listener', async () => {
6+
const original = process.stdin;
7+
const stdin = new PassThrough();
8+
Object.defineProperty(process, 'stdin', { configurable: true, value: stdin });
9+
try {
10+
const reading = stdioIo().promptSecret('');
11+
stdin.end('partial');
12+
expect(await reading).toBe('partial');
13+
expect(stdin.listenerCount('data')).toBe(0);
14+
} finally {
15+
Object.defineProperty(process, 'stdin', { configurable: true, value: original });
16+
}
17+
});

0 commit comments

Comments
 (0)