From 2ba9763b1b2a3b9c669f30d6723f14091a2bb0f8 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 15:01:00 -0500 Subject: [PATCH 01/19] fix(cli): deterministic watch/io teardown on failure and EOF --- cli/src/commands/watch.test.ts | 81 ++++++++++++++++++++++++++++++++++ cli/src/commands/watch.ts | 24 ++++++---- cli/src/io.test.ts | 17 +++++++ cli/src/io.ts | 5 ++- 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 cli/src/commands/watch.test.ts create mode 100644 cli/src/io.test.ts diff --git a/cli/src/commands/watch.test.ts b/cli/src/commands/watch.test.ts new file mode 100644 index 0000000..9f9a0cb --- /dev/null +++ b/cli/src/commands/watch.test.ts @@ -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, + 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((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); +}); diff --git a/cli/src/commands/watch.ts b/cli/src/commands/watch.ts index 3bc34a4..ec4438f 100644 --- a/cli/src/commands/watch.ts +++ b/cli/src/commands/watch.ts @@ -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 ( @@ -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; } diff --git a/cli/src/io.test.ts b/cli/src/io.test.ts new file mode 100644 index 0000000..7c64472 --- /dev/null +++ b/cli/src/io.test.ts @@ -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 }); + } +}); diff --git a/cli/src/io.ts b/cli/src/io.ts index 2ab78b1..519900d 100644 --- a/cli/src/io.ts +++ b/cli/src/io.ts @@ -79,7 +79,10 @@ function readLine(stdin: NodeJS.ReadStream): Promise { } }; stdin.on('data', onData); - stdin.on('end', () => resolve(buffer.replace(/\r$/, ''))); + stdin.on('end', () => { + stdin.off('data', onData); + resolve(buffer.replace(/\r$/, '')); + }); }); } From f9205bd2eaab8057ea89b8328678fe1f35067cd1 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 15:02:20 -0500 Subject: [PATCH 02/19] fix(backend): injectable router-ethernet probe + timeouts + classified failures --- control/src/backend/router-ethernet.test.ts | 47 +++++++- control/src/backend/router-ethernet.ts | 114 ++++++++++++++++---- 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/control/src/backend/router-ethernet.test.ts b/control/src/backend/router-ethernet.test.ts index 982bcfb..840dc6f 100644 --- a/control/src/backend/router-ethernet.test.ts +++ b/control/src/backend/router-ethernet.test.ts @@ -4,7 +4,11 @@ import { describe, expect, test } from 'bun:test'; import { deviceIfname } from '../ports'; -import { createRouterEthernetProbe, type RouterEthernetProbeDeps } from './router-ethernet'; +import { + classifyRouterProbeFailure, + createRouterEthernetProbe, + type RouterEthernetProbeDeps, +} from './router-ethernet'; const IFNAME = deviceIfname('eth1'); @@ -68,4 +72,45 @@ describe('createRouterEthernetProbe — advisory health', () => { expect(health.gatewayReachable).toBe(false); expect(health.egressHealthy).toBe(false); }); + + test('uses the injected egress host', async () => { + const hosts: string[] = []; + const probe = probeWith({ + probeHost: '198.51.100.7', + checkLinkUp: () => Promise.resolve(true), + resolveGateway: () => Promise.resolve('192.168.8.1'), + ping: (host) => { + hosts.push(host); + return Promise.resolve(true); + }, + }); + await probe.checkHealth(IFNAME); + expect(hosts).toEqual(['192.168.8.1', '198.51.100.7']); + }); + + test('classifies command missing, timeout, nonzero exit, and unreachable failures', () => { + expect( + classifyRouterProbeFailure(Object.assign(new Error('missing'), { code: 'ENOENT' })), + ).toBe('command-missing'); + expect( + classifyRouterProbeFailure(Object.assign(new Error('hung'), { name: 'TimeoutError' })), + ).toBe('timeout'); + expect(classifyRouterProbeFailure(new Error('exit 1'))).toBe('nonzero-exit'); + expect( + classifyRouterProbeFailure( + Object.assign(new Error('unreachable'), { name: 'UnreachableError' }), + ), + ).toBe('unreachable'); + }); + + test('a fake hung subprocess is bounded by the configured timeout', async () => { + const started = performance.now(); + const probe = probeWith({ + subprocessTimeoutMs: 5, + checkLinkUp: () => new Promise(() => undefined), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.presence).toBe('absent'); + expect(performance.now() - started).toBeLessThan(1000); + }); }); diff --git a/control/src/backend/router-ethernet.ts b/control/src/backend/router-ethernet.ts index 3174001..8851695 100644 --- a/control/src/backend/router-ethernet.ts +++ b/control/src/backend/router-ethernet.ts @@ -20,43 +20,98 @@ export interface RouterEthernetProbeDeps { readonly resolveGateway?: (ifname: DeviceIfname) => Promise; /** Whether `host` is reachable via `ifname` (ICMP). */ readonly ping?: (host: string, ifname: DeviceIfname) => Promise; + /** Override the public DNS target used by the egress probe. */ + readonly probeHost?: string; + /** Bound applied to every default subprocess, including `ip`. */ + readonly subprocessTimeoutMs?: number; +} + +export const ROUTER_PROBE_FAILURES = [ + 'command-missing', + 'timeout', + 'nonzero-exit', + 'unreachable', +] as const; +export type RouterProbeFailureKind = (typeof ROUTER_PROBE_FAILURES)[number]; + +export function classifyRouterProbeFailure(error: unknown): RouterProbeFailureKind { + if (error instanceof Error && error.name === 'TimeoutError') return 'timeout'; + if (error instanceof Error && error.name === 'UnreachableError') return 'unreachable'; + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ENOENT' + ) { + return 'command-missing'; + } + return 'nonzero-exit'; } /** The host used for the basic egress-health probe (public DNS anycast). */ const EGRESS_PROBE_HOST = '1.1.1.1'; +const DEFAULT_SUBPROCESS_TIMEOUT_MS = 5_000; + +function bounded(operation: Promise, timeoutMs: number): Promise { + return Promise.race([ + operation, + new Promise((_, reject) => + setTimeout( + () => reject(Object.assign(new Error('probe timed out'), { name: 'TimeoutError' })), + timeoutMs, + ), + ), + ]); +} -async function spawnSucceeds(command: readonly string[]): Promise { +async function spawnSucceeds(command: readonly string[], timeoutMs: number): Promise { try { const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); - return (await proc.exited) === 0; + return ( + (await Promise.race([ + proc.exited, + new Promise((resolve) => setTimeout(() => resolve(124), timeoutMs)), + ])) === 0 + ); } catch { return false; } } -async function spawnOutput(command: readonly string[]): Promise { +async function spawnOutput(command: readonly string[], timeoutMs: number): Promise { try { const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); - const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const [out, code] = await Promise.race([ + Promise.all([new Response(proc.stdout).text(), proc.exited]), + new Promise((resolve) => + setTimeout(() => resolve(['', 124]), timeoutMs), + ), + ]); return code === 0 ? out : ''; } catch { return ''; } } -function defaultCheckLinkUp(ifname: DeviceIfname): Promise { - return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)]); +function defaultCheckLinkUp(ifname: DeviceIfname, timeoutMs: number): Promise { + return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)], timeoutMs); } -async function defaultResolveGateway(ifname: DeviceIfname): Promise { +async function defaultResolveGateway( + ifname: DeviceIfname, + timeoutMs: number, +): Promise { // `ip -o route show default dev ` → "default via 192.168.8.1 dev …". - const out = await spawnOutput(['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)]); + const out = await spawnOutput( + ['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)], + timeoutMs, + ); const match = out.match(/default via (\S+)/); return match?.[1]; } -function defaultPing(host: string, ifname: DeviceIfname): Promise { - return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host]); +function defaultPing(host: string, ifname: DeviceIfname, timeoutMs: number): Promise { + return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host], timeoutMs); } /** @@ -67,23 +122,46 @@ function defaultPing(host: string, ifname: DeviceIfname): Promise { */ export function createRouterEthernetProbe(deps: RouterEthernetProbeDeps = {}): RouterPort { const now = deps.now ?? ((): EpochMillis => epochMillis(Date.now())); - const checkLinkUp = deps.checkLinkUp ?? defaultCheckLinkUp; - const resolveGateway = deps.resolveGateway ?? defaultResolveGateway; - const ping = deps.ping ?? defaultPing; + const timeoutMs = deps.subprocessTimeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS; + const checkLinkUp = + deps.checkLinkUp ?? ((ifname: DeviceIfname) => defaultCheckLinkUp(ifname, timeoutMs)); + const resolveGateway = + deps.resolveGateway ?? ((ifname: DeviceIfname) => defaultResolveGateway(ifname, timeoutMs)); + const ping = + deps.ping ?? ((host: string, ifname: DeviceIfname) => defaultPing(host, ifname, timeoutMs)); + const probeHost = deps.probeHost ?? EGRESS_PROBE_HOST; async function probePresence(ifname: DeviceIfname): Promise { - return (await checkLinkUp(ifname).catch(() => false)) ? 'present' : 'absent'; + return (await bounded(checkLinkUp(ifname), timeoutMs).catch(() => false)) + ? 'present' + : 'absent'; } async function checkHealth(ifname: DeviceIfname): Promise { const presence = await probePresence(ifname); + const failureKinds: RouterProbeFailureKind[] = []; const gateway = - presence === 'present' ? await resolveGateway(ifname).catch(() => undefined) : undefined; + presence === 'present' + ? await bounded(resolveGateway(ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return undefined; + }) + : undefined; const gatewayReachable = - gateway !== undefined && (await ping(gateway, ifname).catch(() => false)); + gateway !== undefined && + (await bounded(ping(gateway, ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return false; + })); const egressHealthy = - gatewayReachable && (await ping(EGRESS_PROBE_HOST, ifname).catch(() => false)); - return { presence, gatewayReachable, egressHealthy, observedAt: now() }; + gatewayReachable && + (await bounded(ping(probeHost, ifname), timeoutMs).catch((error: unknown) => { + failureKinds.push(classifyRouterProbeFailure(error)); + return false; + })); + const health = { presence, gatewayReachable, egressHealthy, observedAt: now() }; + Object.defineProperty(health, 'failureKinds', { value: failureKinds, enumerable: true }); + return health; } return { probePresence, checkHealth }; From 29a66415cc10f99596e845ba56302eb4f53c92fd Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 15:05:21 -0500 Subject: [PATCH 03/19] build: first-party version unity (three package.json + companion changelog == tag) --- .github/workflows/release.yml | 29 ++++++++++++-------- README.md | 12 ++++----- bun.lock | 8 +++--- cli/package.json | 2 +- control/src/version-unity.test.ts | 44 +++++++++++++++++++++++++++++++ docs/VERSIONING.md | 11 +++++--- package.json | 2 +- 7 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 control/src/version-unity.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 290c9f5..6302582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index 8d3b39b..9705408 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,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 `-~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 `-~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 diff --git a/bun.lock b/bun.lock index 073336d..8084860 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -14,7 +14,7 @@ }, "cli": { "name": "modem-control-cli", - "version": "1.0.0", + "version": "1.2.1", "bin": { "modem-control": "./src/index.ts", }, @@ -25,7 +25,7 @@ }, "control": { "name": "@ceralive/modem-control", - "version": "1.1.0", + "version": "1.2.1", "dependencies": { "@httptoolkit/dbus-native": "0.1.5", "zod": "4.4.3", @@ -61,7 +61,7 @@ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], "@typescript/old": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/cli/package.json b/cli/package.json index 22369d4..3bd5742 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "modem-control-cli", - "version": "1.0.0", + "version": "1.2.1", "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).", diff --git a/control/src/version-unity.test.ts b/control/src/version-unity.test.ts new file mode 100644 index 0000000..a228e38 --- /dev/null +++ b/control/src/version-unity.test.ts @@ -0,0 +1,44 @@ +import { test } from "bun:test"; + +const FIRST_PARTY_PACKAGE_PATHS = [ + "package.json", + "control/package.json", + "cli/package.json", +] as const; + +type PackageVersion = { + readonly path: string; + readonly version: string; +}; + +async function readPackageVersion(path: string): Promise { + const manifest: unknown = await Bun.file(path).json(); + if (typeof manifest !== "object" || manifest === null) { + throw new Error(`${path} must contain a JSON object`); + } + + const version = Reflect.get(manifest, "version"); + if (typeof version !== "string") { + throw new Error(`${path} must contain a string version`); + } + + return { path, version }; +} + +test("first-party workspace package versions stay unified", async () => { + const packageVersions = await Promise.all( + FIRST_PARTY_PACKAGE_PATHS.map(readPackageVersion), + ); + const root = packageVersions[0]; + if (root === undefined) { + throw new Error("first-party package version list is unexpectedly empty"); + } + + for (const packageVersion of packageVersions.slice(1)) { + if (packageVersion.version !== root.version) { + throw new Error( + `${packageVersion.path} version ${packageVersion.version} does not match ${root.path} version ${root.version}`, + ); + } + } +}); diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 1c4cf2e..8a99bf6 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -192,7 +192,10 @@ i.e. `-~ceralive0.0.0~dev`, which sorts below every real release ## npm version provenance Following the house OIDC pattern, the npm publish job is **not** version-injected: it -verifies that `control/package.json` `version` **equals** the tag's `X.Y.Z` and fails -closed on any mismatch before publishing. To cut a release, bump `control/package.json` -`version`, commit, then create the matching `vX.Y.Z` tag. The `.deb` side is injected -(above); the npm side is verified — both are driven by the same tag. +verifies that `package.json`, `control/package.json`, and `cli/package.json` all have a +`version` **equal** to the tag's `X.Y.Z`, naming the mismatched file and failing closed +before publishing. The companion build receives that same tag through `RELEASE_VERSION`, +which `build-companion.sh` converts to the bare companion version and validates from the +produced `.deb`. To cut a release, bump all three `package.json` versions, commit, then +create the matching `vX.Y.Z` tag. The four upstream `.deb` sources retain their independent +per-source counter versions; only first-party artifacts obey this tag-version unity rule. diff --git a/package.json b/package.json index 1ac0d8e..bb9baec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modem-stack", - "version": "1.1.0", + "version": "1.2.1", "private": true, "type": "module", "description": "Cellular modem control for CeraLive — @ceralive/modem-control library, bench CLI, and ModemManager-stack .deb packaging (Phase A, standalone).", From 6f1095ae1e5fbf9d2be4792a6d8b1886b2f77edb Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 15:09:26 -0500 Subject: [PATCH 04/19] fix(safety): reap flock helper on failure; bound IO; startup deadline --- .../src/safety/flock-resource-ownership.ts | 95 ++++++++++++--- .../resource-ownership.integration.test.ts | 111 +++++++++++++++++- 2 files changed, 190 insertions(+), 16 deletions(-) diff --git a/control/src/safety/flock-resource-ownership.ts b/control/src/safety/flock-resource-ownership.ts index aafcb4e..9c7e34a 100644 --- a/control/src/safety/flock-resource-ownership.ts +++ b/control/src/safety/flock-resource-ownership.ts @@ -24,6 +24,9 @@ type ChildStart = | { readonly status: 'acquired'; readonly holder: ResourceOwnershipHolder } | { readonly status: 'closed'; readonly code: number | null; readonly stderr: string }; +const STARTUP_DEADLINE_MS = 1_000; +const STARTUP_OUTPUT_LIMIT_BYTES = 64 * 1024; + export function createFlockResourceOwnershipPort( options: FlockResourceOwnershipOptions, ): ResourceOwnershipPort { @@ -34,12 +37,19 @@ export function createFlockResourceOwnershipPort( ['--exclusive', '--nonblock', '--no-fork', options.lockPath, '/bin/cat'], { stdio: ['pipe', 'pipe', 'pipe'] }, ); + const closed = childClosed(child); if (child.pid === undefined) { + await reapChild(child, closed); throw new FlockResourceOwnershipError('helper started without a process id'); } const holder = { pid: child.pid, startedAtEpochMs: Date.now() }; - const closed = childClosed(child); - const started = await childStarted(child, options.lockPath, holder); + let started: ChildStart; + try { + started = await childStarted(child, options.lockPath, holder); + } catch (error) { + await reapChild(child, closed); + throw error; + } if (started.status === 'closed') { if (started.code === 1) { const holder = await readHolder(options.lockPath); @@ -86,6 +96,16 @@ function childClosed(child: ChildProcessWithoutNullStreams): Promise { return new Promise((resolve) => child.once('close', () => resolve())); } +async function reapChild( + child: ChildProcessWithoutNullStreams, + closed: Promise, +): Promise { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + await closed; +} + function childStarted( child: ChildProcessWithoutNullStreams, lockPath: string, @@ -94,32 +114,77 @@ function childStarted( return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let acknowledged = false; let settled = false; + const startupDeadline = setTimeout( + () => fail(new FlockResourceOwnershipError('helper startup deadline exceeded')), + STARTUP_DEADLINE_MS, + ); + const cleanup = (): void => { + clearTimeout(startupDeadline); + child.stdout.off('data', onStdout); + child.stderr.off('data', onStderr); + child.off('close', onClose); + child.off('error', fail); + child.stdin.off('error', fail); + }; const finish = (result: ChildStart): void => { if (settled) return; settled = true; + cleanup(); resolve(result); }; - child.stderr.on('data', (chunk: Buffer) => { + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onStderr = (chunk: Buffer): void => { + stderrBytes += chunk.length; + if (stderrBytes > STARTUP_OUTPUT_LIMIT_BYTES) { + fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stderr')); + return; + } stderr += chunk.toString('utf8'); - }); - child.stdout.on('data', (chunk: Buffer) => { + }; + const onStdout = (chunk: Buffer): void => { + stdoutBytes += chunk.length; + if (stdoutBytes > STARTUP_OUTPUT_LIMIT_BYTES) { + fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stdout')); + return; + } stdout += chunk.toString('utf8'); const newline = stdout.indexOf('\n'); if (newline < 0) return; const acquired = parseAcquiredLine(stdout.slice(0, newline)); - if (acquired === undefined || settled) return; - settled = true; + if (acquired === undefined || acknowledged) return; + acknowledged = true; void writeFile(lockPath, `${JSON.stringify(holder)}\n`, { mode: 0o600 }).then( - () => resolve({ status: 'acquired', holder }), - (error: unknown) => { - child.stdin.end(); - reject(error); - }, + () => finish({ status: 'acquired', holder }), + fail, ); - }); - child.once('close', (code) => finish({ status: 'closed', code, stderr: stderr.trim() })); - child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`); + }; + const onClose = (code: number | null): void => + finish({ status: 'closed', code, stderr: stderr.trim() }); + child.stderr.on('data', onStderr); + child.stdout.on('data', onStdout); + child.once('close', onClose); + child.once('error', fail); + child.stdin.once('error', fail); + try { + child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`, (error) => { + if (error !== null) fail(error); + }); + } catch (error) { + if (error instanceof Error) { + fail(error); + return; + } + fail(new FlockResourceOwnershipError('helper stdin failed with a non-error value')); + } }); } diff --git a/control/src/safety/resource-ownership.integration.test.ts b/control/src/safety/resource-ownership.integration.test.ts index 5c531f3..4b4533c 100644 --- a/control/src/safety/resource-ownership.integration.test.ts +++ b/control/src/safety/resource-ownership.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createFlockResourceOwnershipPort } from './flock-resource-ownership'; @@ -32,6 +32,55 @@ async function lockPath(): Promise { return join(directory, 'ownership.lock'); } +async function helperPid(pidPath: string): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + return Number.parseInt((await readFile(pidPath, 'utf8')).trim(), 10); + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('helper did not record its process id'); +} + +async function expectHelperReaped(pid: number): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`helper ${pid} was not reaped`); +} + +async function writeFlockHelper( + directory: string, + name: string, + body: string, +): Promise<{ readonly flockBinary: string; readonly pidPath: string }> { + const flockBinary = join(directory, name); + const pidPath = join(directory, `${name}.pid`); + await writeFile(flockBinary, `#!/bin/sh\necho $$ > ${pidPath}\n${body}\n`, { mode: 0o755 }); + await chmod(flockBinary, 0o755); + return { flockBinary, pidPath }; +} + +function acquisitionFailure(acquisition: Promise): Promise { + return acquisition.then( + () => new Error('expected acquisition to reject'), + (error: unknown) => + error instanceof Error ? error : new Error('acquisition rejected with a non-error value'), + ); +} + function spawnRoot(path: string): ChildProcessWithoutNullStreams { const child = spawn(process.execPath, ['control/test-support/ownership-root-fixture.ts', path], { cwd: process.cwd(), @@ -112,4 +161,64 @@ describe('flock resource ownership across composition roots', () => { const successor = spawnRoot(path); expect(await nextMessage(successor)).toMatchObject({ type: 'acquired' }); }); + + test('Given a holder-file write failure, When the helper stays alive, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-write-failure-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'write-failure-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nIFS= read -r line\nprintf \'%s\\n\' "$line"\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: directory, + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('EISDIR'); + await expectHelperReaped(pid); + }); + + test('Given a helper that never acknowledges the pipe round-trip, When startup reaches its deadline, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-hang-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'hanging-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: join(directory, 'ownership.lock'), + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('startup deadline'); + await expectHelperReaped(pid); + }); + + test('Given a helper that floods startup stdout, When output exceeds 64 KiB, Then acquisition rejects and reaps the helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-flock-flood-')); + tempPaths.push(directory); + const { flockBinary, pidPath } = await writeFlockHelper( + directory, + 'flooding-flock', + 'fifo="$0.fifo"\nmkfifo "$fifo"\nhead -c 65537 /dev/zero | tr \'\\000\' x\nexec /bin/cat "$fifo"', + ); + + const acquisition = createFlockResourceOwnershipPort({ + lockPath: join(directory, 'ownership.lock'), + flockBinary, + }).acquire({ resource: 'usb-hub' }); + const failed = acquisitionFailure(acquisition); + const pid = await helperPid(pidPath); + + expect((await failed).message).toContain('startup output limit'); + await expectHelperReaped(pid); + }); }); From 807250d82f01be3c070edaaedf8383b2a2439f01 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:06:53 -0500 Subject: [PATCH 05/19] test: directory-scoped safety-gate scan roots with count floors --- control/src/journal/journal-path-injection.test.ts | 9 +++++---- .../modem-manager/forbidden-subprocess.test.ts | 3 ++- .../network-manager/network-manager-adapter.test.ts | 12 ++++++++---- .../src/providers/ufi-himi/credential-fence.test.ts | 2 ++ .../src/providers/zte-goform/secret-fence.test.ts | 2 ++ control/src/sms/readonly-gate.test.ts | 8 ++++---- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/control/src/journal/journal-path-injection.test.ts b/control/src/journal/journal-path-injection.test.ts index 8604cb4..c19e611 100644 --- a/control/src/journal/journal-path-injection.test.ts +++ b/control/src/journal/journal-path-injection.test.ts @@ -12,7 +12,7 @@ // count-literal gate and CeraUI's link-id authority gate.) import { describe, expect, test } from 'bun:test'; -import { readdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -40,8 +40,8 @@ const ABSOLUTE_PATH_LITERAL = /(['"`])\/[A-Za-z][\w./${}-]*\1/g; const CERAUI_PATH_TOKENS = /\/data\b|ceralive\/modem-mutations|CERALIVE_MODEM_MUTATION_DIR/; async function shippedSources(): Promise { - const names = (await readdir(JOURNAL_DIR)).filter( - (name) => name.endsWith('.ts') && !name.endsWith('.test.ts'), + const names = (await Array.fromAsync(new Bun.Glob('**/*.ts').scan({ cwd: JOURNAL_DIR, onlyFiles: true }))).filter( + (name) => !name.endsWith('.test.ts'), ); return Promise.all( names.map(async (name) => [name, await readFile(join(JOURNAL_DIR, name), 'utf8')] as const), @@ -51,7 +51,8 @@ async function shippedSources(): Promise describe('no journal source hardcodes a path', () => { test('the gate actually has files to scan', async () => { const sources = await shippedSources(); - expect(sources.length).toBeGreaterThanOrEqual(5); + // Scanned 7 files before directory-glob widening. + expect(sources.length).toBeGreaterThanOrEqual(7); expect(sources.map(([name]) => name)).toContain('store.ts'); expect(sources.map(([name]) => name)).toContain('legacy-ceraui.ts'); }); diff --git a/control/src/providers/modem-manager/forbidden-subprocess.test.ts b/control/src/providers/modem-manager/forbidden-subprocess.test.ts index 6a2c371..8841393 100644 --- a/control/src/providers/modem-manager/forbidden-subprocess.test.ts +++ b/control/src/providers/modem-manager/forbidden-subprocess.test.ts @@ -27,7 +27,8 @@ describe('ModemManagerProvider subprocess fence', () => { test('no provider production source shells out to a modem diagnostic CLI', () => { const files = productionSources(import.meta.dir); - expect(files.length).toBeGreaterThan(0); + // Scanned 8 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(8); const violations = files.filter((path) => FORBIDDEN.test(readFileSync(path, 'utf8'))); expect(violations).toEqual([]); }); diff --git a/control/src/providers/network-manager/network-manager-adapter.test.ts b/control/src/providers/network-manager/network-manager-adapter.test.ts index fd8f38b..827fbbd 100644 --- a/control/src/providers/network-manager/network-manager-adapter.test.ts +++ b/control/src/providers/network-manager/network-manager-adapter.test.ts @@ -632,12 +632,16 @@ describe('NetworkManagerAdapter — scope boundary', () => { 'physicalModemId', ]; - const sources = ['adapter.ts', 'types.ts', 'index.ts'].map((name) => ({ - name, - code: stripComments(readFileSync(new URL(name, import.meta.url), 'utf8')), - })); + const sources = [...new Bun.Glob('**/*.ts').scanSync({ cwd: import.meta.dir, onlyFiles: true })] + .filter((name) => !name.endsWith('.test.ts')) + .map((name) => ({ + name, + code: stripComments(readFileSync(new URL(name, import.meta.url), 'utf8')), + })); test('the comment strip is non-vacuous in both directions', () => { + // Scanned 3 files before directory-glob widening. + expect(sources.length).toBeGreaterThanOrEqual(3); expect( stripComments('const a = 1; // setRadioModes\n/* sendPin */\nconst b = 2;'), ).not.toContain('setRadioModes'); diff --git a/control/src/providers/ufi-himi/credential-fence.test.ts b/control/src/providers/ufi-himi/credential-fence.test.ts index fe675af..48c3116 100644 --- a/control/src/providers/ufi-himi/credential-fence.test.ts +++ b/control/src/providers/ufi-himi/credential-fence.test.ts @@ -33,6 +33,8 @@ describe('UFI bench credential fence', () => { ]); expect(exitCode).toBe(0); const files = names.split('\0').filter((name) => name.length > 0); + // Scanned 605 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(605); const leaks: string[] = []; for (const file of files) { const content = await Bun.file(file).text(); diff --git a/control/src/providers/zte-goform/secret-fence.test.ts b/control/src/providers/zte-goform/secret-fence.test.ts index 3643797..4b4df19 100644 --- a/control/src/providers/zte-goform/secret-fence.test.ts +++ b/control/src/providers/zte-goform/secret-fence.test.ts @@ -26,6 +26,8 @@ describe('MF79U credential fence', () => { ]); expect(exitCode).toBe(0); const files = names.split('\0').filter((name) => name.length > 0); + // Scanned 605 files before adding this count floor. + expect(files.length).toBeGreaterThanOrEqual(605); const leaks: string[] = []; for (const file of files) { const content = await Bun.file(file).text(); diff --git a/control/src/sms/readonly-gate.test.ts b/control/src/sms/readonly-gate.test.ts index fdd78a0..30e6f53 100644 --- a/control/src/sms/readonly-gate.test.ts +++ b/control/src/sms/readonly-gate.test.ts @@ -15,7 +15,6 @@ // interlock design. import { describe, expect, test } from 'bun:test'; -import { readdirSync } from 'node:fs'; import { join } from 'node:path'; const SMS_DIR = import.meta.dir; @@ -66,8 +65,8 @@ function stripComments(source: string): string { } function smsSourceFiles(): string[] { - const files = readdirSync(SMS_DIR) - .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts') && name !== SELF) + const files = [...new Bun.Glob('**/*.ts').scanSync({ cwd: SMS_DIR, onlyFiles: true })] + .filter((name) => !name.endsWith('.test.ts') && name !== SELF) .map((name) => join(SMS_DIR, name)); return [...files, PORT_FILE]; } @@ -81,7 +80,8 @@ describe('the SMS surface is read-only, and stays that way', () => { test('scans the whole SMS surface, port included', () => { // Guards the gate itself: a moved directory would otherwise make this // suite pass vacuously by scanning nothing. - expect(CODE.size).toBeGreaterThanOrEqual(5); + // Scanned 6 files before directory-glob widening. + expect(CODE.size).toBeGreaterThanOrEqual(6); for (const expected of [ 'ports/sms.ts', 'sms/normalize.ts', From 3173ea9d6d990a5365d6ea118432a5d4a81a7c7b Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:14:09 -0500 Subject: [PATCH 06/19] fix(transport): cancellable backoff + observable teardown + unified timing policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconnect backoff was a bare setTimeout promise, so a disconnect() landing mid-backoff left the loop parked for the whole remaining delay — 2s at the default ceiling, unbounded for a caller that raised it. It is now cancellable: disconnect() cuts the sleep short and awaits the loop, so no reconnect loop outlives a close. The reconnect loop is retained rather than fired and forgotten. The single-loop property no longer rests solely on the #handleDrop state guard, and disconnect() has a handle to wait on. Two teardown failures were swallowed outright — a rejecting bus.disconnect() and a throwing connection.end() — so a socket a consumer believed closed could fail to close with no signal anywhere. Both now emit a typed TransportTeardownFailure (phase + step + cause) on the existing 'error' event. It is never thrown, disconnect() never rejects because of it, and emission is guarded against Node's unobserved-'error' re-throw so a report about a failed teardown cannot become the crash it describes. The 2s connect bound and the 30s call bound are now one named TransportTimingPolicy, injectable via options.timing. Both default values are unchanged, and the call bound is sourced from calls.ts's own constant so the policy cannot drift from it. Also fences the case where disconnect() lands while an establish attempt is in flight: the attempt now fails into the shared teardown path instead of publishing a live bus onto a closed transport. Wire-level reconnect semantics and happy-path timing are unchanged. New coverage in lifecycle.test.ts drives an injected fake bus, so the failure paths a real dbus-daemon cannot be made to take on demand are deterministic and need no session bus. --- control/src/transport/lifecycle.test.ts | 403 ++++++++++++++++++++++++ control/src/transport/transport.ts | 202 ++++++++++-- 2 files changed, 587 insertions(+), 18 deletions(-) create mode 100644 control/src/transport/lifecycle.test.ts diff --git a/control/src/transport/lifecycle.test.ts b/control/src/transport/lifecycle.test.ts new file mode 100644 index 0000000..99d88a4 --- /dev/null +++ b/control/src/transport/lifecycle.test.ts @@ -0,0 +1,403 @@ +// Lifecycle tests for the transport seam: cancellable reconnect backoff, observable teardown +// failures, and the unified timing policy. +// +// Unlike reliability.test.ts and characterization.test.ts, these drive an INJECTED fake bus +// rather than a private `dbus-daemon`. The paths under test are exactly the ones a real daemon +// cannot be made to take on demand — a `bus.disconnect()` that rejects, a `connection.end()` +// that throws, a handshake that never completes — and a timing assertion measured against a +// live socket would be a flake generator. Nothing here needs `dbus-run-session`, so these run +// on every machine rather than skipping where the daemon is absent. + +import { expect, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; +import type { + CreateClientOptions, + RawBus, + RawConnection, + RawMessage, + ReplyCallback, +} from './dbus-native'; +import type { BusFactory } from './transport'; +import { + createDbusTransportForTest, + DEFAULT_TRANSPORT_TIMING, + TransportTeardownFailure, +} from './transport'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(5); + } + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); +} + +// `stall` never resolves the handshake either way, which is the only way to reach the connect +// timeout without waiting on a real socket. +type HandshakeOutcome = 'succeed' | 'fail' | 'stall'; + +interface FakeBusBehaviour { + readonly handshake: HandshakeOutcome; + readonly endThrows?: boolean; + readonly disconnectRejects?: boolean; +} + +// A real EventEmitter underneath, so `listenerCount` is a measurement rather than a fixture — +// that is what makes the leak assertions mean anything. +class FakeConnection implements RawConnection { + readonly #emitter = new EventEmitter(); + readonly #endThrows: boolean; + endCalls = 0; + + constructor(endThrows: boolean) { + this.#endThrows = endThrows; + this.#emitter.setMaxListeners(0); + } + + on(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.on(event, handler); + } + + once(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.once(event, handler); + } + + removeListener(event: string, handler: (...args: unknown[]) => void): void { + this.#emitter.removeListener(event, handler); + } + + removeAllListeners(event?: string): void { + this.#emitter.removeAllListeners(event); + } + + listenerCount(event: string): number { + return this.#emitter.listenerCount(event); + } + + end(): void { + this.endCalls += 1; + if (this.#endThrows) { + throw new Error('fake connection.end() failed'); + } + } + + emit(event: string, ...args: unknown[]): void { + this.#emitter.emit(event, ...args); + } +} + +class FakeBus implements RawBus { + readonly connection: FakeConnection; + readonly matches: string[] = []; + readonly #behaviour: FakeBusBehaviour; + disconnectCalls = 0; + invokes = 0; + + constructor(behaviour: FakeBusBehaviour) { + this.#behaviour = behaviour; + this.connection = new FakeConnection(behaviour.endThrows ?? false); + } + + // The transport attaches its handshake listeners synchronously in a Promise executor that + // runs after the factory returns, so the outcome must be deferred by at least a macrotask. + arm(): void { + setTimeout(() => { + if (this.#behaviour.handshake === 'succeed') { + this.connection.emit('connect'); + } else if (this.#behaviour.handshake === 'fail') { + this.connection.emit('error', new Error('fake handshake refused')); + } + }, 0); + } + + // A call that never gets a reply — the per-call timeout is what the policy test measures. + invoke(_message: RawMessage, _callback: ReplyCallback): void { + this.invokes += 1; + } + + async addMatch(rule: string): Promise { + this.matches.push(rule); + return undefined; + } + + async removeMatch(_rule: string): Promise { + return undefined; + } + + async disconnect(): Promise { + this.disconnectCalls += 1; + if (this.#behaviour.disconnectRejects === true) { + throw new Error('fake bus.disconnect() failed'); + } + } +} + +interface ScriptedBuses { + readonly factory: BusFactory; + readonly buses: FakeBus[]; +} + +// The last behaviour repeats, so a script's tail describes "and every reconnect attempt after +// that", which is what the backoff tests need. +function scriptedBuses(script: readonly FakeBusBehaviour[]): ScriptedBuses { + const buses: FakeBus[] = []; + const factory: BusFactory = (_options: CreateClientOptions): RawBus => { + const behaviour = script[Math.min(buses.length, script.length - 1)]; + if (behaviour === undefined) { + throw new Error('scriptedBuses called with an empty script'); + } + const bus = new FakeBus(behaviour); + buses.push(bus); + bus.arm(); + return bus; + }; + return { factory, buses }; +} + +function teardownReports(transport: { + on(event: 'error', handler: (payload?: unknown) => void): void; +}): TransportTeardownFailure[] { + const reports: TransportTeardownFailure[] = []; + transport.on('error', (payload) => { + if (payload instanceof TransportTeardownFailure) { + reports.push(payload); + } + }); + return reports; +} + +test('disconnect() during reconnect backoff resolves promptly instead of waiting the delay out', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed' }, { handshake: 'fail' }]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 30_000, maxDelayMs: 30_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let dropped = false; + transport.on('disconnected', () => { + dropped = true; + }); + + await transport.connect(); + expect(transport.isConnected()).toBe(true); + + // Drop the connection under the transport. Its first reconnect attempt fails, and the loop + // parks in a 30s backoff — an interval no honest close should ever have to sit through. + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + await waitFor(() => dropped && buses.length >= 2, 5_000, 'the first failed reconnect attempt'); + await sleep(50); + + const startedAt = Date.now(); + await transport.disconnect(); + const elapsedMs = Date.now() - startedAt; + expect(elapsedMs).toBeLessThan(100); + + // And the loop is genuinely gone rather than merely unawaited: no further attempt is made. + const attemptsAtClose = buses.length; + await sleep(200); + expect(buses.length).toBe(attemptsAtClose); +}, 15_000); + +test('a connection.end() that throws while abandoning a failed connect is reported', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'fail', endThrows: true }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { connectTimeoutMs: 50 } }, + factory, + ); + const reports = teardownReports(transport); + + await expect(transport.connect()).rejects.toThrow('fake handshake refused'); + + expect(reports).toHaveLength(1); + const report = reports[0]; + if (report === undefined) { + throw new Error('no teardown report was emitted'); + } + expect(report.phase).toBe('establish-abort'); + expect(report.step).toBe('connection-end'); + expect(report.name).toBe('TransportTeardownFailure'); + expect(report.cause).toBeInstanceOf(Error); + expect(buses[0]?.connection.endCalls).toBe(1); +}); + +test('a bus.disconnect() that rejects is reported and never rejects disconnect()', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed', disconnectRejects: true }]); + const transport = createDbusTransportForTest({ reconnect: { enabled: false } }, factory); + const reports = teardownReports(transport); + + await transport.connect(); + await transport.disconnect(); + + expect(transport.isConnected()).toBe(false); + expect(buses[0]?.disconnectCalls).toBe(1); + expect(reports).toHaveLength(1); + const report = reports[0]; + if (report === undefined) { + throw new Error('no teardown report was emitted'); + } + expect(report.phase).toBe('disconnect'); + expect(report.step).toBe('bus-disconnect'); +}); + +test('a teardown failure with no error listener attached does not throw', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed', disconnectRejects: true }]); + const transport = createDbusTransportForTest({ reconnect: { enabled: false } }, factory); + + await transport.connect(); + // Node re-throws an unobserved EventEmitter 'error'. A report about a failed teardown must + // not become the crash it is describing. + await transport.disconnect(); + expect(transport.isConnected()).toBe(false); +}); + +test('the new teardown failure paths leak no connection listener', async () => { + const { factory, buses } = scriptedBuses([ + { handshake: 'succeed', disconnectRejects: true }, + { handshake: 'fail', endThrows: true }, + ]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 20_000, maxDelayMs: 20_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let dropped = false; + transport.on('disconnected', () => { + dropped = true; + }); + + await transport.connect(); + const subscription = await transport.subscribeSignal( + { interface: 'tv.ceralive.Fake', member: 'Tick' }, + () => undefined, + ); + + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + await waitFor(() => dropped && buses.length >= 2, 5_000, 'the failed reconnect attempt'); + await sleep(50); + + await subscription.unsubscribe(); + await transport.disconnect(); + + expect(transport.subscriptionCount()).toBe(0); + expect(buses).toHaveLength(2); + for (const bus of buses) { + expect(bus.connection.listenerCount('message')).toBe(0); + expect(bus.connection.listenerCount('end')).toBe(0); + expect(bus.connection.listenerCount('connect')).toBe(0); + // Exactly one listener survives on each abandoned connection: the deliberate swallow the + // transport installs so a late socket error cannot crash the process. + expect(bus.connection.listenerCount('error')).toBe(1); + } +}); + +test('repeated drop signals produce one disconnected event and one reconnect loop', async () => { + const { factory, buses } = scriptedBuses([{ handshake: 'succeed' }, { handshake: 'fail' }]); + const transport = createDbusTransportForTest( + { + reconnect: { initialDelayMs: 20_000, maxDelayMs: 20_000 }, + timing: { connectTimeoutMs: 50 }, + }, + factory, + ); + transport.on('error', () => undefined); + let disconnects = 0; + transport.on('disconnected', () => { + disconnects += 1; + }); + + await transport.connect(); + const first = buses[0]; + if (first === undefined) { + throw new Error('the initial connect created no bus'); + } + first.connection.emit('end'); + first.connection.emit('error', new Error('a second drop signal')); + first.connection.emit('end'); + + await waitFor(() => buses.length >= 2, 5_000, 'the single reconnect attempt'); + await sleep(150); + + expect(disconnects).toBe(1); + // Two loops would each be establishing against the same script, so a second attempt would + // show up as a third bus well inside the 20s backoff. + expect(buses).toHaveLength(2); + + await transport.disconnect(); +}); + +test('the timing policy keeps the values this transport has always used', () => { + expect(DEFAULT_TRANSPORT_TIMING.connectTimeoutMs).toBe(2_000); + expect(DEFAULT_TRANSPORT_TIMING.callTimeoutMs).toBe(30_000); +}); + +test('an injected connect bound is the one the handshake enforces', async () => { + const { factory } = scriptedBuses([{ handshake: 'stall' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { connectTimeoutMs: 40 } }, + factory, + ); + transport.on('error', () => undefined); + + await expect(transport.connect()).rejects.toThrow('bus connect timed out after 40ms'); + + await transport.disconnect(); +}); + +test('an injected call bound reaches the call dispatcher', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, timing: { callTimeoutMs: 40 } }, + factory, + ); + + await transport.connect(); + await expect( + transport.callMethod({ + destination: 'tv.ceralive.Fake', + path: '/tv/ceralive/Fake', + interface: 'tv.ceralive.Fake', + member: 'Ping', + }), + ).rejects.toThrow('timed out after 40ms'); + + await transport.disconnect(); +}); + +test('the standalone callTimeoutMs option still applies when no timing policy is given', async () => { + const { factory } = scriptedBuses([{ handshake: 'succeed' }]); + const transport = createDbusTransportForTest( + { reconnect: { enabled: false }, callTimeoutMs: 40 }, + factory, + ); + + await transport.connect(); + await expect( + transport.callMethod({ + destination: 'tv.ceralive.Fake', + path: '/tv/ceralive/Fake', + interface: 'tv.ceralive.Fake', + member: 'Ping', + }), + ).rejects.toThrow('timed out after 40ms'); + + await transport.disconnect(); +}); diff --git a/control/src/transport/transport.ts b/control/src/transport/transport.ts index 099dadf..ac4bb29 100644 --- a/control/src/transport/transport.ts +++ b/control/src/transport/transport.ts @@ -36,10 +36,6 @@ interface ResolvedReconnect { readonly maxAttempts: number; } -// Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect -// loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom -// while keeping reconnect responsive after a bus restart. -const CONNECT_TIMEOUT_MS = 2_000; const DEFAULT_RECONNECT: ResolvedReconnect = { enabled: true, initialDelayMs: 50, @@ -47,11 +43,74 @@ const DEFAULT_RECONNECT: ResolvedReconnect = { maxAttempts: 0, }; -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +// Every wall-clock bound this transport enforces, in ONE named object rather than scattered +// module constants — so a caller (or a test) moves them together and cannot leave the connect +// bound and the call bound disagreeing about how patient the transport is. +// +// The defaults are exactly the values this module has always used, unchanged. `connectTimeoutMs` +// bounds a single connect/auth attempt so a stalled handshake cannot freeze the reconnect loop: +// a local unix-socket D-Bus connect completes in milliseconds, so 2s is ample headroom while +// keeping reconnect responsive after a bus restart. `callTimeoutMs` is the per-call reply bound +// `./calls` has always defaulted to. +export interface TransportTimingPolicy { + readonly connectTimeoutMs: number; + readonly callTimeoutMs: number; +} + +export const DEFAULT_TRANSPORT_TIMING: TransportTimingPolicy = { + connectTimeoutMs: 2_000, + callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, +}; + +// `timing` outranks the standalone `callTimeoutMs`, which stays honoured so a caller that +// only cares about the reply bound never has to name the whole policy. +export interface DbusTransportSeamOptions extends DbusTransportOptions { + readonly timing?: Partial; +} + +function resolveTimingPolicy(options: DbusTransportSeamOptions): TransportTimingPolicy { + return { + connectTimeoutMs: options.timing?.connectTimeoutMs ?? DEFAULT_TRANSPORT_TIMING.connectTimeoutMs, + callTimeoutMs: + options.timing?.callTimeoutMs ?? + options.callTimeoutMs ?? + DEFAULT_TRANSPORT_TIMING.callTimeoutMs, + }; +} + +// Which teardown the transport was performing, and which step of it failed. Both are needed: +// the same step fails for different reasons depending on whether a caller asked to close or +// the transport is abandoning a half-open connection mid-reconnect. +export type TransportTeardownPhase = 'disconnect' | 'establish-abort'; +export type TransportTeardownStep = 'bus-disconnect' | 'connection-end'; + +// A teardown step failed. This is REPORTED on the transport's existing `error` event and is +// never thrown: by the time it happens the caller is already closing (or the establish error +// is already on its way up), so escalating would replace the failure a caller must act on with +// one they cannot. It is an `Error` subclass because everything else on that event is — +// consumers narrow with `instanceof` and branch on `phase` / `step`. +export class TransportTeardownFailure extends TransportError { + readonly phase: TransportTeardownPhase; + readonly step: TransportTeardownStep; + + constructor(phase: TransportTeardownPhase, step: TransportTeardownStep, cause: unknown) { + super(`D-Bus transport teardown step "${step}" failed during ${phase}`, { cause }); + this.name = 'TransportTeardownFailure'; + this.phase = phase; + this.step = step; + } +} + +// How the transport obtains a raw bus. Production is `createClient`; a test supplies its own +// through `createDbusTransportForTest`, so lifecycle failure paths are reachable without a +// real daemon to break. +export type BusFactory = (options: CreateClientOptions) => RawBus; class DbusTransportImpl implements DbusTransport { - readonly #options: DbusTransportOptions; + readonly #options: DbusTransportSeamOptions; readonly #reconnect: ResolvedReconnect; + readonly #timing: TransportTimingPolicy; + readonly #createBus: BusFactory; readonly #emitter = new EventEmitter(); readonly #calls: CallDispatcher; readonly #signals: SignalRegistry; @@ -59,6 +118,12 @@ class DbusTransportImpl implements DbusTransport { #bus: RawBus | null = null; #state: State = 'idle'; #closing = false; + // The single live reconnect loop, retained rather than fired and forgotten: a second drop + // cannot start a second loop, and `disconnect()` has something to await so it can promise + // that no loop outlives it. + #reconnectLoopPromise: Promise | null = null; + // Set only while that loop is parked in its backoff sleep; calling it cuts the sleep short. + #wakeBackoff: (() => void) | null = null; // Bound once so the same references can be detached from a dead connection. readonly #onMessage = (message: RawMessage): void => this.#signals.dispatch(message); @@ -67,9 +132,11 @@ class DbusTransportImpl implements DbusTransport { readonly #onConnectionEnd = (): void => this.#handleDrop(new DisconnectedError('bus connection ended')); - constructor(options: DbusTransportOptions) { + constructor(options: DbusTransportSeamOptions, createBus: BusFactory) { this.#options = options; - this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS); + this.#createBus = createBus; + this.#timing = resolveTimingPolicy(options); + this.#calls = new CallDispatcher(this.#timing.callTimeoutMs); this.#signals = new SignalRegistry({ currentBus: () => this.#bus, isConnected: () => this.#state === 'connected', @@ -106,10 +173,17 @@ class DbusTransportImpl implements DbusTransport { this.#state = 'closed'; const bus = this.#bus; this.#bus = null; + // Capture the loop BEFORE waking it: waking lets it run to completion, which clears the + // field, and a `disconnect()` that lost the handle could not wait for it. + const loop = this.#reconnectLoopPromise; + this.#cancelBackoff(); this.#calls.rejectAll(new DisconnectedError('transport closed')); if (bus) { this.#quiesce(bus); - await bus.disconnect().catch(() => undefined); + await this.#closeBus(bus, 'disconnect'); + } + if (loop) { + await loop; } } @@ -143,7 +217,8 @@ class DbusTransportImpl implements DbusTransport { options.busAddress = this.#options.busAddress; } - const bus = createClient(options); + const connectTimeoutMs = this.#timing.connectTimeoutMs; + const bus = this.#createBus(options); try { await new Promise((resolve, reject) => { const onConnect = (): void => { @@ -156,8 +231,8 @@ class DbusTransportImpl implements DbusTransport { }; const timer = setTimeout(() => { cleanup(); - reject(new TransportError(`bus connect timed out after ${CONNECT_TIMEOUT_MS}ms`)); - }, CONNECT_TIMEOUT_MS); + reject(new TransportError(`bus connect timed out after ${connectTimeoutMs}ms`)); + }, connectTimeoutMs); const cleanup = (): void => { clearTimeout(timer); bus.connection.removeListener('connect', onConnect); @@ -174,6 +249,13 @@ class DbusTransportImpl implements DbusTransport { // Re-issue every live match rule so a reconnect resubscribes transparently. await this.#signals.reissueRules(bus); + if (this.#closing) { + // `disconnect()` landed while this attempt was in flight. Fail the attempt so the + // shared catch below tears the fresh connection down — a closed transport must + // never publish a live bus that nobody is left to close. + throw new TransportError('Transport is closed'); + } + this.#bus = bus; this.#state = 'connected'; } catch (error) { @@ -182,8 +264,12 @@ class DbusTransportImpl implements DbusTransport { bus.connection.on('error', () => undefined); try { bus.connection.end(); - } catch { - // The half-open connection is already dead; nothing to close. + } catch (cause) { + // The half-open connection is usually already dead, so this ordinarily does + // nothing. When it does fail, that is a real teardown outcome and it is reported + // rather than discarded — but never rethrown, because `error` is the failure the + // caller actually needs to see. + this.#reportTeardownFailure('establish-abort', 'connection-end', cause); } throw error; } @@ -203,6 +289,33 @@ class DbusTransportImpl implements DbusTransport { bus.connection.on('error', () => undefined); } + // A rejected `bus.disconnect()` used to vanish into `.catch(() => undefined)`, leaving a + // consumer no way to learn that a socket it believed closed never actually was. + async #closeBus(bus: RawBus, phase: TransportTeardownPhase): Promise { + try { + await bus.disconnect(); + } catch (cause) { + this.#reportTeardownFailure(phase, 'bus-disconnect', cause); + } + } + + #reportTeardownFailure( + phase: TransportTeardownPhase, + step: TransportTeardownStep, + cause: unknown, + ): void { + this.#emitObservable('error', new TransportTeardownFailure(phase, step, cause)); + } + + // Node re-throws an unobserved EventEmitter 'error', which would turn a report ABOUT a + // failed teardown into a process crash on the path that is already unwinding. + #emitObservable(event: TransportEvent, payload: unknown): void { + if (event === 'error' && this.#emitter.listenerCount('error') === 0) { + return; + } + this.#emitter.emit(event, payload); + } + #handleDrop(cause: unknown): void { if (this.#closing) { return; @@ -218,10 +331,28 @@ class DbusTransportImpl implements DbusTransport { this.#calls.rejectAll(cause); this.#emitter.emit('disconnected', cause); if (this.#reconnect.enabled) { - void this.#reconnectLoop(); + this.#startReconnectLoop(); } } + // The state guard above already blocks the common double-drop, but holding the promise + // makes the single-loop property structural instead of a consequence of state ordering, + // and it is what lets `disconnect()` wait for the loop it just cancelled. + #startReconnectLoop(): void { + if (this.#reconnectLoopPromise !== null) { + return; + } + const loop = this.#reconnectLoop().catch((error: unknown) => { + this.#emitObservable('error', error); + }); + this.#reconnectLoopPromise = loop; + void loop.then(() => { + if (this.#reconnectLoopPromise === loop) { + this.#reconnectLoopPromise = null; + } + }); + } + async #reconnectLoop(): Promise { this.#state = 'reconnecting'; let delay = this.#reconnect.initialDelayMs; @@ -238,13 +369,48 @@ class DbusTransportImpl implements DbusTransport { this.#emitter.emit('error', error); return; } - await sleep(delay); + await this.#backoff(delay); delay = Math.min(delay * 2, this.#reconnect.maxDelayMs); } } } + + // A plain `setTimeout` promise holds the loop — and the event loop — for the whole + // remaining delay after a caller has already asked for teardown. At the default 2s ceiling + // that is a 2s stall on every close landing mid-backoff, and an unbounded one for a caller + // that raised the ceiling. + #backoff(ms: number): Promise { + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer); + if (this.#wakeBackoff === finish) { + this.#wakeBackoff = null; + } + resolve(); + }; + const timer = setTimeout(finish, ms); + this.#wakeBackoff = finish; + }); + } + + #cancelBackoff(): void { + const wake = this.#wakeBackoff; + this.#wakeBackoff = null; + wake?.(); + } +} + +export function createDbusTransport(options: DbusTransportSeamOptions = {}): DbusTransport { + return new DbusTransportImpl(options, createClient); } -export function createDbusTransport(options: DbusTransportOptions = {}): DbusTransport { - return new DbusTransportImpl(options); +// Deliberately NOT re-exported from `./index.ts`, whose public surface is pinned by +// `no-library-leak.test.ts`. It exists so lifecycle failure paths — a bus whose `disconnect()` +// rejects, a connection whose `end()` throws, a handshake that never completes — are reachable +// deterministically, without a real `dbus-daemon` to break. +export function createDbusTransportForTest( + options: DbusTransportSeamOptions, + busFactory: BusFactory, +): DbusTransport { + return new DbusTransportImpl(options, busFactory); } From 0ddbd879d47888bf60e31aa6ed2cb8e759dd0396 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:17:28 -0500 Subject: [PATCH 07/19] style: fix biome formatting in version-unity.test.ts --- control/src/version-unity.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/control/src/version-unity.test.ts b/control/src/version-unity.test.ts index a228e38..f5e58e8 100644 --- a/control/src/version-unity.test.ts +++ b/control/src/version-unity.test.ts @@ -1,9 +1,9 @@ -import { test } from "bun:test"; +import { test } from 'bun:test'; const FIRST_PARTY_PACKAGE_PATHS = [ - "package.json", - "control/package.json", - "cli/package.json", + 'package.json', + 'control/package.json', + 'cli/package.json', ] as const; type PackageVersion = { @@ -13,25 +13,23 @@ type PackageVersion = { async function readPackageVersion(path: string): Promise { const manifest: unknown = await Bun.file(path).json(); - if (typeof manifest !== "object" || manifest === null) { + if (typeof manifest !== 'object' || manifest === null) { throw new Error(`${path} must contain a JSON object`); } - const version = Reflect.get(manifest, "version"); - if (typeof version !== "string") { + const version = Reflect.get(manifest, 'version'); + if (typeof version !== 'string') { throw new Error(`${path} must contain a string version`); } return { path, version }; } -test("first-party workspace package versions stay unified", async () => { - const packageVersions = await Promise.all( - FIRST_PARTY_PACKAGE_PATHS.map(readPackageVersion), - ); +test('first-party workspace package versions stay unified', async () => { + const packageVersions = await Promise.all(FIRST_PARTY_PACKAGE_PATHS.map(readPackageVersion)); const root = packageVersions[0]; if (root === undefined) { - throw new Error("first-party package version list is unexpectedly empty"); + throw new Error('first-party package version list is unexpectedly empty'); } for (const packageVersion of packageVersions.slice(1)) { From b8a8179508f82013aece60a9399fa441951775b3 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:17:28 -0500 Subject: [PATCH 08/19] style: fix biome formatting in journal-path-injection.test.ts --- control/src/journal/journal-path-injection.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/control/src/journal/journal-path-injection.test.ts b/control/src/journal/journal-path-injection.test.ts index c19e611..7d90d18 100644 --- a/control/src/journal/journal-path-injection.test.ts +++ b/control/src/journal/journal-path-injection.test.ts @@ -40,9 +40,9 @@ const ABSOLUTE_PATH_LITERAL = /(['"`])\/[A-Za-z][\w./${}-]*\1/g; const CERAUI_PATH_TOKENS = /\/data\b|ceralive\/modem-mutations|CERALIVE_MODEM_MUTATION_DIR/; async function shippedSources(): Promise { - const names = (await Array.fromAsync(new Bun.Glob('**/*.ts').scan({ cwd: JOURNAL_DIR, onlyFiles: true }))).filter( - (name) => !name.endsWith('.test.ts'), - ); + const names = ( + await Array.fromAsync(new Bun.Glob('**/*.ts').scan({ cwd: JOURNAL_DIR, onlyFiles: true })) + ).filter((name) => !name.endsWith('.test.ts')); return Promise.all( names.map(async (name) => [name, await readFile(join(JOURNAL_DIR, name), 'utf8')] as const), ); From 2e9d1f80ef6087c722340d030ab9d307afcb961e Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:26:39 -0500 Subject: [PATCH 09/19] refactor: shared JSON boundary parser + unified bench AT sender --- cli/src/bench-at-sender.ts | 10 +++++++ cli/src/commands/certify.ts | 11 +------- cli/src/wiring.ts | 12 +------- control/src/hardware/router-parsers.ts | 31 ++------------------- control/src/json-boundary.ts | 20 +++++++++++++ control/src/providers/ufi-himi/session.ts | 16 ++--------- control/src/providers/zte-goform/session.ts | 16 ++--------- 7 files changed, 39 insertions(+), 77 deletions(-) create mode 100644 cli/src/bench-at-sender.ts create mode 100644 control/src/json-boundary.ts diff --git a/cli/src/bench-at-sender.ts b/cli/src/bench-at-sender.ts new file mode 100644 index 0000000..e952c44 --- /dev/null +++ b/cli/src/bench-at-sender.ts @@ -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 { + return Promise.reject( + new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`), + ); + }, +}; diff --git a/cli/src/commands/certify.ts b/cli/src/commands/certify.ts index 8db5979..d946e4d 100644 --- a/cli/src/commands/certify.ts +++ b/cli/src/commands/certify.ts @@ -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, @@ -59,15 +59,6 @@ export interface CertifyDeps { writeBundle(path: string, content: string): Promise; } -/** 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 = diff --git a/cli/src/wiring.ts b/cli/src/wiring.ts index d715a94..2fc1241 100644 --- a/cli/src/wiring.ts +++ b/cli/src/wiring.ts @@ -6,8 +6,6 @@ // error rather than pretending — full wiring lands with the Phase-B composition root. import { - type AtCommandSender, - type AtResponse, createUsageFileStore, createUsageSampler, fetchManagedObjects, @@ -23,6 +21,7 @@ import { type UsageSampler, UsbModeTransition, } from '@ceralive/modem-control'; +import { benchAtSender } from './bench-at-sender'; import type { RequestResolver, UsbModeArgs } from './commands/set-usb-mode'; import type { StackContext } from './context'; import { selectModem } from './select'; @@ -32,15 +31,6 @@ import { matchUsbDevice } from './usb-device-match'; const USAGE_STORE_PATH = process.env.MODEM_CONTROL_USAGE_STORE ?? '/var/lib/modem-control/usage.json'; -/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */ -const benchAtSender: AtCommandSender = { - send(command: string): Promise { - return Promise.reject( - new Error(`no AT serial transport on the bench (hardware-gated): '${command}'`), - ); - }, -}; - /** Build the real USB-mode transaction over the live NM + MM ports. */ export function buildUsbModeTransition(ctx: StackContext): UsbModeTransition { return new UsbModeTransition({ diff --git a/control/src/hardware/router-parsers.ts b/control/src/hardware/router-parsers.ts index 97f334a..2d5e5c1 100644 --- a/control/src/hardware/router-parsers.ts +++ b/control/src/hardware/router-parsers.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseJsonWith } from '../json-boundary'; import { parseHilinkXmlValue } from './hilink-protocol'; export * from './hilink-protocol'; @@ -122,37 +123,11 @@ const ufiBodySchema = z.object({ }); function parseFlatRecord(body: string): Readonly> | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(flatRecordSchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(flatRecordSchema, body); } function parseUfiBody(body: string): z.infer | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(ufiBodySchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(ufiBodySchema, body); } export function parseHilinkSignal(input: { diff --git a/control/src/json-boundary.ts b/control/src/json-boundary.ts new file mode 100644 index 0000000..cdc1e54 --- /dev/null +++ b/control/src/json-boundary.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +type JsonSchema = z.ZodType; + +export function parseJsonWith(schema: JsonSchema, body: string): T | undefined { + const parsed = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(schema) + .safeParse(body); + return parsed.success ? parsed.data : undefined; +} diff --git a/control/src/providers/ufi-himi/session.ts b/control/src/providers/ufi-himi/session.ts index 63b2811..f02489f 100644 --- a/control/src/providers/ufi-himi/session.ts +++ b/control/src/providers/ufi-himi/session.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseJsonWith } from '../../json-boundary'; import type { ProviderMatchRequest } from '../contracts'; import { UFI_API_PATH, @@ -32,20 +33,7 @@ const ufiReplySchema = z.object({ export type UfiReply = z.infer; export function parseUfiReply(body: string): UfiReply | undefined { - const parsed = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(ufiReplySchema) - .safeParse(body); - return parsed.success ? parsed.data : undefined; + return parseJsonWith(ufiReplySchema, body); } /** diff --git a/control/src/providers/zte-goform/session.ts b/control/src/providers/zte-goform/session.ts index dbb665e..4fee0f2 100644 --- a/control/src/providers/zte-goform/session.ts +++ b/control/src/providers/zte-goform/session.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; +import { parseJsonWith } from '../../json-boundary'; import type { AuthenticatedProfileResult, ProviderMatchRequest } from '../contracts'; import { ZTE_EVIDENCE_CMD, @@ -25,20 +26,7 @@ const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])) export function parseZteRecord( body: string, ): Readonly> | undefined { - const result = z - .string() - .transform((value, context) => { - try { - return JSON.parse(value); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - context.addIssue({ code: 'custom', message: 'invalid JSON' }); - return z.NEVER; - } - }) - .pipe(flatRecordSchema) - .safeParse(body); - return result.success ? result.data : undefined; + return parseJsonWith(flatRecordSchema, body); } function stokCookie(response: ZteHttpResponse): string | undefined { From 33577dab40c923538be7a6f712c70aee7b25f774 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:38:33 -0500 Subject: [PATCH 10/19] refactor: decompose five oversized modules (three-gate zero-behavior proof) --- control/src/backend/mm-mutations.ts | 290 ++---------------- control/src/backend/mm-mutations/bands.ts | 64 ++++ control/src/backend/mm-mutations/context.ts | 14 + control/src/backend/mm-mutations/inhibit.ts | 31 ++ control/src/backend/mm-mutations/modes.ts | 66 ++++ control/src/backend/mm-mutations/scan.ts | 55 ++++ control/src/backend/mm-mutations/sim.ts | 77 +++++ control/src/backend/observer.ts | 74 ++--- control/src/backend/observer/bus-lifecycle.ts | 39 +++ .../src/backend/observer/epoch-reconcile.ts | 42 +++ .../src/backend/observer/signal-routing.ts | 22 ++ control/src/backend/usage/persistence.ts | 59 ++++ control/src/backend/usage/policy.ts | 65 ++++ control/src/backend/usage/sampler.ts | 162 +++------- control/src/backend/usage/sampling.ts | 48 +++ control/src/backend/usb-mode-transition.ts | 118 ++----- .../backend/usb-mode-transition/admission.ts | 28 ++ control/src/backend/usb-mode-transition/at.ts | 33 ++ .../backend/usb-mode-transition/outcome.ts | 26 ++ .../usb-mode-transition/reenumeration.ts | 51 +++ .../src/providers/network-manager/adapter.ts | 190 +----------- .../providers/network-manager/divergence.ts | 15 + .../providers/network-manager/observe-fold.ts | 144 +++++++++ .../providers/network-manager/projection.ts | 25 ++ .../src/providers/network-manager/state.ts | 30 ++ 25 files changed, 1069 insertions(+), 699 deletions(-) create mode 100644 control/src/backend/mm-mutations/bands.ts create mode 100644 control/src/backend/mm-mutations/context.ts create mode 100644 control/src/backend/mm-mutations/inhibit.ts create mode 100644 control/src/backend/mm-mutations/modes.ts create mode 100644 control/src/backend/mm-mutations/scan.ts create mode 100644 control/src/backend/mm-mutations/sim.ts create mode 100644 control/src/backend/observer/bus-lifecycle.ts create mode 100644 control/src/backend/observer/epoch-reconcile.ts create mode 100644 control/src/backend/observer/signal-routing.ts create mode 100644 control/src/backend/usage/persistence.ts create mode 100644 control/src/backend/usage/policy.ts create mode 100644 control/src/backend/usage/sampling.ts create mode 100644 control/src/backend/usb-mode-transition/admission.ts create mode 100644 control/src/backend/usb-mode-transition/at.ts create mode 100644 control/src/backend/usb-mode-transition/outcome.ts create mode 100644 control/src/backend/usb-mode-transition/reenumeration.ts create mode 100644 control/src/providers/network-manager/divergence.ts create mode 100644 control/src/providers/network-manager/observe-fold.ts create mode 100644 control/src/providers/network-manager/projection.ts create mode 100644 control/src/providers/network-manager/state.ts diff --git a/control/src/backend/mm-mutations.ts b/control/src/backend/mm-mutations.ts index 7da7333..9ee61ab 100644 --- a/control/src/backend/mm-mutations.ts +++ b/control/src/backend/mm-mutations.ts @@ -1,319 +1,87 @@ -// The ModemManager mutations — the disruptive-and-SIM half of `ModemManagerPort`. -// -// Every disruptive op runs through the shared per-modem `ModemActor` (serialized on -// the STABLE key, so two ops on one modem never interleave and a replug keeps the -// same queue). Mode and slot changes additionally run QUIESCED (NM briefly stands -// down first, via the actor's quiesce hook). PIN / PUK / scan serialize too but do -// NOT quiesce — they don't touch the bearer. NONE of these methods can reach a bearer -// or connect verb: the port has none, and the fake's tripwire proves it at test time. - -import { decodeBandList, encodeBandList, isResetSelection } from '../band'; -import type { DesiredRadio, RadioAccessTechnology } from '../domain'; -import { epochMillis } from '../domain'; +import type { DesiredRadio } from '../domain'; import type { BandReadResult, InhibitLease, ModemRef, NetworkScanResult, Receipt, - ScannedNetwork, SimPukUnlockResult, SimUnlockResult, } from '../ports'; -import { receipt } from '../ports'; import type { DbusTransport } from '../transport'; -import { - MM_BUS_NAME, - MM_MANAGER_IFACE, - MM_ROOT_PATH, - MODEM_IFACE, - MODEM3GPP_IFACE, -} from './constants'; -import { - type DecodedProps, - fetchManagedObjects, - findInterface, - numberProp, - propValue, - stringProp, -} from './managed-objects'; +import { MM_BUS_NAME } from './constants'; +import { readBands, setCurrentBands } from './mm-mutations/bands'; +import type { MmMutationContext } from './mm-mutations/context'; +import { inhibitDevice, uninhibitDevice } from './mm-mutations/inhibit'; +import { setModeCombination, setRadioModes } from './mm-mutations/modes'; +import { scanNetworks } from './mm-mutations/scan'; +import { setPrimarySimSlot, unlockWithPin, unlockWithPuk } from './mm-mutations/sim'; import type { ModemActor } from './modem-actor'; -import { sendSimPin, sendSimPuk } from './sim-unlock'; - -/** MMModemMode bit per RAT family (2G/3G/4G/5G). */ -const MODE_BIT: Record = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 }; -/** MMModem3gppNetworkAvailability → the port's availability. */ -const AVAILABILITY: Record = { - 0: 'unknown', - 1: 'available', - 2: 'current', - 3: 'forbidden', -}; - -/** A network scan can take a long time — MM's own default is not enough. */ const DEFAULT_SCAN_TIMEOUT_MS = 300_000; export interface MmMutationsDeps { readonly transport: DbusTransport; readonly actor: ModemActor; readonly destination?: string; - /** Map a live modem path to its stable actor key (survives replug). */ readonly resolveStableKey: (modem: ModemRef) => string; readonly scanTimeoutMs?: number; readonly now?: () => number; } -/** The disruptive + SIM mutations of `ModemManagerPort`, serialized per modem. */ export class MmMutations { - readonly #transport: DbusTransport; - readonly #actor: ModemActor; - readonly #destination: string; - readonly #resolveStableKey: (modem: ModemRef) => string; + readonly #context: MmMutationContext; readonly #scanTimeoutMs: number; readonly #now: () => number; constructor(deps: MmMutationsDeps) { - this.#transport = deps.transport; - this.#actor = deps.actor; - this.#destination = deps.destination ?? MM_BUS_NAME; - this.#resolveStableKey = deps.resolveStableKey; + this.#context = { + transport: deps.transport, + actor: deps.actor, + destination: deps.destination ?? MM_BUS_NAME, + resolveStableKey: deps.resolveStableKey, + }; this.#scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS; this.#now = deps.now ?? Date.now; } setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise { - const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered)); - const preferred = preference.preferenceOrdered[0]; - const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0; - if (allowed === 0) { - return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentModes', - signature: '(uu)', - args: [[allowed, preferredMask]], - }); - return receipt('radio', 'applied', 'radio mode preference applied'); - } catch (error) { - return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); - } - }); + return setRadioModes(this.#context, modem, preference); } - /** - * `SetCurrentModes` over the RAW `(uu)` masks the modem itself advertised. - * - * `setRadioModes` above speaks the reconciler's vocabulary — an ordered RAT - * preference — and structurally cannot express `MM_MODEM_MODE_NONE`: its preferred - * mask is derived from `preferenceOrdered[0]`, so "allow this set and prefer - * nothing within it" has no spelling. That is the combination the bench FM350-GL - * actually advertises, so a caller selecting an advertised combination verbatim - * needs this entry point. It quiesces for the same reason `setRadioModes` does: a - * mode change re-registers the radio and drops the bearer underneath NM. - * - * An `allowed` mask of 0 is refused — that is not a selection, it is a radio with - * nothing switched on — while a `preferred` mask of 0 is passed through untouched. - */ setModeCombination(modem: ModemRef, allowed: number, preferred: number): Promise { - if (allowed === 0) { - return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentModes', - signature: '(uu)', - args: [[allowed, preferred]], - }); - return receipt('radio', 'applied', 'radio mode combination applied'); - } catch (error) { - return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); - } - }); + return setModeCombination(this.#context, modem, allowed, preferred); } - async readBands(modem: ModemRef): Promise { - try { - const tree = await fetchManagedObjects(this.#transport, this.#destination); - const props = findInterface(tree, modem, MODEM_IFACE); - if (props === undefined) { - return { ok: false, reason: 'the modem exports no Modem interface' }; - } - return { - ok: true, - bands: { - supported: decodeBandList(propValue(props, 'SupportedBands')), - current: decodeBandList(propValue(props, 'CurrentBands')), - }, - }; - } catch (error) { - return { ok: false, reason: `reading bands failed: ${describe(error)}` }; - } + readBands(modem: ModemRef): Promise { + return readBands(this.#context, modem); } - // Quiesced like `setRadioModes`, and for the same reason: a band change - // re-registers the radio, so NM must stand down before the bearer drops - // underneath it rather than after. setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise { - if (bands.length === 0) { - return Promise.resolve(receipt('band', 'failed', 'no bands were requested')); - } - const encoded = encodeBandList(bands); - if (!encoded.ok) { - return Promise.resolve( - receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`), - ); - } - const values = encoded.values; - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetCurrentBands', - signature: 'au', - args: [values], - }); - return receipt( - 'band', - 'applied', - isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`, - ); - } catch (error) { - return receipt('band', 'failed', `SetCurrentBands failed: ${describe(error)}`); - } - }); + return setCurrentBands(this.#context, modem, bands); } - async setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { - const slots = await this.#readSlotCount(modem); - if (slots === undefined) { - return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list'); - } - if (slots <= 1) { - return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select'); - } - if (slotIndex < 1 || slotIndex > slots) { - return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`); - } - return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { - try { - await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM_IFACE, - member: 'SetPrimarySimSlot', - signature: 'u', - args: [slotIndex], - }); - return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`); - } catch (error) { - return receipt('simSlot', 'failed', `SetPrimarySimSlot failed: ${describe(error)}`); - } - }); + setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { + return setPrimarySimSlot(this.#context, modem, slotIndex); } sendPin(modem: ModemRef, pin: string): Promise { - return this.#actor.run(this.#resolveStableKey(modem), () => - sendSimPin(this.#transport, this.#destination, modem, pin), - ); + return unlockWithPin(this.#context, modem, pin); } sendPuk(modem: ModemRef, puk: string, newPin: string): Promise { - return this.#actor.run(this.#resolveStableKey(modem), () => - sendSimPuk(this.#transport, this.#destination, modem, puk, newPin), - ); + return unlockWithPuk(this.#context, modem, puk, newPin); } scanNetworks(modem: ModemRef): Promise { - return this.#actor.run(this.#resolveStableKey(modem), async () => { - try { - const reply = await this.#transport.callMethod({ - destination: this.#destination, - path: modem, - interface: MODEM3GPP_IFACE, - member: 'Scan', - timeoutMs: this.#scanTimeoutMs, - }); - return { ok: true, networks: parseScan(reply.body[0]) }; - } catch (error) { - return { ok: false, reason: `network scan failed: ${describe(error)}` }; - } - }); - } - - async inhibit(uid: string): Promise { - await this.#inhibitDevice(uid, true); - return { uid, acquiredAt: epochMillis(this.#now()) }; - } - - async uninhibit(lease: InhibitLease): Promise { - await this.#inhibitDevice(lease.uid, false); - } - - #inhibitDevice(uid: string, inhibit: boolean): Promise { - return this.#transport.callMethod({ - destination: this.#destination, - path: MM_ROOT_PATH, - interface: MM_MANAGER_IFACE, - member: 'InhibitDevice', - signature: 'sb', - args: [uid, inhibit], - }); - } - - async #readSlotCount(modem: ModemRef): Promise { - try { - const tree = await fetchManagedObjects(this.#transport, this.#destination); - const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots'); - return Array.isArray(slots) ? slots.length : undefined; - } catch { - return undefined; - } + return scanNetworks(this.#context, modem, this.#scanTimeoutMs); } -} -/** OR of the MMModemMode bits for a set of RATs. */ -function maskOf(rats: ReadonlySet): number { - let mask = 0; - for (const rat of rats) { - mask |= MODE_BIT[rat]; + inhibit(uid: string): Promise { + return inhibitDevice(this.#context, uid, this.#now); } - return mask; -} -/** Parse a `Modem3gpp.Scan` reply (`aa{sv}` → dicts) into scanned networks. */ -function parseScan(value: unknown): readonly ScannedNetwork[] { - if (!Array.isArray(value)) { - return []; - } - const networks: ScannedNetwork[] = []; - for (const entry of value as DecodedProps[]) { - const operatorCode = stringProp(entry, 'operator-code'); - if (operatorCode === undefined) { - continue; - } - const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short'); - const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown'; - networks.push({ - operatorCode, - ...(name !== undefined ? { operatorName: name } : {}), - availability, - }); + uninhibit(lease: InhibitLease): Promise { + return uninhibitDevice(this.#context, lease); } - return networks; -} - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); } diff --git a/control/src/backend/mm-mutations/bands.ts b/control/src/backend/mm-mutations/bands.ts new file mode 100644 index 0000000..f7a7ffd --- /dev/null +++ b/control/src/backend/mm-mutations/bands.ts @@ -0,0 +1,64 @@ +import { decodeBandList, encodeBandList, isResetSelection } from '../../band'; +import type { BandReadResult, ModemRef, Receipt } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import { fetchManagedObjects, findInterface, propValue } from '../managed-objects'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +export async function readBands( + context: MmMutationContext, + modem: ModemRef, +): Promise { + try { + const tree = await fetchManagedObjects(context.transport, context.destination); + const props = findInterface(tree, modem, MODEM_IFACE); + if (props === undefined) { + return { ok: false, reason: 'the modem exports no Modem interface' }; + } + return { + ok: true, + bands: { + supported: decodeBandList(propValue(props, 'SupportedBands')), + current: decodeBandList(propValue(props, 'CurrentBands')), + }, + }; + } catch (error) { + return { ok: false, reason: `reading bands failed: ${describeMutationError(error)}` }; + } +} + +export function setCurrentBands( + context: MmMutationContext, + modem: ModemRef, + bands: readonly string[], +): Promise { + if (bands.length === 0) { + return Promise.resolve(receipt('band', 'failed', 'no bands were requested')); + } + const encoded = encodeBandList(bands); + if (!encoded.ok) { + return Promise.resolve( + receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`), + ); + } + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentBands', + signature: 'au', + args: [encoded.values], + }); + return receipt( + 'band', + 'applied', + isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`, + ); + } catch (error) { + return receipt('band', 'failed', `SetCurrentBands failed: ${describeMutationError(error)}`); + } + }); +} diff --git a/control/src/backend/mm-mutations/context.ts b/control/src/backend/mm-mutations/context.ts new file mode 100644 index 0000000..db01fbe --- /dev/null +++ b/control/src/backend/mm-mutations/context.ts @@ -0,0 +1,14 @@ +import type { ModemRef } from '../../ports'; +import type { DbusTransport } from '../../transport'; +import type { ModemActor } from '../modem-actor'; + +export interface MmMutationContext { + readonly transport: DbusTransport; + readonly actor: ModemActor; + readonly destination: string; + readonly resolveStableKey: (modem: ModemRef) => string; +} + +export function describeMutationError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/control/src/backend/mm-mutations/inhibit.ts b/control/src/backend/mm-mutations/inhibit.ts new file mode 100644 index 0000000..140b2ee --- /dev/null +++ b/control/src/backend/mm-mutations/inhibit.ts @@ -0,0 +1,31 @@ +import { epochMillis } from '../../domain'; +import type { InhibitLease } from '../../ports'; +import { MM_MANAGER_IFACE, MM_ROOT_PATH } from '../constants'; +import type { MmMutationContext } from './context'; + +export async function inhibitDevice( + context: MmMutationContext, + uid: string, + now: () => number, +): Promise { + await setInhibited(context, uid, true); + return { uid, acquiredAt: epochMillis(now()) }; +} + +export async function uninhibitDevice( + context: MmMutationContext, + lease: InhibitLease, +): Promise { + await setInhibited(context, lease.uid, false); +} + +function setInhibited(context: MmMutationContext, uid: string, inhibit: boolean): Promise { + return context.transport.callMethod({ + destination: context.destination, + path: MM_ROOT_PATH, + interface: MM_MANAGER_IFACE, + member: 'InhibitDevice', + signature: 'sb', + args: [uid, inhibit], + }); +} diff --git a/control/src/backend/mm-mutations/modes.ts b/control/src/backend/mm-mutations/modes.ts new file mode 100644 index 0000000..e9f2f34 --- /dev/null +++ b/control/src/backend/mm-mutations/modes.ts @@ -0,0 +1,66 @@ +import type { DesiredRadio, RadioAccessTechnology } from '../../domain'; +import type { ModemRef, Receipt } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +const MODE_BIT: Record = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 }; + +export function setRadioModes( + context: MmMutationContext, + modem: ModemRef, + preference: DesiredRadio, +): Promise { + const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered)); + const preferred = preference.preferenceOrdered[0]; + const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0; + if (allowed === 0) { + return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); + } + return setModeMasks(context, modem, allowed, preferredMask, 'radio mode preference applied'); +} + +export function setModeCombination( + context: MmMutationContext, + modem: ModemRef, + allowed: number, + preferred: number, +): Promise { + if (allowed === 0) { + return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); + } + return setModeMasks(context, modem, allowed, preferred, 'radio mode combination applied'); +} + +function setModeMasks( + context: MmMutationContext, + modem: ModemRef, + allowed: number, + preferred: number, + successMessage: string, +): Promise { + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentModes', + signature: '(uu)', + args: [[allowed, preferred]], + }); + return receipt('radio', 'applied', successMessage); + } catch (error) { + return receipt('radio', 'failed', `SetCurrentModes failed: ${describeMutationError(error)}`); + } + }); +} + +function maskOf(rats: ReadonlySet): number { + let mask = 0; + for (const rat of rats) { + mask |= MODE_BIT[rat]; + } + return mask; +} diff --git a/control/src/backend/mm-mutations/scan.ts b/control/src/backend/mm-mutations/scan.ts new file mode 100644 index 0000000..c5666d7 --- /dev/null +++ b/control/src/backend/mm-mutations/scan.ts @@ -0,0 +1,55 @@ +import type { ModemRef, NetworkScanResult, ScannedNetwork } from '../../ports'; +import { MODEM3GPP_IFACE } from '../constants'; +import type { DecodedProps } from '../managed-objects'; +import { numberProp, stringProp } from '../managed-objects'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +const AVAILABILITY: Record = { + 0: 'unknown', + 1: 'available', + 2: 'current', + 3: 'forbidden', +}; + +export function scanNetworks( + context: MmMutationContext, + modem: ModemRef, + timeoutMs: number, +): Promise { + return context.actor.run(context.resolveStableKey(modem), async () => { + try { + const reply = await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM3GPP_IFACE, + member: 'Scan', + timeoutMs, + }); + return { ok: true, networks: parseScan(reply.body[0]) }; + } catch (error) { + return { ok: false, reason: `network scan failed: ${describeMutationError(error)}` }; + } + }); +} + +function parseScan(value: unknown): readonly ScannedNetwork[] { + if (!Array.isArray(value)) { + return []; + } + const networks: ScannedNetwork[] = []; + for (const entry of value as DecodedProps[]) { + const operatorCode = stringProp(entry, 'operator-code'); + if (operatorCode === undefined) { + continue; + } + const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short'); + const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown'; + networks.push({ + operatorCode, + ...(name !== undefined ? { operatorName: name } : {}), + availability, + }); + } + return networks; +} diff --git a/control/src/backend/mm-mutations/sim.ts b/control/src/backend/mm-mutations/sim.ts new file mode 100644 index 0000000..f918ae2 --- /dev/null +++ b/control/src/backend/mm-mutations/sim.ts @@ -0,0 +1,77 @@ +import type { ModemRef, Receipt, SimPukUnlockResult, SimUnlockResult } from '../../ports'; +import { receipt } from '../../ports'; +import { MODEM_IFACE } from '../constants'; +import { fetchManagedObjects, findInterface, propValue } from '../managed-objects'; +import { sendSimPin, sendSimPuk } from '../sim-unlock'; +import type { MmMutationContext } from './context'; +import { describeMutationError } from './context'; + +export async function setPrimarySimSlot( + context: MmMutationContext, + modem: ModemRef, + slotIndex: number, +): Promise { + const slots = await readSlotCount(context, modem); + if (slots === undefined) { + return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list'); + } + if (slots <= 1) { + return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select'); + } + if (slotIndex < 1 || slotIndex > slots) { + return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`); + } + return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => { + try { + await context.transport.callMethod({ + destination: context.destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetPrimarySimSlot', + signature: 'u', + args: [slotIndex], + }); + return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`); + } catch (error) { + return receipt( + 'simSlot', + 'failed', + `SetPrimarySimSlot failed: ${describeMutationError(error)}`, + ); + } + }); +} + +export function unlockWithPin( + context: MmMutationContext, + modem: ModemRef, + pin: string, +): Promise { + return context.actor.run(context.resolveStableKey(modem), () => + sendSimPin(context.transport, context.destination, modem, pin), + ); +} + +export function unlockWithPuk( + context: MmMutationContext, + modem: ModemRef, + puk: string, + newPin: string, +): Promise { + return context.actor.run(context.resolveStableKey(modem), () => + sendSimPuk(context.transport, context.destination, modem, puk, newPin), + ); +} + +async function readSlotCount( + context: MmMutationContext, + modem: ModemRef, +): Promise { + try { + const tree = await fetchManagedObjects(context.transport, context.destination); + const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots'); + return Array.isArray(slots) ? slots.length : undefined; + } catch { + return undefined; + } +} diff --git a/control/src/backend/observer.ts b/control/src/backend/observer.ts index e1df1a0..b2674a4 100644 --- a/control/src/backend/observer.ts +++ b/control/src/backend/observer.ts @@ -22,16 +22,11 @@ import type { Unsubscribe, } from '../ports'; import type { DbusTransport, SignalEvent, Subscription } from '../transport'; -import { - DBUS_DESTINATION, - DBUS_IFACE, - DBUS_PATH, - MM_BUS_NAME, - MM_ROOT_PATH, - OBJECT_MANAGER_IFACE, - PROPERTIES_IFACE, -} from './constants'; -import { asManagedObjects, type DecodedManagedObjects } from './managed-objects'; +import { MM_BUS_NAME } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { subscribeObserverSignals, unsubscribeObserverSignals } from './observer/bus-lifecycle'; +import { queryModemManagerOwner, readAuthoritativeTree } from './observer/epoch-reconcile'; +import { isCurrentOwnerSignal, routeOwnerSignal } from './observer/signal-routing'; import { ObservationRowStore } from './row-store'; /** @@ -121,30 +116,19 @@ export class MmDbusObserver implements ModemObservationPort { this.#stopped = true; this.#transport.off('disconnected', this.#onDisconnected); this.#transport.off('reconnected', this.#onReconnected); - const subs = this.#subscriptions.splice(0); - await Promise.all(subs.map((sub) => sub.unsubscribe().catch(() => undefined))); + const subscriptions = this.#subscriptions.splice(0); + await unsubscribeObserverSignals(subscriptions); this.#listeners.clear(); } // ── signal subscription ──────────────────────────────────────────────────────── async #subscribeAll(): Promise { - const om = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH } as const; this.#subscriptions.push( - await this.#transport.subscribeSignal({ ...om, member: 'InterfacesAdded' }, (event) => - this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal({ ...om, member: 'InterfacesRemoved' }, (event) => - this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal( - { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, - (event) => this.#onObjectSignal(event), - ), - await this.#transport.subscribeSignal( - { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, - (event) => this.#onNameOwnerChanged(event), - ), + ...(await subscribeObserverSignals(this.#transport, { + onObjectSignal: (event) => this.#onObjectSignal(event), + onNameOwnerChanged: (event) => this.#onNameOwnerChanged(event), + })), ); } @@ -161,48 +145,34 @@ export class MmDbusObserver implements ModemObservationPort { } async #queryOwner(): Promise { - try { - const reply = await this.#transport.callMethod({ - destination: DBUS_DESTINATION, - path: DBUS_PATH, - interface: DBUS_IFACE, - member: 'GetNameOwner', - signature: 's', - args: [MM_BUS_NAME], - }); - const owner = reply.body[0]; - return typeof owner === 'string' && owner.length > 0 ? owner : undefined; - } catch { - // NameHasNoOwner (or a transient failure) → no current epoch yet. - return undefined; - } + return queryModemManagerOwner(this.#transport); } #onNameOwnerChanged(event: SignalEvent): void { - if (event.body[0] !== MM_BUS_NAME) { + const routed = routeOwnerSignal(event); + if (routed.kind === 'unrelated') { return; } - const newOwner = typeof event.body[2] === 'string' ? event.body[2] : ''; - if (newOwner.length === 0) { + if (routed.kind === 'lost') { // Owner lost — stale, never a removal. this.#handleSourceGone('source-unavailable'); return; } - if (newOwner === this.#currentOwner) { + if (routed.owner === this.#currentOwner) { return; } // New epoch: everything goes stale until the fresh snapshot restores it. if (this.#store.markUnavailable('source-unavailable')) { this.#emit(); } - this.#currentOwner = newOwner; + this.#currentOwner = routed.owner; this.#scheduleRefresh(); } #onObjectSignal(event: SignalEvent): void { // Epoch guard: a signal from anyone but the current owner is an OLD-epoch // straggler and must never drive a removal (draft §Oracle round-3 #5). - if (this.#currentOwner === undefined || event.sender !== this.#currentOwner) { + if (!isCurrentOwnerSignal(event, this.#currentOwner)) { return; } if (this.#priming) { @@ -229,17 +199,11 @@ export class MmDbusObserver implements ModemObservationPort { async #runRefresh(epochOwner: string): Promise { this.#refreshing = true; try { - const reply = await this.#transport.callMethod({ - destination: this.#destination, - path: MM_ROOT_PATH, - interface: OBJECT_MANAGER_IFACE, - member: 'GetManagedObjects', - }); + const tree = await readAuthoritativeTree(this.#transport, this.#destination); // Late reply from a superseded epoch — discard (draft §Oracle round-3 #5). if (this.#currentOwner !== epochOwner || this.#stopped) { return; } - const tree = asManagedObjects(reply.body[0]); const rowsChanged = this.#store.reconcile(tree); const healthChanged = this.#store.markHealthy(); this.#notifyEpochRefresh(epochOwner, tree); diff --git a/control/src/backend/observer/bus-lifecycle.ts b/control/src/backend/observer/bus-lifecycle.ts new file mode 100644 index 0000000..0e8fa63 --- /dev/null +++ b/control/src/backend/observer/bus-lifecycle.ts @@ -0,0 +1,39 @@ +import type { DbusTransport, SignalEvent, Subscription } from '../../transport'; +import { DBUS_IFACE, MM_ROOT_PATH, OBJECT_MANAGER_IFACE, PROPERTIES_IFACE } from '../constants'; + +export interface ObserverSignalHandlers { + readonly onObjectSignal: (event: SignalEvent) => void; + readonly onNameOwnerChanged: (event: SignalEvent) => void; +} + +export async function subscribeObserverSignals( + transport: DbusTransport, + handlers: ObserverSignalHandlers, +): Promise { + const objectManager = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH } as const; + const interfacesAdded = await transport.subscribeSignal( + { ...objectManager, member: 'InterfacesAdded' }, + handlers.onObjectSignal, + ); + const interfacesRemoved = await transport.subscribeSignal( + { ...objectManager, member: 'InterfacesRemoved' }, + handlers.onObjectSignal, + ); + const propertiesChanged = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, + handlers.onObjectSignal, + ); + const nameOwnerChanged = await transport.subscribeSignal( + { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, + handlers.onNameOwnerChanged, + ); + return [interfacesAdded, interfacesRemoved, propertiesChanged, nameOwnerChanged]; +} + +export async function unsubscribeObserverSignals( + subscriptions: readonly Subscription[], +): Promise { + await Promise.all( + subscriptions.map((subscription) => subscription.unsubscribe().catch(() => undefined)), + ); +} diff --git a/control/src/backend/observer/epoch-reconcile.ts b/control/src/backend/observer/epoch-reconcile.ts new file mode 100644 index 0000000..5b7fb5f --- /dev/null +++ b/control/src/backend/observer/epoch-reconcile.ts @@ -0,0 +1,42 @@ +import type { DbusTransport } from '../../transport'; +import { + DBUS_DESTINATION, + DBUS_IFACE, + DBUS_PATH, + MM_BUS_NAME, + MM_ROOT_PATH, + OBJECT_MANAGER_IFACE, +} from '../constants'; +import { asManagedObjects, type DecodedManagedObjects } from '../managed-objects'; + +export async function queryModemManagerOwner( + transport: DbusTransport, +): Promise { + try { + const reply = await transport.callMethod({ + destination: DBUS_DESTINATION, + path: DBUS_PATH, + interface: DBUS_IFACE, + member: 'GetNameOwner', + signature: 's', + args: [MM_BUS_NAME], + }); + const owner = reply.body[0]; + return typeof owner === 'string' && owner.length > 0 ? owner : undefined; + } catch { + return undefined; + } +} + +export async function readAuthoritativeTree( + transport: DbusTransport, + destination: string, +): Promise { + const reply = await transport.callMethod({ + destination, + path: MM_ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'GetManagedObjects', + }); + return asManagedObjects(reply.body[0]); +} diff --git a/control/src/backend/observer/signal-routing.ts b/control/src/backend/observer/signal-routing.ts new file mode 100644 index 0000000..dd98743 --- /dev/null +++ b/control/src/backend/observer/signal-routing.ts @@ -0,0 +1,22 @@ +import type { SignalEvent } from '../../transport'; +import { MM_BUS_NAME } from '../constants'; + +export type OwnerSignal = + | { readonly kind: 'unrelated' } + | { readonly kind: 'lost' } + | { readonly kind: 'owned'; readonly owner: string }; + +export function routeOwnerSignal(event: SignalEvent): OwnerSignal { + if (event.body[0] !== MM_BUS_NAME) { + return { kind: 'unrelated' }; + } + const newOwner = typeof event.body[2] === 'string' ? event.body[2] : ''; + return newOwner.length === 0 ? { kind: 'lost' } : { kind: 'owned', owner: newOwner }; +} + +export function isCurrentOwnerSignal( + event: SignalEvent, + currentOwner: string | undefined, +): boolean { + return currentOwner !== undefined && event.sender === currentOwner; +} diff --git a/control/src/backend/usage/persistence.ts b/control/src/backend/usage/persistence.ts new file mode 100644 index 0000000..a6dcb61 --- /dev/null +++ b/control/src/backend/usage/persistence.ts @@ -0,0 +1,59 @@ +import type { SlotAccount } from './accounting'; +import type { PersistedSlot, PersistedUsage } from './store'; +import { USAGE_SCHEMA_VERSION } from './store'; + +export function hydrateUsageAccounts( + initial: PersistedUsage, + bootId: string, +): Map { + const accounts = new Map(); + const sameBoot = initial.bootId === bootId; + for (const slot of initial.slots) { + const canResume = + sameBoot && + slot.ifname !== undefined && + slot.mappingGeneration !== undefined && + slot.lastObserved !== undefined; + if (canResume) { + accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + key: { + logicalSlotId: slot.logicalSlotId, + mappingGeneration: slot.mappingGeneration, + ifname: slot.ifname, + bootId, + }, + lastObserved: slot.lastObserved, + }); + } else { + accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + }); + } + } + return accounts; +} + +export function persistedUsageState( + bootId: string, + savedAtMs: number, + accounts: ReadonlyMap, +): PersistedUsage { + const slots: PersistedSlot[] = []; + for (const [logicalSlotId, account] of accounts) { + slots.push({ + logicalSlotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + ...(account.key !== undefined + ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname } + : {}), + ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}), + }); + } + return { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs, slots }; +} diff --git a/control/src/backend/usage/policy.ts b/control/src/backend/usage/policy.ts new file mode 100644 index 0000000..cc6eb0c --- /dev/null +++ b/control/src/backend/usage/policy.ts @@ -0,0 +1,65 @@ +import type { DesiredUsage } from '../../domain'; +import { epochMillis } from '../../domain'; +import type { SlotAccount } from './accounting'; +import { initialAccount } from './accounting'; +import { cycleStart } from './billing-cycle'; +import type { SlotUsageSnapshot, UsageSnapshot } from './sampler'; + +export interface UsagePolicyState { + readonly bootId: string; + readonly defaultCycleDay: number; + readonly accounts: Map; + readonly policies: Map; + readonly policyOverrides: Map; +} + +export interface UsagePolicyApplicationResult { + readonly cycleStartMs: number; + readonly cycleReset: boolean; + readonly dirty: boolean; +} + +export function applyPolicy( + state: UsagePolicyState, + logicalSlotId: string, + usage: DesiredUsage, + now: number, +): UsagePolicyApplicationResult { + state.policyOverrides.set(logicalSlotId, usage); + state.policies.set(logicalSlotId, usage); + const cycleStartMs = cycleStart( + epochMillis(now), + usage.cycleDay ?? state.defaultCycleDay, + ) as number; + const account = state.accounts.get(logicalSlotId); + if (account === undefined) { + state.accounts.set(logicalSlotId, initialAccount(cycleStartMs)); + return { cycleStartMs, cycleReset: false, dirty: true }; + } + if (account.cycleStartMs === cycleStartMs) { + return { cycleStartMs, cycleReset: false, dirty: false }; + } + state.accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs }); + return { cycleStartMs, cycleReset: true, dirty: true }; +} + +export function projectUsageSnapshot( + state: Pick, + generatedAtMs: number, +): UsageSnapshot { + const slots: SlotUsageSnapshot[] = []; + for (const [slotId, account] of state.accounts) { + const policy = state.policies.get(slotId); + const thresholdBytes = policy?.thresholdBytes; + slots.push({ + logicalSlotId: slotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + paused: account.paused, + ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}), + ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), + thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, + }); + } + return { bootId: state.bootId, generatedAtMs, slots }; +} diff --git a/control/src/backend/usage/sampler.ts b/control/src/backend/usage/sampler.ts index 3ef9dad..3537767 100644 --- a/control/src/backend/usage/sampler.ts +++ b/control/src/backend/usage/sampler.ts @@ -11,12 +11,13 @@ // deltas (the window since the last rate-limited write); a clean shutdown calls // `flush()` and loses effectively nothing. -import { type DesiredUsage, epochMillis, type LogicalSlotId } from '../../domain'; -import { applySample, type BaselineKey, initialAccount, type SlotAccount } from './accounting'; -import { cycleStart } from './billing-cycle'; +import type { DesiredUsage, LogicalSlotId } from '../../domain'; +import type { SlotAccount } from './accounting'; +import { hydrateUsageAccounts, persistedUsageState } from './persistence'; +import { applyPolicy, projectUsageSnapshot } from './policy'; import type { CounterSource } from './proc-net-dev'; -import type { PersistedSlot, PersistedUsage, UsageStore } from './store'; -import { USAGE_SCHEMA_VERSION } from './store'; +import { applyUsageSamples } from './sampling'; +import type { PersistedUsage, UsageStore } from './store'; /** One slot's observation for a sampling pass — identity + mapping + local policy. */ export interface UsageObservation { @@ -66,18 +67,6 @@ export interface UsageSamplerOptions { const DEFAULT_PERSIST_INTERVAL_MS = 60_000; const DEFAULT_CYCLE_DAY = 1; -function toPersistedSlot(logicalSlotId: string, account: SlotAccount): PersistedSlot { - return { - logicalSlotId, - cycleBytes: account.cycleBytes, - cycleStartMs: account.cycleStartMs, - ...(account.key !== undefined - ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname } - : {}), - ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}), - }; -} - export class UsageSampler { readonly #bootId: string; readonly #source: CounterSource; @@ -85,7 +74,7 @@ export class UsageSampler { readonly #now: () => number; readonly #persistIntervalMs: number; readonly #defaultCycleDay: number; - readonly #accounts = new Map(); + readonly #accounts: Map; readonly #policies = new Map(); // Policies written through `applyUsagePolicy` OUTRANK whatever an observation // carries, for the life of the process. Without this, the next `sample()` would @@ -105,7 +94,7 @@ export class UsageSampler { this.#persistIntervalMs = options.persistIntervalMs ?? DEFAULT_PERSIST_INTERVAL_MS; this.#defaultCycleDay = options.defaultCycleDay ?? DEFAULT_CYCLE_DAY; this.#lastPersistMs = this.#now(); - this.#hydrate(initial); + this.#accounts = hydrateUsageAccounts(initial, this.#bootId); } /** Load persisted state (recreating a fresh file if absent/corrupt) then build the sampler. */ @@ -115,93 +104,32 @@ export class UsageSampler { return new UsageSampler(options, initial); } - /** Rebuild in-memory accounts. A reboot (differing boot id) drops the baselines. */ - #hydrate(initial: PersistedUsage): void { - const sameBoot = initial.bootId === this.#bootId; - for (const slot of initial.slots) { - const canResume = - sameBoot && - slot.ifname !== undefined && - slot.mappingGeneration !== undefined && - slot.lastObserved !== undefined; - if (canResume) { - const key: BaselineKey = { - logicalSlotId: slot.logicalSlotId, - mappingGeneration: slot.mappingGeneration as number, - ifname: slot.ifname as string, - bootId: this.#bootId, - }; - this.#accounts.set(slot.logicalSlotId, { - cycleBytes: slot.cycleBytes, - cycleStartMs: slot.cycleStartMs, - paused: false, - key, - lastObserved: slot.lastObserved as number, - }); - } else { - this.#accounts.set(slot.logicalSlotId, { - cycleBytes: slot.cycleBytes, - cycleStartMs: slot.cycleStartMs, - paused: false, - }); - } - } - } - /** Take one sampling pass over the current counters for the given observations. */ async sample(observations: readonly UsageObservation[]): Promise { const counters = await this.#source.read(); const now = this.#now(); - for (const obs of observations) { - const slotId = obs.logicalSlotId as string; - const usage = this.#policyOverrides.get(slotId) ?? obs.usage; - this.#policies.set(slotId, usage); - const cycleDay = usage.cycleDay ?? this.#defaultCycleDay; - const cycleStartMs = cycleStart(epochMillis(now), cycleDay); - const current = counters.get(obs.ifname); - if (current === undefined) { - // No reading for this interface — ensure the slot exists, attribute nothing. - if (!this.#accounts.has(slotId)) { - this.#accounts.set(slotId, initialAccount(cycleStartMs)); - } - continue; - } - const key: BaselineKey = { - logicalSlotId: slotId, - mappingGeneration: obs.mappingGeneration, - ifname: obs.ifname, + applyUsageSamples( + { bootId: this.#bootId, - }; - const next = applySample(this.#accounts.get(slotId), { - key, - current, - confidence: obs.confidence, - cycleStartMs, - }); - this.#accounts.set(slotId, next); - } + defaultCycleDay: this.#defaultCycleDay, + accounts: this.#accounts, + policies: this.#policies, + policyOverrides: this.#policyOverrides, + }, + observations, + counters, + now, + ); this.#dirty = true; await this.#maybePersist(now); } /** Current per-slot usage — the queryable snapshot the CLI and platform read. */ snapshot(): UsageSnapshot { - const generatedAtMs = this.#now(); - const slots: SlotUsageSnapshot[] = []; - for (const [slotId, account] of this.#accounts) { - const policy = this.#policies.get(slotId); - const thresholdBytes = policy?.thresholdBytes; - slots.push({ - logicalSlotId: slotId, - cycleBytes: account.cycleBytes, - cycleStartMs: account.cycleStartMs, - paused: account.paused, - ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}), - ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), - thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, - }); - } - return { bootId: this.#bootId, generatedAtMs, slots }; + return projectUsageSnapshot( + { bootId: this.#bootId, accounts: this.#accounts, policies: this.#policies }, + this.#now(), + ); } /** @@ -225,25 +153,20 @@ export class UsageSampler { cycleStartMs: number; cycleReset: boolean; } { - const now = atMs ?? this.#now(); - this.#policyOverrides.set(logicalSlotId, usage); - this.#policies.set(logicalSlotId, usage); - const cycleStartMs = cycleStart( - epochMillis(now), - usage.cycleDay ?? this.#defaultCycleDay, - ) as number; - const account = this.#accounts.get(logicalSlotId); - if (account === undefined) { - this.#accounts.set(logicalSlotId, initialAccount(cycleStartMs)); - this.#dirty = true; - return { cycleStartMs, cycleReset: false }; - } - if (account.cycleStartMs === cycleStartMs) { - return { cycleStartMs, cycleReset: false }; - } - this.#accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs }); - this.#dirty = true; - return { cycleStartMs, cycleReset: true }; + const result = applyPolicy( + { + bootId: this.#bootId, + defaultCycleDay: this.#defaultCycleDay, + accounts: this.#accounts, + policies: this.#policies, + policyOverrides: this.#policyOverrides, + }, + logicalSlotId, + usage, + atMs ?? this.#now(), + ); + this.#dirty ||= result.dirty; + return { cycleStartMs: result.cycleStartMs, cycleReset: result.cycleReset }; } /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */ @@ -260,16 +183,7 @@ export class UsageSampler { } async #persist(now: number): Promise { - const slots: PersistedSlot[] = []; - for (const [slotId, account] of this.#accounts) { - slots.push(toPersistedSlot(slotId, account)); - } - const state: PersistedUsage = { - schemaVersion: USAGE_SCHEMA_VERSION, - bootId: this.#bootId, - savedAtMs: now, - slots, - }; + const state = persistedUsageState(this.#bootId, now, this.#accounts); await this.#store.save(state); this.#lastPersistMs = now; this.#dirty = false; diff --git a/control/src/backend/usage/sampling.ts b/control/src/backend/usage/sampling.ts new file mode 100644 index 0000000..9a66a10 --- /dev/null +++ b/control/src/backend/usage/sampling.ts @@ -0,0 +1,48 @@ +import type { DesiredUsage } from '../../domain'; +import { epochMillis } from '../../domain'; +import { applySample, type BaselineKey, initialAccount, type SlotAccount } from './accounting'; +import { cycleStart } from './billing-cycle'; +import type { UsageObservation } from './sampler'; + +export interface UsageSamplingState { + readonly bootId: string; + readonly defaultCycleDay: number; + readonly accounts: Map; + readonly policies: Map; + readonly policyOverrides: ReadonlyMap; +} + +export function applyUsageSamples( + state: UsageSamplingState, + observations: readonly UsageObservation[], + counters: ReadonlyMap, + now: number, +): void { + for (const observation of observations) { + const slotId = observation.logicalSlotId as string; + const usage = state.policyOverrides.get(slotId) ?? observation.usage; + state.policies.set(slotId, usage); + const cycleDay = usage.cycleDay ?? state.defaultCycleDay; + const cycleStartMs = cycleStart(epochMillis(now), cycleDay); + const current = counters.get(observation.ifname); + if (current === undefined) { + if (!state.accounts.has(slotId)) { + state.accounts.set(slotId, initialAccount(cycleStartMs)); + } + continue; + } + const key: BaselineKey = { + logicalSlotId: slotId, + mappingGeneration: observation.mappingGeneration, + ifname: observation.ifname, + bootId: state.bootId, + }; + const next = applySample(state.accounts.get(slotId), { + key, + current, + confidence: observation.confidence, + cycleStartMs, + }); + state.accounts.set(slotId, next); + } +} diff --git a/control/src/backend/usb-mode-transition.ts b/control/src/backend/usb-mode-transition.ts index 616ad68..48616a7 100644 --- a/control/src/backend/usb-mode-transition.ts +++ b/control/src/backend/usb-mode-transition.ts @@ -22,29 +22,21 @@ import type { DeviceIfname, InhibitLease, ModemManagerPort, NetworkManagerPort } from '../ports'; import { deviceIfname } from '../ports'; -import { - CERTIFIED_CATALOG, - type CertifiedCatalog, - readRuntimeCompositionCurrent, -} from '../usb-mode'; -import { - type AtAuditSink, - AtCommandLease, - type AtCommandSender, - computeAtAllowlist, -} from './at-lease'; -import { descriptorsMatch, detectUsbMode, type UsbDeviceSnapshot } from './device-classifier'; +import { CERTIFIED_CATALOG, type CertifiedCatalog } from '../usb-mode'; +import type { AtAuditSink, AtCommandSender } from './at-lease'; +import type { UsbDeviceSnapshot } from './device-classifier'; import type { ModemActor } from './modem-actor'; import { ALLOW_ALL_TRANSITION_INTERLOCK, - checkTransitionPreconditions, type TransitionInterlock, type UsbModeTransitionOutcome, type UsbModeTransitionPlan, type UsbModeTransitionRequest, } from './transition-preconditions'; - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +import { checkTransitionAdmission, releaseInhibit } from './usb-mode-transition/admission'; +import { createTransitionAtLease } from './usb-mode-transition/at'; +import { transitionPostconditionFailure } from './usb-mode-transition/outcome'; +import { ReenumerationWaiter } from './usb-mode-transition/reenumeration'; const DEFAULT_WATCHDOG_MS = 30_000; const DEFAULT_REENUM_TIMEOUT_MS = 60_000; @@ -87,6 +79,7 @@ export class UsbModeTransition { readonly #watchdogMs: number; readonly #reenumMs: number; readonly #pollMs: number; + readonly #waiter: ReenumerationWaiter; constructor(deps: UsbModeTransitionDeps) { this.#actor = deps.actor; @@ -101,13 +94,14 @@ export class UsbModeTransition { this.#watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS; this.#reenumMs = deps.reenumerationTimeoutMs ?? DEFAULT_REENUM_TIMEOUT_MS; this.#pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.#waiter = new ReenumerationWaiter(this.#enumerate, this.#reenumMs, this.#pollMs); } /** Run one transition. Preconditions are checked at entry, then again in-actor. */ async execute(request: UsbModeTransitionRequest): Promise { const steps: string[] = []; // ENTRY check — a doomed request NEVER enters the actor (TIER A: zero calls). - const entry = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + const entry = await checkTransitionAdmission(request, this.#catalog, this.#interlock); if (!entry.ok) { return { status: 'refused', stage: 'entry', reason: entry.reason, steps }; } @@ -120,7 +114,7 @@ export class UsbModeTransition { ): Promise { steps.push('actor-enter'); // IN-ACTOR re-check — catches a race that closed a gate while queued (TIER B). - const recheck = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + const recheck = await checkTransitionAdmission(request, this.#catalog, this.#interlock); if (!recheck.ok) { return { status: 'refused', stage: 'in-actor', reason: recheck.reason, steps }; } @@ -142,19 +136,20 @@ export class UsbModeTransition { let inhibit: InhibitLease | undefined; let reactivated = false; const forceUninhibit = async (): Promise => { - if (inhibit === undefined) { - return; - } const held = inhibit; inhibit = undefined; - steps.push('force-uninhibit'); - await this.#modemManager.uninhibit(held).catch(() => undefined); + await releaseInhibit(this.#modemManager, held, steps); }; - const lease = new AtCommandLease({ + const lease = createTransitionAtLease({ sender: this.#atSender, - allowlist: computeAtAllowlist(allowlistedCommands), + allowlistedCommands, timeoutMs: this.#watchdogMs, - onWatchdog: forceUninhibit, + modemManager: this.#modemManager, + currentInhibit: () => inhibit, + clearInhibit: () => { + inhibit = undefined; + }, + steps, ...(this.#audit !== undefined ? { audit: this.#audit } : {}), }); @@ -174,7 +169,7 @@ export class UsbModeTransition { } steps.push('await-port-drop'); - await this.#awaitPortDrop(request.cachedPhysicalUid); + await this.#waiter.awaitPortDrop(request.cachedPhysicalUid); steps.push('uninhibit'); if (inhibit !== undefined) { @@ -184,34 +179,18 @@ export class UsbModeTransition { } steps.push('await-reenumeration'); - const device = await this.#awaitReenumeration(request.cachedPhysicalUid); + const device = await this.#waiter.awaitDevice(request.cachedPhysicalUid); steps.push('postcondition'); - if (plan.proof.tier === 'catalog-descriptors') { - const observedMode = detectUsbMode(device); - const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors); - if (observedMode !== plan.proof.transition.to || !descriptorsOk) { - return { - status: 'failed', - degraded: true, - reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`, - steps, - }; - } - } else { - steps.push('postcondition-runtime-read'); - const response = await lease.run(plan.proof.currentQuery, { - inhibitUid: request.inhibitUid, - }); - const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw); - if (!Object.is(observed, plan.proof.target)) { - return { - status: 'failed', - degraded: true, - reason: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`, - steps, - }; - } + const postconditionFailure = await transitionPostconditionFailure( + plan, + device, + lease, + request.inhibitUid, + steps, + ); + if (postconditionFailure !== undefined) { + return { status: 'failed', degraded: true, reason: postconditionFailure, steps }; } steps.push('resolve-ifname'); @@ -226,7 +205,7 @@ export class UsbModeTransition { return { status: 'succeeded', newIfname, steps }; } catch (error) { await forceUninhibit(); - await this.#reprobe(); + await this.#waiter.reprobe(); return { status: 'failed', degraded: true, @@ -243,37 +222,4 @@ export class UsbModeTransition { } } } - - async #awaitPortDrop(uid: string): Promise { - const deadline = Date.now() + this.#reenumMs; - while (Date.now() < deadline) { - const devices = await this.#enumerate(); - if (!devices.some((d) => d.physicalUid === uid)) { - return; - } - await sleep(this.#pollMs); - } - throw new Error(`control port did not drop within ${this.#reenumMs}ms (uid ${uid})`); - } - - async #awaitReenumeration(uid: string): Promise { - const deadline = Date.now() + this.#reenumMs; - while (Date.now() < deadline) { - const devices = await this.#enumerate(); - const device = devices.find((d) => d.physicalUid === uid); - if (device !== undefined) { - return device; - } - await sleep(this.#pollMs); - } - throw new Error(`device did not re-enumerate within ${this.#reenumMs}ms (uid ${uid})`); - } - - /** Best-effort state re-read after a crash — the transaction still fails degraded. */ - async #reprobe(): Promise { - await this.#enumerate().then( - () => undefined, - () => undefined, - ); - } } diff --git a/control/src/backend/usb-mode-transition/admission.ts b/control/src/backend/usb-mode-transition/admission.ts new file mode 100644 index 0000000..5f87672 --- /dev/null +++ b/control/src/backend/usb-mode-transition/admission.ts @@ -0,0 +1,28 @@ +import type { InhibitLease, ModemManagerPort } from '../../ports'; +import type { CertifiedCatalog } from '../../usb-mode'; +import { + checkTransitionPreconditions, + type TransitionInterlock, + type UsbModeTransitionRequest, +} from '../transition-preconditions'; + +export function checkTransitionAdmission( + request: UsbModeTransitionRequest, + catalog: CertifiedCatalog, + interlock: TransitionInterlock, +) { + return checkTransitionPreconditions(request, catalog, interlock); +} + +export async function releaseInhibit( + modemManager: Pick, + lease: InhibitLease | undefined, + steps: string[], +): Promise { + if (lease === undefined) { + return undefined; + } + steps.push('force-uninhibit'); + await modemManager.uninhibit(lease).catch(() => undefined); + return undefined; +} diff --git a/control/src/backend/usb-mode-transition/at.ts b/control/src/backend/usb-mode-transition/at.ts new file mode 100644 index 0000000..78657d6 --- /dev/null +++ b/control/src/backend/usb-mode-transition/at.ts @@ -0,0 +1,33 @@ +import type { InhibitLease, ModemManagerPort } from '../../ports'; +import { + type AtAuditSink, + AtCommandLease, + type AtCommandSender, + computeAtAllowlist, +} from '../at-lease'; +import { releaseInhibit } from './admission'; + +export interface TransitionAtLeaseOptions { + readonly sender: AtCommandSender; + readonly allowlistedCommands: readonly string[]; + readonly timeoutMs: number; + readonly modemManager: Pick; + readonly currentInhibit: () => InhibitLease | undefined; + readonly clearInhibit: () => void; + readonly steps: string[]; + readonly audit?: AtAuditSink; +} + +export function createTransitionAtLease(options: TransitionAtLeaseOptions): AtCommandLease { + return new AtCommandLease({ + sender: options.sender, + allowlist: computeAtAllowlist(options.allowlistedCommands), + timeoutMs: options.timeoutMs, + onWatchdog: async () => { + const held = options.currentInhibit(); + options.clearInhibit(); + await releaseInhibit(options.modemManager, held, options.steps); + }, + ...(options.audit !== undefined ? { audit: options.audit } : {}), + }); +} diff --git a/control/src/backend/usb-mode-transition/outcome.ts b/control/src/backend/usb-mode-transition/outcome.ts new file mode 100644 index 0000000..16c3a4e --- /dev/null +++ b/control/src/backend/usb-mode-transition/outcome.ts @@ -0,0 +1,26 @@ +import { readRuntimeCompositionCurrent } from '../../usb-mode'; +import type { AtCommandLease } from '../at-lease'; +import { descriptorsMatch, detectUsbMode, type UsbDeviceSnapshot } from '../device-classifier'; +import type { UsbModeTransitionPlan } from '../transition-preconditions'; + +export async function transitionPostconditionFailure( + plan: UsbModeTransitionPlan, + device: UsbDeviceSnapshot, + lease: AtCommandLease, + inhibitUid: string, + steps: string[], +): Promise { + if (plan.proof.tier === 'catalog-descriptors') { + const observedMode = detectUsbMode(device); + const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors); + return observedMode === plan.proof.transition.to && descriptorsOk + ? undefined + : `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`; + } + steps.push('postcondition-runtime-read'); + const response = await lease.run(plan.proof.currentQuery, { inhibitUid }); + const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw); + return Object.is(observed, plan.proof.target) + ? undefined + : `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`; +} diff --git a/control/src/backend/usb-mode-transition/reenumeration.ts b/control/src/backend/usb-mode-transition/reenumeration.ts new file mode 100644 index 0000000..c5aa302 --- /dev/null +++ b/control/src/backend/usb-mode-transition/reenumeration.ts @@ -0,0 +1,51 @@ +import type { UsbDeviceSnapshot } from '../device-classifier'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export class ReenumerationWaiter { + readonly #enumerate: () => Promise; + readonly #timeoutMs: number; + readonly #pollMs: number; + + constructor( + enumerate: () => Promise, + timeoutMs: number, + pollMs: number, + ) { + this.#enumerate = enumerate; + this.#timeoutMs = timeoutMs; + this.#pollMs = pollMs; + } + + async awaitPortDrop(uid: string): Promise { + const deadline = Date.now() + this.#timeoutMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + if (!devices.some((device) => device.physicalUid === uid)) { + return; + } + await sleep(this.#pollMs); + } + throw new Error(`control port did not drop within ${this.#timeoutMs}ms (uid ${uid})`); + } + + async awaitDevice(uid: string): Promise { + const deadline = Date.now() + this.#timeoutMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + const device = devices.find((candidate) => candidate.physicalUid === uid); + if (device !== undefined) { + return device; + } + await sleep(this.#pollMs); + } + throw new Error(`device did not re-enumerate within ${this.#timeoutMs}ms (uid ${uid})`); + } + + async reprobe(): Promise { + await this.#enumerate().then( + () => undefined, + () => undefined, + ); + } +} diff --git a/control/src/providers/network-manager/adapter.ts b/control/src/providers/network-manager/adapter.ts index 4127537..576ae49 100644 --- a/control/src/providers/network-manager/adapter.ts +++ b/control/src/providers/network-manager/adapter.ts @@ -20,7 +20,7 @@ // deactivate. There is deliberately no delete path here: profile removal is not in // that set, so this adapter cannot express it. -import type { DeviceGeneration, EpochMillis } from '../../domain'; +import type { DeviceGeneration } from '../../domain'; import { epochMillis } from '../../domain'; import type { AppliedConfiguration, @@ -30,14 +30,7 @@ import type { ObservedState, StateDivergence, } from '../../observations'; -import { - appliedConfiguration, - describeStateDivergence, - desiredProfile, - freshObservation, - observedState, - unavailableObservation, -} from '../../observations'; +import { appliedConfiguration, desiredProfile } from '../../observations'; import type { ConnectionId, DeviceIfname, @@ -46,39 +39,21 @@ import type { Receipt, } from '../../ports'; import { receipt } from '../../ports'; +import { projectDivergence } from './divergence'; +import { foldObservation } from './observe-fold'; +import { projectStateView } from './projection'; +import type { ConnectionSlots } from './state'; +import { slotsFor, targetIfname } from './state'; import type { NmAdapterRefusalReason, - NmAppliedLoss, - NmAppliedOutcome, NmApplyResult, NmBearerState, - NmConnectionOutcome, NmDesiredRequest, NmObservationInput, NmObservationResult, - NmObservedDevice, NmSaveResult, } from './types'; -import { boundBearer, nmBearerStateEquals, unboundBearer } from './types'; - -const SOURCE = 'networkmanager' as const; - -/** The device states in which NM is still settling an activation, not losing it. */ -const TRANSITIONAL_STATES: ReadonlySet = new Set([ - 'prepare', - 'config', - 'need-auth', - 'ip-config', - 'ip-check', - 'secondaries', - 'deactivating', -]); - -interface ConnectionSlots { - desired: DesiredProfile | null; - applied: AppliedConfiguration | null; - observed: ObservedState | null; -} +import { boundBearer, unboundBearer } from './types'; export interface NetworkManagerAdapterOptions { readonly port: NetworkManagerPort; @@ -143,7 +118,7 @@ export class NetworkManagerAdapter { epochMillis(this.#now()), request.requestedBy, ); - this.#slotsFor(id).desired = desired; + slotsFor(this.#slots, id).desired = desired; return { ok: true, connectionId: id, desired }; } @@ -165,7 +140,7 @@ export class NetworkManagerAdapter { }; } const desired = desiredProfile(unboundBearer(ifname), epochMillis(this.#now()), requestedBy); - this.#slotsFor(id).desired = desired; + slotsFor(this.#slots, id).desired = desired; return { ok: true, connectionId: id, desired }; } @@ -224,44 +199,9 @@ export class NetworkManagerAdapter { * was. */ observe(input: NmObservationInput): NmObservationResult { - const generation = input.context.generation; - if (this.#observedGeneration !== null && generation < this.#observedGeneration) { - return { - kind: 'refused', - reason: 'superseded-generation', - currentGeneration: this.#observedGeneration, - }; - } - this.#observedGeneration = generation; - const devices = new Map( - input.devices.map((device) => [device.ifname, device]), - ); - const outcomes: NmConnectionOutcome[] = []; - const losses: NmAppliedLoss[] = []; - for (const [id, slots] of this.#slots) { - const ifname = targetIfname(slots); - if (ifname === undefined) { - continue; - } - const device = devices.get(ifname); - slots.observed = observedState( - device === undefined - ? unavailableObservation(SOURCE, input.context, 'device-absent') - : freshObservation(SOURCE, input.context, observedBearer(device)), - ); - const outcome = this.#classify(id, slots, ifname, device, input.context.observedAt); - outcomes.push({ connectionId: id, outcome }); - if (outcome.status === 'lost') { - losses.push(outcome.loss); - } - } - return { - kind: 'accepted', - generation, - observedAt: input.context.observedAt, - outcomes, - losses, - }; + const folded = foldObservation(this.#slots, this.#observedGeneration, input); + this.#observedGeneration = folded.generation; + return folded.result; } observedFor(id: ConnectionId): ObservedState | null { @@ -277,25 +217,12 @@ export class NetworkManagerAdapter { id: ConnectionId, context: NormalizationContext, ): ModemStateView | null { - const slots = this.#slots.get(id); - if (slots === undefined) { - return null; - } - return { - desired: slots.desired, - applied: slots.applied, - observed: - slots.observed ?? - observedState( - unavailableObservation(SOURCE, context, 'provider-unavailable'), - ), - }; + return projectStateView(this.#slots, id, context); } /** `desiredVsApplied` ("did our write happen") and `appliedVsObserved` ("did it stick"). */ divergence(id: ConnectionId, context: NormalizationContext): StateDivergence | null { - const view = this.stateView(id, context); - return view === null ? null : describeStateDivergence(view, nmBearerStateEquals); + return projectDivergence(this.#slots, id, context); } // ── internals ────────────────────────────────────────────────────────────── @@ -334,7 +261,7 @@ export class NetworkManagerAdapter { generation: options.generation, operationId: options.operationId, }); - this.#slotsFor(id).applied = applied; + slotsFor(this.#slots, id).applied = applied; return { ok: true, applied, receipt: activation }; } @@ -358,92 +285,9 @@ export class NetworkManagerAdapter { generation: options.generation, operationId: options.operationId, }); - this.#slotsFor(id).applied = applied; + slotsFor(this.#slots, id).applied = applied; return { ok: true, applied, receipt: result }; } - - #classify( - id: ConnectionId, - slots: ConnectionSlots, - ifname: DeviceIfname, - device: NmObservedDevice | undefined, - observedAt: EpochMillis, - ): NmAppliedOutcome { - const applied = slots.applied; - if (applied === null) { - return { status: 'unapplied' }; - } - const lose = (reason: NmAppliedLoss['reason']): NmAppliedOutcome => { - slots.applied = null; - return { - status: 'lost', - loss: { - connectionId: id, - deviceIfname: ifname, - reason, - lostAt: observedAt, - generation: applied.generation, - previous: applied, - }, - }; - }; - if (device === undefined) { - return lose('interface-absent'); - } - if (device.state === 'failed') { - return lose('activation-failed'); - } - if (applied.configuration.kind === 'unbound') { - // We deliberately took the bearer down; anything active here is somebody else. - return device.activeConnection === undefined - ? { status: 'retained', applied } - : lose('connection-replaced'); - } - if (device.activeConnection === undefined) { - return lose('interface-detached'); - } - if (device.activeConnection.connectionId !== id) { - return lose('connection-replaced'); - } - if (device.state === 'activated') { - return { status: 'retained', applied }; - } - return TRANSITIONAL_STATES.has(device.state) - ? { status: 'pending', applied, deviceState: device.state } - : lose('interface-detached'); - } - - #slotsFor(id: ConnectionId): ConnectionSlots { - const existing = this.#slots.get(id); - if (existing !== undefined) { - return existing; - } - const created: ConnectionSlots = { desired: null, applied: null, observed: null }; - this.#slots.set(id, created); - return created; - } -} - -/** The device the slots are about: where the bearer IS, else where it was asked for. */ -function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined { - const state = slots.applied?.configuration ?? slots.desired?.profile; - if (state === undefined) { - return undefined; - } - return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname; -} - -function observedBearer(device: NmObservedDevice): NmBearerState { - const active = device.activeConnection; - return active === undefined - ? unboundBearer(device.ifname) - : boundBearer({ - connectionId: active.connectionId, - deviceIfname: device.ifname, - apn: active.apn, - autoConfig: active.autoConfig, - homeOnly: active.homeOnly, - }); } function refuse(reason: NmAdapterRefusalReason, message: string): NmApplyResult { diff --git a/control/src/providers/network-manager/divergence.ts b/control/src/providers/network-manager/divergence.ts new file mode 100644 index 0000000..6726dda --- /dev/null +++ b/control/src/providers/network-manager/divergence.ts @@ -0,0 +1,15 @@ +import type { NormalizationContext, StateDivergence } from '../../observations'; +import { describeStateDivergence } from '../../observations'; +import type { ConnectionId } from '../../ports'; +import { projectStateView } from './projection'; +import type { ConnectionSlots } from './state'; +import { nmBearerStateEquals } from './types'; + +export function projectDivergence( + slotsByConnection: ReadonlyMap, + id: ConnectionId, + context: NormalizationContext, +): StateDivergence | null { + const view = projectStateView(slotsByConnection, id, context); + return view === null ? null : describeStateDivergence(view, nmBearerStateEquals); +} diff --git a/control/src/providers/network-manager/observe-fold.ts b/control/src/providers/network-manager/observe-fold.ts new file mode 100644 index 0000000..a1f79d6 --- /dev/null +++ b/control/src/providers/network-manager/observe-fold.ts @@ -0,0 +1,144 @@ +import type { DeviceGeneration, EpochMillis } from '../../domain'; +import { freshObservation, observedState, unavailableObservation } from '../../observations'; +import type { ConnectionId, DeviceIfname } from '../../ports'; +import type { ConnectionSlots } from './state'; +import { targetIfname } from './state'; +import type { + NmAppliedLoss, + NmAppliedOutcome, + NmBearerState, + NmConnectionOutcome, + NmObservationInput, + NmObservationResult, + NmObservedDevice, +} from './types'; +import { boundBearer, unboundBearer } from './types'; + +const SOURCE = 'networkmanager' as const; +const TRANSITIONAL_STATES: ReadonlySet = new Set([ + 'prepare', + 'config', + 'need-auth', + 'ip-config', + 'ip-check', + 'secondaries', + 'deactivating', +]); + +export interface ObservationFold { + readonly generation: DeviceGeneration; + readonly result: NmObservationResult; +} + +export function foldObservation( + slotsByConnection: Map, + observedGeneration: DeviceGeneration | null, + input: NmObservationInput, +): ObservationFold { + const generation = input.context.generation; + if (observedGeneration !== null && generation < observedGeneration) { + return { + generation: observedGeneration, + result: { + kind: 'refused', + reason: 'superseded-generation', + currentGeneration: observedGeneration, + }, + }; + } + const devices = new Map( + input.devices.map((device) => [device.ifname, device]), + ); + const outcomes: NmConnectionOutcome[] = []; + const losses: NmAppliedLoss[] = []; + for (const [id, slots] of slotsByConnection) { + const ifname = targetIfname(slots); + if (ifname === undefined) { + continue; + } + const device = devices.get(ifname); + slots.observed = observedState( + device === undefined + ? unavailableObservation(SOURCE, input.context, 'device-absent') + : freshObservation(SOURCE, input.context, observedBearer(device)), + ); + const outcome = classifyApplied(id, slots, ifname, device, input.context.observedAt); + outcomes.push({ connectionId: id, outcome }); + if (outcome.status === 'lost') { + losses.push(outcome.loss); + } + } + return { + generation, + result: { + kind: 'accepted', + generation, + observedAt: input.context.observedAt, + outcomes, + losses, + }, + }; +} + +function classifyApplied( + id: ConnectionId, + slots: ConnectionSlots, + ifname: DeviceIfname, + device: NmObservedDevice | undefined, + observedAt: EpochMillis, +): NmAppliedOutcome { + const applied = slots.applied; + if (applied === null) { + return { status: 'unapplied' }; + } + const lose = (reason: NmAppliedLoss['reason']): NmAppliedOutcome => { + slots.applied = null; + return { + status: 'lost', + loss: { + connectionId: id, + deviceIfname: ifname, + reason, + lostAt: observedAt, + generation: applied.generation, + previous: applied, + }, + }; + }; + if (device === undefined) { + return lose('interface-absent'); + } + if (device.state === 'failed') { + return lose('activation-failed'); + } + if (applied.configuration.kind === 'unbound') { + return device.activeConnection === undefined + ? { status: 'retained', applied } + : lose('connection-replaced'); + } + if (device.activeConnection === undefined) { + return lose('interface-detached'); + } + if (device.activeConnection.connectionId !== id) { + return lose('connection-replaced'); + } + if (device.state === 'activated') { + return { status: 'retained', applied }; + } + return TRANSITIONAL_STATES.has(device.state) + ? { status: 'pending', applied, deviceState: device.state } + : lose('interface-detached'); +} + +function observedBearer(device: NmObservedDevice): NmBearerState { + const active = device.activeConnection; + return active === undefined + ? unboundBearer(device.ifname) + : boundBearer({ + connectionId: active.connectionId, + deviceIfname: device.ifname, + apn: active.apn, + autoConfig: active.autoConfig, + homeOnly: active.homeOnly, + }); +} diff --git a/control/src/providers/network-manager/projection.ts b/control/src/providers/network-manager/projection.ts new file mode 100644 index 0000000..141f8e2 --- /dev/null +++ b/control/src/providers/network-manager/projection.ts @@ -0,0 +1,25 @@ +import type { ModemStateView, NormalizationContext } from '../../observations'; +import { observedState, unavailableObservation } from '../../observations'; +import type { ConnectionId } from '../../ports'; +import type { ConnectionSlots } from './state'; +import type { NmBearerState } from './types'; + +const SOURCE = 'networkmanager' as const; + +export function projectStateView( + slotsByConnection: ReadonlyMap, + id: ConnectionId, + context: NormalizationContext, +): ModemStateView | null { + const slots = slotsByConnection.get(id); + if (slots === undefined) { + return null; + } + return { + desired: slots.desired, + applied: slots.applied, + observed: + slots.observed ?? + observedState(unavailableObservation(SOURCE, context, 'provider-unavailable')), + }; +} diff --git a/control/src/providers/network-manager/state.ts b/control/src/providers/network-manager/state.ts new file mode 100644 index 0000000..7d382c9 --- /dev/null +++ b/control/src/providers/network-manager/state.ts @@ -0,0 +1,30 @@ +import type { AppliedConfiguration, DesiredProfile, ObservedState } from '../../observations'; +import type { ConnectionId, DeviceIfname } from '../../ports'; +import type { NmBearerState } from './types'; + +export interface ConnectionSlots { + desired: DesiredProfile | null; + applied: AppliedConfiguration | null; + observed: ObservedState | null; +} + +export function slotsFor( + slotsByConnection: Map, + id: ConnectionId, +): ConnectionSlots { + const existing = slotsByConnection.get(id); + if (existing !== undefined) { + return existing; + } + const created: ConnectionSlots = { desired: null, applied: null, observed: null }; + slotsByConnection.set(id, created); + return created; +} + +export function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined { + const state = slots.applied?.configuration ?? slots.desired?.profile; + if (state === undefined) { + return undefined; + } + return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname; +} From cac7e73a7df9549bdd86e25b8ba68ba1ba1a128e Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:43:10 -0500 Subject: [PATCH 11/19] docs(adr): stay on TypeScript; modem-metrics idea provenance --- AGENTS.md | 8 ++ README.md | 6 ++ docs/adr/ADR-STAY-TYPESCRIPT.md | 185 ++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 docs/adr/ADR-STAY-TYPESCRIPT.md diff --git a/AGENTS.md b/AGENTS.md index c8b66c0..67affbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1362,6 +1362,14 @@ rationale, source cites, and the open gates are recorded in `docs/FM350-DECISION ## WORKSPACE / TOOLCHAIN +**The language is a decided question, not a default.** A Rust migration (a `zbus` daemon +fronted by a thin TS client, the `srtla-send-rs` shape) was assessed and **rejected by the +project owner on 2026-08-24**. TypeScript on Bun stays, and the decision is final until one +of the named revisit triggers fires. That record also carries the idea-attribution for +`irlserver/modem-metrics`: MIT-licensed, concepts adopted, **no source code copied**. Read it +before proposing a rewrite or extending the telemetry surface: +[`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + - **Bun 1.4.0** (`.bun-version`, `packageManager` in `package.json`). `control/` + `cli/` are Bun workspace members. - **Strict TypeScript 7.0.2** incl. `exactOptionalPropertyTypes` — the repo-root diff --git a/README.md b/README.md index 9705408..132efe6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,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 ``` @@ -64,6 +65,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 diff --git a/docs/adr/ADR-STAY-TYPESCRIPT.md b/docs/adr/ADR-STAY-TYPESCRIPT.md new file mode 100644 index 0000000..cf3d2d9 --- /dev/null +++ b/docs/adr/ADR-STAY-TYPESCRIPT.md @@ -0,0 +1,185 @@ +# ADR — Stay on TypeScript: no Rust migration for the modem control stack + +**Status:** ACCEPTED. The project owner reviewed the Rust migration option and rejected it on +2026-08-24. The decision is final until one of the named revisit triggers in §6 fires; it is +not a deferral and carries no scheduled re-evaluation. +**Date:** 2026-08-24 +**Deciders:** CeraLive project owner (decision); modem-stack maintainers (analysis and +recommendation). +**Supersedes:** nothing. **Amends:** nothing. It records the language decision that +[`AGENTS.md`](../../AGENTS.md) § WORKSPACE / TOOLCHAIN previously stated only as a fact +("Bun 1.4.0, strict TypeScript 7.0.2") with no rationale behind it. + +--- + +## 1. What this ADR is for + +Two questions arrived together while planning the `modem-stack-quality-compat` effort. The +first: should the ModemManager control path be rewritten in Rust, following the +`srtla-send-rs` precedent of a Rust core with a thin TypeScript client over a versioned +JSON-RPC socket? The second: what may this repository take from `irlserver/modem-metrics`, +an MIT-licensed Rust project covering some of the same ground? + +Both were answered in the same review, and both answers need to outlive the effort that +produced them. Without this record the next person reading `control/src/providers/` sees a +pure-JS D-Bus stack with no explanation of why it is not Rust, and sees telemetry fields +whose shape was clearly informed by somebody else's work with no statement of what was and +was not taken. This document is that explanation. + +--- + +## 2. Decision + +**No Rust migration.** `@ceralive/modem-control` and the `modem-control` bench CLI stay +TypeScript on Bun. No Rust daemon, no `zbus` core, no napi addon, no bounded prototype spike, +and no Rust source file enters this repository under this decision. + +The alternative that was on the table and is now closed: a Rust ModemManager daemon built on +hand-generated `zbus` proxies, fronted by a thin TypeScript client over a versioned JSON-RPC +Unix socket. It was assessed as a genuine reliability and ownership improvement in the +abstract. It was rejected because the specific improvement it offers is not the improvement +this stack needs, and the cost of getting it is paid in exactly the semantics that are +hardest to re-prove. + +--- + +## 3. Rationale + +### 3.1 The quality asymmetry runs the other way + +The comparison that prompted the question is `irlserver/modem-metrics` (read at commit +`bf15066`), roughly 5,300 lines of Rust: a `zbus` ModemManager provider, a serial AT +provider, NETGEAR and Huawei HTTP providers, a GPS crate, an interface-metrics module, and an +NDJSON probe CLI. + +It is a metrics project, and it is measurably narrower than this one on every axis that makes +modem control dangerous: + +| Property | `irlserver/modem-metrics` | `modem-stack` | +|---|---|---| +| Control operations | none; it reads and reports | descriptor-gated read and write surface | +| Safety model | none | mutation admission, exclusive ownership, durable journal, armed rollback, required readback | +| Refusal vocabulary | none; errors are strings | typed refusal reasons across every provider | +| Redaction | none | key-based redaction classes for PII, credentials, SMS bodies, coordinates, USSD text | +| Hardware-integration tests | none | bench runbooks RB-1..RB-17 plus a private-session-bus integration suite | +| Test surface | none found | ~190 test files, including grep gates that fail the build on forbidden constructs | + +Rust would not have produced any of that, and TypeScript did not prevent any of it. The +memory-safety and concurrency guarantees a Rust rewrite buys are real, but nothing in this +stack's defect history points at them: the fences that keep a modem from being stranded are +architectural, and they are already built. Rewriting them in a second language would put +every one of those guarantees back on the "unproven again" list to buy a class of safety this +codebase has not been failing on. + +### 3.2 No mature ModemManager Rust binding exists + +`zbus` 5.19 is the standard D-Bus crate and it is solid, but there is no maintained Rust +binding for ModemManager 1.24's interface surface. The realistic path is hand-generated +proxies via `zbus_xmlgen`, which means this repository would own and maintain the generated +binding for every MM interface it touches, and re-own it on every MM bump. That is a +permanent maintenance liability accepted in exchange for a benefit §3.1 already found to be +misaimed. + +### 3.3 Migrating proven semantics is where the regressions live + +The behaviours that would have to be re-implemented and re-proven are precisely the subtle +ones: epoch-scoped observer reconnection and row retention across daemon loss, device +generation fencing on async completion, the `unknown-outcome` uncertainty fence and its +durable journal counterpart, per-modem actor serialization with quiesce leases, the +one-bounded-attempt authentication rules in three router providers, and the redaction classes +that keep a voucher code or a coordinate out of a log line. + +Each of those is currently pinned by tests that would not survive a language change intact. +A rewrite does not carry a test suite across; it carries the intent and re-expresses it, and +every re-expression is a chance to lose a distinction that took a hardware drill to find. The +`preferred: none` case, the `absent` versus `unknown` SIM split, and the `armed` versus +`executing` journal mapping are three examples where the right answer is one line different +from a plausible wrong one. + +### 3.4 Pure-JS D-Bus is adequate under Bun + +The performance argument for Rust does not apply to this workload. The control path is +event-driven and low-rate: property changes, signal subscriptions, and a handful of method +calls per modem per minute. It is not a hot loop, not a codec, and not a packet path. The +typed pure-JavaScript D-Bus transport under Bun handles it with headroom, and the production +provider already spawns no subprocess at all (`forbidden-subprocess.test.ts` proves no path +can reach `mmcli`, `qmicli`, or `mbimcli`). + +`srtla-send-rs` is not a counter-precedent. That component sits in the media path, where +per-packet cost and jitter are the product. This one does not. + +--- + +## 4. What this decision does NOT do + +- It does not claim Rust is the wrong language in general, or that the daemon shape assessed + here is a bad design. It says this stack does not need it. +- It does not forbid Rust elsewhere in CeraLive. `srtla-send-rs` is unaffected. +- It does not authorize adopting any code from `irlserver/modem-metrics`. See §5. +- It does not schedule a re-evaluation. There is no "revisit in six months" clause here, and + a future change may not cite this document as evidence that a migration was planned. + +--- + +## 5. Attribution: `irlserver/modem-metrics` + +`irlserver/modem-metrics` (`github.com/irlserver/modem-metrics`, read at commit `bf15066`) is +**MIT-licensed**. Its license permits code reuse. **No source code from it was copied into +this repository, in any form, at any point.** No file, function, type, constant table, or +test fixture originates there. What was taken is a small set of CONCEPTS, re-expressed from +scratch against this package's own contracts: + +- **Signal telemetry breadth.** The idea that a directly-managed ModemManager modem should + report RF detail well beyond a quality percentage: RSRP, RSRQ, SINR, cell identity, tracking + area, operator identity, and serving band. This repository had that breadth only on the + router providers; the concept is what prompted extending it to the MM path. The + implementation is our own, through `ObservationEnvelope` with per-metric provenance and the + four-state `readMetric` vocabulary, neither of which exists in their model. +- **Counter-reset-aware rates.** The idea that a byte counter running backwards means the + counter was reset, so the correct response is to emit no rate for that interval and + rebaseline, rather than to publish a negative or wildly large figure. Applied in + `control/src/backend/usage/sampler.ts` under our existing window and anchor rules. +- **Availability framing.** The idea that "is this source reachable" and "is this source + healthy" are separate questions that must not collapse into one status. This reinforced the + existing separation between an unavailable observation and a fresh observation whose metrics + are unknown with a reason. + +Several parts of their design were examined and deliberately NOT adopted: the raw serial AT +provider (it competes with ModemManager for the port), shallow AT response parsing, an +unversioned NDJSON output contract, URL-based device identity, first-bearer-only byte +counters, and treating an untyped extras bag as part of the contract. + +Credit for the ideas above belongs to that project's author. Responsibility for everything in +this repository, including the way those ideas are expressed here, belongs to CeraLive. + +--- + +## 6. Revisit triggers + +The decision in §2 stands until one of these specific conditions is met. Each is a fact +somebody can check, not a judgement call: + +| # | Trigger | Why it would reopen the question | +|---|---|---| +| 1 | A maintained, third-party-owned Rust binding for ModemManager's current interface surface exists, with a release history and more than one maintainer. | It removes §3.2 entirely: the generated-proxy maintenance liability disappears. | +| 2 | A defect class attributable to the runtime, not the architecture, is measured on a board: a memory-safety fault, a data race, or a GC pause that misses a control deadline. | It would be the first evidence that §3.1's "misaimed benefit" reading is wrong. | +| 3 | The control path acquires a sustained high-rate workload, for example continuous per-packet or per-frame processing rather than event-driven property changes. | It invalidates §3.4's adequacy argument on its own terms. | +| 4 | Bun or the pure-JS D-Bus transport stops being viable for this workload: an upstream deprecation, an unfixed protocol defect, or a loss of maintenance. | The current runtime choice would no longer be available, so the comparison restarts from scratch. | +| 5 | The project owner reverses the decision. | It was an owner decision; it is theirs to change. | + +A trigger firing reopens the QUESTION. It does not pre-approve a migration, and it does not +make any part of §3 obsolete on its own. + +--- + +## 7. Evidence index + +| Source | What it carries | +|--------|-----------------| +| `github.com/irlserver/modem-metrics` @ `bf15066` | The Rust metrics project surveyed in §3.1 and credited in §5: workspace layout, provider set, absence of control operations, absence of tests, MIT license text. | +| `github.com/dbus2/zbus` | The D-Bus crate assessed in §3.2 (5.19 at review time), including `zbus_xmlgen` as the proxy-generation path. | +| `github.com/linux-mobile-broadband/ModemManager` | Read at tag `1.24.2` for the interface surface a hand-generated binding would have to cover. | +| [`AGENTS.md`](../../AGENTS.md) § WORKSPACE / TOOLCHAIN | The toolchain this decision preserves: Bun 1.4.0, strict TypeScript 7.0.2, Biome. | +| [`AGENTS.md`](../../AGENTS.md) §§ safety model | The admission, ownership, journal, rollback, readback and redaction fences §3.1 and §3.3 describe. | +| `control/src/providers/modem-manager/forbidden-subprocess.test.ts` | The proof cited in §3.4 that the production MM path spawns no CLI. | +| `srtla-send-rs/docs/adr/ADR-001-control-protocol.md` (workspace sibling) | The Rust-core-plus-thin-TS-client precedent §3.4 distinguishes this stack from. | From c411d76cba73e116db511f69dc9c8e15dfa2889d Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 16:50:19 -0500 Subject: [PATCH 12/19] feat: Sierra classifier/FCC-coverage/fixtures + RB-18 bench capture --- AGENTS.md | 26 ++++- README.md | 8 ++ control/src/backend/device-classifier.test.ts | 41 ++++++++ control/src/backend/device-classifier.ts | 44 +++++++++ control/src/fcc/coverage.test.ts | 26 ++++- control/src/fcc/coverage.ts | 8 ++ docs/BENCH.md | 96 +++++++++++++++++++ docs/FCC-UNLOCK-COVERAGE.md | 4 + 8 files changed, 247 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67affbb..ef6f302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -519,10 +519,25 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse nothing: the SIMCom's PID→composition mapping is unproven so its target modes stay UNCERTIFIED and HIDDEN, and the FM350 gains **no** classifier entry for its `0e8d:7127` carrier id — `docs/FM350-DECISION.md` is unchanged. -- The full bench-runbook ladder, RB-1 through RB-17, lives in `docs/BENCH.md`: RB-9 is the +- The full bench-runbook ladder, RB-1 through RB-18, lives in `docs/BENCH.md`: RB-9 is the fleet-inventory capture (one identity bundle per acquired physical unit), RB-10 is the hub VBUS port-cycle verification backing the PowerHook above, RB-11..15/17 are the - per-SKU/flap-resilience captures documented above, RB-16 is the FM350 probe. + per-SKU/flap-resilience captures documented above, RB-16 is the FM350 probe, and RB-18 is + the Sierra identity/composition capture. Its 2026-08-25 run is an honest + `device-not-present` skip: no Sierra VID was attached, `certify` was not invoked, and no + bundle or certification claim exists. + +### Sierra classifier groundwork — exact evidence, never a support claim + +`backend/device-classifier.ts` carries exact application-mode Sierra family rows for +EM74xx, EM75xx, and EM919x-class devices, including Sierra's `1199` VID and the HP/Dell +rebrands represented in ModemManager's pinned FCC mapping. Rows are evidence-tiered: +`modemmanager-1.24.2-fcc` means the VID:PID occurs in the pinned release's available-tier +mapping; `mainline-kernel` means Linux's qmi_wwan/qcserial tables name that family. A row can +label a known family but cannot make a device `mm-managed` — live control-interface/driver +evidence remains the only authority for that class, and an unknown Sierra PID remains +unknown. Every classifier fixture is stamped `synthetic: true`, so it cannot cross the +catalog-promotion gate. ## USB-COMPOSITION SWITCH — RUNTIME OFFER, TIERED PROOF @@ -963,6 +978,13 @@ that record on every boot. The unlocking is ModemManager's dispatcher's job, sta to finish. Full model, matrix and certification status: [`docs/FCC-UNLOCK-COVERAGE.md`](docs/FCC-UNLOCK-COVERAGE.md). +The coverage mirror names its exact provenance in `MM_FCC_UNLOCK_SOURCE`: ModemManager +1.24.2 commit `f2b9ab1ad78d322f32134a444b5b54c6e8160e19`, +`data/dispatcher-fcc-unlock/meson.build`, installed into the inert +`fcc-unlock.available.d` tier. Its four Sierra entries are `03f0:4e1d`, `1199:9079`, +`413c:81a3`, and `413c:81a8`; another well-formed Sierra PID is positively `absent`, not +guessed covered. This classifier-side mirror does not create or own any packaging link. + - **`:` is the ONLY correct key.** `mm-dispatcher-fcc-unlock.c` builds exactly `g_strdup_printf("%04x:%04x", vid, pid)` and opens no other name, so a vendor-only file is never a dispatcher target — it exists only as what the diff --git a/README.md b/README.md index 132efe6..e4ea5b8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ 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 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, diff --git a/control/src/backend/device-classifier.test.ts b/control/src/backend/device-classifier.test.ts index 00cfdea..568d042 100644 --- a/control/src/backend/device-classifier.test.ts +++ b/control/src/backend/device-classifier.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test'; import { + cellularModelEvidence, classifyDevice, classifyUsbNetDevice, detectUsbMode, @@ -13,6 +14,18 @@ import { vendorLabel, } from './device-classifier'; +const SIERRA_SYNTHETIC_FIXTURES = [ + ['1199', '9071', 'EM74xx', 'mainline-kernel'], + ['1199', '9079', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['1199', '907b', 'EM74xx', 'mainline-kernel'], + ['03f0', '4e1d', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['413c', '81a3', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['413c', '81a8', 'EM74xx', 'modemmanager-1.24.2-fcc'], + ['1199', '9091', 'EM75xx', 'mainline-kernel'], + ['1199', 'c081', 'EM75xx', 'mainline-kernel'], + ['1199', '90d3', 'EM919x', 'mainline-kernel'], +] as const satisfies readonly (readonly [string, string, string, string])[]; + /** A Quectel-style QMI composition: a `qmi_wwan` control port (+ AT serials). */ const QMI: UsbDeviceSnapshot = { vendorId: '2c7c', @@ -175,6 +188,34 @@ describe('classifyDevice — honesty guards', () => { }); }); +describe('Sierra model evidence — exact VID:PID rows only', () => { + for (const [vendorId, productId, family, evidenceTier] of SIERRA_SYNTHETIC_FIXTURES) { + test(`Given synthetic ${vendorId}:${productId}, When model evidence is resolved, Then it names ${family} at its evidence tier`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + family, + evidenceTier, + vendor: 'Sierra Wireless', + }); + }); + } + + test('Given an unlisted Sierra PID, When model evidence is resolved, Then it stays unknown', () => { + const fixture = { + snapshot: { vendorId: '1199', productId: 'ffff', bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toBeUndefined(); + }); +}); + describe('CeraUI USB-net classification parity', () => { test('requires positive cellular evidence before naming a tether cellular', () => { const plainNic: UsbDeviceSnapshot = { diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts index 554cdc8..8cbaac2 100644 --- a/control/src/backend/device-classifier.ts +++ b/control/src/backend/device-classifier.ts @@ -58,6 +58,44 @@ const ECM_NCM_DRIVERS: ReadonlySet = new Set(['cdc_ether', 'cdc_ncm']); const RNDIS_DRIVERS: ReadonlySet = new Set(['rndis_host']); const STORAGE_DRIVERS: ReadonlySet = new Set(['usb-storage', 'uas']); +export type CellularModelEvidenceTier = 'modemmanager-1.24.2-fcc' | 'mainline-kernel'; + +export interface CellularModelEvidence { + readonly vendor: string; + readonly family: string; + readonly evidenceTier: CellularModelEvidenceTier; +} + +/** + * Exact model-family evidence. This table may label a known VID:PID, but it never + * decides whether the device is MM-managed: live interface/driver evidence below + * remains authoritative. The pinned ModemManager FCC map is the stronger tier; + * remaining application-mode PIDs come from Linux qmi_wwan/qcserial device tables. + */ +export const CELLULAR_USB_MODEL_ROWS: ReadonlyMap = new Map([ + [ + '03f0:4e1d', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + ['1199:9071', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }], + [ + '1199:9079', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + ['1199:907b', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }], + ['1199:9091', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }], + ['1199:90d3', { vendor: 'Sierra Wireless', family: 'EM919x', evidenceTier: 'mainline-kernel' }], + ['1199:c081', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }], + [ + '413c:81a3', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], + [ + '413c:81a8', + { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, + ], +]); + export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ ['05c6', 'Qualcomm'], ['0af0', 'Option'], @@ -202,6 +240,12 @@ export function cellularVendorName(vendorId: string): string | undefined { return CELLULAR_USB_VENDOR_IDS.get(vendorId.toLowerCase()); } +export function cellularModelEvidence( + device: Pick, +): CellularModelEvidence | undefined { + return CELLULAR_USB_MODEL_ROWS.get(`${device.vendorId}:${device.productId}`.toLowerCase()); +} + export function cellularEvidence(device: UsbDeviceSnapshot): string | undefined { const vendor = cellularVendorName(device.vendorId); if (vendor !== undefined) diff --git a/control/src/fcc/coverage.test.ts b/control/src/fcc/coverage.test.ts index e49a1aa..6f4910d 100644 --- a/control/src/fcc/coverage.test.ts +++ b/control/src/fcc/coverage.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'bun:test'; import { fccUnlockRuntimeBinary, - fccUnlockVendorScript, isFccUnlockKey, MM_FCC_UNLOCK_COVERAGE, + MM_FCC_UNLOCK_SOURCE, MM_FCC_UNLOCK_VENDOR_SCRIPTS, normalizeVidPid, resolveFccUnlockCoverage, @@ -33,9 +33,23 @@ describe('the ModemManager 1.24.2 coverage catalog', () => { // The whole reason the key is :: one silicon vendor, three USB vendor // ids. A vendor-keyed rule would miss the HP and Dell rebrands entirely. it('Given Sierra silicon under three vendor ids, When resolved, Then all three map to the 1199 script', () => { - expect(fccUnlockVendorScript('1199:9079')).toBe('1199'); - expect(fccUnlockVendorScript('03f0:4e1d')).toBe('1199'); - expect(fccUnlockVendorScript('413c:81a3')).toBe('1199'); + expect( + Object.entries(MM_FCC_UNLOCK_COVERAGE).filter(([, script]) => script === '1199'), + ).toEqual([ + ['03f0:4e1d', '1199'], + ['1199:9079', '1199'], + ['413c:81a3', '1199'], + ['413c:81a8', '1199'], + ]); + }); + + it('Given the coverage mirror, When provenance is inspected, Then it names the pinned MM source and available-tier mapping', () => { + expect(MM_FCC_UNLOCK_SOURCE).toEqual({ + version: '1.24.2', + commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + path: 'data/dispatcher-fcc-unlock/meson.build', + installedTier: 'fcc-unlock.available.d', + }); }); it('Given a covered key, When its interpreter is asked, Then it names the packaged binary', () => { @@ -82,6 +96,10 @@ describe('resolveFccUnlockCoverage — three answers, none interchangeable', () expect(resolveFccUnlockCoverage('14c3', '4d75')).toBe('present'); }); + it('Given an unlisted but well-formed Sierra PID, When coverage is resolved, Then it is absent rather than guessed present', () => { + expect(resolveFccUnlockCoverage('1199', '90d3')).toBe('absent'); + }); + // A statement about the READ is not a statement about the DEVICE. Folding this // into `absent` would hide the module on hardware that may well be covered. it.each([ diff --git a/control/src/fcc/coverage.ts b/control/src/fcc/coverage.ts index 4b3542d..e9dc55a 100644 --- a/control/src/fcc/coverage.ts +++ b/control/src/fcc/coverage.ts @@ -28,6 +28,14 @@ export const MM_FCC_UNLOCK_VENDOR_SCRIPTS = { export type MmFccUnlockVendorScript = keyof typeof MM_FCC_UNLOCK_VENDOR_SCRIPTS; +/** Provenance for the exact available-tier mapping mirrored below. */ +export const MM_FCC_UNLOCK_SOURCE = { + version: '1.24.2', + commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + path: 'data/dispatcher-fcc-unlock/meson.build', + installedTier: 'fcc-unlock.available.d', +} as const; + /** The interpreters those scripts invoke, and the packages that provide them. */ export const MM_FCC_UNLOCK_RUNTIME_PACKAGES = { qmicli: 'libqmi-utils', diff --git a/docs/BENCH.md b/docs/BENCH.md index 34c917d..c960c90 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -1577,6 +1577,101 @@ guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed o --- +## RB-18 — Sierra identity + composition capture `[PARTIAL]` + +Capture a real Sierra module's redacted stage-1 identity bundle and its observed USB +composition. This is groundwork only: it certifies no SKU, adds no catalog entry, performs +no FCC unlock, and sends no AT command. + +**Preconditions** + +- A Sierra module physically attached under Sierra's own VID `1199`, HP's `03f0`, or Dell's + `413c`, and visible in `mmcli -L`. +- The compiled `modem-control` binary and the existing `certify` prerequisites from the + shared RB-11…RB-15 contract. + +**Commands** + +```sh +OUT=test-results/modem-control/RB-18; mkdir -p "$OUT" + +# 1) Presence gate. An absent device is a named SKIP and certify is not invoked. +SIERRA_USB=$(lsusb | grep -Ei ' ID (1199|03f0|413c):' || true) +if [ -z "$SIERRA_USB" ]; then + { + echo 'runbook: RB-18' + echo 'outcome: SKIP' + echo 'reason: device-not-present' + echo 'certify_invoked: false' + echo 'bundle: none' + } | tee "$OUT/status.txt" + exit 0 +fi + +# 2) Resolve the Sierra MM slot from a fresh inventory; never reuse a recorded index. +mmcli -L | tee "$OUT/mmcli-list.txt" +SLOT=${SIERRA_MM_SLOT:?set from the Sierra row in the fresh mmcli listing} + +# 3) Existing redacted identity capture. No --transition and no AT/FCC action. +./modem-control certify "$SLOT" --output "$OUT/bundle.json" \ + 2>&1 | tee "$OUT/certify.txt" + +# 4) Record the descriptors and bound drivers that determined the composition. +usb-devices | tee "$OUT/usb-devices.txt" +{ + echo 'runbook: RB-18' + echo 'outcome: EXECUTED' + echo 'reason: none' + echo 'certify_invoked: true' + echo 'bundle: bundle.json' +} | tee "$OUT/status.txt" +``` + +**Expected output** + +With the module attached, `certify` ends with the real-capture gate: + +``` +CERTIFY OK: sha256=<64 hex> synthetic=false transition=none slot= +``` + +Without it, `status.txt` names the skip rather than simulating a bundle: + +``` +outcome: SKIP +reason: device-not-present +certify_invoked: false +bundle: none +``` + +**Machine check** + +```sh +OUT=test-results/modem-control/RB-18 +if grep -Fqx 'outcome: EXECUTED' "$OUT/status.txt"; then + grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/certify.txt" \ + && grep -Eq '"vidPid": *"(1199|03f0|413c):[0-9a-f]{4}"' "$OUT/bundle.json" \ + && echo 'RB-18 PASS' || echo 'RB-18 FAIL' +elif grep -Fqx 'outcome: SKIP' "$OUT/status.txt" \ + && grep -Fqx 'reason: device-not-present' "$OUT/status.txt" \ + && grep -Fqx 'certify_invoked: false' "$OUT/status.txt"; then + echo 'RB-18 SKIP reason=device-not-present' +else + echo 'RB-18 FAIL (neither executed bundle nor named skip)' +fi +``` + +**Status (2026-08-25):** `[PARTIAL]` — `SKIP`, reason `device-not-present`. The local USB +inventory contained no `1199:*`, `03f0:*`, or `413c:*` device, so `certify` was not invoked +and no bundle exists. This is a recorded hardware absence, not a passing capture. + +**Evidence:** `test-results/modem-control/RB-18/status.txt` (repo-local, gitignored); on an +executed run the same directory also carries `{mmcli-list,certify,usb-devices}.txt` and +`bundle.json`. + +--- + ## Evidence index | Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | @@ -1598,6 +1693,7 @@ guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed o | RB-15 | ZTE MF79U router-mode capture | `[PARTIAL]` | `test-results/modem-phase-b/08/zte-mf79u/{class-evidence.txt,lan-dhcp.txt,web-ui.txt}` | | RB-16 | Fibocom FM350 USB-vs-PCIe probe | `[PARTIAL]` | `test-results/modem-phase-b/09/{usb-sweep,driver-binding,pcie-sweep,mmcli-list,mm-version,bearer-connect,hil-cycle-fm350}.txt` | | RB-17 | Modem-flap resilience under a live bonded stream | `[PARTIAL]` | `test-results/modem-phase-b/08/flap/{baseline.txt,flap-x5.txt,final-groups.txt,hub-map.json}` | +| RB-18 | Sierra identity + composition capture | `[PARTIAL]` — `SKIP: device-not-present` | `RB-18/status.txt` (+ `{mmcli-list,certify,usb-devices}.txt`, `bundle.json` when executed) | Every row stays `[PARTIAL]` until its evidence artifact is captured on a real bench device and its machine check prints `PASS`. No row may be claimed `[EXISTS]` on the strength of the diff --git a/docs/FCC-UNLOCK-COVERAGE.md b/docs/FCC-UNLOCK-COVERAGE.md index 1675ffb..d952797 100644 --- a/docs/FCC-UNLOCK-COVERAGE.md +++ b/docs/FCC-UNLOCK-COVERAGE.md @@ -42,6 +42,9 @@ opened by the dispatcher; it is only ever the *target* of a `:` link. `data/dispatcher-fcc-unlock/` in ModemManager 1.24.2 contains four real scripts, one per vendor, and the build installs a `:` symlink onto the right one for every model the vendor script covers (`meson.build`, the `vidpids` dict). +The classifier mirror pins that source as upstream commit +`f2b9ab1ad78d322f32134a444b5b54c6e8160e19` in `MM_FCC_UNLOCK_SOURCE`; its test asserts +all 14 entries and all four Sierra-branded keys exactly. | Vendor script | Talks to the modem via | |---|---| @@ -126,6 +129,7 @@ the CeraLive toggle. | Quectel EM120R-GL | `2c7c:030a` | ✅ | | Quectel EM060K-GL | `2c7c:0314` | ✅ | | Sierra Wireless EM7455 / MC7455 | `1199:9079` | ✅ | +| Sierra Wireless EM9191 | `1199:90d3` | ❌ — known classifier family, no MM 1.24.2 FCC procedure | | Dell DW5811e / DW5816e | `413c:81a3` / `413c:81a8` | ✅ | | HP lt4120 / lt4132 | `03f0:4e1d` | ✅ | | Foxconn T77W968 / T99W175 | `105b:e0ab` / `105b:e0c3` | ✅ | From 34e44f50868a55a5b7462e62a467aaac7add4dc5 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:07:44 -0500 Subject: [PATCH 13/19] feat: Telit/u-blox/NETGEAR classifier + sourced vendor quirk table --- AGENTS.md | 44 ++++++ README.md | 10 ++ control/src/backend/device-classifier.test.ts | 141 +++++++++++++++++ control/src/backend/device-classifier.ts | 79 +++++++++- docs/VENDOR-QUIRKS.md | 147 ++++++++++++++++++ 5 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 docs/VENDOR-QUIRKS.md diff --git a/AGENTS.md b/AGENTS.md index ef6f302..b899618 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -539,6 +539,50 @@ evidence remains the only authority for that class, and an unknown Sierra PID re unknown. Every classifier fixture is stamped `synthetic: true`, so it cannot cross the catalog-promotion gate. +### Telit / u-blox / NETGEAR groundwork — and the mixed-VID trap + +The same table carries ten exact Telit (`1bc7`) rows, six u-blox (`1546`) LARA-R6/LARA-L6 +rows, and ONE NETGEAR (`0846`) row. `CELLULAR_MODEL_EVIDENCE_SOURCES` now pins each tier's +provenance so a reviewer can re-derive any row; a test asserts every tier a row uses has a +pin. **No provider was added for any of these vendors** — this is classifier and doc +groundwork only. + +- **A third evidence tier exists: `usb-ids-registry`,** the weakest of the three. It is used + ONLY where no kernel modem driver claims the id at all — which is itself the evidence + that the device is a router appliance rather than a controllable module. The NETGEAR + LB1120 (`0846:68e1`) is the sole row at that tier. +- **`CellularModelEvidence.familyKind` is an OPTIONAL, positive `router-webui` claim, and + its ABSENCE IS NOT A CLAIM.** Silence does not mean "modem module"; it means nothing here + asserts otherwise — the same tri-state discipline `fcc/coverage.ts` uses for `unknown`. A + test pins that exactly one row carries it. It still decides no device class: NETGEAR + classifies `router-mode` because its interfaces say so, and a test proves the class is + unchanged when the same composition is presented under an unlisted PID. +- **`0846` is deliberately ABSENT from `CELLULAR_USB_VENDOR_IDS`, and that absence is + load-bearing.** The USB ID Repository's `0846` block is dominated by NETGEAR Wi-Fi and + Ethernet adapters — `68e1` is the only cellular entry — so a vendor-keyed rule would + report a Wi-Fi dongle as a cellular uplink. `1546` has the same shape (u-blox GNSS + receivers share it with cellular modules) but predates this work and stays; treat a + vendor-only match on it as WEAK evidence. `1bc7` IS added, because that range is Telit + cellular modules end to end. Consequence worth knowing: the LB1120 tether currently + reads `wired-ethernet` from `classifyUsbNetDevice`, which is honest — a known gap, not a + guess. + +### `docs/VENDOR-QUIRKS.md` — sourced, and capped at `implemented` + +[`docs/VENDOR-QUIRKS.md`](docs/VENDOR-QUIRKS.md) records the per-vendor behaviours that make +one module behave differently from another on Linux, with a citation for every claim +(pinned to MM `1.24.2`, a named Linux commit, `usb.ids` `2026.06.26`, OpenWrt's `qmi.sh`, +`usb-modeswitch-data`, and the BELABOX tutorial wiki). Two properties are the whole point: + +- **No row sits above `implemented` on the five-state ladder, and none may.** `capable` + needs a live probe and `certified` needs a hardware drill; a document can produce + neither. Rows for surfaces this repo ships no code for read `unavailable`, which is + BELOW `implemented`, not above it. +- **Nothing in it is on a write path, and nothing in it may be put on one.** A quirk is a + description of somebody else's firmware. It may inform a classifier LABEL, a diagnostic + READ, or a doc — never an AT command, a QMI/MBIM write, a composition switch, or a band + lock. An unsourced operator report is recorded AS unsourced and claims nothing. + ## USB-COMPOSITION SWITCH — RUNTIME OFFER, TIERED PROOF The ModemManager provider's `usbComposition` operation derives its targets from the diff --git a/README.md b/README.md index e4ea5b8..e8aa5ea 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,16 @@ complete ModemManager 1.24.2 available-tier mapping and does not install or acti 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. + 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, diff --git a/control/src/backend/device-classifier.test.ts b/control/src/backend/device-classifier.test.ts index 568d042..8049cb5 100644 --- a/control/src/backend/device-classifier.test.ts +++ b/control/src/backend/device-classifier.test.ts @@ -4,7 +4,10 @@ import { describe, expect, test } from 'bun:test'; import { + CELLULAR_MODEL_EVIDENCE_SOURCES, + CELLULAR_USB_MODEL_ROWS, cellularModelEvidence, + cellularVendorName, classifyDevice, classifyUsbNetDevice, detectUsbMode, @@ -216,6 +219,144 @@ describe('Sierra model evidence — exact VID:PID rows only', () => { }); }); +const MODULE_SYNTHETIC_FIXTURES = [ + ['1bc7', '1031', 'Telit', 'LE910C1-EUX'], + ['1bc7', '1034', 'Telit', 'LE910C4-WWX'], + ['1bc7', '1040', 'Telit', 'LE922A'], + ['1bc7', '1050', 'Telit', 'FN980'], + ['1bc7', '1060', 'Telit', 'LN920'], + ['1bc7', '1070', 'Telit', 'FN990A'], + ['1bc7', '1080', 'Telit', 'FE990A'], + ['1bc7', '10a0', 'Telit', 'FN920C04'], + ['1bc7', '1100', 'Telit', 'ME910'], + ['1bc7', '1200', 'Telit', 'LE920'], + ['1546', '1311', 'u-blox', 'LARA-R6'], + ['1546', '1312', 'u-blox', 'LARA-R6'], + ['1546', '1313', 'u-blox', 'LARA-R6'], + ['1546', '1341', 'u-blox', 'LARA-L6'], + ['1546', '1342', 'u-blox', 'LARA-L6'], + ['1546', '1343', 'u-blox', 'LARA-L6'], +] as const satisfies readonly (readonly [string, string, string, string])[]; + +const UNLISTED_SYNTHETIC_FIXTURES = [ + ['1bc7', 'ffff'], + ['1546', 'ffff'], + ['0846', '9052'], +] as const satisfies readonly (readonly [string, string])[]; + +/** + * NETGEAR's LB1120 as it actually enumerates: an RNDIS tether and nothing else. No + * kernel modem driver claims `0846:68e1` and ModemManager 1.24.2 ships no NETGEAR + * plugin, so there is no control port for one to bind — the vendor web UI is the + * whole control surface. + */ +const NETGEAR_ROUTER_WEBUI: UsbDeviceSnapshot = { + vendorId: '0846', + productId: '68e1', + bDeviceClass: 0x00, + interfaces: [ + { interfaceClass: 0xe0, interfaceSubClass: 0x01, interfaceProtocol: 0x03 }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x00 }, + ], +}; + +describe('Telit / u-blox model evidence — exact VID:PID rows only', () => { + for (const [vendorId, productId, vendor, family] of MODULE_SYNTHETIC_FIXTURES) { + test(`Given synthetic ${vendorId}:${productId}, When model evidence is resolved, Then it names ${family} from the pinned kernel tables`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + vendor, + family, + evidenceTier: 'mainline-kernel', + }); + }); + } + + for (const [vendorId, productId] of UNLISTED_SYNTHETIC_FIXTURES) { + test(`Given an unlisted ${vendorId}:${productId}, When model evidence is resolved, Then it stays unknown rather than inferring the vendor range`, () => { + const fixture = { + snapshot: { vendorId, productId, bDeviceClass: 0, interfaces: [] }, + provenance: { synthetic: true }, + } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toBeUndefined(); + }); + } +}); + +describe('NETGEAR 0846 — a router/WebUI family, never an MM-managed modem', () => { + test('Given the LB1120 row, When model evidence is resolved, Then it is labelled router-webui from the USB ID Repository', () => { + const fixture = { snapshot: NETGEAR_ROUTER_WEBUI, provenance: { synthetic: true } } as const; + + expect(fixture.provenance.synthetic).toBe(true); + expect(cellularModelEvidence(fixture.snapshot)).toEqual({ + vendor: 'NETGEAR', + family: 'LB1120', + evidenceTier: 'usb-ids-registry', + familyKind: 'router-webui', + }); + }); + + test('Given the LB1120 composition, When it is classified, Then it is router-mode and never mm-managed', () => { + const result = classifyDevice(NETGEAR_ROUTER_WEBUI); + + expect(result.deviceClass).toBe('router-mode'); + expect(result.deviceClass).not.toBe('mm-managed'); + expect(detectUsbMode(NETGEAR_ROUTER_WEBUI)).toBe('rndis'); + }); + + test('Given the same composition under an unlisted PID, When it is classified, Then the class is unchanged — interfaces decide it, not the family row', () => { + const unlisted: UsbDeviceSnapshot = { ...NETGEAR_ROUTER_WEBUI, productId: '9052' }; + + expect(cellularModelEvidence(unlisted)).toBeUndefined(); + expect(classifyDevice(unlisted).deviceClass).toBe( + classifyDevice(NETGEAR_ROUTER_WEBUI).deviceClass, + ); + }); + + test('Given a Telit module row on a bare tether, When it is classified, Then a modem-module label still cannot promote it to mm-managed', () => { + const telitTether: UsbDeviceSnapshot = { + ...NETGEAR_ROUTER_WEBUI, + vendorId: '1bc7', + productId: '1070', + }; + + expect(cellularModelEvidence(telitTether)?.family).toBe('FN990A'); + expect(classifyDevice(telitTether).deviceClass).toBe('router-mode'); + }); + + test('Given NETGEAR 0846, When the vendor range is consulted, Then it is NOT a cellular vendor id — the range carries Wi-Fi and Ethernet adapters too', () => { + expect(cellularVendorName('0846')).toBeUndefined(); + expect(cellularVendorName('1bc7')).toBe('Telit'); + }); + + test('Given the LB1120 tether, When it is USB-net classified, Then it reads wired-ethernet — there is no positive cellular evidence to claim', () => { + expect(classifyUsbNetDevice(NETGEAR_ROUTER_WEBUI).deviceClass).toBe('wired-ethernet'); + }); +}); + +describe('model-evidence provenance', () => { + test('Given every row in the table, When its tier is looked up, Then a pinned source exists for it', () => { + for (const evidence of CELLULAR_USB_MODEL_ROWS.values()) { + expect(CELLULAR_MODEL_EVIDENCE_SOURCES[evidence.evidenceTier].pin).not.toBe(''); + } + }); + + test('Given the whole table, When router-webui claims are counted, Then only rows with that evidence carry one — absence is not a modem-module claim', () => { + const routerRows = [...CELLULAR_USB_MODEL_ROWS.entries()].filter( + ([, evidence]) => evidence.familyKind === 'router-webui', + ); + + expect(routerRows.map(([key]) => key)).toEqual(['0846:68e1']); + }); +}); + describe('CeraUI USB-net classification parity', () => { test('requires positive cellular evidence before naming a tether cellular', () => { const plainNic: UsbDeviceSnapshot = { diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts index 8cbaac2..8f27de6 100644 --- a/control/src/backend/device-classifier.ts +++ b/control/src/backend/device-classifier.ts @@ -58,19 +58,59 @@ const ECM_NCM_DRIVERS: ReadonlySet = new Set(['cdc_ether', 'cdc_ncm']); const RNDIS_DRIVERS: ReadonlySet = new Set(['rndis_host']); const STORAGE_DRIVERS: ReadonlySet = new Set(['usb-storage', 'uas']); -export type CellularModelEvidenceTier = 'modemmanager-1.24.2-fcc' | 'mainline-kernel'; +export type CellularModelEvidenceTier = + | 'modemmanager-1.24.2-fcc' + | 'mainline-kernel' + | 'usb-ids-registry'; + +/** + * A POSITIVE, sourced claim that a labelled family is a router appliance whose only + * control surface is a vendor web UI — no ModemManager plugin, no kernel modem driver + * claiming its application PID. ABSENCE OF THIS FIELD IS NOT A CLAIM: it does not say + * a family is a modem module, only that nothing here asserts otherwise. Same tri-state + * discipline as `fcc/coverage.ts` — `unknown` is never folded into `absent`. + * + * It also does not, and must not, decide a DEVICE CLASS. `classifyDevice` reads live + * interfaces and bound drivers; this label is model-family evidence sitting beside it. + */ +export type CellularModelFamilyKind = 'router-webui'; export interface CellularModelEvidence { readonly vendor: string; readonly family: string; readonly evidenceTier: CellularModelEvidenceTier; + readonly familyKind?: CellularModelFamilyKind; } +/** Where each evidence tier's rows were read from, so a reviewer can re-check them. */ +export const CELLULAR_MODEL_EVIDENCE_SOURCES = { + 'modemmanager-1.24.2-fcc': { + source: 'ModemManager 1.24.2 data/dispatcher-fcc-unlock/meson.build', + pin: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19', + }, + 'mainline-kernel': { + source: 'Linux drivers/net/usb/qmi_wwan.c + drivers/usb/serial/option.c', + pin: '45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229', + }, + 'usb-ids-registry': { + source: 'The USB ID Repository (linux-usb.org/usb.ids)', + pin: '2026.06.26', + }, +} as const satisfies Record; + /** * Exact model-family evidence. This table may label a known VID:PID, but it never * decides whether the device is MM-managed: live interface/driver evidence below - * remains authoritative. The pinned ModemManager FCC map is the stronger tier; - * remaining application-mode PIDs come from Linux qmi_wwan/qcserial device tables. + * remains authoritative. The pinned ModemManager FCC map is the strongest tier; + * `mainline-kernel` rows are application-mode PIDs named by Linux's own qmi_wwan / + * qcserial / option device tables; `usb-ids-registry` is the weakest and is used only + * where NO kernel modem driver claims the id at all — which is itself the evidence + * that the device is a router appliance rather than a controllable module. + * + * EVERY ROW IS AN EXACT VID:PID. A vendor range is never inferred, and that is not a + * stylistic preference: NETGEAR's `0846` and u-blox's `1546` both carry non-cellular + * products (Wi-Fi/Ethernet adapters and GNSS receivers respectively), so a + * vendor-keyed rule on either would label hardware that has no radio. */ export const CELLULAR_USB_MODEL_ROWS: ReadonlyMap = new Map([ [ @@ -94,8 +134,40 @@ export const CELLULAR_USB_MODEL_ROWS: ReadonlyMap '413c:81a8', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' }, ], + ['1bc7:1031', { vendor: 'Telit', family: 'LE910C1-EUX', evidenceTier: 'mainline-kernel' }], + ['1bc7:1034', { vendor: 'Telit', family: 'LE910C4-WWX', evidenceTier: 'mainline-kernel' }], + ['1bc7:1040', { vendor: 'Telit', family: 'LE922A', evidenceTier: 'mainline-kernel' }], + ['1bc7:1050', { vendor: 'Telit', family: 'FN980', evidenceTier: 'mainline-kernel' }], + ['1bc7:1060', { vendor: 'Telit', family: 'LN920', evidenceTier: 'mainline-kernel' }], + ['1bc7:1070', { vendor: 'Telit', family: 'FN990A', evidenceTier: 'mainline-kernel' }], + ['1bc7:1080', { vendor: 'Telit', family: 'FE990A', evidenceTier: 'mainline-kernel' }], + ['1bc7:10a0', { vendor: 'Telit', family: 'FN920C04', evidenceTier: 'mainline-kernel' }], + ['1bc7:1100', { vendor: 'Telit', family: 'ME910', evidenceTier: 'mainline-kernel' }], + ['1bc7:1200', { vendor: 'Telit', family: 'LE920', evidenceTier: 'mainline-kernel' }], + ['1546:1311', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1312', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1313', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }], + ['1546:1341', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + ['1546:1342', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + ['1546:1343', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }], + [ + '0846:68e1', + { + vendor: 'NETGEAR', + family: 'LB1120', + evidenceTier: 'usb-ids-registry', + familyKind: 'router-webui', + }, + ], ]); +/** + * Vendor ids whose ENTIRE USB range is cellular, so the vendor id alone is honest + * evidence of a radio. Membership is a claim about the whole range, which is why + * NETGEAR's `0846` is deliberately ABSENT despite having a row above: the USB ID + * Repository lists that vendor's Wi-Fi and Ethernet adapters under it, so a + * vendor-keyed rule there would report a Wi-Fi dongle as a cellular uplink. + */ export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ ['05c6', 'Qualcomm'], ['0af0', 'Option'], @@ -104,6 +176,7 @@ export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ ['1546', 'u-blox'], ['19d2', 'ZTE'], ['1bbb', 'TCL/Alcatel'], + ['1bc7', 'Telit'], ['1c9e', 'Longcheer'], ['1e0e', 'SIMCom'], ['2c7c', 'Quectel'], diff --git a/docs/VENDOR-QUIRKS.md b/docs/VENDOR-QUIRKS.md new file mode 100644 index 0000000..2bbb0f7 --- /dev/null +++ b/docs/VENDOR-QUIRKS.md @@ -0,0 +1,147 @@ +# Vendor quirks — a sourced reading list, not a support matrix + +This document records the per-vendor behaviours that make one cellular module behave +differently from another on Linux, **with a citation for every claim**. It exists so that +the next person to write a provider, a classifier row, or a compatibility matrix starts +from evidence instead of from folklore. + +It is deliberately NOT a support matrix. Nothing here says CeraLive works with a device. + +## How to read the "posture" column + +Every row carries a CeraLive posture drawn from the five-state support-claim ladder in +[`control/src/capability/support-claim.ts`](../control/src/capability/support-claim.ts): + +``` +unavailable → implemented → enabled → capable → certified +``` + +**No row in this document may sit above `implemented`, and none does.** `capable` requires +a live probe against a specific modem; `certified` requires a proven drill on an exact +model and firmware. A document cannot produce either — only hardware can, and only through +the per-SKU capture runbooks in [`BENCH.md`](BENCH.md). Reading a quirk write-up is not +evidence about a device, so a doc that promoted itself past `implemented` would be +manufacturing exactly the false confidence the ladder exists to prevent. + +The two rungs that appear below mean: + +- **`unavailable`** — CeraLive ships no code for this at all. That is the honest answer for + every vendor-specific AT surface named here; the vendor's own behaviour is documented so + a future change starts from a source, not so anything is claimed today. +- **`implemented`** — code exists in this repository, gate OFF, unproven on the device. + +## The one rule that governs this whole file + +**Nothing here is on a write path, and nothing here may be put on one.** + +A quirk is a description of how somebody else's firmware behaves. Turning a description +into an automatic corrective action means writing to a radio on the strength of a document, +which is precisely what the evidence gates in +[`CATALOG-INGESTION.md`](CATALOG-INGESTION.md) and +[`control/src/band/certification.ts`](../control/src/band/certification.ts) refuse. A row +below may inform a classifier LABEL, a diagnostic READ, or a doc. It may never select an AT +command, a QMI/MBIM write, a composition switch, or a band lock. + +## Sources, pinned + +| Key | Source | Pin | +|-----|--------|-----| +| MM | ModemManager `src/plugins/` — [gitlab.freedesktop.org/mobile-broadband/ModemManager](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/tree/1.24.2/src/plugins) | tag `1.24.2` (the release [`packaging/upstream-pins.yaml`](../packaging/upstream-pins.yaml) rebuilds) | +| KERNEL | Linux `drivers/net/usb/qmi_wwan.c`, `drivers/usb/serial/option.c` — [github.com/torvalds/linux](https://github.com/torvalds/linux/blob/master/drivers/net/usb/qmi_wwan.c) | `45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229` | +| USBIDS | The USB ID Repository — [linux-usb.org/usb.ids](http://www.linux-usb.org/usb.ids) | version `2026.06.26` | +| UQMI | OpenWrt `uqmi` protocol handler `qmi.sh` — [github.com/openwrt/openwrt](https://github.com/openwrt/openwrt/blob/main/package/network/utils/uqmi/files/lib/netifd/proto/qmi.sh) | branch `main` | +| MODESWITCH | `usb-modeswitch-data` device data — upstream [draisberghof.de/usb_modeswitch](https://www.draisberghof.de/usb_modeswitch/), file listing read from the [Distrotech mirror](https://github.com/Distrotech/usb-modeswitch-data/tree/master/usb_modeswitch.d) | 297 device files | +| BELABOX | BELABOX tutorial wiki — [Peripherals & modems](https://github.com/BELABOX/tutorial/wiki/Peripherals,-accessories-and-power-banks), [M.2 initial setup](https://github.com/BELABOX/tutorial/wiki/Initial-setup-steps-for-various-M.2-modem-module-models), [README](https://github.com/BELABOX/tutorial) | wiki head | + +--- + +## Quectel (`2c7c`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **QMI data format is raw-IP, not 802.3, on modern modules.** The bearer will not carry traffic until the driver is told, and the switch is a `/sys` write on the netdev, not a QMI message. | UQMI — `qmi.sh` writes `Y` to `/sys/class/net/$ifname/qmi/raw_ip` and fails with `"Device only supports raw-ip mode but is missing this required driver attribute"` when the attribute is absent. | `implemented` — the device path is NetworkManager's; CeraLive owns no data-format write. | +| **AT is tunnelled over MBIM, through the firmware-update service.** On an MBIM composition there is no separate AT tty; MM reaches AT by wrapping it in a Quectel vendor CID. | MM — `quectel/mm-port-mbim-quectel.c` sends `MBIM_QUECTEL_COMMAND_TYPE_AT` inside `MBIM_SERVICE_QDU` / `MBIM_CID_QDU_COMMAND`, and probes `mm_port_mbim_supports_command` first. Copyright Quectel, 2024. | `unavailable` — CeraLive sends no AT over MBIM. | +| **Port roles are per-PID, not per-vendor.** Which `ttyUSB` is AT, which is GPS and which is QCDM differs between an EG06 and an EG91; MM ships a udev rule per product id. | MM — `quectel/77-mm-quectel-port-types.rules` enumerates `ATTRS{idProduct}` one model at a time (`0306`, `0191`, …), never a vendor-wide default. | `implemented` — this is why `CELLULAR_USB_MODEL_ROWS` is keyed on exact VID:PID. | +| **`+QNWPREFCFG` is a vendor AT command ModemManager does not implement.** The 5G SA/NSA preference operators look for is not reachable through MM's D-Bus surface on a Quectel. | MM — grep of all six `quectel/*.c` plugin files at `1.24.2` returns zero occurrences of `QNWPREFCFG`; the plugin's AT vocabulary is `+QGMR?`, `+QGPS*`, `+QUSIM`. | `unavailable` — matches the `not-exposed-by-modemmanager` reason `capability/five-g-preference.ts` already reports. | +| **QMI stalls and MBIM MTU lockups are reported by operators, not by a pinned source.** | BELABOX — RM520N-GL/RM530N-GL rows note instability on USB 2.0/3.0 ports with some carrier boards and a dependence on the SBC's current supply. No upstream source pins a QMI-stall or MBIM-MTU defect. | `unavailable` — recorded as an operator report, deliberately NOT as a device claim. | + +## Sierra Wireless (`1199`, plus HP `03f0` and Dell `413c`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **FCC lock: the radio stays disabled until an unlock procedure runs, and it is keyed per `:`.** OEM rebrands are separate keys, so covering `1199` alone misses two thirds of the fleet. | MM — `data/dispatcher-fcc-unlock/meson.build` at `1.24.2` names exactly `03f0:4e1d`, `1199:9079`, `413c:81a3`, `413c:81a8` for the Sierra script. Mirrored in [`control/src/fcc/coverage.ts`](../control/src/fcc/coverage.ts). | `implemented` — CeraLive records an opt-in policy and re-derives MM's own symlink. It ships no unlock script; see [`FCC-UNLOCK-COVERAGE.md`](FCC-UNLOCK-COVERAGE.md). | +| **`AT!`-prefixed commands (`AT!BAND`, `AT!ENTERCND`) are a password-gated vendor surface.** | MM — the `sierra` plugin is a first-class plugin at `1.24.2`. CeraLive's own AT fence lists no `AT!` form: `providers/ufi-himi`'s static gate scans for `AT!` as a FORBIDDEN construct. | `unavailable` — no `AT!` command exists anywhere in this repository, by gate. | +| EM7455/EM7565 are the widely-deployed bonding-rig parts. | BELABOX — EM7455 (a.k.a. Dell DW5811e) and EM7565 are listed as working on RK3588 and Jetson. | `unavailable` — no CeraLive hardware drill has run; [`BENCH.md`](BENCH.md) RB-18 is a recorded `device-not-present` skip. | + +## Fibocom (`2cb7`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **AT-over-MBIM again — with an upstream kill switch.** MM tunnels AT through a Fibocom vendor CID, and ships a udev opt-OUT because it does not work on every unit. | MM — `fibocom/mm-port-mbim-fibocom.c` uses `MBIM_SERVICE_FIBOCOM` / `MBIM_CID_FIBOCOM_AT_COMMAND` and bails when `ID_MM_FIBOCOM_AT_OVER_MBIM_DISABLED` is set. | `unavailable` — CeraLive sends no AT over MBIM. | +| **Some SKUs need a dedicated ECM bearer path rather than the generic one.** | MM — `fibocom/mm-broadband-bearer-fibocom-ecm.c` exists as its own bearer subclass and drives `+GTRNDIS?`. | `implemented` — CeraLive's bearer authority is the NetworkManager adapter; it has no vendor bearer subclass. | +| **Composition drifts with firmware, and one part is PCIe rather than USB.** The FM350-GL has no USB VID:PID at all. | Repo — [`FM350-DECISION.md`](FM350-DECISION.md) records PCI `14c3:4d75` under `mtk_t7xx`; BELABOX lists FM350-GL as experimental and RK3588-only. | `unavailable` — documented-deferred; it gets no classifier row on purpose. | + +## SIMCom (`1e0e`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **SIM hot-swap is claimed by the plugin, so SIM state can recover without a replug.** | MM — `simtech/mm-broadband-modem-qmi-simtech.c` sets `MM_IFACE_MODEM_SIM_HOT_SWAP_SUPPORTED, TRUE`. | `implemented` — CeraLive reads SIM presence as evidence only; `absent` is reachable through one evidence kind and never inferred from a blank object path. | +| **Coverage is uneven between the AT and QMI halves of the plugin.** The QMI modem is a separate, much smaller subclass than the generic one. | MM — `simtech/` ships `mm-broadband-modem-simtech.c` alongside a distinct `mm-broadband-modem-qmi-simtech.c`; the QMI subclass is ~6 kB against a far larger shared/AT implementation. | `implemented` — CeraLive's provider is generic and runtime-discovered; it makes no SIMCom-specific claim. | +| **A Zero-CD personality exists for at least one SIMCom id.** | MODESWITCH — exactly one `1e0e:*` device file (`1e0e:f000`). | `implemented` — the generic `pending-modeswitch` class covers it; no per-vendor rule. | +| Its USB-mode command (`AT+CUSBPIDSWITCH`) was captured non-mutatingly on the bench and its PID→composition mapping is unproven. | Repo — [`COMPOSITION-EVIDENCE.md`](COMPOSITION-EVIDENCE.md). | `unavailable` — target modes stay UNCERTIFIED and hidden. | + +## Telit (`1bc7`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **Band selection is `AT#BND`, and its argument arity changes with the generation.** A 2G-only part takes one field, a 4G part takes three or four, and the 4G mask has a second extended-mask spelling. | MM — `telit/mm-modem-helpers-telit.c` builds nine distinct `#BND=` forms (`#BND=%d`, `#BND=0,%…`, `#BND=0,0,%…x,%…x`, …) and parses both the `AT#BND=?` range reply and the `AT#BND?` current reply. | `unavailable` — CeraLive's band writes go through ModemManager's generic `SetCurrentBands` and are additionally refused until a four-proof catalog entry exists; the catalog ships EMPTY. | +| **One family occupies many application PIDs, one per composition.** FN990A alone is `1070` rmnet / `1071` MBIM / `1072` RNDIS / `1073` ECM. Reading the family from the PID therefore requires the exact PID. | KERNEL — `option.c` names each variant explicitly; `qmi_wwan.c` carries the rmnet ones with `QMI_QUIRK_SET_DTR`. | `implemented` — ten exact Telit rows added to `CELLULAR_USB_MODEL_ROWS`, all `mainline-kernel`. | +| **`QMI_QUIRK_SET_DTR` is a real per-device driver quirk**, not a formality: those PIDs need DTR asserted before the QMI control channel behaves. | KERNEL — the macro is used for `1bc7:1031`, `1034`, `1040`, `1050`, `1060`, `1070`, `1080`, `10a0`, `1200`+ and not for the `QMI_FIXED_INTF` entries (`1100`, `1101`, `1200`). | `implemented` — recorded so the classifier row's tier is checkable; CeraLive performs no DTR handling. | +| **No Zero-CD entry exists for Telit.** A Telit module enumerates in its application composition directly. | MODESWITCH — zero `1bc7:*` device files. | `implemented` — no modeswitch rule is needed, and none is shipped. | +| IPv6 bearer cleanup is an operator-reported rough edge. | No pinned source found. | `unavailable` — stated as unsourced and therefore claimed as nothing. | + +## u-blox (`1546`) + +| Quirk | Evidence | Posture | +|---|---|---| +| **There are TWO band-configuration commands and which one works is per-model.** MM preloads a per-model support config and picks `+UBANDSEL?` or `+UACT?`; a model supporting neither gets an explicit "loading current bands is unsupported" error. | MM — `ublox/mm-broadband-modem-ublox.c` `preload_support_config` / `load_current_bands`. | `unavailable` — CeraLive issues neither command. | +| **Applying a band change may require dropping to low-power mode or explicitly unregistering.** MM models this as a per-model `SETTINGS_UPDATE_METHOD_CFUN` / `SETTINGS_UPDATE_METHOD_COPS` / `UNKNOWN`. | MM — same file, `preload_support_config`'s `support_config.method` switch. | `unavailable` — this is precisely the "a band lock can strand a radio" hazard behind CeraLive's four-proof certification gate. | +| **`1546` is a MIXED vendor id.** It carries u-blox GNSS receivers (Antaris 4, u-blox 5/6/7/8) as well as cellular modules. | USBIDS — the `1546` block lists `01a4`–`01a8` GNSS parts beside `1102` LISA-U2. | `implemented` — six exact LARA-R6/LARA-L6 rows added. The vendor id remains in `CELLULAR_USB_VENDOR_IDS` as inherited behaviour; treat a vendor-only match there as WEAK evidence. | +| **No Zero-CD entry exists for u-blox.** | MODESWITCH — zero `1546:*` device files. | `implemented` — none shipped. | + +## NETGEAR (`0846`) — a router/WebUI family + +| Quirk | Evidence | Posture | +|---|---|---| +| **ModemManager ships no NETGEAR plugin.** There is no vendor plugin to bind, so there is no MM-managed control surface to expect. | MM — the `src/plugins/` tree at `1.24.2` contains 39 vendor plugins; NETGEAR is not among them. | `implemented` — the classifier labels the family `router-webui` and nothing more. | +| **No kernel modem driver claims `0846:68e1` (LB1120).** It is absent from both `qmi_wwan.c` and `option.c`, so it comes up as a plain Ethernet-over-USB tether. | KERNEL + USBIDS — `0846:68e1 LB1120-100NAS` is in `usb.ids`; grep of both driver tables returns nothing for it. | `implemented` — one exact row, tier `usb-ids-registry`, `familyKind: 'router-webui'`. | +| **`0846` is a MIXED vendor id, and heavily so.** Its `usb.ids` block is dominated by Wi-Fi (`9050`–`9055`, `4110`–`4301`, …) and Ethernet (`1001`, `1040`) adapters; `68e1` is the only cellular entry. | USBIDS — the `0846` block. | `implemented` — `0846` is deliberately **absent** from `CELLULAR_USB_VENDOR_IDS`. A vendor-keyed rule would report a Wi-Fi dongle as a cellular uplink. | +| Two NETGEAR AirCard ids DO appear in `qmi_wwan.c` (`0846:68a2`, `0846:68d3` "Aircard 779S"). | KERNEL — `qmi_wwan.c`. | `unavailable` — a different family from the router/WebUI one this section covers; no row is added for them here. Note `68a2` is reused across four unrelated vendor ids in the same table, which is why a PID is never a key on its own. | + +## Huawei (`12d1`) and ZTE (`19d2`) — Zero-CD and HiLink + +| Quirk | Evidence | Posture | +|---|---|---| +| **Zero-CD: the device enumerates as a CD-ROM first and must be mode-switched.** The switch is a SCSI command sequence, per device, and the target PID differs from the installer PID. | MODESWITCH — 38 `12d1:*` and 45 `19d2:*` device files. `12d1:1f01` carries `TargetProduct= 0x14db`, a 31-byte `MessageContent`, and `NoDriverLoading=1`. | `implemented` — `classifyDevice` returns `pending-modeswitch` as a DISTINCT class, never folded into `unmanaged`. | +| **HiLink is a firmware personality, not a mode switch.** A HiLink stick presents CDC-ECM plus a web UI and has no modem control port at all; no amount of `usb_modeswitch` produces one. | Repo — [`HUAWEI-HILINK-PROVIDER.md`](HUAWEI-HILINK-PROVIDER.md); the classifier's `HILINK_ECM` fixture is `12d1:14db` with `cdc_ether` only. | `implemented` — a dedicated read/limited-write HTTP provider for two exact E3372H firmwares, gate off. | +| **Several Huawei models present the SAME MAC address**, which breaks per-interface configuration that keys on it. | BELABOX — the tutorial README states `/etc/network/interfaces` "brings up all the modems even when they use the same MAC address (which is the case for several Huawei models), unlike NetworkManager". | `implemented` — CeraLive derives physical identity from serial → udev `ID_PATH` → bounded fallback, and `PhysicalModemId` REFUSES interface names and addresses by construction. | +| **ZTE authentication differs per firmware within one model.** MF79U alone has a base64 dialect and an `LD`-salted SHA-256 dialect under the same `LOGIN` verb. | Repo — [`MF79U-DIAGNOSIS.md`](MF79U-DIAGNOSIS.md) and `providers/zte-goform/`. | `implemented` — three evidence-selected profiles, one bounded attempt, no fallback between them. | + +## Phone tethering — an interface, not a modem + +| Quirk | Evidence | Posture | +|---|---|---| +| **A tethering phone exposes a network interface and nothing else.** There is no control port, no band surface, no SIM surface and no signal reading; the phone's own OS owns all of it. | KERNEL/classifier — an RNDIS or ECM/NCM data interface with no MBIM/QMI/AT control interface is exactly `classifyDevice`'s `router-mode` branch. | `implemented` — classified `router-mode` with an honest reason; no capability module is offered. | +| **Cellular-ness cannot be inferred from the descriptors.** A phone's RNDIS tether and a USB Ethernet dongle are the same shape on the wire. | Classifier — `classifyUsbNetDevice` requires POSITIVE cellular evidence (a known cellular vendor id, a modeswitch trigger, or a Zero-CD storage interface) before naming a tether `router-cellular`; absent that it is `wired-ethernet`. | `implemented` — and this is why the NETGEAR LB1120 currently reads `wired-ethernet`: honest, and a known gap rather than a guess. | + +--- + +## What this document does NOT license + +- It does not add a provider. No Telit, u-blox or NETGEAR provider exists in this + repository, and none may be inferred from a row above. +- It does not certify anything. Certification comes from a captured bundle and a + human-reviewed commit — [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). +- It does not authorize an AT command. The runtime AT allowlist is a closed set of exact + READ/TEST forms in `control/src/usb-mode/`; adding to it is a separate, reviewed change + with its own evidence. From 16d168742de28c0e49b5d21ae800242aa5537372 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:10:34 -0500 Subject: [PATCH 14/19] feat(provider): MM Signal-interface metric normalization + injectable setup rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claim NormalizedSignal.rsrp/rsrq/snr/sinr for MM-managed modems from the Modem.Signal interface's own per-RAT a{sv} properties, so a ModemManager device reports the detail the Huawei/ZTE dongles always did. An a{sv} decodes to [key, variant][], and snapshot.ts's rawValue had no variant branch — every dict retained as [['rsrp'], ['rsrq'], ...], keeping the key and dropping the reading. Unwrapping it is what makes the extended metrics reachable at all; removing that line reddens 3 of the 5 new provider tests. rsrp/rsrq/snr read Nr5g then Lte and dbm reads rssi across Lte/Umts/Gsm/ Evdo/Cdma. Nothing is merged or averaged: on an NSA attach both dicts are populated with different measurements, so the ladder picks one and provenance names it (Signal.Nr5g.rsrp), while the unchosen dict stays verbatim in the diagnostics block. sinr comes from Evdo and from no other dict — verified against MM 1.24.2's own introspection XML, where Lte/Nr5g publish snr, a different quantity. So an LTE/5G modem reporting no SINR now answers not-reported rather than the previous blanket unsupported, which was a false capability claim: the source can express SINR, this modem did not. The NR SINR available through Modem.GetCellInfo is a different call on a different interface and is not folded in here. Also thread the Signal.Setup rate through ModemManagerProviderOptions. The backend had accepted signalIntervalSeconds since it was written, but the provider — what an embedder actually constructs — had no way to pass it. Setup takes a `u`, so a fractional or non-positive rate is refused at construction rather than marshalled. The once-per-(epoch, modem) issue semantics are unchanged and now additionally pinned by test. --- AGENTS.md | 51 +++++- control/README.md | 24 +++ control/src/backend/signal-setup-rate.test.ts | 101 ++++++++++++ control/src/backend/signal-setup.ts | 39 ++++- .../src/observations/normalization.test.ts | 58 ++++++- control/src/observations/raw.ts | 38 +++++ .../src/observations/sources/modemmanager.ts | 110 +++++++++++-- .../src/providers/modem-manager/provider.ts | 13 ++ .../modem-manager/signal-richness.test.ts | 147 ++++++++++++++++++ .../src/providers/modem-manager/snapshot.ts | 6 +- .../test-support/conformance/mm-transport.ts | 12 +- control/test-support/fake-mm/object-model.ts | 33 +++- control/test-support/observation-fixtures.ts | 67 +++++++- 13 files changed, 667 insertions(+), 32 deletions(-) create mode 100644 control/src/backend/signal-setup-rate.test.ts create mode 100644 control/src/providers/modem-manager/signal-richness.test.ts diff --git a/AGENTS.md b/AGENTS.md index b899618..bbb8e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1229,8 +1229,57 @@ The observation layer's SIM and signal halves are finalized on top of todo 18. (when WE last read). The router APIs have no such flag and answer `unsupported` — a capability claim, the `bars` / `maxBars` precedent. +### `Modem.Signal`'s per-RAT dicts — the detail the router dongles already carried + +`NormalizedSignal.rsrp` / `rsrq` / `snr` / `sinr` are now claimed for MM-managed modems +too, from the `Modem.Signal` interface's own `a{sv}` properties. Five facts about that +path are load-bearing, and each is pinned by a test: + +- **A dict member arrives WRAPPED, and used to be dropped.** An `a{sv}` decodes to + `[key, variant][]`, so `snapshot.ts`'s `rawValue` kept the key and discarded the reading + — `Signal.Lte` retained as `[['rsrp'], ['rsrq'], …]`. It now unwraps the variant, which + is why the extended metrics are reachable at all. `signal-richness.test.ts` fails three + ways with that unwrap removed, so the fix is not a silent one. +- **The dict is read by MEMBER, never flattened at retention.** `raw.ts`'s + `rawDictMember` / `rawDictNumber` / `hasRawDictMember` read one member by name, so + `error-rate`, `ecio`, `io` and `rscp` — every key the normalized model has no slot for — + stay verbatim in the diagnostics block instead of being lost to make four metrics fit. +- **The RAT ladder is NEWEST FIRST, and provenance names which rung answered.** On an NSA + attach `Nr5g` and `Lte` are both populated with genuinely different measurements (the NR + leg and the LTE anchor), so one has to be the reported reading. `rsrp`/`rsrq`/`snr` take + `Nr5g` then `Lte`; `dbm` takes `Lte → Umts → Gsm → Evdo → Cdma`. Nothing is merged and + nothing is averaged — `MetricProvenance.rawFields` carries `Signal.Nr5g.rsrp` rather than + a bare `Signal.rsrp`, and the unchosen dict is still in `raw`. +- **SINR comes from `Evdo` and from NOWHERE ELSE.** Checked against MM 1.24.2's own + introspection rather than recalled: `sinr` is a member of the `Evdo` dict only — `Lte` + and `Nr5g` publish `snr`, which is a different quantity and must never populate it (the + same rule `backend/cell-info.ts` already enforces in the other direction). So an LTE/NR + modem reporting no SINR answers **`not-reported`, not `unsupported`**: the source CAN + express it, this modem did not. The former blanket `unsupported` was a false capability + claim and is gone. The NR SINR a device may publish through `Modem.GetCellInfo` is a + different call on a different interface and is deliberately NOT folded in here. +- **A dict-sourced metric consumes the whole property.** `consumed` must name a real raw + key or `createObservationDiagnostics` drops it, so the entry is `Signal.Nr5g` while + `rawFields` stays member-precise. An exported-but-silent `Modem.Signal` therefore yields + five READ-class `not-reported` metrics carrying no `value` field at all, and a modem with + no `Modem.Signal` interface yields `not-observed` — never a zero, in either case. + +**The `Signal.Setup` rate is injected at three levels and defaults at all three.** +`SignalSetupManagerOptions.intervalSeconds` → `MmDbusBackendOptions.signalIntervalSeconds` +→ `ModemManagerProviderOptions.signalIntervalSeconds`, each resolving to +`DEFAULT_SIGNAL_INTERVAL_SECONDS` (5) when absent. The provider seam is the one this work +added: the backend had accepted the option since it was written, and the provider — which +is what an embedder actually constructs — had no way to pass it. `Setup` takes a `u`, so a +fractional or non-positive rate is REFUSED at construction rather than marshalled; a modem +silently polling at the wrong cadence is a defect nothing downstream can see. The +once-per-(epoch, modem) issue semantics are the conformance-scale pin and are untouched. + Coverage: `control/src/observations/sim-evidence.test.ts`, whose control case is a modem -with the identical blank fields and NO failure reason, asserting `unknown`. +with the identical blank fields and NO failure reason, asserting `unknown`; +`control/src/observations/normalization.test.ts` for the per-metric claims and the +absent-dict unknowns; `control/src/providers/modem-manager/signal-richness.test.ts` for +the same claims over the real provider wire; `control/src/backend/signal-setup-rate.test.ts` +for the injected rate and the once-per-(epoch, modem) issue count. ## GPS / LOCATION — A LIVE FIX, AND DELIBERATELY NO HISTORY diff --git a/control/README.md b/control/README.md index 6092a32..f24c4ee 100644 --- a/control/README.md +++ b/control/README.md @@ -199,6 +199,30 @@ are, so the preferred mode and the measurement-recency flag survive normalizatio `NormalizedSignal.qualityRecent` claims that flag, and the router sources answer `unsupported` for it. +### Extended signal — the `Modem.Signal` per-RAT dicts + +`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. + ### Mutation safety ports The root export includes `MutationAdmissionPort`, `ResourceOwnershipPort`, diff --git a/control/src/backend/signal-setup-rate.test.ts b/control/src/backend/signal-setup-rate.test.ts new file mode 100644 index 0000000..58af0dd --- /dev/null +++ b/control/src/backend/signal-setup-rate.test.ts @@ -0,0 +1,101 @@ +// The `Signal.Setup` reporting rate is INJECTED, and 5s is only what an absent +// injection resolves to. +// +// These run without a bus on purpose: the epoch lifecycle is proven against the real +// fake MM service in `test-support/signal-setup.test.ts`, and what is asserted here is +// the ARGUMENT the call carries, which a recording transport answers exactly. The +// once-per-(epoch, modem) semantics are the conformance-scale pin and are untouched. + +import { describe, expect, test } from 'bun:test'; +import type { DbusTransport, MethodCall, MethodReply, SignalSpec } from '../transport'; +import { MODEM_IFACE } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { + DEFAULT_SIGNAL_INTERVAL_SECONDS, + resolveSignalInterval, + SignalSetupManager, +} from './signal-setup'; + +const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`; +const MODEM_PATH = '/org/freedesktop/ModemManager1/Modem/0'; + +const TREE: DecodedManagedObjects = [ + [ + MODEM_PATH, + [ + [MODEM_IFACE, []], + [SIGNAL_IFACE, []], + ], + ], +]; + +function recordingTransport(): { transport: DbusTransport; calls: MethodCall[] } { + const calls: MethodCall[] = []; + const transport: DbusTransport = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + isConnected: () => true, + callMethod: (call: MethodCall): Promise => { + calls.push(call); + return Promise.resolve({ signature: '', body: [] }); + }, + subscribeSignal: (_spec: SignalSpec) => + Promise.resolve({ unsubscribe: () => Promise.resolve() }), + on: () => {}, + off: () => {}, + subscriptionCount: () => 0, + }; + return { transport, calls }; +} + +async function setupArgs(intervalSeconds?: number): Promise { + const { transport, calls } = recordingTransport(); + const manager = new SignalSetupManager({ + transport, + ...(intervalSeconds === undefined ? {} : { intervalSeconds }), + }); + manager.applyForEpoch('epoch-1', TREE); + await Promise.resolve(); + const setup = calls.find((call) => call.member === 'Setup'); + if (setup === undefined) { + throw new Error('no Signal.Setup call was issued'); + } + return setup.args ?? []; +} + +describe('Signal.Setup rate injection', () => { + test('Given no injected rate, when setup runs, then the call carries the 5s default', async () => { + expect(DEFAULT_SIGNAL_INTERVAL_SECONDS).toBe(5); + expect(await setupArgs()).toEqual([5]); + }); + + test('Given an injected rate, when setup runs, then the call carries THAT rate', async () => { + expect(await setupArgs(1)).toEqual([1]); + expect(await setupArgs(30)).toEqual([30]); + }); + + test('Given an injected rate, when the manager is constructed, then it reports the rate it will send', () => { + const { transport } = recordingTransport(); + + expect(new SignalSetupManager({ transport }).intervalSeconds).toBe(5); + expect(new SignalSetupManager({ transport, intervalSeconds: 2 }).intervalSeconds).toBe(2); + }); + + test('Given a rate `Setup(u)` cannot carry, when resolved, then it is refused rather than marshalled', () => { + for (const rate of [0, -1, 2.5, Number.NaN]) { + expect(() => resolveSignalInterval(rate)).toThrow(RangeError); + } + expect(resolveSignalInterval(undefined)).toBe(DEFAULT_SIGNAL_INTERVAL_SECONDS); + }); + + test('Given one epoch, when applied twice, then Setup is issued ONCE per (epoch, modem)', async () => { + const { transport, calls } = recordingTransport(); + const manager = new SignalSetupManager({ transport, intervalSeconds: 3 }); + + manager.applyForEpoch('epoch-1', TREE); + manager.applyForEpoch('epoch-1', TREE); + await Promise.resolve(); + + expect(calls.filter((call) => call.member === 'Setup')).toHaveLength(1); + }); +}); diff --git a/control/src/backend/signal-setup.ts b/control/src/backend/signal-setup.ts index 3c26a96..c48892b 100644 --- a/control/src/backend/signal-setup.ts +++ b/control/src/backend/signal-setup.ts @@ -27,17 +27,45 @@ export type SignalCadence = 'active' | 'unsupported' | 'unknown'; /** The `Modem.Signal` interface — absent means signal cadence is unsupported. */ const SIGNAL_IFACE = 'org.freedesktop.ModemManager1.Modem.Signal'; -/** MM's default reporting rate is seconds; callers pass whole seconds. */ +/** + * MM's default reporting rate is seconds; callers pass whole seconds. + * + * It is a DEFAULT and not a constant: extended signal costs a modem-side poll per tick, + * so an embedding process running a bonded uplink may want it faster than a bench tool + * does. The rate is therefore injectable end to end — `SignalSetupManagerOptions`, + * `MmDbusBackendOptions.signalIntervalSeconds` and + * `ModemManagerProviderOptions.signalIntervalSeconds` are the same seam at three levels + * — and this value is what an absent injection resolves to, at every one of them. + */ export const DEFAULT_SIGNAL_INTERVAL_SECONDS = 5; export interface SignalSetupManagerOptions { readonly transport: DbusTransport; /** MM bus name override (defaults to `org.freedesktop.ModemManager1`). */ readonly destination?: string; - /** Reporting interval in seconds passed to `Signal.Setup`. */ + /** + * Reporting interval in seconds passed to `Signal.Setup`. + * + * `Setup` takes a `u`, so a fractional or negative rate has no wire representation + * and is refused HERE rather than marshalled into whatever the codec makes of it — + * a modem silently polling at the wrong cadence is a defect nothing downstream can + * see. An absent value is not a refusal; it takes the default. + */ readonly intervalSeconds?: number; } +export function resolveSignalInterval(intervalSeconds: number | undefined): number { + if (intervalSeconds === undefined) { + return DEFAULT_SIGNAL_INTERVAL_SECONDS; + } + if (!Number.isInteger(intervalSeconds) || intervalSeconds <= 0) { + throw new RangeError( + `Signal.Setup interval must be a positive whole number of seconds, got ${intervalSeconds}`, + ); + } + return intervalSeconds; +} + /** * Drives `Signal.Setup` across the modem fleet, keyed to the observer's epochs. Feed * it every `onEpochRefresh` event; it applies setup to each modem exactly once per @@ -55,7 +83,12 @@ export class SignalSetupManager { constructor(options: SignalSetupManagerOptions) { this.#transport = options.transport; this.#destination = options.destination ?? MM_BUS_NAME; - this.#interval = options.intervalSeconds ?? DEFAULT_SIGNAL_INTERVAL_SECONDS; + this.#interval = resolveSignalInterval(options.intervalSeconds); + } + + /** The rate every `Signal.Setup` on this manager carries — injected, or the default. */ + get intervalSeconds(): number { + return this.#interval; } /** The last-known cadence for a modem path (`'unknown'` until first applied). */ diff --git a/control/src/observations/normalization.test.ts b/control/src/observations/normalization.test.ts index a3f632b..22bc054 100644 --- a/control/src/observations/normalization.test.ts +++ b/control/src/observations/normalization.test.ts @@ -7,7 +7,9 @@ import { fixtureContext, HILINK_AUTH_EXPIRED_FIXTURE, HILINK_FIXTURE, + MM_EVDO_SIGNAL_FIXTURE, MM_FIXTURE, + MM_SIGNAL_SILENT_FIXTURE, UFI_AUTH_EXPIRED_FIXTURE, UFI_FIXTURE, ZTE_FIXTURE, @@ -15,7 +17,7 @@ import { } from '../../test-support/observation-fixtures'; import { REDACTED } from '../redact'; import { viewEnvelope } from './envelope'; -import type { NormalizedMetric } from './metric'; +import { metricUnknownClass, type NormalizedMetric } from './metric'; import type { NormalizedModemObservation } from './model'; import { redactObservationDiagnostics } from './provenance'; import { normalizeHilinkObservation } from './sources/hilink'; @@ -107,14 +109,62 @@ describe('ModemManager normalization', () => { sourceEpoch: FIXTURE_SOURCE_EPOCH, observedAt: FIXTURE_OBSERVED_AT, authority: 'authoritative', - rawFields: ['Signal.rsrp'], + rawFields: ['Signal.Nr5g.rsrp'], }); }); + const EVDO = observation(normalizeModemManagerObservation(MM_EVDO_SIGNAL_FIXTURE, CONTEXT)); + const extended = [ + ['rsrp', MM.signal.rsrp, -98.5, 'Signal.Nr5g.rsrp'], + ['rsrq', MM.signal.rsrq, -11, 'Signal.Nr5g.rsrq'], + ['snr', MM.signal.snr, 6.5, 'Signal.Nr5g.snr'], + ['sinr', EVDO.signal.sinr, 9.5, 'Signal.Evdo.sinr'], + ] as const; + + test.each(extended.map(([name]) => name))( + 'Given a Signal RAT dict carrying %s, when normalized, then it is fresh, authoritative and field-attributed', + (name) => { + const entry = extended.find(([id]) => id === name); + if (entry === undefined) { + throw new Error(`missing extended metric ${name}`); + } + const [, metric, value, rawField] = entry; + + expect(metric).toMatchObject({ state: 'known', value }); + expect(metric.provenance.authority).toBe('authoritative'); + expect(metric.provenance.rawFields).toEqual([rawField]); + expect(metric.provenance.observedAt).toBe(FIXTURE_OBSERVED_AT); + }, + ); + + test('Given an NSA attach, when normalized, then the LTE anchor is retained rather than merged', () => { + expect(MM.diagnostics.raw['Signal.Lte']).toContainEqual(['rsrp', -104]); + expect(MM.diagnostics.consumed).toContain('Signal.Nr5g'); + }); + + test('Given every RAT dict empty, when normalized, then each metric is a READ-class unknown and never a zero', () => { + const silent = observation(normalizeModemManagerObservation(MM_SIGNAL_SILENT_FIXTURE, CONTEXT)); + + for (const metric of [ + silent.signal.dbm, + silent.signal.rsrp, + silent.signal.rsrq, + silent.signal.snr, + silent.signal.sinr, + ]) { + expect(metric).toMatchObject({ state: 'unknown', reason: 'not-reported' }); + expect(metric).not.toHaveProperty('value'); + } + }); + + test('Given an LTE/NR modem reporting no SINR, when normalized, then it is not-reported and NOT unsupported', () => { + expect(reason(MM.signal.sinr)).toBe('not-reported'); + expect(metricUnknownClass('not-reported')).toBe('read'); + }); + test('Given a metric ModemManager cannot express, when normalized, then it is a capability claim', () => { expect(reason(MM.signal.bars)).toBe('unsupported'); expect(reason(MM.signal.maxBars)).toBe('unsupported'); - expect(reason(MM.signal.sinr)).toBe('unsupported'); }); test('Given a field present but undecodable, when normalized, then it is malformed and noted', () => { @@ -252,7 +302,7 @@ describe('no raw vendor field is dropped during normalization', () => { ['modemmanager', MM, 'Modem.Ports', ['ttyUSB0', 'wwan0']], ['modemmanager', MM, 'Modem3gpp.Pco', 'dns-primary=10.0.0.1'], ['modemmanager', MM, 'Sim.OperatorName', 'CLARO COL'], - ['modemmanager', MM, 'Signal.refresh_rate', 5], + ['modemmanager', MM, 'Signal.Rate', 5], ['huawei-hilink', HILINK, 'monitoring-status.CurrentNetworkTypeEx', '101'], ['huawei-hilink', HILINK, 'device-signal.TotalDownload', '987654321'], ['huawei-hilink', HILINK, 'monitoring-status.ConnectionStatus', '901'], diff --git a/control/src/observations/raw.ts b/control/src/observations/raw.ts index 0226f37..e51e0f6 100644 --- a/control/src/observations/raw.ts +++ b/control/src/observations/raw.ts @@ -102,6 +102,44 @@ export function rawBooleanAt( return typeof member === 'boolean' ? member : undefined; } +/** + * One member of a D-Bus DICT (`a{sv}`) retained verbatim. + * + * A dict decodes to `[key, value][]`, so a retained `Modem.Signal.Lte` is a nested pair + * array rather than a flat scalar. Reading it by member name here — instead of flattening + * it at retention time — is what keeps `error-rate` and every future MM key in the + * diagnostics block while a metric claims only the member it names. + */ +export function rawDictMember( + record: RawFieldRecord, + key: string, + member: string, +): RawFieldValue | undefined { + const dict = record[key]; + if (!Array.isArray(dict)) return undefined; + for (const entry of dict) { + if (Array.isArray(entry) && entry.length >= 2 && entry[0] === member) return entry[1]; + } + return undefined; +} + +/** Whether a retained dict CARRIES a member — absent and undecodable stay separable. */ +export function hasRawDictMember(record: RawFieldRecord, key: string, member: string): boolean { + return rawDictMember(record, key, member) !== undefined; +} + +export function rawDictNumber( + record: RawFieldRecord, + key: string, + member: string, +): number | undefined { + const value = rawDictMember(record, key, member); + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value !== 'string' || value.trim() === '') return undefined; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + const XML_LEAF = /<([A-Za-z_][\w.-]*)>([^<]*)<\/\1>/g; /** diff --git a/control/src/observations/sources/modemmanager.ts b/control/src/observations/sources/modemmanager.ts index 50288d8..0d2ba9f 100644 --- a/control/src/observations/sources/modemmanager.ts +++ b/control/src/observations/sources/modemmanager.ts @@ -35,10 +35,12 @@ import { type RawFieldValue, } from '../provenance'; import { + hasRawDictMember, hasRawField, mergeRawRecords, prefixRawRecord, rawBooleanAt, + rawDictNumber, rawKey, rawNumber, rawNumberAt, @@ -84,9 +86,23 @@ export function normalizeModemManagerObservation( return metricProvenance(SOURCE, context, fields); }; + // A dict-sourced metric CONSUMES the whole `a{sv}` property (that is the raw key it + // actually has) while NAMING the exact member it read, so provenance stays precise + // without `unmapped` gaining a key that was never in the payload. + const dictProvenance = (dict: string, member: string) => { + consumed.push(dict); + return metricProvenance(SOURCE, context, [`${dict}.${member}`]); + }; + const hardware = normalizeHardware(raw, provenance); const radio = normalizeRadio(raw, provenance, notes); - const signal = normalizeSignal(raw, input.signal !== undefined, provenance); + const signal = normalizeSignal( + raw, + input.signal !== undefined, + provenance, + dictProvenance, + notes, + ); const sim = normalizeSim(raw, input.sim !== undefined, provenance, notes); return freshObservation(SOURCE, context, { @@ -100,6 +116,7 @@ export function normalizeModemManagerObservation( } type Provenance = (...fields: readonly string[]) => ReturnType; +type DictProvenance = (dict: string, member: string) => ReturnType; const MODEL = rawKey(MODEM, 'Model'); const MANUFACTURER = rawKey(MODEM, 'Manufacturer'); @@ -116,10 +133,64 @@ const UNLOCK_REQUIRED = rawKey(MODEM, 'UnlockRequired'); const REGISTRATION_STATE = rawKey(MODEM3GPP, 'RegistrationState'); const SIM_TYPE = rawKey(SIM, 'SimType'); const ESIM_STATUS = rawKey(SIM, 'EsimStatus'); -const RSSI = rawKey(SIGNAL, 'rssi'); -const RSRP = rawKey(SIGNAL, 'rsrp'); -const RSRQ = rawKey(SIGNAL, 'rsrq'); -const SNR = rawKey(SIGNAL, 'snr'); + +// `Modem.Signal` publishes ONE `a{sv}` per RAT, and the member sets differ (MM 1.24.2 +// `org.freedesktop.ModemManager1.Modem.Signal.xml`): +// Cdma rssi/ecio/error-rate Evdo rssi/ecio/SINR/io/error-rate +// Gsm rssi/error-rate Umts rssi/rscp/ecio/error-rate +// Lte rssi/rsrq/rsrp/snr/error-rate Nr5g rsrq/rsrp/snr/error-rate +const SIGNAL_CDMA = rawKey(SIGNAL, 'Cdma'); +const SIGNAL_EVDO = rawKey(SIGNAL, 'Evdo'); +const SIGNAL_GSM = rawKey(SIGNAL, 'Gsm'); +const SIGNAL_UMTS = rawKey(SIGNAL, 'Umts'); +const SIGNAL_LTE = rawKey(SIGNAL, 'Lte'); +const SIGNAL_NR5G = rawKey(SIGNAL, 'Nr5g'); + +/** + * Where one extended metric may be claimed from, in order. + * + * `dicts` is the RAT ladder, NEWEST FIRST: on an NSA attach both `Nr5g` and `Lte` are + * populated with genuinely different measurements (the NR leg and the LTE anchor), so + * one of them has to be the reported reading — and `rawFields` names WHICH, rather than + * leaving a consumer to guess. Nothing is lost either way: every dict stays verbatim in + * the diagnostics block. `flat` is the mmcli-flattened spelling of the same datum, kept + * for the same reason `rawStructMember` answers at index 0 for a flattened struct. + */ +type ExtendedSignalMetric = { + readonly member: string; + readonly dicts: readonly string[]; + readonly flat: string; +}; + +const RSSI: ExtendedSignalMetric = { + member: 'rssi', + dicts: [SIGNAL_LTE, SIGNAL_UMTS, SIGNAL_GSM, SIGNAL_EVDO, SIGNAL_CDMA], + flat: rawKey(SIGNAL, 'rssi'), +}; +const RSRP: ExtendedSignalMetric = { + member: 'rsrp', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'rsrp'), +}; +const RSRQ: ExtendedSignalMetric = { + member: 'rsrq', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'rsrq'), +}; +const SNR: ExtendedSignalMetric = { + member: 'snr', + dicts: [SIGNAL_NR5G, SIGNAL_LTE], + flat: rawKey(SIGNAL, 'snr'), +}; +// SINR is a member of the `Evdo` dict and of NO other, so an LTE/NR modem reporting no +// SINR is a READ-class `not-reported` — ModemManager CAN express it, this modem did not. +// The NR SINR a device may publish through `Modem.GetCellInfo` is a different call on a +// different interface and is deliberately not folded in here. +const SINR: ExtendedSignalMetric = { + member: 'sinr', + dicts: [SIGNAL_EVDO], + flat: rawKey(SIGNAL, 'sinr'), +}; function hardwareIdentity(raw: RawFieldRecord): ModemHardwareIdentity { const model = rawString(raw, MODEL); @@ -230,13 +301,29 @@ function normalizeModeLabel( return knownMetric(label, source); } -function normalizeSignal(raw: RawFieldRecord, signalRead: boolean, provenance: Provenance) { +function normalizeSignal( + raw: RawFieldRecord, + signalRead: boolean, + provenance: Provenance, + dictProvenance: DictProvenance, + notes: ObservationDiagnosticNote[], +) { const missing = signalRead ? ('not-reported' as const) : ('not-observed' as const); - const extended = (key: string): NormalizedMetric => { - const source = provenance(key); - const value = rawNumber(raw, key); + const extended = (metric: ExtendedSignalMetric): NormalizedMetric => { + for (const dict of metric.dicts) { + if (!hasRawDictMember(raw, dict, metric.member)) continue; + const source = dictProvenance(dict, metric.member); + const value = rawDictNumber(raw, dict, metric.member); + if (value === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: `${dict}.${metric.member}` }); + return unknownMetric('malformed', source); + } + return knownMetric(value, source); + } + const source = provenance(metric.flat); + const value = rawNumber(raw, metric.flat); return value === undefined - ? unknownMetric(hasRawField(raw, key) ? 'malformed' : missing, source) + ? unknownMetric(hasRawField(raw, metric.flat) ? 'malformed' : missing, source) : knownMetric(value, source); }; // `SignalQuality` is a `(ub)`: percentage at 0, "measured recently" at 1. Both are @@ -262,8 +349,7 @@ function normalizeSignal(raw: RawFieldRecord, signalRead: boolean, provenance: P rsrp: extended(RSRP), rsrq: extended(RSRQ), snr: extended(SNR), - // `Modem.Signal` exposes rssi/rsrp/rsrq/snr/ecio/io/rscp — there is no SINR member. - sinr: unknownMetric('unsupported', metricProvenanceEmpty(provenance)), + sinr: extended(SINR), }; } diff --git a/control/src/providers/modem-manager/provider.ts b/control/src/providers/modem-manager/provider.ts index 80c359d..1675d0f 100644 --- a/control/src/providers/modem-manager/provider.ts +++ b/control/src/providers/modem-manager/provider.ts @@ -47,6 +47,16 @@ export interface ModemManagerProviderOptions { readonly destination?: string; readonly quiesce?: QuiesceHook; readonly now?: () => number; + /** + * `Signal.Setup` reporting rate in whole seconds, defaulting to + * `DEFAULT_SIGNAL_INTERVAL_SECONDS` (5). + * + * The backend has accepted this since it was written; the provider had no way to pass + * it, so an embedder constructing a provider — which is every embedder — was pinned to + * the default with no seam to change it. That is the gap this closes, and it is why + * the option is threaded rather than re-implemented here. + */ + readonly signalIntervalSeconds?: number; /** * The band-lock certification catalog. Defaults to the one shipped in this package, * which is EMPTY — so a band write is refused on every device until a human-reviewed @@ -95,6 +105,9 @@ export class ModemManagerProvider implements ModemManagerProviderLifecycle { destination: this.#destination, actor, now: this.#now, + ...(options.signalIntervalSeconds === undefined + ? {} + : { signalIntervalSeconds: options.signalIntervalSeconds }), }); const location = new MmLocation({ transport: this.#transport, diff --git a/control/src/providers/modem-manager/signal-richness.test.ts b/control/src/providers/modem-manager/signal-richness.test.ts new file mode 100644 index 0000000..4aef344 --- /dev/null +++ b/control/src/providers/modem-manager/signal-richness.test.ts @@ -0,0 +1,147 @@ +// End-to-end extended-signal truth through the REAL ModemManager provider. +// +// `observations/normalization.test.ts` proves the normalizer against a fixture. This one +// proves the WIRE, and it exists because the wire is exactly where this used to break: +// a `Modem.Signal` RAT dict is an `a{sv}`, so every member value arrives WRAPPED in a +// variant, and the retention layer used to keep the key while dropping the reading. A +// normalizer test cannot see that — it is handed the record the provider built. +// +// It runs on the in-memory transport for the reason the conformance matrix does: a suite +// that SKIPS wherever no session bus exists answers nothing. + +import { describe, expect, test } from 'bun:test'; +import { FakeMmTransport } from '../../../test-support/conformance/mm-transport'; +import type { ModemSpec } from '../../../test-support/fake-mm/object-model'; +import { type DeviceGeneration, deviceGeneration, physicalModemId } from '../../domain'; +import type { NormalizedMetric, NormalizedSignal } from '../../observations'; +import type { ProviderExecutionContext } from '../contracts'; +import { createModemManagerProvider } from './provider'; + +const GENERATION: DeviceGeneration = deviceGeneration(1); +const PROFILE = 'generic-mm'; + +/** Quectel RM530N-GL attached 5G NSA: the NR leg and the LTE anchor are both live. */ +const NSA_SPEC: ModemSpec = { + index: 1, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + sims: [{ index: 1, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], + extendedSignal: { + Nr5g: { rsrp: -98.5, rsrq: -11, snr: 6.5, 'error-rate': 0 }, + Lte: { rssi: -71, rsrp: -104, rsrq: -13.5, snr: 4, 'error-rate': 0 }, + }, +}; + +/** Sierra MC7354 on EV-DO — the ONE `Modem.Signal` dict MM defines `sinr` on. */ +const EVDO_SPEC: ModemSpec = { + index: 2, + manufacturer: 'Sierra Wireless', + model: 'MC7354', + revision: 'SWI9X15C', + sims: [{ index: 2, iccid: '8900000000000000002', imsi: '001010000000002', active: true }], + extendedSignal: { Evdo: { rssi: -83, ecio: -2.5, sinr: 9.5, io: -95, 'error-rate': 0 } }, +}; + +/** The same modem with `Modem.Signal` exported and every RAT dict still empty. */ +const SILENT_SPEC: ModemSpec = { + index: 3, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + sims: [{ index: 3, iccid: '8900000000000000003', imsi: '001010000000003', active: true }], +}; + +function contextFor(spec: ModemSpec): ProviderExecutionContext { + return { + physicalModemId: physicalModemId(`serial:fake-device-${spec.index}`), + generation: GENERATION, + transport: 'modemmanager', + passiveFacts: [], + composition: 'generic', + profile: PROFILE, + }; +} + +async function signalOf(spec: ModemSpec): Promise { + const provider = createModemManagerProvider({ + transport: new FakeMmTransport({ modems: [spec] }), + }); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok) { + throw new Error('the fake modem must produce a snapshot'); + } + const observation = snapshot.observation.value; + if (observation === null) { + throw new Error('a served payload must produce a valued envelope'); + } + return observation.signal; +} + +async function rawOf(spec: ModemSpec): Promise> { + const provider = createModemManagerProvider({ + transport: new FakeMmTransport({ modems: [spec] }), + }); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok || snapshot.observation.value === null) { + throw new Error('the fake modem must produce a valued snapshot'); + } + return snapshot.observation.value.diagnostics.raw as Record; +} + +function reasonOf(metric: NormalizedMetric): string { + return metric.state === 'unknown' ? metric.reason : `known:${metric.value}`; +} + +describe('Modem.Signal RAT dicts reach NormalizedSignal through the provider', () => { + test('an NSA attach yields rsrp/rsrq/snr, each attributed to the dict it came from', async () => { + const signal = await signalOf(NSA_SPEC); + + expect(signal.rsrp).toMatchObject({ state: 'known', value: -98.5 }); + expect(signal.rsrq).toMatchObject({ state: 'known', value: -11 }); + expect(signal.snr).toMatchObject({ state: 'known', value: 6.5 }); + expect(signal.dbm).toMatchObject({ state: 'known', value: -71 }); + for (const metric of [signal.rsrp, signal.rsrq, signal.snr, signal.dbm]) { + expect(metric.provenance.authority).toBe('authoritative'); + } + expect(signal.rsrp.provenance.rawFields).toEqual(['Signal.Nr5g.rsrp']); + expect(signal.dbm.provenance.rawFields).toEqual(['Signal.Lte.rssi']); + }); + + test('an EV-DO attach yields SINR, the member no LTE/NR dict carries', async () => { + const signal = await signalOf(EVDO_SPEC); + + expect(signal.sinr).toMatchObject({ state: 'known', value: 9.5 }); + expect(signal.sinr.provenance.rawFields).toEqual(['Signal.Evdo.sinr']); + expect(signal.sinr.provenance.authority).toBe('authoritative'); + }); + + test('every dict member survives retention verbatim, values included', async () => { + const raw = await rawOf(NSA_SPEC); + + expect(raw['Signal.Nr5g']).toEqual([ + ['rsrp', -98.5], + ['rsrq', -11], + ['snr', 6.5], + ['error-rate', 0], + ]); + expect(raw['Signal.Lte']).toContainEqual(['rsrp', -104]); + }); + + test('an exported but silent Signal interface reports READ-class unknowns, never zeros', async () => { + const signal = await signalOf(SILENT_SPEC); + + for (const metric of [signal.dbm, signal.rsrp, signal.rsrq, signal.snr, signal.sinr]) { + expect(reasonOf(metric)).toBe('not-reported'); + expect(metric).not.toHaveProperty('value'); + } + }); + + test('a modem with no Modem.Signal interface at all reports not-observed', async () => { + const signal = await signalOf({ ...SILENT_SPEC, index: 4, hasSignal: false }); + + for (const metric of [signal.rsrp, signal.rsrq, signal.snr, signal.sinr]) { + expect(reasonOf(metric)).toBe('not-observed'); + } + }); +}); diff --git a/control/src/providers/modem-manager/snapshot.ts b/control/src/providers/modem-manager/snapshot.ts index f421eb0..d0e5b97 100644 --- a/control/src/providers/modem-manager/snapshot.ts +++ b/control/src/providers/modem-manager/snapshot.ts @@ -25,7 +25,7 @@ import { readSimPresence } from '../../hardware/router-parsers'; import type { NormalizationContext, RawFieldRecord, RawFieldValue } from '../../observations'; import { normalizeModemManagerObservation } from '../../observations'; import { describeBandWriteCertification, readRadioModeTruth } from '../../radio'; -import type { DbusValue } from '../../transport'; +import { type DbusValue, isVariant } from '../../transport'; import type { ProviderExecutionContext } from '../contracts'; import type { ModemManagerCapabilities, @@ -40,6 +40,10 @@ const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`; function rawValue(value: DbusValue): RawFieldValue | undefined { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + // An `a{sv}` decodes to `[key, variant][]`, so a dict member's value arrives WRAPPED. + // Without this unwrap a `Modem.Signal` RAT dict retained as `[['rsrp'], ['rsrq'], …]`: + // the key survived and the reading did not. + if (isVariant(value)) return rawValue(value.value); if (Array.isArray(value)) { const result: RawFieldValue[] = []; for (const item of value) { diff --git a/control/test-support/conformance/mm-transport.ts b/control/test-support/conformance/mm-transport.ts index c698b1a..e732895 100644 --- a/control/test-support/conformance/mm-transport.ts +++ b/control/test-support/conformance/mm-transport.ts @@ -48,8 +48,18 @@ export type RecordedCall = { readonly member: string; }; +/** + * An `a{sv}` member is ITSELF a variant on the wire, so its decode form is + * `[key, {signature, value}][]` — not `[key, [signature, value]][]`. Recursing here is + * what keeps this transport symmetric with the real codec; without it a `Modem.Signal` + * RAT dict decodes into a shape no daemon ever sends and the suite proves nothing. + */ function decodeVariant(variant: EncodeVariant): DbusValue { - return { signature: variant[0], value: variant[1] as DbusValue }; + const [signature, value] = variant; + if (signature.startsWith('a{s') && Array.isArray(value)) { + return { signature, value: decodeProps(value as readonly PropEntry[]) }; + } + return { signature, value: value as DbusValue }; } function decodeProps(props: readonly PropEntry[]): DbusValue { diff --git a/control/test-support/fake-mm/object-model.ts b/control/test-support/fake-mm/object-model.ts index 306f995..ebb6e2c 100644 --- a/control/test-support/fake-mm/object-model.ts +++ b/control/test-support/fake-mm/object-model.ts @@ -49,6 +49,10 @@ export type ManagedObject = readonly [string, readonly InterfaceEntry[]]; /** The full `a{oa{sa{sv}}}` GetManagedObjects payload. */ export type ManagedObjects = readonly ManagedObject[]; +/** The six per-RAT `a{sv}` properties of `Modem.Signal`, in introspection order. */ +export const MM_SIGNAL_RATS = ['Cdma', 'Evdo', 'Gsm', 'Umts', 'Lte', 'Nr5g'] as const; +export type MmSignalRat = (typeof MM_SIGNAL_RATS)[number]; + /** A visible network row returned by `Modem3gpp.Scan` (`a{sv}`). */ export interface ScannedNetworkEntry { readonly operatorCode: string; @@ -102,6 +106,17 @@ export interface ModemSpec { readonly bearerIndex?: number; /** Whether this modem exposes the `Modem.Signal` interface (default true). */ readonly hasSignal?: boolean; + /** + * Extended-signal readings per RAT, as `Modem.Signal` publishes them. + * + * MM exports all six `a{sv}` properties on every `Modem.Signal` interface and leaves + * the ones the modem is not attached on EMPTY, so a RAT omitted here is served as an + * empty dict rather than as a missing property — the difference the observation layer + * reads as "the modem did not say" instead of "nobody asked". + */ + readonly extendedSignal?: Readonly< + Partial>>> + >; /** MMModemLock currently required (`Modem.UnlockRequired`); default NONE. */ readonly unlockRequired?: number; /** Remaining attempts per lock (`Modem.UnlockRetries`, `a(uu)`). */ @@ -205,8 +220,20 @@ export function simProps(sim: SimSpec): readonly PropEntry[] { /** The `Modem.Signal` interface property set — its mere presence is what the * Signal.Setup manager gates on (absent ⇒ `signalCadence: unsupported`). */ -export function signalProps(): readonly PropEntry[] { - return [['Rate', ['u', 0]]]; +export function signalProps(spec?: ModemSpec): readonly PropEntry[] { + const readings = spec?.extendedSignal ?? {}; + return [ + ['Rate', ['u', 0]], + ...MM_SIGNAL_RATS.map( + (rat): PropEntry => [ + rat, + [ + 'a{sv}', + Object.entries(readings[rat] ?? {}).map(([member, value]) => [member, ['d', value]]), + ], + ], + ), + ]; } export function locationProps(spec: ModemSpec): readonly PropEntry[] { @@ -237,7 +264,7 @@ export function modemObject(spec: ModemSpec, shape: MmShape): ManagedObject { [MODEM3GPP_IFACE, modem3gppProps(spec)], ]; if (spec.hasSignal !== false) { - interfaces.push([SIGNAL_IFACE, signalProps()]); + interfaces.push([SIGNAL_IFACE, signalProps(spec)]); } if (spec.location !== undefined) interfaces.push([LOCATION_IFACE, locationProps(spec)]); if (spec.messaging === true) interfaces.push([MESSAGING_IFACE, []]); diff --git a/control/test-support/observation-fixtures.ts b/control/test-support/observation-fixtures.ts index e26b83b..200fd4a 100644 --- a/control/test-support/observation-fixtures.ts +++ b/control/test-support/observation-fixtures.ts @@ -24,6 +24,7 @@ import type { HilinkObservationInput, ModemManagerObservationInput, NormalizationContext, + RawFieldValue, UfiObservationInput, ZteObservationInput, } from '../src/observations'; @@ -49,10 +50,25 @@ export function fixtureContext( } /** - * A registered ModemManager modem. + * One `Modem.Signal` RAT dict in the shape the retention layer keeps it. * - * `Modem.Ports` and `Modem3gpp.Pco` are the vendor-specific extras here: real MM + * A D-Bus `a{sv}` decodes to `[key, value][]`, never to a flat object, so a fixture that + * spelled these as objects would be testing a payload ModemManager never sends. + */ +function signalDict(members: Readonly>): RawFieldValue { + return Object.entries(members); +} + +/** + * A registered ModemManager modem, attached 5G NSA. + * + * `Modem.Ports`, `Modem3gpp.Pco` and `Signal.Rate` are the extras here: real MM * properties that this layer's normalized model has no slot for. + * + * `Signal` carries the REAL interface shape — one `a{sv}` per RAT. `Nr5g` and `Lte` are + * both populated because an NSA attach populates both, which is exactly the case where + * the RAT ladder has to choose and provenance has to say which it chose. Every remaining + * dict is present-and-empty, as MM exports them for RATs the modem is not on. */ export const MM_FIXTURE: ModemManagerObservationInput = { modem: { @@ -78,14 +94,51 @@ export const MM_FIXTURE: ModemManagerObservationInput = { OperatorName: 'CLARO COL', }, signal: { - rssi: -71, - rsrp: -98.5, - rsrq: -11, - snr: 6.5, - refresh_rate: 5, + Rate: 5, + Nr5g: signalDict({ rsrp: -98.5, rsrq: -11, snr: 6.5, 'error-rate': 0 }), + Lte: signalDict({ rssi: -71, rsrp: -104, rsrq: -13.5, snr: 4, 'error-rate': 0 }), + Cdma: [], + Evdo: [], + Gsm: [], + Umts: [], }, }; +/** + * A CDMA/EV-DO reading — `Evdo` is the ONE `Modem.Signal` dict MM defines `sinr` on. + * + * It exists so the SINR claim is proven against the dict that really carries it rather + * than asserted against an LTE payload that never could. + */ +export const MM_EVDO_SIGNAL_FIXTURE: ModemManagerObservationInput = { + modem: { + Model: 'MC7354', + Manufacturer: 'Sierra Wireless', + State: 8, + SignalQuality: [44, true], + }, + signal: { + Rate: 5, + Evdo: signalDict({ rssi: -83, ecio: -2.5, sinr: 9.5, io: -95, 'error-rate': 0 }), + Cdma: signalDict({ rssi: -83, ecio: -2.5, 'error-rate': 0 }), + Gsm: [], + Umts: [], + Lte: [], + Nr5g: [], + }, +}; + +/** + * The `Modem.Signal` interface READ, with every RAT dict empty. + * + * This is the honest shape of a modem that has not reported extended signal yet — the + * interface exists, the reading does not. It must never normalize to a zero. + */ +export const MM_SIGNAL_SILENT_FIXTURE: ModemManagerObservationInput = { + modem: { Model: 'RM530N-GL', Manufacturer: 'Quectel', State: 8 }, + signal: { Rate: 5, Cdma: [], Evdo: [], Gsm: [], Umts: [], Lte: [], Nr5g: [] }, +}; + /** * A HiLink dongle answering both bodies. * From 312f7687962054565e5c3aaf4ef050ea993c6dbe Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:18:59 -0500 Subject: [PATCH 15/19] =?UTF-8?q?docs:=20compatibility=20matrix=20(vendor?= =?UTF-8?q?=20=C3=97=20firmware=20=C3=97=20composition=20=C3=97=20operatio?= =?UTF-8?q?n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 27 +++++ README.md | 8 ++ docs/COMPAT-MATRIX.md | 253 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 docs/COMPAT-MATRIX.md diff --git a/AGENTS.md b/AGENTS.md index bbb8e7b..b5353af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -583,6 +583,33 @@ one module behave differently from another on Linux, with a citation for every c READ, or a doc — never an AT command, a QMI/MBIM write, a composition switch, or a band lock. An unsourced operator report is recorded AS unsourced and claims nothing. +### `docs/COMPAT-MATRIX.md` — the one tracked support matrix + +[`docs/COMPAT-MATRIX.md`](docs/COMPAT-MATRIX.md) is the single vendor × firmware × +composition × operation matrix: 22 hardware rows (the six long-standing vendor families plus +the Sierra, Telit, u-blox and NETGEAR groundwork rows) against 18 operations spanning first +enumeration through a sustained bonded uplink. + +- **Every claim cell is a member of the five-state ladder in `capability/support-claim.ts`, + and there is no second status vocabulary.** No "partial", no "works", no tick-and-cross. + 198 cells, all of them `implemented` or `unavailable`; `enabled` / `capable` / `certified` + appear in the matrix nowhere, for exactly the reason `VENDOR-QUIRKS.md` is capped the same + way. It follows that **no combination in the repository is `certified`, so none may be + described as supported.** +- **It states which claims are hardware-free and which are hardware-required**, and links + each hardware-required operation to its RB runbook. `BENCH.md` remains the sole owner of + per-runbook status; the matrix links and restates none of it. A cell is raised by a bench + capture plus a reviewed commit, never by an edit to the matrix. +- **The NETGEAR LB1120 gap is recorded rather than smoothed over**: the row labels the + family `router-webui`, but with no positive cellular evidence the tether classifies + `wired-ethernet`, so no operation in this stack reaches it. Its column is mostly + `unavailable` as a consequence, and the phone-tether row reads the same way for the same + reason. +- It also records one discrepancy in `VENDOR-QUIRKS.md`: the Sierra row's "no `AT!` form + exists anywhere in this repository" is true of the `providers/ufi-himi` gate it cites, but + `usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP` forms, which is + what makes the composition-switch operation `implemented` for Sierra. + ## USB-COMPOSITION SWITCH — RUNTIME OFFER, TIERED PROOF The ModemManager provider's `usbComposition` operation derives its targets from the diff --git a/README.md b/README.md index e8aa5ea..b18ef41 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ report a Wi-Fi dongle as an uplink. No Telit, u-blox, or NETGEAR provider exists 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, diff --git a/docs/COMPAT-MATRIX.md b/docs/COMPAT-MATRIX.md new file mode 100644 index 0000000..f37c30a --- /dev/null +++ b/docs/COMPAT-MATRIX.md @@ -0,0 +1,253 @@ +# Compatibility matrix — vendor × firmware × composition × operation + +One tracked matrix for the whole modem stack: which hardware rows this repository knows +about, which operations it has code for, and, for every cell, exactly how much is actually +known. It is the answer to "does CeraLive work with this modem", stated in the only +vocabulary that cannot overstate itself. + +Two documents sit either side of this one and are not duplicated here: + +- [`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md) is the sourced per-vendor reading list, with a + pinned citation for every behavioural claim. When you want to know *why* a cell below + reads the way it does, that is the document, organised as one `Quirk | Evidence | Posture` + table per vendor behind a "how to read the posture column" preamble, a pinned six-key + SOURCES table, and a closing "what this does NOT license". Do not re-derive its content + into this file. +- [`BENCH.md`](BENCH.md) is the runbook ladder RB-1 … RB-18 and the per-SKU status ledger. + **Every hardware evidence claim lives there and only there.** This matrix links to it and + deliberately restates none of its per-runbook status. + +## The only vocabulary in use + +Every claim cell below is one member of the five-state support-claim ladder in +[`control/src/capability/support-claim.ts`](../control/src/capability/support-claim.ts): + +``` +unavailable → implemented → enabled → capable → certified +``` + +- **`unavailable`** — not shipped in this build, or the device positively lacks it. It is + the rung *below* `implemented`, never above it. +- **`implemented`** — code exists in this repository, gate OFF, unproven on the device. +- **`enabled`** — gate ON, capability unknown. +- **`capable`** — gate ON, device advertises it. The floor for offering a control. +- **`certified`** — proven on this exact model and firmware. The only rung a support matrix + may claim as support. + +**No cell in this document exceeds `implemented`, and `enabled` / `capable` / `certified` +appear nowhere in the matrix at all.** That is not modesty. `enabled` is a statement about a +deployment's gate settings, which a document does not have; `capable` needs a live probe +against one device; `certified` needs a completed hardware drill. A document produces none +of the three. The rung a row reaches is raised by a bench capture under +[`BENCH.md`](BENCH.md), never by an edit here. + +**There is no second status vocabulary.** No "partial", no "works", no "untested", no +tick-and-cross. Those words are how a matrix comes to promise a combination nobody ever +ran, which is the exact failure the ladder was built to prevent. If a cell needs more +nuance than the ladder carries, it gets a footnote, not a new word. + +One column below does use a different vocabulary, and it is labelled as such: the FCC state +in Table A is `fcc/coverage.ts`'s coverage tri-state (`present` / `absent` / `unknown`), +which answers "does ModemManager ship an unlock procedure keyed on these ids". It is a fact +about MM's catalog, not a support claim, and it is deliberately kept out of Table B. + +--- + +## Table A — the hardware rows + +Vendor and model, firmware/SKU including FCC state, and protocol/composition. The evidence +column names the classifier tier that placed the row; `family` is the Table B column the row +answers under. + +| # | Vendor / model | Firmware / SKU, FCC state | Protocol / composition | Classifier evidence | Family | +|---|---|---|---|---|---| +| 1 | Quectel RM530N-GL | `RM530NGLAAR05A01M4G`; FCC coverage `absent` | QMI raw-IP (`2c7c`) | vendor id, whole range cellular | Quectel | +| 2 | SIMCom SIM7600G-H | firmware unrecorded on the bench; FCC coverage `absent` | QMI raw-IP (`1e0e`); one Zero-CD id `1e0e:f000` | vendor id | SIMCom | +| 3 | Fibocom FM350-GL, carrier-mounted | carrier id `0e8d:7127`; FCC coverage `unknown` | RNDIS over the carrier's USB composition | no classifier row, deliberately | Fibocom | +| 4 | Fibocom FM350-GL, native | PCI `14c3:4d75` under `mtk_t7xx`; FCC coverage `unknown` | PCIe, not USB | none; documented-deferred | see note **F** | +| 5 | Sierra EM74xx | `1199:9071`, `1199:907b`; FCC coverage `absent` for these ids | MBIM / QMI | `mainline-kernel` | Sierra | +| 6 | Sierra EM74xx, FCC-locked ids | `1199:9079`, `03f0:4e1d` (HP), `413c:81a3` / `413c:81a8` (Dell); FCC coverage `present` | MBIM / QMI | `modemmanager-1.24.2-fcc` | Sierra | +| 7 | Sierra EM75xx | `1199:9091`, `1199:c081`; FCC coverage `absent` | MBIM / QMI | `mainline-kernel` | Sierra | +| 8 | Sierra EM919x | `1199:90d3`; FCC coverage `absent` | MBIM / QMI | `mainline-kernel` | Sierra | +| 9 | Telit module families | `1bc7:` `1031` LE910C1-EUX, `1034` LE910C4-WWX, `1040` LE922A, `1050` FN980, `1060` LN920, `1070` FN990A, `1080` FE990A, `10a0` FN920C04, `1100` ME910, `1200` LE920; FCC coverage `absent` | one application PID per composition: QMI rmnet, MBIM, RNDIS, ECM | `mainline-kernel` | Telit | +| 10 | u-blox LARA-R6 | `1546:1311` / `1312` / `1313`; FCC coverage `absent` | per-PID composition | `mainline-kernel` | u-blox | +| 11 | u-blox LARA-L6 | `1546:1341` / `1342` / `1343`; FCC coverage `absent` | per-PID composition | `mainline-kernel` | u-blox | +| 12 | NETGEAR LB1120 | `0846:68e1`, `familyKind: 'router-webui'`; FCC coverage `absent` | Ethernet-over-USB tether plus a vendor web UI | `usb-ids-registry` | NETGEAR | +| 13 | Huawei E3372H HiLink | firmware `22.200.05.00.1080`, password type 3; FCC coverage `absent` | CDC-ECM plus HTTP web UI, no control port | vendor id `12d1` | Huawei | +| 14 | Huawei E3372H HiLink | firmware `22.333.01.00.00`, password type 4; FCC coverage `absent` | CDC-ECM plus HTTP web UI, no control port | vendor id `12d1` | Huawei | +| 15 | Huawei Zero-CD installer personality | e.g. `12d1:1f01`, target `14db`; FCC coverage `absent` | mass-storage installer until mode-switched | vendor id plus a `usb_modeswitch` trigger | Huawei | +| 16 | ZTE MF79U | legacy base64 under `LOGIN`; FCC coverage `absent` | router web UI (`goform`), Zero-CD lineage | vendor id `19d2` | ZTE | +| 17 | ZTE MF79U | `LD`-salted SHA-256 under the same bare `LOGIN`; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 18 | ZTE MF266 | salted SHA-256 under `LOGIN_MULTI_USER`, `stok` + `RD` + derived `AD`; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 19 | ZTE, unrecognised firmware | fingerprinted, read-only telemetry profile; FCC coverage `absent` | router web UI (`goform`) | vendor id `19d2` | ZTE | +| 20 | Qualcomm UFI / HIMI | `05c6:9091`, measured four-interface composition; FCC coverage `absent` | QMI plus an ADB-class interface; HIMI HTTP telemetry | vendor id `05c6` | UFI/HIMI | +| 21 | Qualcomm UFI / HIMI | `05c6:9024`; FCC coverage `absent` | RNDIS plus ADB | vendor id `05c6` | UFI/HIMI | +| 22 | Phone tether, any vendor | not applicable; FCC coverage `absent` | RNDIS or ECM/NCM data interface, no control interface | none; no positive cellular evidence | Tether | + +**Note F — the FM350-GL is two rows because it is two devices.** Row 3 is the carrier board +that presents it over USB; row 4 is the native PCIe part under `mtk_t7xx`, which has no USB +VID:PID at all and therefore no classifier row, on purpose. Every Table B claim for row 4 is +`unavailable`: this stack's enumeration, classification and identity derivation are all USB +snapshot driven, so a PCIe part reaches none of them. That is documented-deferred rather than +broken; see [`FM350-DECISION.md`](FM350-DECISION.md). + +**Note N — the NETGEAR row is a family label, and its device class is an open gap.** The row +labels the LB1120 `router-webui`, which is a positive claim about the family and nothing +more. The device class is decided by interfaces, not by the family row, and `0846` is +deliberately absent from `CELLULAR_USB_VENDOR_IDS` (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 a +cellular uplink). With no kernel modem driver claiming `68e1` and no modeswitch trigger, +`classifyUsbNetDevice` finds no positive cellular evidence and answers **`wired-ethernet`, +not `router-cellular`**. So an attached LB1120 is presently not recognised as a cellular +uplink at all. This is recorded as a known gap rather than papered over, and it is why the +NETGEAR column below is mostly `unavailable`: no operation in this stack reaches a device it +does not classify as cellular. Row 22 reads the same way for the same reason, and that shared +reason is the point: a phone's RNDIS tether and a USB Ethernet dongle are identical on the +wire. + +--- + +## Table B — operation × family + +Eighteen operations, from first enumeration through a sustained bonded uplink. Columns are +the Table A families. Read every cell as a claim about *this repository's code*, at the rung +the ladder allows a document to state. + +| # | Operation | Quectel | SIMCom | Fibocom | Sierra | Telit | u-blox | NETGEAR | Huawei | ZTE | UFI/HIMI | Tether | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | Enumerate: USB descriptors, `/sys` composition, udev properties | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 2 | Classify: device class from interfaces and drivers | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 3 | Identity: `PhysicalModemId` stable across replug | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | implemented | +| 4 | Control surface: an MM control port, or a vendor HTTP session | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 5 | SIM presence as evidence | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 6 | SIM unlock: PIN and PUK | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 7 | Registration and cell context read | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 8 | Signal read, including the per-RAT extended metrics | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | +| 9 | Radio capability read: mode and band catalogs | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 10 | Mode write, including the 5G preference postures | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 11 | Band lock write, certification-gated | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 12 | USB composition switch | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | +| 13 | FCC auto-unlock policy reconciliation | unavailable | unavailable | unavailable | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | unavailable | +| 14 | SMS list and read, never send or delete | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 15 | USSD session | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 16 | GNSS fix, live only, no history | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | unavailable | unavailable | unavailable | unavailable | +| 17 | Bearer and APN activation | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | unavailable | unavailable | unavailable | +| 18 | Sustained bonded uplink: flap, re-enumeration, applied-state recovery | implemented | implemented | implemented | implemented | implemented | implemented | unavailable | implemented | implemented | implemented | unavailable | + +### Reading the cells that are easy to misread + +- **Operation 11 is `implemented`, not `unavailable`, and the shipped band catalog is + empty.** The code exists, gate off, and a band write is refused today on every device for + want of a four-proof catalog entry. `unavailable` would say "not shipped", which is false; + `capable` would say a modem advertised it, which no document may say. Band lock is also the + one module that requires `certified` rather than `capable` to be OFFERED at all, a + deliberate deviation from the framework floor, because a band the SIM's network does not + operate on registers nowhere and a modem that ignores a reset leaves an operator with no + way back short of a replug. +- **Operation 12 is `implemented` for exactly four vendors** — Fibocom, Quectel, SIMCom and + Sierra — because `RUNTIME_COMPOSITION_VENDORS` names those four and no others. A vendor + outside that set has no reviewed READ/TEST/SET form, so there is no code to claim. Targets + still come from the device's own enumeration and only when that enumeration proves a return + path. +- **Operation 13's `unavailable` is a positive statement, not a shrug.** `fcc/coverage.ts` + answers `absent` when the ids are well formed and are not in ModemManager's pinned mapping, + which is knowledge about the device. It answers `unknown` only when the ids could not be + read. Sierra is `implemented` because four Sierra-silicon ids are covered (under Sierra's + own, HP's and Dell's vendor ids), and because this repo records an opt-in policy and + re-derives MM's symlink; it ships no unlock script and performs no unlock. +- **Operations 5 and 6 are `unavailable` for every router family on purpose.** Huawei, ZTE + and the UFI each report their own SIM code (`SimStatus`, `simcard_state`, `simstate`) with + vendor semantics that no decoder here covers. The code stays verbatim in the diagnostics + block for a per-vendor provider to claim later with evidence. Guessing one would invent a + reading, which is the failure the observation layer exists to prevent. +- **Operation 18 is about this stack surviving a flap, not about bonding.** No bonding code + lives in this repository; SRTLA is elsewhere in the workspace. What is claimed here is + generation fencing, applied-state loss reporting, and re-promotion after a re-enumeration + on a leg this stack tracks. A family it classifies `wired-ethernet` is not a tracked + cellular leg, hence `unavailable` for NETGEAR and Tether. +- **A cell says nothing about a firmware that is not in Table A.** Family-level code is + generic and runtime discovered, so an unlisted PID within a listed vendor still reaches the + generic controls, but it gets no family label and no row here. Absence from Table A is + absence of a claim, never a claim of absence. + +### What the operation axis deliberately omits + +Data-usage metering is not an operation column, because it is measured from +`/proc/net/dev` and a local policy file and is therefore identical for every family that has +a tracked interface at all. Making it a column would add eleven cells that all say the same +thing for a non-vendor reason. Its accuracy gate is RB-6 in [`BENCH.md`](BENCH.md). + +### One discrepancy worth recording + +[`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md)'s Sierra section states that no `AT!` form exists +anywhere in this repository. That is true of the `providers/ufi-himi` static gate it cites, +which scans that provider's directory, but it is not true repo-wide: +`control/src/usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP?` / +`AT!USBCOMP=?` / `AT!USBCOMP=` forms in the composition registries, which is what makes +operation 12 `implemented` for Sierra. The forms are reviewed, allowlisted by name, and +gated by the same admission, journal, rollback and readback fences as every other +composition write, so the behaviour is intended. It is the fence's *scope* that the quirks +row overstates. Recorded here rather than silently corrected there. + +--- + +## Hardware-free versus hardware-required + +The split below is the whole reason this matrix can be trusted: it says exactly which claims +a green CI run establishes, and which ones only a bench device can. + +### Hardware-free — provable in CI, no device attached + +These are the claims Table B's `implemented` rungs actually rest on, and every one is +exercised by the workspace suite on any machine: + +- **Every Table A row's identity.** Vendor and model rows are exact VID:PID map entries with + a pinned evidence tier; a test asserts every tier a row uses has provenance recorded. No + vendor range is ever inferred, and an unlisted PID returns nothing. +- **Classification from a snapshot.** Interface-and-driver precedence, the `router-mode` and + `pending-modeswitch` and `wired-ethernet` branches, and the proof that a family label + decides no device class. Note N's LB1120 gap is a CI-provable fact, not a bench finding. +- **Physical identity derivation** across the serial → udev `ID_PATH` → bounded-fallback + ladder, including the constructors' refusal of interface names, addresses and subscriber + identifiers. +- **Response parsing and normalization** for every router family, over the canonical fixture + corpus, including the malformed, auth-expired and lockout variants. +- **Every refusal path.** Band-write certification refusal, composition suppression states, + the read-only SMS and UFI fences, the USSD illegal-verb refusals, the prohibited Qualcomm + operations answering before any transport contact. +- **FCC coverage lookups**, which are a table read against MM's pinned mapping. +- **The provider-matching conformance matrix**, including its 16-modem scale case, which is a + fixture upper bound and never a hardware claim. +- **The vocabulary cap itself**: that this document and the matrix above use only ladder + members. See the grep in the evidence note below. + +Nothing in this list can raise a cell above `implemented`, by construction. A fixture proves +that code behaves as written; it cannot prove a radio did anything. + +### Hardware-required — only a bench device can raise these + +Every operation from Table B row 5 onward needs an attached device, a live SIM, or both, +before its cell can move past `implemented`. The runbook that captures each one is in +[`BENCH.md`](BENCH.md) (RB-1 … RB-18), and that document owns the per-runbook status; this +matrix links and does not restate it. + +| Table B operations | What hardware is required | Where the evidence is captured | +|---|---|---| +| 1-3 | A device attached, for a real descriptor and udev capture; the derivations themselves are hardware-free | RB-9 fleet inventory, RB-2 slot-UID stability, RB-18 Sierra identity and composition | +| 4 | The device's control interface actually claimed by a driver and, for routers, a reachable web UI | RB-1 system-bus probe, RB-14 Huawei personality, RB-15 ZTE, RB-16 FM350 | +| 5-7 | A physical SIM, and a network the SIM registers on | RB-4 PIN/PUK, and the per-SKU captures RB-11 … RB-15 | +| 8-9 | A registered modem reporting live measurements | RB-11 … RB-15 | +| 10-12 | A disruptive write, its readback, and a re-enumeration | RB-5 certified USB-mode transition, RB-11 … RB-15 stage 2 | +| 13 | A covered Sierra unit, and a boot to prove the reconciler re-materialises the link | RB-12 Sierra locked-state capture, RB-18 | +| 14-16 | A SIM that can receive a message, open a session, and a GNSS antenna | RB-11 … RB-15 | +| 17-18 | A live bearer and a deliberate flap under load | RB-17 modem-flap resilience, RB-10 hub VBUS cycle, RB-6 usage accuracy | + +Two consequences follow, and both are deliberate: + +1. **No row in this matrix is `certified`, so no combination in it may be described as + supported.** `mayClaimSupport` returns true for exactly one rung, and no cell reaches it. +2. **A bench run raises a cell; an edit here does not.** A cell moves when a capture under + [`BENCH.md`](BENCH.md) lands and a human-reviewed commit records it, exactly like a + certified-catalog entry. Editing a cell without that evidence is the same error as + promoting a catalog entry from a synthetic bundle, which the ingestion code refuses + outright. From 655c195b364a39d3d0fc8b4976a1524ee0527b5d Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:30:02 -0500 Subject: [PATCH 16/19] feat: operator/cell registration context + counter-reset-aware usage rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive read surfaces, both fetched-and-verified against ModemManager 1.24.2 rather than written from recall. Registration and cell context. `NormalizedRadio` gains `operatorName` and `operatorCode`, read from `Modem3gpp` and never from `Sim` — the first is the operator the modem is registered with, the second is the home operator written into the SIM, and they disagree for the whole time a device is roaming. The code stays text because a two- versus three-digit MNC is a different network. A new `cell` block reports `cellId` and `tac`, decoded together out of the existing `3gpp-lac-ci` source's single five-token value; a value in any other shape fails both fields rather than decoding partially, and the uppercase hex is kept as written so an identifier matches what mmcli shows. The GNSS fence is untouched by design: `signal_location` stays false, `3gpp-lac-ci` stays outside `GNSS_SOURCES`, coarse cell context stays out of the coordinate redaction class, and nothing here enables a location source. One consequence is recorded rather than hidden — MM masks the `Location` property unless `signal_location` is true, so nothing populates the optional `location` input today and an MM observation honestly reads `not-observed` for `cell`. Cell identity that is wired runs through `Modem.GetCellInfo`, where `CellReading` now also carries `tac` and reads MM's real `ci` key ahead of the older `cell-id` spelling it had been reading instead. No EARFCN is claimed anywhere. MM publishes none generically; the only occurrences are per-cell, under `earfcn` for LTE and `nrarfcn` for 5GNR — two keys for two quantities, so one slot would have to merge them or pick a RAT. Counter-reset-aware throughput. `SlotUsageSnapshot` gains `rateBytesPerSecond`, and the key is omitted rather than set to zero whenever there was no interval to measure. Interface counters restart when the interface is re-created, and both obvious repairs report something untrue: clamping the negative delta shows an idle link that was carrying traffic, while dividing the raw post-reset value shows every byte since the interface came up as one interval's traffic. So a backwards counter reports no rate at all and the baseline is rebased in the same pass, which is what makes the next interval correct instead of inheriting the gap. Rates are never persisted — a same-boot reload resumes the cumulative baseline but restarts the rate unmeasured, because this process did not observe that interval's start. The counter-reset concept is credited to irlserver/modem-metrics (MIT); concepts adopted, no source code copied. `accounting.ts`'s private `sameKey` becomes the exported `sameBaselineKey` so the rate and the reducer cannot drift about what "same counter" means. Full workspace 1492 pass / 0 fail (from 1459), typecheck and lint clean, verify:tarball OK with the unchanged seven-entry public surface. --- AGENTS.md | 96 ++++++++ README.md | 23 ++ control/README.md | 55 +++++ control/src/backend/cell-info.test.ts | 27 +++ control/src/backend/cell-info.ts | 22 +- control/src/backend/usage/accounting.ts | 10 +- control/src/backend/usage/persistence.test.ts | 141 +++++++++++ control/src/backend/usage/persistence.ts | 12 + control/src/backend/usage/policy.ts | 13 +- control/src/backend/usage/sampler.ts | 20 +- control/src/backend/usage/sampling.test.ts | 218 ++++++++++++++++++ control/src/backend/usage/sampling.ts | 102 +++++++- control/src/domain/mm-enums.ts | 58 +++++ control/src/observations/model.ts | 46 ++++ .../observations/registration-context.test.ts | 216 +++++++++++++++++ control/src/observations/sources/hilink.ts | 8 + .../src/observations/sources/modemmanager.ts | 96 +++++++- .../src/observations/sources/router-shared.ts | 54 ++++- control/src/observations/sources/ufi.ts | 11 + control/src/observations/sources/zte.ts | 14 ++ control/test-support/observation-fixtures.ts | 10 + 21 files changed, 1236 insertions(+), 16 deletions(-) create mode 100644 control/src/backend/usage/persistence.test.ts create mode 100644 control/src/backend/usage/sampling.test.ts create mode 100644 control/src/observations/registration-context.test.ts diff --git a/AGENTS.md b/AGENTS.md index b5353af..7c43031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -481,6 +481,47 @@ near it. `SlotUsageSnapshot` gained an additive `cycleDay` so the read side reports the policy in force, not only its consequences. +### COUNTER-RESET-AWARE THROUGHPUT — AN ABSENT RATE IS A REAL ANSWER + +`SlotUsageSnapshot` also gained an additive `rateBytesPerSecond`, measured in +`backend/usage/sampling.ts` (`SlotRate`) and projected by `policy.ts`. The whole design is +one rule: **a rate exists only when this process observed BOTH ends of one sampling +interval over one unchanged counter.** Absent is not zero, and `projectUsageSnapshot` +OMITS the key rather than emitting `0` — an operator cannot tell an idle link from an +unmeasured one if both render as zero. + +- **A BACKWARDS COUNTER PRODUCES NO RATE AND REBASELINES.** `/proc/net/dev` counters + restart at zero when an interface is re-created — a replug, a `wwan0` teardown, a driver + reload. Both obvious repairs report something untrue: clamping the negative delta to 0 + shows an idle link that was carrying traffic, and dividing the raw post-reset value by + the interval shows every byte since the interface came up as if it had all moved inside + that one interval. Reporting nothing is the honest answer, and `applySample` rebases the + baseline in the SAME pass so the next interval measures correctly instead of inheriting + the gap. Both halves are pinned together — the rollback fixture asserts the absent rate + AND the correct attribution on the following sample. +- **Five other cases are equally unmeasured**, each for its own reason: the first sample + and a resume-from-pause are zero-delta rebaselines; a low-confidence slot attributes + nothing at all; a remap or reboot means the two values are two different counters + (`sameBaselineKey` is now exported from `accounting.ts` precisely so the rate and the + reducer cannot drift about what "same counter" means); an interface missing from the + counter table drops the rate sample, which deliberately costs the RETURN pass its rate + too; and a clock that did not advance yields no interval rather than `Infinity`. +- **NO RATE IS PERSISTED, and the negative is pinned by a test.** A throughput is a + measurement over an interval whose two ends one process observed; a restart observed + neither. Persisting the last rate would republish a pre-gap figure as current, and + persisting the baseline's sample TIME would invite the next sample to divide a whole + downtime's bytes by one interval. So the counter BASELINE resumes across a same-boot + reload — it is a cumulative total and still true — while the rate restarts unmeasured. +- **No history verb was added.** The sampler still reports the CURRENT window and the last + interval; there is no series, no retention and no export, and none may be added. + +Idea provenance: `irlserver/modem-metrics` (MIT) — concepts adopted, no source code +copied, recorded in [`docs/adr/ADR-STAY-TYPESCRIPT.md`](docs/adr/ADR-STAY-TYPESCRIPT.md). + +Coverage: `control/src/backend/usage/sampling.test.ts` (the rollback fixture plus every +unmeasured case) and `control/src/backend/usage/persistence.test.ts` (the persisted +negative, from both the bytes on disk and the behaviour after a reload). + ## CERTIFICATION EVIDENCE → CATALOG (evidence-gated, human-reviewed) A SKU reaches `control/src/usb-mode/certified-catalog.json` only through a captured @@ -1308,6 +1349,61 @@ absent-dict unknowns; `control/src/providers/modem-manager/signal-richness.test. the same claims over the real provider wire; `control/src/backend/signal-setup-rate.test.ts` for the injected rate and the once-per-(epoch, modem) issue count. +### REGISTRATION AND CELL CONTEXT — WHO WE ARE ATTACHED TO, AND TO WHICH CELL + +`NormalizedRadio` gained `operatorName` / `operatorCode`, and `NormalizedModemObservation` +gained an additive `cell` block (`NormalizedCell`: `cellId` + `tac`). Every slot is a +`NormalizedMetric`, so the four-state model and the capability-vs-read reason split apply +unchanged. Five facts are load-bearing: + +- **The operator comes from `Modem3gpp`, NEVER from `Sim`.** `Modem3gpp.OperatorName` is + the operator the modem is REGISTERED with; `Sim.OperatorName` is the HOME operator + written into the SIM. They agree on a home network and disagree for the entire time a + device is roaming — which is exactly when an operator reads the field. `MM_FIXTURE` + carries two DIFFERENT strings on purpose so reading the wrong interface fails a test + rather than looking right. +- **`operatorCode` is TEXT and is never derived from parts.** The MNC is two OR three + digits and the width is significant (`31001` ≠ `310001`). MM emits the code as one + fixed-width string and splits it back apart at exactly three characters. The ZTE + payload has `rmcc` and `rmnc` as separate UNPADDED fields, so joining them would name a + different network whenever the leading zero matters — every router source therefore + answers `not-reported` for the code while ZTE does claim the NAME. +- **TAC/CID come from the EXISTING `3gpp-lac-ci` source, and the GNSS fence is untouched.** + `decode3gppLacCi` (`domain/mm-enums.ts`) parses MM's own five-token string — verified + against 1.24.2's `libmm-glib/mm-location-3gpp.c`, whose serializer is + `g_strdup_printf ("%.3s,%s,%lX,%lX,%lX", …)`: MCC, MNC, then LAC/CI/TAC in uppercase + HEX. Values stay as that text; parsing the hex to decimal would render an identifier + matching nothing `mmcli` shows. A token count other than five decodes to NOTHING rather + than a partial record, and both `cellId` and `tac` fail together. `signal_location` + stays `false`, `3gpp-lac-ci` stays OUTSIDE `GNSS_SOURCES`, coarse cell context stays + outside the GNSS redaction class, and nothing on this path enables a location source. +- **Nothing supplies `location` today, and that is the fence rather than an oversight.** + MM masks the `Location` PROPERTY unless `Location.Setup` was called with + `signal_location = true` — permanently forbidden here — so the value must come from an + explicit `GetLocation()` call, which the provider's snapshot path does not make. An MM + observation therefore reads `not-observed` for `cell`: nobody looked. Cell identity that + IS wired runs through `Modem.GetCellInfo` → `backend/cell-info.ts`, a different method + needing no location source. `CellReading` gained `tac` there, and its cell identifier now + reads MM's REAL key `ci` first (`PROPERTY_CI` in `mm-cell-info-{lte,nr5g}.c`) with the + older `cell-id` spelling kept behind it as a fallback. +- **NO EARFCN IS CLAIMED ANYWHERE, and none may be added from these sources.** MM + publishes no generic ARFCN on `Modem`, `Modem3gpp` or `Location`; the only occurrences + are inside PER-CELL `GetCellInfo` dicts under two DIFFERENT keys for two different + quantities — `earfcn` (LTE) and `nrarfcn` (5GNR). A single normalized slot would have to + merge them or silently pick a RAT, so `NormalizedCell` and `CellReading` both make no + ARFCN claim and the raw keys stay available to a caller that needs them. + +Router sources claim only what a MIGRATED parser already decoded: ZTE claims the operator +name and `cell_id`, UFI claims `cell_id`, and HiLink claims neither — its `` tag +is retained verbatim in the diagnostics block's `unmapped` set rather than lifted into a +claim, the same rule that keeps `SimStatus` out of `sim.presence`. `tac` reads +`not-reported` for all three, never `unsupported`: no migrated parser decodes one, which +is a fact about this package, not a claim about the vendor's firmware. + +Coverage: `control/src/observations/registration-context.test.ts` (the decoder's token +rules, the Modem3gpp-vs-Sim distinction, the four unknown reasons, and the per-source +claims) plus the `ci` / `tac` / no-ARFCN cases in `control/src/backend/cell-info.test.ts`. + ## GPS / LOCATION — A LIVE FIX, AND DELIBERATELY NO HISTORY `control/src/ports/location.ts` + `control/src/location/` + diff --git a/README.md b/README.md index b18ef41..62b4269 100644 --- a/README.md +++ b/README.md @@ -174,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 diff --git a/control/README.md b/control/README.md index f24c4ee..d1ecab3 100644 --- a/control/README.md +++ b/control/README.md @@ -223,6 +223,61 @@ The `Signal.Setup` reporting rate is injectable — `signalIntervalSeconds` on each. `Setup` takes an unsigned integer, so a fractional or non-positive rate is refused at construction rather than marshalled. +### Registration and cell context + +`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. + +### Data-usage throughput — absent is not zero + +`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. + ### Mutation safety ports The root export includes `MutationAdmissionPort`, `ResourceOwnershipPort`, diff --git a/control/src/backend/cell-info.test.ts b/control/src/backend/cell-info.test.ts index 3fbb176..d09fbe9 100644 --- a/control/src/backend/cell-info.test.ts +++ b/control/src/backend/cell-info.test.ts @@ -83,6 +83,33 @@ describe('normalizeCellReading — pinned key mappings', () => { expect(read({ 'cell-type': 'lte-serving' }).serving).toBe(true); expect(read({ 'cell-type': 'lte-neighbor' }).serving).toBe(false); }); + + // MM 1.24.2 spells the cell identifier `ci` (`PROPERTY_CI` in both + // `libmm-glib/mm-cell-info-lte.c` and `mm-cell-info-nr5g.c`). `cell-id` is the + // flattened spelling this module read first, and it stays as the fallback so a + // source using it keeps decoding. + test('`ci` is the real MM key, and `cell-id` still decodes behind it', () => { + expect(read({ ci: '0A1B2C3D' }).cellId).toBe('0A1B2C3D'); + expect(read({ 'cell-id': 'LEGACY' }).cellId).toBe('LEGACY'); + expect(read({ ci: 'REAL', 'cell-id': 'LEGACY' }).cellId).toBe('REAL'); + }); + + test('`tac` is surfaced when the RAT reports one, and never derived otherwise', () => { + expect(read({ ci: '0A1B2C3D', tac: '4E5F' }).tac).toBe('4E5F'); + // A GSM/UMTS cell reports no tracking area; nothing is invented from `ci`. + expect(read({ ci: '0A1B2C3D' }).tac).toBeUndefined(); + }); + + // EARFCN is NOT read: MM publishes `earfcn` for LTE and `nrarfcn` for 5GNR — two + // keys for two quantities, and no generic property anywhere. One field here would + // have to merge them or pick a RAT, so the reading claims neither and both stay + // available to a caller that needs the raw dict. + test('an ARFCN key is retained by the source but claimed by no field', () => { + const reading = read({ ci: '0A1B2C3D', earfcn: 1850, nrarfcn: 630000 }); + expect(Object.keys(reading)).not.toContain('earfcn'); + expect(Object.keys(reading)).not.toContain('nrarfcn'); + expect(reading.cellId).toBe('0A1B2C3D'); + }); }); describe('selectServingCell — TOTAL order', () => { diff --git a/control/src/backend/cell-info.ts b/control/src/backend/cell-info.ts index a50709e..7b76d0b 100644 --- a/control/src/backend/cell-info.ts +++ b/control/src/backend/cell-info.ts @@ -8,11 +8,24 @@ // - `rsrp`, `rsrq` pass through as numbers // - `sinr` the REAL NR SINR key. A dict carrying `snr` (the // WRONG name) is IGNORED — `sinr` stays undefined. -// - `cell-id` the cell identifier (serving-cell tiebreak) +// - `ci`, else `cell-id` the cell identifier (serving-cell tiebreak) +// - `tac` the tracking-area code, when the RAT reports one // - `band` surfaced ONLY when the source supplies it directly; // never inferred from earfcn / frequency / anything. // - `serving` / `cell-type` whether this is the serving cell. // +// `ci` IS THE REAL MM KEY and `cell-id` is the fallback, not the other way round. +// ModemManager 1.24.2 spells it `PROPERTY_CI "ci"` in both `libmm-glib/mm-cell-info-lte.c` +// and `mm-cell-info-nr5g.c`; `cell-id` is kept behind it so a source that already +// flattened to that older spelling still decodes rather than silently losing its id. +// +// NO ARFCN IS READ HERE, and that is deliberate rather than pending. MM does publish +// one — but under TWO different keys for two different quantities: `earfcn` in an LTE +// cell dict and `nrarfcn` in a 5GNR one. There is no generic ARFCN property anywhere on +// the `Modem`, `Modem3gpp` or `Location` interfaces, so a single `earfcn` field on this +// reading would have to either merge the two or quietly pick a RAT. `CellReading` makes +// no ARFCN claim instead, and an unread key stays available to a caller that needs it. +// // Every reading also carries `source` + `observedAt` provenance, so a consumer can // tell a fresh reading from a cached one and know where it came from. Pure — no I/O. @@ -33,6 +46,8 @@ export interface CellReading { readonly serving: boolean; /** The cell identifier, when supplied (serving-cell tiebreak key). */ readonly cellId?: string; + /** Tracking-area code — from the `tac` key ONLY, never derived from the cell id. */ + readonly tac?: string; /** Physical cell id — from the `physical-ci` key ONLY. */ readonly pci?: number; readonly rsrp?: number; @@ -60,7 +75,8 @@ export function normalizeCellReading( cell: DecodedProps, provenance: CellInfoProvenance, ): CellReading { - const cellId = stringProp(cell, 'cell-id'); + const cellId = stringProp(cell, 'ci') ?? stringProp(cell, 'cell-id'); + const tac = stringProp(cell, 'tac'); const pci = numberProp(cell, 'physical-ci'); const rsrp = numberProp(cell, 'rsrp'); const rsrq = numberProp(cell, 'rsrq'); @@ -71,6 +87,7 @@ export function normalizeCellReading( return { serving: readServing(cell), ...(cellId !== undefined ? { cellId } : {}), + ...(tac !== undefined ? { tac } : {}), ...(pci !== undefined ? { pci } : {}), ...(rsrp !== undefined ? { rsrp } : {}), ...(rsrq !== undefined ? { rsrq } : {}), @@ -140,6 +157,7 @@ function stableTag(reading: CellReading): string { return JSON.stringify([ reading.serving, reading.cellId ?? null, + reading.tac ?? null, reading.pci ?? null, reading.rsrp ?? null, reading.rsrq ?? null, diff --git a/control/src/backend/usage/accounting.ts b/control/src/backend/usage/accounting.ts index bd8c03d..41f4a49 100644 --- a/control/src/backend/usage/accounting.ts +++ b/control/src/backend/usage/accounting.ts @@ -58,7 +58,13 @@ export function initialAccount(cycleStartMs: number): SlotAccount { return { cycleBytes: 0, cycleStartMs, paused: false }; } -function sameKey(a: BaselineKey, b: BaselineKey): boolean { +/** + * Whether two baseline keys name the SAME counter — a difference in any field is a + * remap. Exported because `sampling.ts` must ask the identical question before it + * divides a delta by an interval; two spellings of it would eventually disagree, and + * the rate is the half that would then be wrong silently. + */ +export function sameBaselineKey(a: BaselineKey, b: BaselineKey): boolean { return ( a.logicalSlotId === b.logicalSlotId && a.mappingGeneration === b.mappingGeneration && @@ -92,7 +98,7 @@ export function applySample(prior: SlotAccount | undefined, input: SampleInput): if (base.paused || base.key === undefined || base.lastObserved === undefined) { return { ...base, paused: false, key: input.key, lastObserved: input.current }; } - if (!sameKey(base.key, input.key)) { + if (!sameBaselineKey(base.key, input.key)) { return { ...base, paused: false, key: input.key, lastObserved: input.current }; } diff --git a/control/src/backend/usage/persistence.test.ts b/control/src/backend/usage/persistence.test.ts new file mode 100644 index 0000000..b4e8c6c --- /dev/null +++ b/control/src/backend/usage/persistence.test.ts @@ -0,0 +1,141 @@ +// What the persisted usage document carries — and the one thing it must not. +// +// The counter BASELINE resumes across a same-boot reload because a cumulative total is +// still true after a restart. A THROUGHPUT is not: it is a measurement over an interval +// whose two ends one process observed, and a restart observed neither. This suite pins +// that negative from both sides — the bytes on disk, and the behaviour after a reload. + +import { describe, expect, test } from 'bun:test'; +import { logicalSlotId } from '../../domain'; +import { persistedUsageState } from './persistence'; +import type { CounterSource } from './proc-net-dev'; +import { createUsageSampler, type UsageObservation } from './sampler'; +import { type PersistedUsage, USAGE_SCHEMA_VERSION, type UsageStore } from './store'; + +const SLOT_A = logicalSlotId('slot-a'); +const RATE_TOKENS = ['rate', 'bytesPerSecond', 'sampledAt', 'throughput']; + +class FakeCounters implements CounterSource { + readonly #map = new Map(); + set(ifname: string, value: number): void { + this.#map.set(ifname, value); + } + async read(): Promise> { + return new Map(this.#map); + } +} + +class MemStore implements UsageStore { + doc: PersistedUsage | null = null; + async load(bootId: string, nowMs: number): Promise { + return this.doc ?? { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; + } + async save(state: PersistedUsage): Promise { + this.doc = state; + } +} + +const obs = (ifname: string): UsageObservation => ({ + logicalSlotId: SLOT_A, + mappingGeneration: 0, + ifname, + confidence: 'high', + usage: {}, +}); + +describe('persisted usage state — no throughput reaches the disk', () => { + test('a slot that WAS measuring a rate serializes only the cumulative facts', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + let clock = 1_000_000; + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => clock, + }); + + counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + clock += 10_000; + counters.set('wwan0', 21_000); + await sampler.sample([obs('wwan0')]); + // Non-vacuity: there IS a live rate to leak at the moment of the flush. + expect(sampler.snapshot().slots[0]?.rateBytesPerSecond).toBe(2000); + + await sampler.flush(); + + const written = store.doc; + expect(written?.slots[0]?.cycleBytes).toBe(20_000); + expect(written?.slots[0]?.lastObserved).toBe(21_000); + const serialized = JSON.stringify(written); + for (const token of RATE_TOKENS) { + expect(serialized.toLowerCase()).not.toContain(token.toLowerCase()); + } + }); + + test('a same-boot reload resumes the baseline but restarts unmeasured', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + let clock = 1_000_000; + const options = { bootId: 'boot-1', source: counters, store, now: () => clock }; + + const first = await createUsageSampler(options); + counters.set('wwan0', 1000); + await first.sample([obs('wwan0')]); + clock += 10_000; + counters.set('wwan0', 21_000); + await first.sample([obs('wwan0')]); + await first.flush(); + + const reloaded = await createUsageSampler(options); + clock += 10_000; + counters.set('wwan0', 41_000); + await reloaded.sample([obs('wwan0')]); + + const resumed = reloaded.snapshot().slots[0]; + // The baseline survived: 41_000 - 21_000 is attributed, not the whole counter. + expect(resumed?.cycleBytes).toBe(40_000); + // The rate did not: this process never observed the start of that interval. + expect(resumed?.rateBytesPerSecond).toBeUndefined(); + + // And the pass after it — both ends now observed here — measures normally. + clock += 10_000; + counters.set('wwan0', 51_000); + await reloaded.sample([obs('wwan0')]); + expect(reloaded.snapshot().slots[0]?.rateBytesPerSecond).toBe(1000); + }); + + test('persistedUsageState emits no rate field even for a fully-populated account', () => { + const state = persistedUsageState( + 'boot-1', + 5000, + new Map([ + [ + 'slot-a', + { + cycleBytes: 42, + cycleStartMs: 1, + paused: false, + key: { + logicalSlotId: 'slot-a', + mappingGeneration: 0, + ifname: 'wwan0', + bootId: 'boot-1', + }, + lastObserved: 99, + }, + ], + ]), + ); + + expect(Object.keys(state.slots[0] ?? {}).sort()).toEqual([ + 'cycleBytes', + 'cycleStartMs', + 'ifname', + 'lastObserved', + 'logicalSlotId', + 'mappingGeneration', + ]); + }); +}); diff --git a/control/src/backend/usage/persistence.ts b/control/src/backend/usage/persistence.ts index a6dcb61..fa9f0aa 100644 --- a/control/src/backend/usage/persistence.ts +++ b/control/src/backend/usage/persistence.ts @@ -1,3 +1,15 @@ +// The persisted usage document — what survives a restart, and what deliberately does not. +// +// THROUGHPUT IS NOT PERSISTED, and its absence from `PersistedSlot` is a decision +// rather than an omission. A rate is a measurement over an interval whose two ends this +// process observed; a restart observed neither. Writing the last rate down would +// republish a figure measured before the gap as though it described now, and writing +// the baseline's sample TIME down would invite the next sample to divide a whole +// downtime's bytes by one sampling interval — the same invented spike `sampling.ts` +// refuses for a missing interface. So the counter BASELINE resumes across a same-boot +// reload (that is a cumulative total, and it is still true) while the rate restarts +// unmeasured. `persistence.test.ts` pins the negative. + import type { SlotAccount } from './accounting'; import type { PersistedSlot, PersistedUsage } from './store'; import { USAGE_SCHEMA_VERSION } from './store'; diff --git a/control/src/backend/usage/policy.ts b/control/src/backend/usage/policy.ts index cc6eb0c..a773420 100644 --- a/control/src/backend/usage/policy.ts +++ b/control/src/backend/usage/policy.ts @@ -4,6 +4,7 @@ import type { SlotAccount } from './accounting'; import { initialAccount } from './accounting'; import { cycleStart } from './billing-cycle'; import type { SlotUsageSnapshot, UsageSnapshot } from './sampler'; +import type { SlotRate } from './sampling'; export interface UsagePolicyState { readonly bootId: string; @@ -43,14 +44,23 @@ export function applyPolicy( return { cycleStartMs, cycleReset: true, dirty: true }; } +export interface UsageProjectionState { + readonly bootId: string; + readonly accounts: ReadonlyMap; + readonly policies: ReadonlyMap; + readonly rates: ReadonlyMap; +} + export function projectUsageSnapshot( - state: Pick, + state: UsageProjectionState, generatedAtMs: number, ): UsageSnapshot { const slots: SlotUsageSnapshot[] = []; for (const [slotId, account] of state.accounts) { const policy = state.policies.get(slotId); const thresholdBytes = policy?.thresholdBytes; + // An unmeasured interval OMITS the key rather than reporting 0 — see `SlotRate`. + const bytesPerSecond = state.rates.get(slotId)?.bytesPerSecond; slots.push({ logicalSlotId: slotId, cycleBytes: account.cycleBytes, @@ -59,6 +69,7 @@ export function projectUsageSnapshot( ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}), ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, + ...(bytesPerSecond !== undefined ? { rateBytesPerSecond: bytesPerSecond } : {}), }); } return { bootId: state.bootId, generatedAtMs, slots }; diff --git a/control/src/backend/usage/sampler.ts b/control/src/backend/usage/sampler.ts index 3537767..8b526fa 100644 --- a/control/src/backend/usage/sampler.ts +++ b/control/src/backend/usage/sampler.ts @@ -16,7 +16,7 @@ import type { SlotAccount } from './accounting'; import { hydrateUsageAccounts, persistedUsageState } from './persistence'; import { applyPolicy, projectUsageSnapshot } from './policy'; import type { CounterSource } from './proc-net-dev'; -import { applyUsageSamples } from './sampling'; +import { applyUsageSamples, type SlotRate } from './sampling'; import type { PersistedUsage, UsageStore } from './store'; /** One slot's observation for a sampling pass — identity + mapping + local policy. */ @@ -42,6 +42,12 @@ export interface SlotUsageSnapshot { readonly thresholdBytes?: number; /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */ readonly thresholdExceeded: boolean; + /** + * Throughput over the last measured sampling interval. ABSENT — never 0 — when + * this pass had no interval to measure: a first sample, a rebaseline, a paused + * slot, a missing interface, or a counter that went BACKWARDS. See `SlotRate`. + */ + readonly rateBytesPerSecond?: number; } /** The sampler's current state, per slot, at a point in time. */ @@ -83,6 +89,10 @@ export class UsageSampler { // revert. The durable store is the source of truth for both, so an override and // an observation can only ever disagree inside that window. readonly #policyOverrides = new Map(); + // Rates are IN-MEMORY ONLY and start empty on every construction — deliberately, + // see `persistence.ts`. A throughput is a measurement over an interval this + // process observed both ends of; a restart has observed neither. + readonly #rates = new Map(); #lastPersistMs: number; #dirty = false; @@ -115,6 +125,7 @@ export class UsageSampler { accounts: this.#accounts, policies: this.#policies, policyOverrides: this.#policyOverrides, + rates: this.#rates, }, observations, counters, @@ -127,7 +138,12 @@ export class UsageSampler { /** Current per-slot usage — the queryable snapshot the CLI and platform read. */ snapshot(): UsageSnapshot { return projectUsageSnapshot( - { bootId: this.#bootId, accounts: this.#accounts, policies: this.#policies }, + { + bootId: this.#bootId, + accounts: this.#accounts, + policies: this.#policies, + rates: this.#rates, + }, this.#now(), ); } diff --git a/control/src/backend/usage/sampling.test.ts b/control/src/backend/usage/sampling.test.ts new file mode 100644 index 0000000..88846d8 --- /dev/null +++ b/control/src/backend/usage/sampling.test.ts @@ -0,0 +1,218 @@ +// Counter-reset-aware throughput — the rules that decide when a rate exists at all. +// +// Driven through the real `UsageSampler` rather than against `measureRate` directly, +// because the property under test spans three modules: `sampling.ts` decides there is +// no interval, `accounting.ts` rebases the baseline in the same pass, and `policy.ts` +// omits the key from the projected snapshot. A unit test of any one of them would pass +// while an operator still saw an invented spike. + +import { describe, expect, test } from 'bun:test'; +import { type DesiredUsage, logicalSlotId } from '../../domain'; +import type { CounterSource } from './proc-net-dev'; +import { createUsageSampler, type UsageObservation, type UsageSnapshot } from './sampler'; +import { type PersistedUsage, USAGE_SCHEMA_VERSION, type UsageStore } from './store'; + +const SLOT_A = logicalSlotId('slot-a'); + +class FakeCounters implements CounterSource { + readonly #map = new Map(); + set(ifname: string, value: number): void { + this.#map.set(ifname, value); + } + clear(ifname: string): void { + this.#map.delete(ifname); + } + async read(): Promise> { + return new Map(this.#map); + } +} + +class MemStore implements UsageStore { + doc: PersistedUsage | null = null; + async load(bootId: string, nowMs: number): Promise { + return this.doc ?? { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; + } + async save(state: PersistedUsage): Promise { + this.doc = state; + } +} + +function obs( + ifname: string, + confidence: 'high' | 'medium' | 'low' = 'high', + mappingGeneration = 0, + usage: DesiredUsage = {}, +): UsageObservation { + return { logicalSlotId: SLOT_A, mappingGeneration, ifname, confidence, usage }; +} + +function harness(store: UsageStore = new MemStore()) { + const counters = new FakeCounters(); + let clock = 1_000_000; + const build = () => + createUsageSampler({ bootId: 'boot-1', source: counters, store, now: () => clock }); + return { + counters, + store, + build, + advance: (ms: number) => { + clock += ms; + }, + at: () => clock, + }; +} + +const slot = (snapshot: UsageSnapshot) => snapshot.slots[0]; + +describe('usage throughput — a measured interval produces a rate', () => { + test('a steady climb over a known interval reports bytes per second', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 21_000); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(2000); + }); + + test('the FIRST sample reports no rate — a rebaseline is not a measurement', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + + const first = slot(sampler.snapshot()); + expect(first?.rateBytesPerSecond).toBeUndefined(); + expect(first?.cycleBytes).toBe(0); + }); + + test('a clock that did not advance yields no rate rather than Infinity', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.counters.set('wwan0', 5000); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); +}); + +describe('usage throughput — A COUNTER RESET (the rollback fixture)', () => { + test('a backwards counter reports NO rate, and the next interval is measured correctly', async () => { + const h = harness(); + const sampler = await h.build(); + + // Two honest intervals first, so the assertions below cannot pass vacuously. + h.counters.set('wwan0', 1_000_000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 1_050_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(5000); + expect(slot(sampler.snapshot())?.cycleBytes).toBe(50_000); + + // THE RESET: the interface was re-created and its counter restarted near zero. + h.advance(10_000); + h.counters.set('wwan0', 4000); + await sampler.sample([obs('wwan0')]); + + const reset = slot(sampler.snapshot()); + // No rate at all — not a negative one, and not 4000/10s either, which would + // report the whole post-reset total as if it had moved in this one interval. + expect(reset?.rateBytesPerSecond).toBeUndefined(); + // The cycle total is untouched: the reset attributed nothing. + expect(reset?.cycleBytes).toBe(50_000); + + // THE REBASELINE IS THE OTHER HALF. The next interval must be measured from + // the post-reset value (4000), never from the stale pre-reset one. + h.advance(10_000); + h.counters.set('wwan0', 34_000); + await sampler.sample([obs('wwan0')]); + + const recovered = slot(sampler.snapshot()); + expect(recovered?.rateBytesPerSecond).toBe(3000); + expect(recovered?.cycleBytes).toBe(80_000); + }); + + test('a counter resetting to exactly zero is still a reset, not a full-total rate', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 900_000); + await sampler.sample([obs('wwan0')]); + h.advance(5000); + h.counters.set('wwan0', 0); + await sampler.sample([obs('wwan0')]); + + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); +}); + +describe('usage throughput — every other unmeasurable interval', () => { + test('an ambiguous-identity pause reports no rate, and neither does the resume', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + h.counters.set('wwan0', 99_000); + await sampler.sample([obs('wwan0', 'low')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + // The resume is a zero-delta rebaseline, so it has no interval either. + h.advance(10_000); + h.counters.set('wwan0', 100_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); + + test('a remap reports no rate — the two counters are not one counter', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + await sampler.sample([obs('wwan0', 'high', 1)]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + }); + + test('an interface missing from the counter table costs the RETURN sample its rate too', async () => { + const h = harness(); + const sampler = await h.build(); + h.counters.set('wwan0', 1000); + await sampler.sample([obs('wwan0')]); + h.advance(10_000); + h.counters.set('wwan0', 3000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(200); + + h.advance(10_000); + h.counters.clear('wwan0'); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + // Whatever moved while the interface was gone did not move inside ONE + // interval, so the pass that finds it again still reports nothing. + h.advance(10_000); + h.counters.set('wwan0', 500_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBeUndefined(); + + h.advance(10_000); + h.counters.set('wwan0', 510_000); + await sampler.sample([obs('wwan0')]); + expect(slot(sampler.snapshot())?.rateBytesPerSecond).toBe(1000); + }); +}); diff --git a/control/src/backend/usage/sampling.ts b/control/src/backend/usage/sampling.ts index 9a66a10..c6613a4 100644 --- a/control/src/backend/usage/sampling.ts +++ b/control/src/backend/usage/sampling.ts @@ -1,15 +1,100 @@ import type { DesiredUsage } from '../../domain'; import { epochMillis } from '../../domain'; -import { applySample, type BaselineKey, initialAccount, type SlotAccount } from './accounting'; +import { + applySample, + type BaselineKey, + initialAccount, + type SlotAccount, + sameBaselineKey, +} from './accounting'; import { cycleStart } from './billing-cycle'; import type { UsageObservation } from './sampler'; +/** + * One slot's throughput measurement. + * + * `sampledAtMs` is when the counter behind the current baseline was read; without it + * there is no interval to divide a delta by. `bytesPerSecond` is ABSENT — never zero — + * whenever this pass had no measurable interval, and that is a frequent, real answer: + * the first sample, a rebaseline, a paused slot, an interface missing from the counter + * table, and a counter that went BACKWARDS each produce one. + * + * The backwards case is the one worth spelling out. `/proc/net/dev` counters are + * per-interface and cumulative, and they restart at zero when the interface is + * re-created — a modem replug, a `wwan0` teardown, a driver reload. Subtracting across + * that boundary yields a negative number, and both obvious repairs report something + * untrue: clamping to zero shows an idle link that was in fact carrying traffic, while + * taking the raw post-reset value shows every byte since the interface came up as if it + * had all moved inside one sampling interval. Reporting NOTHING is the honest answer, + * and `applySample` rebases the baseline in the same pass so the NEXT interval measures + * correctly rather than inheriting the gap. + * + * Idea provenance: `irlserver/modem-metrics` (MIT) — concepts adopted, no source code + * copied. See `docs/adr/ADR-STAY-TYPESCRIPT.md`. + */ +export interface SlotRate { + readonly sampledAtMs: number; + readonly bytesPerSecond?: number; +} + export interface UsageSamplingState { readonly bootId: string; readonly defaultCycleDay: number; readonly accounts: Map; readonly policies: Map; readonly policyOverrides: ReadonlyMap; + readonly rates: Map; +} + +interface RateInput { + readonly key: BaselineKey; + readonly current: number; + readonly confidence: UsageObservation['confidence']; +} + +/** + * Measure one interval, or state that there was none. Pure — the caller supplies the + * prior account, the prior rate sample and `now`. Every early return is a case where a + * number could be produced but would not be a measurement; see `SlotRate`. + */ +function measureRate( + prior: SlotAccount | undefined, + priorRate: SlotRate | undefined, + input: RateInput, + now: number, +): SlotRate { + const unmeasured: SlotRate = { sampledAtMs: now }; + if (input.confidence === 'low') { + return unmeasured; + } + if ( + prior === undefined || + prior.paused || + prior.key === undefined || + prior.lastObserved === undefined + ) { + return unmeasured; + } + if (!sameBaselineKey(prior.key, input.key)) { + return unmeasured; + } + // No prior rate sample means no interval was ever measured under this baseline — + // it was restored from disk, or the interface was absent from the last pass. The + // elapsed wall time is then a gap, not a sampling interval. + if (priorRate === undefined) { + return unmeasured; + } + if (input.current < prior.lastObserved) { + return unmeasured; + } + const elapsedMs = now - priorRate.sampledAtMs; + if (elapsedMs <= 0) { + return unmeasured; + } + return { + sampledAtMs: now, + bytesPerSecond: ((input.current - prior.lastObserved) * 1000) / elapsedMs, + }; } export function applyUsageSamples( @@ -29,6 +114,11 @@ export function applyUsageSamples( if (!state.accounts.has(slotId)) { state.accounts.set(slotId, initialAccount(cycleStartMs)); } + // The counter was not readable this pass. Dropping the rate sample costs + // the NEXT pass its rate too, which is the point: whatever moved while the + // interface was missing did not move inside one sampling interval, and + // dividing it by one would render an invented spike. + state.rates.delete(slotId); continue; } const key: BaselineKey = { @@ -37,12 +127,10 @@ export function applyUsageSamples( ifname: observation.ifname, bootId: state.bootId, }; - const next = applySample(state.accounts.get(slotId), { - key, - current, - confidence: observation.confidence, - cycleStartMs, - }); + const prior = state.accounts.get(slotId); + const rateInput: RateInput = { key, current, confidence: observation.confidence }; + state.rates.set(slotId, measureRate(prior, state.rates.get(slotId), rateInput, now)); + const next = applySample(prior, { ...rateInput, cycleStartMs }); state.accounts.set(slotId, next); } } diff --git a/control/src/domain/mm-enums.ts b/control/src/domain/mm-enums.ts index 9c33722..4cc1576 100644 --- a/control/src/domain/mm-enums.ts +++ b/control/src/domain/mm-enums.ts @@ -141,3 +141,61 @@ export function runtimeIdFromPath(path: string): number | undefined { const value = /\/(\d+)$/.exec(path)?.[1]; return value === undefined ? undefined : Number.parseInt(value, 10); } + +/** + * The coarse registration context ModemManager's `3gpp-lac-ci` location source reports. + * + * Every field is kept as the SOURCE'S OWN TEXT. `locationAreaCode` / `cellId` / + * `trackingAreaCode` arrive as uppercase hexadecimal and a consumer that parsed them to + * a decimal number would render a different identifier than every other tool on the + * device shows; `mnc` is two OR three digits and its width is significant, so a numeric + * round-trip loses a leading zero that distinguishes two real operators. + * + * This is COARSE CELL location and is deliberately NOT a GNSS fix: it names a cell, not + * a position. It carries no coordinate, so it is outside the GNSS redaction class and + * outside `GNSS_SOURCES`, and decoding it enables nothing — `Location.Setup`'s + * `signal_location` argument stays false and this decoder never touches a mask. + */ +export interface Mm3gppLacCi { + readonly mcc: string; + readonly mnc: string; + readonly locationAreaCode: string; + readonly cellId: string; + readonly trackingAreaCode: string; +} + +// ModemManager 1.24.2 `libmm-glib/mm-location-3gpp.c` builds this value with +// g_strdup_printf ("%.3s,%s,%lX,%lX,%lX", operator_code, operator_code + 3, lac, ci, tac) +// so it is a STRING of exactly five comma-separated tokens — MCC, MNC, then LAC, CI and +// TAC in uppercase hex. Its own parser reads exactly `split[0]`..`split[4]`, so a value +// with a different token count is not a shape this build should guess at. +const LAC_CI_TOKENS = 5; + +/** + * Decode the `3gpp-lac-ci` location string, or `undefined` when it is not that shape. + * + * A token count other than five, or an empty MCC/MNC/CI, decodes to nothing rather than + * to a partially-populated record: a cell identifier that is wrong is worse than one + * that is missing, because only the second is visible as missing. + */ +export function decode3gppLacCi(value: string | undefined): Mm3gppLacCi | undefined { + if (value === undefined) return undefined; + const tokens = value.trim().split(','); + if (tokens.length !== LAC_CI_TOKENS) return undefined; + const [mcc, mnc, locationAreaCode, cellId, trackingAreaCode] = tokens.map((token) => + token.trim(), + ) as [string, string, string, string, string]; + if (mcc === '' || mnc === '' || cellId === '') return undefined; + return { mcc, mnc, locationAreaCode, cellId, trackingAreaCode }; +} + +/** + * The PLMN id (`Modem3gpp.OperatorCode`'s spelling) for a decoded `3gpp-lac-ci` value. + * + * MCC and MNC are concatenated with no separator and no padding — MM splits the operator + * code back apart at exactly three characters, so this is the inverse of its own + * serialization rather than a guess about the MNC's width. + */ +export function lacCiOperatorCode(decoded: Mm3gppLacCi): string { + return `${decoded.mcc}${decoded.mnc}`; +} diff --git a/control/src/observations/model.ts b/control/src/observations/model.ts index 398d40e..f942677 100644 --- a/control/src/observations/model.ts +++ b/control/src/observations/model.ts @@ -30,6 +30,51 @@ export type NormalizedRadio = { readonly registration: NormalizedMetric; readonly accessTechnologies: NormalizedMetric; readonly modeLabel: NormalizedMetric; + /** + * The operator the modem is REGISTERED WITH right now — `Modem3gpp.OperatorName`. + * + * Deliberately NOT `Sim.OperatorName`, which is the HOME operator written into the + * SIM. The two agree on a home network and disagree the entire time a device is + * roaming, which is exactly when an operator is looking at this field. + */ + readonly operatorName: NormalizedMetric; + /** + * The registered operator's PLMN id (MCC+MNC) — `Modem3gpp.OperatorCode`. + * + * Kept as text, not a number: the MNC is two OR three digits and the width is + * significant, so `732101` and `73201` are different networks and a numeric + * round-trip would lose the leading zero that separates them. + */ + readonly operatorCode: NormalizedMetric; +}; + +/** + * Coarse registration context — WHICH CELL, not where the device is. + * + * This is the `3gpp-lac-ci` location source's output, and it is a different class of + * datum from a GNSS fix: it names a cell in the operator's network and carries no + * coordinate. That is why it lives here rather than behind the GPS module's privacy + * fence, why `3gpp-lac-ci` stays outside `GNSS_SOURCES`, and why nothing on this path + * enables a location source or flips `Location.Setup`'s `signal_location`. + * + * Both fields are the SOURCE'S OWN TEXT. ModemManager emits them as uppercase hex, the + * ZTE and UFI admin APIs emit their own vendor spellings, and no radix is common to all + * three — so parsing to a number here would render an identifier that matches nothing + * an operator sees in `mmcli` or in the vendor's own web UI. + * + * EARFCN IS ABSENT ON PURPOSE and cannot be added from these sources. ModemManager + * publishes no generic ARFCN anywhere on the `Modem`, `Modem3gpp` or `Location` + * interfaces (checked against 1.24.2's introspection, not recalled); the only place one + * appears is inside a PER-CELL `GetCellInfo` dict, under two DIFFERENT keys — `earfcn` + * for LTE (`libmm-glib/mm-cell-info-lte.c`) and `nrarfcn` for 5GNR + * (`libmm-glib/mm-cell-info-nr5g.c`). A single normalized slot would therefore have to + * either merge two different quantities or silently pick a RAT, so this model makes no + * ARFCN claim at all and `backend/cell-info.ts` keeps the per-cell reading where it + * belongs. + */ +export type NormalizedCell = { + readonly cellId: NormalizedMetric; + readonly tac: NormalizedMetric; }; /** @@ -92,6 +137,7 @@ export type NormalizedModemObservation = { readonly radio: NormalizedRadio; readonly signal: NormalizedSignal; readonly sim: NormalizedSim; + readonly cell: NormalizedCell; /** Everything the provider said, verbatim, plus what was and was not claimed. */ readonly diagnostics: ObservationDiagnostics; }; diff --git a/control/src/observations/registration-context.test.ts b/control/src/observations/registration-context.test.ts new file mode 100644 index 0000000..6c3befc --- /dev/null +++ b/control/src/observations/registration-context.test.ts @@ -0,0 +1,216 @@ +// Registration + coarse-cell context — who we are attached to, and to which cell. +// +// Two confusions are what this suite exists to catch, because both produce a +// plausible-looking value rather than a visible failure: +// +// 1. `Sim.OperatorName` (the HOME operator burned into the SIM) standing in for +// `Modem3gpp.OperatorName` (the operator we are REGISTERED with). They agree on a +// home network and disagree for the whole time a device is roaming. +// 2. A partially-decoded `3gpp-lac-ci` string. It carries five tokens in one value, +// so a decoder that tolerated a short one would report a TAC read out of the cell +// id's position — a well-formed identifier naming the wrong thing. + +import { describe, expect, test } from 'bun:test'; +import { + fixtureContext, + HILINK_FIXTURE, + MM_FIXTURE, + UFI_FIXTURE, + ZTE_FIXTURE, +} from '../../test-support/observation-fixtures'; +import { decode3gppLacCi, lacCiOperatorCode } from '../domain'; +import { viewEnvelope } from './envelope'; +import { metricUnknownClass, type NormalizedMetric } from './metric'; +import type { NormalizedModemObservation } from './model'; +import { normalizeHilinkObservation } from './sources/hilink'; +import { + type ModemManagerObservationInput, + normalizeModemManagerObservation, +} from './sources/modemmanager'; +import { normalizeUfiObservation } from './sources/ufi'; +import { normalizeZteObservation } from './sources/zte'; + +const CONTEXT = fixtureContext(); + +function observation( + envelope: ReturnType, +): NormalizedModemObservation { + const view = viewEnvelope(envelope); + if (view.kind === 'unavailable') { + throw new Error('fixture normalization must not produce an unavailable envelope'); + } + return view.value; +} + +const mm = (input: ModemManagerObservationInput) => + observation(normalizeModemManagerObservation(input, CONTEXT)); + +function known(metric: NormalizedMetric): T { + if (metric.state !== 'known') { + throw new Error(`expected a known metric, got unknown:${metric.reason}`); + } + return metric.value; +} + +function unknownReason(metric: NormalizedMetric): string { + if (metric.state !== 'unknown') { + throw new Error(`expected an unknown metric, got known:${String(metric.value)}`); + } + return metric.reason; +} + +describe('decode3gppLacCi — MM 1.24.2 emits exactly five comma-separated tokens', () => { + test('the five tokens decode in MM\u2019s own order, as text', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F')).toEqual({ + mcc: '732', + mnc: '101', + locationAreaCode: '2B1C', + cellId: '0A1B2C3D', + trackingAreaCode: '4E5F', + }); + }); + + test('hex stays hex — a cell id is never reinterpreted as decimal', () => { + // `0A1B2C3D` parsed as a number would render 169552957, which matches nothing + // `mmcli` or a vendor UI shows for this cell. + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F')?.cellId).toBe('0A1B2C3D'); + }); + + test('a two-digit MNC keeps its width', () => { + expect(decode3gppLacCi('310,01,1A,2B,3C')?.mnc).toBe('01'); + expect( + lacCiOperatorCode({ + mcc: '310', + mnc: '01', + locationAreaCode: '1A', + cellId: '2B', + trackingAreaCode: '3C', + }), + ).toBe('31001'); + }); + + test('a token count other than five decodes to NOTHING, never a partial record', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D')).toBeUndefined(); + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,4E5F,extra')).toBeUndefined(); + expect(decode3gppLacCi('')).toBeUndefined(); + expect(decode3gppLacCi(undefined)).toBeUndefined(); + }); + + test('an empty MCC, MNC or cell id is not a decode', () => { + expect(decode3gppLacCi(',101,2B1C,0A1B2C3D,4E5F')).toBeUndefined(); + expect(decode3gppLacCi('732,,2B1C,0A1B2C3D,4E5F')).toBeUndefined(); + expect(decode3gppLacCi('732,101,2B1C,,4E5F')).toBeUndefined(); + }); + + test('an EMPTY tracking-area code still decodes — a 2G/3G attach has none', () => { + expect(decode3gppLacCi('732,101,2B1C,0A1B2C3D,')?.trackingAreaCode).toBe(''); + }); +}); + +describe('ModemManager registration context', () => { + test('the operator comes from Modem3gpp, NOT from the SIM\u2019s home operator', () => { + const radio = mm(MM_FIXTURE).radio; + expect(known(radio.operatorName)).toBe('Claro'); + expect(known(radio.operatorCode)).toBe('732101'); + // The fixture's SIM carries a DIFFERENT name on purpose; reading the wrong + // interface would surface it here. + expect(MM_FIXTURE.sim?.OperatorName).toBe('CLARO COL'); + }); + + test('provenance names the exact Modem3gpp field each value came from', () => { + const radio = mm(MM_FIXTURE).radio; + expect( + radio.operatorName.state === 'known' ? radio.operatorName.provenance.rawFields : [], + ).toEqual(['Modem3gpp.OperatorName']); + expect( + radio.operatorCode.state === 'known' ? radio.operatorCode.provenance.rawFields : [], + ).toEqual(['Modem3gpp.OperatorCode']); + }); + + test('an unregistered modem reports not-reported, never an empty string', () => { + const radio = mm({ modem: MM_FIXTURE.modem ?? {}, modem3gpp: { OperatorName: '' } }).radio; + expect(unknownReason(radio.operatorName)).toBe('not-reported'); + expect(unknownReason(radio.operatorCode)).toBe('not-reported'); + // A READ-class answer, so a consumer keeps showing the field as pending rather + // than hiding it as something ModemManager cannot express. + expect(metricUnknownClass('not-reported')).toBe('read'); + }); +}); + +describe('ModemManager coarse-cell context (3gpp-lac-ci)', () => { + test('TAC and CID are claimed from the one location string', () => { + const cell = mm(MM_FIXTURE).cell; + expect(known(cell.cellId)).toBe('0A1B2C3D'); + expect(known(cell.tac)).toBe('4E5F'); + expect(cell.cellId.state === 'known' ? cell.cellId.provenance.rawFields : []).toEqual([ + 'Location.3gpp-lac-ci', + ]); + }); + + test('NO location body read is `not-observed` — nobody looked', () => { + const { location: _location, ...withoutLocation } = MM_FIXTURE; + const cell = mm(withoutLocation).cell; + expect(unknownReason(cell.cellId)).toBe('not-observed'); + expect(unknownReason(cell.tac)).toBe('not-observed'); + }); + + test('a location body carrying no 3gpp-lac-ci entry is `not-reported`', () => { + const cell = mm({ ...MM_FIXTURE, location: {} }).cell; + expect(unknownReason(cell.cellId)).toBe('not-reported'); + expect(unknownReason(cell.tac)).toBe('not-reported'); + }); + + test('a malformed value fails BOTH fields together, never one of them', () => { + const cell = mm({ ...MM_FIXTURE, location: { '3gpp-lac-ci': '732,101,2B1C' } }).cell; + expect(unknownReason(cell.cellId)).toBe('malformed'); + expect(unknownReason(cell.tac)).toBe('malformed'); + }); + + test('an empty TAC on an attach that has none is not-reported, and the CID still reads', () => { + const cell = mm({ ...MM_FIXTURE, location: { '3gpp-lac-ci': '732,101,2B1C,00A1B2,' } }).cell; + expect(known(cell.cellId)).toBe('00A1B2'); + expect(unknownReason(cell.tac)).toBe('not-reported'); + }); + + test('the whole Location property stays in the diagnostics block', () => { + const diagnostics = mm(MM_FIXTURE).diagnostics; + expect(diagnostics.raw['Location.3gpp-lac-ci']).toBe('732,101,2B1C,0A1B2C3D,4E5F'); + }); +}); + +describe('router sources claim only what a migrated parser decoded', () => { + test('ZTE claims the operator NAME and the cell id it already parses', () => { + const zte = observation(normalizeZteObservation(ZTE_FIXTURE, CONTEXT)); + expect(known(zte.radio.operatorName)).toBe('Movistar'); + expect(known(zte.cell.cellId)).toBe('0A1B2C'); + // `rmcc` + `rmnc` are in the payload, but joining an unpadded MNC would name a + // different network — so no code is derived. + expect(unknownReason(zte.radio.operatorCode)).toBe('not-reported'); + expect(zte.diagnostics.raw['goform.rmcc']).toBe('732'); + expect(zte.diagnostics.raw['goform.rmnc']).toBe('123'); + }); + + test('UFI claims its cell id and no operator', () => { + const ufi = observation(normalizeUfiObservation(UFI_FIXTURE, CONTEXT)); + expect(known(ufi.cell.cellId)).toBe('3344'); + expect(unknownReason(ufi.radio.operatorName)).toBe('not-reported'); + }); + + test('HiLink ships a raw that is retained but NOT claimed', () => { + const hilink = observation(normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)); + expect(unknownReason(hilink.cell.cellId)).toBe('not-reported'); + expect(hilink.diagnostics.raw['device-signal.cell_id']).toBe('12345678'); + expect(hilink.diagnostics.unmapped).toContain('device-signal.cell_id'); + }); + + test('no router source claims `unsupported` for TAC — that would be a source claim', () => { + for (const observed of [ + observation(normalizeZteObservation(ZTE_FIXTURE, CONTEXT)), + observation(normalizeUfiObservation(UFI_FIXTURE, CONTEXT)), + observation(normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)), + ]) { + expect(unknownReason(observed.cell.tac)).toBe('not-reported'); + expect(metricUnknownClass(unknownReason(observed.cell.tac) as 'not-reported')).toBe('read'); + } + }); +}); diff --git a/control/src/observations/sources/hilink.ts b/control/src/observations/sources/hilink.ts index 1b285cb..e7c9737 100644 --- a/control/src/observations/sources/hilink.ts +++ b/control/src/observations/sources/hilink.ts @@ -28,7 +28,9 @@ import { createObservationDiagnostics, type ObservationDiagnosticNote } from '.. import { flattenXmlBody, hasRawField, mergeRawRecords, rawKey } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -93,6 +95,9 @@ export function normalizeHilinkObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: normalizeModeLabel(capabilities.net_mode, provenance), + // The registered operator lives on `/api/net/current-plmn`, a body this + // source does not read and no migrated parser decodes. + ...routerOperator(provenance, undefined), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -112,6 +117,9 @@ export function normalizeHilinkObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([rawKey(SIGNAL, 'sinr')])), }, sim: routerSim(provenance, hasRawField(raw, SIM_STATUS) ? SIM_STATUS : undefined), + // HiLink's `` is a raw tag no migrated parser reads; it stays verbatim + // in the diagnostics block rather than being lifted into a claim. + cell: routerCell(provenance, undefined), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/src/observations/sources/modemmanager.ts b/control/src/observations/sources/modemmanager.ts index 0d2ba9f..f7da392 100644 --- a/control/src/observations/sources/modemmanager.ts +++ b/control/src/observations/sources/modemmanager.ts @@ -10,6 +10,7 @@ // those three into one absent value is precisely the loss this layer exists to stop. import { + decode3gppLacCi, decodeEsimStatus, decodeMmAccessTechnologies, decodeMmState, @@ -27,7 +28,7 @@ import { import { readSimPresence, type SimPresenceFacts } from '../../hardware/router-parsers'; import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; -import type { NormalizedModemObservation, SimPresenceValue } from '../model'; +import type { NormalizedCell, NormalizedModemObservation, SimPresenceValue } from '../model'; import { createObservationDiagnostics, type ObservationDiagnosticNote, @@ -62,12 +63,38 @@ export type ModemManagerObservationInput = { readonly modem3gpp?: Readonly>; readonly sim?: Readonly>; readonly signal?: Readonly>; + /** + * The `Modem.Location` reading, keyed by DECODED SOURCE NAME (`3gpp-lac-ci`). + * + * On the wire that property is an `a{uv}` keyed by the `MMModemLocationSource` BIT, + * and the bit-to-name vocabulary lives in `backend/mm-location.ts`. Naming the + * source here instead keeps this layer reading a flat named field like every other + * body, and keeps exactly one copy of that vocabulary. + * + * Supplying it enables nothing. A location SOURCE is switched on by + * `Location.Setup`, which this layer never calls; reading a value that is already + * being reported is a normalization step. `3gpp-lac-ci` in particular is coarse cell + * context rather than a GNSS fix, so it stays outside `GNSS_SOURCES` and the GNSS + * enable/disable path is untouched by it. + * + * NOTHING SUPPLIES IT TODAY, and the reason is the fence rather than an oversight. + * ModemManager masks the `Location` PROPERTY unless `Location.Setup` was called with + * `signal_location = true` — which broadcasts the value over `PropertiesChanged` and + * is therefore permanently forbidden here (`backend/mm-location.ts`). The value has + * to come from an explicit `GetLocation()` call instead, and the provider's snapshot + * path makes no such call. So an MM observation reads `not-observed` for `cell`: + * nobody looked, which is the honest answer and is distinct from a modem that + * reported nothing. Cell identity that IS wired today comes from `Modem.GetCellInfo` + * through `backend/cell-info.ts`, a different method that needs no location source. + */ + readonly location?: Readonly>; }; const MODEM = 'Modem'; const MODEM3GPP = 'Modem3gpp'; const SIM = 'Sim'; const SIGNAL = 'Signal'; +const LOCATION = 'Location'; export function normalizeModemManagerObservation( input: ModemManagerObservationInput, @@ -78,6 +105,7 @@ export function normalizeModemManagerObservation( prefixRawRecord(MODEM3GPP, input.modem3gpp), prefixRawRecord(SIM, input.sim), prefixRawRecord(SIGNAL, input.signal), + prefixRawRecord(LOCATION, input.location), ); const notes: ObservationDiagnosticNote[] = []; const consumed: string[] = []; @@ -104,6 +132,7 @@ export function normalizeModemManagerObservation( notes, ); const sim = normalizeSim(raw, input.sim !== undefined, provenance, notes); + const cell = normalizeCell(raw, input.location !== undefined, provenance, notes); return freshObservation(SOURCE, context, { source: SOURCE, @@ -111,6 +140,7 @@ export function normalizeModemManagerObservation( radio, signal, sim, + cell, diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } @@ -131,8 +161,13 @@ const SIM_SLOTS = rawKey(MODEM, 'SimSlots'); const FAILED_REASON = rawKey(MODEM, 'StateFailedReason'); const UNLOCK_REQUIRED = rawKey(MODEM, 'UnlockRequired'); const REGISTRATION_STATE = rawKey(MODEM3GPP, 'RegistrationState'); +const OPERATOR_NAME = rawKey(MODEM3GPP, 'OperatorName'); +const OPERATOR_CODE = rawKey(MODEM3GPP, 'OperatorCode'); const SIM_TYPE = rawKey(SIM, 'SimType'); const ESIM_STATUS = rawKey(SIM, 'EsimStatus'); +// The `Modem.Location` entry for MM's coarse-cell source. Named, not bit-keyed — see +// `ModemManagerObservationInput.location`. +const LAC_CI = rawKey(LOCATION, '3gpp-lac-ci'); // `Modem.Signal` publishes ONE `a{sv}` per RAT, and the member sets differ (MM 1.24.2 // `org.freedesktop.ModemManager1.Modem.Signal.xml`): @@ -233,6 +268,65 @@ function normalizeRadio( registration: decodedLabel(raw, REGISTRATION_STATE, decodeRegistrationState, provenance, notes), accessTechnologies: normalizeAccessTechnologies(raw, provenance, notes), modeLabel: normalizeModeLabel(raw, provenance, notes), + // `Modem3gpp`, never `Sim` — the registered operator, not the SIM's home one. + // A modem that is not registered reports these empty, which is `not-reported`: + // the property exists and this read carried no value. + operatorName: registrationString(raw, OPERATOR_NAME, provenance), + operatorCode: registrationString(raw, OPERATOR_CODE, provenance), + }; +} + +/** A `Modem3gpp` registration string — present-and-empty is still no reading. */ +function registrationString( + raw: RawFieldRecord, + key: string, + provenance: Provenance, +): NormalizedMetric { + const source = provenance(key); + const value = rawString(raw, key); + return value === undefined + ? unknownMetric('not-reported', source) + : knownMetric(value, source); +} + +/** + * Coarse cell context from the `3gpp-lac-ci` location reading. + * + * The whole value is ONE string, so a `cellId` and a `tac` read out of it necessarily + * agree with each other — they came from the same reported cell, never from two reads + * that raced. A value in an unrecognized shape is `malformed` for both rather than + * partially decoded; see `decode3gppLacCi`. + */ +function normalizeCell( + raw: RawFieldRecord, + locationRead: boolean, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +): NormalizedCell { + const source = provenance(LAC_CI); + if (!hasRawField(raw, LAC_CI)) { + const reason = locationRead ? ('not-reported' as const) : ('not-observed' as const); + return { + cellId: unknownMetric(reason, source), + tac: unknownMetric(reason, source), + }; + } + const decoded = decode3gppLacCi(rawString(raw, LAC_CI)); + if (decoded === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: LAC_CI }); + return { + cellId: unknownMetric('malformed', source), + tac: unknownMetric('malformed', source), + }; + } + return { + cellId: knownMetric(decoded.cellId, source), + // MM emits an EMPTY tracking-area code on a device with no LTE/NR attach — the + // reading arrived, this field of it did not. + tac: + decoded.trackingAreaCode === '' + ? unknownMetric('not-reported', source) + : knownMetric(decoded.trackingAreaCode, source), }; } diff --git a/control/src/observations/sources/router-shared.ts b/control/src/observations/sources/router-shared.ts index 92d5eed..ff64f05 100644 --- a/control/src/observations/sources/router-shared.ts +++ b/control/src/observations/sources/router-shared.ts @@ -20,7 +20,7 @@ import { type ObservationAuthority, } from '../../domain'; import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; -import type { NormalizedHardware, NormalizedSim, SimPresenceValue } from '../model'; +import type { NormalizedCell, NormalizedHardware, NormalizedSim, SimPresenceValue } from '../model'; import type { MetricProvenance } from '../provenance'; export type RouterProvenance = ( @@ -60,6 +60,58 @@ export function routerSim( }; } +/** + * The cell block a router source produces. + * + * `cellId` is claimed only when a MIGRATED parser decoded one — `parseZteDetails` and + * `parseUfiDetails` both do — and the raw vendor key is named in provenance. HiLink + * ships a `` tag that no migrated parser reads, so it stays verbatim in the + * diagnostics block rather than being lifted here: the same rule that keeps `SimStatus` + * out of `sim.presence`. + * + * `tac` is always `not-reported`, never `unsupported`. None of the three migrated + * parsers decodes a tracking-area code, but that is a fact about what THIS package + * reads, not a claim that the vendor's firmware cannot report one — and `unsupported` + * would be the second kind of statement. + */ +export function routerCell( + provenance: RouterProvenance, + cell: { readonly id: string; readonly field: string } | undefined, +): NormalizedCell { + return { + cellId: + cell === undefined + ? unknownMetric('not-reported', provenance([])) + : knownMetric(cell.id, provenance([cell.field])), + tac: unknownMetric('not-reported', provenance([])), + }; +} + +/** + * A registered-operator NAME a migrated parser decoded, or an honest silence. + * + * The matching CODE is deliberately never derived. `parseZteDetails` reports `mcc` and + * `mnc` as separate unpadded vendor fields, while `Modem3gpp.OperatorCode` is a + * fixed-width concatenation that MM itself splits back apart at exactly three + * characters — joining an unpadded 2-vs-3-digit MNC would name a different network + * roughly whenever the leading zero matters. So it reads `not-reported`. + */ +export function routerOperator( + provenance: RouterProvenance, + operator: { readonly name: string; readonly field: string } | undefined, +): { + readonly operatorName: NormalizedMetric; + readonly operatorCode: NormalizedMetric; +} { + return { + operatorName: + operator === undefined + ? unknownMetric('not-reported', provenance([])) + : knownMetric(operator.name, provenance([operator.field])), + operatorCode: unknownMetric('not-reported', provenance([])), + }; +} + /** Router admin APIs report no measurement-recency flag; that is a source capability claim. */ export function unsupportedQualityRecent(provenance: RouterProvenance): NormalizedMetric { return unknownMetric('unsupported', provenance([])); diff --git a/control/src/observations/sources/ufi.ts b/control/src/observations/sources/ufi.ts index dd75baf..313a708 100644 --- a/control/src/observations/sources/ufi.ts +++ b/control/src/observations/sources/ufi.ts @@ -32,7 +32,9 @@ import { } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -111,6 +113,9 @@ export function normalizeUfiObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: unknownMetric('not-reported', provenance([])), + // `parseUfiDetails` decodes no operator field from any of the three + // endpoints, so neither half is claimed. + ...routerOperator(provenance, undefined), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -131,6 +136,12 @@ export function normalizeUfiObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), }, sim: routerSim(provenance, hasRawField(raw, SIM_STATE) ? SIM_STATE : undefined), + cell: routerCell( + provenance, + details?.cell_id === undefined + ? undefined + : { id: details.cell_id, field: rawKey(SYSINFO, 'cellid') }, + ), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/src/observations/sources/zte.ts b/control/src/observations/sources/zte.ts index 6f100fd..939e2fb 100644 --- a/control/src/observations/sources/zte.ts +++ b/control/src/observations/sources/zte.ts @@ -25,7 +25,9 @@ import { createObservationDiagnostics, type ObservationDiagnosticNote } from '.. import { hasRawField, parseJsonRecord, prefixRawRecord, rawKey } from '../raw'; import { type RouterProvenance, + routerCell, routerHardware, + routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, @@ -70,6 +72,12 @@ export function normalizeZteObservation( registration: unsupportedRadioMetric(provenance), accessTechnologies: unknownMetric('unsupported', provenance([])), modeLabel: normalizeModeLabel(details?.network_type, parsed !== undefined, provenance), + ...routerOperator( + provenance, + details?.provider === undefined + ? undefined + : { name: details.provider, field: rawKey(BODY, 'network_provider_fullname') }, + ), }, signal: { quality: unknownMetric('unsupported', provenance([])), @@ -88,6 +96,12 @@ export function normalizeZteObservation( sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), }, sim: routerSim(provenance, hasRawField(raw, SIM_CARD_STATE) ? SIM_CARD_STATE : undefined), + cell: routerCell( + provenance, + details?.cell_id === undefined + ? undefined + : { id: details.cell_id, field: rawKey(BODY, 'cell_id') }, + ), diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), }); } diff --git a/control/test-support/observation-fixtures.ts b/control/test-support/observation-fixtures.ts index 200fd4a..942e594 100644 --- a/control/test-support/observation-fixtures.ts +++ b/control/test-support/observation-fixtures.ts @@ -86,8 +86,18 @@ export const MM_FIXTURE: ModemManagerObservationInput = { }, modem3gpp: { RegistrationState: 1, + // The REGISTERED operator. `sim.OperatorName` below is the SIM's HOME operator + // and is deliberately a different string, so a normalizer that read the wrong + // interface is visible rather than accidentally correct. + OperatorName: 'Claro', + OperatorCode: '732101', Pco: 'dns-primary=10.0.0.1', }, + // `Modem.Location`, keyed by DECODED SOURCE NAME. The value is MM's own five-token + // `3gpp-lac-ci` string: MCC, MNC, then LAC / CI / TAC in uppercase hex. + location: { + '3gpp-lac-ci': '732,101,2B1C,0A1B2C3D,4E5F', + }, sim: { SimType: 1, EsimStatus: 0, From 98e48bf33ae5a23670011253b29024595921e19f Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:41:03 -0500 Subject: [PATCH 17/19] release: finalize modem-stack v1.3.0 versions before tagging (STEP 0 commit) --- bun.lock | 4 ++-- cli/package.json | 2 +- control/package.json | 2 +- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 8084860..3b2a7d4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "cli": { "name": "modem-control-cli", - "version": "1.2.1", + "version": "1.3.0", "bin": { "modem-control": "./src/index.ts", }, @@ -25,7 +25,7 @@ }, "control": { "name": "@ceralive/modem-control", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { "@httptoolkit/dbus-native": "0.1.5", "zod": "4.4.3", diff --git a/cli/package.json b/cli/package.json index 3bd5742..5df92b4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "modem-control-cli", - "version": "1.2.1", + "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).", diff --git a/control/package.json b/control/package.json index 939024a..2505c7f 100644 --- a/control/package.json +++ b/control/package.json @@ -1,6 +1,6 @@ { "name": "@ceralive/modem-control", - "version": "1.2.1", + "version": "1.3.0", "type": "module", "description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.", "license": "AGPL-3.0", diff --git a/package.json b/package.json index bb9baec..199f6bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modem-stack", - "version": "1.2.1", + "version": "1.3.0", "private": true, "type": "module", "description": "Cellular modem control for CeraLive — @ceralive/modem-control library, bench CLI, and ModemManager-stack .deb packaging (Phase A, standalone).", From dd58469371020f2d38942c0efdb95a45f6d4fd2f Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:45:28 -0500 Subject: [PATCH 18/19] fix(ci): match the companion build by invocation, not by a `run:` scalar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo 7 wrapped the companion build in a `run: |` guard that validates RELEASE_VERSION against the tag, so the literal `run: packaging/ci/build-companion.sh` the wiring contract grepped for no longer exists and the contract went red — which fails release.yml's own test job before build-deb can start. Match the invocation anchored at end-of-line instead. A plain fixed-string match on the path would have silently matched the `echo "::error::…"` line inside the guard and passed even with the invocation deleted. --- packaging/ci/test-release-workflow-wiring.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packaging/ci/test-release-workflow-wiring.sh b/packaging/ci/test-release-workflow-wiring.sh index f2b072b..ae59711 100755 --- a/packaging/ci/test-release-workflow-wiring.sh +++ b/packaging/ci/test-release-workflow-wiring.sh @@ -48,6 +48,11 @@ echo " workflow: ${WORKFLOW#"$REPO_ROOT"/}" # First 1-based line number matching a fixed string; empty when absent. line_of() { grep -n -m1 -F -- "$1" "$WORKFLOW" | cut -d: -f1; } +# Same, for a command INVOCATION. The end-of-line anchor — not a `run:` prefix, which a step +# that guards its invocation inside `run: |` no longer carries — is what skips an earlier +# mention of the same path inside an `echo "::error::…"` string. +line_of_invocation() { grep -nE -m1 -- "$1"'[[:space:]]*$' "$WORKFLOW" | cut -d: -f1; } + # The comparator every ordering assertion goes through, so ONE non-vacuity control covers them all. assert_before() { # local a_label="$1" a="$2" b_label="$3" b="$4" @@ -97,7 +102,7 @@ DETECT_LINE="$(line_of '- name: Detect changed sources (per-source verdicts)')" STAGE_LINE="$(line_of '- name: Stage carry-forward debs (unchanged sources, sha256-verified)')" BUILD_STEP_LINE="$(line_of '- name: Build the MM 1.24 stack (.deb)')" BUILD_CALL_LINE="$(line_of 'packaging/ci/build-bookworm.sh amd64')" -COMPANION_LINE="$(line_of 'run: packaging/ci/build-companion.sh')" +COMPANION_LINE="$(line_of_invocation 'packaging/ci/build-companion\.sh')" MANIFEST_LINE="$(line_of 'run: bash packaging/ci/generate-release-manifest.sh')" UPLOAD_LINE="$(line_of '- name: Upload .deb artifacts + release manifest')" From 26db4f9f92bb1c742b7f9cb9bb4d53cbc8b0f417 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Tue, 25 Aug 2026 17:50:47 -0500 Subject: [PATCH 19/19] docs: narrow Sierra's `AT!` fence claim to the surface it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quirks row claimed no `AT!` form existed anywhere in this repository. The gate it cites (`providers/ufi-himi`) scans that provider's directory, and `usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP` composition forms — which is what makes the composition switch `implemented` for Sierra. The row is about the password-gated `AT!BAND` / `AT!ENTERCND` surface, which really is absent, so the rung stays `unavailable`; only the scope of the justification was wrong. COMPAT-MATRIX.md recorded this as an open discrepancy and now records the reconciliation. --- docs/COMPAT-MATRIX.md | 16 ++++++++++------ docs/VENDOR-QUIRKS.md | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/COMPAT-MATRIX.md b/docs/COMPAT-MATRIX.md index f37c30a..551d622 100644 --- a/docs/COMPAT-MATRIX.md +++ b/docs/COMPAT-MATRIX.md @@ -177,17 +177,21 @@ Data-usage metering is not an operation column, because it is measured from a tracked interface at all. Making it a column would add eleven cells that all say the same thing for a non-vendor reason. Its accuracy gate is RB-6 in [`BENCH.md`](BENCH.md). -### One discrepancy worth recording +### Why Sierra's composition switch is `implemented` even though `AT!` is fenced -[`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md)'s Sierra section states that no `AT!` form exists -anywhere in this repository. That is true of the `providers/ufi-himi` static gate it cites, -which scans that provider's directory, but it is not true repo-wide: `control/src/usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP?` / `AT!USBCOMP=?` / `AT!USBCOMP=` forms in the composition registries, which is what makes operation 12 `implemented` for Sierra. The forms are reviewed, allowlisted by name, and gated by the same admission, journal, rollback and readback fences as every other -composition write, so the behaviour is intended. It is the fence's *scope* that the quirks -row overstates. Recorded here rather than silently corrected there. +composition write. + +That coexists with the `AT!` fence rather than contradicting it, because the two speak +about different surfaces. [`VENDOR-QUIRKS.md`](VENDOR-QUIRKS.md)'s Sierra `AT!` row is +about the **password-gated** `AT!BAND` / `AT!ENTERCND` surface, which is absent from this +repository and stays `unavailable`; the static gate it cites (`providers/ufi-himi`) scans +that provider's directory, not the repository. An earlier revision of that row claimed a +repo-wide absence of every `AT!` form, which was not true — it has been narrowed to the +surface it actually describes. --- diff --git a/docs/VENDOR-QUIRKS.md b/docs/VENDOR-QUIRKS.md index 2bbb0f7..6f026d4 100644 --- a/docs/VENDOR-QUIRKS.md +++ b/docs/VENDOR-QUIRKS.md @@ -70,7 +70,7 @@ command, a QMI/MBIM write, a composition switch, or a band lock. | Quirk | Evidence | Posture | |---|---|---| | **FCC lock: the radio stays disabled until an unlock procedure runs, and it is keyed per `:`.** OEM rebrands are separate keys, so covering `1199` alone misses two thirds of the fleet. | MM — `data/dispatcher-fcc-unlock/meson.build` at `1.24.2` names exactly `03f0:4e1d`, `1199:9079`, `413c:81a3`, `413c:81a8` for the Sierra script. Mirrored in [`control/src/fcc/coverage.ts`](../control/src/fcc/coverage.ts). | `implemented` — CeraLive records an opt-in policy and re-derives MM's own symlink. It ships no unlock script; see [`FCC-UNLOCK-COVERAGE.md`](FCC-UNLOCK-COVERAGE.md). | -| **`AT!`-prefixed commands (`AT!BAND`, `AT!ENTERCND`) are a password-gated vendor surface.** | MM — the `sierra` plugin is a first-class plugin at `1.24.2`. CeraLive's own AT fence lists no `AT!` form: `providers/ufi-himi`'s static gate scans for `AT!` as a FORBIDDEN construct. | `unavailable` — no `AT!` command exists anywhere in this repository, by gate. | +| **`AT!`-prefixed commands (`AT!BAND`, `AT!ENTERCND`) are a password-gated vendor surface.** | MM — the `sierra` plugin is a first-class plugin at `1.24.2`. `providers/ufi-himi`'s static gate scans for `AT!` as a FORBIDDEN construct, but its scope is that provider's directory, not the repository. | `unavailable` — for this password-gated surface: no `AT!BAND` or `AT!ENTERCND` form exists anywhere in this repository. It is NOT a repo-wide absence of `AT!` — `usb-mode/runtime-capability.ts` carries Sierra's reviewed `AT!USBCOMP?` / `AT!USBCOMP=?` / `AT!USBCOMP=` composition forms, allowlisted by name and fenced like every other composition write. | | EM7455/EM7565 are the widely-deployed bonding-rig parts. | BELABOX — EM7455 (a.k.a. Dell DW5811e) and EM7565 are listed as working on RK3588 and Jetson. | `unavailable` — no CeraLive hardware drill has run; [`BENCH.md`](BENCH.md) RB-18 is a recorded `device-not-present` skip. | ## Fibocom (`2cb7`)