@ceralive/modem-control 1.2.1 → 1.3.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 (86) hide show
  1. package/README.md +79 -0
  2. package/dist/backend/cell-info.d.ts +2 -0
  3. package/dist/backend/cell-info.js +18 -2
  4. package/dist/backend/device-classifier.d.ts +56 -0
  5. package/dist/backend/device-classifier.js +88 -0
  6. package/dist/backend/mm-mutations/bands.d.ts +4 -0
  7. package/dist/backend/mm-mutations/bands.js +49 -0
  8. package/dist/backend/mm-mutations/context.d.ts +10 -0
  9. package/dist/backend/mm-mutations/context.js +3 -0
  10. package/dist/backend/mm-mutations/inhibit.d.ts +4 -0
  11. package/dist/backend/mm-mutations/inhibit.js +19 -0
  12. package/dist/backend/mm-mutations/modes.d.ts +5 -0
  13. package/dist/backend/mm-mutations/modes.js +44 -0
  14. package/dist/backend/mm-mutations/scan.d.ts +3 -0
  15. package/dist/backend/mm-mutations/scan.js +46 -0
  16. package/dist/backend/mm-mutations/sim.d.ts +5 -0
  17. package/dist/backend/mm-mutations/sim.js +49 -0
  18. package/dist/backend/mm-mutations.d.ts +0 -16
  19. package/dist/backend/mm-mutations.js +27 -234
  20. package/dist/backend/observer/bus-lifecycle.d.ts +7 -0
  21. package/dist/backend/observer/bus-lifecycle.js +12 -0
  22. package/dist/backend/observer/epoch-reconcile.d.ts +4 -0
  23. package/dist/backend/observer/epoch-reconcile.js +28 -0
  24. package/dist/backend/observer/signal-routing.d.ts +11 -0
  25. package/dist/backend/observer/signal-routing.js +11 -0
  26. package/dist/backend/observer.d.ts +1 -1
  27. package/dist/backend/observer.js +18 -35
  28. package/dist/backend/router-ethernet.d.ts +7 -0
  29. package/dist/backend/router-ethernet.js +70 -18
  30. package/dist/backend/signal-setup.d.ts +21 -2
  31. package/dist/backend/signal-setup.js +24 -2
  32. package/dist/backend/usage/accounting.d.ts +7 -0
  33. package/dist/backend/usage/accounting.js +8 -2
  34. package/dist/backend/usage/persistence.d.ts +4 -0
  35. package/dist/backend/usage/persistence.js +59 -0
  36. package/dist/backend/usage/policy.d.ts +24 -0
  37. package/dist/backend/usage/policy.js +38 -0
  38. package/dist/backend/usage/sampler.d.ts +7 -1
  39. package/dist/backend/usage/sampler.js +33 -119
  40. package/dist/backend/usage/sampling.d.ts +38 -0
  41. package/dist/backend/usage/sampling.js +72 -0
  42. package/dist/backend/usb-mode-transition/admission.d.ts +5 -0
  43. package/dist/backend/usb-mode-transition/admission.js +12 -0
  44. package/dist/backend/usb-mode-transition/at.d.ts +13 -0
  45. package/dist/backend/usb-mode-transition/at.js +15 -0
  46. package/dist/backend/usb-mode-transition/outcome.d.ts +4 -0
  47. package/dist/backend/usb-mode-transition/outcome.js +17 -0
  48. package/dist/backend/usb-mode-transition/reenumeration.d.ts +8 -0
  49. package/dist/backend/usb-mode-transition/reenumeration.js +37 -0
  50. package/dist/backend/usb-mode-transition.d.ts +2 -2
  51. package/dist/backend/usb-mode-transition.js +25 -71
  52. package/dist/domain/mm-enums.d.ts +37 -0
  53. package/dist/domain/mm-enums.js +34 -0
  54. package/dist/fcc/coverage.d.ts +7 -0
  55. package/dist/fcc/coverage.js +7 -0
  56. package/dist/hardware/router-parsers.js +3 -32
  57. package/dist/json-boundary.d.ts +4 -0
  58. package/dist/json-boundary.js +19 -0
  59. package/dist/observations/model.d.ts +45 -0
  60. package/dist/observations/raw.d.ts +12 -0
  61. package/dist/observations/raw.js +31 -0
  62. package/dist/observations/sources/hilink.js +7 -1
  63. package/dist/observations/sources/modemmanager.d.ts +25 -0
  64. package/dist/observations/sources/modemmanager.js +123 -15
  65. package/dist/observations/sources/router-shared.d.ts +35 -1
  66. package/dist/observations/sources/router-shared.js +39 -0
  67. package/dist/observations/sources/ufi.js +7 -1
  68. package/dist/observations/sources/zte.js +7 -1
  69. package/dist/providers/modem-manager/provider.d.ts +10 -0
  70. package/dist/providers/modem-manager/provider.js +3 -0
  71. package/dist/providers/modem-manager/snapshot.js +6 -0
  72. package/dist/providers/network-manager/adapter.js +15 -136
  73. package/dist/providers/network-manager/divergence.d.ts +4 -0
  74. package/dist/providers/network-manager/divergence.js +7 -0
  75. package/dist/providers/network-manager/observe-fold.d.ts +9 -0
  76. package/dist/providers/network-manager/observe-fold.js +109 -0
  77. package/dist/providers/network-manager/projection.d.ts +5 -0
  78. package/dist/providers/network-manager/projection.js +14 -0
  79. package/dist/providers/network-manager/state.d.ts +10 -0
  80. package/dist/providers/network-manager/state.js +16 -0
  81. package/dist/providers/ufi-himi/session.js +2 -16
  82. package/dist/providers/zte-goform/session.js +2 -16
  83. package/dist/safety/flock-resource-ownership.js +73 -13
  84. package/dist/transport/transport.d.ts +20 -1
  85. package/dist/transport/transport.js +133 -16
  86. package/package.json +1 -1
@@ -1,259 +1,52 @@
1
- // The ModemManager mutations the disruptive-and-SIM half of `ModemManagerPort`.
2
- //
3
- // Every disruptive op runs through the shared per-modem `ModemActor` (serialized on
4
- // the STABLE key, so two ops on one modem never interleave and a replug keeps the
5
- // same queue). Mode and slot changes additionally run QUIESCED (NM briefly stands
6
- // down first, via the actor's quiesce hook). PIN / PUK / scan serialize too but do
7
- // NOT quiesce — they don't touch the bearer. NONE of these methods can reach a bearer
8
- // or connect verb: the port has none, and the fake's tripwire proves it at test time.
9
- import { decodeBandList, encodeBandList, isResetSelection } from '../band/index.js';
10
- import { epochMillis } from '../domain/index.js';
11
- import { receipt } from '../ports/index.js';
12
- import { MM_BUS_NAME, MM_MANAGER_IFACE, MM_ROOT_PATH, MODEM_IFACE, MODEM3GPP_IFACE, } from './constants.js';
13
- import { fetchManagedObjects, findInterface, numberProp, propValue, stringProp, } from './managed-objects.js';
14
- import { sendSimPin, sendSimPuk } from './sim-unlock.js';
15
- /** MMModemMode bit per RAT family (2G/3G/4G/5G). */
16
- const MODE_BIT = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 };
17
- /** MMModem3gppNetworkAvailability → the port's availability. */
18
- const AVAILABILITY = {
19
- 0: 'unknown',
20
- 1: 'available',
21
- 2: 'current',
22
- 3: 'forbidden',
23
- };
24
- /** A network scan can take a long time — MM's own default is not enough. */
1
+ import { MM_BUS_NAME } from './constants.js';
2
+ import { readBands, setCurrentBands } from './mm-mutations/bands.js';
3
+ import { inhibitDevice, uninhibitDevice } from './mm-mutations/inhibit.js';
4
+ import { setModeCombination, setRadioModes } from './mm-mutations/modes.js';
5
+ import { scanNetworks } from './mm-mutations/scan.js';
6
+ import { setPrimarySimSlot, unlockWithPin, unlockWithPuk } from './mm-mutations/sim.js';
25
7
  const DEFAULT_SCAN_TIMEOUT_MS = 300_000;
26
- /** The disruptive + SIM mutations of `ModemManagerPort`, serialized per modem. */
27
8
  export class MmMutations {
28
- #transport;
29
- #actor;
30
- #destination;
31
- #resolveStableKey;
9
+ #context;
32
10
  #scanTimeoutMs;
33
11
  #now;
34
12
  constructor(deps) {
35
- this.#transport = deps.transport;
36
- this.#actor = deps.actor;
37
- this.#destination = deps.destination ?? MM_BUS_NAME;
38
- this.#resolveStableKey = deps.resolveStableKey;
13
+ this.#context = {
14
+ transport: deps.transport,
15
+ actor: deps.actor,
16
+ destination: deps.destination ?? MM_BUS_NAME,
17
+ resolveStableKey: deps.resolveStableKey,
18
+ };
39
19
  this.#scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS;
40
20
  this.#now = deps.now ?? Date.now;
41
21
  }
42
22
  setRadioModes(modem, preference) {
43
- const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered));
44
- const preferred = preference.preferenceOrdered[0];
45
- const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0;
46
- if (allowed === 0) {
47
- return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested'));
48
- }
49
- return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => {
50
- try {
51
- await this.#transport.callMethod({
52
- destination: this.#destination,
53
- path: modem,
54
- interface: MODEM_IFACE,
55
- member: 'SetCurrentModes',
56
- signature: '(uu)',
57
- args: [[allowed, preferredMask]],
58
- });
59
- return receipt('radio', 'applied', 'radio mode preference applied');
60
- }
61
- catch (error) {
62
- return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`);
63
- }
64
- });
23
+ return setRadioModes(this.#context, modem, preference);
65
24
  }
66
- /**
67
- * `SetCurrentModes` over the RAW `(uu)` masks the modem itself advertised.
68
- *
69
- * `setRadioModes` above speaks the reconciler's vocabulary — an ordered RAT
70
- * preference — and structurally cannot express `MM_MODEM_MODE_NONE`: its preferred
71
- * mask is derived from `preferenceOrdered[0]`, so "allow this set and prefer
72
- * nothing within it" has no spelling. That is the combination the bench FM350-GL
73
- * actually advertises, so a caller selecting an advertised combination verbatim
74
- * needs this entry point. It quiesces for the same reason `setRadioModes` does: a
75
- * mode change re-registers the radio and drops the bearer underneath NM.
76
- *
77
- * An `allowed` mask of 0 is refused — that is not a selection, it is a radio with
78
- * nothing switched on — while a `preferred` mask of 0 is passed through untouched.
79
- */
80
25
  setModeCombination(modem, allowed, preferred) {
81
- if (allowed === 0) {
82
- return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested'));
83
- }
84
- return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => {
85
- try {
86
- await this.#transport.callMethod({
87
- destination: this.#destination,
88
- path: modem,
89
- interface: MODEM_IFACE,
90
- member: 'SetCurrentModes',
91
- signature: '(uu)',
92
- args: [[allowed, preferred]],
93
- });
94
- return receipt('radio', 'applied', 'radio mode combination applied');
95
- }
96
- catch (error) {
97
- return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`);
98
- }
99
- });
26
+ return setModeCombination(this.#context, modem, allowed, preferred);
100
27
  }
101
- async readBands(modem) {
102
- try {
103
- const tree = await fetchManagedObjects(this.#transport, this.#destination);
104
- const props = findInterface(tree, modem, MODEM_IFACE);
105
- if (props === undefined) {
106
- return { ok: false, reason: 'the modem exports no Modem interface' };
107
- }
108
- return {
109
- ok: true,
110
- bands: {
111
- supported: decodeBandList(propValue(props, 'SupportedBands')),
112
- current: decodeBandList(propValue(props, 'CurrentBands')),
113
- },
114
- };
115
- }
116
- catch (error) {
117
- return { ok: false, reason: `reading bands failed: ${describe(error)}` };
118
- }
28
+ readBands(modem) {
29
+ return readBands(this.#context, modem);
119
30
  }
120
- // Quiesced like `setRadioModes`, and for the same reason: a band change
121
- // re-registers the radio, so NM must stand down before the bearer drops
122
- // underneath it rather than after.
123
31
  setCurrentBands(modem, bands) {
124
- if (bands.length === 0) {
125
- return Promise.resolve(receipt('band', 'failed', 'no bands were requested'));
126
- }
127
- const encoded = encodeBandList(bands);
128
- if (!encoded.ok) {
129
- return Promise.resolve(receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`));
130
- }
131
- const values = encoded.values;
132
- return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => {
133
- try {
134
- await this.#transport.callMethod({
135
- destination: this.#destination,
136
- path: modem,
137
- interface: MODEM_IFACE,
138
- member: 'SetCurrentBands',
139
- signature: 'au',
140
- args: [values],
141
- });
142
- return receipt('band', 'applied', isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`);
143
- }
144
- catch (error) {
145
- return receipt('band', 'failed', `SetCurrentBands failed: ${describe(error)}`);
146
- }
147
- });
32
+ return setCurrentBands(this.#context, modem, bands);
148
33
  }
149
- async setPrimarySimSlot(modem, slotIndex) {
150
- const slots = await this.#readSlotCount(modem);
151
- if (slots === undefined) {
152
- return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list');
153
- }
154
- if (slots <= 1) {
155
- return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select');
156
- }
157
- if (slotIndex < 1 || slotIndex > slots) {
158
- return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`);
159
- }
160
- return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => {
161
- try {
162
- await this.#transport.callMethod({
163
- destination: this.#destination,
164
- path: modem,
165
- interface: MODEM_IFACE,
166
- member: 'SetPrimarySimSlot',
167
- signature: 'u',
168
- args: [slotIndex],
169
- });
170
- return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`);
171
- }
172
- catch (error) {
173
- return receipt('simSlot', 'failed', `SetPrimarySimSlot failed: ${describe(error)}`);
174
- }
175
- });
34
+ setPrimarySimSlot(modem, slotIndex) {
35
+ return setPrimarySimSlot(this.#context, modem, slotIndex);
176
36
  }
177
37
  sendPin(modem, pin) {
178
- return this.#actor.run(this.#resolveStableKey(modem), () => sendSimPin(this.#transport, this.#destination, modem, pin));
38
+ return unlockWithPin(this.#context, modem, pin);
179
39
  }
180
40
  sendPuk(modem, puk, newPin) {
181
- return this.#actor.run(this.#resolveStableKey(modem), () => sendSimPuk(this.#transport, this.#destination, modem, puk, newPin));
41
+ return unlockWithPuk(this.#context, modem, puk, newPin);
182
42
  }
183
43
  scanNetworks(modem) {
184
- return this.#actor.run(this.#resolveStableKey(modem), async () => {
185
- try {
186
- const reply = await this.#transport.callMethod({
187
- destination: this.#destination,
188
- path: modem,
189
- interface: MODEM3GPP_IFACE,
190
- member: 'Scan',
191
- timeoutMs: this.#scanTimeoutMs,
192
- });
193
- return { ok: true, networks: parseScan(reply.body[0]) };
194
- }
195
- catch (error) {
196
- return { ok: false, reason: `network scan failed: ${describe(error)}` };
197
- }
198
- });
44
+ return scanNetworks(this.#context, modem, this.#scanTimeoutMs);
199
45
  }
200
- async inhibit(uid) {
201
- await this.#inhibitDevice(uid, true);
202
- return { uid, acquiredAt: epochMillis(this.#now()) };
46
+ inhibit(uid) {
47
+ return inhibitDevice(this.#context, uid, this.#now);
203
48
  }
204
- async uninhibit(lease) {
205
- await this.#inhibitDevice(lease.uid, false);
49
+ uninhibit(lease) {
50
+ return uninhibitDevice(this.#context, lease);
206
51
  }
207
- #inhibitDevice(uid, inhibit) {
208
- return this.#transport.callMethod({
209
- destination: this.#destination,
210
- path: MM_ROOT_PATH,
211
- interface: MM_MANAGER_IFACE,
212
- member: 'InhibitDevice',
213
- signature: 'sb',
214
- args: [uid, inhibit],
215
- });
216
- }
217
- async #readSlotCount(modem) {
218
- try {
219
- const tree = await fetchManagedObjects(this.#transport, this.#destination);
220
- const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots');
221
- return Array.isArray(slots) ? slots.length : undefined;
222
- }
223
- catch {
224
- return undefined;
225
- }
226
- }
227
- }
228
- /** OR of the MMModemMode bits for a set of RATs. */
229
- function maskOf(rats) {
230
- let mask = 0;
231
- for (const rat of rats) {
232
- mask |= MODE_BIT[rat];
233
- }
234
- return mask;
235
- }
236
- /** Parse a `Modem3gpp.Scan` reply (`aa{sv}` → dicts) into scanned networks. */
237
- function parseScan(value) {
238
- if (!Array.isArray(value)) {
239
- return [];
240
- }
241
- const networks = [];
242
- for (const entry of value) {
243
- const operatorCode = stringProp(entry, 'operator-code');
244
- if (operatorCode === undefined) {
245
- continue;
246
- }
247
- const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short');
248
- const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown';
249
- networks.push({
250
- operatorCode,
251
- ...(name !== undefined ? { operatorName: name } : {}),
252
- availability,
253
- });
254
- }
255
- return networks;
256
- }
257
- function describe(error) {
258
- return error instanceof Error ? error.message : String(error);
259
52
  }
@@ -0,0 +1,7 @@
1
+ import type { DbusTransport, SignalEvent, Subscription } from '../../transport/index.js';
2
+ export interface ObserverSignalHandlers {
3
+ readonly onObjectSignal: (event: SignalEvent) => void;
4
+ readonly onNameOwnerChanged: (event: SignalEvent) => void;
5
+ }
6
+ export declare function subscribeObserverSignals(transport: DbusTransport, handlers: ObserverSignalHandlers): Promise<readonly Subscription[]>;
7
+ export declare function unsubscribeObserverSignals(subscriptions: readonly Subscription[]): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { DBUS_IFACE, MM_ROOT_PATH, OBJECT_MANAGER_IFACE, PROPERTIES_IFACE } from '../constants.js';
2
+ export async function subscribeObserverSignals(transport, handlers) {
3
+ const objectManager = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH };
4
+ const interfacesAdded = await transport.subscribeSignal({ ...objectManager, member: 'InterfacesAdded' }, handlers.onObjectSignal);
5
+ const interfacesRemoved = await transport.subscribeSignal({ ...objectManager, member: 'InterfacesRemoved' }, handlers.onObjectSignal);
6
+ const propertiesChanged = await transport.subscribeSignal({ interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, handlers.onObjectSignal);
7
+ const nameOwnerChanged = await transport.subscribeSignal({ interface: DBUS_IFACE, member: 'NameOwnerChanged' }, handlers.onNameOwnerChanged);
8
+ return [interfacesAdded, interfacesRemoved, propertiesChanged, nameOwnerChanged];
9
+ }
10
+ export async function unsubscribeObserverSignals(subscriptions) {
11
+ await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe().catch(() => undefined)));
12
+ }
@@ -0,0 +1,4 @@
1
+ import type { DbusTransport } from '../../transport/index.js';
2
+ import { type DecodedManagedObjects } from '../managed-objects.js';
3
+ export declare function queryModemManagerOwner(transport: DbusTransport): Promise<string | undefined>;
4
+ export declare function readAuthoritativeTree(transport: DbusTransport, destination: string): Promise<DecodedManagedObjects>;
@@ -0,0 +1,28 @@
1
+ import { DBUS_DESTINATION, DBUS_IFACE, DBUS_PATH, MM_BUS_NAME, MM_ROOT_PATH, OBJECT_MANAGER_IFACE, } from '../constants.js';
2
+ import { asManagedObjects } from '../managed-objects.js';
3
+ export async function queryModemManagerOwner(transport) {
4
+ try {
5
+ const reply = await transport.callMethod({
6
+ destination: DBUS_DESTINATION,
7
+ path: DBUS_PATH,
8
+ interface: DBUS_IFACE,
9
+ member: 'GetNameOwner',
10
+ signature: 's',
11
+ args: [MM_BUS_NAME],
12
+ });
13
+ const owner = reply.body[0];
14
+ return typeof owner === 'string' && owner.length > 0 ? owner : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ export async function readAuthoritativeTree(transport, destination) {
21
+ const reply = await transport.callMethod({
22
+ destination,
23
+ path: MM_ROOT_PATH,
24
+ interface: OBJECT_MANAGER_IFACE,
25
+ member: 'GetManagedObjects',
26
+ });
27
+ return asManagedObjects(reply.body[0]);
28
+ }
@@ -0,0 +1,11 @@
1
+ import type { SignalEvent } from '../../transport/index.js';
2
+ export type OwnerSignal = {
3
+ readonly kind: 'unrelated';
4
+ } | {
5
+ readonly kind: 'lost';
6
+ } | {
7
+ readonly kind: 'owned';
8
+ readonly owner: string;
9
+ };
10
+ export declare function routeOwnerSignal(event: SignalEvent): OwnerSignal;
11
+ export declare function isCurrentOwnerSignal(event: SignalEvent, currentOwner: string | undefined): boolean;
@@ -0,0 +1,11 @@
1
+ import { MM_BUS_NAME } from '../constants.js';
2
+ export function routeOwnerSignal(event) {
3
+ if (event.body[0] !== MM_BUS_NAME) {
4
+ return { kind: 'unrelated' };
5
+ }
6
+ const newOwner = typeof event.body[2] === 'string' ? event.body[2] : '';
7
+ return newOwner.length === 0 ? { kind: 'lost' } : { kind: 'owned', owner: newOwner };
8
+ }
9
+ export function isCurrentOwnerSignal(event, currentOwner) {
10
+ return currentOwner !== undefined && event.sender === currentOwner;
11
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ModemObservationPort, ObservationList, ObservationListener, Unsubscribe } from '../ports/index.js';
2
2
  import type { DbusTransport } from '../transport/index.js';
3
- import { type DecodedManagedObjects } from './managed-objects.js';
3
+ import type { DecodedManagedObjects } from './managed-objects.js';
4
4
  /**
5
5
  * A current-epoch authoritative snapshot, delivered to `onEpochRefresh` AFTER the
6
6
  * epoch guard passes. `epoch` is the owning MM unique bus name; `tree` is the decoded
@@ -13,8 +13,10 @@
13
13
  // `sourceUnavailable`. The false-removal class is dead by construction: even the
14
14
  // `ObservationList` failure arm retains its rows. Row bookkeeping lives in
15
15
  // `ObservationRowStore`; this file owns epoch tracking, subscriptions, and refresh.
16
- import { DBUS_DESTINATION, DBUS_IFACE, DBUS_PATH, MM_BUS_NAME, MM_ROOT_PATH, OBJECT_MANAGER_IFACE, PROPERTIES_IFACE, } from './constants.js';
17
- import { asManagedObjects } from './managed-objects.js';
16
+ import { MM_BUS_NAME } from './constants.js';
17
+ import { subscribeObserverSignals, unsubscribeObserverSignals } from './observer/bus-lifecycle.js';
18
+ import { queryModemManagerOwner, readAuthoritativeTree } from './observer/epoch-reconcile.js';
19
+ import { isCurrentOwnerSignal, routeOwnerSignal } from './observer/signal-routing.js';
18
20
  import { ObservationRowStore } from './row-store.js';
19
21
  export class MmDbusObserver {
20
22
  #transport;
@@ -70,14 +72,16 @@ export class MmDbusObserver {
70
72
  this.#stopped = true;
71
73
  this.#transport.off('disconnected', this.#onDisconnected);
72
74
  this.#transport.off('reconnected', this.#onReconnected);
73
- const subs = this.#subscriptions.splice(0);
74
- await Promise.all(subs.map((sub) => sub.unsubscribe().catch(() => undefined)));
75
+ const subscriptions = this.#subscriptions.splice(0);
76
+ await unsubscribeObserverSignals(subscriptions);
75
77
  this.#listeners.clear();
76
78
  }
77
79
  // ── signal subscription ────────────────────────────────────────────────────────
78
80
  async #subscribeAll() {
79
- const om = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH };
80
- this.#subscriptions.push(await this.#transport.subscribeSignal({ ...om, member: 'InterfacesAdded' }, (event) => this.#onObjectSignal(event)), await this.#transport.subscribeSignal({ ...om, member: 'InterfacesRemoved' }, (event) => this.#onObjectSignal(event)), await this.#transport.subscribeSignal({ interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, (event) => this.#onObjectSignal(event)), await this.#transport.subscribeSignal({ interface: DBUS_IFACE, member: 'NameOwnerChanged' }, (event) => this.#onNameOwnerChanged(event)));
81
+ this.#subscriptions.push(...(await subscribeObserverSignals(this.#transport, {
82
+ onObjectSignal: (event) => this.#onObjectSignal(event),
83
+ onNameOwnerChanged: (event) => this.#onNameOwnerChanged(event),
84
+ })));
81
85
  }
82
86
  // ── epoch tracking ───────────────────────────────────────────────────────────
83
87
  async #adoptCurrentOwner() {
@@ -90,47 +94,32 @@ export class MmDbusObserver {
90
94
  await this.#runRefresh(owner);
91
95
  }
92
96
  async #queryOwner() {
93
- try {
94
- const reply = await this.#transport.callMethod({
95
- destination: DBUS_DESTINATION,
96
- path: DBUS_PATH,
97
- interface: DBUS_IFACE,
98
- member: 'GetNameOwner',
99
- signature: 's',
100
- args: [MM_BUS_NAME],
101
- });
102
- const owner = reply.body[0];
103
- return typeof owner === 'string' && owner.length > 0 ? owner : undefined;
104
- }
105
- catch {
106
- // NameHasNoOwner (or a transient failure) → no current epoch yet.
107
- return undefined;
108
- }
97
+ return queryModemManagerOwner(this.#transport);
109
98
  }
110
99
  #onNameOwnerChanged(event) {
111
- if (event.body[0] !== MM_BUS_NAME) {
100
+ const routed = routeOwnerSignal(event);
101
+ if (routed.kind === 'unrelated') {
112
102
  return;
113
103
  }
114
- const newOwner = typeof event.body[2] === 'string' ? event.body[2] : '';
115
- if (newOwner.length === 0) {
104
+ if (routed.kind === 'lost') {
116
105
  // Owner lost — stale, never a removal.
117
106
  this.#handleSourceGone('source-unavailable');
118
107
  return;
119
108
  }
120
- if (newOwner === this.#currentOwner) {
109
+ if (routed.owner === this.#currentOwner) {
121
110
  return;
122
111
  }
123
112
  // New epoch: everything goes stale until the fresh snapshot restores it.
124
113
  if (this.#store.markUnavailable('source-unavailable')) {
125
114
  this.#emit();
126
115
  }
127
- this.#currentOwner = newOwner;
116
+ this.#currentOwner = routed.owner;
128
117
  this.#scheduleRefresh();
129
118
  }
130
119
  #onObjectSignal(event) {
131
120
  // Epoch guard: a signal from anyone but the current owner is an OLD-epoch
132
121
  // straggler and must never drive a removal (draft §Oracle round-3 #5).
133
- if (this.#currentOwner === undefined || event.sender !== this.#currentOwner) {
122
+ if (!isCurrentOwnerSignal(event, this.#currentOwner)) {
134
123
  return;
135
124
  }
136
125
  if (this.#priming) {
@@ -154,17 +143,11 @@ export class MmDbusObserver {
154
143
  async #runRefresh(epochOwner) {
155
144
  this.#refreshing = true;
156
145
  try {
157
- const reply = await this.#transport.callMethod({
158
- destination: this.#destination,
159
- path: MM_ROOT_PATH,
160
- interface: OBJECT_MANAGER_IFACE,
161
- member: 'GetManagedObjects',
162
- });
146
+ const tree = await readAuthoritativeTree(this.#transport, this.#destination);
163
147
  // Late reply from a superseded epoch — discard (draft §Oracle round-3 #5).
164
148
  if (this.#currentOwner !== epochOwner || this.#stopped) {
165
149
  return;
166
150
  }
167
- const tree = asManagedObjects(reply.body[0]);
168
151
  const rowsChanged = this.#store.reconcile(tree);
169
152
  const healthChanged = this.#store.markHealthy();
170
153
  this.#notifyEpochRefresh(epochOwner, tree);
@@ -9,7 +9,14 @@ export interface RouterEthernetProbeDeps {
9
9
  readonly resolveGateway?: (ifname: DeviceIfname) => Promise<string | undefined>;
10
10
  /** Whether `host` is reachable via `ifname` (ICMP). */
11
11
  readonly ping?: (host: string, ifname: DeviceIfname) => Promise<boolean>;
12
+ /** Override the public DNS target used by the egress probe. */
13
+ readonly probeHost?: string;
14
+ /** Bound applied to every default subprocess, including `ip`. */
15
+ readonly subprocessTimeoutMs?: number;
12
16
  }
17
+ export declare const ROUTER_PROBE_FAILURES: readonly ['command-missing', 'timeout', 'nonzero-exit', 'unreachable'];
18
+ export type RouterProbeFailureKind = (typeof ROUTER_PROBE_FAILURES)[number];
19
+ export declare function classifyRouterProbeFailure(error: unknown): RouterProbeFailureKind;
13
20
  /**
14
21
  * Create a router-ethernet probe implementing the advisory `RouterPort`. Presence is
15
22
  * link-up; health additionally reports DHCP-gateway reachability and a basic egress
@@ -8,38 +8,70 @@
8
8
  // observed. Each probe is an injectable seam (default `Bun.spawn` of `ip` / `ping`)
9
9
  // so tests run with no real network.
10
10
  import { epochMillis } from '../domain/index.js';
11
+ export const ROUTER_PROBE_FAILURES = [
12
+ 'command-missing',
13
+ 'timeout',
14
+ 'nonzero-exit',
15
+ 'unreachable',
16
+ ];
17
+ export function classifyRouterProbeFailure(error) {
18
+ if (error instanceof Error && error.name === 'TimeoutError')
19
+ return 'timeout';
20
+ if (error instanceof Error && error.name === 'UnreachableError')
21
+ return 'unreachable';
22
+ if (error instanceof Error &&
23
+ 'code' in error &&
24
+ typeof error.code === 'string' &&
25
+ error.code === 'ENOENT') {
26
+ return 'command-missing';
27
+ }
28
+ return 'nonzero-exit';
29
+ }
11
30
  /** The host used for the basic egress-health probe (public DNS anycast). */
12
31
  const EGRESS_PROBE_HOST = '1.1.1.1';
13
- async function spawnSucceeds(command) {
32
+ const DEFAULT_SUBPROCESS_TIMEOUT_MS = 5_000;
33
+ function bounded(operation, timeoutMs) {
34
+ return Promise.race([
35
+ operation,
36
+ new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error('probe timed out'), { name: 'TimeoutError' })), timeoutMs)),
37
+ ]);
38
+ }
39
+ async function spawnSucceeds(command, timeoutMs) {
14
40
  try {
15
41
  const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' });
16
- return (await proc.exited) === 0;
42
+ return ((await Promise.race([
43
+ proc.exited,
44
+ new Promise((resolve) => setTimeout(() => resolve(124), timeoutMs)),
45
+ ])) === 0);
17
46
  }
18
47
  catch {
19
48
  return false;
20
49
  }
21
50
  }
22
- async function spawnOutput(command) {
51
+ async function spawnOutput(command, timeoutMs) {
23
52
  try {
24
53
  const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' });
25
- const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
54
+ const [out, code] = await Promise.race([
55
+ Promise.all([new Response(proc.stdout).text(), proc.exited]),
56
+ new Promise((resolve) => setTimeout(() => resolve(['', 124]), timeoutMs)),
57
+ ]);
26
58
  return code === 0 ? out : '';
27
59
  }
28
60
  catch {
29
61
  return '';
30
62
  }
31
63
  }
32
- function defaultCheckLinkUp(ifname) {
33
- return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)]);
64
+ function defaultCheckLinkUp(ifname, timeoutMs) {
65
+ return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)], timeoutMs);
34
66
  }
35
- async function defaultResolveGateway(ifname) {
67
+ async function defaultResolveGateway(ifname, timeoutMs) {
36
68
  // `ip -o route show default dev <ifname>` → "default via 192.168.8.1 dev <ifname> …".
37
- const out = await spawnOutput(['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)]);
69
+ const out = await spawnOutput(['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)], timeoutMs);
38
70
  const match = out.match(/default via (\S+)/);
39
71
  return match?.[1];
40
72
  }
41
- function defaultPing(host, ifname) {
42
- return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host]);
73
+ function defaultPing(host, ifname, timeoutMs) {
74
+ return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host], timeoutMs);
43
75
  }
44
76
  /**
45
77
  * Create a router-ethernet probe implementing the advisory `RouterPort`. Presence is
@@ -49,18 +81,38 @@ function defaultPing(host, ifname) {
49
81
  */
50
82
  export function createRouterEthernetProbe(deps = {}) {
51
83
  const now = deps.now ?? (() => epochMillis(Date.now()));
52
- const checkLinkUp = deps.checkLinkUp ?? defaultCheckLinkUp;
53
- const resolveGateway = deps.resolveGateway ?? defaultResolveGateway;
54
- const ping = deps.ping ?? defaultPing;
84
+ const timeoutMs = deps.subprocessTimeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS;
85
+ const checkLinkUp = deps.checkLinkUp ?? ((ifname) => defaultCheckLinkUp(ifname, timeoutMs));
86
+ const resolveGateway = deps.resolveGateway ?? ((ifname) => defaultResolveGateway(ifname, timeoutMs));
87
+ const ping = deps.ping ?? ((host, ifname) => defaultPing(host, ifname, timeoutMs));
88
+ const probeHost = deps.probeHost ?? EGRESS_PROBE_HOST;
55
89
  async function probePresence(ifname) {
56
- return (await checkLinkUp(ifname).catch(() => false)) ? 'present' : 'absent';
90
+ return (await bounded(checkLinkUp(ifname), timeoutMs).catch(() => false))
91
+ ? 'present'
92
+ : 'absent';
57
93
  }
58
94
  async function checkHealth(ifname) {
59
95
  const presence = await probePresence(ifname);
60
- const gateway = presence === 'present' ? await resolveGateway(ifname).catch(() => undefined) : undefined;
61
- const gatewayReachable = gateway !== undefined && (await ping(gateway, ifname).catch(() => false));
62
- const egressHealthy = gatewayReachable && (await ping(EGRESS_PROBE_HOST, ifname).catch(() => false));
63
- return { presence, gatewayReachable, egressHealthy, observedAt: now() };
96
+ const failureKinds = [];
97
+ const gateway = presence === 'present'
98
+ ? await bounded(resolveGateway(ifname), timeoutMs).catch((error) => {
99
+ failureKinds.push(classifyRouterProbeFailure(error));
100
+ return undefined;
101
+ })
102
+ : undefined;
103
+ const gatewayReachable = gateway !== undefined &&
104
+ (await bounded(ping(gateway, ifname), timeoutMs).catch((error) => {
105
+ failureKinds.push(classifyRouterProbeFailure(error));
106
+ return false;
107
+ }));
108
+ const egressHealthy = gatewayReachable &&
109
+ (await bounded(ping(probeHost, ifname), timeoutMs).catch((error) => {
110
+ failureKinds.push(classifyRouterProbeFailure(error));
111
+ return false;
112
+ }));
113
+ const health = { presence, gatewayReachable, egressHealthy, observedAt: now() };
114
+ Object.defineProperty(health, 'failureKinds', { value: failureKinds, enumerable: true });
115
+ return health;
64
116
  }
65
117
  return { probePresence, checkHealth };
66
118
  }