@ceralive/modem-control 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceralive/modem-control",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.",
6
6
  "license": "AGPL-3.0",
@@ -9,6 +9,14 @@
9
9
  // `mm-managed`; a bare vendor-specific interface with no recognized driver is NOT a
10
10
  // modem. `pending-modeswitch` is a DISTINCT state (a modem installer awaiting
11
11
  // `usb_modeswitch`), never conflated with `unmanaged`.
12
+ //
13
+ // SCOPE — USB ONLY: the whole input here is a `UsbDeviceSnapshot`, a udev/sysfs view of a
14
+ // USB device. PCIe modems are out of scope by construction and get NO entry in this model —
15
+ // a PCI `vendor:device` pair is never smuggled in as a pseudo-USB identity. The Fibocom
16
+ // FM350 is the canonical example: it is a PCIe module (PCI `14c3:4d75`, bound by the
17
+ // `mtk_t7xx` driver on the `wwan`/`net` subsystems, with no USB VID:PID), so it is
18
+ // documented-deferred rather than classified here. See `docs/FM350-DECISION.md` for the
19
+ // evidence and the three-gate ledger behind that decision.
12
20
 
13
21
  import type { CanonicalUsbMode, ExpectedDescriptors } from '../usb-mode';
14
22
 
@@ -22,6 +22,10 @@ describe('parseMmVersion', () => {
22
22
  expect(parseMmVersion('1.24.0')).toEqual({ major: 1, minor: 24 });
23
23
  });
24
24
 
25
+ test('parses the 1.24.2 FM350-fix release', () => {
26
+ expect(parseMmVersion('1.24.2')).toEqual({ major: 1, minor: 24 });
27
+ });
28
+
25
29
  test('parses major.minor without a patch', () => {
26
30
  expect(parseMmVersion('1.20')).toEqual({ major: 1, minor: 20 });
27
31
  });
@@ -210,6 +210,21 @@ export {
210
210
  type UsbModeTransitionOutcome,
211
211
  type UsbModeTransitionRequest,
212
212
  } from './transition-preconditions';
213
+ export {
214
+ createUhubctlPowerHook,
215
+ parseUhubctlPortMap,
216
+ readUhubctlPortMap,
217
+ SpawnUhubctlRunner,
218
+ type UhubctlPortMap,
219
+ type UhubctlPortMapping,
220
+ type UhubctlPowerHookDeps,
221
+ type UhubctlResult,
222
+ type UhubctlRunner,
223
+ type UsbEnumerationPoller,
224
+ uhubctlCycleArgv,
225
+ uhubctlPortMappingSchema,
226
+ uhubctlPortMapSchema,
227
+ } from './uhubctl-power-hook';
213
228
  export * from './usage';
214
229
  export {
215
230
  createUsbEnumerator,
@@ -0,0 +1,274 @@
1
+ // The uhubctl power hook — the refusals matter more than the happy path:
2
+ // - a mapped key cycles the right port and reports `applied` only on re-enumeration
3
+ // - an UNMAPPED key is `unsupported` with ZERO commands run
4
+ // - a non-zero uhubctl exit is `failed` and never claims re-enumeration
5
+ // - a modem that never comes back is `failed` with expected-vs-observed
6
+ // - wiring this hook into the ladder does NOT arm it: recovery.enabled=false still
7
+ // fires zero cycles
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { epochMillis, runtimePath } from '../domain';
11
+ import { ModemActor } from './modem-actor';
12
+ import { RecoveryLadder, type RecoveryRequest, type RecoverySteps } from './recovery-ladder';
13
+ import {
14
+ createUhubctlPowerHook,
15
+ parseUhubctlPortMap,
16
+ type UhubctlPortMap,
17
+ type UhubctlResult,
18
+ type UhubctlRunner,
19
+ type UsbEnumerationPoller,
20
+ uhubctlCycleArgv,
21
+ } from './uhubctl-power-hook';
22
+
23
+ const STABLE_KEY = 'slot:a';
24
+ const ID_PATH = 'platform-fc800000.usb-usb-0:1.4.1:1.2';
25
+
26
+ const PORTS: UhubctlPortMap = {
27
+ [STABLE_KEY]: { hubLocation: '1-1.4', port: 1 },
28
+ };
29
+
30
+ /** A runner that records every argv it was handed and returns a canned result. */
31
+ function fakeRunner(result: UhubctlResult, calls: string[][]): UhubctlRunner {
32
+ return {
33
+ run(argv) {
34
+ calls.push([...argv]);
35
+ return result;
36
+ },
37
+ };
38
+ }
39
+
40
+ const OK: UhubctlResult = { stdout: 'Sent power off request\n', stderr: '', exitCode: 0 };
41
+
42
+ /** A poller that walks a scripted sequence of ID_PATH observations. */
43
+ function scriptedPoller(sequence: readonly (string | undefined)[]): UsbEnumerationPoller {
44
+ let index = 0;
45
+ return {
46
+ idPathFor() {
47
+ const value = sequence[Math.min(index, sequence.length - 1)];
48
+ index += 1;
49
+ return value;
50
+ },
51
+ };
52
+ }
53
+
54
+ /** A clock that advances a fixed step per read — makes the timeout loop deterministic. */
55
+ function steppingClock(stepMs: number): () => number {
56
+ let value = 0;
57
+ return () => {
58
+ const current = value;
59
+ value += stepMs;
60
+ return current;
61
+ };
62
+ }
63
+
64
+ const context = { stableKey: STABLE_KEY, at: epochMillis(0) };
65
+ const noSleep = (): Promise<void> => Promise.resolve();
66
+
67
+ describe('uhubctl power hook — a mapped key cycles its port', () => {
68
+ test('applied: the exact argv is run and the SAME ID_PATH comes back', async () => {
69
+ const calls: string[][] = [];
70
+ const hook = createUhubctlPowerHook({
71
+ ports: PORTS,
72
+ runner: fakeRunner(OK, calls),
73
+ // Pre-cut observation, then absent, then the same path returns.
74
+ poller: scriptedPoller([ID_PATH, undefined, ID_PATH]),
75
+ sleep: noSleep,
76
+ });
77
+
78
+ expect(hook.capability.power).toBe('usb-hub-port-cycle');
79
+
80
+ const result = await hook.cycle(context);
81
+ expect(result.status).toBe('applied');
82
+ expect(result.reason).toContain(ID_PATH);
83
+ // Argv array, no shell, allowlisted flags — asserted byte-for-byte.
84
+ expect(calls).toEqual([['-l', '1-1.4', '-p', '1', '-a', 'cycle', '-d', '3']]);
85
+ });
86
+
87
+ test('the argv builder refuses a token that is not allowlisted', () => {
88
+ expect(uhubctlCycleArgv({ hubLocation: '1-1.4', port: 1 }, 3)).toEqual([
89
+ '-l',
90
+ '1-1.4',
91
+ '-p',
92
+ '1',
93
+ '-a',
94
+ 'cycle',
95
+ '-d',
96
+ '3',
97
+ ]);
98
+ // A mapping that evaded the schema cannot smuggle a flag into the argv.
99
+ expect(() => uhubctlCycleArgv({ hubLocation: '--force', port: 1 }, 3)).toThrow('allowlisted');
100
+ });
101
+
102
+ test('the schema rejects a hub location that is not a bus-port path', () => {
103
+ expect(() =>
104
+ parseUhubctlPortMap('{"slot:a":{"hubLocation":"; rm -rf /","port":1}}', 'x'),
105
+ ).toThrow('hubLocation');
106
+ });
107
+ });
108
+
109
+ describe('uhubctl power hook — an unmapped stable key is unsupported', () => {
110
+ test('no mapping ⇒ unsupported, and the runner is NEVER invoked', async () => {
111
+ const calls: string[][] = [];
112
+ const hook = createUhubctlPowerHook({
113
+ ports: PORTS,
114
+ runner: fakeRunner(OK, calls),
115
+ poller: { idPathFor: () => Promise.reject(new Error('poller must not be consulted')) },
116
+ sleep: noSleep,
117
+ });
118
+ const result = await hook.cycle({ stableKey: 'slot:unknown', at: epochMillis(0) });
119
+ expect(result.status).toBe('unsupported');
120
+ expect(result.reason).toContain('slot:unknown');
121
+ expect(calls).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe('uhubctl power hook — a failing cycle command fails', () => {
126
+ test('a non-zero exit is failed, carries stderr, and never claims re-enumeration', async () => {
127
+ const calls: string[][] = [];
128
+ const hook = createUhubctlPowerHook({
129
+ ports: PORTS,
130
+ runner: fakeRunner(
131
+ { stdout: '', stderr: 'No compatible devices detected!', exitCode: 1 },
132
+ calls,
133
+ ),
134
+ poller: scriptedPoller([ID_PATH, ID_PATH]),
135
+ sleep: noSleep,
136
+ });
137
+ const result = await hook.cycle(context);
138
+ expect(result.status).toBe('failed');
139
+ expect(result.reason).toContain('exited 1');
140
+ expect(result.reason).toContain('No compatible devices detected!');
141
+ expect(calls).toHaveLength(1);
142
+ });
143
+
144
+ test('a runner that throws is failed, not an unhandled rejection', async () => {
145
+ const hook = createUhubctlPowerHook({
146
+ ports: PORTS,
147
+ runner: { run: () => Promise.reject(new Error('uhubctl: command not found')) },
148
+ poller: scriptedPoller([ID_PATH]),
149
+ sleep: noSleep,
150
+ });
151
+ const result = await hook.cycle(context);
152
+ expect(result.status).toBe('failed');
153
+ expect(result.reason).toContain('command not found');
154
+ });
155
+ });
156
+
157
+ describe('uhubctl power hook — an enumeration timeout fails', () => {
158
+ test('the modem never returning is failed with expected-vs-observed', async () => {
159
+ const calls: string[][] = [];
160
+ const hook = createUhubctlPowerHook({
161
+ ports: PORTS,
162
+ runner: fakeRunner(OK, calls),
163
+ // Seen before the cut, then gone forever.
164
+ poller: scriptedPoller([ID_PATH, undefined]),
165
+ enumerationTimeoutMs: 1000,
166
+ pollIntervalMs: 250,
167
+ now: steppingClock(400),
168
+ sleep: noSleep,
169
+ });
170
+ const result = await hook.cycle(context);
171
+ expect(result.status).toBe('failed');
172
+ expect(result.reason).toContain('did not re-enumerate within 1000ms');
173
+ expect(result.reason).toContain(ID_PATH);
174
+ expect(result.reason).toContain('no device');
175
+ // The port WAS cycled — the failure is the postcondition, not the command.
176
+ expect(calls).toHaveLength(1);
177
+ });
178
+
179
+ test('a DIFFERENT device appearing at that key is not accepted as recovery', async () => {
180
+ const hook = createUhubctlPowerHook({
181
+ ports: PORTS,
182
+ runner: fakeRunner(OK, []),
183
+ poller: scriptedPoller([ID_PATH, 'platform-fc800000.usb-usb-0:9.9:1.0']),
184
+ enumerationTimeoutMs: 1000,
185
+ now: steppingClock(400),
186
+ sleep: noSleep,
187
+ });
188
+ const result = await hook.cycle(context);
189
+ expect(result.status).toBe('failed');
190
+ expect(result.reason).toContain('9.9');
191
+ });
192
+
193
+ test('an aborted signal ends the wait instead of hanging', async () => {
194
+ const controller = new AbortController();
195
+ controller.abort();
196
+ const hook = createUhubctlPowerHook({
197
+ ports: PORTS,
198
+ runner: fakeRunner(OK, []),
199
+ poller: scriptedPoller([ID_PATH, undefined]),
200
+ signal: controller.signal,
201
+ sleep: noSleep,
202
+ });
203
+ const result = await hook.cycle(context);
204
+ expect(result.status).toBe('failed');
205
+ expect(result.reason).toContain('cancelled');
206
+ });
207
+ });
208
+
209
+ describe('uhubctl power hook — wiring it in does NOT arm recovery', () => {
210
+ test('recovery.enabled=false fires ZERO uhubctl cycles even with a real hook installed', async () => {
211
+ const calls: string[][] = [];
212
+ const hook = createUhubctlPowerHook({
213
+ ports: PORTS,
214
+ runner: fakeRunner(OK, calls),
215
+ poller: { idPathFor: () => Promise.reject(new Error('poller must not be consulted')) },
216
+ sleep: noSleep,
217
+ });
218
+ const throwingSteps: RecoverySteps = {
219
+ nmCycle: () => Promise.reject(new Error('nmCycle must not fire')),
220
+ mmCycle: () => Promise.reject(new Error('mmCycle must not fire')),
221
+ reset: () => Promise.reject(new Error('reset must not fire')),
222
+ };
223
+ const request: RecoveryRequest = {
224
+ stableKey: STABLE_KEY,
225
+ modem: runtimePath('/org/freedesktop/ModemManager1/Modem/0'),
226
+ attribution: 'modem-fault',
227
+ now: epochMillis(0),
228
+ probeHealthy: () => Promise.resolve(false),
229
+ };
230
+ const ladder = new RecoveryLadder({
231
+ actor: new ModemActor(),
232
+ steps: throwingSteps,
233
+ powerHook: hook,
234
+ });
235
+
236
+ const outcome = await ladder.run({ enabled: false }, request);
237
+
238
+ expect(outcome.kind).toBe('disabled');
239
+ expect(outcome.steps).toEqual([]);
240
+ // The whole point: a REAL power hook is installed and still nothing ran.
241
+ expect(calls).toEqual([]);
242
+ });
243
+
244
+ test('the same hook DOES cycle once recovery is explicitly enabled', async () => {
245
+ const calls: string[][] = [];
246
+ const hook = createUhubctlPowerHook({
247
+ ports: PORTS,
248
+ runner: fakeRunner(OK, calls),
249
+ poller: scriptedPoller([ID_PATH, ID_PATH]),
250
+ sleep: noSleep,
251
+ });
252
+ const ladder = new RecoveryLadder({
253
+ actor: new ModemActor(),
254
+ steps: {
255
+ nmCycle: () => Promise.resolve({ status: 'failed', reason: 'x' }),
256
+ mmCycle: () => Promise.resolve({ status: 'failed', reason: 'x' }),
257
+ reset: () => Promise.resolve({ status: 'failed', reason: 'x' }),
258
+ },
259
+ powerHook: hook,
260
+ });
261
+ const outcome = await ladder.run(
262
+ { enabled: true },
263
+ {
264
+ stableKey: STABLE_KEY,
265
+ modem: runtimePath('/org/freedesktop/ModemManager1/Modem/0'),
266
+ attribution: 'modem-fault',
267
+ now: epochMillis(0),
268
+ probeHealthy: () => Promise.resolve(false),
269
+ },
270
+ );
271
+ expect(outcome.steps.find((s) => s.rung === 'powerCycle')?.status).toBe('applied');
272
+ expect(calls).toHaveLength(1);
273
+ });
274
+ });