@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,189 @@
1
+ // Orthogonal state dimensions.
2
+ //
3
+ // Draft §Oracle round-1: modem state is NOT one enum. It is a set of independent
4
+ // dimensions that vary separately — presence, SIM, radio, registration,
5
+ // NM-activation, data interface, reconcile, recovery. Collapsing them into a
6
+ // single enum loses real, simultaneously-true facts (e.g. "present + radio on +
7
+ // searching + no NM connection"). Each dimension below mirrors a real
8
+ // ModemManager / NetworkManager concept; values track those enums.
9
+
10
+ import type { Brand } from './brand';
11
+ import { nonNegativeInteger } from './brand';
12
+
13
+ /** Milliseconds since the Unix epoch. */
14
+ export type EpochMillis = Brand<number, 'EpochMillis'>;
15
+
16
+ /** Construct an `EpochMillis` from a non-negative integer timestamp. */
17
+ export function epochMillis(value: number): EpochMillis {
18
+ return nonNegativeInteger(value, 'epochMillis') as EpochMillis;
19
+ }
20
+
21
+ // --- 1. presence + source health -------------------------------------------
22
+
23
+ /** Whether the modem is in the current authoritative observation snapshot. */
24
+ export type Presence = 'present' | 'absent';
25
+
26
+ /**
27
+ * Health of the observation SOURCE (the ModemManager daemon / bus), independent
28
+ * of presence. When the source drops (owner loss, bus disconnect) the last data
29
+ * goes `stale` and then `sourceUnavailable` — it is NEVER silently turned into
30
+ * `absent`. Only an authoritative snapshot confirms real removal (A3.1 epochs).
31
+ */
32
+ export type SourceHealth = 'live' | 'stale' | 'sourceUnavailable';
33
+
34
+ // --- 2. SIM slots + lock ----------------------------------------------------
35
+
36
+ /** SIM lock state — subset of `MMModemLock` plus `unknown`. */
37
+ export type SimLock =
38
+ | 'unknown'
39
+ | 'none'
40
+ | 'sim-pin'
41
+ | 'sim-puk'
42
+ | 'sim-pin2'
43
+ | 'sim-puk2'
44
+ | 'net-pers'
45
+ | 'permanently-blocked';
46
+
47
+ /** A single physical SIM slot. */
48
+ export interface SimSlot {
49
+ /** 1-based slot index (ModemManager numbers slots from 1). */
50
+ readonly index: number;
51
+ /** A SIM card is physically inserted in this slot. */
52
+ readonly occupied: boolean;
53
+ /** This is the primary/active slot the modem is currently using. */
54
+ readonly active: boolean;
55
+ readonly lock: SimLock;
56
+ }
57
+
58
+ /** Lock states that require a SIM to actually be present in the slot. */
59
+ export const SIM_LOCK_REQUIRES_CARD: ReadonlySet<SimLock> = new Set<SimLock>([
60
+ 'sim-pin',
61
+ 'sim-puk',
62
+ 'sim-pin2',
63
+ 'sim-puk2',
64
+ 'net-pers',
65
+ 'permanently-blocked',
66
+ ]);
67
+
68
+ // --- 3. radio power + MM state ---------------------------------------------
69
+
70
+ /** Radio power state — `MMModemPowerState`. */
71
+ export type RadioPower = 'unknown' | 'off' | 'low' | 'on';
72
+
73
+ /** Modem lifecycle state — `MMModemState`. */
74
+ export type MmState =
75
+ | 'failed'
76
+ | 'unknown'
77
+ | 'initializing'
78
+ | 'locked'
79
+ | 'disabled'
80
+ | 'disabling'
81
+ | 'enabling'
82
+ | 'enabled'
83
+ | 'searching'
84
+ | 'registered'
85
+ | 'disconnecting'
86
+ | 'connecting'
87
+ | 'connected';
88
+
89
+ /**
90
+ * MM states that imply the radio is powered and actively on the air — none of
91
+ * these can coexist with `radioPower: 'off'`, and all imply the modem is present.
92
+ */
93
+ export const MM_STATES_REQUIRING_RADIO: ReadonlySet<MmState> = new Set<MmState>([
94
+ 'enabled',
95
+ 'searching',
96
+ 'registered',
97
+ 'connecting',
98
+ 'connected',
99
+ 'disconnecting',
100
+ ]);
101
+
102
+ // --- 4. registration + RAT set ---------------------------------------------
103
+
104
+ /** 3GPP registration state — `MMModem3gppRegistrationState`. */
105
+ export type RegistrationStatus = 'idle' | 'home' | 'searching' | 'denied' | 'unknown' | 'roaming';
106
+
107
+ /** Radio access technology family — subset of `MMModemAccessTechnology` groups. */
108
+ export type RadioAccessTechnology = 'gsm' | 'umts' | 'lte' | '5gnr';
109
+
110
+ /**
111
+ * Registration dimension: a status plus the SET of currently-active access
112
+ * technologies. MM's access-technology field is a bitmask (carrier aggregation
113
+ * can light more than one), so a set — not a single value — is the faithful model.
114
+ */
115
+ export interface Registration {
116
+ readonly status: RegistrationStatus;
117
+ readonly activeRats: ReadonlySet<RadioAccessTechnology>;
118
+ }
119
+
120
+ /** Registration statuses that mean the modem is attached to a network. */
121
+ export function isRegistered(status: RegistrationStatus): boolean {
122
+ return status === 'home' || status === 'roaming';
123
+ }
124
+
125
+ // --- 5. NM activation -------------------------------------------------------
126
+
127
+ /**
128
+ * NetworkManager connection/activation state for this modem's device — `NMDeviceState`
129
+ * collapsed to the states that matter. NM is the SOLE owner of activation; this
130
+ * dimension reflects, never drives, that ownership.
131
+ */
132
+ export type NmActivation =
133
+ | 'unmanaged'
134
+ | 'unavailable'
135
+ | 'disconnected'
136
+ | 'activating'
137
+ | 'activated'
138
+ | 'deactivating'
139
+ | 'failed';
140
+
141
+ // --- 6. data interface ------------------------------------------------------
142
+
143
+ /**
144
+ * The net device the modem exposes for data (e.g. `wwan0`). `name` may be absent
145
+ * even when `present` (MM can report a bearer whose ip-interface is not yet
146
+ * named); a `name` without `present` is impossible and guarded.
147
+ */
148
+ export interface DataInterface {
149
+ readonly present: boolean;
150
+ readonly name?: string;
151
+ }
152
+
153
+ // --- 7. reconcile status ----------------------------------------------------
154
+
155
+ /**
156
+ * Aggregate desired-state reconciliation status. Mirrors the A2.2 receipt
157
+ * taxonomy at snapshot granularity: `unsupported` means the desired state cannot
158
+ * be applied on this hardware (e.g. "prefer 5G" on a 4G-only modem) — surfaced,
159
+ * never silently dropped.
160
+ */
161
+ export type ReconcileStatus = 'converged' | 'reconciling' | 'pending' | 'divergent' | 'unsupported';
162
+
163
+ // --- 8. recovery state ------------------------------------------------------
164
+
165
+ /**
166
+ * Recovery-ladder stage. Ordered rungs mirror A3.4:
167
+ * nm-cycle → mm-cycle → reset → power-cycle, gated by attribution and budgets.
168
+ * `idle` = nothing in flight; `exhausted` = budget spent, gave up.
169
+ */
170
+ export type RecoveryStage =
171
+ | 'idle'
172
+ | 'attributing'
173
+ | 'nm-cycle'
174
+ | 'mm-cycle'
175
+ | 'reset'
176
+ | 'power-cycle'
177
+ | 'cooldown'
178
+ | 'exhausted';
179
+
180
+ /**
181
+ * Recovery dimension. `cooldownUntil` is present ONLY while `stage` is `cooldown`
182
+ * (guarded); `attempts` counts disruptive rungs fired in the current budget window
183
+ * and resets to 0 at `idle`.
184
+ */
185
+ export interface RecoveryState {
186
+ readonly stage: RecoveryStage;
187
+ readonly attempts: number;
188
+ readonly cooldownUntil?: EpochMillis;
189
+ }
@@ -0,0 +1,6 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { PACKAGE_NAME } from './index';
3
+
4
+ test('control package exposes its name', () => {
5
+ expect(PACKAGE_NAME).toBe('@ceralive/modem-control');
6
+ });
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // @ceralive/modem-control — package entry point.
2
+ //
3
+ // Phase A: the domain model (identity + orthogonal state + revisions) under
4
+ // `./domain`, the MM / NM / Router port contracts + desired-state planner under
5
+ // `./ports`, the redaction module (`./redact`), and the epoch-scoped ModemManager
6
+ // D-Bus observer under `./backend`. The NetworkManager adapter, USB composition-mode
7
+ // model, and data-usage sampler land in later waves.
8
+
9
+ export const PACKAGE_NAME = '@ceralive/modem-control';
10
+
11
+ export * from './backend';
12
+ export * from './domain';
13
+ export * from './ports';
14
+ export * from './redact';
15
+ export * from './usb-mode';
@@ -0,0 +1,61 @@
1
+ # Port contracts + ownership matrix
2
+
3
+ The adapter boundaries `@ceralive/modem-control` reconciles across. Every concrete
4
+ backend — the A2.3 fake harness, the A3.x ModemManager D-Bus backend, the A4.1
5
+ `nmcli` NetworkManager adapter — implements one of these interfaces. Nothing here
6
+ performs I/O; these are pure TypeScript contracts.
7
+
8
+ ## The ports
9
+
10
+ | Port | File | Responsibility |
11
+ |------|------|----------------|
12
+ | `ModemObservationPort` | [`observation.ts`](./observation.ts) | Read-only: `start()`, `observe()`, `stop()`; emits discriminated `ObservationList` results that **retain rows** on source failure (removal is only ever an authoritative snapshot omission). |
13
+ | `ModemManagerPort` | [`modem-manager.ts`](./modem-manager.ts) | **Extends** the observation port with the mutations MM owns: `setRadioModes`, `setPrimarySimSlot`, `sendPin`/`sendPuk`, `scanNetworks`, `inhibit`/`uninhibit`. **NO bearer/connect verb.** |
14
+ | `NetworkManagerPort` | [`network-manager.ts`](./network-manager.ts) | GSM profile CRUD; `activate`/`deactivate` taking **both** `(connectionId, deviceIfname)`; quiesce lease. |
15
+ | `RouterPort` | [`router.ts`](./router.ts) | Presence + advisory health only, for devices MM cannot control. |
16
+
17
+ ## Ownership matrix — one sole writer per resource
18
+
19
+ Each resource below has **exactly one** owner. No other component writes it. This is
20
+ the NM-owns-bearers architecture the whole package is built around (independent
21
+ review correction, draft §Oracle #1): the controller is a reconciler over two
22
+ adapters, never a second writer of the same resource.
23
+
24
+ | Resource | Sole writer | Port / owner |
25
+ |----------|-------------|--------------|
26
+ | APN | NetworkManager | `NetworkManagerPort` (`gsm.apn` / auto-config) |
27
+ | Connection auth (username / password) | NetworkManager | `NetworkManagerPort` (`gsm.username` / `gsm.password`) |
28
+ | Roaming | NetworkManager | `NetworkManagerPort` (`gsm.home-only`) |
29
+ | Autoconnect | NetworkManager | `NetworkManagerPort` (`connection.autoconnect`) |
30
+ | Activation (bearer lifecycle) | NetworkManager | `NetworkManagerPort` (`activate`/`deactivate`) |
31
+ | Radio access-technology modes | ModemManager | `ModemManagerPort.setRadioModes` |
32
+ | SIM operations (PIN / PUK / primary slot / scan) | ModemManager | `ModemManagerPort` |
33
+ | Recovery ladder | Local controller | policy `recovery` (disabled by default, A3.4) |
34
+ | Usage policy (cycle / threshold) | Local controller | policy `usage` (A4.3 sampler) |
35
+
36
+ ### The bearer invariant (safety-critical)
37
+
38
+ **The controller NEVER calls MM's `Simple.Connect`, `CreateBearer`, or
39
+ `Bearer.Connect`.** Bearers, APN, and connection activation belong exclusively to
40
+ NetworkManager. `ModemManagerPort` therefore has no `connect`, `createBearer`, or
41
+ `bearerConnect` method, and none may ever be added. This is enforced at build time
42
+ by [`forbidden-surface.test.ts`](./forbidden-surface.test.ts), which scans every
43
+ port source file and fails if any bearer/connect method declaration appears.
44
+
45
+ ## Port-tagged ops
46
+
47
+ The planner ([`reconcile.ts`](./reconcile.ts)) emits **port-tagged ops**
48
+ ([`ops.ts`](./ops.ts)): a discriminated union `{ port: 'mm', op: MmOp } | { port:
49
+ 'nm', op: NmOp }` whose op-kind spaces are disjoint. A radio op tagged for the NM
50
+ port — `{ port: 'nm', op: { kind: 'setRadioModes', … } }` — is a **compile-time type
51
+ error**, proved by [`ops.type-test.ts`](./ops.type-test.ts) (`@ts-expect-error`
52
+ lines that `tsc --noEmit` must flag). The ownership matrix is thus enforced by the
53
+ type system, not merely by convention.
54
+
55
+ ## Receipts
56
+
57
+ Reconciliation is honest: each policy dimension yields exactly one
58
+ [`Receipt`](./receipts.ts) with a status (`applied | pending | unsupported |
59
+ failed`) and a **reason**. Nothing is silently dropped — "prefer 5G" on a 4G-only
60
+ modem returns `unsupported` with an explicit reason, never a quiet downgrade to a
61
+ 4G-only mode.
@@ -0,0 +1,80 @@
1
+ // Guard: no port interface may ever declare a bearer / connect method.
2
+ //
3
+ // The single most safety-critical constraint in the package (Must-NOT-Have: "no MM
4
+ // Simple.Connect / CreateBearer / Bearer.Connect calls ever"). Interfaces are
5
+ // erased at runtime, so the enforcement is a source scan: every port `.ts` file is
6
+ // stripped of comments (so prose mentions of "bearer" never trip it) and checked
7
+ // for a method declaration whose name is `connect`, `simpleConnect`, or contains
8
+ // "bearer". Adding such a method to any port fails this test — and therefore CI.
9
+
10
+ import { expect, test } from 'bun:test';
11
+ import { readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ const portsDir = import.meta.dir;
15
+
16
+ function isForbiddenMethodName(name: string): boolean {
17
+ const lower = name.toLowerCase();
18
+ return lower === 'connect' || lower === 'simpleconnect' || lower.includes('bearer');
19
+ }
20
+
21
+ function stripComments(source: string): string {
22
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
23
+ }
24
+
25
+ function declaredMemberNames(source: string): string[] {
26
+ const names: string[] = [];
27
+ const methodDecl = /(?:^|\n)\s*([a-zA-Z_$][\w$]*)\s*[<(]/g;
28
+ let match = methodDecl.exec(source);
29
+ while (match !== null) {
30
+ const name = match[1];
31
+ if (name !== undefined) {
32
+ names.push(name);
33
+ }
34
+ match = methodDecl.exec(source);
35
+ }
36
+ return names;
37
+ }
38
+
39
+ function portSourceFiles(): string[] {
40
+ return readdirSync(portsDir)
41
+ .filter((file) => file.endsWith('.ts'))
42
+ .filter((file) => !file.endsWith('.test.ts') && !file.endsWith('.type-test.ts'));
43
+ }
44
+
45
+ test('no port source declares a bearer / connect method', async () => {
46
+ for (const file of portSourceFiles()) {
47
+ const source = stripComments(await Bun.file(join(portsDir, file)).text());
48
+ const forbidden = declaredMemberNames(source).filter(isForbiddenMethodName);
49
+ expect(forbidden, `forbidden method(s) in ${file}: ${forbidden.join(', ')}`).toEqual([]);
50
+ }
51
+ });
52
+
53
+ test('the ModemManager port source declares the expected non-bearer mutations', async () => {
54
+ const source = stripComments(await Bun.file(join(portsDir, 'modem-manager.ts')).text());
55
+ const names = declaredMemberNames(source);
56
+ for (const expected of [
57
+ 'setRadioModes',
58
+ 'setPrimarySimSlot',
59
+ 'sendPin',
60
+ 'sendPuk',
61
+ 'scanNetworks',
62
+ 'inhibit',
63
+ 'uninhibit',
64
+ ]) {
65
+ expect(names).toContain(expected);
66
+ }
67
+ });
68
+
69
+ test('the detector actually catches a bearer / connect method (self-test)', () => {
70
+ const rogue = `
71
+ export interface Rogue {
72
+ connect(): Promise<void>;
73
+ createBearer(): Promise<void>;
74
+ bearerConnect(): Promise<void>;
75
+ setRadioModes(): Promise<void>;
76
+ }
77
+ `;
78
+ const flagged = declaredMemberNames(stripComments(rogue)).filter(isForbiddenMethodName).sort();
79
+ expect(flagged).toEqual(['bearerConnect', 'connect', 'createBearer']);
80
+ });
@@ -0,0 +1,16 @@
1
+ // Port contracts — the MM / NM / Router adapter boundaries plus the desired-state
2
+ // planner and receipts. Concrete backends (A2.3 fake, A3.x D-Bus, A4.1 nmcli)
3
+ // implement these interfaces; nothing here performs I/O.
4
+ //
5
+ // OWNERSHIP MATRIX (see ./README.md): NM owns bearers / APN / auth / roaming /
6
+ // autoconnect / activation; MM owns radio / SIM ops; the local controller owns
7
+ // recovery + usage policy. The ModemManagerPort has NO bearer / connect verb —
8
+ // enforced at build time by forbidden-surface.test.ts.
9
+
10
+ export * from './modem-manager';
11
+ export * from './network-manager';
12
+ export * from './observation';
13
+ export * from './ops';
14
+ export * from './receipts';
15
+ export * from './reconcile';
16
+ export * from './router';
@@ -0,0 +1,72 @@
1
+ // The ModemManager port — radio, SIM, scan, inhibit, and observation.
2
+ //
3
+ // CRITICAL INVARIANT (the single most safety-critical constraint in the package):
4
+ // this port has NO bearer / connection verb. There is no connect, no createBearer,
5
+ // no bearerConnect — MM's Simple.Connect / CreateBearer / Bearer.Connect are NEVER
6
+ // callable through here. NetworkManager is the sole owner of bearers and activation
7
+ // (see README ownership table). `forbidden-surface.test.ts` fails the build if any
8
+ // such method is ever added to this file.
9
+
10
+ import type { DesiredRadio, EpochMillis, RuntimePath } from '../domain';
11
+ import type { ModemObservationPort } from './observation';
12
+ import type { Receipt } from './receipts';
13
+
14
+ /** A live handle to one modem — its ModemManager D-Bus object path (per-boot). */
15
+ export type ModemRef = RuntimePath;
16
+
17
+ /** Outcome of a SIM PIN unlock attempt. */
18
+ export interface SimUnlockResult {
19
+ readonly outcome: 'unlocked' | 'incorrect-pin' | 'sim-puk-required' | 'unsupported' | 'error';
20
+ readonly remainingAttempts?: number;
21
+ readonly reason: string;
22
+ }
23
+
24
+ /** Outcome of a SIM PUK unblock attempt. */
25
+ export interface SimPukUnlockResult {
26
+ readonly outcome: 'unlocked' | 'incorrect-puk' | 'permanently-blocked' | 'unsupported' | 'error';
27
+ readonly remainingAttempts?: number;
28
+ readonly reason: string;
29
+ }
30
+
31
+ /** One operator returned by a network scan. */
32
+ export interface ScannedNetwork {
33
+ /** MCC+MNC operator code. */
34
+ readonly operatorCode: string;
35
+ readonly operatorName?: string;
36
+ readonly availability: 'available' | 'current' | 'forbidden' | 'unknown';
37
+ }
38
+
39
+ /** Result of a network scan — discriminated, retaining the reason on failure. */
40
+ export type NetworkScanResult =
41
+ | { readonly ok: true; readonly networks: readonly ScannedNetwork[] }
42
+ | { readonly ok: false; readonly reason: string };
43
+
44
+ /** A held inhibition over a modem, released via `uninhibit`. */
45
+ export interface InhibitLease {
46
+ /** The equipment UID the inhibition is keyed to. */
47
+ readonly uid: string;
48
+ readonly acquiredAt: EpochMillis;
49
+ }
50
+
51
+ /**
52
+ * The ModemManager port — EXTENDS the read-only observation port with the modem
53
+ * mutations MM legitimately owns: radio modes, primary SIM slot, PIN / PUK unlock,
54
+ * network scan, and inhibit / uninhibit. It owns NO bearer / connection lifecycle;
55
+ * bearers and activation belong to `NetworkManagerPort`.
56
+ */
57
+ export interface ModemManagerPort extends ModemObservationPort {
58
+ /** Set the modem's radio access-technology preference. */
59
+ setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise<Receipt>;
60
+ /** Select the primary SIM slot (multi-slot modems only). */
61
+ setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise<Receipt>;
62
+ /** Submit a SIM PIN (exactly-once; read-before-submit is the adapter's job). */
63
+ sendPin(modem: ModemRef, pin: string): Promise<SimUnlockResult>;
64
+ /** Submit a SIM PUK plus the new PIN (exactly-once). */
65
+ sendPuk(modem: ModemRef, puk: string, newPin: string): Promise<SimPukUnlockResult>;
66
+ /** Scan for visible networks (long-running). */
67
+ scanNetworks(modem: ModemRef): Promise<NetworkScanResult>;
68
+ /** Inhibit MM from managing a device (for a maintenance lease), keyed by UID. */
69
+ inhibit(uid: string): Promise<InhibitLease>;
70
+ /** Release a previously-taken inhibition. */
71
+ uninhibit(lease: InhibitLease): Promise<void>;
72
+ }
@@ -0,0 +1,87 @@
1
+ // The NetworkManager port — NM is the SOLE owner of bearers, APN, auth, roaming,
2
+ // autoconnect, and activation (see README ownership table). This port is the ONLY
3
+ // way the controller touches any of those resources; the ModemManager port has no
4
+ // bearer / connection verbs at all.
5
+
6
+ import type { Brand, EpochMillis } from '../domain';
7
+ import { nonEmptyString } from '../domain';
8
+ import type { Receipt } from './receipts';
9
+
10
+ /** A NetworkManager connection-profile UUID. */
11
+ export type ConnectionId = Brand<string, 'ConnectionId'>;
12
+
13
+ /** A kernel network-device interface name (e.g. `wwan0`). */
14
+ export type DeviceIfname = Brand<string, 'DeviceIfname'>;
15
+
16
+ /** Construct a `ConnectionId` from a non-empty NM connection UUID. */
17
+ export function connectionId(value: string): ConnectionId {
18
+ return nonEmptyString(value, 'connectionId') as ConnectionId;
19
+ }
20
+
21
+ /** Construct a `DeviceIfname` from a non-empty interface name. */
22
+ export function deviceIfname(value: string): DeviceIfname {
23
+ return nonEmptyString(value, 'deviceIfname') as DeviceIfname;
24
+ }
25
+
26
+ /**
27
+ * A GSM connection profile — NM's `gsm.*` setting group. `password` is SENSITIVE
28
+ * and MUST be redacted in every log / output (see `../redact`). The full nine-field
29
+ * nmcli write parity lands with the concrete adapter (A4.1); this is the port
30
+ * contract that adapter fulfils.
31
+ */
32
+ export interface GsmProfileInput {
33
+ readonly connectionName: string;
34
+ /** Concrete APN, or the empty string when `autoConfig` drives it (A4.1). */
35
+ readonly apn: string;
36
+ readonly username?: string;
37
+ /** SENSITIVE — redact everywhere. */
38
+ readonly password?: string;
39
+ /** `true` ⇒ `gsm.home-only` (roaming disabled). */
40
+ readonly homeOnly: boolean;
41
+ /** `true` ⇒ `gsm.auto-config yes` (Auto-APN); mutually exclusive with creds. */
42
+ readonly autoConfig: boolean;
43
+ /** Manual operator selection (`gsm.network-id`); empty unless roaming-pinned. */
44
+ readonly networkId?: string;
45
+ }
46
+
47
+ /** A persisted GSM profile as read back from NM, keyed by its connection id. */
48
+ export interface GsmProfile extends GsmProfileInput {
49
+ readonly connectionId: ConnectionId;
50
+ }
51
+
52
+ /** A partial update to an existing GSM profile. */
53
+ export type GsmProfilePatch = Partial<GsmProfileInput>;
54
+
55
+ /**
56
+ * A quiesce lease — a held guarantee that the connection on THIS exact device stays
57
+ * deactivated while a disruptive MM operation runs, then is reactivated on release.
58
+ * Bound to BOTH `connectionId` and `deviceIfname` so it can never quiesce the wrong
59
+ * device (the two-device isolation invariant, A4.1).
60
+ */
61
+ export interface QuiesceLease {
62
+ readonly connectionId: ConnectionId;
63
+ readonly deviceIfname: DeviceIfname;
64
+ readonly acquiredAt: EpochMillis;
65
+ }
66
+
67
+ /**
68
+ * The NetworkManager port. Activation and deactivation take BOTH the connection id
69
+ * AND the device interface name — never an id alone. This encodes, at the type
70
+ * level, that we address a connection on an EXACT device (`nmcli connection up
71
+ * <uuid> ifname <dev>` / verify-then-`device disconnect <ifname>`); the id-only
72
+ * `nmcli connection down` is structurally impossible to express here (A4.1).
73
+ */
74
+ export interface NetworkManagerPort {
75
+ createGsmProfile(profile: GsmProfileInput): Promise<GsmProfile>;
76
+ readGsmProfile(id: ConnectionId): Promise<GsmProfile | undefined>;
77
+ updateGsmProfile(id: ConnectionId, patch: GsmProfilePatch): Promise<GsmProfile>;
78
+ deleteGsmProfile(id: ConnectionId): Promise<void>;
79
+ /** Activate `id` on the EXACT device `ifname`. */
80
+ activate(id: ConnectionId, ifname: DeviceIfname): Promise<Receipt>;
81
+ /** Deactivate `id` on the EXACT device `ifname`. */
82
+ deactivate(id: ConnectionId, ifname: DeviceIfname): Promise<Receipt>;
83
+ /** Take a quiesce lease over `id` on the EXACT device `ifname`. */
84
+ acquireQuiesceLease(id: ConnectionId, ifname: DeviceIfname): Promise<QuiesceLease>;
85
+ /** Release a quiesce lease, reactivating the connection it held down. */
86
+ releaseQuiesceLease(lease: QuiesceLease): Promise<void>;
87
+ }
@@ -0,0 +1,48 @@
1
+ // The observation contract — the read side shared by every ModemManager backend.
2
+ //
3
+ // `ModemObservationPort` is the NARROW port: it observes modems and reports them as
4
+ // a stream of discriminated list results. It carries NO mutation methods at all.
5
+ // The full `ModemManagerPort` (mutations) EXTENDS this, so a shadow / read-only
6
+ // consumer can depend on observation alone and never gain a mutating verb.
7
+
8
+ import type { CellularSnapshot } from '../domain';
9
+
10
+ /** Cancels a subscription created by `observe`. Calling it twice is a no-op. */
11
+ export type Unsubscribe = () => void;
12
+
13
+ /** Why an observation could not produce an authoritative list this cycle. */
14
+ export type ObservationFailureReason = 'not-started' | 'source-unavailable' | 'bus-error';
15
+
16
+ /**
17
+ * The result of listing modems — DISCRIMINATED on `ok`, and it NEVER throws away
18
+ * rows. Even the failure arm carries `rows`: the last-known modems are RETAINED so
19
+ * a source drop can never be mistaken for a removal. Real removal is only ever
20
+ * expressed by an `ok: true` snapshot that OMITS a modem (A3.1 epoch authority) —
21
+ * the false-removal class is dead by construction.
22
+ */
23
+ export type ObservationList<T = CellularSnapshot> =
24
+ | { readonly ok: true; readonly rows: readonly T[] }
25
+ | {
26
+ readonly ok: false;
27
+ readonly reason: ObservationFailureReason;
28
+ /** Retained rows from the last authoritative snapshot — never dropped. */
29
+ readonly rows: readonly T[];
30
+ };
31
+
32
+ /** A subscriber to the ongoing observation stream. */
33
+ export type ObservationListener<T = CellularSnapshot> = (list: ObservationList<T>) => void;
34
+
35
+ /**
36
+ * The read-only observation port. `start()` connects, subscribes, and resolves the
37
+ * first AUTHORITATIVE list; `observe()` streams every subsequent list (each snapshot
38
+ * carrying a monotonic revision); `stop()` tears the subscription down. No method
39
+ * here can change modem state — that narrowness is the whole point of the port.
40
+ */
41
+ export interface ModemObservationPort {
42
+ /** Connect, subscribe, and resolve the first authoritative observation list. */
43
+ start(): Promise<ObservationList>;
44
+ /** Subscribe to the ongoing stream of observation lists. Returns an unsubscribe. */
45
+ observe(listener: ObservationListener): Unsubscribe;
46
+ /** Tear down the subscription and release the source. Idempotent. */
47
+ stop(): Promise<void>;
48
+ }
@@ -0,0 +1,60 @@
1
+ // Port-tagged reconcile ops — a planned mutation TAGGED with the port that owns it.
2
+ //
3
+ // The op kinds are DISJOINT by construction: a radio / SIM op (`MmOp`) shares no
4
+ // `kind` with a connection / APN op (`NmOp`). Tagging is therefore type-checked —
5
+ // `{ port: 'nm', op: { kind: 'setRadioModes', … } }` does NOT compile, because a
6
+ // radio op is not assignable to the NM arm. That mis-tag is a COMPILE-TIME error
7
+ // (see `ops.type-test.ts` for the `@ts-expect-error` proofs). This is the ownership
8
+ // matrix (README) enforced by the type system, not merely by convention.
9
+
10
+ import type { DesiredRadio } from '../domain';
11
+ import type {
12
+ ConnectionId,
13
+ DeviceIfname,
14
+ GsmProfileInput,
15
+ GsmProfilePatch,
16
+ } from './network-manager';
17
+
18
+ /** Radio + SIM ops — owned SOLELY by the ModemManager port. */
19
+ export type MmOp =
20
+ | { readonly kind: 'setRadioModes'; readonly preference: DesiredRadio }
21
+ | { readonly kind: 'setPrimarySimSlot'; readonly slotIndex: number };
22
+
23
+ /** Connection / APN / activation ops — owned SOLELY by the NetworkManager port. */
24
+ export type NmOp =
25
+ | { readonly kind: 'createGsmProfile'; readonly profile: GsmProfileInput }
26
+ | {
27
+ readonly kind: 'updateGsmProfile';
28
+ readonly connectionId: ConnectionId;
29
+ readonly patch: GsmProfilePatch;
30
+ }
31
+ | {
32
+ readonly kind: 'activate';
33
+ readonly connectionId: ConnectionId;
34
+ readonly deviceIfname: DeviceIfname;
35
+ }
36
+ | {
37
+ readonly kind: 'deactivate';
38
+ readonly connectionId: ConnectionId;
39
+ readonly deviceIfname: DeviceIfname;
40
+ };
41
+
42
+ /**
43
+ * A planned op tagged with its owning port. The two arms are disjoint on BOTH the
44
+ * `port` discriminant AND the op `kind` space, so the planner cannot emit — and a
45
+ * reviewer cannot write — a radio op under the NM port, or a connection op under
46
+ * the MM port.
47
+ */
48
+ export type PortTaggedOp =
49
+ | { readonly port: 'mm'; readonly op: MmOp }
50
+ | { readonly port: 'nm'; readonly op: NmOp };
51
+
52
+ /** Tag an MM op for the ModemManager port. Only an `MmOp` is accepted. */
53
+ export function mmOp(op: MmOp): PortTaggedOp {
54
+ return { port: 'mm', op };
55
+ }
56
+
57
+ /** Tag an NM op for the NetworkManager port. Only an `NmOp` is accepted. */
58
+ export function nmOp(op: NmOp): PortTaggedOp {
59
+ return { port: 'nm', op };
60
+ }