@ceralive/modem-control 0.1.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.
Files changed (108) hide show
  1. package/package.json +31 -0
  2. package/src/backend/at-lease.test.ts +106 -0
  3. package/src/backend/at-lease.ts +158 -0
  4. package/src/backend/cell-info.test.ts +154 -0
  5. package/src/backend/cell-info.ts +160 -0
  6. package/src/backend/constants.ts +35 -0
  7. package/src/backend/device-classifier.test.ts +168 -0
  8. package/src/backend/device-classifier.ts +240 -0
  9. package/src/backend/enrichment.ts +96 -0
  10. package/src/backend/features.test.ts +158 -0
  11. package/src/backend/features.ts +179 -0
  12. package/src/backend/identity-ladder.test.ts +117 -0
  13. package/src/backend/identity-ladder.ts +221 -0
  14. package/src/backend/identity-registry.test.ts +89 -0
  15. package/src/backend/identity-registry.ts +151 -0
  16. package/src/backend/index.ts +221 -0
  17. package/src/backend/lifecycle-interlock.ts +38 -0
  18. package/src/backend/managed-objects.ts +108 -0
  19. package/src/backend/mapping.ts +160 -0
  20. package/src/backend/mm-backend.ts +191 -0
  21. package/src/backend/mm-mutations.ts +228 -0
  22. package/src/backend/modem-actor.test.ts +95 -0
  23. package/src/backend/modem-actor.ts +112 -0
  24. package/src/backend/nm-auto-apn.ts +161 -0
  25. package/src/backend/nm-gsm-fields.ts +122 -0
  26. package/src/backend/nmcli-nm-port.ts +228 -0
  27. package/src/backend/nmcli-runner.ts +52 -0
  28. package/src/backend/observer.ts +297 -0
  29. package/src/backend/power-contract.test.ts +40 -0
  30. package/src/backend/power-contract.ts +83 -0
  31. package/src/backend/recovery-attribution.test.ts +102 -0
  32. package/src/backend/recovery-attribution.ts +86 -0
  33. package/src/backend/recovery-budget.test.ts +64 -0
  34. package/src/backend/recovery-budget.ts +84 -0
  35. package/src/backend/recovery-ladder.test.ts +257 -0
  36. package/src/backend/recovery-ladder.ts +249 -0
  37. package/src/backend/router-ethernet.test.ts +71 -0
  38. package/src/backend/router-ethernet.ts +90 -0
  39. package/src/backend/row-store.ts +105 -0
  40. package/src/backend/signal-setup.ts +112 -0
  41. package/src/backend/sim-unlock.ts +193 -0
  42. package/src/backend/transition-preconditions.ts +149 -0
  43. package/src/backend/usage/accounting.test.ts +147 -0
  44. package/src/backend/usage/accounting.ts +123 -0
  45. package/src/backend/usage/billing-cycle.test.ts +62 -0
  46. package/src/backend/usage/billing-cycle.ts +45 -0
  47. package/src/backend/usage/boot-id.ts +18 -0
  48. package/src/backend/usage/index.ts +37 -0
  49. package/src/backend/usage/proc-net-dev.test.ts +56 -0
  50. package/src/backend/usage/proc-net-dev.ts +77 -0
  51. package/src/backend/usage/sampler.test.ts +219 -0
  52. package/src/backend/usage/sampler.ts +228 -0
  53. package/src/backend/usage/store.test.ts +148 -0
  54. package/src/backend/usage/store.ts +177 -0
  55. package/src/backend/usb-enumerator.test.ts +87 -0
  56. package/src/backend/usb-enumerator.ts +181 -0
  57. package/src/backend/usb-mode-transition.test.ts +323 -0
  58. package/src/backend/usb-mode-transition.ts +253 -0
  59. package/src/domain/brand.ts +29 -0
  60. package/src/domain/errors.ts +77 -0
  61. package/src/domain/guards.test.ts +218 -0
  62. package/src/domain/guards.ts +144 -0
  63. package/src/domain/identity.test.ts +83 -0
  64. package/src/domain/identity.ts +165 -0
  65. package/src/domain/index.ts +12 -0
  66. package/src/domain/policy.ts +137 -0
  67. package/src/domain/snapshot.test.ts +266 -0
  68. package/src/domain/snapshot.ts +120 -0
  69. package/src/domain/state.ts +189 -0
  70. package/src/index.test.ts +6 -0
  71. package/src/index.ts +15 -0
  72. package/src/ports/README.md +61 -0
  73. package/src/ports/forbidden-surface.test.ts +80 -0
  74. package/src/ports/index.ts +16 -0
  75. package/src/ports/modem-manager.ts +72 -0
  76. package/src/ports/network-manager.ts +87 -0
  77. package/src/ports/observation.ts +48 -0
  78. package/src/ports/ops.ts +60 -0
  79. package/src/ports/ops.type-test.ts +39 -0
  80. package/src/ports/receipts.test.ts +153 -0
  81. package/src/ports/receipts.ts +43 -0
  82. package/src/ports/reconcile.test.ts +152 -0
  83. package/src/ports/reconcile.ts +338 -0
  84. package/src/ports/router.ts +29 -0
  85. package/src/redact.test.ts +82 -0
  86. package/src/redact.ts +73 -0
  87. package/src/transport/README.md +65 -0
  88. package/src/transport/codec.test.ts +118 -0
  89. package/src/transport/codec.ts +240 -0
  90. package/src/transport/conformance-python.test.ts +152 -0
  91. package/src/transport/conformance-same-lib.test.ts +115 -0
  92. package/src/transport/dbus-native-lib.d.ts +19 -0
  93. package/src/transport/dbus-native.ts +85 -0
  94. package/src/transport/errors.ts +74 -0
  95. package/src/transport/index.ts +30 -0
  96. package/src/transport/no-library-leak.test.ts +50 -0
  97. package/src/transport/reliability.test.ts +173 -0
  98. package/src/transport/signature.ts +110 -0
  99. package/src/transport/test-support/fake-service.ts +143 -0
  100. package/src/transport/test-support/independent-producer.py +110 -0
  101. package/src/transport/test-support/private-bus.ts +66 -0
  102. package/src/transport/transport.ts +439 -0
  103. package/src/transport/types.ts +118 -0
  104. package/src/usb-mode/catalog-schema.test.ts +181 -0
  105. package/src/usb-mode/catalog-schema.ts +113 -0
  106. package/src/usb-mode/catalog.ts +60 -0
  107. package/src/usb-mode/certified-catalog.json +67 -0
  108. package/src/usb-mode/index.ts +27 -0
@@ -0,0 +1,118 @@
1
+ // Public value and message types for the D-Bus transport seam.
2
+ //
3
+ // These types are the transport's own vocabulary. None of the underlying
4
+ // `@httptoolkit/dbus-native` types appear here or in `./index.ts`, so the package's
5
+ // public surface never leaks the library — swapping the implementation (see README:
6
+ // documented fallback `@particle/dbus-next`) would not change a single caller type.
7
+
8
+ // A decoded / encodable D-Bus value.
9
+ //
10
+ // 64-bit integers (`x`, `t`) are ALWAYS `bigint`, never `number`. Byte arrays (`ay`)
11
+ // are `Uint8Array`. Arrays, structs, and dict entries are all plain arrays; a dict
12
+ // `a{KV}` decodes to an array of `[key, value]` entry pairs, preserving order and
13
+ // tolerating duplicate/non-string keys losslessly. Variants are wrapped in
14
+ // `DbusVariant` so their inner signature survives a round-trip.
15
+ export type DbusValue = string | number | boolean | bigint | Uint8Array | DbusVariant | DbusValue[];
16
+
17
+ // A D-Bus variant (`v`): a value tagged with the signature of its contained type.
18
+ export interface DbusVariant {
19
+ readonly signature: string;
20
+ readonly value: DbusValue;
21
+ }
22
+
23
+ // Construct a variant for encoding, e.g. `variant('u', 42)` or `variant('t', 5n)`.
24
+ export function variant(signature: string, value: DbusValue): DbusVariant {
25
+ return { signature, value };
26
+ }
27
+
28
+ // Narrowing guard for a decoded variant.
29
+ export function isVariant(value: DbusValue): value is DbusVariant {
30
+ return (
31
+ typeof value === 'object' &&
32
+ value !== null &&
33
+ !Array.isArray(value) &&
34
+ !(value instanceof Uint8Array) &&
35
+ typeof (value as DbusVariant).signature === 'string' &&
36
+ 'value' in value
37
+ );
38
+ }
39
+
40
+ // An outgoing method call.
41
+ export interface MethodCall {
42
+ readonly destination: string;
43
+ readonly path: string;
44
+ readonly interface: string;
45
+ readonly member: string;
46
+ // D-Bus signature of `args`. Omit (or empty) for a no-argument call.
47
+ readonly signature?: string;
48
+ readonly args?: readonly DbusValue[];
49
+ // Per-call reply timeout in ms. Defaults to the transport's configured timeout.
50
+ readonly timeoutMs?: number;
51
+ }
52
+
53
+ // A decoded method reply.
54
+ export interface MethodReply {
55
+ readonly signature: string;
56
+ readonly body: DbusValue[];
57
+ }
58
+
59
+ // A signal subscription filter. `interface` and `member` are required; `path` and
60
+ // `sender` narrow further when supplied (MM emits the same signal from many paths).
61
+ export interface SignalSpec {
62
+ readonly interface: string;
63
+ readonly member: string;
64
+ readonly path?: string;
65
+ readonly sender?: string;
66
+ }
67
+
68
+ // A decoded signal delivered to a subscriber.
69
+ export interface SignalEvent {
70
+ readonly path: string;
71
+ readonly interface: string;
72
+ readonly member: string;
73
+ readonly sender: string | undefined;
74
+ readonly signature: string;
75
+ readonly body: DbusValue[];
76
+ }
77
+
78
+ export type SignalListener = (event: SignalEvent) => void;
79
+
80
+ // Handle returned by `subscribeSignal`; call `unsubscribe` to detach the listener and
81
+ // (when it was the last subscriber for its match rule) drop the bus-side match.
82
+ export interface Subscription {
83
+ unsubscribe(): Promise<void>;
84
+ }
85
+
86
+ export type TransportEvent = 'connected' | 'reconnected' | 'disconnected' | 'error';
87
+
88
+ export interface DbusTransportOptions {
89
+ // Encoded bus address. Defaults to `DBUS_SESSION_BUS_ADDRESS`.
90
+ readonly busAddress?: string;
91
+ // Unix socket path (an alternative to `busAddress` for a private test bus).
92
+ readonly socket?: string;
93
+ // Default per-call reply timeout in ms (default 30000).
94
+ readonly callTimeoutMs?: number;
95
+ // Automatic reconnect after an unexpected bus drop (default enabled).
96
+ readonly reconnect?: ReconnectOptions;
97
+ }
98
+
99
+ export interface ReconnectOptions {
100
+ readonly enabled?: boolean;
101
+ readonly initialDelayMs?: number;
102
+ readonly maxDelayMs?: number;
103
+ // 0 = retry forever (default).
104
+ readonly maxAttempts?: number;
105
+ }
106
+
107
+ // The transport seam. This is the entire surface the A3.x D-Bus backend builds on.
108
+ export interface DbusTransport {
109
+ connect(): Promise<void>;
110
+ disconnect(): Promise<void>;
111
+ isConnected(): boolean;
112
+ callMethod(call: MethodCall): Promise<MethodReply>;
113
+ subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription>;
114
+ on(event: TransportEvent, handler: (payload?: unknown) => void): void;
115
+ off(event: TransportEvent, handler: (payload?: unknown) => void): void;
116
+ // Number of live signal subscriptions — exposed for leak assertions in tests.
117
+ subscriptionCount(): number;
118
+ }
@@ -0,0 +1,181 @@
1
+ // The catalog schema is a SAFETY BOUNDARY: the two invariants it enforces (only
2
+ // within-MM transitions are expressible; no unknown field slips through) are what
3
+ // keep an uncertified or MM↔router switch from ever becoming valid catalog data.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import {
7
+ CERTIFIED_CATALOG,
8
+ findCatalogEntry,
9
+ findPermittedTransition,
10
+ loadCertifiedCatalog,
11
+ } from './catalog';
12
+ import { catalogEntrySchema, certifiedCatalogSchema } from './catalog-schema';
13
+
14
+ function validCatalog(): unknown {
15
+ return {
16
+ schemaVersion: 1,
17
+ entries: [
18
+ {
19
+ vidPid: '2c7c:0125',
20
+ model: 'TEST-SKU',
21
+ firmwarePrefix: 'FW01',
22
+ canonicalMode: 'qmi',
23
+ permittedTransitions: [
24
+ {
25
+ from: 'qmi',
26
+ to: 'mbim',
27
+ atCommand: 'AT+QCFG="usbnet",2',
28
+ expectedResponse: 'OK',
29
+ expectsPortDrop: true,
30
+ expectedDescriptors: {
31
+ deviceClass: 0,
32
+ interfaces: [{ interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }],
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ };
39
+ }
40
+
41
+ function firstTransition(catalog: ReturnType<typeof validCatalog>): Record<string, unknown> {
42
+ // biome-ignore lint/suspicious/noExplicitAny: test helper reaching into a plain fixture
43
+ return (catalog as any).entries[0].permittedTransitions[0];
44
+ }
45
+
46
+ function firstEntry(catalog: ReturnType<typeof validCatalog>): Record<string, unknown> {
47
+ // biome-ignore lint/suspicious/noExplicitAny: test helper reaching into a plain fixture
48
+ return (catalog as any).entries[0];
49
+ }
50
+
51
+ describe('certified catalog schema — accepts valid data', () => {
52
+ test('a well-formed catalog parses', () => {
53
+ expect(certifiedCatalogSchema.safeParse(validCatalog()).success).toBe(true);
54
+ });
55
+
56
+ test('a router-ethernet SKU with NO transitions is valid', () => {
57
+ const catalog = validCatalog() as { entries: Array<Record<string, unknown>> };
58
+ catalog.entries[0] = {
59
+ vidPid: '12d1:14db',
60
+ model: 'HILINK-STICK',
61
+ firmwarePrefix: 'HILINK',
62
+ canonicalMode: 'router-ethernet',
63
+ permittedTransitions: [],
64
+ };
65
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(true);
66
+ });
67
+ });
68
+
69
+ describe('certified catalog schema — rejects MM↔router transitions (schema-invalid)', () => {
70
+ test('a transition TO router-ethernet fails to parse', () => {
71
+ const catalog = validCatalog();
72
+ firstTransition(catalog).to = 'router-ethernet';
73
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
74
+ });
75
+
76
+ test('a transition FROM router-ethernet fails to parse', () => {
77
+ const catalog = validCatalog();
78
+ firstTransition(catalog).from = 'router-ethernet';
79
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
80
+ });
81
+
82
+ test('a transition touching rndis fails to parse', () => {
83
+ const catalog = validCatalog();
84
+ firstTransition(catalog).to = 'rndis';
85
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
86
+ });
87
+
88
+ test('a router-ethernet SKU may NOT declare any transition', () => {
89
+ const catalog = validCatalog();
90
+ firstEntry(catalog).canonicalMode = 'router-ethernet';
91
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('certified catalog schema — strict mode rejects unknown fields', () => {
96
+ test('an unknown field on the catalog root is rejected', () => {
97
+ const catalog = validCatalog() as Record<string, unknown>;
98
+ catalog.smuggled = true;
99
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
100
+ });
101
+
102
+ test('an unknown field on an entry is rejected', () => {
103
+ const catalog = validCatalog();
104
+ firstEntry(catalog).hidden = 'x';
105
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
106
+ });
107
+
108
+ test('an unknown field on a transition is rejected', () => {
109
+ const catalog = validCatalog();
110
+ firstTransition(catalog).sneaky = 1;
111
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('certified catalog schema — field validation', () => {
116
+ test('a self-transition (from === to) is rejected', () => {
117
+ const catalog = validCatalog();
118
+ firstTransition(catalog).to = 'qmi';
119
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
120
+ });
121
+
122
+ test('a malformed VID:PID is rejected', () => {
123
+ const catalog = validCatalog();
124
+ firstEntry(catalog).vidPid = '2c7c-0125';
125
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
126
+ });
127
+
128
+ test('a schemaVersion other than 1 is rejected', () => {
129
+ const catalog = validCatalog() as Record<string, unknown>;
130
+ catalog.schemaVersion = 2;
131
+ expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false);
132
+ });
133
+
134
+ test('a valid evidence-bundle sha256 is accepted; a malformed one is rejected', () => {
135
+ const good = validCatalog();
136
+ firstTransition(good).evidenceBundleSha256 = 'a'.repeat(64);
137
+ expect(certifiedCatalogSchema.safeParse(good).success).toBe(true);
138
+
139
+ const bad = validCatalog();
140
+ firstTransition(bad).evidenceBundleSha256 = 'nothex';
141
+ expect(certifiedCatalogSchema.safeParse(bad).success).toBe(false);
142
+ });
143
+
144
+ test('loadCertifiedCatalog throws on invalid input', () => {
145
+ expect(() => loadCertifiedCatalog({ schemaVersion: 1 })).toThrow();
146
+ });
147
+
148
+ test('a lone entry also validates against catalogEntrySchema', () => {
149
+ const entry = (validCatalog() as { entries: unknown[] }).entries[0];
150
+ expect(catalogEntrySchema.safeParse(entry).success).toBe(true);
151
+ });
152
+ });
153
+
154
+ describe('the shipped certified catalog', () => {
155
+ test('validates and exposes the synthetic test SKU with within-MM transitions', () => {
156
+ const sku = {
157
+ vidPid: '2c7c:0125',
158
+ model: 'CERALIVE-SYNTHETIC-TEST-SKU',
159
+ firmwarePrefix: 'SYNTHETICFW01',
160
+ };
161
+ const entry = findCatalogEntry(CERTIFIED_CATALOG, sku);
162
+ expect(entry).toBeDefined();
163
+ expect(entry?.canonicalMode).toBe('qmi');
164
+ const transition =
165
+ entry !== undefined ? findPermittedTransition(entry, 'qmi', 'mbim') : undefined;
166
+ expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2');
167
+ // A transition the catalog does NOT declare is absent (never a guess).
168
+ expect(
169
+ entry !== undefined ? findPermittedTransition(entry, 'mbim', 'ecm-ncm') : undefined,
170
+ ).toBeUndefined();
171
+ });
172
+
173
+ test('an unmatched SKU discriminator returns undefined (uncertified)', () => {
174
+ const entry = findCatalogEntry(CERTIFIED_CATALOG, {
175
+ vidPid: '2c7c:0125',
176
+ model: 'CERALIVE-SYNTHETIC-TEST-SKU',
177
+ firmwarePrefix: 'WRONGFW',
178
+ });
179
+ expect(entry).toBeUndefined();
180
+ });
181
+ });
@@ -0,0 +1,113 @@
1
+ // The certified USB-mode catalog schema — the contract that gates a mode switch.
2
+ //
3
+ // A catalog ENTRY is one server-derived SKU discriminator (VID:PID + model +
4
+ // firmware prefix) plus everything a certified transition needs: the canonical mode
5
+ // the SKU speaks, the AT commands that switch it, the expected AT response and
6
+ // port-drop behaviour, the USB descriptors it should present AFTER the switch, and a
7
+ // forward-reference to the evidence bundle that certified it. Certification is a
8
+ // human-reviewed commit that adds an entry — evidence bundles are inputs to that
9
+ // review, never read at runtime.
10
+ //
11
+ // TWO safety invariants are enforced by the SCHEMA ITSELF, not by convention:
12
+ // 1. Permitted transitions are WITHIN-ModemManager only — `qmi ↔ mbim ↔ ecm-ncm`.
13
+ // A transition's `from`/`to` are typed to the MM-mode enum, so any attempt to
14
+ // declare an `MM ↔ router` (or `rndis`) transition FAILS to parse.
15
+ // 2. `.strict()` everywhere — an entry carrying an unknown field is rejected, so a
16
+ // typo or a smuggled extra field can never slip past review as valid data.
17
+
18
+ import { z } from 'zod';
19
+
20
+ /**
21
+ * The full canonical USB composition-mode vocabulary. A SKU's `canonicalMode` may be
22
+ * any of these; only the first three are ModemManager-manageable. (Structurally
23
+ * identical to `PreferredUsbMode` in the power contract — A4.2 owns this vocabulary.)
24
+ */
25
+ export const CANONICAL_USB_MODES = ['qmi', 'mbim', 'ecm-ncm', 'rndis', 'router-ethernet'] as const;
26
+ export type CanonicalUsbMode = (typeof CANONICAL_USB_MODES)[number];
27
+
28
+ /**
29
+ * The ModemManager-manageable modes — the ONLY modes a transition may move between.
30
+ * `rndis` and `router-ethernet` are deliberately absent: a device in either is not
31
+ * MM-managed, so switching to/from them crosses the MM↔router line the schema forbids.
32
+ */
33
+ export const MM_USB_MODES = ['qmi', 'mbim', 'ecm-ncm'] as const;
34
+ export type MmUsbMode = (typeof MM_USB_MODES)[number];
35
+
36
+ const canonicalMode = z.enum(CANONICAL_USB_MODES);
37
+ const mmMode = z.enum(MM_USB_MODES);
38
+
39
+ /** A `bInterfaceClass`/`bInterfaceSubClass`/`bInterfaceProtocol` byte triple. */
40
+ const usbByte = z.number().int().min(0).max(255);
41
+
42
+ /** The USB descriptors a device must present AFTER a transition — the postcondition. */
43
+ export const expectedDescriptorsSchema = z.strictObject({
44
+ deviceClass: usbByte,
45
+ interfaces: z
46
+ .array(
47
+ z.strictObject({
48
+ interfaceClass: usbByte,
49
+ interfaceSubClass: usbByte,
50
+ interfaceProtocol: usbByte,
51
+ }),
52
+ )
53
+ .min(1),
54
+ });
55
+ export type ExpectedDescriptors = z.infer<typeof expectedDescriptorsSchema>;
56
+
57
+ /** One permitted, certified transition between two MM modes. */
58
+ export const permittedTransitionSchema = z
59
+ .strictObject({
60
+ from: mmMode,
61
+ to: mmMode,
62
+ /** The EXACT AT command that performs the switch (allowlisted at send time). */
63
+ atCommand: z.string().min(1),
64
+ /** The AT response expected on success (e.g. `OK`) — never proof on its own. */
65
+ expectedResponse: z.string().min(1),
66
+ /** Whether the control port is expected to drop after the command is written. */
67
+ expectsPortDrop: z.boolean(),
68
+ /** The descriptors the device must present after re-enumerating — the postcondition. */
69
+ expectedDescriptors: expectedDescriptorsSchema,
70
+ /** sha256 of the evidence bundle that certified this transition (A6.2 fills it). */
71
+ evidenceBundleSha256: z
72
+ .string()
73
+ .regex(/^[0-9a-f]{64}$/)
74
+ .optional(),
75
+ })
76
+ .refine((t) => t.from !== t.to, { message: 'a transition must change mode (from !== to)' });
77
+ export type PermittedTransition = z.infer<typeof permittedTransitionSchema>;
78
+
79
+ /** One certified SKU: its discriminator, canonical mode, and permitted transitions. */
80
+ export const catalogEntrySchema = z
81
+ .strictObject({
82
+ /** VID:PID, lowercase hex, `xxxx:xxxx`. */
83
+ vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/),
84
+ /** The server-derived model string (a discriminator, not free text). */
85
+ model: z.string().min(1),
86
+ /** The firmware-revision prefix that discriminates this personality. */
87
+ firmwarePrefix: z.string().min(1),
88
+ /** The mode this SKU speaks as certified. */
89
+ canonicalMode,
90
+ /** The certified transitions — empty for a non-MM (router/rndis) SKU. */
91
+ permittedTransitions: z.array(permittedTransitionSchema),
92
+ })
93
+ .refine(
94
+ (e) =>
95
+ e.permittedTransitions.length === 0 ||
96
+ (MM_USB_MODES as readonly string[]).includes(e.canonicalMode),
97
+ { message: 'only an MM-mode SKU (qmi/mbim/ecm-ncm) may declare permitted transitions' },
98
+ );
99
+ export type CatalogEntry = z.infer<typeof catalogEntrySchema>;
100
+
101
+ /** The whole certified catalog file. */
102
+ export const certifiedCatalogSchema = z.strictObject({
103
+ schemaVersion: z.literal(1),
104
+ entries: z.array(catalogEntrySchema),
105
+ });
106
+ export type CertifiedCatalog = z.infer<typeof certifiedCatalogSchema>;
107
+
108
+ /** A live device's SKU discriminator — matched against the catalog. */
109
+ export interface SkuDiscriminator {
110
+ readonly vidPid: string;
111
+ readonly model: string;
112
+ readonly firmwarePrefix: string;
113
+ }
@@ -0,0 +1,60 @@
1
+ // Loading and querying the certified USB-mode catalog.
2
+ //
3
+ // The catalog is validated on load — a malformed file fails LOUDLY, never silently
4
+ // half-parsed. The certified-catalog.json shipped in this package is validated once
5
+ // at module load and exposed as `CERTIFIED_CATALOG`; a caller that wants to validate
6
+ // an alternate file (e.g. A6.2's `certify` tool checking a candidate entry) uses
7
+ // `loadCertifiedCatalog`. The evidence bundles the entries reference are NOT read
8
+ // here — only the catalog metadata is.
9
+
10
+ import {
11
+ type CatalogEntry,
12
+ type CertifiedCatalog,
13
+ certifiedCatalogSchema,
14
+ type MmUsbMode,
15
+ type PermittedTransition,
16
+ type SkuDiscriminator,
17
+ } from './catalog-schema';
18
+ import rawCatalog from './certified-catalog.json' with { type: 'json' };
19
+
20
+ /**
21
+ * Parse and validate an untrusted value as a certified catalog. Throws a `ZodError`
22
+ * with a precise path if the value violates the schema (unknown field, an MM↔router
23
+ * transition, a bad VID:PID, etc.).
24
+ */
25
+ export function loadCertifiedCatalog(value: unknown): CertifiedCatalog {
26
+ return certifiedCatalogSchema.parse(value);
27
+ }
28
+
29
+ /** The catalog shipped in this package, validated at module load. */
30
+ export const CERTIFIED_CATALOG: CertifiedCatalog = loadCertifiedCatalog(rawCatalog);
31
+
32
+ /**
33
+ * Find the catalog entry matching a live device's SKU discriminator. All three
34
+ * discriminators (VID:PID, model, firmware prefix) must match — a partial match is
35
+ * NOT a certified device. Returns `undefined` for an uncertified SKU (never a guess).
36
+ */
37
+ export function findCatalogEntry(
38
+ catalog: CertifiedCatalog,
39
+ sku: SkuDiscriminator,
40
+ ): CatalogEntry | undefined {
41
+ return catalog.entries.find(
42
+ (entry) =>
43
+ entry.vidPid === sku.vidPid &&
44
+ entry.model === sku.model &&
45
+ entry.firmwarePrefix === sku.firmwarePrefix,
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Find the permitted transition `from → to` in a catalog entry. Returns `undefined`
51
+ * when the entry declares no such transition — the caller MUST treat that as "not
52
+ * permitted", never as "permitted with no command".
53
+ */
54
+ export function findPermittedTransition(
55
+ entry: CatalogEntry,
56
+ from: MmUsbMode,
57
+ to: MmUsbMode,
58
+ ): PermittedTransition | undefined {
59
+ return entry.permittedTransitions.find((t) => t.from === from && t.to === to);
60
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "entries": [
4
+ {
5
+ "vidPid": "2c7c:0125",
6
+ "model": "CERALIVE-SYNTHETIC-TEST-SKU",
7
+ "firmwarePrefix": "SYNTHETICFW01",
8
+ "canonicalMode": "qmi",
9
+ "permittedTransitions": [
10
+ {
11
+ "from": "qmi",
12
+ "to": "mbim",
13
+ "atCommand": "AT+QCFG=\"usbnet\",2",
14
+ "expectedResponse": "OK",
15
+ "expectsPortDrop": true,
16
+ "expectedDescriptors": {
17
+ "deviceClass": 0,
18
+ "interfaces": [
19
+ { "interfaceClass": 2, "interfaceSubClass": 14, "interfaceProtocol": 0 },
20
+ { "interfaceClass": 10, "interfaceSubClass": 0, "interfaceProtocol": 2 }
21
+ ]
22
+ }
23
+ },
24
+ {
25
+ "from": "mbim",
26
+ "to": "qmi",
27
+ "atCommand": "AT+QCFG=\"usbnet\",0",
28
+ "expectedResponse": "OK",
29
+ "expectsPortDrop": true,
30
+ "expectedDescriptors": {
31
+ "deviceClass": 0,
32
+ "interfaces": [
33
+ { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 }
34
+ ]
35
+ }
36
+ },
37
+ {
38
+ "from": "qmi",
39
+ "to": "ecm-ncm",
40
+ "atCommand": "AT+QCFG=\"usbnet\",1",
41
+ "expectedResponse": "OK",
42
+ "expectsPortDrop": true,
43
+ "expectedDescriptors": {
44
+ "deviceClass": 0,
45
+ "interfaces": [
46
+ { "interfaceClass": 2, "interfaceSubClass": 6, "interfaceProtocol": 0 },
47
+ { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 }
48
+ ]
49
+ }
50
+ },
51
+ {
52
+ "from": "ecm-ncm",
53
+ "to": "qmi",
54
+ "atCommand": "AT+QCFG=\"usbnet\",0",
55
+ "expectedResponse": "OK",
56
+ "expectsPortDrop": true,
57
+ "expectedDescriptors": {
58
+ "deviceClass": 0,
59
+ "interfaces": [
60
+ { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 }
61
+ ]
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ ]
67
+ }
@@ -0,0 +1,27 @@
1
+ // The certified USB-mode catalog — schema, data, and lookups.
2
+ //
3
+ // A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the
4
+ // CLI looks up the permitted transition for a target mode, `certify` validates a
5
+ // candidate entry against the schema before a human commits it.
6
+
7
+ export {
8
+ CERTIFIED_CATALOG,
9
+ findCatalogEntry,
10
+ findPermittedTransition,
11
+ loadCertifiedCatalog,
12
+ } from './catalog';
13
+ export {
14
+ CANONICAL_USB_MODES,
15
+ type CanonicalUsbMode,
16
+ type CatalogEntry,
17
+ type CertifiedCatalog,
18
+ catalogEntrySchema,
19
+ certifiedCatalogSchema,
20
+ type ExpectedDescriptors,
21
+ expectedDescriptorsSchema,
22
+ MM_USB_MODES,
23
+ type MmUsbMode,
24
+ type PermittedTransition,
25
+ permittedTransitionSchema,
26
+ type SkuDiscriminator,
27
+ } from './catalog-schema';