From 4d9cafb110d1e2614e3d5a9eaa1aa9c4c7684ade Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 01:26:59 -0500 Subject: [PATCH 01/12] chore(deps): dependabot TS-major ignore; absorb actions bump PR #6 (actions/setup-qemu v3->v4) squash-merged and rebased in. PR #9's npm group (Biome 2.5.4->2.5.5 + TypeScript 5.9.3->7.0.2) closed: the TS major bump is forbidden by policy, so only the Biome patch bump is taken here; Dependabot re-raises TypeScript standalone if it clears the existing major-version ignore rule in .github/dependabot.yml (already present, verified unchanged). --- biome.json | 2 +- bun.lock | 24 ++++++++++++------------ package.json | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/biome.json b/biome.json index 035ceef..38ce7be 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "extends": ["@ceralive/biome-config"], "files": { "includes": ["**", "!**/node_modules", "!**/dist", "!test-results"] diff --git a/bun.lock b/bun.lock index 20dfb5b..e33006d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "modem-stack", "devDependencies": { - "@biomejs/biome": "2.5.4", + "@biomejs/biome": "2.5.5", "@ceralive/biome-config": "2026.6.2", "@types/bun": "1.3.14", "typescript": "^5", @@ -13,7 +13,7 @@ }, "cli": { "name": "modem-control-cli", - "version": "0.1.0", + "version": "0.2.0", "bin": { "modem-control": "./src/index.ts", }, @@ -24,7 +24,7 @@ }, "control": { "name": "@ceralive/modem-control", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@httptoolkit/dbus-native": "0.1.5", "zod": "4.4.3", @@ -32,23 +32,23 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.5", "@biomejs/cli-darwin-x64": "2.5.5", "@biomejs/cli-linux-arm64": "2.5.5", "@biomejs/cli-linux-arm64-musl": "2.5.5", "@biomejs/cli-linux-x64": "2.5.5", "@biomejs/cli-linux-x64-musl": "2.5.5", "@biomejs/cli-win32-arm64": "2.5.5", "@biomejs/cli-win32-x64": "2.5.5" }, "bin": { "biome": "bin/biome" } }, "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g=="], "@ceralive/biome-config": ["@ceralive/biome-config@2026.6.2", "", {}, "sha512-LqDaJufxLOC3Rm38u/ocpUro/ub8llv1q9pcS3W/Ys7gBy+wLjS2TmcyhMjNMRg9c7pCQlPMI6eS8JkhpN+ecw=="], diff --git a/package.json b/package.json index 6e8c0d4..af461a4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "bun test" }, "devDependencies": { - "@biomejs/biome": "2.5.4", + "@biomejs/biome": "2.5.5", "@ceralive/biome-config": "2026.6.2", "@types/bun": "1.3.14", "typescript": "^5" From 05c407a08d072cef785c2c05b87e1e9544b32e44 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 03:09:27 -0500 Subject: [PATCH 02/12] feat(control): uhubctl usb-hub-port-cycle PowerHook (config-mapped, bounded, disabled-by-default recovery) --- control/src/backend/index.ts | 15 + .../src/backend/uhubctl-power-hook.test.ts | 274 +++++++++++++ control/src/backend/uhubctl-power-hook.ts | 377 ++++++++++++++++++ 3 files changed, 666 insertions(+) create mode 100644 control/src/backend/uhubctl-power-hook.test.ts create mode 100644 control/src/backend/uhubctl-power-hook.ts diff --git a/control/src/backend/index.ts b/control/src/backend/index.ts index 5fbaf71..67f64ad 100644 --- a/control/src/backend/index.ts +++ b/control/src/backend/index.ts @@ -210,6 +210,21 @@ export { type UsbModeTransitionOutcome, type UsbModeTransitionRequest, } from './transition-preconditions'; +export { + createUhubctlPowerHook, + parseUhubctlPortMap, + readUhubctlPortMap, + SpawnUhubctlRunner, + type UhubctlPortMap, + type UhubctlPortMapping, + type UhubctlPowerHookDeps, + type UhubctlResult, + type UhubctlRunner, + type UsbEnumerationPoller, + uhubctlCycleArgv, + uhubctlPortMappingSchema, + uhubctlPortMapSchema, +} from './uhubctl-power-hook'; export * from './usage'; export { createUsbEnumerator, diff --git a/control/src/backend/uhubctl-power-hook.test.ts b/control/src/backend/uhubctl-power-hook.test.ts new file mode 100644 index 0000000..d4de420 --- /dev/null +++ b/control/src/backend/uhubctl-power-hook.test.ts @@ -0,0 +1,274 @@ +// The uhubctl power hook — the refusals matter more than the happy path: +// - a mapped key cycles the right port and reports `applied` only on re-enumeration +// - an UNMAPPED key is `unsupported` with ZERO commands run +// - a non-zero uhubctl exit is `failed` and never claims re-enumeration +// - a modem that never comes back is `failed` with expected-vs-observed +// - wiring this hook into the ladder does NOT arm it: recovery.enabled=false still +// fires zero cycles + +import { describe, expect, test } from 'bun:test'; +import { epochMillis, runtimePath } from '../domain'; +import { ModemActor } from './modem-actor'; +import { RecoveryLadder, type RecoveryRequest, type RecoverySteps } from './recovery-ladder'; +import { + createUhubctlPowerHook, + parseUhubctlPortMap, + type UhubctlPortMap, + type UhubctlResult, + type UhubctlRunner, + type UsbEnumerationPoller, + uhubctlCycleArgv, +} from './uhubctl-power-hook'; + +const STABLE_KEY = 'slot:a'; +const ID_PATH = 'platform-fc800000.usb-usb-0:1.4.1:1.2'; + +const PORTS: UhubctlPortMap = { + [STABLE_KEY]: { hubLocation: '1-1.4', port: 1 }, +}; + +/** A runner that records every argv it was handed and returns a canned result. */ +function fakeRunner(result: UhubctlResult, calls: string[][]): UhubctlRunner { + return { + run(argv) { + calls.push([...argv]); + return result; + }, + }; +} + +const OK: UhubctlResult = { stdout: 'Sent power off request\n', stderr: '', exitCode: 0 }; + +/** A poller that walks a scripted sequence of ID_PATH observations. */ +function scriptedPoller(sequence: readonly (string | undefined)[]): UsbEnumerationPoller { + let index = 0; + return { + idPathFor() { + const value = sequence[Math.min(index, sequence.length - 1)]; + index += 1; + return value; + }, + }; +} + +/** A clock that advances a fixed step per read — makes the timeout loop deterministic. */ +function steppingClock(stepMs: number): () => number { + let value = 0; + return () => { + const current = value; + value += stepMs; + return current; + }; +} + +const context = { stableKey: STABLE_KEY, at: epochMillis(0) }; +const noSleep = (): Promise => Promise.resolve(); + +describe('uhubctl power hook — a mapped key cycles its port', () => { + test('applied: the exact argv is run and the SAME ID_PATH comes back', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, calls), + // Pre-cut observation, then absent, then the same path returns. + poller: scriptedPoller([ID_PATH, undefined, ID_PATH]), + sleep: noSleep, + }); + + expect(hook.capability.power).toBe('usb-hub-port-cycle'); + + const result = await hook.cycle(context); + expect(result.status).toBe('applied'); + expect(result.reason).toContain(ID_PATH); + // Argv array, no shell, allowlisted flags — asserted byte-for-byte. + expect(calls).toEqual([['-l', '1-1.4', '-p', '1', '-a', 'cycle', '-d', '3']]); + }); + + test('the argv builder refuses a token that is not allowlisted', () => { + expect(uhubctlCycleArgv({ hubLocation: '1-1.4', port: 1 }, 3)).toEqual([ + '-l', + '1-1.4', + '-p', + '1', + '-a', + 'cycle', + '-d', + '3', + ]); + // A mapping that evaded the schema cannot smuggle a flag into the argv. + expect(() => uhubctlCycleArgv({ hubLocation: '--force', port: 1 }, 3)).toThrow('allowlisted'); + }); + + test('the schema rejects a hub location that is not a bus-port path', () => { + expect(() => + parseUhubctlPortMap('{"slot:a":{"hubLocation":"; rm -rf /","port":1}}', 'x'), + ).toThrow('hubLocation'); + }); +}); + +describe('uhubctl power hook — an unmapped stable key is unsupported', () => { + test('no mapping ⇒ unsupported, and the runner is NEVER invoked', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, calls), + poller: { idPathFor: () => Promise.reject(new Error('poller must not be consulted')) }, + sleep: noSleep, + }); + const result = await hook.cycle({ stableKey: 'slot:unknown', at: epochMillis(0) }); + expect(result.status).toBe('unsupported'); + expect(result.reason).toContain('slot:unknown'); + expect(calls).toEqual([]); + }); +}); + +describe('uhubctl power hook — a failing cycle command fails', () => { + test('a non-zero exit is failed, carries stderr, and never claims re-enumeration', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner( + { stdout: '', stderr: 'No compatible devices detected!', exitCode: 1 }, + calls, + ), + poller: scriptedPoller([ID_PATH, ID_PATH]), + sleep: noSleep, + }); + const result = await hook.cycle(context); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('exited 1'); + expect(result.reason).toContain('No compatible devices detected!'); + expect(calls).toHaveLength(1); + }); + + test('a runner that throws is failed, not an unhandled rejection', async () => { + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: { run: () => Promise.reject(new Error('uhubctl: command not found')) }, + poller: scriptedPoller([ID_PATH]), + sleep: noSleep, + }); + const result = await hook.cycle(context); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('command not found'); + }); +}); + +describe('uhubctl power hook — an enumeration timeout fails', () => { + test('the modem never returning is failed with expected-vs-observed', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, calls), + // Seen before the cut, then gone forever. + poller: scriptedPoller([ID_PATH, undefined]), + enumerationTimeoutMs: 1000, + pollIntervalMs: 250, + now: steppingClock(400), + sleep: noSleep, + }); + const result = await hook.cycle(context); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('did not re-enumerate within 1000ms'); + expect(result.reason).toContain(ID_PATH); + expect(result.reason).toContain('no device'); + // The port WAS cycled — the failure is the postcondition, not the command. + expect(calls).toHaveLength(1); + }); + + test('a DIFFERENT device appearing at that key is not accepted as recovery', async () => { + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, []), + poller: scriptedPoller([ID_PATH, 'platform-fc800000.usb-usb-0:9.9:1.0']), + enumerationTimeoutMs: 1000, + now: steppingClock(400), + sleep: noSleep, + }); + const result = await hook.cycle(context); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('9.9'); + }); + + test('an aborted signal ends the wait instead of hanging', async () => { + const controller = new AbortController(); + controller.abort(); + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, []), + poller: scriptedPoller([ID_PATH, undefined]), + signal: controller.signal, + sleep: noSleep, + }); + const result = await hook.cycle(context); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('cancelled'); + }); +}); + +describe('uhubctl power hook — wiring it in does NOT arm recovery', () => { + test('recovery.enabled=false fires ZERO uhubctl cycles even with a real hook installed', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, calls), + poller: { idPathFor: () => Promise.reject(new Error('poller must not be consulted')) }, + sleep: noSleep, + }); + const throwingSteps: RecoverySteps = { + nmCycle: () => Promise.reject(new Error('nmCycle must not fire')), + mmCycle: () => Promise.reject(new Error('mmCycle must not fire')), + reset: () => Promise.reject(new Error('reset must not fire')), + }; + const request: RecoveryRequest = { + stableKey: STABLE_KEY, + modem: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), + attribution: 'modem-fault', + now: epochMillis(0), + probeHealthy: () => Promise.resolve(false), + }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: throwingSteps, + powerHook: hook, + }); + + const outcome = await ladder.run({ enabled: false }, request); + + expect(outcome.kind).toBe('disabled'); + expect(outcome.steps).toEqual([]); + // The whole point: a REAL power hook is installed and still nothing ran. + expect(calls).toEqual([]); + }); + + test('the same hook DOES cycle once recovery is explicitly enabled', async () => { + const calls: string[][] = []; + const hook = createUhubctlPowerHook({ + ports: PORTS, + runner: fakeRunner(OK, calls), + poller: scriptedPoller([ID_PATH, ID_PATH]), + sleep: noSleep, + }); + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: { + nmCycle: () => Promise.resolve({ status: 'failed', reason: 'x' }), + mmCycle: () => Promise.resolve({ status: 'failed', reason: 'x' }), + reset: () => Promise.resolve({ status: 'failed', reason: 'x' }), + }, + powerHook: hook, + }); + const outcome = await ladder.run( + { enabled: true }, + { + stableKey: STABLE_KEY, + modem: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), + attribution: 'modem-fault', + now: epochMillis(0), + probeHealthy: () => Promise.resolve(false), + }, + ); + expect(outcome.steps.find((s) => s.rung === 'powerCycle')?.status).toBe('applied'); + expect(calls).toHaveLength(1); + }); +}); diff --git a/control/src/backend/uhubctl-power-hook.ts b/control/src/backend/uhubctl-power-hook.ts new file mode 100644 index 0000000..0418743 --- /dev/null +++ b/control/src/backend/uhubctl-power-hook.ts @@ -0,0 +1,377 @@ +// The `usb-hub-port-cycle` power hook — recovery ladder rung 4, backed by `uhubctl`. +// +// This is the FIRST real `PowerHook` implementation. It cuts VBUS on one port of a +// per-port-power-switching (PPPS) USB hub, waits for the modem to come back on the +// SAME physical topology path, and reports `applied` only when it actually did. +// +// FOUR safety properties, in the order they bite: +// +// 1. CONFIG-MAPPED, NEVER DISCOVERED. A stable key is power-cyclable only if an +// operator wrote it into an explicitly-pathed config file (`readUhubctlPowerConfig` +// takes the path as an argument — there is no default path, no search, no probe). +// An unmapped key returns `unsupported` and touches nothing. Guessing which hub +// port a modem is on and then cutting its power is exactly the failure mode that +// would black out an unrelated device. +// 2. ARGV ONLY, ALLOWLISTED. The command is built as an argv array and handed to an +// injected runner — there is no shell, no string interpolation, no `sh -c`. Every +// generated token is re-checked against `ALLOWED_ARGV` before the runner is +// called, so even a config that somehow evaded the schema cannot smuggle a flag. +// 3. BOUNDED + CANCELLABLE. The runner call is bounded by `commandTimeoutMs`, the +// re-enumeration wait by `enumerationTimeoutMs`, and both observe an optional +// `AbortSignal`. The worst case is `commandTimeoutMs + enumerationTimeoutMs`; +// there is no path that waits forever. +// 4. SERIALISED PER MODEM. Like every other disruptive op in this backend (see +// `mm-mutations.ts`), a cycle runs through the shared per-modem `ModemActor`, +// keyed on the STABLE key. Two overlapping cycles on one port would otherwise +// interleave a power-on with a power-off and leave the port dark. +// +// PROOF OF SUCCESS IS RE-ENUMERATION, NOT EXIT CODE 0. `uhubctl` exiting 0 only means +// the hub accepted the request. The hook records the modem's `ID_PATH` BEFORE the cut +// and only reports `applied` once that same `ID_PATH` is observed again — a port that +// powers back up with nothing on it is a `failed`, reported with expected-vs-observed. +// +// ----------------------------------------------------------------------------------- +// CAVEAT — STALE DEVICE FILES ON LINUX KERNELS BEFORE 6.0. +// +// uhubctl README, FAQ section `_USB devices are not removed after port power down on +// Linux_` (github.com/mvp/uhubctl, README.md), verbatim: +// +// "After powering down USB port, udev does not get any event, so it keeps the device +// files around. However, trying to access the device files will lead to an IO error. +// This is Linux kernel issue and is fixed since uhubctl 2.5.0 for systems with Linux +// kernel 6.0 or later. If you are still using Linux 5.x or older, you can use this +// workaround for this issue: +// +// sudo uhubctl -a off -l ${location} -p ${port} +// sudo udevadm trigger --action=remove /sys/bus/usb/devices/${location}.${port}/ +// +// Device file will be removed by udev, but USB device will be still visible in +// `lsusb`. Note that path /sys/bus/usb/devices/${location}.${port} will only exist if +// device was detected on that port. When you turn power back on, device should +// re-enumerate properly (no need to call `udevadm` again)." +// +// Why it matters HERE: during the dark window of a cycle, a pre-6.0 kernel leaves the +// device files in place, so a presence check that asks "does the node still exist?" +// reports the modem as present when it is electrically gone — and would let this hook +// declare `applied` off a stale artefact rather than a real re-enumeration. +// +// THIS HOOK DOES NOT RUN `udevadm trigger --action=remove` ITSELF, deliberately: it is +// a privileged host-wide udev mutation whose sysfs path only exists if a device was +// detected there, and firing it from a recovery rung would make rung 4 mutate state +// well outside the port it was mapped to. Instead the hook is built so the caveat +// cannot corrupt its verdict — presence is resolved by the INJECTED +// `UsbEnumerationPoller`, whose production implementation re-reads udev every call and +// never caches (see `usb-enumerator.ts`, which re-runs `udevadm info --export-db` per +// `enumerate()`), and the postcondition compares `ID_PATH`, not a device-node path. A +// deployment pinned to a pre-6.0 kernel wires the `udevadm trigger --action=remove` +// step into that poller or into a udev rule — one explicit, auditable place. +// +// Hardware note: the README's compatible-hub table lists `0BDA:0411` (Rosonway RSH-A10 +// / RSH-A16, Juiced Systems 6HUB-01) as per-port-power-switching capable — that is the +// Realtek chipset on this project's bench board. `0bda:5411` is NOT on that list, so a +// hub reporting that id may need `-f`, which this hook never passes. +// ----------------------------------------------------------------------------------- + +import { z } from 'zod'; +import { ModemActor } from './modem-actor'; +import type { + PowerCapability, + PowerCycleContext, + PowerCycleResult, + PowerHook, + PreferredUsbMode, +} from './power-contract'; + +/** + * A uhubctl hub location: `-[.…]` (e.g. `1-1`, `2-1.4`), or a bare + * bus number for a root hub. This mirrors the Linux sysfs USB path and is the ONLY + * shape accepted — a VID:PID selector or a `--` flag can never parse as one. + */ +const HUB_LOCATION = /^[0-9]{1,3}(-[0-9]{1,3}(\.[0-9]{1,3})*)?$/; + +/** One mapped modem: which PPPS hub it hangs off, and which port on that hub. */ +export const uhubctlPortMappingSchema = z.strictObject({ + /** The hub's uhubctl location (`-l`), e.g. `1-1` or `2-1.4`. */ + hubLocation: z.string().regex(HUB_LOCATION, 'hubLocation must look like `1-1` or `2-1.4`'), + /** The 1-based port number on that hub (`-p`). */ + port: z.number().int().min(1).max(255), +}); +export type UhubctlPortMapping = z.infer; + +/** + * The whole config file: a map from STABLE KEY to its hub/port mapping. `.strictObject` + * on each entry means a typo'd or smuggled extra field is rejected rather than ignored. + */ +export const uhubctlPortMapSchema = z.record(z.string().min(1), uhubctlPortMappingSchema); +export type UhubctlPortMap = z.infer; + +/** + * Parse config text (JSON) into a validated port map. `path` is used only for the + * error message, so a malformed file fails visibly with a named field. + */ +export function parseUhubctlPortMap(text: string, path: string): UhubctlPortMap { + let raw: unknown; + try { + raw = JSON.parse(text) as unknown; + } catch (error) { + throw new Error(`invalid uhubctl port map ${path}: ${describe(error)}`); + } + const result = uhubctlPortMapSchema.safeParse(raw); + if (!result.success) { + const issue = result.error.issues[0]; + const where = issue?.path.join('.') || '(root)'; + throw new Error( + `invalid uhubctl port map ${path}: ${where}: ${issue?.message ?? 'schema mismatch'}`, + ); + } + return result.data; +} + +/** + * Read + validate a port map from an EXPLICIT path. There is intentionally no default + * and no discovery: a caller that cannot name the file gets no power control. + */ +export async function readUhubctlPortMap(path: string): Promise { + return parseUhubctlPortMap(await Bun.file(path).text(), path); +} + +/** The result of one `uhubctl` invocation. */ +export interface UhubctlResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +/** + * A runner over `uhubctl` argv — structurally the same seam as `NmcliRunner`. Tests + * inject a fake; the device injects `SpawnUhubctlRunner`. The hook NEVER spawns + * directly, so the argv the tests assert against is byte-for-byte what runs on-device. + */ +export interface UhubctlRunner { + run(argv: readonly string[]): UhubctlResult | Promise; +} + +/** The device-exact runner: spawns the real `uhubctl` with the argv array verbatim. */ +export class SpawnUhubctlRunner implements UhubctlRunner { + async run(argv: readonly string[]): Promise { + const proc = Bun.spawn(['uhubctl', ...argv], { stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; + } +} + +/** + * Resolves the modem's current udev `ID_PATH` (its physical topology UID) for a stable + * key, or `undefined` when nothing is enumerated there. Injected so tests need no + * hardware; the production implementation MUST re-read udev/sysfs every call (see the + * pre-6.0 stale-devfile caveat at the top of this file). + */ +export interface UsbEnumerationPoller { + idPathFor(stableKey: string): string | undefined | Promise; +} + +const DEFAULT_ENUMERATION_TIMEOUT_MS = 30_000; +const DEFAULT_COMMAND_TIMEOUT_MS = 15_000; +const DEFAULT_POLL_INTERVAL_MS = 250; +/** `uhubctl -d` — seconds the port stays dark before it is powered back on. */ +const DEFAULT_POWER_OFF_DELAY_SECONDS = 3; + +/** Construction dependencies. Everything that touches the system is injectable. */ +export interface UhubctlPowerHookDeps { + /** The validated stable-key → hub/port map (see `readUhubctlPortMap`). */ + readonly ports: UhubctlPortMap; + readonly runner: UhubctlRunner; + readonly poller: UsbEnumerationPoller; + /** Shared per-modem serialisation. Defaults to a private actor. */ + readonly actor?: ModemActor; + readonly enumerationTimeoutMs?: number; + readonly commandTimeoutMs?: number; + readonly pollIntervalMs?: number; + readonly powerOffDelaySeconds?: number; + readonly preferredUsbMode?: PreferredUsbMode; + /** Cancels an in-flight cycle — the hook resolves `failed`, it never hangs. */ + readonly signal?: AbortSignal; + readonly sleep?: (ms: number) => Promise; + readonly now?: () => number; +} + +/** + * Every argv token this hook is permitted to emit. The flags are literals; the two + * value slots are re-validated against the same shapes the schema enforced. Anything + * else is a bug in this file and fails closed before the runner is called. + */ +const ALLOWED_ARGV: readonly RegExp[] = [ + /^-l$/, + /^-p$/, + /^-a$/, + /^-d$/, + /^cycle$/, + HUB_LOCATION, + /^[0-9]{1,3}$/, +]; + +/** Build the `uhubctl` argv for one mapping. Pure + exported so tests can assert it. */ +export function uhubctlCycleArgv( + mapping: UhubctlPortMapping, + powerOffDelaySeconds: number, +): readonly string[] { + const argv = [ + '-l', + mapping.hubLocation, + '-p', + String(mapping.port), + '-a', + 'cycle', + '-d', + String(powerOffDelaySeconds), + ]; + for (const token of argv) { + if (!ALLOWED_ARGV.some((allowed) => allowed.test(token))) { + throw new Error(`refusing to run uhubctl: argv token '${token}' is not allowlisted`); + } + } + return argv; +} + +/** The `usb-hub-port-cycle` power hook. One instance serves every mapped modem. */ +export function createUhubctlPowerHook(deps: UhubctlPowerHookDeps): PowerHook { + const enumerationTimeoutMs = deps.enumerationTimeoutMs ?? DEFAULT_ENUMERATION_TIMEOUT_MS; + const commandTimeoutMs = deps.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const powerOffDelaySeconds = deps.powerOffDelaySeconds ?? DEFAULT_POWER_OFF_DELAY_SECONDS; + const actor = deps.actor ?? new ModemActor(); + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? defaultSleep; + + const capability: PowerCapability = { + power: 'usb-hub-port-cycle', + usbReset: true, + enumerationTimeoutMs, + ...(deps.preferredUsbMode !== undefined ? { preferredUsbMode: deps.preferredUsbMode } : {}), + }; + + const cancelled = (): PowerCycleResult | undefined => + deps.signal?.aborted === true + ? { status: 'failed', reason: 'power cycle cancelled by the caller' } + : undefined; + + async function awaitReenumeration( + stableKey: string, + expected: string | undefined, + ): Promise { + const deadline = now() + enumerationTimeoutMs; + let observed: string | undefined; + while (now() < deadline) { + const abort = cancelled(); + if (abort !== undefined) { + return abort; + } + observed = await deps.poller.idPathFor(stableKey); + // A port cycle preserves the physical topology, so the SAME ID_PATH must + // come back. If nothing was enumerated before the cut there is no path to + // compare against — any device re-appearing at that key is the recovery. + if (observed !== undefined && (expected === undefined || observed === expected)) { + return { + status: 'applied', + reason: `port cycled; modem re-enumerated at ID_PATH '${observed}'`, + }; + } + await sleep(pollIntervalMs); + } + return { + status: 'failed', + reason: + `modem did not re-enumerate within ${enumerationTimeoutMs}ms — expected ID_PATH ` + + `${expected === undefined ? '(any device)' : `'${expected}'`}, observed ` + + `${observed === undefined ? 'no device' : `'${observed}'`}`, + }; + } + + async function cycleMapped( + stableKey: string, + mapping: UhubctlPortMapping, + ): Promise { + // Record the pre-cut topology path — the postcondition compares against it. + const expected = await deps.poller.idPathFor(stableKey); + + let argv: readonly string[]; + try { + argv = uhubctlCycleArgv(mapping, powerOffDelaySeconds); + } catch (error) { + return { status: 'failed', reason: describe(error) }; + } + + let result: UhubctlResult; + try { + result = await withTimeout( + Promise.resolve(deps.runner.run(argv)), + commandTimeoutMs, + `uhubctl did not return within ${commandTimeoutMs}ms`, + ); + } catch (error) { + return { status: 'failed', reason: `uhubctl ${argv.join(' ')} failed: ${describe(error)}` }; + } + if (result.exitCode !== 0) { + return { + status: 'failed', + reason: + `uhubctl ${argv.join(' ')} exited ${result.exitCode}: ` + + `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`, + }; + } + + // Exit 0 only means the hub accepted the request — re-enumeration is the proof. + return awaitReenumeration(stableKey, expected); + } + + return { + capability, + cycle(context: PowerCycleContext): Promise { + const { stableKey } = context; + const mapping = deps.ports[stableKey]; + if (mapping === undefined) { + // Refuse BEFORE the actor and before any I/O: an unmapped key must never + // cut power to a port that was never declared to belong to it. + return Promise.resolve({ + status: 'unsupported', + reason: `no uhubctl hub/port mapping is configured for stable key '${stableKey}'`, + }); + } + const abort = cancelled(); + if (abort !== undefined) { + return Promise.resolve(abort); + } + // Serialised on the STABLE key, like every other disruptive op (mm-mutations). + return actor.run(stableKey, () => cycleMapped(stableKey, mapping)); + }, + }; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Bound a promise; rejects with `message` if it has not settled in `ms`. */ +async function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 12dcd1d1c5ef55e8af5284ef47ca8c77874422af Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 03:28:48 -0500 Subject: [PATCH 03/12] docs(bench): RB-9 fleet inventory runbook --- docs/BENCH.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/docs/BENCH.md b/docs/BENCH.md index 6bb97ac..be0d173 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -529,6 +529,103 @@ grep -q 'ModemManager1' A6.3/daemon-smoke.txt \ --- +## RB-9 — Fleet inventory capture `[PARTIAL]` + +Capture a per-unit inventory bundle for every physical modem/dongle on the bench: raw USB +descriptor (VID:PID per port), USB `ID_PATH`, firmware string, personality (Stick vs HiLink; +router vs MM-managed), ModemManager detection, transport, hub port mapping, SIM state, and — +for router-mode dongles — the actual served LAN subnet. This is inventory only: it records +what is physically connected and what state it honestly reports. It does **not** certify a +USB-mode transition (that is RB-5) and does not claim any unit "certified" (that is a later +step in the plan, outside this runbook). + +**Preconditions** + +- Bench device reachable over SSH. `mmcli` on `PATH` for MM-managed units. **No `lsusb` binary + on the bench image** — the canonical raw-USB-tree capture below uses the `/sys/bus/usb/devices/*` + sweep instead, never `lsusb`. +- Re-run the sweep immediately before trusting any prior capture — hardware on this bench gets + physically moved/reconnected between sessions, and USB port position is **not** a stable + identity key across a replug (empirically confirmed — see the machine check below for the + `ID_PATH`, not port, assertion). + +**Commands** + +```sh +mkdir -p A6.3 +# 1) Raw USB tree — canonical capture, NOT lsusb (not installed on this image): +for d in /sys/bus/usb/devices/*; do [ -f "$d/idVendor" ] && echo "$(basename $d): $(cat $d/idVendor):$(cat $d/idProduct) mfg=\"$(cat $d/manufacturer 2>/dev/null)\" product=\"$(cat $d/product 2>/dev/null)\" serial=\"$(cat $d/serial 2>/dev/null)\""; done | tee A6.3/usb-tree.txt + +# 2) ModemManager-visible units: +mmcli -L | tee A6.3/mmcli-list.txt + +# 3) Per MM-managed unit, full dump (repeat per index reported by step 2): +for i in $(mmcli -L 2>/dev/null | grep -oE '/Modem/[0-9]+' | grep -oE '[0-9]+$'); do + mmcli -m "$i" | tee -a A6.3/mmcli-dump.txt +done + +# 4) USB ID_PATH per network interface (router-mode dongles and MM net legs alike): +for ifc in $(ip -br addr | awk '{print $1}'); do + echo "--- $ifc ---" + udevadm info -q property -p "/sys/class/net/$ifc" 2>/dev/null | grep -E '^ID_PATH=|^ID_VENDOR_ID=|^ID_MODEL_ID=' +done | tee A6.3/id-path-per-iface.txt + +# 5) Router-dongle LAN subnets actually served (host-side DHCP lease per interface): +ip -br addr | tee A6.3/ip-addr.txt +``` + +**Expected output** — each command produces at least one non-empty line matching its capture +target: + +``` +1-1.1: 19d2:1405 mfg="ZTE,Incorporated" product="ZTE Mobile Boardband" serial="..." +``` + +for the USB tree sweep; + +``` + /org/freedesktop/ModemManager1/Modem/ [] +``` + +for every MM-managed unit; and a non-empty `ID_PATH=platform-...` line per claimed network +interface. + +**Machine check** + +```sh +grep -Eq '^[0-9]+-[0-9.]+: [0-9a-f]{4}:[0-9a-f]{4} ' A6.3/usb-tree.txt \ + && grep -Eq 'ID_PATH=platform-' A6.3/id-path-per-iface.txt \ + && echo "RB-9 PASS" || echo "RB-9 FAIL" +``` + +**Per-unit capture status (this bench, `ceralive2` 192.168.78.132)** + +| Unit | VID:PID | Personality | MM status | Evidence | +|------|---------|-------------|-----------|----------| +| ZTE Mobile Broadband | `19d2:1405` | router-hilink | not MM-managed | `test-results/modem-phase-b/05/zte-mf79u/` | +| Huawei HiLink #1 | `12d1:14dc` | router-hilink | not MM-managed | `test-results/modem-phase-b/05/huawei-hilink-1/` | +| Huawei HiLink #2 | `12d1:14dc` | router-hilink | not MM-managed | `test-results/modem-phase-b/05/huawei-hilink-2/` | +| Quectel RM530N-GL | `2c7c:0801` | MM-managed stick | enabled, sim-pin2, not registered | `test-results/modem-phase-b/05/quectel-rm530n-gl/` | +| SIMCom SIM7600G-H R2 | `1e0e:9001` | MM-managed stick | failed, sim-missing | `test-results/modem-phase-b/05/simcom-sim7600g-h/` | +| Generic AliExpress stick #1 | `05c6:9024` | generic router-class, QMI passthrough not supported by design | not MM-managed | `test-results/modem-phase-b/05/aliexpress-stick-1/` | +| Generic AliExpress stick #2 | `05c6:9024` | generic router-class, QMI passthrough not supported by design | not MM-managed | `test-results/modem-phase-b/05/aliexpress-stick-2/` | +| Sierra EM75xx | `1199:*` (expected) | — | — | `[PARTIAL]` — not physically connected to this bench yet; capture commands above are documented and ready, no capture run | +| Fibocom FM350 | `0e8d:7126` / `14c3:4d75` (expected) | — | — | `[PARTIAL]` — not physically connected to this bench yet (also see `docs/FM350-DECISION.md` — this SKU is documented-deferred for support, not just uncaptured); capture commands above are documented and ready, no capture run | + +The Huawei HiLink pair ships from the factory with an **identical MAC address** +(`0c:5b:8f:27:9a:64`) on both physically distinct units. Both units independently DHCP-serve +`192.168.8.100/24`; the host loses `enx`-predictable naming for the second unit to +enumerate and it falls back to a legacy `ethN` name. Recorded explicitly in both units' evidence +bundles — this is the exact scenario `image-building-pipeline`'s dongle-netns contract +(`docs/dongle-netns-contract.md`) requires keying identity off USB `ID_PATH`, never MAC, to +survive. + +**Evidence:** `test-results/modem-control/A6.3/{usb-tree,mmcli-list,mmcli-dump,id-path-per-iface,ip-addr}.txt` +plus one JSON+text bundle per unit under `test-results/modem-phase-b/05//` (repo-local, +gitignored). + +--- + ## Evidence index | Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | @@ -541,6 +638,7 @@ grep -q 'ModemManager1' A6.3/daemon-smoke.txt \ | RB-6 | Usage-meter accuracy (machine-checkable) | `[PARTIAL]` | `A6.3/usage-accuracy.txt` | | RB-7 | arm64-on-real-hardware validation | `[PARTIAL]` | `A6.3/arm64-{uname,probe}.txt` | | RB-8 | Daemon smoke on real hardware | `[PARTIAL]` | `A6.3/daemon-smoke.txt` | +| RB-9 | Fleet inventory capture | `[PARTIAL]` | `A6.3/{usb-tree,mmcli-list,mmcli-dump,id-path-per-iface,ip-addr}.txt` + `test-results/modem-phase-b/05//` | 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 From 0f4648bf797865509ce77c1ceddd913e6abb09de Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 04:01:40 -0500 Subject: [PATCH 04/12] feat(cli): hil-cycle harness + RB-10 hub VBUS verification runbook --- AGENTS.md | 2 +- README.md | 2 +- cli/README.md | 1 + cli/src/cli.ts | 53 ++++- cli/src/commands/hil-cycle.ts | 382 ++++++++++++++++++++++++++++++++++ cli/src/hil-cycle.test.ts | 249 ++++++++++++++++++++++ cli/src/hil-probe.ts | 127 +++++++++++ docs/BENCH.md | 165 +++++++++++++++ 8 files changed, 977 insertions(+), 4 deletions(-) create mode 100644 cli/src/commands/hil-cycle.ts create mode 100644 cli/src/hil-cycle.test.ts create mode 100644 cli/src/hil-probe.ts diff --git a/AGENTS.md b/AGENTS.md index c07df19..619a1e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE | Directory | Artifact | Role | |-----------|----------|------| | `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler. Published to public npm under `@ceralive`. | -| `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | +| `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | | `packaging/` | ModemManager stack `.deb`s | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`). Bench installs from CI artifacts. | `control/` + `cli/` are one **Bun** workspace. `packaging/` builds in a bookworm container. diff --git a/README.md b/README.md index 5f104db..88c67ea 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ changes to CeraUI, the device image, or apt-worker**. Bench devices install the | Directory | Artifact | What it is | |-----------|----------|------------| | [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | -| [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package and capture per-SKU certification bundles. | +| [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling (RB-10). | | [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork, zero source patches** (see [`POLICY.md`](POLICY.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | ## Versioning at a glance diff --git a/cli/README.md b/cli/README.md index 5079534..6734da0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -15,6 +15,7 @@ to mature the package and (with `certify`) capture per-SKU certification bundles | `certify ` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. `--transition ` adds transition evidence (before/after descriptors, the executed AT command, and the port-drop / re-enumeration timeline) shaped to drop straight into an A4.2 catalog entry. Prints `CERTIFY OK: sha256= …` — the sha256 is the value a reviewer records in a catalog entry's `evidenceBundleSha256`. Real captures are marked `synthetic: false`; ICCID / IMSI / EID are masked; a malformed capture exits non-zero with a clear error rather than writing a broken bundle. `--output ` writes the bundle JSON (default stdout). | | `usage` | Prints the data-usage sampler snapshot (per-slot cumulative-cycle bytes; advisory threshold). | | `unlock-pin [slot]` / `unlock-puk [slot]` | Prompts for the PIN / PUK with **terminal echo disabled** (the secret is never printed back) and submits it. | +| `hil-cycle --hub-map ` | The hardware-in-the-loop port-cycle harness (bench runbook **RB-10**). Captures pre-state (the `/sys/bus/usb/devices/*` sweep — there is no `lsusb` on the bench image — plus `mmcli -L`), drives the `usb-hub-port-cycle` PowerHook for the mapped slot, then asserts the modem **left** the USB bus, **came back** at the same udev `ID_PATH` within the deadline, and that ModemManager re-detects the **same** `modem.generic.device` slot UID. Prints `HIL-CYCLE PASS slot= disappeared= reenumerated=`, or `HIL-CYCLE FAIL slot= reason=` with a named reason (`no-vbus-drop`, `reenumeration-timeout`, `mm-slot-mismatch`, `hub-map-slot-unmapped`, …) and a non-zero exit. **A zero exit from `uhubctl` is never the gate** — the modem's disappearance from the bus is. `` is the modem's udev `ID_PATH` (the hub-map key); `--mm-slot ` names the same modem to ModemManager (defaults to ``). Needs `sudo` for `uhubctl`; refuses with zero side effects when the slot is not in the map. | ### Global options diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 4022500..399ff51 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -6,15 +6,25 @@ // at the A2.3 fake ModemManager service instead of the real system bus. import { parseArgs } from 'node:util'; -import { MM_USB_MODES, type MmUsbMode } from '@ceralive/modem-control'; +import { + createUhubctlPowerHook, + createUsbEnumerator, + MM_USB_MODES, + type MmUsbMode, + readUhubctlPortMap, + SpawnUhubctlRunner, +} from '@ceralive/modem-control'; +import { SpawnCommandRunner } from './certify/command-runner'; import { runApply } from './commands/apply'; import { type CertifyArgs, certifyDepsFromContext, runCertify } from './commands/certify'; +import { runHilCycle } from './commands/hil-cycle'; import { runProbe } from './commands/probe'; import { runSetUsbMode, type UsbModeArgs } from './commands/set-usb-mode'; import { runUnlock } from './commands/unlock'; import { runUsage } from './commands/usage'; import { runWatch } from './commands/watch'; import { createStackContext, type GlobalOptions, type StackContext } from './context'; +import { mmcliSlots, sysfsUsbSweep, usbIdPathPoller } from './hil-probe'; import type { CliIo } from './io'; import { readPolicyFile } from './policy-file'; import { buildRequestResolver, buildUsageInputs, buildUsbModeTransition } from './wiring'; @@ -32,6 +42,7 @@ Commands: usage Print the data-usage sampler snapshot unlock-pin [slot] Unlock a SIM PIN (redacted prompt) unlock-puk [slot] Unblock a SIM PUK (redacted prompts) + hil-cycle --hub-map HIL port cycle: cut VBUS and prove the modem came back (RB-10) Global options: --bus-address D-Bus bus address (harness/smoke injection; env MODEM_CONTROL_BUS_ADDRESS) @@ -43,7 +54,9 @@ Command options: --duration | --events (watch) exit bound; default runs until Ctrl-C --transition (certify) capture transition evidence into (qmi/mbim/ecm-ncm) --output (certify) write the bundle here (default: stdout) - --synthetic (certify) mark the bundle a synthetic sample (never for real captures)`; + --synthetic (certify) mark the bundle a synthetic sample (never for real captures) + --hub-map (hil-cycle) uhubctl stable-key -> {hubLocation,port} map (required) + --mm-slot (hil-cycle) ModemManager selector for the same modem (default: )`; /** Run one CLI invocation. Returns a process exit code. */ export async function runCli(argv: readonly string[], io: CliIo): Promise { @@ -62,6 +75,8 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise --hub-map [--mm-slot ]'); + return 2; + } + // No stack context: the harness reads sysfs + `mmcli`, never the D-Bus stack, + // so it must not open (and then lose) a bus connection across a port cycle. + const commandRunner = new SpawnCommandRunner(); + const enumerator = createUsbEnumerator(); + const poller = usbIdPathPoller(() => enumerator.enumerate()); + try { + return await runHilCycle( + io, + { slot, mmSlot: values['mm-slot'] ?? slot }, + { + readPortMap: () => readUhubctlPortMap(hubMap), + createPowerHook: (ports, recordingPoller) => + createUhubctlPowerHook({ + ports, + runner: new SpawnUhubctlRunner(), + poller: recordingPoller, + }), + poller, + usbSweep: () => sysfsUsbSweep(), + mmSlots: () => mmcliSlots(commandRunner), + }, + ); + } catch (error) { + io.err(`error: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + } case 'usage': return withStack(global, io, async (ctx) => { const { sampler, observations } = await buildUsageInputs(ctx); diff --git a/cli/src/commands/hil-cycle.ts b/cli/src/commands/hil-cycle.ts new file mode 100644 index 0000000..e20b1db --- /dev/null +++ b/cli/src/commands/hil-cycle.ts @@ -0,0 +1,382 @@ +// `modem-control hil-cycle --hub-map ` — the hardware-in-the-loop +// port-cycle harness (bench runbook RB-10). +// +// It drives ONE real VBUS cycle on a per-port-power-switching (PPPS) hub and proves the +// modem came back — physically, on the bus, and in ModemManager. It consumes the +// `usb-hub-port-cycle` PowerHook as-is (`createUhubctlPowerHook`) through the +// `createPowerHook` factory seam; it never builds argv or spawns anything itself. +// +// WHY A ZERO EXIT CODE FROM `uhubctl` IS NOT THE GATE. `uhubctl -a cycle` exiting 0 only +// means the hub ACCEPTED the request. A hub that silently ignores per-port power (very +// common on non-PPPS silicon) exits 0 and cuts nothing. The only honest observable is the +// modem VANISHING from the USB bus and coming back at the SAME physical topology path. +// This harness therefore asserts, in order: +// +// 1. pre-capture — the `/sys/bus/usb/devices/*` sweep (there is NO `lsusb` on the +// bench image — see RB-9), `mmcli -L`, the slot's current udev +// `ID_PATH`, and the slot's `modem.generic.device` UID. +// 2. cycle — `PowerHook.cycle()` for the mapped stable key. +// 3. disappeared — a recorded sample where the slot's `ID_PATH` was ABSENT. This is +// the VBUS-drop proof. +// 4. reenumerated — a later sample where the SAME `ID_PATH` is back, within deadline. +// 5. mm-redetect — ModemManager reports the SAME `modem.generic.device` slot UID +// again (the RB-2 assertion, re-run after the cycle). The runtime +// `/Modem/` path is allowed to change; the slot UID is not. +// 6. emit — `HIL-CYCLE PASS slot= disappeared= reenumerated=`. +// +// HOW THE DISAPPEARANCE IS OBSERVED, AND WHY IT IS NOT A SECOND POLLER. The harness wraps +// the injected `UsbEnumerationPoller` in a RECORDER and hands that recorder to the hook. +// Every presence sample the hook takes during its own cycle is timestamped into one +// trace, so the harness and the hook can never disagree about what the bus did — there is +// exactly one source of truth and no concurrent double-polling of udev. +// +// KNOWN OBSERVATION LIMIT (documented in RB-10, not hidden here). `uhubctl -a cycle -d N` +// BLOCKS for the whole dark window and only then returns, so the hook's first presence +// sample lands AFTER power is already back on. What makes the drop observable anyway is +// that USB re-enumeration is not instantaneous: on Linux >= 6.0 the device node is really +// removed on power-down (see the pre-6.0 caveat in `uhubctl-power-hook.ts`) and takes +// order-of-a-second to reappear. If the sampling still misses the gap the harness fails +// with `no-vbus-drop` rather than passing on a cycle it could not prove — and RB-10's +// manual two-phase `-a off` / sweep / `-a on` proof is the fallback that settles it. + +import { + epochMillis, + type PowerHook, + type UhubctlPortMap, + type UsbEnumerationPoller, +} from '@ceralive/modem-control'; +import type { CliIo } from '../io'; + +/** The ordered phases of one harness run — the call-order contract the tests pin. */ +export const HIL_CYCLE_PHASES = [ + 'pre-capture', + 'cycle', + 'disappeared', + 'reenumerated', + 'mm-redetect', + 'emit', +] as const; +export type HilCyclePhase = (typeof HIL_CYCLE_PHASES)[number]; + +/** + * Every way this harness can refuse or fail. A run NEVER throws a bare exception at the + * operator: it names one of these and exits non-zero, so a runbook can grep the reason. + */ +export const HIL_CYCLE_FAILURES = [ + /** The `--hub-map` file is missing, unreadable, or does not match the schema. */ + 'hub-map-unreadable', + /** The file parsed, but carries no hub/port entry for this slot. */ + 'hub-map-slot-unmapped', + /** The pre-state capture (sysfs sweep / `mmcli -L`) itself failed. */ + 'pre-capture-failed', + /** Nothing is enumerated at this slot right now — there is no baseline to prove. */ + 'slot-not-enumerated', + /** ModemManager does not report this slot before the cycle. */ + 'mm-slot-absent', + /** The stable key is mapped, but the hook reports no power capability for it. */ + 'power-cycle-unsupported', + /** The hook ran and reported a failure that is not a bus-observation failure. */ + 'power-cycle-failed', + /** The modem never left the bus — power was almost certainly NOT cut. */ + 'no-vbus-drop', + /** It left the bus but the same `ID_PATH` did not come back inside the deadline. */ + 'reenumeration-timeout', + /** The bus recovered but ModemManager never re-reported the slot UID. */ + 'mm-redetect-timeout', + /** ModemManager re-detected the slot with a DIFFERENT `modem.generic.device`. */ + 'mm-slot-mismatch', +] as const; +export type HilCycleFailure = (typeof HIL_CYCLE_FAILURES)[number]; + +/** One ModemManager modem as the harness needs it: runtime path + stable slot UID. */ +export interface MmSlotRecord { + /** The runtime D-Bus path, e.g. `/org/freedesktop/ModemManager1/Modem/2`. */ + readonly path: string; + /** `modem.generic.device` — the udev slot UID that must survive the cycle. */ + readonly device: string; +} + +/** The outcome of one harness run. */ +export type HilCycleOutcome = + | { + readonly kind: 'pass'; + readonly slot: string; + readonly idPath: string; + readonly mmDevice: string; + readonly disappearedMs: number; + readonly reenumeratedMs: number; + } + | { readonly kind: 'failed'; readonly reason: HilCycleFailure; readonly detail: string }; + +/** Parsed `hil-cycle` arguments. */ +export interface HilCycleArgs { + /** The stable key: the `--hub-map` key AND the poller's lookup key. */ + readonly slot: string; + /** The ModemManager selector (`--mm-slot`), defaulting to `slot` at the CLI layer. */ + readonly mmSlot: string; +} + +/** Everything that touches the system is injected — the tests supply fakes for all of it. */ +export interface HilCycleDeps { + /** Read + validate the `--hub-map` file. Any throw becomes `hub-map-unreadable`. */ + readonly readPortMap: () => Promise; + /** + * Build the PowerHook over the validated map and the harness's RECORDING poller. + * Production passes `createUhubctlPowerHook`; tests pass the same factory with a + * fake `UhubctlRunner`, so the real hook is exercised end to end. + */ + readonly createPowerHook: (ports: UhubctlPortMap, poller: UsbEnumerationPoller) => PowerHook; + /** Resolves the slot's current udev `ID_PATH`; must re-read udev on every call. */ + readonly poller: UsbEnumerationPoller; + /** The `/sys/bus/usb/devices/*` sweep text (RB-9's canonical capture, never `lsusb`). */ + readonly usbSweep: () => Promise; + /** Every modem ModemManager currently reports, with its `modem.generic.device`. */ + readonly mmSlots: () => Promise; + readonly now?: () => number; + readonly sleep?: (ms: number) => Promise; + /** How long after the cycle the same `ID_PATH` may take to return. */ + readonly reenumerationDeadlineMs?: number; + /** How long ModemManager may take to re-report the slot UID after the bus recovers. */ + readonly mmRedetectTimeoutMs?: number; + readonly mmPollIntervalMs?: number; + /** Phase tap — the call-order seam the integration test asserts against. */ + readonly onPhase?: (phase: HilCyclePhase) => void; +} + +const DEFAULT_REENUMERATION_DEADLINE_MS = 60_000; +const DEFAULT_MM_REDETECT_TIMEOUT_MS = 60_000; +const DEFAULT_MM_POLL_INTERVAL_MS = 500; + +/** One timestamped presence sample taken by the hook through the recording poller. */ +interface PresenceSample { + readonly atMs: number; + readonly idPath: string | undefined; +} + +/** The `PASS` line RB-10 greps for. Exactly one shape, no optional fields. */ +export function hilCyclePassLine(outcome: Extract): string { + return `HIL-CYCLE PASS slot=${outcome.slot} disappeared=${outcome.disappearedMs} reenumerated=${outcome.reenumeratedMs}`; +} + +/** The `FAIL` line RB-10 greps for — always carries a NAMED reason, never a stack trace. */ +export function hilCycleFailLine(slot: string, reason: HilCycleFailure): string { + return `HIL-CYCLE FAIL slot=${slot} reason=${reason}`; +} + +/** + * Run one HIL port cycle and report a typed outcome. This function performs no I/O of + * its own — every system touch is a `deps` seam, so the same code path runs on the bench + * and under test. + */ +export async function hilCycle(args: HilCycleArgs, deps: HilCycleDeps): Promise { + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const reenumerationDeadlineMs = deps.reenumerationDeadlineMs ?? DEFAULT_REENUMERATION_DEADLINE_MS; + const mmRedetectTimeoutMs = deps.mmRedetectTimeoutMs ?? DEFAULT_MM_REDETECT_TIMEOUT_MS; + const mmPollIntervalMs = deps.mmPollIntervalMs ?? DEFAULT_MM_POLL_INTERVAL_MS; + const phase = (p: HilCyclePhase): void => deps.onPhase?.(p); + const fail = (reason: HilCycleFailure, detail: string): HilCycleOutcome => ({ + kind: 'failed', + reason, + detail, + }); + + let ports: UhubctlPortMap; + try { + ports = await deps.readPortMap(); + } catch (error) { + return fail('hub-map-unreadable', describe(error)); + } + // Refuse an unmapped slot BEFORE any capture and before the hook exists — the same + // fail-closed ordering the hook itself uses. An unmapped key must never reach argv. + if (ports[args.slot] === undefined) { + return fail( + 'hub-map-slot-unmapped', + `no hub/port entry for slot '${args.slot}' in the --hub-map file ` + + `(mapped keys: ${Object.keys(ports).join(', ') || '(none)'})`, + ); + } + + // ---- 1. pre-capture ------------------------------------------------------------- + phase('pre-capture'); + let sweep: string; + let mmBefore: readonly MmSlotRecord[]; + try { + sweep = await deps.usbSweep(); + mmBefore = await deps.mmSlots(); + } catch (error) { + return fail('pre-capture-failed', describe(error)); + } + if (sweep.trim() === '') { + return fail('pre-capture-failed', 'the /sys/bus/usb/devices sweep produced no output'); + } + + let expectedIdPath: string | undefined; + try { + expectedIdPath = await deps.poller.idPathFor(args.slot); + } catch (error) { + return fail('pre-capture-failed', `resolving the slot ID_PATH failed: ${describe(error)}`); + } + if (expectedIdPath === undefined) { + return fail( + 'slot-not-enumerated', + `nothing is enumerated at slot '${args.slot}' right now — there is no ` + + 'baseline ID_PATH, so a re-enumeration could not be proven', + ); + } + + const before = matchMmSlot(mmBefore, args.mmSlot); + if (before === undefined) { + return fail( + 'mm-slot-absent', + `ModemManager does not report a modem matching '${args.mmSlot}' before the cycle ` + + `(reported: ${mmBefore.map((r) => r.path).join(', ') || '(none)'})`, + ); + } + + // ---- 2. cycle ------------------------------------------------------------------- + // The hook polls presence through this recorder, so the disappearance/re-enumeration + // evidence is the hook's OWN observation trace rather than a second, racing poller. + const trace: PresenceSample[] = []; + let recording = false; + const recorder: UsbEnumerationPoller = { + async idPathFor(stableKey: string): Promise { + const idPath = await deps.poller.idPathFor(stableKey); + if (recording) { + trace.push({ atMs: now(), idPath }); + } + return idPath; + }, + }; + + const hook = deps.createPowerHook(ports, recorder); + phase('cycle'); + recording = true; + const startedAtMs = now(); + const result = await hook.cycle({ stableKey: args.slot, at: epochMillis(startedAtMs) }); + recording = false; + + if (result.status === 'unsupported') { + return fail('power-cycle-unsupported', result.reason); + } + + // ---- 3. disappeared ------------------------------------------------------------- + // The VBUS-drop proof: a sample in which the slot's ID_PATH was gone from the bus. + const goneAt = trace.findIndex((s) => s.idPath !== expectedIdPath); + const gone = goneAt < 0 ? undefined : trace[goneAt]; + if (gone === undefined) { + return fail( + 'no-vbus-drop', + `the modem never left the bus during the cycle — ID_PATH '${expectedIdPath}' was ` + + `present in all ${trace.length} sample(s). A zero exit from uhubctl does not ` + + 'prove VBUS was cut; run RB-10\u2019s manual two-phase `-a off` / sweep / `-a on` ' + + 'proof to settle whether this hub really switches per-port power.', + ); + } + const disappearedMs = gone.atMs - startedAtMs; + phase('disappeared'); + + // ---- 4. reenumerated ------------------------------------------------------------ + const backAt = trace.findIndex((s, i) => i > goneAt && s.idPath === expectedIdPath); + const back = backAt < 0 ? undefined : trace[backAt]; + if (back === undefined) { + return fail( + 'reenumeration-timeout', + `ID_PATH '${expectedIdPath}' left the bus after ${disappearedMs}ms and never came ` + + `back (hook verdict: ${result.status} — ${result.reason})`, + ); + } + const reenumeratedMs = back.atMs - startedAtMs; + if (reenumeratedMs > reenumerationDeadlineMs) { + return fail( + 'reenumeration-timeout', + `ID_PATH '${expectedIdPath}' came back after ${reenumeratedMs}ms, past the ` + + `${reenumerationDeadlineMs}ms deadline`, + ); + } + if (result.status !== 'applied') { + return fail('power-cycle-failed', result.reason); + } + phase('reenumerated'); + + // ---- 5. mm-redetect ------------------------------------------------------------- + // The RB-2 assertion, re-run: the runtime /Modem/ path MAY change across a cycle, + // the `modem.generic.device` slot UID may NOT. + phase('mm-redetect'); + const deadline = now() + mmRedetectTimeoutMs; + let lastSeen: readonly MmSlotRecord[] = []; + for (;;) { + try { + lastSeen = await deps.mmSlots(); + } catch (error) { + return fail('mm-redetect-timeout', `re-reading ModemManager failed: ${describe(error)}`); + } + if (lastSeen.some((r) => r.device === before.device)) { + break; + } + const atSelector = matchMmSlot(lastSeen, args.mmSlot); + if (atSelector !== undefined) { + // A modem answered at the same selector but is NOT the same physical slot — + // waiting cannot fix that, so fail immediately with the honest reason. + return fail( + 'mm-slot-mismatch', + `ModemManager re-detected '${args.mmSlot}' with device '${atSelector.device}', ` + + `expected '${before.device}'`, + ); + } + if (now() >= deadline) { + return fail( + 'mm-redetect-timeout', + `ModemManager did not re-report device '${before.device}' within ` + + `${mmRedetectTimeoutMs}ms (reported: ${lastSeen.map((r) => r.device).join(', ') || '(none)'})`, + ); + } + await sleep(mmPollIntervalMs); + } + + // ---- 6. emit -------------------------------------------------------------------- + phase('emit'); + return { + kind: 'pass', + slot: args.slot, + idPath: expectedIdPath, + mmDevice: before.device, + disappearedMs, + reenumeratedMs, + }; +} + +/** Run the harness and print its result. Returns a process exit code. */ +export async function runHilCycle( + io: CliIo, + args: HilCycleArgs, + deps: HilCycleDeps, +): Promise { + const outcome = await hilCycle(args, deps); + if (outcome.kind === 'failed') { + io.out(hilCycleFailLine(args.slot, outcome.reason)); + io.err(`hil-cycle: ${outcome.reason}: ${outcome.detail}`); + return 1; + } + io.out(`slot ID_PATH: ${outcome.idPath}`); + io.out(`mm slot UID: ${outcome.mmDevice}`); + io.out(hilCyclePassLine(outcome)); + return 0; +} + +/** + * Match a ModemManager record by its runtime path, a trailing path segment (`Modem/2` + * or `2`), or its `modem.generic.device` slot UID. + */ +export function matchMmSlot( + records: readonly MmSlotRecord[], + selector: string, +): MmSlotRecord | undefined { + return records.find( + (r) => r.path === selector || r.path.endsWith(`/${selector}`) || r.device === selector, + ); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/cli/src/hil-cycle.test.ts b/cli/src/hil-cycle.test.ts new file mode 100644 index 0000000..c429bd0 --- /dev/null +++ b/cli/src/hil-cycle.test.ts @@ -0,0 +1,249 @@ +// Harness-driven `hil-cycle` integration — the RB-10 port-cycle harness end to end. +// +// The REAL `createUhubctlPowerHook` from `control/src/backend/uhubctl-power-hook.ts` is +// used unmodified; only the `UhubctlRunner` and the presence poller are faked. So these +// tests exercise the actual argv construction, the actual serialisation, and the actual +// re-enumeration postcondition — not a stand-in. +// +// NOTHING HERE MAY SHELL OUT. Real `uhubctl` IS installed on this dev sandbox +// (`/usr/bin/uhubctl`), so the environment provides no accidental protection: the +// guarantee is structural. This file never imports `SpawnUhubctlRunner` / `SpawnCommandRunner` +// and contains no `Bun.spawn`. Anyone extending it must keep that property. +// +// A virtual clock stands in for wall time: the fake runner advances it by the dark-window +// length, so `disappeared=` / `reenumerated=` are exact and the suite runs instantly. + +import { expect, test } from 'bun:test'; +import { + createUhubctlPowerHook, + type UhubctlPortMap, + type UhubctlResult, + type UhubctlRunner, + type UsbEnumerationPoller, +} from '@ceralive/modem-control'; +import { + type HilCycleDeps, + type HilCyclePhase, + type MmSlotRecord, + runHilCycle, +} from './commands/hil-cycle'; +import { capturingIo } from './io'; + +const SLOT = 'platform-fc000000.usb-usb-0:1.4.4:1.4'; +const MM_DEVICE = '/sys/devices/platform/fc000000.usb/usb4/4-1/4-1.4/4-1.4.4'; +const MM_PATH = '/org/freedesktop/ModemManager1/Modem/2'; +const PORT_MAP: UhubctlPortMap = { [SLOT]: { hubLocation: '4-1.4', port: 4 } }; + +const SWEEP = [ + '4-1: 0bda:0411 mfg="Generic" product="USB3.2 Hub" serial=""', + '4-1.4: 0bda:0411 mfg="Generic" product="USB3.2 Hub" serial=""', + '4-1.4.4: 2c7c:0801 mfg="Quectel" product="RM530N-GL" serial="abc"', + '', +].join('\n'); + +/** How the fake bus behaves once `uhubctl` has been asked to cycle the port. */ +type BusScript = + /** Dark for `darkPolls` samples, then the SAME ID_PATH returns. */ + | { readonly kind: 'recovers'; readonly darkPolls: number } + /** Dark forever — the hub cut power and nothing came back. */ + | { readonly kind: 'never-returns' } + /** Never dark: the hub accepted the request (exit 0) but did NOT cut VBUS. */ + | { readonly kind: 'no-drop' }; + +interface Harness { + readonly deps: HilCycleDeps; + /** Every dep call and phase marker, in the order they happened. */ + readonly calls: string[]; + /** Exactly the argv arrays handed to `uhubctl` — empty means it never ran. */ + readonly argvs: string[][]; +} + +interface HarnessOptions { + readonly bus: BusScript; + /** What ModemManager reports AFTER the cycle (default: the same slot UID). */ + readonly mmAfter?: readonly MmSlotRecord[]; + readonly ports?: UhubctlPortMap; + /** Simulated dark-window duration — `uhubctl -a cycle -d N` blocks for it. */ + readonly darkWindowMs?: number; +} + +function harness(options: HarnessOptions): Harness { + const calls: string[] = []; + const argvs: string[][] = []; + let clockMs = 1_000; + const now = (): number => clockMs; + const sleep = (ms: number): Promise => { + clockMs += ms; + return Promise.resolve(); + }; + + let cut = false; + let darkSamples = 0; + const poller: UsbEnumerationPoller = { + idPathFor(): Promise { + calls.push('poller'); + if (!cut || options.bus.kind === 'no-drop') { + return Promise.resolve(SLOT); + } + if (options.bus.kind === 'never-returns') { + return Promise.resolve(undefined); + } + if (darkSamples < options.bus.darkPolls) { + darkSamples += 1; + return Promise.resolve(undefined); + } + return Promise.resolve(SLOT); + }, + }; + + const runner: UhubctlRunner = { + run(argv: readonly string[]): Promise { + calls.push(`uhubctl:${argv.join(' ')}`); + argvs.push([...argv]); + // A real `uhubctl -a cycle -d N` BLOCKS for the whole dark window and only + // then returns — model that, because it is what makes the drop hard to see. + clockMs += options.darkWindowMs ?? 3_000; + cut = true; + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }, + }; + + let mmReads = 0; + const mmSlots = (): Promise => { + calls.push('mmSlots'); + mmReads += 1; + if (mmReads === 1) { + return Promise.resolve([{ path: MM_PATH, device: MM_DEVICE }]); + } + return Promise.resolve(options.mmAfter ?? [{ path: MM_PATH, device: MM_DEVICE }]); + }; + + const deps: HilCycleDeps = { + readPortMap: () => Promise.resolve(options.ports ?? PORT_MAP), + createPowerHook: (ports, recordingPoller) => + createUhubctlPowerHook({ + ports, + runner, + poller: recordingPoller, + enumerationTimeoutMs: 200, + pollIntervalMs: 50, + now, + sleep, + }), + poller, + usbSweep: () => { + calls.push('usbSweep'); + return Promise.resolve(SWEEP); + }, + mmSlots, + now, + sleep, + mmRedetectTimeoutMs: 200, + mmPollIntervalMs: 50, + onPhase: (phase: HilCyclePhase) => calls.push(`phase:${phase}`), + }; + + return { deps, calls, argvs }; +} + +/** Collapse runs of the same entry so a poll loop does not make the order unassertable. */ +function dedupeAdjacent(entries: readonly string[]): string[] { + return entries.filter((entry, i) => entry !== entries[i - 1]); +} + +test('hil-cycle drives the full call order and emits the PASS line', async () => { + const { deps, calls, argvs } = harness({ bus: { kind: 'recovers', darkPolls: 2 } }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(0); + // The whole point of the harness: the order is the contract. + expect(dedupeAdjacent(calls)).toEqual([ + 'phase:pre-capture', + 'usbSweep', + 'mmSlots', + 'poller', + 'phase:cycle', + 'poller', + 'uhubctl:-l 4-1.4 -p 4 -a cycle -d 3', + 'poller', + 'phase:disappeared', + 'phase:reenumerated', + 'phase:mm-redetect', + 'mmSlots', + 'phase:emit', + ]); + // Exactly one cycle, on exactly the mapped hub/port, with no smuggled flag. + expect(argvs).toEqual([['-l', '4-1.4', '-p', '4', '-a', 'cycle', '-d', '3']]); + // The dark window is 3000ms; two 50ms polls elapse before the device is back. + expect(io.stdout).toContain(`HIL-CYCLE PASS slot=${SLOT} disappeared=3000 reenumerated=3100`); + expect(io.stdout.join('\n')).toContain(`mm slot UID: ${MM_DEVICE}`); +}); + +test('a hub that never re-enumerates fails with reenumeration-timeout', async () => { + const { deps, argvs } = harness({ bus: { kind: 'never-returns' } }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(1); + expect(io.stdout).toContain(`HIL-CYCLE FAIL slot=${SLOT} reason=reenumeration-timeout`); + expect(io.stdout.join('\n')).not.toContain('HIL-CYCLE PASS'); + // The cycle really was attempted — this is a recovery failure, not a refusal. + expect(argvs).toHaveLength(1); +}); + +test('exit 0 from uhubctl without a VBUS drop is NOT a pass', async () => { + // The RB-10 thesis, as a test: the hub accepted the request and exited 0, but the + // modem never left the bus. A harness that trusted the exit code would pass here. + const { deps, argvs } = harness({ bus: { kind: 'no-drop' } }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(1); + expect(io.stdout).toContain(`HIL-CYCLE FAIL slot=${SLOT} reason=no-vbus-drop`); + expect(argvs).toEqual([['-l', '4-1.4', '-p', '4', '-a', 'cycle', '-d', '3']]); + expect(io.stderr.join('\n')).toContain('A zero exit from uhubctl does not prove VBUS was cut'); +}); + +test('an unmapped slot is refused before any capture or power call', async () => { + const { deps, calls, argvs } = harness({ + bus: { kind: 'recovers', darkPolls: 2 }, + ports: { 'some-other-key': { hubLocation: '1-1', port: 2 } }, + }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(1); + expect(io.stdout).toContain(`HIL-CYCLE FAIL slot=${SLOT} reason=hub-map-slot-unmapped`); + // Zero side effects: no sweep, no mmcli, no poll, and above all no uhubctl. + expect(calls).toEqual([]); + expect(argvs).toEqual([]); +}); + +test('a different modem at the same selector fails with mm-slot-mismatch', async () => { + const { deps } = harness({ + bus: { kind: 'recovers', darkPolls: 2 }, + mmAfter: [{ path: MM_PATH, device: '/sys/devices/platform/fc000000.usb/usb1/1-1/1-1.3' }], + }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(1); + expect(io.stdout).toContain(`HIL-CYCLE FAIL slot=${SLOT} reason=mm-slot-mismatch`); + expect(io.stderr.join('\n')).toContain(MM_DEVICE); +}); + +test('a bus that recovers but ModemManager never re-reports fails with mm-redetect-timeout', async () => { + const { deps } = harness({ bus: { kind: 'recovers', darkPolls: 2 }, mmAfter: [] }); + const io = capturingIo(); + + const code = await runHilCycle(io, { slot: SLOT, mmSlot: MM_PATH }, deps); + + expect(code).toBe(1); + expect(io.stdout).toContain(`HIL-CYCLE FAIL slot=${SLOT} reason=mm-redetect-timeout`); +}); diff --git a/cli/src/hil-probe.ts b/cli/src/hil-probe.ts new file mode 100644 index 0000000..a7ed51f --- /dev/null +++ b/cli/src/hil-probe.ts @@ -0,0 +1,127 @@ +// Production wiring for the `hil-cycle` harness — the three system seams it reads. +// +// Kept out of `commands/hil-cycle.ts` on purpose: the orchestrator must stay pure so the +// integration test drives the REAL control flow with fakes, and nothing in the test path +// can accidentally reach udev, ModemManager, or a hub. +// +// NO `lsusb`. The bench image ships no `lsusb`/`usbutils` binary (confirmed on +// `ceralive2`), so the USB tree comes from the `/sys/bus/usb/devices/*` sweep that RB-9 +// already standardised. `sysfsUsbSweep` emits byte-identical lines to RB-9's shell +// one-liner, so a harness capture and a manual capture are directly comparable. + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { UsbDeviceSnapshot, UsbEnumerationPoller } from '@ceralive/modem-control'; +import type { CommandRunner } from './certify/command-runner'; +import type { MmSlotRecord } from './commands/hil-cycle'; + +/** Where the kernel exposes the USB device tree. */ +const SYSFS_USB_DEVICES = '/sys/bus/usb/devices'; + +/** Read one sysfs attribute, or `''` when the attribute does not exist. */ +async function attr(dir: string, name: string): Promise { + try { + return (await readFile(join(dir, name), 'utf8')).trim(); + } catch { + return ''; + } +} + +/** + * The canonical RB-9 USB sweep, in-process. One line per device node that carries an + * `idVendor`, in the exact shape the runbook's shell one-liner produces: + * + * `1-1.1: 19d2:1405 mfg="ZTE,Incorporated" product="ZTE Mobile Boardband" serial="…"` + */ +export async function sysfsUsbSweep(root: string = SYSFS_USB_DEVICES): Promise { + const entries = (await readdir(root)).sort(); + const lines: string[] = []; + for (const entry of entries) { + const dir = join(root, entry); + const idVendor = await attr(dir, 'idVendor'); + if (idVendor === '') { + continue; + } + const idProduct = await attr(dir, 'idProduct'); + const mfg = await attr(dir, 'manufacturer'); + const product = await attr(dir, 'product'); + const serial = await attr(dir, 'serial'); + lines.push( + `${entry}: ${idVendor}:${idProduct} mfg="${mfg}" product="${product}" serial="${serial}"`, + ); + } + return `${lines.join('\n')}\n`; +} + +/** Pull the `/Modem/` indices out of `mmcli -L` output. */ +export function parseMmcliList(stdout: string): readonly string[] { + const indices: string[] = []; + for (const match of stdout.matchAll(/\/org\/freedesktop\/ModemManager1\/Modem\/(\d+)/g)) { + const index = match[1]; + if (index !== undefined && !indices.includes(index)) { + indices.push(index); + } + } + return indices; +} + +/** Pull `modem.generic.device` out of an `mmcli -m -K` keyfile dump. */ +export function parseMmcliDevice(stdout: string): string | undefined { + for (const line of stdout.split('\n')) { + const match = /^modem\.generic\.device\s*:\s*(.+)$/.exec(line.trim()); + if (match?.[1] !== undefined) { + return match[1].trim(); + } + } + return undefined; +} + +/** + * Enumerate ModemManager's modems with their stable `modem.generic.device` slot UID. + * A modem MM reports but that has no device value is skipped rather than recorded with + * an empty UID — an empty UID would make the post-cycle equality check pass vacuously. + */ +export async function mmcliSlots(runner: CommandRunner): Promise { + const list = await runner.run('mmcli', ['-L']); + if (list.exitCode !== 0) { + throw new Error(`mmcli -L exited ${list.exitCode}: ${list.stderr.trim() || '(no output)'}`); + } + const records: MmSlotRecord[] = []; + for (const index of parseMmcliList(list.stdout)) { + const dump = await runner.run('mmcli', ['-m', index, '-K']); + if (dump.exitCode !== 0) { + continue; + } + const device = parseMmcliDevice(dump.stdout); + if (device === undefined || device === '') { + continue; + } + records.push({ path: `/org/freedesktop/ModemManager1/Modem/${index}`, device }); + } + return records; +} + +/** + * A poller that resolves a stable key to the udev `ID_PATH` currently enumerated for it. + * + * The default matcher is `physicalUid === stableKey`, which means the `--hub-map` key on + * the bench IS the modem's udev `ID_PATH`. That is deliberate: `ID_PATH` is the physical + * topology path, the one identity that survives a replug, and the same key the + * dongle-netns contract mandates. A MAC, an `ifname`, or a USB port number would all be + * wrong here — this bench has already produced a duplicate-MAC pair and has seen port + * numbers move when devices were reordered on the hub. + * + * `enumerate` MUST re-read udev on every call (`createUsbEnumerator()` does); a cached + * enumeration would report a powered-down device as present and turn the VBUS-drop proof + * into a no-op. + */ +export function usbIdPathPoller( + enumerate: () => Promise, +): UsbEnumerationPoller { + return { + async idPathFor(stableKey: string): Promise { + const devices = await enumerate(); + return devices.find((d) => d.physicalUid === stableKey)?.physicalUid; + }, + }; +} diff --git a/docs/BENCH.md b/docs/BENCH.md index be0d173..0a31248 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -626,6 +626,170 @@ gitignored). --- +## RB-10 — Hub VBUS verification `[PARTIAL]` + +Prove that a USB hub on the bench really **switches per-port power**, and that cutting a +port's power actually recovers a modem. This is the hardware half of the +`usb-hub-port-cycle` PowerHook (recovery-ladder rung 4): the code path is unit-tested with +a fake runner, but nothing in CI can prove a physical hub drops VBUS. + +> **A zero exit code from `uhubctl` does NOT prove power was cut — and it is not this +> runbook's gate.** `uhubctl -a cycle` exits 0 whenever the hub *accepts* the control +> request. A hub whose per-port power switching is absent, ganged, or simply not wired to +> the VBUS rail accepts the request and cuts nothing, exiting 0 every time. **The only +> honest observable is the modem DISAPPEARING from the USB bus and coming back at the same +> physical topology path.** Step 3 below is that proof, performed by hand; step 4 is the +> automated harness that asserts the same thing plus ModemManager re-detection. A run that +> reports only "uhubctl exited 0" is **not** a passing RB-10. + +> **Second honesty caveat — `uhubctl` may disable the port instead of cutting VBUS.** +> `uhubctl --help` (2.6.0) documents `--nosysfs, -S — do not use the Linux sysfs port +> disable interface`, i.e. by default it *prefers* the kernel's port-disable path where +> one exists. Port-disable makes the device vanish from the bus exactly like a power cut, +> so it satisfies steps 3 and 4 — but it does **not** electrically de-power a wedged +> modem, which is the whole reason rung 4 exists. Run step 3 **both ways** (default and +> `-S`) and record which one the hub honoured. The PowerHook never passes `-S` (its argv +> allowlist admits only `-l`, `-p`, `-a`, `-d`), so whatever the default path does on this +> hub is what rung 4 will do in production. + +**Preconditions** + +- Bench device reachable over SSH. `mmcli` on `PATH`. **No `lsusb` and no `usbutils` on + the bench image** — the USB tree comes from the `/sys/bus/usb/devices/*` sweep (RB-9). +- `uhubctl` installed and runnable as root. **It is NOT on the bench image and is NOT in + the image's apt archive** (`apt-cache show uhubctl` → `E: No packages found`, verified + 2026-08-16 on `ceralive2`), so it must be installed for the bench run — from a bookworm + archive that carries it, or built from source. `uhubctl` needs `sudo` (or a udev + permissions rule); the PowerHook never escalates on its own. +- The compiled `modem-control` binary (RB-1 preconditions). + +**Commands** + +```sh +OUT=test-results/modem-phase-b/07; mkdir -p "$OUT" + +# 1) DISCOVERY — list every hub uhubctl considers power-switchable. Read-only, no action. +sudo uhubctl | tee "$OUT/uhubctl-discovery.txt" + +# 2) CAPABILITY — assert the target hub advertises per-port power switching. +# `ppps` in the hub's status line is uhubctl's rendering of the USB hub descriptor's +# "Per-port power switching" bit — the `lsusb -v` field, read without lsusb. +grep -E 'Current status for hub .*, ppps\]' "$OUT/uhubctl-discovery.txt" \ + | tee "$OUT/uhubctl-ppps.txt" + +# 3) PHYSICAL VBUS-DROP PROOF — the real gate. Cut the port, observe the modem LEAVE the +# bus, restore the port, observe it COME BACK. HUB/PORT below are the mapped values. +HUB=4-1.4; PORT=4; DEV=4-1.4.4 +sweep() { for d in /sys/bus/usb/devices/*; do [ -f "$d/idVendor" ] && \ + echo "$(basename "$d"): $(cat "$d/idVendor"):$(cat "$d/idProduct") product=\"$(cat "$d/product" 2>/dev/null)\""; done; } +{ + echo "--- before (device MUST be present) ---"; sweep + echo "--- uhubctl -a off ---"; sudo uhubctl -l "$HUB" -p "$PORT" -a off; echo "exit=$?" + sleep 3 + echo "--- dark (device MUST be absent) ---"; sweep + echo "--- uhubctl -a on ---"; sudo uhubctl -l "$HUB" -p "$PORT" -a on; echo "exit=$?" + sleep 8 + echo "--- after (device MUST be present again) ---"; sweep +} 2>&1 | tee "$OUT/vbus-drop-proof.txt" + +# 3b) Repeat step 3 with -S (no sysfs port-disable) to learn which mechanism the hub +# actually honours. Same assertions; recorded separately, never merged with 3. +{ + sudo uhubctl -S -l "$HUB" -p "$PORT" -a off; echo "exit=$?"; sleep 3; sweep + sudo uhubctl -S -l "$HUB" -p "$PORT" -a on; echo "exit=$?"; sleep 8; sweep +} 2>&1 | tee "$OUT/vbus-drop-proof-nosysfs.txt" + +# 4) AUTOMATED HARNESS — one bounded cycle with all five assertions. `` is the +# modem's udev ID_PATH (the hub-map key); `--mm-slot` is its ModemManager selector. +cat > "$OUT/hub-map.json" <<'EOF' +{ + "platform-xhci-hcd.0.auto-usb-0:1.4.4": { "hubLocation": "4-1.4", "port": 4 } +} +EOF +sudo ./modem-control hil-cycle 'platform-xhci-hcd.0.auto-usb-0:1.4.4' \ + --hub-map "$OUT/hub-map.json" --mm-slot 2 \ + 2>&1 | tee "$OUT/hil-cycle-quectel-rm530n-gl.txt" +``` + +**Expected output** + +Step 1 prints one block per power-switchable hub, in this exact shape (uhubctl 2.6.0): + +``` +Current status for hub 4-1.4 [0bda:0411 Generic USB3.2 Hub, USB 3.20, 4 ports, ppps] + Port 1: 0100 power + Port 4: 0503 power highspeed enable connect [2c7c:0801] +``` + +The trailing **`ppps`** token is the capability assertion of step 2 — a hub listed +`ganged` (or absent entirely, which is how `uhubctl` reports a hub it will not touch +without `-f`) **fails** RB-10 and must not be put in a hub map. + +Step 3 must show the target `$DEV` line **present**, then **absent** in the dark sweep, +then **present again**. The absence is the pass; the two `exit=0` lines are not. + +Step 4 ends with exactly: + +``` +HIL-CYCLE PASS slot= disappeared= reenumerated= +``` + +Any failure prints `HIL-CYCLE FAIL slot= reason=` and exits non-zero, where +`` is one of `hub-map-unreadable`, `hub-map-slot-unmapped`, `pre-capture-failed`, +`slot-not-enumerated`, `mm-slot-absent`, `power-cycle-unsupported`, `power-cycle-failed`, +`no-vbus-drop`, `reenumeration-timeout`, `mm-redetect-timeout`, `mm-slot-mismatch`. + +`no-vbus-drop` is the reason that means **this hub exited 0 and cut nothing** — or that +the port came back faster than the harness sampled it. `uhubctl -a cycle -d N` blocks for +the whole dark window and only returns afterwards, so the harness observes the gap through +the tail of re-enumeration rather than the dark window itself. If step 3 proves a real +drop by hand but step 4 reports `no-vbus-drop`, that is a sampling miss, not a hub fault — +record both captures and treat step 3 as authoritative. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/07; DEV=4-1.4.4 +grep -Eq 'Current status for hub .*, ppps\]' "$OUT/uhubctl-discovery.txt" \ + && awk -v d="$DEV" '/^--- dark/{s=1} /^--- after/{s=0} s && $0 ~ ("^" d ":"){f=1} END{exit !f}' \ + "$OUT/vbus-drop-proof.txt" && echo "RB-10 FAIL (device present while dark)" \ + || grep -Eq "^HIL-CYCLE PASS slot=.+ disappeared=[0-9]+ reenumerated=[0-9]+$" \ + "$OUT/hil-cycle-quectel-rm530n-gl.txt" \ + && echo "RB-10 PASS" || echo "RB-10 FAIL" +``` + +**Per-hub capability status (this bench, `ceralive2` 192.168.78.132)** + +| Hub location | VID:PID | uhubctl compatible-hub list | Modems behind it | Status | +|---|---|---|---|---| +| `4-1`, `4-1.3`, `4-1.4` | `0bda:0411` | **yes** — listed for the Rosonway RSH-A10 / RSH-A16 | Quectel RM530N-GL at `4-1.4.4` (hub `4-1.4`, port 4) | `[PARTIAL]` — `uhubctl` not installed on the board and not in its apt archive; no discovery, no VBUS proof, no harness run | +| `1-1`, `1-1.3`, `1-1.4` | `0bda:5411` | **no** — not on the list; may need `-f`, which the PowerHook never passes | SIMCom SIM7600G-H at `1-1.3.4`; ZTE, both Huawei HiLink, both generic sticks | `[PARTIAL]` — untested; if it reports no `ppps` it is not mappable and rung 4 stays `unsupported` for every modem behind it | +| `3-1` | `1a40:0101` | not evaluated | none (Bluetooth radio only) | `[PARTIAL]` — out of scope, no modem behind it | + +The two hub families matter: only the `0bda:0411` tree is a documented per-port-power +switcher, and on this bench that tree carries exactly **one** modem (the Quectel). Every +other unit hangs off `0bda:5411` hubs, so until step 2 is actually run there is no evidence +any of them can be power-cycled at all. Do not write a hub-map entry for a hub that has not +passed step 2 — the PowerHook's refuse-if-unmapped rule is the only thing standing between +a wrong entry and blacking out an unrelated device. + +**Slot keys.** The hub-map key is the modem's udev **`ID_PATH`**, not a MAC, not an +`ifname`, and not a USB port number. This bench has already produced a duplicate-MAC pair +and has seen port positions move when devices were reordered on the hub (RB-9), so those +are all disqualified as identity. Current values, captured 2026-08-16: + +| Unit | Sysfs node | `ID_PATH` (hub-map key) | `modem.generic.device` (`--mm-slot 2` / `4`) | +|---|---|---|---| +| Quectel RM530N-GL | `4-1.4.4` | `platform-xhci-hcd.0.auto-usb-0:1.4.4` | `/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb4/4-1/4-1.4/4-1.4.4` | +| SIMCom SIM7600G-H | `1-1.3.4` | `platform-xhci-hcd.0.auto-usb-0:1.3.4` | `/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.3/1-1.3.4` | + +**Evidence:** `test-results/modem-phase-b/07/{uhubctl-discovery,uhubctl-ppps,vbus-drop-proof,vbus-drop-proof-nosysfs}.txt` +plus one `test-results/modem-phase-b/07/hil-cycle-.txt` per cycled unit (repo-local, +gitignored). `` is the unit slug, matching RB-9's per-unit directory names — e.g. +`test-results/modem-phase-b/07/hil-cycle-quectel-rm530n-gl.txt`. + +--- + ## Evidence index | Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | @@ -639,6 +803,7 @@ gitignored). | RB-7 | arm64-on-real-hardware validation | `[PARTIAL]` | `A6.3/arm64-{uname,probe}.txt` | | RB-8 | Daemon smoke on real hardware | `[PARTIAL]` | `A6.3/daemon-smoke.txt` | | RB-9 | Fleet inventory capture | `[PARTIAL]` | `A6.3/{usb-tree,mmcli-list,mmcli-dump,id-path-per-iface,ip-addr}.txt` + `test-results/modem-phase-b/05//` | +| RB-10 | Hub VBUS verification | `[PARTIAL]` | `test-results/modem-phase-b/07/{uhubctl-discovery,uhubctl-ppps,vbus-drop-proof,vbus-drop-proof-nosysfs}.txt` + `hil-cycle-.txt` | 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 From 81969635a39afec58b06e1372766ff1c9d449780 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 05:25:38 -0500 Subject: [PATCH 05/12] feat(certify): per-SKU certification runbooks RB-11..15,17 + evidence-gated catalog ingestion --- AGENTS.md | 23 +- README.md | 4 +- control/src/usb-mode/index.ts | 33 +- control/src/usb-mode/ingestion.test.ts | 268 +++++++++ control/src/usb-mode/ingestion.ts | 297 ++++++++++ control/src/usb-mode/promotion-review.ts | 117 ++++ control/src/usb-mode/usb-devices-parse.ts | 196 +++++++ docs/BENCH.md | 634 ++++++++++++++++++++++ docs/CATALOG-INGESTION.md | 227 ++++++++ 9 files changed, 1795 insertions(+), 4 deletions(-) create mode 100644 control/src/usb-mode/ingestion.test.ts create mode 100644 control/src/usb-mode/ingestion.ts create mode 100644 control/src/usb-mode/promotion-review.ts create mode 100644 control/src/usb-mode/usb-devices-parse.ts create mode 100644 docs/CATALOG-INGESTION.md diff --git a/AGENTS.md b/AGENTS.md index 619a1e3..972d2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE | Directory | Artifact | Role | |-----------|----------|------| -| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler. Published to public npm under `@ceralive`. | +| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler. Published to public npm under `@ceralive`. | | `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | | `packaging/` | ModemManager stack `.deb`s | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`). Bench installs from CI artifacts. | @@ -74,6 +74,27 @@ arch-dependent stanzas + enumerated `-dbgsym`) for exact per-source set **equali `packaging/ci/check-package-sets.sh` (add/remove/rename fails closed). Full detail: `packaging/README.md`. +## CERTIFICATION EVIDENCE → CATALOG (evidence-gated, human-reviewed) + +A SKU reaches `control/src/usb-mode/certified-catalog.json` only through a captured +`certify` bundle and a human-reviewed commit. The path is documented in +[`docs/CATALOG-INGESTION.md`](docs/CATALOG-INGESTION.md) and implemented as a pure +transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse}.ts`. + +- **`synthetic: true` is REFUSED for catalog promotion by the code**, not by convention + (`buildCatalogEntryCandidate` → typed `reason: 'synthetic-bundle'`). Classifier fixtures + may be synthetic; their provenance says so. That asymmetry is the design. +- **Certification is two-stage** because `certify --transition` refuses a SKU that is not + already in the catalog: stage 1 merges an entry with `permittedTransitions: []`, stage 2 + adds one transition carrying its own `evidenceBundleSha256`. +- **`canonicalMode` is a reviewer's stated claim, never inferred**; when transition evidence + exists the seam cross-checks it against the captured `transition.from`. +- Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 reserved for the + FM350 probe, RB-17 is modem-flap resilience). All are `[PARTIAL]` — four named blockers + (`usbutils` absent from the board and its archive; the enumerator not populating `ifname`; + no AT transport on the bench; an empty real-SKU catalog) are recorded in `docs/BENCH.md` + § "Per-SKU certification". No SKU is certified and no matrix row is promoted. + ## POLICY `packaging/` is a **no-fork** effort: the first release carries zero quilt patches; adding a diff --git a/README.md b/README.md index 88c67ea..a6af42e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ changes to CeraUI, the device image, or apt-worker**. Bench devices install the | Directory | Artifact | What it is | |-----------|----------|------------| -| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | +| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | | [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling (RB-10). | | [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork, zero source patches** (see [`POLICY.md`](POLICY.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | @@ -31,7 +31,7 @@ modem-stack/ ├── control/ @ceralive/modem-control — TS control library (Bun workspace member) ├── cli/ modem-control bench CLI (Bun workspace member) ├── packaging/ ModemManager-stack .deb rebuilds + provenance/verification CI -├── docs/ VERSIONING.md and other engineering docs +├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, VERSIONING.md, FM350-DECISION.md ├── AGENTS.md AI routing + repo contract (self-contained; see Rule D) └── POLICY.md no-fork gate + upstream-contribution-first policy ``` diff --git a/control/src/usb-mode/index.ts b/control/src/usb-mode/index.ts index 0994236..8f33a08 100644 --- a/control/src/usb-mode/index.ts +++ b/control/src/usb-mode/index.ts @@ -1,8 +1,13 @@ -// The certified USB-mode catalog — schema, data, and lookups. +// The certified USB-mode catalog — schema, data, lookups, and the evidence-bundle +// ingestion seam. // // A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the // CLI looks up the permitted transition for a target mode, `certify` validates a // candidate entry against the schema before a human commits it. +// +// The ingestion seam (`./ingestion`, `./promotion-review`) is the documented path from a +// captured `certify` bundle to a reviewed catalog commit — see `docs/CATALOG-INGESTION.md`. +// It refuses a `synthetic: true` bundle for catalog promotion, by construction. export { CERTIFIED_CATALOG, @@ -25,3 +30,29 @@ export { permittedTransitionSchema, type SkuDiscriminator, } from './catalog-schema'; +export { + buildCatalogEntryCandidate, + buildClassifierFixture, + type CatalogClaim, + CLAIMABLE_CANONICAL_MODES, + type ClassifierFixture, + type EvidenceBundleView, + evidenceBundleViewSchema, + type FixtureProvenance, + type IngestionOutcome, + type IngestionRefusal, + type IngestionRefusalReason, + type IngestionRequest, + parseIngestionRequest, +} from './ingestion'; +export { + type PromotionContext, + type PromotionRequest, + renderPromotionReview, +} from './promotion-review'; +export { + type ParsedUsbDevice, + type ParsedUsbInterface, + parseUsbDevices, + selectUniqueDevice, +} from './usb-devices-parse'; diff --git a/control/src/usb-mode/ingestion.test.ts b/control/src/usb-mode/ingestion.test.ts new file mode 100644 index 0000000..68033bc --- /dev/null +++ b/control/src/usb-mode/ingestion.test.ts @@ -0,0 +1,268 @@ +// The ingestion seam's contract: a bundle round-trips into a schema-valid catalog entry +// and a real-shaped classifier fixture, the sha256 links the two, and a synthetic bundle +// is REFUSED for catalog promotion with a typed reason rather than silently accepted. + +import { describe, expect, test } from 'bun:test'; +import { classifyDevice, detectUsbMode } from '../backend/device-classifier'; +import { catalogEntrySchema } from './catalog-schema'; +import { + buildCatalogEntryCandidate, + buildClassifierFixture, + type IngestionRequest, + parseIngestionRequest, +} from './ingestion'; +import { renderPromotionReview } from './promotion-review'; +import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse'; + +const SHA = 'a'.repeat(64); + +/** Verbatim-shaped `usb-devices` output for a QMI stick plus one unrelated hub. */ +const USB_DEVICES = ` +T: Bus=04 Lev=01 Prnt=01 Port=00 Cnt=01 Dev#= 2 Spd=5000 MxCh= 4 +D: Ver= 3.20 Cls=09(hub ) Sub=00 Prot=03 MxPS= 9 #Cfgs= 1 +P: Vendor=0bda ProdID=0411 Rev=01.01 +S: Manufacturer=Generic +S: Product=USB3.2 Hub +I: If#= 0 Alt= 0 #EPs= 1 Cls=09(hub ) Sub=00 Prot=00 Driver=hub + +T: Bus=04 Lev=03 Prnt=04 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0 +D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1 +P: Vendor=2c7c ProdID=0125 Rev=03.18 +S: Manufacturer=Quectel +S: Product=SYNTHETIC-BENCH-STICK +I: If#= 2 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan +`; + +function bundle(overrides: Record = {}): Record { + return { + schemaVersion: 1, + synthetic: false, + capturedAtMs: 1_760_000_000_000, + slot: 'Modem/2', + sku: { + vidPid: '2c7c:0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwarePrefix: 'SYNTHETICFW01', + }, + usb: { + usbDevices: USB_DEVICES, + udevProperties: { + ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.4.4', + ID_VENDOR_ID: '2c7c', + ID_MODEL_ID: '0125', + INTERFACE: 'wwan0', + }, + }, + // Fields the real bundle carries and the ingestion VIEW deliberately ignores. + usbExtra: { lsusb: 'Device Descriptor:' }, + modemManager: { mmcliKeyfile: {}, managedObjects: {}, signalWindow: [] }, + ...overrides, + }; +} + +const request = (overrides: Record = {}, sha = SHA): IngestionRequest => ({ + bundle: bundle(overrides), + bundleSha256: sha, +}); + +const TRANSITION = { + from: 'qmi', + to: 'mbim', + atCommand: 'AT+QCFG="usbnet",2', + expectedResponse: 'OK', + expectsPortDrop: true, + afterDescriptors: { + deviceClass: 0, + interfaces: [ + { interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }, + { interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 }, + ], + }, + timeline: [{ event: 'command-sent', atMs: 1 }], +}; + +describe('parseUsbDevices — the descriptor source a base bundle actually carries', () => { + test('parses every device, its bDeviceClass, and each interface driver', () => { + const devices = parseUsbDevices(USB_DEVICES); + expect(devices).toHaveLength(2); + const stick = devices[1]; + expect(stick?.vidPid).toBe('2c7c:0125'); + expect(stick?.bDeviceClass).toBe(0); + expect(stick?.product).toBe('SYNTHETIC-BENCH-STICK'); + expect(stick?.interfaces).toEqual([ + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' }, + { + interfaceClass: 0xff, + interfaceSubClass: 0xff, + interfaceProtocol: 0xff, + driver: 'qmi_wwan', + }, + ]); + }); + + test('a block with no P: line yields no record — identity is never invented', () => { + expect(parseUsbDevices('T: Bus=01\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=x')).toEqual([]); + }); + + test('a duplicate VID:PID is AMBIGUOUS, not first-wins (the Huawei HiLink pair)', () => { + const pair = `${USB_DEVICES}\nT: Bus=01 Lev=01\nD: Cls=00\nP: Vendor=2c7c ProdID=0125 Rev=03.18\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=qmi_wwan\n`; + const selected = selectUniqueDevice(parseUsbDevices(pair), '2c7c:0125'); + expect(selected).toEqual({ ambiguousMatches: 2 }); + }); +}); + +describe('buildClassifierFixture — the real udev shape, not a hand-typed approximation', () => { + test('produces a snapshot the REAL classifier classifies correctly', () => { + const outcome = buildClassifierFixture(request()); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + const { snapshot, provenance } = outcome.value; + expect(snapshot.vendorId).toBe('2c7c'); + expect(snapshot.productId).toBe('0125'); + expect(snapshot.model).toBe('CERALIVE-SYNTHETIC-TEST-SKU'); + expect(snapshot.firmwareRevision).toBe('SYNTHETICFW01'); + expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.4.4'); + expect(snapshot.ifname).toBe('wwan0'); + // The whole point of deriving from real capture text: the fixture must survive + // the production classifier, not merely typecheck. + expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed'); + expect(detectUsbMode(snapshot)).toBe('qmi'); + expect(provenance.bundleSha256).toBe(SHA); + expect(provenance.synthetic).toBe(false); + }); + + test('a SYNTHETIC bundle still yields a fixture, stamped synthetic in provenance', () => { + const outcome = buildClassifierFixture(request({ synthetic: true })); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.provenance.synthetic).toBe(true); + } + }); + + test('refuses a bundle with no sku (blocker B2 shape) rather than inventing one', () => { + const outcome = buildClassifierFixture(request({ sku: undefined })); + expect(outcome).toMatchObject({ ok: false, reason: 'sku-missing' }); + }); + + test('refuses when the SKU is absent from the usb-devices capture', () => { + const outcome = buildClassifierFixture( + request({ sku: { ...(bundle().sku as object), vidPid: '1199:9071' } }), + ); + expect(outcome).toMatchObject({ ok: false, reason: 'device-not-in-capture' }); + }); +}); + +describe('buildCatalogEntryCandidate — schema round-trip and sha linkage', () => { + test('a stage-1 bundle yields an entry with NO permitted transitions', () => { + const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'qmi' }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + expect(outcome.value.permittedTransitions).toEqual([]); + // Round-trip through the AUTHORITATIVE schema, not the builder's own view. + expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value); + }); + + test('a stage-2 bundle links the transition to THIS bundle sha256', () => { + const sha = 'b'.repeat(64); + const outcome = buildCatalogEntryCandidate( + { bundle: bundle({ transition: TRANSITION }), bundleSha256: sha }, + { canonicalMode: 'qmi' }, + ); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + const [transition] = outcome.value.permittedTransitions; + expect(transition?.evidenceBundleSha256).toBe(sha); + expect(transition?.expectedDescriptors).toEqual(TRANSITION.afterDescriptors); + expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2'); + expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value); + }); + + test('REFUSES a synthetic:true bundle for catalog promotion — typed, not silent', () => { + const outcome = buildCatalogEntryCandidate(request({ synthetic: true }), { + canonicalMode: 'qmi', + }); + expect(outcome.ok).toBe(false); + if (outcome.ok) { + return; + } + expect(outcome.reason).toBe('synthetic-bundle'); + expect(outcome.detail).toContain('synthetic:true'); + }); + + test('refuses a claimed mode that contradicts the captured transition.from', () => { + const outcome = buildCatalogEntryCandidate( + { bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA }, + { canonicalMode: 'mbim' }, + ); + expect(outcome).toMatchObject({ ok: false, reason: 'transition-mode-mismatch' }); + }); + + test('refuses a router-mode SKU that carries a transition (schema invariant)', () => { + const outcome = buildCatalogEntryCandidate( + { bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA }, + { canonicalMode: 'router-ethernet' }, + ); + // The mode cross-check fires first; either refusal is correct, neither is an accept. + expect(outcome.ok).toBe(false); + }); + + test('accepts a router-mode SKU with no transitions (the RB-15 shape)', () => { + const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'router-ethernet' }); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.canonicalMode).toBe('router-ethernet'); + expect(outcome.value.permittedTransitions).toEqual([]); + } + }); + + test('refuses a malformed sha256 before reading the bundle at all', () => { + expect(parseIngestionRequest({ bundle: bundle(), bundleSha256: 'nope' })).toMatchObject({ + ok: false, + reason: 'sha256-malformed', + }); + }); + + test('refuses a bundle that fails the view schema', () => { + expect( + parseIngestionRequest({ bundle: { schemaVersion: 2 }, bundleSha256: SHA }), + ).toMatchObject({ ok: false, reason: 'bundle-malformed' }); + }); +}); + +describe('renderPromotionReview — the review artifact, including for refusals', () => { + test('renders the entry, the fixture, and a checklist on success', () => { + const req = request(); + const comment = renderPromotionReview({ + context: { runbook: 'RB-11', evidencePath: 'test-results/modem-phase-b/08/x/bundle.json' }, + entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }), + fixture: buildClassifierFixture(req), + }); + expect(comment).toContain('Proposed `certified-catalog.json` entry'); + expect(comment).toContain('Proposed classifier fixture'); + expect(comment).toContain('Reviewer checklist'); + expect(comment).toContain('RB-11'); + expect(comment).toContain('**This comment promotes'); + }); + + test('a refused promotion renders the refusal and NO checklist', () => { + const req = request({ synthetic: true }); + const comment = renderPromotionReview({ + context: { runbook: 'RB-11', evidencePath: 'x.json' }, + entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }), + fixture: buildClassifierFixture(req), + }); + expect(comment).toContain('Catalog entry — REFUSED'); + expect(comment).toContain('`synthetic-bundle`'); + expect(comment).toContain('### No checklist'); + expect(comment).not.toContain('Reviewer checklist'); + // The fixture half still renders — synthetic fixtures are legitimate test data. + expect(comment).toContain('Derived from a **synthetic** bundle'); + }); +}); diff --git a/control/src/usb-mode/ingestion.ts b/control/src/usb-mode/ingestion.ts new file mode 100644 index 0000000..1dfd630 --- /dev/null +++ b/control/src/usb-mode/ingestion.ts @@ -0,0 +1,297 @@ +// The evidence-bundle ingestion seam — turning ONE real `certify` bundle into (i) a +// classifier test fixture in the real udev shape and (ii) a candidate catalog entry. +// +// This is the documented path between the bench and the catalog. It is deliberately a +// PURE TRANSFORM that produces a REVIEW ARTIFACT: nothing here writes a file, mutates +// `certified-catalog.json`, or promotes anything. A catalog addition stays what Phase A +// made it — a human-reviewed commit — and this seam only removes the hand-transcription +// step between the bundle and that commit. +// +// THE ONE RULE THE CODE ENFORCES, NOT THE REVIEWER: +// A bundle marked `synthetic: true` is REFUSED for catalog promotion, with a typed +// reason. Synthetic bundles are legitimate test data — `buildClassifierFixture` accepts +// them and stamps the fixture's provenance with `synthetic: true` — but a catalog entry +// asserts a certified hardware fact, and no synthetic capture may ever back one. +// +// SHAPE COMPATIBILITY, not shape duplication: the bundle is validated here through a +// deliberately NON-strict VIEW schema. The authoritative bundle schema lives beside the +// `certify` command in the CLI, which depends on this package and not the reverse, so +// this file describes only the subset ingestion reads and ignores the rest (`lsusb`, +// `modemManager`, the transition timeline). Adding a field to the bundle can therefore +// never break ingestion — which is the point of a view. + +import { z } from 'zod'; +import type { UsbDeviceSnapshot } from '../backend/device-classifier'; +import { + CANONICAL_USB_MODES, + type CanonicalUsbMode, + type CatalogEntry, + catalogEntrySchema, + expectedDescriptorsSchema, + MM_USB_MODES, +} from './catalog-schema'; +import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse'; + +const mmMode = z.enum(MM_USB_MODES); + +/** The ingestion VIEW of a certification bundle — non-strict on purpose (see header). */ +export const evidenceBundleViewSchema = z.object({ + schemaVersion: z.literal(1), + synthetic: z.boolean(), + capturedAtMs: z.number(), + slot: z.string().min(1), + sku: z + .object({ + vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/), + model: z.string().min(1), + firmwarePrefix: z.string().min(1), + }) + .optional(), + usb: z.object({ + usbDevices: z.string().min(1), + udevProperties: z.record(z.string(), z.string()), + }), + transition: z + .object({ + from: mmMode, + to: mmMode, + atCommand: z.string().min(1), + expectedResponse: z.string().min(1), + expectsPortDrop: z.boolean(), + afterDescriptors: expectedDescriptorsSchema, + }) + .optional(), +}); +export type EvidenceBundleView = z.infer; + +/** Every way ingestion can refuse. Each is a named, actionable condition — never a throw. */ +export type IngestionRefusalReason = + | 'bundle-malformed' + | 'sha256-malformed' + | 'sku-missing' + | 'device-not-in-capture' + | 'device-ambiguous' + | 'no-interfaces-captured' + | 'synthetic-bundle' + | 'transition-mode-mismatch' + | 'entry-schema-invalid'; + +/** A typed refusal. `detail` is for a human reviewer; `reason` is for a machine. */ +export interface IngestionRefusal { + readonly ok: false; + readonly reason: IngestionRefusalReason; + readonly detail: string; +} + +/** A refusal or a value — ingestion never throws and never returns a partial result. */ +export type IngestionOutcome = { readonly ok: true; readonly value: T } | IngestionRefusal; + +const refuse = (reason: IngestionRefusalReason, detail: string): IngestionRefusal => ({ + ok: false, + reason, + detail, +}); + +/** Where a fixture came from — stamped onto every fixture, honest about synthetic input. */ +export interface FixtureProvenance { + readonly bundleSha256: string; + /** `true` when the source bundle was synthetic — such a fixture is test data only. */ + readonly synthetic: boolean; + readonly slot: string; + readonly capturedAtMs: number; +} + +/** A classifier fixture: the snapshot `classifyDevice` consumes, plus its provenance. */ +export interface ClassifierFixture { + readonly snapshot: UsbDeviceSnapshot; + readonly provenance: FixtureProvenance; +} + +/** One bundle plus the sha256 `certify` printed for it — the two halves are inseparable. */ +export interface IngestionRequest { + /** The bundle JSON, already `JSON.parse`d. Validated here against the view schema. */ + readonly bundle: unknown; + /** The `CERTIFY OK: sha256=…` value. Becomes the entry's `evidenceBundleSha256`. */ + readonly bundleSha256: string; +} + +/** + * The claim a REVIEWER makes about the SKU. `canonicalMode` is stated, never inferred: + * a machine reading descriptors could guess it, but a catalog entry is an assertion a + * human signs, and a stage-2 bundle's `transition.from` is cross-checked against it. + */ +export interface CatalogClaim { + readonly canonicalMode: CanonicalUsbMode; +} + +const SHA256_RE = /^[0-9a-f]{64}$/; + +/** Validate a request's bundle + sha, or refuse with a precise reason. */ +export function parseIngestionRequest( + request: IngestionRequest, +): IngestionOutcome { + if (!SHA256_RE.test(request.bundleSha256)) { + return refuse( + 'sha256-malformed', + `bundle sha256 must be 64 lowercase hex characters, got '${request.bundleSha256}'`, + ); + } + const parsed = evidenceBundleViewSchema.safeParse(request.bundle); + if (!parsed.success) { + return refuse('bundle-malformed', z.prettifyError(parsed.error)); + } + return { ok: true, value: parsed.data }; +} + +/** + * Build a classifier test fixture from a bundle — the real udev shape, not a hand-typed + * approximation. Descriptors and per-interface DRIVERS come from the bundle's + * `usb-devices` text (the only structured descriptor source a base bundle carries); + * identity comes from the bundle's SKU; `physicalUid` / `ifname` come from the captured + * udev properties. A synthetic bundle is ACCEPTED here and the provenance says so. + */ +export function buildClassifierFixture( + request: IngestionRequest, +): IngestionOutcome { + const parsed = parseIngestionRequest(request); + if (!parsed.ok) { + return parsed; + } + const bundle = parsed.value; + const sku = bundle.sku; + if (sku === undefined) { + // Blocker B2 in `docs/BENCH.md` produces exactly this: an unmatched USB device + // yields a bundle with no SKU at all. + return refuse( + 'sku-missing', + 'bundle carries no `sku` — the capture did not match a USB device to the slot', + ); + } + + const selected = selectUniqueDevice(parseUsbDevices(bundle.usb.usbDevices), sku.vidPid); + if (!('device' in selected)) { + return selected.ambiguousMatches === 0 + ? refuse( + 'device-not-in-capture', + `no device with vidPid ${sku.vidPid} in the bundle's usb-devices capture`, + ) + : refuse( + 'device-ambiguous', + `${selected.ambiguousMatches} devices share vidPid ${sku.vidPid} in this capture; a fixture must name one physical device`, + ); + } + const device = selected.device; + if (device.interfaces.length === 0) { + return refuse( + 'no-interfaces-captured', + `device ${sku.vidPid} has no parsed interface lines; a classifier fixture with no interfaces classifies nothing`, + ); + } + + const [vendorId, productId] = sku.vidPid.split(':') as [string, string]; + const props = bundle.usb.udevProperties; + const physicalUid = props.ID_PATH; + const ifname = props.INTERFACE; + + return { + ok: true, + value: { + snapshot: { + vendorId, + productId, + model: sku.model, + firmwareRevision: sku.firmwarePrefix, + bDeviceClass: device.bDeviceClass, + interfaces: device.interfaces, + udevProperties: props, + ...(physicalUid !== undefined ? { physicalUid } : {}), + ...(ifname !== undefined ? { ifname } : {}), + }, + provenance: { + bundleSha256: request.bundleSha256, + synthetic: bundle.synthetic, + slot: bundle.slot, + capturedAtMs: bundle.capturedAtMs, + }, + }, + }; +} + +/** + * Build a CANDIDATE catalog entry from a bundle. The entry is a review artifact: it is + * returned, never written. + * + * REFUSES a `synthetic: true` bundle — a catalog entry asserts a certified hardware + * fact, so synthetic evidence can never back one (`docs/BENCH.md` Must-NOT-Have 7). + * + * A stage-1 (base) bundle yields `permittedTransitions: []`. A stage-2 bundle — one + * captured with `certify --transition` — yields exactly ONE permitted transition, whose + * `expectedDescriptors` is the captured `afterDescriptors` and whose + * `evidenceBundleSha256` is this bundle's hash. The reviewer's stated `canonicalMode` + * must equal the captured `transition.from`; a mismatch is refused rather than silently + * resolved in either direction. + */ +export function buildCatalogEntryCandidate( + request: IngestionRequest, + claim: CatalogClaim, +): IngestionOutcome { + const parsed = parseIngestionRequest(request); + if (!parsed.ok) { + return parsed; + } + const bundle = parsed.value; + if (bundle.synthetic) { + return refuse( + 'synthetic-bundle', + `bundle for slot '${bundle.slot}' is marked synthetic:true; a catalog entry requires a real capture (synthetic:false)`, + ); + } + const sku = bundle.sku; + if (sku === undefined) { + return refuse( + 'sku-missing', + 'bundle carries no `sku` — a catalog entry needs all three discriminators (vidPid, model, firmwarePrefix)', + ); + } + + const transition = bundle.transition; + if (transition !== undefined && transition.from !== claim.canonicalMode) { + return refuse( + 'transition-mode-mismatch', + `claimed canonicalMode '${claim.canonicalMode}' contradicts the captured transition.from '${transition.from}'`, + ); + } + + const candidate = { + vidPid: sku.vidPid, + model: sku.model, + firmwarePrefix: sku.firmwarePrefix, + canonicalMode: claim.canonicalMode, + permittedTransitions: + transition === undefined + ? [] + : [ + { + from: transition.from, + to: transition.to, + atCommand: transition.atCommand, + expectedResponse: transition.expectedResponse, + expectsPortDrop: transition.expectsPortDrop, + expectedDescriptors: transition.afterDescriptors, + evidenceBundleSha256: request.bundleSha256, + }, + ], + }; + + // The candidate is re-validated through the AUTHORITATIVE entry schema, so an + // impossible combination (a router-mode SKU declaring a transition, say) is refused + // here rather than at review time. + const entry = catalogEntrySchema.safeParse(candidate); + if (!entry.success) { + return refuse('entry-schema-invalid', z.prettifyError(entry.error)); + } + return { ok: true, value: entry.data }; +} + +/** The canonical-mode vocabulary a reviewer's claim may use — re-exported for callers. */ +export const CLAIMABLE_CANONICAL_MODES = CANONICAL_USB_MODES; diff --git a/control/src/usb-mode/promotion-review.ts b/control/src/usb-mode/promotion-review.ts new file mode 100644 index 0000000..52cd53b --- /dev/null +++ b/control/src/usb-mode/promotion-review.ts @@ -0,0 +1,117 @@ +// Rendering the REVIEW ARTIFACT for a catalog promotion — the PR-comment template. +// +// Catalog additions are human-reviewed commits (Phase-A rule). This module renders what +// a reviewer reads: the proposed entry, the classifier fixture derived from the same +// bundle, and a checklist whose boxes a machine cannot tick. It renders a REFUSAL with +// equal prominence — a refused promotion produces a comment that says so, never silence, +// because a silently-absent comment is indistinguishable from a forgotten run. +// +// Nothing here writes a file or opens a PR. The output is text. + +import type { CatalogEntry } from './catalog-schema'; +import type { ClassifierFixture, IngestionOutcome, IngestionRefusal } from './ingestion'; + +/** Everything the rendered comment needs beyond the two ingestion outcomes. */ +export interface PromotionContext { + /** The runbook that captured the bundle (`RB-11` …) — the evidence's provenance. */ + readonly runbook: string; + /** The repo-local evidence path the bundle was written to. */ + readonly evidencePath: string; +} + +/** A promotion request: the two ingestion outcomes plus where the evidence came from. */ +export interface PromotionRequest { + readonly context: PromotionContext; + readonly entry: IngestionOutcome; + readonly fixture: IngestionOutcome; +} + +function refusalBlock(what: string, refusal: IngestionRefusal): string { + return [ + `### ❌ ${what} — REFUSED`, + '', + `**Reason:** \`${refusal.reason}\``, + '', + `> ${refusal.detail}`, + '', + 'This is a typed refusal from the ingestion seam, not a review opinion. Fix the', + 'capture and re-run the runbook; do not hand-author the artifact around it.', + ].join('\n'); +} + +function entryBlock(entry: CatalogEntry): string { + return [ + '### Proposed `certified-catalog.json` entry', + '', + '```json', + JSON.stringify(entry, null, 2), + '```', + ].join('\n'); +} + +function fixtureBlock(fixture: ClassifierFixture): string { + const { snapshot, provenance } = fixture; + const syntheticNote = provenance.synthetic + ? '> ⚠️ Derived from a **synthetic** bundle — valid as test data, never as certification evidence.' + : `> Derived from a real capture, bundle sha256 \`${provenance.bundleSha256}\`.`; + return [ + '### Proposed classifier fixture (`control/src/backend/device-classifier.test.ts`)', + '', + syntheticNote, + '', + '```ts', + `const FIXTURE: UsbDeviceSnapshot = ${JSON.stringify(snapshot, null, 2)};`, + '```', + ].join('\n'); +} + +function checklistBlock(context: PromotionContext, entry: CatalogEntry): string { + const transitions = entry.permittedTransitions.length; + return [ + '### Reviewer checklist (every box is a human judgement)', + '', + `- [ ] The bundle at \`${context.evidencePath}\` was captured by **${context.runbook}** on real hardware, and its \`CERTIFY OK\` line reads \`synthetic=false\`.`, + '- [ ] The bundle sha256 in the entry matches the sha256 the capture printed — recomputed, not copied from this comment.', + `- [ ] \`canonicalMode: "${entry.canonicalMode}"\` is the mode the device was actually observed in, not the mode it was expected to be in.`, + transitions === 0 + ? '- [ ] `permittedTransitions: []` is correct for this stage — a stage-1 entry never declares a transition.' + : '- [ ] The declared transition was OBSERVED end to end: the AT command executed, the port dropped if `expectsPortDrop`, and the device re-enumerated presenting `expectedDescriptors`.', + '- [ ] No claim in `docs/MODEM-SUPPORT-MATRIX.md` is being changed by this commit without its own evidence.', + ].join('\n'); +} + +/** + * Render the review comment for a promotion request. Always returns a comment: a + * refusal renders a refusal block, so a run that produced nothing promotable still + * leaves a visible, auditable trace. + */ +export function renderPromotionReview(request: PromotionRequest): string { + const { context, entry, fixture } = request; + const parts: string[] = [ + `## Catalog promotion review — ${context.runbook}`, + '', + `Evidence: \`${context.evidencePath}\``, + '', + 'Generated by the `control/src/usb-mode/` ingestion seam. **This comment promotes', + 'nothing** — the promotion is the human-reviewed commit that follows it.', + '', + ]; + + parts.push(entry.ok ? entryBlock(entry.value) : refusalBlock('Catalog entry', entry)); + parts.push(''); + parts.push( + fixture.ok ? fixtureBlock(fixture.value) : refusalBlock('Classifier fixture', fixture), + ); + parts.push(''); + if (entry.ok) { + parts.push(checklistBlock(context, entry.value)); + } else { + parts.push( + '### No checklist', + '', + 'The catalog entry was refused, so there is nothing to review. A checklist here', + 'would invite a reviewer to approve an artifact that does not exist.', + ); + } + return `${parts.join('\n')}\n`; +} diff --git a/control/src/usb-mode/usb-devices-parse.ts b/control/src/usb-mode/usb-devices-parse.ts new file mode 100644 index 0000000..ed2035c --- /dev/null +++ b/control/src/usb-mode/usb-devices-parse.ts @@ -0,0 +1,196 @@ +// Parsing `usb-devices` text — the ONLY per-interface descriptor source inside a +// certification bundle. +// +// A base certification bundle (`certify ` with no `--transition`) carries no +// structured descriptors at all: it holds `lsusb -v` and `usb-devices` as raw text plus +// the slot's udev property map. Authoring a classifier fixture or a catalog entry from +// such a bundle therefore requires reading the descriptors back out of that text, and +// `usb-devices` is the right half to read — it is line-oriented, one fixed-width record +// per device, and it names each interface's BOUND KERNEL DRIVER, which `lsusb -v` does +// not. The driver is not optional detail here: `classifyDevice` decides `mm-managed` vs +// `router-mode` partly on `qmi_wwan` / `cdc_ether` / `option` bindings. +// +// The parser is pure and total: unparseable lines are SKIPPED, never guessed at, and a +// device that yields no interfaces still yields a record (callers decide whether an +// interface-less device is usable — this file never makes that judgement). +// +// Record shape (`usb-devices`, one blank-line-separated block per device): +// T: Bus=04 Lev=03 Prnt=03 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0 +// D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1 +// P: Vendor=2c7c ProdID=0801 Rev=05.04 +// S: Manufacturer=Quectel +// S: Product=RM530N-GL +// I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan + +/** One interface line of a `usb-devices` record. */ +export interface ParsedUsbInterface { + readonly interfaceClass: number; + readonly interfaceSubClass: number; + readonly interfaceProtocol: number; + /** The bound kernel driver, omitted when `usb-devices` reports `(none)`. */ + readonly driver?: string; +} + +/** One device block of a `usb-devices` capture. */ +export interface ParsedUsbDevice { + /** Lowercase hex `xxxx:xxxx`, exactly the catalog's `vidPid` discriminator shape. */ + readonly vidPid: string; + /** The `D:` line's `Cls=` byte — the device-descriptor `bDeviceClass`. */ + readonly bDeviceClass: number; + readonly manufacturer?: string; + readonly product?: string; + readonly interfaces: readonly ParsedUsbInterface[]; +} + +/** Read `Key=value` from a `usb-devices` line; `undefined` when the key is absent. */ +function field(line: string, key: string): string | undefined { + // Values are whitespace-delimited and may be preceded by padding spaces (`Dev#= 7`). + // `Cls=ff(vend.)` carries a trailing gloss, stripped by the hex/number parsers below. + const match = new RegExp(`${key}=\\s*(\\S+)`).exec(line); + return match?.[1]; +} + +/** Parse a hex byte field, tolerating `usb-devices`' `ff(vend.)` gloss suffix. */ +function hexByte(line: string, key: string): number | undefined { + const raw = field(line, key); + if (raw === undefined) { + return undefined; + } + const digits = /^[0-9a-fA-F]{1,2}/.exec(raw)?.[0]; + if (digits === undefined) { + return undefined; + } + const value = Number.parseInt(digits, 16); + return Number.isNaN(value) ? undefined : value; +} + +/** Parse an `S: Manufacturer=…` style line into its `[key, value]` pair. */ +function stringField(line: string): readonly [string, string] | undefined { + const eq = line.indexOf('='); + if (eq < 0) { + return undefined; + } + const key = line.slice(3, eq).trim(); + const value = line.slice(eq + 1).trim(); + return key === '' || value === '' ? undefined : [key, value]; +} + +interface DeviceAccumulator { + vidPid?: string; + bDeviceClass?: number; + manufacturer?: string; + product?: string; + interfaces: ParsedUsbInterface[]; +} + +function emptyAccumulator(): DeviceAccumulator { + return { interfaces: [] }; +} + +function finish(acc: DeviceAccumulator, out: ParsedUsbDevice[]): void { + // A record with no `P:` line is not a device — never synthesise an identity for it. + if (acc.vidPid === undefined) { + return; + } + out.push({ + vidPid: acc.vidPid, + bDeviceClass: acc.bDeviceClass ?? 0, + interfaces: acc.interfaces, + ...(acc.manufacturer !== undefined ? { manufacturer: acc.manufacturer } : {}), + ...(acc.product !== undefined ? { product: acc.product } : {}), + }); +} + +function applyProductLine(line: string, acc: DeviceAccumulator): void { + const vendor = field(line, 'Vendor'); + const product = field(line, 'ProdID'); + if (vendor !== undefined && product !== undefined) { + acc.vidPid = `${vendor.toLowerCase()}:${product.toLowerCase()}`; + } +} + +function applyStringLine(line: string, acc: DeviceAccumulator): void { + const pair = stringField(line); + if (pair === undefined) { + return; + } + const [key, value] = pair; + if (key === 'Manufacturer') { + acc.manufacturer = value; + } else if (key === 'Product') { + acc.product = value; + } +} + +function applyInterfaceLine(line: string, acc: DeviceAccumulator): void { + const interfaceClass = hexByte(line, 'Cls'); + const interfaceSubClass = hexByte(line, 'Sub'); + const interfaceProtocol = hexByte(line, 'Prot'); + if ( + interfaceClass === undefined || + interfaceSubClass === undefined || + interfaceProtocol === undefined + ) { + return; + } + const driver = field(line, 'Driver'); + acc.interfaces.push({ + interfaceClass, + interfaceSubClass, + interfaceProtocol, + ...(driver !== undefined && driver !== '(none)' ? { driver } : {}), + }); +} + +/** + * Parse `usb-devices` output into one record per device. Pure and total: malformed + * lines are skipped rather than guessed at, and a block with no `P:` line yields no + * record (it has no identity, so inventing one would be a lie). + */ +export function parseUsbDevices(text: string): ParsedUsbDevice[] { + const out: ParsedUsbDevice[] = []; + let acc = emptyAccumulator(); + for (const line of text.split('\n')) { + // A `T:` line opens a new device record; `usb-devices` also blank-line-separates + // them, but the topology line is the reliable delimiter (blank lines are optional + // in some kernels' output). + if (line.startsWith('T:')) { + finish(acc, out); + acc = emptyAccumulator(); + continue; + } + if (line.startsWith('D:')) { + const deviceClass = hexByte(line, 'Cls'); + if (deviceClass !== undefined) { + acc.bDeviceClass = deviceClass; + } + } else if (line.startsWith('P:')) { + applyProductLine(line, acc); + } else if (line.startsWith('S:')) { + applyStringLine(line, acc); + } else if (line.startsWith('I:')) { + applyInterfaceLine(line, acc); + } + } + finish(acc, out); + return out; +} + +/** + * Find the single device matching `vidPid` in a parsed capture. Returns `undefined` + * when there is NO match, and — deliberately — also when there is more than one: a + * duplicate VID:PID (this bench has two identical Huawei HiLink units) makes the + * selection ambiguous, and an ambiguous selection must refuse rather than pick the + * first. The caller turns that into a typed refusal. + */ +export function selectUniqueDevice( + devices: readonly ParsedUsbDevice[], + vidPid: string, +): { readonly device: ParsedUsbDevice } | { readonly ambiguousMatches: number } { + const matches = devices.filter((d) => d.vidPid === vidPid); + const only = matches[0]; + if (matches.length === 1 && only !== undefined) { + return { device: only }; + } + return { ambiguousMatches: matches.length }; +} diff --git a/docs/BENCH.md b/docs/BENCH.md index 0a31248..7547033 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -790,6 +790,633 @@ gitignored). `` is the unit slug, matching RB-9's per-unit directory names --- +## Per-SKU certification (RB-11 … RB-15) — shared contract + +RB-11 through RB-15 are one runbook per acquired modem family. They all drive the **same** +command (`./modem-control certify `) and differ only in the SKU-specific facts each +one must additionally capture. Everything the five have in common is stated **once**, here. + +### The two-stage certification order (not optional — it falls out of the code) + +A catalog entry cannot be authored in one pass, because `certify --transition` **refuses** +a SKU that is not already in the catalog (`cli/src/certify/transition-evidence.ts`, the +`no certified catalog entry for SKU …` throw). The order is therefore: + +| Stage | Command | Produces | Lands as | +|-------|---------|----------|----------| +| **1 — base** | `./modem-control certify --output .json` | a base bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, `mmcli -K`, redacted `GetManagedObjects`, a bounded signal window | a candidate entry with `permittedTransitions: []`, reviewed and committed by a human | +| **2 — transition** | `./modem-control certify --transition --output .json` | stage-1 evidence **plus** before/after descriptors, the executed AT command, and the port-drop / re-enumeration timeline | ONE `permittedTransitions[]` element on the entry from stage 1, carrying that bundle's `evidenceBundleSha256` | + +Stage 2 is only reachable for a SKU whose stage-1 entry is already merged. Both stages feed +the same reviewed ingestion path: [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). + +### Blockers that make every RB-11…RB-15 row `[PARTIAL]` today + +All four were verified on `ceralive2` (192.168.78.132) on 2026-08-16. None is "not run yet"; +each is a named obligation with a code or packaging fix behind it. + +| # | Blocker | Verified how | Consequence | +|---|---------|--------------|-------------| +| **B1** | `usbutils` is **not installed and not in the board's apt archive** (`apt-cache show usbutils` → `E: No packages found`) | live on the board | `certify` runs `lsusb -v` **first** and `usb-devices` second (`cli/src/certify/capture.ts`); both fail, so **no bundle of any kind can be captured** until `usbutils` is installed from an archive that carries it, or built | +| **B2** | The production USB enumerator never populates `ifname` (`control/src/backend/usb-enumerator.ts`, `buildSnapshot`), but `certify` matches its target device **by** `ifname` | code read | the matched device is always `undefined`, so a bundle comes out with **no `sku` and empty `udevProperties`**, and `--transition` refuses with `--transition needs a matched USB device` | +| **B3** | There is **no AT transport** on the bench: the CLI's `benchAtSender` rejects every send, and the image has no `socat` / `picocom` / `minicom` for a manual session either | code read + live `command -v` sweep | stage 2 cannot execute an AT command at all — every `AT+…` line in RB-11/13/14 below is **documented, never executed** | +| **B4** | The shipped `certified-catalog.json` holds exactly one entry, `CERALIVE-SYNTHETIC-TEST-SKU` | repo read | stage 2 is unreachable for every real SKU until that SKU's stage-1 entry is merged | + +**B1 and B2 gate stage 1. B3 and B4 additionally gate stage 2.** A run that reports a +`CERTIFY OK` line with `synthetic=true`, or with an empty `sku`, is **not** a passing +RB-11…RB-15 — the gate is the `synthetic=false` line *and* a bundle whose `sku` is populated. + +### The shared certify step + +Every RB-11…RB-15 "Commands" block below opens with this, differing only in `SLOT`/`UNIT`: + +```sh +OUT=test-results/modem-phase-b/08; mkdir -p "$OUT/$UNIT" +./modem-control certify "$SLOT" --output "$OUT/$UNIT/bundle.json" \ + 2>&1 | tee "$OUT/$UNIT/certify.txt" +``` + +and every one asserts the same gate line: + +``` +CERTIFY OK: sha256=<64 hex> synthetic=false transition=none slot= +``` + +```sh +grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/$UNIT/certify.txt" +``` + +> **`` is a ModemManager selector, and MM indices renumber.** `Modem/2` is the +> Quectel *today*; a replug or an MM restart can move it. Re-read `mmcli -L` immediately +> before each run (RB-9 step 2) and never reuse a recorded index across sessions. The +> stable key is the udev `ID_PATH`, which is what the bundle carries. + +--- + +## RB-11 — Quectel RM530N-GL certification capture (QMI) `[PARTIAL]` + +Capture the certification bundle for the Quectel RM530N-GL, and record the `AT+QCFG="usbnet"` +composition-mode **transition candidate** — the one SKU on this bench with a documented, +schema-representable within-ModemManager mode switch. + +> **`AT+QCFG="usbnet"` is a CANDIDATE, not a certified transition.** Nothing below promotes +> it. The value set is firmware-dependent, so step 3 **reads** the current setting rather +> than assuming a mapping, and the read result is what a reviewer uses to author the +> stage-2 transition. Note also that the RNDIS value is **not representable** in a catalog +> entry at all: `catalog-schema.ts` types a transition's `from`/`to` to the MM-mode enum +> (`qmi` / `mbim` / `ecm-ncm`), so an `MM → rndis` transition fails to parse by construction. +> Do not record one. + +**Preconditions** + +- The shared contract above, including blockers **B1–B4**. +- The Quectel enumerated and MM-managed. Live values on this bench (2026-08-16): + USB `4-1.4.4`, VID:PID `2c7c:0801`, `ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.4.4:1.4`, + MM `/org/freedesktop/ModemManager1/Modem/2`, plugin `quectel`, drivers `qmi_wwan` + + `option`, primary port `cdc-wdm0`, net `wwan0`, firmware `RM530NGLAAR05A01M4G`. +- **A SIM whose state does not block registration.** The inserted SIM currently reports + `lock: sim-pin2` (PIN1 satisfied — PIN2 only gates Fixed-Dialing-Number edits and does + **not** block registration) with `registration: idle`, `packet service state: detached`, + `signal quality: 0%`. That is a captureable state for the bundle, but it means no bearer + smoke can accompany it. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=quectel-rm530n-gl; SLOT=Modem/2 +mkdir -p "$OUT/$UNIT" + +# 1) STAGE-1 BUNDLE — the gate. +./modem-control certify "$SLOT" --output "$OUT/$UNIT/bundle.json" \ + 2>&1 | tee "$OUT/$UNIT/certify.txt" + +# 2) SKU-SPECIFIC FACTS — QMI transport, raw-IP flag, port map. +{ + echo "--- mmcli dump ---"; mmcli -m "$SLOT" + echo "--- qmi raw_ip ---"; cat /sys/class/net/wwan0/qmi/raw_ip + echo "--- wda data format ---"; qmicli -d /dev/cdc-wdm0 --wda-get-data-format + echo "--- id_path ---"; udevadm info -q property -p /sys/class/net/wwan0 \ + | grep -E '^ID_PATH=|^ID_NET_DRIVER=' +} 2>&1 | tee "$OUT/$UNIT/qmi-facts.txt" + +# 3) TRANSITION CANDIDATE — READ ONLY. Blocked by B3 (no AT transport); the command is +# recorded so the bench run is a fill-in, not a judgment call. AT ports: ttyUSB2, ttyUSB3. +{ + echo 'AT+QCFG="usbnet"' # read the CURRENT value — never assume the mapping + echo 'AT+QCFG="usbnet",' # the switch, executed ONLY under stage 2, never here +} | tee "$OUT/$UNIT/transition-candidate.txt" +``` + +**Expected output** + +Step 1 ends with the shared gate line, `slot=Modem/2`. Step 2's raw-IP read prints exactly +`Y` (captured live 2026-08-16 — the Quectel's `qmi_wwan` link is in raw-IP mode), and the +`ID_PATH` line reads `ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.4.4:1.4` with +`ID_NET_DRIVER=qmi_wwan`. Step 3's read returns, on a working AT channel: + +``` ++QCFG: "usbnet", + +OK +``` + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=quectel-rm530n-gl +grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/$UNIT/certify.txt" \ + && grep -Eq '"vidPid": *"2c7c:0801"' "$OUT/$UNIT/bundle.json" \ + && echo "RB-11 PASS" || echo "RB-11 FAIL" +``` + +The `vidPid` clause is not decoration: it is what fails when blocker **B2** is still live, +because an unmatched device yields a bundle with no `sku` at all. + +**Status:** `[PARTIAL]` — no capture. Blockers B1 (no `usbutils`) and B2 (no `ifname` on +enumerated devices) both stop stage 1; B3 and B4 additionally stop stage 2. + +**Evidence:** `test-results/modem-phase-b/08/quectel-rm530n-gl/{certify.txt,bundle.json,qmi-facts.txt,transition-candidate.txt}` + +--- + +## RB-12 — Sierra EM75xx certification capture (MBIM/QMI, FCC-locked) `[PARTIAL]` + +Capture the certification bundle for a Sierra Wireless EM75xx-class module, and record the +**FCC-lock** behaviour honestly in its locked state. + +> **FCC unlock stays UNRUN, and that is a policy, not an omission.** Sierra EM-series +> modules ship RF-disabled until an FCC-authorization sequence is sent; the community +> `qmi-fcc-unlock` / `mbim-fcc-unlock` helpers exist and ModemManager 1.24 can invoke a +> per-vendor unlock script. This project does **not** run one, and this runbook does not +> document one as a step. Sending an FCC-authorization command is a **regulatory act about +> the operator's own equipment**, so it is **opt-in per deployment** and is never performed +> as part of a certification capture. What RB-12 captures instead is the **LOCKED-state +> behaviour** — which is a real, useful, reportable fact: an FCC-locked EM75xx enumerates, +> is MM-managed, reports its identity, and reports RF disabled. A bundle captured in the +> locked state is a valid stage-1 bundle. It is **not** evidence of a working data path, +> and no row may claim one from it. + +**Preconditions** + +- The shared contract above, including blockers **B1–B4**. +- **A Sierra EM75xx module physically connected. There is none on this bench** — the + 2026-08-16 USB sweep shows no `1199:*` device. Every value below is the *documented + expectation*, captured from no hardware, and is marked as such. +- Expected identity when one is connected: VID `1199` (Sierra Wireless), MBIM composition + binding `cdc_mbim` (`0x02/0x0e` control + `0x0a` data), QMI composition binding + `qmi_wwan`; MM plugin `sierra`. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=sierra-em75xx; SLOT=Modem/0 # re-read mmcli -L first +mkdir -p "$OUT/$UNIT" + +# 1) STAGE-1 BUNDLE — the gate. +./modem-control certify "$SLOT" --output "$OUT/$UNIT/bundle.json" \ + 2>&1 | tee "$OUT/$UNIT/certify.txt" + +# 2) LOCKED-STATE CAPTURE — the whole point of this runbook. +{ + echo "--- mmcli dump ---"; mmcli -m "$SLOT" + echo "--- power state ---"; mmcli -m "$SLOT" -K | grep -E '^modem\.generic\.(state|power-state|device)' + echo "--- transport ---"; udevadm info -q property -p /sys/class/net/wwan0 \ + | grep -E '^ID_PATH=|^ID_NET_DRIVER=|^ID_VENDOR_ID=' +} 2>&1 | tee "$OUT/$UNIT/fcc-locked-state.txt" + +# 3) FCC POLICY MARKER — records that the unlock was deliberately NOT run. +printf 'FCC-UNLOCK: NOT RUN (opt-in per deployment; see RB-12 policy note)\n' \ + | tee "$OUT/$UNIT/fcc-policy.txt" +``` + +**Expected output** + +Step 1 ends with the shared gate line. Step 2, on an FCC-locked unit, shows the modem +present and identified with its RF path disabled — typically `state: disabled` or +`state: failed` alongside a `power state`, with the module's manufacturer/model/firmware all +populated. **Both are a valid capture**; RB-12 does not require a particular one, it +requires the observed one to be recorded verbatim. Step 3 writes the literal marker line. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=sierra-em75xx +grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/$UNIT/certify.txt" \ + && grep -Eq '"vidPid": *"1199:[0-9a-f]{4}"' "$OUT/$UNIT/bundle.json" \ + && grep -Fq 'FCC-UNLOCK: NOT RUN' "$OUT/$UNIT/fcc-policy.txt" \ + && echo "RB-12 PASS" || echo "RB-12 FAIL" +``` + +**Status:** `[PARTIAL]` — **fully** unrun, and unrunnable: no EM75xx exists on this bench, on +top of blockers B1–B4. No capture directory is created; no value above was observed. + +**Evidence:** `test-results/modem-phase-b/08/sierra-em75xx/{certify.txt,bundle.json,fcc-locked-state.txt,fcc-policy.txt}` + +--- + +## RB-13 — SIMCom SIM7600G-H certification capture (QMI raw-IP) `[PARTIAL]` + +Capture the certification bundle for the SIMCom SIM7600G-H R2, recording its **QMI raw-IP +flag state** explicitly, and record the `AT+CUSBPIDSWITCH` RNDIS personality as an +**observation only**. + +> **`AT+CUSBPIDSWITCH=9011` exists and is NOT certified.** SIM7600-series firmware exposes a +> USB PID switch, and `9011` selects an RNDIS personality. It is recorded here so a bench +> operator who encounters it knows what it is — **and knows not to certify it**. Two +> independent reasons: (a) RNDIS is not a ModemManager-manageable mode, so the transition is +> **schema-invalid** in `catalog-schema.ts` and cannot be written down as a permitted +> transition; (b) a PID switch changes the device's VID:PID, i.e. its catalog +> **discriminator**, so the post-switch device is a *different* catalog subject, not a mode +> of this one. Observation only. Do not run it as part of a certification capture. + +**Preconditions** + +- The shared contract above, including blockers **B1–B4**. +- The SIM7600G-H enumerated and MM-managed. Live values on this bench (2026-08-16): + USB `1-1.3.4`, VID:PID `1e0e:9001`, `ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.3.4:1.5`, + MM `/org/freedesktop/ModemManager1/Modem/4`, plugin `simtech`, drivers `qmi_wwan` + + `option`, primary port `cdc-wdm1`, net `wwan1`, firmware `LE20B04SIM7600G22`. +- **No SIM is inserted** in this unit — MM reports `state: failed`, + `failed reason: sim-missing`. That is a captureable stage-1 state (the module identifies + itself fully), but it forbids any registration or bearer claim from this bundle. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=simcom-sim7600g-h; SLOT=Modem/4 +mkdir -p "$OUT/$UNIT" + +# 1) STAGE-1 BUNDLE — the gate. +./modem-control certify "$SLOT" --output "$OUT/$UNIT/bundle.json" \ + 2>&1 | tee "$OUT/$UNIT/certify.txt" + +# 2) RAW-IP FLAG STATE — the SKU-specific fact this runbook exists to capture. The kernel +# sysfs flag and the modem's own WDA data format must AGREE; a mismatch is the classic +# "QMI link up, no traffic" fault and is a finding, not a pass. +{ + echo "--- kernel raw_ip flag ---"; cat /sys/class/net/wwan1/qmi/raw_ip + echo "--- modem wda format ---"; qmicli -d /dev/cdc-wdm1 --wda-get-data-format + echo "--- transport ---"; udevadm info -q property -p /sys/class/net/wwan1 \ + | grep -E '^ID_PATH=|^ID_NET_DRIVER=' + echo "--- sim state ---"; mmcli -m "$SLOT" -K | grep -E '^modem\.generic\.(state|state-failed-reason)' +} 2>&1 | tee "$OUT/$UNIT/raw-ip-state.txt" + +# 3) RNDIS PERSONALITY — OBSERVATION ONLY. Recorded, never executed, never certified. +printf 'AT+CUSBPIDSWITCH=9011 # RNDIS personality — EXISTS, NOT CERTIFIED, NOT RUN\n' \ + | tee "$OUT/$UNIT/rndis-observation.txt" +``` + +**Expected output** + +Step 1 ends with the shared gate line, `slot=Modem/4`. Step 2's kernel flag prints exactly +`Y` (captured live 2026-08-16), the `ID_PATH` line reads +`ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.3.4:1.5` with `ID_NET_DRIVER=qmi_wwan`, and the +SIM state reads `modem.generic.state-failed-reason : sim-missing` until a card is inserted. +`--wda-get-data-format` reports its link-layer protocol; on a healthy raw-IP link it reads +`raw-ip`, matching the `Y` above. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=simcom-sim7600g-h +grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot=' \ + "$OUT/$UNIT/certify.txt" \ + && grep -Eq '"vidPid": *"1e0e:9001"' "$OUT/$UNIT/bundle.json" \ + && sed -n '2p' "$OUT/$UNIT/raw-ip-state.txt" | grep -Eq '^(Y|N)$' \ + && echo "RB-13 PASS" || echo "RB-13 FAIL" +``` + +The raw-IP clause asserts the flag was **captured**, not that it holds a particular value — +`N` is a legitimate observation and a legitimate finding. Recording it is the gate. + +**Status:** `[PARTIAL]` — no capture; blockers B1/B2 stop stage 1, and this unit has no SIM. +The raw-IP flag value (`Y`) and the transport facts above are RB-9-class live observations +already captured on 2026-08-16; they are **not** a certification bundle. + +**Evidence:** `test-results/modem-phase-b/08/simcom-sim7600g-h/{certify.txt,bundle.json,raw-ip-state.txt,rndis-observation.txt}` + +--- + +## RB-14 — Huawei personality capture (Stick vs HiLink) `[PARTIAL]` + +Capture which **personality** a Huawei dongle is presenting — *Stick* (an MM-managed modem +with AT/QMI control ports) or *HiLink* (a self-contained router that presents only a +CDC-Ethernet tether) — and record the class evidence for each. The two personalities are +different device classes to this stack, not different settings of one device. + +> **A HiLink unit is `router-mode`, and that is a terminal classification here.** With a +> CDC-Ethernet tether and no control port of any kind, `classifyDevice` returns +> `router-mode` and `detectUsbMode` returns `router-ethernet`. Neither is an MM mode, so a +> HiLink unit can hold a catalog entry with `canonicalMode: "router-ethernet"` and +> **`permittedTransitions: []`** — and nothing else. Any Stick↔HiLink switch (`AT^SETPORT`, +> a vendor tool, or a firmware reflash) crosses the MM↔router line the schema forbids and +> is therefore **observation only**, exactly like RB-13's PID switch. + +**Preconditions** + +- The shared contract above, including blockers **B1–B4**. Note a HiLink unit is **not** + MM-managed, so `certify ` — which selects a *ModemManager* slot — cannot be run + against one at all; RB-14's HiLink half is a udev/network capture, and only its Stick half + is a certify capture. +- Live values on this bench (2026-08-16): **two** HiLink units, both `12d1:14dc`, at USB + `1-1.3.1` and `1-1.3.2`, both bound by `cdc_ether`, neither in `mmcli -L`. **No Huawei + Stick-mode unit is present** — the Stick half of this runbook is documented, not captured. + +> **The duplicate-MAC pair is the reason this runbook cannot key on anything but `ID_PATH`.** +> Both HiLink units carry the **identical factory MAC `0c:5b:8f:27:9a:64`**, both DHCP-serve +> `192.168.8.100/24` to the host, and both offer the same default gateway `192.168.8.1`. The +> unit that wins the naming race gets `enx0c5b8f279a64`; the loser falls back to a legacy +> `eth1` and — verified live — has **no `ID_PATH` udev property at all**, only a `DEVPATH`. +> So the per-unit capture below reads `ID_PATH` **with a `DEVPATH` fallback**, and a run that +> silently produces one row for two physical units is a FAIL, not a tidy result. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=huawei; mkdir -p "$OUT/$UNIT" + +# 1) PERSONALITY SWEEP — one row per physically present Huawei device, keyed on sysfs path. +for d in /sys/bus/usb/devices/*; do + [ -f "$d/idVendor" ] || continue + [ "$(cat "$d/idVendor")" = "12d1" ] || continue + echo "=== $(basename "$d") $(cat "$d/idVendor"):$(cat "$d/idProduct") ===" + echo "interfaces: $(for i in "$d":*; do [ -f "$i/bInterfaceClass" ] && \ + printf '%s/%s/%s(%s) ' "$(cat "$i/bInterfaceClass")" "$(cat "$i/bInterfaceSubClass")" \ + "$(cat "$i/bInterfaceProtocol")" "$(basename "$(readlink -f "$i/driver" 2>/dev/null)")"; done)" + echo "net children: $(ls "$d"/*/net 2>/dev/null | tr '\n' ' ')" + echo "tty children: $(ls -d "$d"/*/tty* 2>/dev/null | tr '\n' ' ')" +done 2>&1 | tee "$OUT/$UNIT/personality-sweep.txt" + +# 2) PER-INTERFACE IDENTITY — ID_PATH, with the DEVPATH fallback the duplicate-MAC loser needs. +for ifc in $(ip -br link | awk '{print $1}'); do + drv=$(udevadm info -q property -p "/sys/class/net/$ifc" 2>/dev/null | grep -E '^ID_NET_DRIVER=') + case "$drv" in *cdc_ether*|*rndis_host*|*cdc_ncm*|*option*|*qmi_wwan*) ;; *) continue ;; esac + echo "--- $ifc ---" + udevadm info -q property -p "/sys/class/net/$ifc" 2>/dev/null \ + | grep -E '^ID_PATH=|^DEVPATH=|^ID_VENDOR_ID=|^ID_MODEL_ID=|^ID_NET_DRIVER=' + echo "mac=$(cat "/sys/class/net/$ifc/address")" +done 2>&1 | tee "$OUT/$UNIT/id-path-fallback.txt" + +# 3) STICK HALF — only when an MM-managed Huawei is present (none on this bench). +# SLOT comes from `mmcli -L`; skip entirely when no 12d1 unit is listed. +# ./modem-control certify "$SLOT" --output "$OUT/$UNIT/stick-bundle.json" \ +# 2>&1 | tee "$OUT/$UNIT/stick-certify.txt" + +# 4) PERSONALITY-SWITCH OBSERVATION — recorded, never executed, never certified. +printf 'AT^SETPORT # Huawei Stick<->HiLink personality — EXISTS, NOT CERTIFIED, NOT RUN\n' \ + | tee "$OUT/$UNIT/personality-switch-observation.txt" +``` + +**Expected output** + +Step 1 prints one `===` block per physical Huawei device — **two** on this bench. A HiLink +block shows CDC-Ethernet interfaces bound to `cdc_ether`, a `net` child, and **no** `tty` +children; a Stick block would show `option`-bound `ttyUSB*` children (and, on a QMI-capable +Stick, a `qmi_wwan` interface). Step 2 prints, for the naming-race winner: + +``` +ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.3.2:1.0 +ID_NET_DRIVER=cdc_ether +mac=0c:5b:8f:27:9a:64 +``` + +and, for the loser, a `DEVPATH=` line with **no** `ID_PATH` — the same MAC on a different +sysfs path. Two blocks, two paths, one MAC. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=huawei +n=$(grep -c '^=== ' "$OUT/$UNIT/personality-sweep.txt") +p=$(grep -cE '^(ID_PATH=|DEVPATH=)' "$OUT/$UNIT/id-path-fallback.txt") +[ "$n" -ge 1 ] && [ "$p" -ge "$n" ] \ + && echo "RB-14 PASS (units=$n identity-rows=$p)" || echo "RB-14 FAIL (units=$n identity-rows=$p)" +``` + +Every physical unit must contribute at least one identity row. That is the assertion the +duplicate-MAC pair would otherwise silently defeat. + +**Status:** `[PARTIAL]` — no certification bundle. The HiLink half is capturable today (it +needs neither `certify` nor `usbutils`), but it has not been run as an RB-14 capture; the +Stick half has no hardware at all. + +**Evidence:** `test-results/modem-phase-b/08/huawei/{personality-sweep.txt,id-path-fallback.txt,personality-switch-observation.txt}` (+ `stick-{certify.txt,bundle.json}` when a Stick unit exists) + +--- + +## RB-15 — ZTE MF79U router-mode capture `[PARTIAL]` + +Capture the router-mode class evidence for the ZTE MF79U-class dongle: the LAN subnet it +actually serves, the DHCP lease it hands the host, its default gateway, and the presence of +its embedded web UI. This is the canonical **`router-ethernet`** capture — the device MM +never manages. + +> **A router dongle's catalog entry can only ever be `canonicalMode: "router-ethernet"` with +> `permittedTransitions: []`.** There is no AT channel, no QMI channel, and no MM slot. The +> entry records *what it is*, so the stack can recognise it and hand it to the netns layer — +> it never records a switch. Nothing in RB-15 produces a `certify` bundle, because `certify` +> selects a ModemManager slot and this device has none. + +**Preconditions** + +- Bench device reachable over SSH; `curl` present (**verified installed**; `wget` is **not** + on this image, so the web-UI step has no fallback and is a hard dependency). +- Live values on this bench (2026-08-16): USB `1-1.1`, VID:PID `19d2:1405`, + `ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.1:1.0`, driver `cdc_ether`, interface + `enx344b50000000` (MAC `34:4b:50:00:00:00`, unique — **no** collision, unlike RB-14's pair), + host lease `192.168.0.169/24`, gateway `192.168.0.1`. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=zte-mf79u; IF=enx344b50000000 +mkdir -p "$OUT/$UNIT" + +# 1) CLASS EVIDENCE — descriptors + driver + the absence of any control port. +{ + D=/sys/bus/usb/devices/1-1.1 + echo "vidpid: $(cat $D/idVendor):$(cat $D/idProduct)" + echo "interfaces: $(for i in $D:*; do [ -f "$i/bInterfaceClass" ] && \ + printf '%s/%s/%s(%s) ' "$(cat "$i/bInterfaceClass")" "$(cat "$i/bInterfaceSubClass")" \ + "$(cat "$i/bInterfaceProtocol")" "$(basename "$(readlink -f "$i/driver" 2>/dev/null)")"; done)" + echo "tty children: $(ls -d $D/*/tty* 2>/dev/null | wc -l) # MUST be 0 for router-mode" + echo "cdc-wdm children: $(ls -d $D/*/usbmisc 2>/dev/null | wc -l) # MUST be 0" + echo "mm-managed: $(mmcli -L 2>/dev/null | grep -c 19d2) # MUST be 0" +} 2>&1 | tee "$OUT/$UNIT/class-evidence.txt" + +# 2) LAN SUBNET + DHCP LEASE the dongle actually serves to the host. +{ + ip -br addr show "$IF" + ip route show dev "$IF" + udevadm info -q property -p "/sys/class/net/$IF" \ + | grep -E '^ID_PATH=|^ID_NET_DRIVER=|^ID_VENDOR_ID=|^ID_MODEL_ID=' + echo "mac=$(cat "/sys/class/net/$IF/address")" +} 2>&1 | tee "$OUT/$UNIT/lan-dhcp.txt" + +# 3) WEB-UI PRESENCE — the router personality's own control surface, header-only. +GW=$(ip route show dev "$IF" | awk '/^default/{print $3}' | head -1) +curl -sS -m 8 -o /dev/null -w "gw=$GW http_code=%{http_code} redirect=%{redirect_url}\n" \ + "http://$GW/" 2>&1 | tee "$OUT/$UNIT/web-ui.txt" +``` + +**Expected output** + +Step 1 prints `vidpid: 19d2:1405`, CDC-Ethernet interface triples bound to `cdc_ether`, and +**three zeros** — no tty child, no `cdc-wdm` child, not in `mmcli -L`. Those three zeros +*are* the router-mode classification. Step 2 prints the live lease and gateway: + +``` +enx344b50000000 UP 192.168.0.169/24 fe80::a408:7435:436b:94db/64 +default via 192.168.0.1 proto dhcp src 192.168.0.169 metric 103 +ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.1:1.0 +ID_NET_DRIVER=cdc_ether +``` + +Step 3 prints the embedded web UI's redirect — captured live 2026-08-16: + +``` +gw=192.168.0.1 http_code=302 redirect=http://192.168.0.1/index.html +``` + +For contrast (RB-14's HiLink units, same command against `192.168.8.1`) the response is +`http_code=307 redirect=http://192.168.8.1/html/index.html` — a different vendor UI at a +different path, which is why the assertion below accepts any 2xx/3xx rather than one code. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08; UNIT=zte-mf79u +grep -Eq '^vidpid: 19d2:1405$' "$OUT/$UNIT/class-evidence.txt" \ + && grep -Eq 'tty children: 0 ' "$OUT/$UNIT/class-evidence.txt" \ + && grep -Eq '^default via [0-9.]+ ' "$OUT/$UNIT/lan-dhcp.txt" \ + && grep -Eq 'http_code=[23][0-9]{2} ' "$OUT/$UNIT/web-ui.txt" \ + && echo "RB-15 PASS" || echo "RB-15 FAIL" +``` + +**Status:** `[PARTIAL]` — no RB-15 capture has been run. Every value quoted above is a live +RB-9-class observation from 2026-08-16, not an RB-15 evidence bundle. Unlike RB-11…RB-14 +this runbook is **not** blocked by B1–B4 (it never invokes `certify`), so it is the first of +the six that can be closed on the current image. + +**Evidence:** `test-results/modem-phase-b/08/zte-mf79u/{class-evidence.txt,lan-dhcp.txt,web-ui.txt}` + +--- + +> **RB-16 is reserved** for the Fibocom FM350 USB probe and its decision-doc gate-ledger +> update. See [`FM350-DECISION.md`](FM350-DECISION.md); the runbook itself is not yet written. + +--- + +## RB-17 — Modem-flap resilience under a live bonded stream `[PARTIAL]` + +Prove that a modem physically disappearing and returning **while a bonded stream is live** +costs the stream nothing permanent: the link re-registers, the surviving link(s) carry the +stream throughout, and the receiver's SRTLA group count returns to its pre-flap baseline +rather than accumulating stale groups. + +> **This is a RESILIENCE runbook, not a certification runbook.** It certifies no SKU and +> produces no catalog entry. It is the acceptance that the device-stable-core effort +> specified and never closed, restated here against the RB-10 hub-cycle mechanism now that +> one exists. + +> **The stale-group assertion is the real gate.** A flap that re-registers the link but +> leaves the receiver holding an orphaned SRTLA group is the failure this runbook exists to +> catch: it is invisible on the sender, invisible in the video for one flap, and accumulates +> — five flaps leave five groups, and the receiver's group table is what eventually breaks. +> "The stream kept playing" is **not** a passing RB-17. + +**Preconditions** + +- **RB-10 passing on the hub carrying the flapped modem.** RB-17 flaps via the RB-10 + mechanism, so a hub that has not proven per-port power switching (`ppps`) cannot host this + test. On this bench only the `0bda:0411` tree qualifies, and it carries exactly one modem + (the Quectel at `4-1.4.4`) — see RB-10's per-hub table. `uhubctl` is **not installed on the + board and not in its apt archive**, so RB-10 itself is unrun. +- **At least TWO links carrying a live bonded stream**, of which the flapped modem is one. + The surviving link is what proves continuity; flapping a single-link stream proves nothing + but that the stream dies. +- Receiver-side access to the SRTLA receiver's group count for the baseline assertion. +- A registered modem with a working bearer. **No modem on this bench is registered** — the + Quectel reports `registration: idle` / `signal quality: 0%`, and the SIMCom has no SIM. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/08; mkdir -p "$OUT/flap" +HUB=4-1.4; PORT=4; SLOT='platform-xhci-hcd.0.auto-usb-0:1.4.4'; MMSLOT=2 + +# GROUPS_CMD belongs to the SRTLA RECEIVER deployment used for this run — it is deliberately +# NOT guessed here. It must print the receiver's current SRTLA group count as a bare integer. +# Unset ⇒ the runbook fails closed on the first line rather than silently asserting nothing. +GROUPS_CMD=${GROUPS_CMD:?set GROUPS_CMD to the receiver group-count command before running} + +# 0) BASELINE — with the stream LIVE over >=2 links, before any flap. +{ + echo "--- links ---"; ip -br addr + echo "--- mm ---"; mmcli -L + echo "--- srtla groups ---"; sh -c "$GROUPS_CMD" +} 2>&1 | tee "$OUT/flap/baseline.txt" + +# 1) FLAP x5 within 60 s. Each iteration is one RB-10 hub cycle plus per-flap assertions. +for n in 1 2 3 4 5; do + echo "===== flap $n =====" + sudo ./modem-control hil-cycle "$SLOT" --hub-map "$OUT/flap/hub-map.json" --mm-slot "$MMSLOT" + sleep 3 + echo "--- post-flap links ---"; ip -br addr + echo "--- post-flap mm ---"; mmcli -L + echo "--- post-flap groups ---"; sh -c "$GROUPS_CMD" +done 2>&1 | tee "$OUT/flap/flap-x5.txt" + +# 2) SETTLE + FINAL GROUP COUNT — must equal the baseline, not merely be "small". +sleep 30 +sh -c "$GROUPS_CMD" 2>&1 | tee "$OUT/flap/final-groups.txt" + +# 3) STREAM CONTINUITY — the receiver-side record for the whole 60 s window, captured +# independently of the sender so a sender-side "everything is fine" cannot mask a gap. +``` + +**Expected output** + +Each of the five iterations ends with the RB-10 harness line + +``` +HIL-CYCLE PASS slot=platform-xhci-hcd.0.auto-usb-0:1.4.4 disappeared= reenumerated= +``` + +and shows the flapped modem back in `mmcli -L` at the same `modem.generic.device` path. The +final group count equals the baseline count **exactly**. The receiver-side continuity record +shows no interruption attributable to the flapped link. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/08 +flaps=$(grep -c '^HIL-CYCLE PASS ' "$OUT/flap/flap-x5.txt") +base=$(tail -1 "$OUT/flap/baseline.txt" | tr -dc '0-9') +final=$(tail -1 "$OUT/flap/final-groups.txt" | tr -dc '0-9') +[ "$flaps" -eq 5 ] && [ -n "$base" ] && [ "$final" = "$base" ] \ + && echo "RB-17 PASS (flaps=$flaps groups=$final==$base)" \ + || echo "RB-17 FAIL (flaps=$flaps baseline=$base final=$final)" +``` + +Both clauses are load-bearing. `flaps -eq 5` fails a run that gave up early; `final = base` +fails the stale-group accumulation that a "the stream survived" eyeball check passes. + +**Status:** `[PARTIAL]` — unrun, and blocked on **four** independent counts: `uhubctl` is +absent from the board and its archive (so RB-10 is unrun and RB-17's flap mechanism does not +exist yet); no modem on this bench is registered; there is no live bonded stream on this +bench; and only one modem sits behind a `ppps`-capable hub, so a two-link flap cannot be +staged there at all. The `GROUPS_CMD` fill-in is deliberate — the receiver-side command +belongs to the SRTLA receiver deployment used for the run and must be taken from it, not +guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed on line one. + +**Evidence:** `test-results/modem-phase-b/08/flap/{baseline.txt,flap-x5.txt,final-groups.txt,hub-map.json}` + +--- + ## Evidence index | Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | @@ -804,6 +1431,13 @@ gitignored). `` is the unit slug, matching RB-9's per-unit directory names | RB-8 | Daemon smoke on real hardware | `[PARTIAL]` | `A6.3/daemon-smoke.txt` | | RB-9 | Fleet inventory capture | `[PARTIAL]` | `A6.3/{usb-tree,mmcli-list,mmcli-dump,id-path-per-iface,ip-addr}.txt` + `test-results/modem-phase-b/05//` | | RB-10 | Hub VBUS verification | `[PARTIAL]` | `test-results/modem-phase-b/07/{uhubctl-discovery,uhubctl-ppps,vbus-drop-proof,vbus-drop-proof-nosysfs}.txt` + `hil-cycle-.txt` | +| RB-11 | Quectel RM530N-GL certification capture (QMI) | `[PARTIAL]` | `test-results/modem-phase-b/08/quectel-rm530n-gl/{certify.txt,bundle.json,qmi-facts.txt,transition-candidate.txt}` | +| RB-12 | Sierra EM75xx certification capture (MBIM/QMI, FCC-locked) | `[PARTIAL]` | `test-results/modem-phase-b/08/sierra-em75xx/{certify.txt,bundle.json,fcc-locked-state.txt,fcc-policy.txt}` | +| RB-13 | SIMCom SIM7600G-H certification capture (QMI raw-IP) | `[PARTIAL]` | `test-results/modem-phase-b/08/simcom-sim7600g-h/{certify.txt,bundle.json,raw-ip-state.txt,rndis-observation.txt}` | +| RB-14 | Huawei personality capture (Stick vs HiLink) | `[PARTIAL]` | `test-results/modem-phase-b/08/huawei/{personality-sweep.txt,id-path-fallback.txt,personality-switch-observation.txt}` | +| 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 probe | *(reserved — runbook not yet written)* | — | +| 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}` | 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/CATALOG-INGESTION.md b/docs/CATALOG-INGESTION.md new file mode 100644 index 0000000..a77bd70 --- /dev/null +++ b/docs/CATALOG-INGESTION.md @@ -0,0 +1,227 @@ +# Evidence-bundle ingestion — from a `certify` bundle to a reviewed catalog commit + +This is the documented path between a bench capture and `certified-catalog.json`. It exists +so the transcription step — reading descriptor bytes out of a capture by eye and typing them +into a JSON file — stops being a place where a certification claim can quietly become wrong. + +**It promotes nothing.** The seam is a pure transform that emits a *review artifact*. A +catalog addition remains what Phase A made it: a human-reviewed commit. Nothing here writes +a file, mutates the shipped catalog, or opens a PR. + +Code: [`control/src/usb-mode/ingestion.ts`](../control/src/usb-mode/ingestion.ts), +[`promotion-review.ts`](../control/src/usb-mode/promotion-review.ts), +[`usb-devices-parse.ts`](../control/src/usb-mode/usb-devices-parse.ts). +Runbooks that produce its input: [`BENCH.md`](BENCH.md) RB-11 … RB-15. + +--- + +## The rule the code enforces + +> **A bundle marked `synthetic: true` is REFUSED for catalog promotion.** + +Not by convention, not by a reviewer remembering — by `buildCatalogEntryCandidate`, which +returns a typed refusal (`reason: 'synthetic-bundle'`) before it looks at anything else. A +catalog entry asserts a certified hardware fact; synthetic evidence can never back one. + +Classifier *fixtures* are a different matter: `buildClassifierFixture` **accepts** a +synthetic bundle and stamps `provenance.synthetic = true` on the result, because synthetic +fixtures are legitimate test data. The asymmetry is deliberate and is the whole design. + +--- + +## What goes in, what comes out + +``` +modem-control certify [--transition ] + │ + ├─▶ bundle.json ─┐ + └─▶ CERTIFY OK: sha256=<64hex> … + │ + ▼ + control/src/usb-mode/ ingestion seam + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + buildClassifierFixture() buildCatalogEntryCandidate(claim) + → UsbDeviceSnapshot → CatalogEntry (REFUSES synthetic:true) + + provenance + evidenceBundleSha256 + └───────────────┬───────────────┘ + ▼ + renderPromotionReview() + → the PR comment a human reviews + ▼ + human-reviewed commit ← the ONLY promotion +``` + +### Where each field comes from + +| Output field | Source in the bundle | +|---|---| +| `vidPid` / `model` / `firmwarePrefix` | `sku` (the three catalog discriminators, captured together) | +| `bDeviceClass`, per-interface class/subclass/protocol, **per-interface driver** | the `usb-devices` text, parsed by `parseUsbDevices` | +| `physicalUid`, `ifname` | `usb.udevProperties` → `ID_PATH`, `INTERFACE` | +| `expectedDescriptors` | `transition.afterDescriptors` (stage 2 only) | +| `evidenceBundleSha256` | the sha256 `certify` printed for **this** bundle | +| `canonicalMode` | **the reviewer's stated claim** — never inferred | + +`usb-devices` rather than `lsusb -v` because it is the only half of a base bundle that names +each interface's **bound kernel driver**, and `classifyDevice` decides `mm-managed` vs +`router-mode` partly on `qmi_wwan` / `cdc_ether` / `option` bindings. + +`canonicalMode` is stated, not inferred, because a catalog entry is an assertion a human +signs. When the bundle carries transition evidence the seam cross-checks the claim against +the captured `transition.from` and refuses a mismatch rather than resolving it either way. + +--- + +## Two stages, because the code says so + +`certify --transition` refuses a SKU that is not already in the catalog, so an entry cannot +be authored in one pass. See [`BENCH.md` § Per-SKU certification](BENCH.md) for the full +table; in short: + +1. **Stage 1** — `certify ` → an entry with `permittedTransitions: []`, reviewed and + merged. +2. **Stage 2** — `certify --transition ` → **one** `permittedTransitions[]` + element on that entry, carrying the stage-2 bundle's own sha256. + +--- + +## Typed refusals + +Every refusal is a named condition with an actionable `detail`, never a throw and never a +partial result. + +| `reason` | What it means | +|---|---| +| `sha256-malformed` | the sha is not 64 lowercase hex — checked before the bundle is read | +| `bundle-malformed` | the bundle fails the ingestion view schema | +| `sku-missing` | the capture matched no USB device to the slot (see `BENCH.md` blocker **B2**) | +| `device-not-in-capture` | the SKU's VID:PID is absent from the `usb-devices` text | +| `device-ambiguous` | two or more devices share that VID:PID — a fixture must name one physical device (this bench has an identical Huawei HiLink pair) | +| `no-interfaces-captured` | the device parsed with zero interfaces; such a fixture classifies nothing | +| `synthetic-bundle` | **catalog promotion only** — the bundle is `synthetic: true` | +| `transition-mode-mismatch` | the claimed `canonicalMode` contradicts the captured `transition.from` | +| `entry-schema-invalid` | the candidate fails the authoritative `catalogEntrySchema` (e.g. a router-mode SKU declaring a transition) | + +### The view schema is deliberately non-strict + +The authoritative bundle schema lives beside the `certify` command in `cli/`, which depends +on `control/` and not the reverse. Ingestion therefore validates a **view**: the subset it +reads, with unknown fields ignored. Adding a field to the bundle can never break ingestion — +which is the point. + +--- + +## Worked example (synthetic — the refusal path) + +Generated by running the seam over a synthetic bundle whose SKU is the repo's existing +`CERALIVE-SYNTHETIC-TEST-SKU`. This is the output verbatim, not a mock-up. It is the +promotion path's **failure** leg, and it is the leg that matters: a synthetic bundle must +produce a loud refusal, not a quiet acceptance. + +````markdown +## Catalog promotion review — RB-11 + +Evidence: `test-results/modem-phase-b/08/quectel-rm530n-gl/bundle.json` + +Generated by the `control/src/usb-mode/` ingestion seam. **This comment promotes +nothing** — the promotion is the human-reviewed commit that follows it. + +### ❌ Catalog entry — REFUSED + +**Reason:** `synthetic-bundle` + +> bundle for slot 'Modem/2' is marked synthetic:true; a catalog entry requires a real capture (synthetic:false) + +This is a typed refusal from the ingestion seam, not a review opinion. Fix the +capture and re-run the runbook; do not hand-author the artifact around it. + +### Proposed classifier fixture (`control/src/backend/device-classifier.test.ts`) + +> ⚠️ Derived from a **synthetic** bundle — valid as test data, never as certification evidence. + +```ts +const FIXTURE: UsbDeviceSnapshot = { + "vendorId": "2c7c", + "productId": "0125", + "model": "CERALIVE-SYNTHETIC-TEST-SKU", + "firmwareRevision": "SYNTHETICFW01", + "bDeviceClass": 0, + "interfaces": [ + { "interfaceClass": 255, "interfaceSubClass": 0, "interfaceProtocol": 0, "driver": "option" }, + { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255, "driver": "qmi_wwan" } + ], + "udevProperties": { + "ID_PATH": "platform-xhci-hcd.0.auto-usb-0:1.4.4", + "INTERFACE": "wwan0" + }, + "physicalUid": "platform-xhci-hcd.0.auto-usb-0:1.4.4", + "ifname": "wwan0" +}; +``` + +### No checklist + +The catalog entry was refused, so there is nothing to review. A checklist here +would invite a reviewer to approve an artifact that does not exist. +```` + +The fixture half still renders, correctly: a synthetic fixture is usable test data, and its +provenance says exactly what it is. + +### The accepted rendering, for reference + +> **This is a RENDERING ILLUSTRATION, not evidence.** It is the same fabricated bundle with +> its `synthetic` flag flipped to `false`, so a reviewer can see the template they will +> receive. **No such capture exists**, no hardware was involved, and +> `certified-catalog.json` is unchanged by this document. The seam's own test suite +> (`control/src/usb-mode/ingestion.test.ts`) is what proves the accepted path. + +````markdown +### Proposed `certified-catalog.json` entry + +```json +{ + "vidPid": "2c7c:0125", + "model": "CERALIVE-SYNTHETIC-TEST-SKU", + "firmwarePrefix": "SYNTHETICFW01", + "canonicalMode": "qmi", + "permittedTransitions": [] +} +``` + +### Reviewer checklist (every box is a human judgement) + +- [ ] The bundle at `…/bundle.json` was captured by **RB-11** on real hardware, and its `CERTIFY OK` line reads `synthetic=false`. +- [ ] The bundle sha256 in the entry matches the sha256 the capture printed — recomputed, not copied from this comment. +- [ ] `canonicalMode: "qmi"` is the mode the device was actually observed in, not the mode it was expected to be in. +- [ ] `permittedTransitions: []` is correct for this stage — a stage-1 entry never declares a transition. +- [ ] No claim in `docs/MODEM-SUPPORT-MATRIX.md` is being changed by this commit without its own evidence. +```` + +Every box is a human judgement a machine cannot tick. That is why the checklist exists and +why the seam stops one step short of the commit. + +--- + +## Using it + +```ts +import { + buildCatalogEntryCandidate, + buildClassifierFixture, + renderPromotionReview, +} from '@ceralive/modem-control'; + +const request = { bundle: JSON.parse(bundleJson), bundleSha256: shaFromCertifyOkLine }; + +const comment = renderPromotionReview({ + context: { runbook: 'RB-11', evidencePath: 'test-results/modem-phase-b/08/…/bundle.json' }, + entry: buildCatalogEntryCandidate(request, { canonicalMode: 'qmi' }), + fixture: buildClassifierFixture(request), +}); +``` + +Post `comment` on the PR that proposes the catalog change. A refused promotion still renders +a comment — a silently-absent comment is indistinguishable from a forgotten run. From dadbad734cca3b7a9cb275bc2bb1956b615642c9 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 05:33:53 -0500 Subject: [PATCH 06/12] docs(fm350): RB-16 USB probe runbook + gate-ledger update template --- AGENTS.md | 6 +- docs/BENCH.md | 152 ++++++++++++++++++++++++++++++++++++++++- docs/FM350-DECISION.md | 79 +++++++++++++++++++++ 3 files changed, 232 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 972d2f3..39fca74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,10 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse adds one transition carrying its own `evidenceBundleSha256`. - **`canonicalMode` is a reviewer's stated claim, never inferred**; when transition evidence exists the seam cross-checks it against the captured `transition.from`. -- Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 reserved for the - FM350 probe, RB-17 is modem-flap resilience). All are `[PARTIAL]` — four named blockers +- Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 is the FM350 + USB-vs-PCIe probe — its 2026-08-16 bench run found the unit not connected, so + `docs/FM350-DECISION.md`'s three-gate ledger stays OPEN with the probe evidence recorded; + RB-17 is modem-flap resilience). All are `[PARTIAL]` — four named blockers (`usbutils` absent from the board and its archive; the enumerator not populating `ifname`; no AT transport on the bench; an empty real-SKU catalog) are recorded in `docs/BENCH.md` § "Per-SKU certification". No SKU is certified and no matrix row is promoted. diff --git a/docs/BENCH.md b/docs/BENCH.md index 7547033..33ac967 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -1305,8 +1305,154 @@ the six that can be closed on the current image. --- -> **RB-16 is reserved** for the Fibocom FM350 USB probe and its decision-doc gate-ledger -> update. See [`FM350-DECISION.md`](FM350-DECISION.md); the runbook itself is not yet written. +## RB-16 — Fibocom FM350 USB-vs-PCIe probe `[PARTIAL]` + +Determine, from real bus evidence, whether the physical FM350 unit enumerates as a **USB** +device (`0e8d:7126` bootloader / `14c3:4d75` MBIM) — which would trigger the contrary-evidence +**HARD STOP** in [`FM350-DECISION.md`](FM350-DECISION.md)'s mechanical rule — or as the +documented **PCIe** `mtk_t7xx` device (`14c3:4d75` on the PCI bus), or is simply **not +connected** to this bench at all. This runbook never certifies the FM350 and never adds a +matrix row; it only produces the bus-level evidence the decision doc's mechanical rule +consumes. + +> **USB enumeration alone never promotes support/matrix status.** Even in the branch where the +> FM350 enumerates as USB (in scope for the classifier per the mechanical rule), the SKU still +> has to clear the same per-family certification ladder as every RB-11…RB-15 unit (a real +> `certify` bundle, `synthetic:false`) before `docs/MODEM-SUPPORT-MATRIX.md` changes. A bus +> match is bus evidence, not a certification. + +**Preconditions** + +- Bench device reachable over SSH. **No `lsusb` and no `lspci` on the bench image** — the USB + tree comes from the `/sys/bus/usb/devices/*` sweep (RB-9's pattern) and the PCI tree from a + `/sys/bus/pci/devices/*` sweep, neither of which needs `usbutils`/`pciutils`. +- `mmcli` on `PATH`, ModemManager **≥ 1.24.2** (the version floor `FM350-DECISION.md`'s gate 1 + already confirms this repo ships). +- Re-run this probe immediately before trusting a prior capture — hardware on this bench has + moved multiple times this session (`.omo/notepads/modem-stack-phase-b/learnings.md`); do not + assume the unit's absence from an earlier pass still holds. + +**Commands** + +```sh +OUT=test-results/modem-phase-b/09; mkdir -p "$OUT" + +# 1) USB DESCRIPTOR SWEEP — lsusb-equivalent. Expect 0e8d:7126 (bootloader) or 14c3:4d75 +# (MBIM mode) if the unit is USB-enumerating. +{ + for d in /sys/bus/usb/devices/*; do [ -f "$d/idVendor" ] && \ + echo "$(basename "$d"): $(cat "$d/idVendor"):$(cat "$d/idProduct") mfg=\"$(cat "$d/manufacturer" 2>/dev/null)\" product=\"$(cat "$d/product" 2>/dev/null)\""; done + echo "--- FM350 USB candidate match ---" + m=$(for d in /sys/bus/usb/devices/*; do [ -f "$d/idVendor" ] && \ + vp="$(cat "$d/idVendor"):$(cat "$d/idProduct")" && \ + case "$vp" in 0e8d:7126|14c3:4d75) echo "MATCH: $(basename "$d") $vp";; esac; done) + [ -n "$m" ] && echo "$m" || echo "NO MATCHES (checked all usb nodes above)" +} 2>&1 | tee "$OUT/usb-sweep.txt" + +# 2) DRIVER BINDING CHECK — only meaningful if step 1 matched. `cdc_mbim` is the expected +# driver for a USB MBIM personality; this is a DIFFERENT driver family than the documented +# PCIe mtk_t7xx path, so a match here is itself part of the contrary-evidence record. +{ + for d in /sys/bus/usb/devices/*:1.*; do [ -d "$d/driver" ] && \ + echo "$(basename "$d"): driver=$(basename "$(readlink -f "$d/driver")")"; done +} 2>&1 | tee "$OUT/driver-binding.txt" + +# 3) PCIe SWEEP — expect 14c3:4d75 (vendor:device) if the unit is in its native M.2 PCIe +# slot, plus the mtk_t7xx driver bound and the wwan/net subsystems populated +# (FM350-DECISION.md Citation 2). +{ + if [ -d /sys/bus/pci/devices ]; then + for d in /sys/bus/pci/devices/*; do [ -f "$d/vendor" ] && \ + echo "$(basename "$d"): $(cat "$d/vendor"):$(cat "$d/device")"; done + echo "--- FM350 PCI candidate match (14c3:4d75) ---" + m=$(for d in /sys/bus/pci/devices/*; do [ -f "$d/vendor" ] && \ + vd="$(cat "$d/vendor" | sed 's/^0x//'):$(cat "$d/device" | sed 's/^0x//')" && \ + [ "$vd" = "14c3:4d75" ] && echo "MATCH: $(basename "$d") $vd"; done) + [ -n "$m" ] && echo "$m" || echo "NO MATCHES (checked all pci nodes above)" + else + echo "/sys/bus/pci/devices does not exist on this board" + fi + echo "--- wwan class (mtk_t7xx exposes the modem here) ---" + ls /sys/class/wwan/ 2>&1 + echo "--- mtk_t7xx module ---" + lsmod | grep -E '^mtk_t7xx' || echo "mtk_t7xx not loaded" +} 2>&1 | tee "$OUT/pcie-sweep.txt" + +# 4) MM DETECTION — does the packaged ModemManager 1.24.2 mtk plugin claim it on either bus. +mmcli -L 2>&1 | tee "$OUT/mmcli-list.txt" +mmcli --version 2>&1 | tee "$OUT/mm-version.txt" + +# 5) BEARER / DATA-SESSION SMOKE — only runs if step 4 found the FM350's MM index; is +# filled in at capture time from that step's real output, never guessed. +N=${FM350_MM_INDEX:-} +if [ -n "$N" ]; then + mmcli -m "$N" 2>&1 | tee "$OUT/mmcli-dump.txt" + mmcli -m "$N" --simple-connect="apn=internet" 2>&1 | tee "$OUT/bearer-connect.txt" +else + echo "FM350_MM_INDEX unset -- no MM index found for FM350 in step 4, bearer smoke SKIPPED (not simulated)" \ + | tee "$OUT/bearer-connect.txt" +fi + +# 6) PORT-CYCLE RECOVERY — via todo 7's hil-cycle harness, same shape as RB-10 step 4. +# is the ID_PATH found in step 1; hub-map entry is filled in once the unit is physically +# mapped to a hub port. +SLOT=${FM350_ID_PATH:-} +if [ -n "$SLOT" ]; then + sudo ./modem-control hil-cycle "$SLOT" --hub-map "$OUT/hub-map.json" --mm-slot "$N" \ + 2>&1 | tee "$OUT/hil-cycle-fm350.txt" +else + echo "FM350_ID_PATH unset -- unit not enumerated on either bus, hil-cycle SKIPPED (not simulated)" \ + | tee "$OUT/hil-cycle-fm350.txt" +fi +``` + +**Expected output** + +If the unit is USB-enumerating, step 1 prints a `MATCH: 0e8d:7126` or +`MATCH: 14c3:4d75` line. If it is PCIe-only, step 3 prints a `MATCH: 14c3:4d75` +line under the PCI sweep, a non-empty `/sys/class/wwan/` listing, and a `mtk_t7xx` row in +`lsmod`. If neither fires, both sweeps print their full device list with a `NO MATCHES` line +appended, `/sys/class/wwan/` does not exist, and `mtk_t7xx` is reported not loaded — the honest +"not connected" outcome. + +**Machine check** + +```sh +OUT=test-results/modem-phase-b/09 +if grep -q '^MATCH: ' "$OUT/usb-sweep.txt"; then + echo "RB-16 RESULT: USB-observed -- see FM350-DECISION.md Branch A template" +elif grep -q '^MATCH: ' "$OUT/pcie-sweep.txt"; then + echo "RB-16 RESULT: PCIe-only observed -- see FM350-DECISION.md Branch B template" +elif grep -q 'NO MATCHES' "$OUT/usb-sweep.txt" && grep -q 'NO MATCHES' "$OUT/pcie-sweep.txt"; then + echo "RB-16 RESULT: not connected -- ledger stays OPEN, probe evidence recorded" +else + echo "RB-16 FAIL (sweep output malformed -- neither MATCH nor NO MATCHES present)" +fi +``` + +**Live capture, 2026-08-16, `ceralive2` (192.168.78.132, kernel `7.1.7-ceralive-rk3588`, +ModemManager `1.24.2`)** + +The FM350 is **not physically connected to this bench** — neither in a USB adapter nor in a +PCIe M.2 slot. Step 1's USB sweep swept all 15 enumerated USB nodes (three `0bda:*` hubs, the +five MM-managed/router-class modems from RB-9, one Bluetooth radio, eight host controllers) — +zero matched `0e8d:7126` or `14c3:4d75`. Step 2 (driver binding) had nothing to check. Step 3's +PCIe sweep found six real PCI devices (the RK3588 root complex `1d87:3588` ×3, a Realtek +`10ec:b852` and `10ec:8125`, i.e. the board's own WiFi/Ethernet silicon, not a WWAN module) — +zero matched `14c3:4d75`; `/sys/class/wwan/` does not exist; `mtk_t7xx` is not loaded (only the +unrelated Bluetooth `btmtk` module is present, confirmed by name — it is not the WWAN driver). +Step 4: `mmcli -L` shows only the Quectel RM530N-GL and SIMCom SIM7600G-H (`mmcli --version` +confirms the packaged `1.24.2`), consistent with every prior inventory pass this session +(RB-9). Steps 5-6 SKIPPED — no MM index or `ID_PATH` exists to act on, and this is recorded +verbatim in `bearer-connect.txt`/`hil-cycle-fm350.txt` rather than simulated. + +**Status:** `[PARTIAL]` — probe run, unit not present. Per `FM350-DECISION.md`'s own mechanical +rule, no branch fires when there is nothing to classify; the three-gate ledger stays exactly as +recorded (gate 1 CLEARED, gates 2 and 3 OPEN). See `FM350-DECISION.md` § "Bench probe evidence +(RB-16)" and § "Gate-ledger update template" for the fill-in-ready next step. + +**Evidence:** `test-results/modem-phase-b/09/{usb-sweep,driver-binding,pcie-sweep,mmcli-list,mm-version,bearer-connect,hil-cycle-fm350}.txt` +(repo-local, gitignored). --- @@ -1436,7 +1582,7 @@ guessed here; the `${GROUPS_CMD:?…}` guard makes an unfilled run fail closed o | RB-13 | SIMCom SIM7600G-H certification capture (QMI raw-IP) | `[PARTIAL]` | `test-results/modem-phase-b/08/simcom-sim7600g-h/{certify.txt,bundle.json,raw-ip-state.txt,rndis-observation.txt}` | | RB-14 | Huawei personality capture (Stick vs HiLink) | `[PARTIAL]` | `test-results/modem-phase-b/08/huawei/{personality-sweep.txt,id-path-fallback.txt,personality-switch-observation.txt}` | | 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 probe | *(reserved — runbook not yet written)* | — | +| 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}` | Every row stays `[PARTIAL]` until its evidence artifact is captured on a real bench device diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index 02f0925..1742976 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -156,6 +156,85 @@ Gate 1 being CLEARED does **not** imply the FM350 works — it only removes the version-floor obstacle. Gates 2 and 3 remain the blocking, hardware-gated unknowns, and are deliberately left OPEN rather than assumed. +## Bench probe evidence (RB-16) + +On 2026-08-16, [`docs/BENCH.md`](BENCH.md) § RB-16 was run against the live bench board +`ceralive2` (192.168.78.132, kernel `7.1.7-ceralive-rk3588`, packaged ModemManager `1.24.2`). +Full transcript: `test-results/modem-phase-b/09/{usb-sweep,driver-binding,pcie-sweep,mmcli-list,mm-version,bearer-connect,hil-cycle-fm350}.txt` +(repo-local, gitignored); mirrored to `.omo/notepads/modem-stack-phase-b/evidence-todo09-fm350.log`. + +**Result: the FM350 is not physically connected to this bench** — neither in a USB adapter nor +in a PCIe M.2 slot. The USB sysfs sweep covered all 15 enumerated USB nodes and matched neither +`0e8d:7126` nor `14c3:4d75`. The PCIe sysfs sweep (this image has no `lspci`; walked +`/sys/bus/pci/devices/*` instead) found six real PCI devices — the RK3588 root complex +(`1d87:3588` ×3) and the board's own Realtek WiFi/Ethernet silicon (`10ec:b852`, `10ec:8125`) — +none matching `14c3:4d75`; `/sys/class/wwan/` does not exist; the `mtk_t7xx` module is not +loaded (the only MediaTek-named module present, `btmtk`, is Bluetooth, not WWAN, confirmed by +name). `mmcli -L` under the packaged `1.24.2` shows only the Quectel RM530N-GL and SIMCom +SIM7600G-H, consistent with every prior inventory pass this session +(`.omo/notepads/modem-stack-phase-b/learnings.md`, RB-9). + +Per the mechanical rule above, **no branch fired** — there was nothing on either bus to +classify. The three-gate ledger stays exactly as recorded: gate 1 CLEARED, gates 2 and 3 OPEN. +This probe does not close gate 3; it is a documented non-event, recorded so a future reader +does not have to re-derive "was the unit ever actually checked on this bench." + +## Gate-ledger update template — fill in on the next bench run that captures real FM350 data + +The mechanical rule above is unambiguous once real bus data exists. Whoever next runs RB-16 +against a physically connected unit MUST resolve to exactly ONE of the two branches below, fill +in the bracketed evidence, and land it as the update to the three-gate ledger table — never +invent a third outcome, and never promote a matrix/certification claim from this probe alone. + +### Branch A — USB VID:PID observed (`0e8d:7126` or `14c3:4d75` on the USB bus) + +This is the mechanical rule's **branch-1 contrary-evidence HARD STOP** (line ~27 above): a USB +VID:PID for the FM350 would contradict Citations 1-4's PCIe-only finding from the verified MM +1.24.2 source. **Do not silently update the classifier** — this is exactly the case the rule +says to stop and surface to a human. + +1. STOP. Do not add a classifier entry, branch, or fixture without human sign-off, even though + the mechanical rule literally reads "in scope for the USB-only classifier." +2. Record the exact `[VID:PID]`, the sysfs node, and the driver binding observed + (`[driver name]` — `cdc_mbim` is the expected driver family for a USB MBIM personality, a + **different** driver family than the documented PCIe `mtk_t7xx` path) as a new Citation 6 + here, with the RB-16 evidence bundle path `[test-results/modem-phase-b/09/...]`. +3. Surface to a human, verbatim: "FM350 unexpectedly enumerated as a USB device — contradicts + MM 1.24.2 source Citations 1-4. Human decision required before any classifier change." +4. Ledger update, ONLY after human sign-off to proceed: gate 2 (kernel) → `N/A (USB path + observed, not PCIe)`; gate 3 (HIL) → `CLEARED`, basis = the RB-16 evidence bundle path. Add + the real classifier fixture (`device-classifier.test.ts`-shape) for the observed descriptor. +5. **USB enumeration alone does NOT promote any support/matrix/certification status** (per the + plan's Metis a5 finding) — `docs/MODEM-SUPPORT-MATRIX.md` stays unchanged until the unit + clears the SAME per-SKU certification ladder every other USB modem does (a real `certify` + bundle, `synthetic:false` for the exact SKU+firmware). + +### Branch B — PCIe-only observed (`14c3:4d75` on the PCI bus, `mtk_t7xx` driver bound, no USB VID:PID) + +This CONFIRMS the mechanical rule's already-fired branch 3 (documented-deferred) with real +hardware evidence for the first time — the deferral decision itself does not change; it moves +from "source-verified" to "source-verified AND bench-confirmed." + +1. Record the exact `[PCI vendor:device]`, `[wwan interface name]`, and `[mtk_t7xx module + version/load status]` as a new Citation 6 here, with the RB-16 evidence bundle path + `[test-results/modem-phase-b/09/...]`. +2. Ledger update: gate 2 (kernel) → `CLEARED`, basis = "`mtk_t7xx` observed bound and + enumerating the module over PCIe on bench `[board id]`, RB-16 evidence `[path]`." Gate 3 + (HIL) → `CLEARED` only if the RB-16 bearer/data-session smoke (step 5) also succeeded end to + end; otherwise gate 3 stays `OPEN` with the partial evidence noted explicitly (kernel-level + PCIe enumeration alone is not an end-to-end HIL pass). +3. **No classifier entry is added** — the FM350 remains correctly out of scope for the + USB-only classifier (see "No USB classifier entry — and why" below); PCIe modems are modeled + elsewhere per "If this is revisited." +4. `docs/MODEM-SUPPORT-MATRIX.md` is NOT updated by this alone — gate closure is a prerequisite + for a future support decision, not the decision itself. + +### If the unit is still not connected + +No branch fires. Append to "Bench probe evidence" above (date, board, what was swept, zero +matches) and leave the ledger untouched — this is the honest, expected outcome until physical +FM350 hardware exists on a reachable bench. + ## No USB classifier entry — and why **No USB classifier entry exists or is added for the FM350.** `control/src/backend/device-classifier.ts` From 17c67854305d47ba2498e0250bdcde765c6cfb61 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 05:38:17 -0500 Subject: [PATCH 07/12] docs(esim): investigate-only eSIM decision record (removable-eUICC path, transfer impossibility, validation checklist) --- docs/ESIM-DECISION.md | 327 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 docs/ESIM-DECISION.md diff --git a/docs/ESIM-DECISION.md b/docs/ESIM-DECISION.md new file mode 100644 index 0000000..7c32a96 --- /dev/null +++ b/docs/ESIM-DECISION.md @@ -0,0 +1,327 @@ +# eSIM Decision Record (Investigate-Only) + +**Status:** Investigation complete. Implementation explicitly deferred by user decision, 2026-08-13. +This document is the exit artifact for that investigation — see §8. + +## Why this document exists + +modem-stack ships six physical modems on the bench: Quectel RM530N-GL, SIMCom SIM7600G-H, +and several Huawei/ZTE USB dongles, plus the still-unconnected Sierra FM350. eSIM +("embedded SIM," an eUICC — embedded Universal Integrated Circuit Card — that holds one or +more remotely-provisioned network profiles) came up as a plausible operator feature: can a +CeraLive device let an operator load, switch, or transfer a cellular profile without a +physical SIM swap? This record answers that question with primary sources, states clearly +what is and is not possible on Linux today, and stops there. It does not propose an +implementation. + +--- + +## 1. SGP.22 profile-binding proof — a profile cannot be copied from a phone or a physical SIM + +The GSMA's Remote SIM Provisioning (RSP) architecture for consumer devices is specified in +[SGP.21 (Architecture)](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2022/03/SGP.21-v3.0.pdf) +and [SGP.22 (Technical Specification)](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2026/04/SGP.22-v2.7-.pdf). +The mechanism that makes an eSIM profile non-portable is the **Bound Profile Package (BPP)**: + +- Every eUICC has a unique **EID** (eUICC Identifier), a 32-digit hardware serial number + established and cryptographically anchored at manufacturing time (SGP.22 §2.4, §2.5.4; + summarized well in the [eSeye eUICC overview](https://docs.eseye.com/Content/GettingStarted/eSIM/Euicc.htm) + and the [Ambimat RSP provisioning writeup](https://esim.ambimat.com/blogs/esim-euicc-rsp-provisioning-architecture.html)). +- The operator's back end (**SM-DP+**, Subscription Manager Data Preparation Plus) prepares + a Profile Package and, in the *binding* step, encrypts it specifically against the target + eUICC's public key and its EID (SGP.22 §2.5.4 "Bound Profile Package," full TLV structure + given in the spec). The result — the BPP — is cryptographically linked to **one** eUICC. + A BPP bound to EID-A cannot be installed on EID-B; the eUICC on the other end will reject + the key-agreement handshake. +- The eUICC's private key never leaves the chip. Decryption of the profile package happens + **inside** the eUICC; "the decrypted profile content never leaves the chip" + (Ambimat, citing SGP.22 §2.6 Security Overview). +- There is no reverse path defined anywhere in SGP.21/22 to read an *installed* profile back + out of an eUICC in a portable form. The Local Profile Assistant (LPA) can list, enable, + disable, delete, and rename profiles (SGP.22 §2.4.9, "LPA Services") — it cannot export one. + +**Conclusion:** "copy the eSIM profile off my phone onto a modem" is not a permissions +problem or a missing-tool problem — it is cryptographically impossible under SGP.22 as +specified. Any product surface implying "clone your phone's eSIM" would be describing +something that does not exist in the standard. + +--- + +## 2. Phone-to-phone transfer flows are carrier+OS-gated, not Linux-invokable + +Both major mobile OSes ship consumer transfer flows, and both route through the carrier, +never through a raw device-to-device protocol a third party (like a Linux modem host) could +invoke: + +- **Apple "eSIM Quick Transfer"** moves a profile between two iPhones on carriers that + support it (US: AT&T, FirstNet, T-Mobile, Verizon; and per-country lists for Japan, + Germany, France, UK) — [Apple support: Set up eSIM on iPhone](https://support.apple.com/en-us/118669), + [Apple support: Transfer an eSIM to a new iPhone](https://support.apple.com/en-us/126058). + It requires a same-account/paired-device flow and explicit carrier support; Apple's own + copy says "if your carrier supports it." +- **Android's eSIM Transfer** ("Transfer SIM from another device") uses a documented + device-to-device (D2D) protocol built on **GSMA TS.43** entitlement-server calls + (`ManageSubscription(TRANSFER)` / `ManageSubscription(UPDATE SUBSCRIPTION)`), requires both + devices paired over Bluetooth+Wi-Fi during first-run setup, a screen-lock-verified source + device, and **carrier-side ES (Entitlement Server) support** — + [Android Open Source Project: eSIM Transfer carrier integration](https://source.android.com/docs/core/connect/esim-transfer-carrier-integration). + The temporary carrier token used in the handshake is stored in an OS keystore-encrypted + block store; "no API is exposed to access [it] from other first-party and third-party + apps." +- Cross-platform (iOS 26 ↔ Android): as of iOS 26.3, Apple ships a "Transfer to Android" + flow that can move an eSIM as part of a broader migration, but it's a **paired-device, + same-Wi-Fi, QR-paired session between the two phones' own OS transfer stacks** — not a + protocol exposed to third-party hosts — + [AndroidPolice: Latest iPhone update makes switching to Android easier](https://www.androidpolice.com/latest-iphone-update-makes-switching-to-android-easier/). + +**Conclusion:** every consumer transfer path is (a) carrier-gated — the carrier's ES/SM-DP+ +has to agree to move the subscription — and (b) OS-gated — it's a proprietary handshake +between two phone OS stacks with no published API for a third device (like a Linux-based +modem host) to participate in. A CeraLive device cannot "answer" or "initiate" a phone's +Quick Transfer or Android eSIM Transfer flow; there is no wire protocol document for it to +implement, and even if there were, it would still require carrier-side ES cooperation that +has nothing to do with our stack. + +**SGP.22 v3.x "Device Change" is the standards-track answer to the same problem — and it's +future, not retrofittable onto our hardware.** SGP.22 v3.0 +([GSMA SGP.22 v3.0 landing page](https://www.gsma.com/esim/resources/sgp-22-v3-0/), +[SGP.21 v3.0 architecture PDF](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2022/03/SGP.21-v3.0.pdf)) +introduced a standardized **Device Change** feature (§4.18, Annex O; use case in Annex K.1) +letting a device conduct the operator handshake on the user's behalf, without a manual +carrier-support call. But: + +- It's an *optional* feature (`#SupportedForDcV3.0.0#` per the v3.0 spec's feature-support + tagging) — an eUICC and LPA must both explicitly implement it. +- GlobalPlatform's compliance program did not even certify SGP.22 v3.0-conformant products; + certification only exists from **v3.1** onward + ([GlobalPlatform Ops Bulletin 33: eUICC Consumer SGP.22 v3.x Qualification](https://globalplatform.org/wp-content/uploads/2023/04/GP-Ops-Bulletin33-eUICC-Consumer-v3.x-Qualification.pdf)). +- None of our bench eUICCs (see §6) advertise SGP.22 v3.x support, and `lpac` — the only + open-source LPA available to us (§5) — targets **SGP.22 v2.2.2** (its own README states + this explicitly: + [estkme-group/lpac README](https://github.com/estkme-group/lpac?tab=readme-ov-file)), not + v3.x. There is no open-source LPA implementation of Device Change as of this writing. + +**Conclusion:** Device Change is a real, standardized answer to "move my subscription +between devices without calling the carrier" — but it needs a v3.x-capable eUICC, a v3.x- +capable LPA, and carrier SM-DP+ support for the feature, none of which exist in our current +toolchain or hardware set. It is a future horizon, not something retrofittable today. + +--- + +## 3. Physical-SIM-to-eSIM is a carrier reissue, not a local conversion + +"Convert my physical SIM to an eSIM" is likewise gated by the carrier, not by anything on +the device. Apple's own documentation frames it as a carrier operation: "If your carrier +supports it, you can convert your physical SIM to an eSIM on the same iPhone" ( +[Apple support: Set up eSIM on iPhone](https://support.apple.com/en-us/118669)). Under the +hood this is not a "conversion" at all in the cryptographic sense of §1 — the carrier's +back end deactivates the physical SIM's subscription and issues a brand-new eSIM profile +bound (per §1) to the target device's EID via the normal SM-DP+ profile-download flow. There +is no on-device or on-modem operation that turns a physical SIM's contents into an eUICC +profile; the physical SIM's ICCID/IMSI pair is simply retired and a new profile is +provisioned. + +**Conclusion:** for our stack, this path is identical in shape to any other new-eSIM +activation (§4) — it requires the carrier to issue a fresh SM-DP+-bound profile. Nothing +about "physical SIM" as the source changes the mechanics from the LPA's point of view. + +--- + +## 4. Workable paths — what CAN actually get an eUICC profile onto a CeraLive board + +Given §1–3 rule out "clone/transfer from a phone," the paths that are cryptographically and +operationally real are: + +1. **Removable eUICC.** A profile lives on a physical, swappable eUICC card (a "MFF2" or + plastic-form-factor eUICC, sometimes called a "programmable SIM" from vendors like + [eSTK.me](https://docs.estk.me/manual/download/lpac/index.html) or ST4SIM). The profile + travels with the physical card exactly like a traditional SIM's IMSI does — moving the + card moves the profile. This is the only path where "moving a modem to a different board" + preserves the subscription without any network operation at all. +2. **Carrier reissue / QR activation targeted at the board's own EID.** The board's eUICC + has its own EID (§1); the operator issues a *new* profile bound to that EID, delivered as + an activation code (an `LPA:1$$` string, typically QR-encoded) + that the LPA on the board redeems against the carrier's SM-DP+ + ([lpac USAGE.md, `profile download`](https://github.com/estkme-group/lpac/blob/main/docs/USAGE.md)). + This is the normal, standards-conformant activation flow for any eUICC device and is the + path every eSIM-capable phone uses today. +3. **Multiple-profile remote switching (post-activation).** Once one or more profiles are + already installed on the board's own eUICC (via path 2), an operator or the local LPA can + `enable`/`disable` between them without a new network round-trip + ([lpac USAGE.md — profile enable/disable](https://github.com/estkme-group/lpac/blob/main/docs/USAGE.md)). + This is local profile *management*, not profile *transfer* — no new binding occurs. + +**Conclusion:** the honest menu is "swap a removable eUICC card" or "have the carrier issue +a profile to this board's own EID," optionally followed by "switch between profiles already +resident on the board." There is no fourth path that avoids carrier involvement. + +--- + +## 5. `lpac` assessment — the only viable open-source LPA, and its real limits + +[`estkme-group/lpac`](https://github.com/estkme-group/lpac) ("C-based eUICC LPA") is the de +facto open-source Linux LPA. Assessment against our bench: + +### Transports (APDU backends) + +Per [`docs/ENVVARS.md`](https://github.com/estkme-group/lpac/blob/main/docs/ENVVARS.md) and +the [Driver Architecture DeepWiki page](https://deepwiki.com/estkme-group/lpac/5-driver-architecture), +`lpac` supports these APDU (eUICC command) backends, selected via `$LPAC_APDU`: + +| Backend | Purpose | Linux availability | +|---|---|---| +| `pcsc` | PC/SC smartcard reader (external eUICC card reader) | default, all platforms | +| `at` | AT `+CCHO`/`+CCHC`/`+CGLA` (managed logical channel) | Linux only, opt-in build flag `LPAC_WITH_APDU_AT` | +| `at_csim` | AT `+CSIM` (unmanaged APDU passthrough) | Linux only, same build flag | +| `qmi` | Direct QMI character device | Qualcomm modems, opt-in | +| `qmi_qrtr` | QMI over QRTR (PCIe/embedded Qualcomm) | Qualcomm modems, opt-in | +| `uqmi` | OpenWrt `uqmi` CLI wrapper | OpenWrt-specific | +| `mbim` | MBIM interface | opt-in | +| `stdio` | Manual/scripted APDU injection | debug only, no `main` support | + +The HTTP (SM-DP+ transport) side offers `curl` (default) or `stdio`. + +### AT backend — "FOR DEMO PURPOSES ONLY," 300 ms caveat + +`lpac`'s own AT-backend documentation is explicit and unambiguous +([`docs/backends/at.md`](https://raw.githubusercontent.com/estkme-group/lpac/main/docs/backends/at.md)): + +> **FOR DEMO PURPOSES ONLY.** +> Only requests that strictly follow the ETSI TS 127 007 specification are supported. +> Requests outside the specification will be REJECTED. +> Some operations (e.g: download, delete, etc.), may fail due to insufficient response +> time. The Maximum Response Time is typically 300ms, which is insufficient for many +> eUICC operations. + +The 300 ms ceiling comes from ETSI TS 127 007's `+CCHO`/`+CGLA` timing model, which was +never designed for eUICC-scale operations (profile download/binding routinely exceeds this). +There is also a known **protocol-conformance bug**: `lpac`'s `at` backend historically +misparsed the `+CCHO` "handle" as a logical-channel number per the strict TS 27.007 reading, +which breaks on modems that implement the spec correctly (Sierra `MC7455`/`WP7611`) while +working by accident on modems that don't (Quectel `EC25`) — +[lpac issue #138, "AT command APDU driver violates 3GPP TS 27.007"](https://github.com/estkme-group/lpac/issues/138). +The `at` backend was undergoing a rewrite (PR #284) as of that thread. + +### ModemManager coordination requirement + +Multiple independent reports confirm the `at`/`at_csim` backends cannot share the serial +port with ModemManager while ModemManager is actively managing the device: "I can't seem to +use the at devices while modem manager is running, so modem manager needs to be stopped in +order for lpac to be able to work" — +[lpac issue #73](https://github.com/estkme-group/lpac/issues/73). ModemManager's own +`--inhibit`/`--inhibit-device` mechanism exists precisely to release a device from +ModemManager's control for exactly this kind of out-of-band tool use — the `mmcli` man page +states: "When a device is inhibited via this method, ModemManager will disable the modem +… and will no longer use it until it is uninhibited" — +[`mmcli` man page](https://manpages.ubuntu.com/manpages/focal/man1/mmcli.1.html). This is +the correct primitive (`mmcli -I ` / `Modem.Inhibit`), not stopping the ModemManager +daemon outright — but either way, **any `lpac` invocation against a UIM-managed modem must +first serialize with ModemManager's ownership of the SIM/UIM APDU channel**, or the two will +race for the port. The `qmi`/`qmi_qrtr` backends fare better here — per +[lpac issue #94](https://github.com/estkme-group/lpac/issues/94), MBIM in particular "requires +no updates to ModemManager… you don't need to stop ModemManager to use MBIM with lpac, both +can communicate with the modem at the same time" — but QMI/MBIM coexistence with an +*actively connected* bearer is a separate, unverified claim from QMI/MBIM coexistence with +an *idle* ModemManager instance, and neither has been tested against our stack's actor +model. + +### AGPL-3.0 packaging constraint + +`lpac`'s own [`REUSE.toml`](https://raw.githubusercontent.com/estkme-group/lpac/main/REUSE.toml) +license manifest is explicit about a **split license**: + +``` +src/**, driver/**, utils/** → AGPL-3.0-only +euicc/** → LGPL-2.1-only OR LicenseRef-ESTKME-Commercial +docs/**, cmake/**, etc. → MIT +``` + +The bulk of the program logic (`src/`, `driver/`, `utils/`) is **AGPL-3.0-only**. AGPL-3.0 is +a network-copyleft license: linking it (statically or dynamically) into another program +requires that program's *entire* source, including anything communicating with it over a +network, to also be released under AGPL-3.0-compatible terms. Given `cerastream`, +`CeraUI`, and the rest of the CeraLive stack are not AGPL-licensed, `lpac` **must remain a +separately-spawned external binary invoked via subprocess/CLI**, never linked into or +statically embedded in any CeraLive process. This is the same boundary already drawn for +other GPL-family tools consumed as external processes elsewhere in the stack, and it must be +preserved for any future eSIM work. + +--- + +## 6. Per-modem eUICC reality table + +Claim status vocabulary, per the plan: **verified** (primary-source documentation or direct +hands-on confirmation exists), **conditional** (documented as SKU/firmware-dependent, not +universal for the part number), **unknown** (no primary source found either way). + +| Modem | On this bench? | eUICC claim status | Evidence | +|---|---|---|---| +| Sierra Wireless EM75xx (EM7565/EM7590/EM7595 family) | No (reference case) | **verified** (SKU-conditional) | EM7565 product page markets "Consumer and M2M eUICC" as a feature — [Sierra EM7565 product page](https://www.sierrawireless.com/iot-modules/4g-modules/em7565/) — but a Sierra forum thread on the *same part* states the eUICC "was never enacted" on some SKUs and that documentation across distributors is unreliable — [Sierra forum: EM7565 eUICC sku](https://forum.sierrawireless.com/t/em7565-euicc-sku/24816). Multiple independent bench reports (EM7590, EM7595, EM7511 family) show *some* units successfully listing/downloading eUICC profiles via `lpac`'s `at`/`at_csim` backend after vendor-specific `AT!CUSTOM="SIMLPA",0` unlock steps — [Sierra EM7590/EM7595 AT+CGLA thread](https://forum.sierrawireless.com/t/at-cgla-not-supported-on-em7590-and-at-ccho-returns-error/32595) — while others report "SIM failure" and no onboard eUICC at all on a nominally-eSIM SKU — [Sierra EM7595 provisioning thread](https://forum.sierrawireless.com/t/em7595-esim-provisioning/35746). **Net: this is the best-documented family in the ecosystem, but "eUICC present and lpac-reachable" is a per-SKU/per-firmware fact, not a blanket EM75xx guarantee.** | +| Quectel RM520N-GL / RM530N-GL (5G family, shares AT+QESIM command set) | RM530N-GL is physically present on the bench | **conditional** | Quectel's proprietary `AT+QESIM` command set exists (`lpa_enable`, `profile_brief`, `profile_detail`, `eid`, `enable_profile`, `disable_profile`, `delete_profile`, `nickname`, `def_svr_addr`) — confirmed live on forum threads for the RM520N-GL sibling part — [Quectel forum: RM520N-GL eSIM Management](https://forums.quectel.com/t/quectel-rm520n-gl-esim-management/47913), [Quectel forum: RM520N-GL eSIM adding profile](https://forums.quectel.com/t/rm520n-gl-esim-adding-profile/49509). Quectel support staff explicitly state: **"module by itself doesn't have eSIM. It should be physical eSIM installed in SIM slot or soldered MFF2 SIM. You should install eSIM on your board, module don't have eSIM, just support the feature"** — [Quectel forum: RM520N-GL eSIM support](https://forums.quectel.com/t/quectel-rm520n-gl-esim-support/48053). Beyond `lpa_enable`/`eid`, most other `AT+QESIM` subcommands (`profile_brief`, `profile_detail`, `def_svr_addr`) return `ERROR` for multiple reporters even with `lpa_enable` set and a valid EID read back, and Quectel support gates the actual profile-add tool (`quectel_lpad`) behind a private, non-forum distribution channel ("we can't share in forum, please contact with your supplier"). **Net: the AT command surface exists and is documented (RM530N-GL ships the same 5G-family firmware/command set as RM520N-GL per Quectel's own module family documentation), the EID-read half works, but the profile-download half is firmware/tooling-gated and unverified on our actual RM530N-GL unit.** | +| SIMCom SIM7600G-H | Physically present on the bench | **unknown, leaning no eUICC AT surface documented** | The official SIM7500/SIM7600 AT Command Manual (v1.10/v1.12, both fetched and checked section-by-section) has **no `AT+EID`, no `AT+QESIM`-equivalent, and no eUICC/eSIM profile-management command group** anywhere in its table of contents — [SIM7500_SIM7600 AT Command Manual v1.10](https://simcom.ee/documents/SIM7600E/SIM7500_SIM7600%20Series_AT%20Command%20Manual%20_V1.10.pdf). A field report on a physical ST4SIM eUICC inserted into a SIM7600 gets `+CME ERROR: SIM not inserted` when querying ICCID, and the reporter notes "Simcomm don't explicitly state that the module supports eSIM, unlike their other modules like the SIM7000 and SIM7070" — [STMicroelectronics community: How to activate an e-SIM on SIM7600](https://community.st.com/t5/interface-and-connectivity-ics/how-to-activate-an-e-sim-on-sim7600/td-p/653404). **Net: no vendor documentation supports an eUICC-management AT surface on this part; treat as no on-device eUICC path until contradicted by a primary source.** | +| Huawei / ZTE USB dongles (router/HiLink-mode devices) | Physically present on the bench | **verified — no eUICC/lpac path** | These are router-mode (HiLink/CPE-style) USB sticks that present a NAT'd LAN/HTTP management surface to the host rather than a raw AT/QMI/MBIM control port — the same reason they only expose an HTTP web UI (`192.168.0.1`/`192.168.8.1`, see `docs/BENCH.md` RB-15) instead of `mmcli`-visible modem objects. `lpac`'s APDU backends all require either a PC/SC reader, a raw AT serial port, or a raw QMI/MBIM character device — none of which router-mode dongles expose to the host. **Net: router-mode USB dongles are categorically out of scope for any local LPA regardless of whether the internal baseband chip has an eUICC, because the host never gets a control channel to it.** | +| Sierra FM350 (M.2, USB or PCIe) | **Not physically connected** as of the 2026-08-16 RB-16 probe (`docs/FM350-DECISION.md`) | **unknown** | No bench evidence exists; no primary-source AT/eUICC documentation was located for this part in this investigation. Not assessed further — the part isn't present to test even if it were the priority. | + +**Cross-cutting observation:** across every documented case, the actual failure mode is +almost never "no eUICC silicon exists." It's one of: (a) the SKU variant lacks the eUICC +option entirely (EM7565/EM7595 both have this exact ambiguity), (b) a vendor-proprietary +lock (Sierra's `SIMLPA` custom flag, Quectel's undocumented `lpa_enable` gate) has to be +found and disabled first, or (c) the modem's *management mode* (HiLink/router mode) removes +host-level AT/QMI access entirely, independent of what silicon is inside. None of these are +resolvable from documentation alone — each requires the validation checklist in §7 run +against the specific physical unit. + +--- + +## 7. Pre-adoption validation checklist + +Before any eSIM work is scoped for real implementation, run this checklist against the +specific physical unit in question. Every step should be run **twice** — once with +ModemManager running (to observe the coordination failure mode from §5, if any), and once +with the device `mmcli --inhibit`-released or with ModemManager stopped (to observe the +clean-path behavior). A unit only clears this checklist if the AT/QMI/MBIM path is +reachable, an EID reads back, and a profile survives a reboot. + +1. **EID read.** `lpac chip info` (or vendor AT equivalent, e.g. `AT+QESIM="eid"`) returns + a 32-digit EID. If this fails outright, there is no reachable eUICC on this unit/SKU — + stop here, the part does not clear regardless of datasheet claims. +2. **Disposable profile download.** Download a throwaway test profile from a known-good + test SM-DP+ (e.g. a `lpac profile download -a` against a vendor test/demo activation + code, per `docs/USAGE.md`). Record the exact APDU backend and timing that succeeded or + failed, including any 300 ms-related failures from §5. +3. **Enable.** `lpac profile enable ` and confirm the profile shows `enabled` in + `lpac profile list`. +4. **Reboot persistence.** Power-cycle the board (not just the modem) and re-run `lpac + profile list` — confirm the profile is still present and still `enabled` after a full + reboot, not just a warm modem reset. +5. **Delete.** `lpac profile delete ` and confirm it no longer appears in `lpac + profile list`. +6. **Notification.** Confirm the SM-DP+ notification queue clears correctly (`lpac + notification list` / `lpac notification remove`) — an unsent enable/delete notification + left pending against a real carrier SM-DP+ can cause billing or provisioning-state drift + on their side, so this step is not optional even for a disposable test profile. +7. **Both MM states.** Run steps 1–6 once with ModemManager actively managing the device + and once with it inhibited/stopped, and record which backend (`at`, `at_csim`, `qmi`, + `mbim`) worked in which state. This is the concrete evidence a future implementation + would need to decide the coordination model (mmcli inhibit vs. a dedicated actor lock) + instead of guessing at it. + +No unit on this bench has been run through this checklist. §6's table entries are all +literature-sourced, not bench-validated. + +--- + +## 8. Implementation is deferred — this document is the exit artifact + +**This investigation was scoped, and remains scoped, as investigate-only.** The user made +the call on 2026-08-13 to defer eSIM implementation entirely for this phase — no lpac +packaging, no APDU/UIM integration code, no eSIM profile mutation, no QR activation flow, +and no eSIM-manager UI beyond the read-only `EsimInfo` display (`sim_type`/`esim_status`) +that CeraUI already surfaces elsewhere in this effort. That decision stands. This document +records what is now known — SGP.22's binding guarantees, the transfer-flow dead ends, the +`lpac` tool's real capabilities and constraints, the per-modem hardware reality, and the +validation checklist a real implementation would need to run first — so that if and when +eSIM work is picked back up, it starts from verified findings instead of re-deriving them. + +Nothing in this document should be read as a task list, a roadmap, or a set of "next steps +to build." It is a closed research record. From ed1e571b6f397e20789956e7cd22f560d90d9bcc Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 05:43:27 -0500 Subject: [PATCH 08/12] docs: Phase-B adoption sweep + POLICY boundary amendment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the uhubctl PowerHook (recovery-ladder rung 4), the hil-cycle HIL harness, the full RB-9..RB-17 bench-runbook ladder, and the eSIM investigate-only decision doc in AGENTS.md/README.md. Amend POLICY.md §4 so the Phase-A scope boundary reads as adoption-versioned: integration into CeraUI/device-image/apt is authorized starting at the v1.0.0 tag rather than blanket-forbidden, while preserving the accurate historical record that every release through v0.2.0 was Phase-A-only. Mirror the same amendment in README.md and packaging/README.md so no stale 'Phase A: no integration' claim survives outside POLICY.md's historical section. --- AGENTS.md | 53 ++++++++++++++++++++++++++++++++++++++++++--- POLICY.md | 22 +++++++++++++++---- README.md | 17 ++++++++++----- packaging/README.md | 3 ++- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39fca74..d3112a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,10 @@ # modem-stack — AI routing & repo contract Cellular modem control for CeraLive: a control library, a bench CLI, and ModemManager-stack -`.deb` packaging. **Phase A** — iterated standalone, no product wiring. +`.deb` packaging. Through `v0.2.0` this repository was **Phase A** — iterated standalone, no +product wiring. **Phase B adoption (CeraUI / device-image / apt integration) is authorized +starting at the `v1.0.0` release tag** — see `POLICY.md` §4. Each downstream integration +remains its own explicit, reviewed change in the receiving repository. Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE/modem-stack`. @@ -9,7 +12,7 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE | Directory | Artifact | Role | |-----------|----------|------| -| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler. Published to public npm under `@ceralive`. | +| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (see § below), USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler. Published to public npm under `@ceralive`. | | `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | | `packaging/` | ModemManager stack `.deb`s | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`). Bench installs from CI artifacts. | @@ -74,6 +77,34 @@ arch-dependent stanzas + enumerated `-dbgsym`) for exact per-source set **equali `packaging/ci/check-package-sets.sh` (add/remove/rename fails closed). Full detail: `packaging/README.md`. +## RECOVERY LADDER — uhubctl POWER HOOK (rung 4) + +`control/src/backend/uhubctl-power-hook.ts` (`createUhubctlPowerHook`) is the first real +`PowerHook` implementation: the `usb-hub-port-cycle` capability backing recovery-ladder +rung 4. It cuts VBUS on one port of a per-port-power-switching (PPPS) USB hub via `uhubctl` +and reports `applied` only once the SAME modem (by udev `ID_PATH`) is observed back on the +bus — a zero exit from `uhubctl` is never treated as success on its own. + +- **Config-mapped, never discovered.** A stable key is cyclable only if an operator wrote + it into an explicit, Zod-validated port-map file: `{ [stableKey]: { hubLocation: string, + port: number } }`. There is no default path and no probing/guessing — `hubLocation` is + regex-pinned to the sysfs bus-port shape so a shell-metacharacter or flag cannot parse + into it. +- **Argv-only, allowlisted, no shell.** The command is built as an argv array + (`['-l', loc, '-p', port, '-a', 'cycle', '-d', '3']`) and every emitted token is + re-checked against an allowlist before the injected runner is called. +- **Bounded + cancellable** (`commandTimeoutMs` + `enumerationTimeoutMs`, plus an optional + `AbortSignal`); **serialised per modem** through the shared `ModemActor`, keyed on the + stable key, so two overlapping cycles on one port cannot interleave power-on/power-off. +- **Disabled by default**, matching the existing recovery-ladder default + (`RECOVERY_DISABLED: { enabled: false }` in `control/src/domain/policy.ts`) — this hook + does not change that default; it is only reachable when an operator opts in. +- The HIL harness (`cli hil-cycle --hub-map `, bench runbook + [`docs/BENCH.md` RB-10](docs/BENCH.md)) orchestrates a full cycle end-to-end: pre-state + capture → PowerHook cycle → USB-disappearance assertion → re-enumeration assertion → MM + re-detection of the same `modem.generic.device` slot UID. See `cli/README.md` for the + exact CLI contract and typed failure reasons. + ## CERTIFICATION EVIDENCE → CATALOG (evidence-gated, human-reviewed) A SKU reaches `control/src/usb-mode/certified-catalog.json` only through a captured @@ -96,12 +127,28 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse (`usbutils` absent from the board and its archive; the enumerator not populating `ifname`; no AT transport on the bench; an empty real-SKU catalog) are recorded in `docs/BENCH.md` § "Per-SKU certification". No SKU is certified and no matrix row is promoted. +- The full bench-runbook ladder, RB-1 through RB-17, 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. + +## eSIM (investigate-only, implementation deferred) + +`docs/ESIM-DECISION.md` records the full eSIM investigation: SGP.22 profile-binding makes +cross-device profile "copying" cryptographically impossible; the workable paths are +removable eUICC, carrier reissue, or multi-profile remote switching; `lpac` (external LPA) +is assessed but not adopted (AGPL-3.0 core, AT backend is demo-only, needs MM +inhibit-coordination). Implementation is **deferred by user decision (2026-08-13)** — this +doc is the exit artifact, not a task list. No eSIM code exists in this repository. ## POLICY `packaging/` is a **no-fork** effort: the first release carries zero quilt patches; adding a patch later is an architecture gate (rationale + filed upstream MR + review); -udev/plugin/device-support improvements go **upstream first**. Full terms: `POLICY.md`. +udev/plugin/device-support improvements go **upstream first** — this binds permanently, +independent of phase. The Phase A → Phase B scope boundary is **version-gated at +`v1.0.0`**: CeraUI / device-image / apt integration is out of scope through `v0.2.0` and +authorized from the `v1.0.0` tag forward. Full terms: `POLICY.md` §4. The Fibocom **FM350** modem (PCIe / `mtk_t7xx`) is documented-**deferred**, not supported — rationale, source cites, and the open gates are recorded in `docs/FM350-DECISION.md`. diff --git a/POLICY.md b/POLICY.md index 6d2b127..1caaea4 100644 --- a/POLICY.md +++ b/POLICY.md @@ -44,8 +44,22 @@ a step away from `apt`-clean rebuilds. Keeping the packaging patch-free — and fixes upstream — is what lets the bench track current ModemManager (1.24 and beyond) without inheriting a fork's maintenance debt. -## 4. Scope reminder (Phase A) +## 4. Scope boundary (Phase A → Phase B, version-gated at v1.0.0) -This policy governs Phase A only: standalone iteration, bench installs from CI artifacts, -no product wiring. It does not authorize any change to CeraUI, the device image, or the -apt distribution. Those are out of scope until Phase B is explicitly triggered. +Sections 1–3 (no-fork gate, upstream-contribution-first) bind this repository permanently +and are unaffected by phase. + +**Through the `0.x` line — including this repository's releases up to and including +`v0.2.0` — this repository was Phase A only:** standalone iteration, bench installs from +CI artifacts, no product wiring. That scope did not authorize any change to CeraUI, the +device image, or the apt distribution. + +**Phase B adoption is authorized starting at the `v1.0.0` release tag, not before.** From +that tag forward, this repository's artifacts — `@ceralive/modem-control` and the packaged +ModemManager-stack `.deb`s — may be integrated into CeraUI, the device image, and the apt +distribution. `v1.0.0` is the version gate, not an integration itself: it marks the control +library's public API and the packaging contract stable enough to build on. Each downstream +integration (CeraUI adopting the npm package, `image-building-pipeline` installing the +`.deb`s, `apt-worker` serving them) remains its own explicit, reviewed change in the +receiving repository — this section lifts the standing prohibition, it does not pre-approve +any specific integration PR. diff --git a/README.md b/README.md index a6af42e..b008074 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,20 @@ Cellular modem control for CeraLive streaming devices — the standalone home for everything modem, iterated on a bench device before any product integration. -This repository is **Phase A**: it builds, tests, and releases on its own, with **zero -changes to CeraUI, the device image, or apt-worker**. Bench devices install the packaged -`.deb`s straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. +Through `v0.2.0` this repository was **Phase A**: it built, tested, and released on its +own, with zero changes to CeraUI, the device image, or apt-worker. **Phase B +adoption — integrating this repository's artifacts into CeraUI, the device image, and the +apt distribution — is authorized starting at the `v1.0.0` release tag** (see +[`POLICY.md`](POLICY.md) §4); each downstream integration is still its own explicit, +reviewed change in the receiving repository. Bench devices install the packaged `.deb`s +straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. ## Three artifacts, one repo | Directory | Artifact | What it is | |-----------|----------|------------| -| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | -| [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling (RB-10). | +| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (`control/src/backend/uhubctl-power-hook.ts`, config-mapped port map, disabled by default), USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | +| [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling ([RB-10](docs/BENCH.md#rb-10--hub-vbus-verification-partial)). | | [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork, zero source patches** (see [`POLICY.md`](POLICY.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | ## Versioning at a glance @@ -31,7 +35,8 @@ modem-stack/ ├── control/ @ceralive/modem-control — TS control library (Bun workspace member) ├── cli/ modem-control bench CLI (Bun workspace member) ├── packaging/ ModemManager-stack .deb rebuilds + provenance/verification CI -├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, VERSIONING.md, FM350-DECISION.md +├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, VERSIONING.md, FM350-DECISION.md, +│ ESIM-DECISION.md ├── AGENTS.md AI routing + repo contract (self-contained; see Rule D) └── POLICY.md no-fork gate + upstream-contribution-first policy ``` diff --git a/packaging/README.md b/packaging/README.md index f9ecbb8..3a2f55c 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -2,7 +2,8 @@ Bookworm rebuilds of the ModemManager stack — **packaging only, not a fork, zero source patches** (see `POLICY.md` at the repo root). Bench devices install the resulting `.deb`s -from CI artifacts; nothing is published to `apt.ceralive.tv` in Phase A. +from CI artifacts; nothing is published to `apt.ceralive.tv` yet — apt publication is part +of Phase B adoption, authorized from the `v1.0.0` release tag forward (`POLICY.md` §4). ## Sources (4) From 6b1a48a8b3e489f78adf728238bf07a444fd1b49 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 05:46:01 -0500 Subject: [PATCH 09/12] chore(release): bump control + cli to 1.0.0 (Phase-B adoption version gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @ceralive/modem-control (control/package.json) and modem-control-cli (cli/package.json) from 0.2.0 to 1.0.0, the version POLICY.md §4 and docs/VERSIONING.md reserve for Phase-B adoption. This is the version bump only — no tag is cut here (release is a separate, later step: workflow_dispatch on release.yml with tag v1.0.0). Pre-checked that no CI lane couples control/package.json's version to an existing tag/npm state on a non-tag build: ci-bun.yml only lints/typechecks/tests; ci-packaging.yml's contract.sh never reads control/package.json (it only handles the four packaging/ debian/changelog files via read-pin.sh); release.yml's version-vs-tag assertion lives in publish-npm, which only runs on workflow_dispatch with an explicit tag input and is not reachable from a PR or a push to this branch. bun install produced zero bun.lock drift (workspace members are referenced by name, not by version, in the lockfile's resolution table) — bun install --frozen-lockfile and git diff --exit-code -- bun.lock both exit 0. --- cli/package.json | 4 ++-- control/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package.json b/cli/package.json index 9327abb..22369d4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,9 +1,9 @@ { "name": "modem-control-cli", - "version": "0.2.0", + "version": "1.0.0", "private": true, "type": "module", - "description": "modem-control bench CLI — probe/watch/apply/set-usb-mode/usage/certify against real modems (Phase A iteration surface).", + "description": "modem-control bench CLI — probe/watch/apply/set-usb-mode/usage/certify/hil-cycle against real modems (the bench iteration surface).", "license": "AGPL-3.0", "bin": { "modem-control": "./src/index.ts" diff --git a/control/package.json b/control/package.json index 1ce8c25..e47d0a8 100644 --- a/control/package.json +++ b/control/package.json @@ -1,6 +1,6 @@ { "name": "@ceralive/modem-control", - "version": "0.2.0", + "version": "1.0.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", From 9b9184b7927f8651a117f760a745e41145a8867b Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 16 Aug 2026 20:38:47 -0500 Subject: [PATCH 10/12] feat(usage): setUsagePolicy write surface (local 0600 policy store, live sampler apply) --- AGENTS.md | 55 ++++- README.md | 2 +- control/src/backend/usage/index.ts | 23 ++ .../src/backend/usage/policy-store.test.ts | 164 +++++++++++++ control/src/backend/usage/policy-store.ts | 216 ++++++++++++++++++ .../src/backend/usage/policy-write.test.ts | 198 ++++++++++++++++ control/src/backend/usage/policy-write.ts | 207 +++++++++++++++++ control/src/backend/usage/sampler.test.ts | 108 +++++++++ control/src/backend/usage/sampler.ts | 60 ++++- 9 files changed, 1028 insertions(+), 5 deletions(-) create mode 100644 control/src/backend/usage/policy-store.test.ts create mode 100644 control/src/backend/usage/policy-store.ts create mode 100644 control/src/backend/usage/policy-write.test.ts create mode 100644 control/src/backend/usage/policy-write.ts diff --git a/AGENTS.md b/AGENTS.md index d3112a6..9ec48a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE | Directory | Artifact | Role | |-----------|----------|------| -| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (see § below), USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler. Published to public npm under `@ceralive`. | +| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (see § below), USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler + the **usage-policy write surface** (see § below). Published to public npm under `@ceralive`. | | `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | | `packaging/` | ModemManager stack `.deb`s | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`). Bench installs from CI artifacts. | @@ -105,6 +105,59 @@ bus — a zero exit from `uhubctl` is never treated as success on its own. re-detection of the same `modem.generic.device` slot UID. See `cli/README.md` for the exact CLI contract and typed failure reasons. +## DATA-USAGE POLICY — A LOCAL WRITE, BECAUSE MODEMMANAGER HAS NO SUCH API + +`setUsagePolicy` (`control/src/backend/usage/policy-write.ts`) is the WRITE half of +the data-usage surface: it persists a slot's cycle day + advisory threshold and, +when a live `UsageSampler` is supplied, applies them to it in the same call. Until +it existed the package could only REPORT `cycleBytes` / `thresholdBytes` / +`thresholdExceeded` — `DesiredUsage` was a shape the planner echoed into a receipt, +with no persistence and no apply path. + +**It writes a local file, never the modem, and that is a finding rather than a +shortcut.** Verified against a live ModemManager 1.24.2 (`mmcli --help-all` plus a +D-Bus introspection of a real `…/ModemManager1/Modem/N`): the only `Setup`/threshold +surface on the entire object is `Modem.Signal.Setup` / +`Modem.Signal.SetupThresholds`, whose keys are `rssi-threshold` and +`error-rate-threshold` — RADIO QUALITY, not bytes. The only byte counters MM offers +are the per-BEARER read-only `Stats` (`rx-bytes`/`tx-bytes`), which reset with every +connection and so cannot carry a monthly cycle. That is why the sampler counts +`/proc/net/dev` instead, and why `control/src/ports/README.md`'s ownership table +records usage policy as LOCAL-CONTROLLER owned. `Modem.Signal.Setup` is separately +forbidden outright by the shadow-mode mutation-freedom contract; nothing here goes +near it. + +- **`createUsagePolicyFileStore`** mirrors `createUsageFileStore` exactly — versioned + document, temp → chmod → atomic rename so the file is **mode 0600** regardless of + umask, and **fail-soft on corruption**: an unparseable file logs METADATA ONLY + (byte count + a named field, never the content) and is replaced by a fresh empty + one. A row carries only an opaque slot id and two numbers, so the no-PII property + of the counter store holds here by construction. +- **Typed results, never a throw on bad input** (the `PowerHook` precedent): an + out-of-range day answers `{status:'rejected', reason:'invalid-cycle-day'}`. This is + called from an RPC boundary, where a throw becomes an opaque 500. +- **Tri-state fields.** `undefined` leaves a persisted value ALONE; an explicit + `null` CLEARS it. So a caller changing only the threshold cannot silently drop a + cycle day it never mentioned, and a caller that cannot express `null` can never + unset a policy. Clearing both fields REMOVES the row rather than storing an empty one. +- **Order is load → validate → persist → apply.** The store is the source of truth + (the composition root rebuilds every `UsageObservation.usage` from it via + `selectUsagePolicy`), so a live apply that landed while the write failed would + leave the running process disagreeing with what a restart restores. +- **`UsageSampler.applyUsagePolicy` resets the window on a CHANGED cycle anchor and + KEEPS the counter baseline.** Bytes already accrued were measured under the old + window and there is no record of how they were distributed within it, so carrying + them over would over-report the new one; keeping `lastObserved` means the next + sample still attributes only genuinely new bytes, never a jump. A threshold-only + change moves no anchor and resets nothing. +- **An applied policy OUTRANKS a later observation carrying the old one**, for the + process lifetime. Without that, the next `sample()` would clobber a just-applied + write with whatever the composition root had built its observation from, and the + operator would watch their setting revert. + +`SlotUsageSnapshot` gained an additive `cycleDay` so the read side reports the policy +in force, not only its consequences. + ## CERTIFICATION EVIDENCE → CATALOG (evidence-gated, human-reviewed) A SKU reaches `control/src/usb-mode/certified-catalog.json` only through a captured diff --git a/README.md b/README.md index b008074..d88f7cd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. | Directory | Artifact | What it is | |-----------|----------|------------| -| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (`control/src/backend/uhubctl-power-hook.ts`, config-mapped port map, disabled by default), USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler. Published to the public npm registry under the `@ceralive` scope. | +| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (`control/src/backend/uhubctl-power-hook.ts`, config-mapped port map, disabled by default), USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler plus its `setUsagePolicy` write surface (`control/src/backend/usage/policy-write.ts` — a local 0600 policy file, because ModemManager exposes no data-usage API at all). Published to the public npm registry under the `@ceralive` scope. | | [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling ([RB-10](docs/BENCH.md#rb-10--hub-vbus-verification-partial)). | | [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork, zero source patches** (see [`POLICY.md`](POLICY.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | diff --git a/control/src/backend/usage/index.ts b/control/src/backend/usage/index.ts index a9774a7..6d0410d 100644 --- a/control/src/backend/usage/index.ts +++ b/control/src/backend/usage/index.ts @@ -12,6 +12,29 @@ export { } from './accounting'; export { clampCycleDay, cycleStart, daysInMonth } from './billing-cycle'; export { readBootId } from './boot-id'; +export { + createUsagePolicyFileStore, + isValidCycleDay, + isValidThresholdBytes, + type PersistedUsagePolicy, + type PersistedUsagePolicySlot, + selectUsagePolicy, + USAGE_POLICY_SCHEMA_VERSION, + type UsagePolicyFileStoreOptions, + type UsagePolicyLogEvent, + type UsagePolicyLogger, + type UsagePolicyStore, +} from './policy-store'; +export { + getUsagePolicy, + type SetUsagePolicyDeps, + type SetUsagePolicyRejection, + type SetUsagePolicyRequest, + type SetUsagePolicyResult, + setUsagePolicy, + type UsagePolicyApplication, + type UsagePolicyTarget, +} from './policy-write'; export { type CounterSource, parseProcNetDev, diff --git a/control/src/backend/usage/policy-store.test.ts b/control/src/backend/usage/policy-store.test.ts new file mode 100644 index 0000000..12b05e8 --- /dev/null +++ b/control/src/backend/usage/policy-store.test.ts @@ -0,0 +1,164 @@ +// Usage-policy persistence contract — mode 0600 (real fs.stat), fail-soft +// corruption recovery with METADATA-ONLY logging, and the per-slot selector. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createUsagePolicyFileStore, + isValidCycleDay, + isValidThresholdBytes, + type PersistedUsagePolicy, + selectUsagePolicy, + USAGE_POLICY_SCHEMA_VERSION, + type UsagePolicyLogEvent, +} from './policy-store'; + +let dir: string; +let path: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'usage-policy-')); + path = join(dir, 'policy.json'); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const sample: PersistedUsagePolicy = { + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: 1_700_000_000_000, + slots: [ + { logicalSlotId: 'slot-a', cycleDay: 15, thresholdBytes: 5_000_000_000 }, + { logicalSlotId: 'slot-b', cycleDay: 1 }, + { logicalSlotId: 'slot-c', thresholdBytes: 0 }, + ], +}; + +describe('UsagePolicyStore — versioned round-trip', () => { + test('save then load returns identical, schema-versioned state', async () => { + const store = createUsagePolicyFileStore({ path }); + await store.save(sample); + expect(await store.load(1)).toEqual(sample); + }); + + test('an absent file loads as a fresh empty document and writes nothing', async () => { + const store = createUsagePolicyFileStore({ path }); + const loaded = await store.load(4242); + expect(loaded).toEqual({ + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: 4242, + slots: [], + }); + expect(readFile(path, 'utf8')).rejects.toThrow(); + }); + + test('the written file is mode 0600 regardless of umask', async () => { + const store = createUsagePolicyFileStore({ path }); + await store.save(sample); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); +}); + +describe('UsagePolicyStore — fail-soft corruption', () => { + test('invalid JSON is replaced by a fresh 0600 file and logged as METADATA ONLY', async () => { + const events: UsagePolicyLogEvent[] = []; + await writeFile(path, '{"schemaVersion":1,"slots":'); + const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) }); + + const loaded = await store.load(7); + + expect(loaded.slots).toEqual([]); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe('corrupt-policy'); + expect(events[0]?.reason).toContain('invalid-json'); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); + + test.each([ + [ + 'schemaVersion', + JSON.stringify({ ...sample, schemaVersion: 99 }), + 'schema-mismatch: schemaVersion', + ], + ['savedAtMs', JSON.stringify({ ...sample, savedAtMs: 'soon' }), 'schema-mismatch: savedAtMs'], + ['slots', JSON.stringify({ ...sample, slots: {} }), 'schema-mismatch: slots'], + [ + 'logicalSlotId', + JSON.stringify({ ...sample, slots: [{ cycleDay: 3 }] }), + 'schema-mismatch: logicalSlotId', + ], + [ + 'cycleDay', + JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', cycleDay: 32 }] }), + 'schema-mismatch: cycleDay', + ], + [ + 'thresholdBytes', + JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', thresholdBytes: -1 }] }), + 'schema-mismatch: thresholdBytes', + ], + ])('a bad %s is rejected by name and never throws', async (_field, text, reason) => { + const events: UsagePolicyLogEvent[] = []; + await writeFile(path, text); + const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) }); + + expect((await store.load(9)).slots).toEqual([]); + expect(events[0]?.reason).toBe(reason); + }); + + test('the corruption log carries a byte count and NEVER the file content', async () => { + const secretish = JSON.stringify({ schemaVersion: 1, slots: 'iccid-8991101200003204514' }); + const events: UsagePolicyLogEvent[] = []; + await writeFile(path, secretish); + const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) }); + + await store.load(11); + + expect(events[0]?.bytes).toBe(Buffer.byteLength(secretish, 'utf8')); + expect(JSON.stringify(events[0])).not.toContain('8991101200003204514'); + }); +}); + +describe('selectUsagePolicy', () => { + test('returns the slot policy, omitting fields the slot never set', () => { + expect(selectUsagePolicy(sample, 'slot-a')).toEqual({ + cycleDay: 15, + thresholdBytes: 5_000_000_000, + }); + expect(selectUsagePolicy(sample, 'slot-b')).toEqual({ cycleDay: 1 }); + expect(selectUsagePolicy(sample, 'slot-c')).toEqual({ thresholdBytes: 0 }); + }); + + test('an unknown slot answers "no policy set", not a default', () => { + expect(selectUsagePolicy(sample, 'nope')).toEqual({}); + }); +}); + +describe('validators', () => { + test.each([ + [1, true], + [31, true], + [15, true], + [0, false], + [32, false], + [1.5, false], + [Number.NaN, false], + ['3', false], + ])('isValidCycleDay(%p) === %p', (value, expected) => { + expect(isValidCycleDay(value)).toBe(expected); + }); + + test.each([ + [0, true], + [5_000_000_000, true], + [-1, false], + [1.5, false], + [Number.POSITIVE_INFINITY, false], + ['5', false], + ])('isValidThresholdBytes(%p) === %p', (value, expected) => { + expect(isValidThresholdBytes(value)).toBe(expected); + }); +}); diff --git a/control/src/backend/usage/policy-store.ts b/control/src/backend/usage/policy-store.ts new file mode 100644 index 0000000..bafc886 --- /dev/null +++ b/control/src/backend/usage/policy-store.ts @@ -0,0 +1,216 @@ +// Durable persistence for the operator's data-usage POLICY (cycle day + advisory +// threshold), the write-side counterpart of `store.ts`'s counter persistence. +// +// WHY THIS IS LOCAL STATE AND NOT A MODEM WRITE. ModemManager has no data-usage +// API at all. Verified against a live MM 1.24.2 (`mmcli --help-all`, plus a D-Bus +// introspection of a real `…/ModemManager1/Modem/N`): the only `Setup`/threshold +// surface on the whole object is `Modem.Signal.Setup` / +// `Modem.Signal.SetupThresholds`, whose keys are `rssi-threshold` and +// `error-rate-threshold` — RADIO QUALITY, not bytes. The only byte counters MM +// offers are the per-BEARER read-only `Stats` (`rx-bytes`/`tx-bytes`), which +// reset with every connection and therefore cannot carry a monthly cycle. +// +// That is exactly why the sampler in this directory counts `/proc/net/dev` +// instead, and why `ports/README.md`'s ownership table records usage policy as +// LOCAL-CONTROLLER owned. So the write path is a local, versioned, fail-soft +// file — never a D-Bus mutation. (`Modem.Signal.Setup` is additionally forbidden +// outright by the shadow-mode mutation-freedom contract; nothing here goes near +// it.) +// +// The two hard guarantees are the SAME ones `store.ts` makes, and deliberately +// implemented the same way so the pair can be read side by side: +// - MODE 0600 via temp → chmod → atomic rename, regardless of umask. +// - FAIL-SOFT ON CORRUPTION: an unparseable/incompatible file logs METADATA +// ONLY (byte length + a classification reason, never the content) and is +// replaced by a fresh empty 0600 file rather than throwing. +// +// A policy row carries ONLY an opaque slot id and two numbers. By construction +// there is no subscriber or device identity here (no ICCID/IMSI/IMEI, no +// operator, no model) — the same no-PII property the counter store holds. + +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { DesiredUsage } from '../../domain'; + +/** The current on-disk schema version. Bump when the persisted shape changes. */ +export const USAGE_POLICY_SCHEMA_VERSION = 1; + +/** One slot's persisted usage policy. Both fields are absent when unset. */ +export interface PersistedUsagePolicySlot { + readonly logicalSlotId: string; + /** Day of month (1–31) the cycle resets; UTC, month-length clamped (A4.3). */ + readonly cycleDay?: number; + /** Advisory threshold in bytes; crossing it raises an advisory, never gates. */ + readonly thresholdBytes?: number; +} + +/** The full persisted policy document. */ +export interface PersistedUsagePolicy { + readonly schemaVersion: typeof USAGE_POLICY_SCHEMA_VERSION; + readonly savedAtMs: number; + readonly slots: readonly PersistedUsagePolicySlot[]; +} + +/** A metadata-only log event. Corruption NEVER carries the raw file content. */ +export type UsagePolicyLogEvent = { + readonly kind: 'corrupt-policy'; + readonly bytes: number; + readonly reason: string; +}; + +/** Sink for policy-store log events. Defaults to a metadata-only `console.warn`. */ +export type UsagePolicyLogger = (event: UsagePolicyLogEvent) => void; + +/** The persistence seam `setUsagePolicy` drives. */ +export interface UsagePolicyStore { + /** Load persisted policy; recreate a fresh 0600 file if absent or corrupt. */ + load(nowMs: number): Promise; + /** Atomically write policy with mode 0600 (temp → chmod → rename). */ + save(state: PersistedUsagePolicy): Promise; +} + +export interface UsagePolicyFileStoreOptions { + readonly path: string; + readonly logger?: UsagePolicyLogger; +} + +function defaultLogger(event: UsagePolicyLogEvent): void { + console.warn(`[usage-policy] ${event.kind}: bytes=${event.bytes} reason=${event.reason}`); +} + +function freshState(nowMs: number): PersistedUsagePolicy { + return { schemaVersion: USAGE_POLICY_SCHEMA_VERSION, savedAtMs: nowMs, slots: [] }; +} + +/** A schema violation naming only the offending FIELD (never file content). */ +class PolicySchemaError extends Error { + constructor(field: string) { + super(`schema-mismatch: ${field}`); + } +} + +/** True for a value that is a legal cycle day (integer 1–31). */ +export function isValidCycleDay(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 31; +} + +/** True for a value that is a legal advisory threshold (non-negative integer). */ +export function isValidThresholdBytes(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function validateSlot(raw: unknown): PersistedUsagePolicySlot { + if (typeof raw !== 'object' || raw === null) { + throw new PolicySchemaError('slot'); + } + const slot = raw as Record; + if (typeof slot.logicalSlotId !== 'string' || slot.logicalSlotId.length === 0) { + throw new PolicySchemaError('logicalSlotId'); + } + if (slot.cycleDay !== undefined && !isValidCycleDay(slot.cycleDay)) { + throw new PolicySchemaError('cycleDay'); + } + if (slot.thresholdBytes !== undefined && !isValidThresholdBytes(slot.thresholdBytes)) { + throw new PolicySchemaError('thresholdBytes'); + } + return { + logicalSlotId: slot.logicalSlotId, + ...(slot.cycleDay !== undefined ? { cycleDay: slot.cycleDay } : {}), + ...(slot.thresholdBytes !== undefined ? { thresholdBytes: slot.thresholdBytes } : {}), + }; +} + +/** Parse + validate the document. Throws `PolicySchemaError` (metadata-only). */ +function validate(raw: unknown): PersistedUsagePolicy { + if (typeof raw !== 'object' || raw === null) { + throw new PolicySchemaError('document'); + } + const doc = raw as Record; + if (doc.schemaVersion !== USAGE_POLICY_SCHEMA_VERSION) { + throw new PolicySchemaError('schemaVersion'); + } + if (typeof doc.savedAtMs !== 'number' || !Number.isFinite(doc.savedAtMs)) { + throw new PolicySchemaError('savedAtMs'); + } + if (!Array.isArray(doc.slots)) { + throw new PolicySchemaError('slots'); + } + return { + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: doc.savedAtMs, + slots: doc.slots.map(validateSlot), + }; +} + +/** Classify a load failure into a metadata-only reason string (no raw content). */ +function classifyFailure(error: unknown): string { + if (error instanceof PolicySchemaError) { + return error.message; + } + if (error instanceof SyntaxError) { + const offset = /position (\d+)/.exec(error.message)?.[1]; + return offset !== undefined ? `invalid-json at offset ${offset}` : 'invalid-json'; + } + return 'unreadable'; +} + +/** + * Read one slot's policy out of a loaded document. + * + * This is the read half the composition root uses to build each slot's + * `UsageObservation.usage`, so the persisted file — not an in-memory guess — is + * what the sampler accounts against. An unknown slot answers `{}`, i.e. "no + * policy set", which is exactly what `defaultCellularPolicy` starts from. + */ +export function selectUsagePolicy( + state: PersistedUsagePolicy, + logicalSlotId: string, +): DesiredUsage { + const slot = state.slots.find((entry) => entry.logicalSlotId === logicalSlotId); + if (slot === undefined) { + return {}; + } + return { + ...(slot.cycleDay !== undefined ? { cycleDay: slot.cycleDay } : {}), + ...(slot.thresholdBytes !== undefined ? { thresholdBytes: slot.thresholdBytes } : {}), + }; +} + +export function createUsagePolicyFileStore(options: UsagePolicyFileStoreOptions): UsagePolicyStore { + const logger = options.logger ?? defaultLogger; + const { path } = options; + + async function writeAtomic(state: PersistedUsagePolicy): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(state)); + // chmod AFTER the write (not an open flag) so mode is 0600 regardless of umask. + await chmod(tmp, 0o600); + await rename(tmp, path); + } + + return { + async load(nowMs: number): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + // Absent (or unreadable) → start empty; the first save lays down a 0600 file. + return freshState(nowMs); + } + try { + return validate(JSON.parse(text)); + } catch (error) { + logger({ + kind: 'corrupt-policy', + bytes: Buffer.byteLength(text, 'utf8'), + reason: classifyFailure(error), + }); + const fresh = freshState(nowMs); + await writeAtomic(fresh); + return fresh; + } + }, + save: writeAtomic, + }; +} diff --git a/control/src/backend/usage/policy-write.test.ts b/control/src/backend/usage/policy-write.test.ts new file mode 100644 index 0000000..fc30c27 --- /dev/null +++ b/control/src/backend/usage/policy-write.test.ts @@ -0,0 +1,198 @@ +// `setUsagePolicy` contract — tri-state merge, typed refusals, persist-before-apply +// ordering, and the live-sampler cycle-reset rule. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { DesiredUsage } from '../../domain'; +import { + createUsagePolicyFileStore, + type PersistedUsagePolicy, + selectUsagePolicy, + USAGE_POLICY_SCHEMA_VERSION, + type UsagePolicyStore, +} from './policy-store'; +import { getUsagePolicy, setUsagePolicy, type UsagePolicyTarget } from './policy-write'; + +let dir: string; +let path: string; +let store: UsagePolicyStore; + +const SLOT = 'slot-a'; +const NOW = 1_700_000_000_000; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'usage-policy-write-')); + path = join(dir, 'policy.json'); + store = createUsagePolicyFileStore({ path }); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function recordingSampler(): UsagePolicyTarget & { + calls: { slot: string; usage: DesiredUsage; at?: number }[]; +} { + const calls: { slot: string; usage: DesiredUsage; at?: number }[] = []; + return { + calls, + applyUsagePolicy(slot, usage, at) { + calls.push({ slot, usage, ...(at !== undefined ? { at } : {}) }); + return { cycleStartMs: NOW, cycleReset: true }; + }, + }; +} + +describe('setUsagePolicy — persistence', () => { + test('writes a policy that reads back through getUsagePolicy', async () => { + const result = await setUsagePolicy( + { store, now: () => NOW }, + { logicalSlotId: SLOT, cycleDay: 15, thresholdBytes: 5_000_000_000 }, + ); + + expect(result.status).toBe('applied'); + expect(await getUsagePolicy({ store }, SLOT)).toEqual({ + cycleDay: 15, + thresholdBytes: 5_000_000_000, + }); + }); + + test('an OMITTED field is left alone — a threshold write never drops the cycle day', async () => { + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9 }); + + await setUsagePolicy({ store }, { logicalSlotId: SLOT, thresholdBytes: 100 }); + + expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 9, thresholdBytes: 100 }); + }); + + test('an explicit null CLEARS that field and leaves the sibling standing', async () => { + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9, thresholdBytes: 100 }); + + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: null }); + + expect(await getUsagePolicy({ store }, SLOT)).toEqual({ thresholdBytes: 100 }); + }); + + test('clearing BOTH fields removes the row rather than storing an empty one', async () => { + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9, thresholdBytes: 100 }); + + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: null, thresholdBytes: null }); + + expect((await store.load(NOW)).slots).toEqual([]); + }); + + test('a write to one slot never disturbs another', async () => { + await setUsagePolicy({ store }, { logicalSlotId: 'slot-a', cycleDay: 1 }); + await setUsagePolicy({ store }, { logicalSlotId: 'slot-b', cycleDay: 20 }); + + await setUsagePolicy({ store }, { logicalSlotId: 'slot-a', cycleDay: 5 }); + + const state = await store.load(NOW); + expect(selectUsagePolicy(state, 'slot-a')).toEqual({ cycleDay: 5 }); + expect(selectUsagePolicy(state, 'slot-b')).toEqual({ cycleDay: 20 }); + expect(state.slots).toHaveLength(2); + }); +}); + +describe('setUsagePolicy — typed refusals', () => { + test.each([ + [{ logicalSlotId: '', cycleDay: 1 }, 'invalid-slot-id'], + [{ logicalSlotId: SLOT, cycleDay: 0 }, 'invalid-cycle-day'], + [{ logicalSlotId: SLOT, cycleDay: 32 }, 'invalid-cycle-day'], + [{ logicalSlotId: SLOT, cycleDay: 3.5 }, 'invalid-cycle-day'], + [{ logicalSlotId: SLOT, thresholdBytes: -1 }, 'invalid-threshold-bytes'], + [{ logicalSlotId: SLOT, thresholdBytes: 1.5 }, 'invalid-threshold-bytes'], + ] as const)('%p is rejected as %s and writes nothing', async (request, reason) => { + const result = await setUsagePolicy({ store }, request); + + expect(result).toMatchObject({ status: 'rejected', reason }); + expect((await store.load(NOW)).slots).toEqual([]); + }); + + test('a refusal is returned, never thrown', async () => { + expect(setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 99 })).resolves.toMatchObject( + { status: 'rejected' }, + ); + }); + + test('a store that throws yields a typed failure and never reaches the sampler', async () => { + const sampler = recordingSampler(); + const broken: UsagePolicyStore = { + load: async () => ({ + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: NOW, + slots: [], + }), + save: async () => { + throw new Error('disk full'); + }, + }; + + const result = await setUsagePolicy( + { store: broken, sampler }, + { logicalSlotId: SLOT, cycleDay: 3 }, + ); + + expect(result).toMatchObject({ status: 'failed', reason: 'disk full' }); + expect(sampler.calls).toEqual([]); + }); +}); + +describe('setUsagePolicy — live apply', () => { + test('the merged policy (not the raw request) reaches the sampler', async () => { + await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9 }); + const sampler = recordingSampler(); + + const result = await setUsagePolicy( + { store, sampler, now: () => NOW }, + { logicalSlotId: SLOT, thresholdBytes: 250 }, + ); + + expect(sampler.calls).toEqual([ + { slot: SLOT, usage: { cycleDay: 9, thresholdBytes: 250 }, at: NOW }, + ]); + expect(result).toMatchObject({ status: 'applied', applied: { cycleReset: true } }); + }); + + test('with no sampler the write is persistence-only and reports no application', async () => { + const result = await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 2 }); + + expect(result).toEqual({ status: 'applied', logicalSlotId: SLOT, usage: { cycleDay: 2 } }); + }); + + test('a sampler that throws is a typed failure AFTER the write already landed', async () => { + const result = await setUsagePolicy( + { + store, + sampler: { + applyUsagePolicy() { + throw new Error('sampler gone'); + }, + }, + }, + { logicalSlotId: SLOT, cycleDay: 4 }, + ); + + expect(result).toMatchObject({ status: 'failed', reason: 'sampler gone' }); + expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 4 }); + }); +}); + +describe('getUsagePolicy', () => { + test('an unwritten slot answers "no policy set"', async () => { + expect(await getUsagePolicy({ store }, 'never-written')).toEqual({}); + }); + + test('reads a policy laid down by a previous process', async () => { + const seeded: PersistedUsagePolicy = { + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: NOW, + slots: [{ logicalSlotId: SLOT, cycleDay: 28 }], + }; + await store.save(seeded); + + expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 28 }); + }); +}); diff --git a/control/src/backend/usage/policy-write.ts b/control/src/backend/usage/policy-write.ts new file mode 100644 index 0000000..eca60f7 --- /dev/null +++ b/control/src/backend/usage/policy-write.ts @@ -0,0 +1,207 @@ +// `setUsagePolicy` — the WRITE half of the data-usage surface. +// +// The read half already existed (`UsageSampler.snapshot()` reports `cycleBytes`, +// `thresholdBytes` and `thresholdExceeded`), but nothing could SET the two +// numbers those readings are computed against: `DesiredUsage` was a shape the +// planner echoed into a receipt, with no persistence and no apply path. This +// module closes that, mirroring the read side's file-store idiom exactly. +// +// It is a LOCAL write, not a modem write — see `policy-store.ts`'s header for the +// ModemManager API evidence. Nothing here touches D-Bus, `mmcli`, or any bearer. +// +// TYPED RESULTS, NEVER THROWS ON BAD INPUT. Following the `PowerHook` precedent +// (`power-contract.ts`: `applied` / `unsupported` / `failed`), an out-of-range +// day is a `rejected` result carrying a named reason rather than an exception — +// this is called from an RPC boundary where a throw becomes an opaque 500. + +import type { DesiredUsage } from '../../domain'; +import { + isValidCycleDay, + isValidThresholdBytes, + type PersistedUsagePolicySlot, + selectUsagePolicy, + USAGE_POLICY_SCHEMA_VERSION, + type UsagePolicyStore, +} from './policy-store'; + +/** + * The live-apply seam. `UsageSampler` implements it; a caller with no running + * sampler simply omits it and the write is persistence-only. + */ +export interface UsagePolicyTarget { + applyUsagePolicy( + logicalSlotId: string, + usage: DesiredUsage, + atMs?: number, + ): UsagePolicyApplication; +} + +/** What a live apply did to the slot's accounting window. */ +export interface UsagePolicyApplication { + /** The UTC start of the cycle the slot is now accruing into. */ + readonly cycleStartMs: number; + /** True when the cycle ANCHOR moved, so the per-cycle total restarted at 0. */ + readonly cycleReset: boolean; +} + +export interface SetUsagePolicyDeps { + readonly store: UsagePolicyStore; + /** Optional live sampler to apply the change to immediately. */ + readonly sampler?: UsagePolicyTarget; + /** Injectable clock (defaults to `Date.now`). */ + readonly now?: () => number; +} + +/** + * The requested change. + * + * Tri-state per field, and the distinction is the whole point: `undefined` + * leaves the persisted value ALONE (so a caller changing only the threshold + * cannot silently drop a cycle day it never mentioned), while an explicit `null` + * CLEARS it. A caller that cannot express `null` can never unset a policy. + */ +export interface SetUsagePolicyRequest { + readonly logicalSlotId: string; + readonly cycleDay?: number | null; + readonly thresholdBytes?: number | null; +} + +export type SetUsagePolicyRejection = + | 'invalid-slot-id' + | 'invalid-cycle-day' + | 'invalid-threshold-bytes'; + +export type SetUsagePolicyResult = + | { + readonly status: 'applied'; + readonly logicalSlotId: string; + /** The policy now persisted for this slot (post-merge). */ + readonly usage: DesiredUsage; + /** Present only when a live sampler was supplied. */ + readonly applied?: UsagePolicyApplication; + } + | { + readonly status: 'rejected'; + readonly logicalSlotId: string; + readonly reason: SetUsagePolicyRejection; + } + | { + readonly status: 'failed'; + readonly logicalSlotId: string; + readonly reason: string; + }; + +function validateRequest(request: SetUsagePolicyRequest): SetUsagePolicyRejection | undefined { + if (typeof request.logicalSlotId !== 'string' || request.logicalSlotId.length === 0) { + return 'invalid-slot-id'; + } + if ( + request.cycleDay !== undefined && + request.cycleDay !== null && + !isValidCycleDay(request.cycleDay) + ) { + return 'invalid-cycle-day'; + } + if ( + request.thresholdBytes !== undefined && + request.thresholdBytes !== null && + !isValidThresholdBytes(request.thresholdBytes) + ) { + return 'invalid-threshold-bytes'; + } + return undefined; +} + +/** Fold the request onto the currently-persisted policy (tri-state merge). */ +function mergePolicy(current: DesiredUsage, request: SetUsagePolicyRequest): DesiredUsage { + const cycleDay = + request.cycleDay === undefined ? current.cycleDay : (request.cycleDay ?? undefined); + const thresholdBytes = + request.thresholdBytes === undefined + ? current.thresholdBytes + : (request.thresholdBytes ?? undefined); + return { + ...(cycleDay !== undefined ? { cycleDay } : {}), + ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), + }; +} + +function toSlot(logicalSlotId: string, usage: DesiredUsage): PersistedUsagePolicySlot { + return { + logicalSlotId, + ...(usage.cycleDay !== undefined ? { cycleDay: usage.cycleDay } : {}), + ...(usage.thresholdBytes !== undefined ? { thresholdBytes: usage.thresholdBytes } : {}), + }; +} + +/** + * Persist a slot's usage policy and, when a live sampler is supplied, apply it + * to that sampler in the same call. + * + * ORDER IS LOAD → VALIDATE → PERSIST → APPLY, and it is deliberate. The store is + * the source of truth (the composition root rebuilds every `UsageObservation` + * from it), so a live apply that landed while the write failed would leave the + * running process disagreeing with what a restart would restore. + */ +export async function setUsagePolicy( + deps: SetUsagePolicyDeps, + request: SetUsagePolicyRequest, +): Promise { + const logicalSlotId = typeof request.logicalSlotId === 'string' ? request.logicalSlotId : ''; + const rejection = validateRequest(request); + if (rejection !== undefined) { + return { status: 'rejected', logicalSlotId, reason: rejection }; + } + + const now = deps.now ?? Date.now; + const at = now(); + + let usage: DesiredUsage; + try { + const state = await deps.store.load(at); + usage = mergePolicy(selectUsagePolicy(state, logicalSlotId), request); + const others = state.slots.filter((slot) => slot.logicalSlotId !== logicalSlotId); + // An empty policy is REMOVED rather than stored as an empty row: "no policy" + // and "a policy that sets nothing" are the same fact, and keeping the row + // would grow the file by one entry per slot an operator ever cleared. + const slots = + usage.cycleDay === undefined && usage.thresholdBytes === undefined + ? others + : [...others, toSlot(logicalSlotId, usage)]; + await deps.store.save({ + schemaVersion: USAGE_POLICY_SCHEMA_VERSION, + savedAtMs: at, + slots, + }); + } catch (error) { + return { + status: 'failed', + logicalSlotId, + reason: error instanceof Error ? error.message : 'persist-failed', + }; + } + + if (deps.sampler === undefined) { + return { status: 'applied', logicalSlotId, usage }; + } + try { + const applied = deps.sampler.applyUsagePolicy(logicalSlotId, usage, at); + return { status: 'applied', logicalSlotId, usage, applied }; + } catch (error) { + return { + status: 'failed', + logicalSlotId, + reason: error instanceof Error ? error.message : 'apply-failed', + }; + } +} + +/** Read one slot's persisted policy. The read counterpart of `setUsagePolicy`. */ +export async function getUsagePolicy( + deps: Pick, + logicalSlotId: string, +): Promise { + const now = deps.now ?? Date.now; + const state = await deps.store.load(now()); + return selectUsagePolicy(state, logicalSlotId); +} diff --git a/control/src/backend/usage/sampler.test.ts b/control/src/backend/usage/sampler.test.ts index e0f8f50..14b6d48 100644 --- a/control/src/backend/usage/sampler.test.ts +++ b/control/src/backend/usage/sampler.test.ts @@ -217,3 +217,111 @@ describe('UsageSampler — reboot (new boot id) re-baselines without losing the expect(rebooted.snapshot().slots[0]?.cycleBytes).toBe(350); }); }); + +describe('UsageSampler — applyUsagePolicy (the setUsagePolicy live-apply seam)', () => { + const AUG_16 = Date.UTC(2026, 7, 16, 12, 0, 0); + + async function samplerAt(now: number) { + const counters = new FakeCounters(); + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store: new MemStore(), + now: () => now, + }); + return { counters, sampler }; + } + + test('a threshold-only change takes effect immediately and resets nothing', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + counters.set('wwan0', 900); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + + const applied = sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 500 }); + + expect(applied.cycleReset).toBe(false); + const slot = sampler.snapshot().slots[0]; + expect(slot?.cycleBytes).toBe(800); + expect(slot?.thresholdBytes).toBe(500); + expect(slot?.thresholdExceeded).toBe(true); + }); + + test('a CHANGED cycle day restarts the window at zero and re-anchors it', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + counters.set('wwan0', 900); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(800); + + const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 }); + + expect(applied.cycleReset).toBe(true); + expect(applied.cycleStartMs).toBe(Date.UTC(2026, 6, 20)); + const slot = sampler.snapshot().slots[0]; + expect(slot?.cycleBytes).toBe(0); + expect(slot?.cycleStartMs).toBe(Date.UTC(2026, 6, 20)); + expect(slot?.cycleDay).toBe(20); + }); + + test('the BASELINE survives the reset, so the next sample attributes no jump', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 1_000_000); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + + sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 }); + counters.set('wwan0', 1_000_150); + await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]); + + expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(150); + }); + + test('re-applying the SAME cycle day is a no-op — repeated saves never zero a window', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]); + counters.set('wwan0', 400); + await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]); + + const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 }); + + expect(applied.cycleReset).toBe(false); + expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(300); + }); + + test('an applied policy OUTRANKS a stale observation on the next sample', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]); + + sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 999 }); + // The composition root has not rebuilt its observations yet and still + // carries the OLD policy — the write must not silently revert. + await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]); + + expect(sampler.snapshot().slots[0]?.thresholdBytes).toBe(999); + }); + + test('applying to a slot never sampled creates it without claiming any bytes', async () => { + const { sampler } = await samplerAt(AUG_16); + + const applied = sampler.applyUsagePolicy(SLOT_B, { cycleDay: 3, thresholdBytes: 7 }); + + expect(applied.cycleReset).toBe(false); + const slot = sampler.snapshot().slots[0]; + expect(slot?.logicalSlotId).toBe('slot-b'); + expect(slot?.cycleBytes).toBe(0); + expect(slot?.cycleDay).toBe(3); + expect(slot?.thresholdBytes).toBe(7); + }); + + test('a slot with no policy reports no cycleDay rather than the sampler default', async () => { + const { counters, sampler } = await samplerAt(AUG_16); + counters.set('wwan0', 10); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + + expect(sampler.snapshot().slots[0]?.cycleDay).toBeUndefined(); + }); +}); diff --git a/control/src/backend/usage/sampler.ts b/control/src/backend/usage/sampler.ts index 554d406..3ef9dad 100644 --- a/control/src/backend/usage/sampler.ts +++ b/control/src/backend/usage/sampler.ts @@ -36,6 +36,8 @@ export interface SlotUsageSnapshot { readonly cycleBytes: number; readonly cycleStartMs: number; readonly paused: boolean; + /** The cycle day in force for this slot, when the operator set one. */ + readonly cycleDay?: number; readonly thresholdBytes?: number; /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */ readonly thresholdExceeded: boolean; @@ -85,6 +87,13 @@ export class UsageSampler { readonly #defaultCycleDay: number; readonly #accounts = new 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 + // clobber a just-applied write with the policy the composition root happened to + // build its observation from — and the operator would watch their setting + // 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(); #lastPersistMs: number; #dirty = false; @@ -145,8 +154,9 @@ export class UsageSampler { const now = this.#now(); for (const obs of observations) { const slotId = obs.logicalSlotId as string; - this.#policies.set(slotId, obs.usage); - const cycleDay = obs.usage.cycleDay ?? this.#defaultCycleDay; + 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) { @@ -179,12 +189,14 @@ export class UsageSampler { const generatedAtMs = this.#now(); const slots: SlotUsageSnapshot[] = []; for (const [slotId, account] of this.#accounts) { - const thresholdBytes = this.#policies.get(slotId)?.thresholdBytes; + 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, }); @@ -192,6 +204,48 @@ export class UsageSampler { return { bootId: this.#bootId, generatedAtMs, slots }; } + /** + * Apply an operator's usage policy to this slot immediately, without waiting + * for the next sampling pass. + * + * A CHANGED CYCLE ANCHOR RESTARTS THE WINDOW AT ZERO, and keeps the counter + * BASELINE. Those two halves are the honest answer to a question with no + * truthful one: bytes already accrued were measured under the OLD window, so + * carrying them into the new one over-reports it, and there is no record of + * how they were distributed within it. Starting fresh states plainly that the + * new window began now; keeping `lastObserved` means the next sample still + * attributes only genuinely new bytes, never a jump. A threshold-only change + * moves no anchor and therefore resets nothing. + */ + applyUsagePolicy( + logicalSlotId: string, + usage: DesiredUsage, + atMs?: number, + ): { + 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 }; + } + /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */ async flush(): Promise { if (this.#dirty) { From 4d53b092a126279fbe9b261fb450df7975d5924c Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Mon, 17 Aug 2026 00:04:18 -0500 Subject: [PATCH 11/12] =?UTF-8?q?docs(fm350):=20record=20Citation=206=20?= =?UTF-8?q?=E2=80=94=20real=20FM350-GL=20observed=20on=20USB,=20Branch-A?= =?UTF-8?q?=20HARD=20STOP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An RB-16 re-run on bench ceralive2 found a physically connected Fibocom FM350-GL enumerating over USB as 0e8d:7127, bound by rndis_host + option (not the cdc_mbim the Branch-A template predicted, and not the documented PCIe mtk_t7xx path — the PCI bus still shows nothing). ModemManager 1.24.2 claims it via the generic fallback plugin as Modem/4, state failed / sim-missing. This is the decision record's own branch-1 contrary-evidence HARD STOP, so this change records evidence and nothing else: Branch-A steps 1-3 only. No classifier entry, branch, type, or fixture is added; the three-gate ledger table is left verbatim; the support matrix is untouched. 0e8d matches none of the previously considered identities (PCI 14c3, fibocom-USB 2cb7/1782/ 1508) — hwdb resolves it to MediaTek's USB vendor id. Also noted: RB-16's literal candidate list (0e8d:7126|14c3:4d75) reports "not connected" against this board. Widening it is deferred to the same human decision that governs the classifier question. --- docs/FM350-DECISION.md | 160 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index 1742976..de5de7e 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -6,7 +6,16 @@ device; the reasons and the future-decision gates are recorded below so the defe deliberate, evidence-backed choice rather than an oversight. This is a **tracked** engineering decision record (committed to git), not a claim that the -FM350 works on any CeraLive device. Nothing here has been exercised on real hardware. +FM350 works on any CeraLive device. + +> **⚠ CONTRADICTED BY HARDWARE, 2026-08-17 — decision frozen pending human review.** A real +> FM350-GL is now connected to the bench and enumerates as a **USB** device (`0e8d:7127`, +> `rndis_host` + `option`), not as the PCIe `mtk_t7xx` device this record describes. The +> evidence is recorded verbatim in **[Citation 6](#citation-6--hardware-a-real-fm350-gl-observed-on-the-usb-bus-2026-08-17)**; +> this is the mechanical rule's branch-1 contrary-evidence **HARD STOP**. Everything below +> Citation 6 — the status line above, "What the FM350 is", the branch verdict, the three-gate +> ledger — is left **exactly as previously written** on purpose, so the contradiction is +> visible rather than smoothed over. Nothing is resolved until a human adjudicates. ## What the FM350 is @@ -141,6 +150,114 @@ keyed on `14c3:4d75` plus its meson wiring. None is a USB identity. The FM350 is handled by the (PCIe) `mtk` plugin's T7xx path — corroborating the source-level findings above. +### Citation 6 — HARDWARE: a real FM350-GL observed on the **USB** bus (2026-08-17) + +> **HARD STOP — Branch A fired. Human decision required before any classifier change.** +> Recorded per the Branch-A template below, steps 1-2 ONLY. No classifier entry, branch, +> type, or fixture has been added; the three-gate ledger below is deliberately left as it +> was. Steps 4-5 require human sign-off that has not happened. + +An RB-16 re-run against the live bench board `ceralive2` (192.168.78.132, kernel +`7.1.7-ceralive-rk3588`, packaged ModemManager `1.24.2-2~ceralive0.2.0`) found a physically +connected Fibocom FM350-GL — **enumerating over USB**, not PCIe. This is the first hardware +evidence of any kind for this module, and it contradicts the "no USB VID:PID" conclusion of +Citations 1-4 at the observation level (see the reconciliation note below for what it does and +does not overturn). + +**Observed identity** + +| Field | Value | +|-------|-------| +| USB `VID:PID` | **`0e8d:7127`** | +| sysfs node | `/sys/bus/usb/devices/1-1.2` → `/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.2` | +| udev `ID_PATH` | `platform-xhci-hcd.0.auto-usb-0:1.2` | +| `manufacturer` / `product` | `Fibocom Wireless Inc.` / `FM350-GL` | +| `ID_VENDOR_FROM_DATABASE` | **`MediaTek Inc.`** | +| Device descriptor | `bDeviceClass=ef` `bDeviceSubClass=02` `bDeviceProtocol=01` (IAD composite), `bNumInterfaces=10`, `bcdDevice=0001`, USB 2.10 @ 480 Mb/s | +| `ID_USB_INTERFACES` | `:0202ff:0a0000:ff0000:ff4201:` | + +**Observed driver bindings — NOT `cdc_mbim`.** The Branch-A template predicted `cdc_mbim` as +the expected driver family for a USB MBIM personality. That prediction is **wrong for this +unit**: the composition is RNDIS + AT, with no MBIM function at all. + +``` +1-1.2:1.0 class=02 sub=02 proto=ff driver=rndis_host -> net/enx000011121314 +1-1.2:1.1 class=0a sub=00 proto=00 driver=rndis_host +1-1.2:1.2 class=ff sub=00 proto=00 driver=option -> ttyUSB9 +1-1.2:1.3 class=ff sub=00 proto=00 driver=option -> ttyUSB10 +1-1.2:1.4 class=ff sub=00 proto=00 driver=option -> ttyUSB11 +1-1.2:1.5 class=ff sub=42 proto=01 driver=(none) +1-1.2:1.6 class=ff sub=00 proto=00 driver=option -> ttyUSB12 +1-1.2:1.7 class=ff sub=00 proto=00 driver=option -> ttyUSB13 +1-1.2:1.8 class=ff sub=00 proto=00 driver=option -> ttyUSB14 +1-1.2:1.9 class=ff sub=00 proto=00 driver=option -> ttyUSB15 +``` + +`cdc_mbim` is not loaded on this board; `mtk_t7xx` is not loaded either. Interface `1-1.2:1.5` +(`ff/42/01`) is claimed by no driver. + +**`0e8d` matches NOTHING previously considered in this record.** It is neither the PCI vendor +`14c3` of Citations 1-2, nor any of the fibocom USB plugin's vendor ids `0x2cb7` / `0x1782` / +`0x1508` from Citation 3. It is a **fourth, previously unconsidered vendor id** — MediaTek's +*USB* vendor id per the hwdb (`ID_VENDOR_FROM_DATABASE=MediaTek Inc.`), as distinct from +MediaTek's *PCI* vendor id `14c3`. The product id `7127` likewise appears nowhere in the MM +1.24.2 tree (Citation 4 enumerated every `14c3`/`4d75` occurrence; neither `0e8d` nor `7127` +was among them), and it is not the `0e8d:7126` bootloader id RB-16 was written to look for. + +**ModemManager's own verdict — the `generic` plugin, not `mtk`, not `fibocom`.** +`mmcli -L` lists it cleanly as `/org/freedesktop/ModemManager1/Modem/4 [Fibocom Wireless Inc.] +FM350-GL`. Abridged `mmcli -m 4 -K` (full transcript in the evidence bundle): + +``` +modem.generic.manufacturer : Fibocom Wireless Inc. +modem.generic.model : FM350-GL +modem.generic.revision : 81600.0000.00.19.17.10 +modem.generic.device : /sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.2 +modem.generic.drivers.value[1] : option +modem.generic.drivers.value[2] : rndis_host +modem.generic.plugin : generic +modem.generic.primary-port : ttyUSB12 +modem.generic.ports.value[1] : enx000011121314 (net) +modem.generic.ports.value[2] : ttyUSB12 (at) +modem.generic.state : failed +modem.generic.state-failed-reason : sim-missing +modem.generic.supported-capabilities.value[1] : gsm-umts, lte +modem.generic.supported-modes.value[1] : allowed: 2g, 3g, 4g, 5g; preferred: none +modem.3gpp.imei : 350274430001765 +modem.3gpp.registration-state : -- +modem.generic.sim : -- +``` + +**No SIM is installed** (`state: failed`, `failed reason: sim-missing`), so every 3GPP field +beyond the IMEI is empty and no registration, bearer, or data-session evidence exists. The +5G capability the SKU advertises is not reported: MM sees `gsm-umts, lte` only. + +**PCIe bus: still nothing.** The step-3 sweep found the same six PCI devices as the 2026-08-16 +run (RK3588 root complex `1d87:3588` ×3, Realtek `10ec:b852` / `10ec:8125`) — no `14c3:4d75`, +`/sys/class/wwan/` does not exist, `mtk_t7xx` not loaded. So this is not a module that also +appeared on PCIe; on this bench it is USB-only. + +**What this does and does not overturn.** Citations 1-4 audited the MM 1.24.2 *plugin* layer +and concluded no plugin matches an FM350 by USB VID:PID. That conclusion is **not** falsified +by this observation — MM here claims the device through the `generic` plugin (the fallback for +a device exposing AT ports), precisely because no vendor plugin's VID table contains `0e8d`. +What IS contradicted is this record's broader hardware claim, stated at the top of the file and +in "What the FM350 is": that the FM350 "is **not** a USB modem and presents **no** USB VID:PID." +The unit on this bench does present one. Whether that is the bare M.2 module in a USB +composition mode or an M.2-to-USB carrier interposing its own USB identity is **not determined +by this evidence** and is part of what needs human adjudication — no inference either way is +recorded here. + +**Evidence bundle:** `test-results/modem-phase-b/65/{usb-sweep,driver-binding,pcie-sweep,mmcli-list,mmcli-dump,context}.txt` +(repo-local, gitignored); mirrored to `.omo/notepads/modem-stack-phase-b/`. + +**RB-16 runbook defect, recorded not fixed.** [`BENCH.md`](BENCH.md) § RB-16 step 1 matches +only `0e8d:7126|14c3:4d75`, so its literal machine check prints `NO MATCHES` — i.e. "not +connected" — against a board where the FM350 is plainly enumerated. This run therefore ran the +runbook expression verbatim AND an explicit `0e8d:7127` extension, and both results are in +`usb-sweep.txt`. Widening the runbook's candidate list is deliberately left to the same human +decision that governs the classifier question, so the runbook and the decision stay in sync. + ## Three-gate ledger FM350 device enablement upstream requires three independent gates. This record is honest @@ -156,6 +273,13 @@ Gate 1 being CLEARED does **not** imply the FM350 works — it only removes the version-floor obstacle. Gates 2 and 3 remain the blocking, hardware-gated unknowns, and are deliberately left OPEN rather than assumed. +> **Ledger frozen pending human decision (2026-08-17).** The table above is UNCHANGED despite +> Citation 6's hardware observation. Branch A's ledger update (gate 2 → `N/A (USB path +> observed, not PCIe)`, gate 3 → `CLEARED`) is step 4 of the Branch-A procedure and is +> explicitly gated on human sign-off, which has not been given. Independently of that gate, +> gate 3 could not be closed on this evidence anyway: no SIM is installed, so no registration +> or bearer/data-session smoke ran — USB enumeration is not an end-to-end HIL pass. + ## Bench probe evidence (RB-16) On 2026-08-16, [`docs/BENCH.md`](BENCH.md) § RB-16 was run against the live bench board @@ -179,6 +303,28 @@ classify. The three-gate ledger stays exactly as recorded: gate 1 CLEARED, gates This probe does not close gate 3; it is a documented non-event, recorded so a future reader does not have to re-derive "was the unit ever actually checked on this bench." +### RB-16 re-run, 2026-08-17 — the unit IS connected, on USB + +Same board, same image, one day later: the FM350-GL is physically present and enumerated at +`/sys/bus/usb/devices/1-1.2` as `0e8d:7127`, claimed by ModemManager 1.24.2 as Modem/4 via the +`generic` plugin over `option` + `rndis_host`. The PCIe sweep is unchanged from the 2026-08-16 +pass (no `14c3:4d75`, no `/sys/class/wwan/`, no `mtk_t7xx`). Steps 5-6 of RB-16 did not run: +no SIM is installed (`state: failed / sim-missing`), so the bearer smoke has nothing to connect +and the port-cycle harness was not exercised — neither was simulated. + +Per the mechanical rule, **branch 1 (USB VID:PID) fires, with its contrary-evidence HARD STOP**. +Full detail, exact descriptors, and driver bindings: [Citation 6](#citation-6--hardware-a-real-fm350-gl-observed-on-the-usb-bus-2026-08-17). +Evidence bundle: `test-results/modem-phase-b/65/` (repo-local, gitignored). + +**Read-only observation on the classifier path (no change made):** on this board the device is +MM-managed — `nmcli` reports `ttyUSB12:gsm:unavailable`, i.e. NetworkManager sees it as a modem +port owned by ModemManager, and its RNDIS interface `enx000011121314` (MAC `00:00:11:12:13:14`, +no IPv4 lease) is not under NM management and hands out no DHCP address, so it does not present +as a router-mode dongle. No classifier in this repo or in CeraUI was modified, and none has an +entry for `0e8d:7127`; an unrecognized descriptor set is correctly `unmanaged` by the USB-only +classifier's own honesty rule. That is the current, truthful behavior — changing it is exactly +the decision this HARD STOP defers. + ## Gate-ledger update template — fill in on the next bench run that captures real FM350 data The mechanical rule above is unambiguous once real bus data exists. Whoever next runs RB-16 @@ -188,6 +334,18 @@ invent a third outcome, and never promote a matrix/certification claim from this ### Branch A — USB VID:PID observed (`0e8d:7126` or `14c3:4d75` on the USB bus) +> **THIS BRANCH FIRED on 2026-08-17 — steps 1-3 DONE, steps 4-5 BLOCKED on human sign-off.** +> The observed id is `0e8d:7127`, a third value this template did not anticipate (see +> [Citation 6](#citation-6--hardware-a-real-fm350-gl-observed-on-the-usb-bus-2026-08-17)); it is +> still a USB VID:PID for the FM350, so the branch applies unchanged. +> Step 1 (STOP — no classifier entry, branch, or fixture added): **done**. +> Step 2 (record VID:PID + sysfs node + driver binding as Citation 6 with the evidence-bundle +> path): **done** — bundle `test-results/modem-phase-b/65/`. +> Step 3 (surface to a human, verbatim): **done** — the message in that step was reported to the +> operator on 2026-08-17. +> Steps 4 (ledger update + classifier fixture) and 5 (matrix/certification) are **NOT done** and +> must not be done without that sign-off. + This is the mechanical rule's **branch-1 contrary-evidence HARD STOP** (line ~27 above): a USB VID:PID for the FM350 would contradict Citations 1-4's PCIe-only finding from the verified MM 1.24.2 source. **Do not silently update the classifier** — this is exactly the case the rule From eb710392825e86ef9c050ad588bb48ca5c991714 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Mon, 17 Aug 2026 00:55:23 -0500 Subject: [PATCH 12/12] docs(fm350): close adapter-mediated decision stop --- docs/FM350-DECISION.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index de5de7e..959c466 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -367,6 +367,24 @@ says to stop and surface to a human. clears the SAME per-SKU certification ladder every other USB modem does (a real `certify` bundle, `synthetic:false` for the exact SKU+firmware). +### Branch-A STOP closed — adapter-mediated bench observation (2026-08-17) + +The human clarification was: **"It is being connected through an adapter M2 to USB."** The +bench FM350 is mounted through an M.2-to-USB carrier/adapter, whose USB identity is interposed +in front of the module. Therefore Citation 6 remains accurate evidence of what this bench +observed (`0e8d:7127`, `rndis_host` + `option`), but it is an adapter artifact and is not +evidence that the FM350 has a native USB mode. A production board seats the FM350 in a real +M.2 PCIe slot, so the ModemManager source-audit conclusion for the shipping topology remains +correct and unchanged. + +Decision: **no classifier change**. This is deliberate: no classifier code, branch, type, or +fixture is added, and no CeraUI classifier is touched. The three-gate ledger remains exactly +as todo 65 left it — gate 1 **CLEARED**, gate 2 **OPEN**, and gate 3 **OPEN** — and the +FM350's **documented-deferred** PCIe conclusion remains unchanged. Consistent with the +existing rule, USB enumeration alone does not promote support, matrix, or certification +status. This note closes the Branch-A human-decision-required STOP; it does not retract or +rewrite Citation 6. + ### Branch B — PCIe-only observed (`14c3:4d75` on the PCI bus, `mtk_t7xx` driver bound, no USB VID:PID) This CONFIRMS the mechanical rule's already-fired branch 3 (documented-deferred) with real