@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.
- package/README.md +79 -0
- package/dist/backend/cell-info.d.ts +2 -0
- package/dist/backend/cell-info.js +18 -2
- package/dist/backend/device-classifier.d.ts +56 -0
- package/dist/backend/device-classifier.js +88 -0
- package/dist/backend/mm-mutations/bands.d.ts +4 -0
- package/dist/backend/mm-mutations/bands.js +49 -0
- package/dist/backend/mm-mutations/context.d.ts +10 -0
- package/dist/backend/mm-mutations/context.js +3 -0
- package/dist/backend/mm-mutations/inhibit.d.ts +4 -0
- package/dist/backend/mm-mutations/inhibit.js +19 -0
- package/dist/backend/mm-mutations/modes.d.ts +5 -0
- package/dist/backend/mm-mutations/modes.js +44 -0
- package/dist/backend/mm-mutations/scan.d.ts +3 -0
- package/dist/backend/mm-mutations/scan.js +46 -0
- package/dist/backend/mm-mutations/sim.d.ts +5 -0
- package/dist/backend/mm-mutations/sim.js +49 -0
- package/dist/backend/mm-mutations.d.ts +0 -16
- package/dist/backend/mm-mutations.js +27 -234
- package/dist/backend/observer/bus-lifecycle.d.ts +7 -0
- package/dist/backend/observer/bus-lifecycle.js +12 -0
- package/dist/backend/observer/epoch-reconcile.d.ts +4 -0
- package/dist/backend/observer/epoch-reconcile.js +28 -0
- package/dist/backend/observer/signal-routing.d.ts +11 -0
- package/dist/backend/observer/signal-routing.js +11 -0
- package/dist/backend/observer.d.ts +1 -1
- package/dist/backend/observer.js +18 -35
- package/dist/backend/router-ethernet.d.ts +7 -0
- package/dist/backend/router-ethernet.js +70 -18
- package/dist/backend/signal-setup.d.ts +21 -2
- package/dist/backend/signal-setup.js +24 -2
- package/dist/backend/usage/accounting.d.ts +7 -0
- package/dist/backend/usage/accounting.js +8 -2
- package/dist/backend/usage/persistence.d.ts +4 -0
- package/dist/backend/usage/persistence.js +59 -0
- package/dist/backend/usage/policy.d.ts +24 -0
- package/dist/backend/usage/policy.js +38 -0
- package/dist/backend/usage/sampler.d.ts +7 -1
- package/dist/backend/usage/sampler.js +33 -119
- package/dist/backend/usage/sampling.d.ts +38 -0
- package/dist/backend/usage/sampling.js +72 -0
- package/dist/backend/usb-mode-transition/admission.d.ts +5 -0
- package/dist/backend/usb-mode-transition/admission.js +12 -0
- package/dist/backend/usb-mode-transition/at.d.ts +13 -0
- package/dist/backend/usb-mode-transition/at.js +15 -0
- package/dist/backend/usb-mode-transition/outcome.d.ts +4 -0
- package/dist/backend/usb-mode-transition/outcome.js +17 -0
- package/dist/backend/usb-mode-transition/reenumeration.d.ts +8 -0
- package/dist/backend/usb-mode-transition/reenumeration.js +37 -0
- package/dist/backend/usb-mode-transition.d.ts +2 -2
- package/dist/backend/usb-mode-transition.js +25 -71
- package/dist/domain/mm-enums.d.ts +37 -0
- package/dist/domain/mm-enums.js +34 -0
- package/dist/fcc/coverage.d.ts +7 -0
- package/dist/fcc/coverage.js +7 -0
- package/dist/hardware/router-parsers.js +3 -32
- package/dist/json-boundary.d.ts +4 -0
- package/dist/json-boundary.js +19 -0
- package/dist/observations/model.d.ts +45 -0
- package/dist/observations/raw.d.ts +12 -0
- package/dist/observations/raw.js +31 -0
- package/dist/observations/sources/hilink.js +7 -1
- package/dist/observations/sources/modemmanager.d.ts +25 -0
- package/dist/observations/sources/modemmanager.js +123 -15
- package/dist/observations/sources/router-shared.d.ts +35 -1
- package/dist/observations/sources/router-shared.js +39 -0
- package/dist/observations/sources/ufi.js +7 -1
- package/dist/observations/sources/zte.js +7 -1
- package/dist/providers/modem-manager/provider.d.ts +10 -0
- package/dist/providers/modem-manager/provider.js +3 -0
- package/dist/providers/modem-manager/snapshot.js +6 -0
- package/dist/providers/network-manager/adapter.js +15 -136
- package/dist/providers/network-manager/divergence.d.ts +4 -0
- package/dist/providers/network-manager/divergence.js +7 -0
- package/dist/providers/network-manager/observe-fold.d.ts +9 -0
- package/dist/providers/network-manager/observe-fold.js +109 -0
- package/dist/providers/network-manager/projection.d.ts +5 -0
- package/dist/providers/network-manager/projection.js +14 -0
- package/dist/providers/network-manager/state.d.ts +10 -0
- package/dist/providers/network-manager/state.js +16 -0
- package/dist/providers/ufi-himi/session.js +2 -16
- package/dist/providers/zte-goform/session.js +2 -16
- package/dist/safety/flock-resource-ownership.js +73 -13
- package/dist/transport/transport.d.ts +20 -1
- package/dist/transport/transport.js +133 -16
- package/package.json +1 -1
|
@@ -1,259 +1,52 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
#
|
|
29
|
-
#actor;
|
|
30
|
-
#destination;
|
|
31
|
-
#resolveStableKey;
|
|
9
|
+
#context;
|
|
32
10
|
#scanTimeoutMs;
|
|
33
11
|
#now;
|
|
34
12
|
constructor(deps) {
|
|
35
|
-
this.#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
38
|
+
return unlockWithPin(this.#context, modem, pin);
|
|
179
39
|
}
|
|
180
40
|
sendPuk(modem, puk, newPin) {
|
|
181
|
-
return
|
|
41
|
+
return unlockWithPuk(this.#context, modem, puk, newPin);
|
|
182
42
|
}
|
|
183
43
|
scanNetworks(modem) {
|
|
184
|
-
return
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
return { uid, acquiredAt: epochMillis(this.#now()) };
|
|
46
|
+
inhibit(uid) {
|
|
47
|
+
return inhibitDevice(this.#context, uid, this.#now);
|
|
203
48
|
}
|
|
204
|
-
|
|
205
|
-
|
|
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 {
|
|
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
|
package/dist/backend/observer.js
CHANGED
|
@@ -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 {
|
|
17
|
-
import {
|
|
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
|
|
74
|
-
await
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
const routed = routeOwnerSignal(event);
|
|
101
|
+
if (routed.kind === 'unrelated') {
|
|
112
102
|
return;
|
|
113
103
|
}
|
|
114
|
-
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
53
|
-
const
|
|
54
|
-
const
|
|
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))
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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
|
}
|