@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readRuntimeCompositionCurrent } from '../../usb-mode/index.js';
|
|
2
|
+
import { descriptorsMatch, detectUsbMode } from '../device-classifier.js';
|
|
3
|
+
export async function transitionPostconditionFailure(plan, device, lease, inhibitUid, steps) {
|
|
4
|
+
if (plan.proof.tier === 'catalog-descriptors') {
|
|
5
|
+
const observedMode = detectUsbMode(device);
|
|
6
|
+
const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors);
|
|
7
|
+
return observedMode === plan.proof.transition.to && descriptorsOk
|
|
8
|
+
? undefined
|
|
9
|
+
: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`;
|
|
10
|
+
}
|
|
11
|
+
steps.push('postcondition-runtime-read');
|
|
12
|
+
const response = await lease.run(plan.proof.currentQuery, { inhibitUid });
|
|
13
|
+
const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw);
|
|
14
|
+
return Object.is(observed, plan.proof.target)
|
|
15
|
+
? undefined
|
|
16
|
+
: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { UsbDeviceSnapshot } from '../device-classifier.js';
|
|
2
|
+
export declare class ReenumerationWaiter {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(enumerate: () => Promise<readonly UsbDeviceSnapshot[]>, timeoutMs: number, pollMs: number);
|
|
5
|
+
awaitPortDrop(uid: string): Promise<void>;
|
|
6
|
+
awaitDevice(uid: string): Promise<UsbDeviceSnapshot>;
|
|
7
|
+
reprobe(): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2
|
+
export class ReenumerationWaiter {
|
|
3
|
+
#enumerate;
|
|
4
|
+
#timeoutMs;
|
|
5
|
+
#pollMs;
|
|
6
|
+
constructor(enumerate, timeoutMs, pollMs) {
|
|
7
|
+
this.#enumerate = enumerate;
|
|
8
|
+
this.#timeoutMs = timeoutMs;
|
|
9
|
+
this.#pollMs = pollMs;
|
|
10
|
+
}
|
|
11
|
+
async awaitPortDrop(uid) {
|
|
12
|
+
const deadline = Date.now() + this.#timeoutMs;
|
|
13
|
+
while (Date.now() < deadline) {
|
|
14
|
+
const devices = await this.#enumerate();
|
|
15
|
+
if (!devices.some((device) => device.physicalUid === uid)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await sleep(this.#pollMs);
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`control port did not drop within ${this.#timeoutMs}ms (uid ${uid})`);
|
|
21
|
+
}
|
|
22
|
+
async awaitDevice(uid) {
|
|
23
|
+
const deadline = Date.now() + this.#timeoutMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
const devices = await this.#enumerate();
|
|
26
|
+
const device = devices.find((candidate) => candidate.physicalUid === uid);
|
|
27
|
+
if (device !== undefined) {
|
|
28
|
+
return device;
|
|
29
|
+
}
|
|
30
|
+
await sleep(this.#pollMs);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`device did not re-enumerate within ${this.#timeoutMs}ms (uid ${uid})`);
|
|
33
|
+
}
|
|
34
|
+
async reprobe() {
|
|
35
|
+
await this.#enumerate().then(() => undefined, () => undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DeviceIfname, ModemManagerPort, NetworkManagerPort } from '../ports/index.js';
|
|
2
2
|
import { type CertifiedCatalog } from '../usb-mode/index.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import type { AtAuditSink, AtCommandSender } from './at-lease.js';
|
|
4
|
+
import type { UsbDeviceSnapshot } from './device-classifier.js';
|
|
5
5
|
import type { ModemActor } from './modem-actor.js';
|
|
6
6
|
import { type TransitionInterlock, type UsbModeTransitionOutcome, type UsbModeTransitionRequest } from './transition-preconditions.js';
|
|
7
7
|
/** Construction dependencies for the transition. Everything I/O is injectable. */
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
// reactivate, and still releases the interlock via `finally`. A hung command trips the
|
|
21
21
|
// AT watchdog, which force-uninhibits so the system reprobes rather than wedging.
|
|
22
22
|
import { deviceIfname } from '../ports/index.js';
|
|
23
|
-
import { CERTIFIED_CATALOG
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
23
|
+
import { CERTIFIED_CATALOG } from '../usb-mode/index.js';
|
|
24
|
+
import { ALLOW_ALL_TRANSITION_INTERLOCK, } from './transition-preconditions.js';
|
|
25
|
+
import { checkTransitionAdmission, releaseInhibit } from './usb-mode-transition/admission.js';
|
|
26
|
+
import { createTransitionAtLease } from './usb-mode-transition/at.js';
|
|
27
|
+
import { transitionPostconditionFailure } from './usb-mode-transition/outcome.js';
|
|
28
|
+
import { ReenumerationWaiter } from './usb-mode-transition/reenumeration.js';
|
|
28
29
|
const DEFAULT_WATCHDOG_MS = 30_000;
|
|
29
30
|
const DEFAULT_REENUM_TIMEOUT_MS = 60_000;
|
|
30
31
|
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
@@ -47,6 +48,7 @@ export class UsbModeTransition {
|
|
|
47
48
|
#watchdogMs;
|
|
48
49
|
#reenumMs;
|
|
49
50
|
#pollMs;
|
|
51
|
+
#waiter;
|
|
50
52
|
constructor(deps) {
|
|
51
53
|
this.#actor = deps.actor;
|
|
52
54
|
this.#nm = deps.nm;
|
|
@@ -60,12 +62,13 @@ export class UsbModeTransition {
|
|
|
60
62
|
this.#watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS;
|
|
61
63
|
this.#reenumMs = deps.reenumerationTimeoutMs ?? DEFAULT_REENUM_TIMEOUT_MS;
|
|
62
64
|
this.#pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
65
|
+
this.#waiter = new ReenumerationWaiter(this.#enumerate, this.#reenumMs, this.#pollMs);
|
|
63
66
|
}
|
|
64
67
|
/** Run one transition. Preconditions are checked at entry, then again in-actor. */
|
|
65
68
|
async execute(request) {
|
|
66
69
|
const steps = [];
|
|
67
70
|
// ENTRY check — a doomed request NEVER enters the actor (TIER A: zero calls).
|
|
68
|
-
const entry = await
|
|
71
|
+
const entry = await checkTransitionAdmission(request, this.#catalog, this.#interlock);
|
|
69
72
|
if (!entry.ok) {
|
|
70
73
|
return { status: 'refused', stage: 'entry', reason: entry.reason, steps };
|
|
71
74
|
}
|
|
@@ -74,7 +77,7 @@ export class UsbModeTransition {
|
|
|
74
77
|
async #inActor(request, steps) {
|
|
75
78
|
steps.push('actor-enter');
|
|
76
79
|
// IN-ACTOR re-check — catches a race that closed a gate while queued (TIER B).
|
|
77
|
-
const recheck = await
|
|
80
|
+
const recheck = await checkTransitionAdmission(request, this.#catalog, this.#interlock);
|
|
78
81
|
if (!recheck.ok) {
|
|
79
82
|
return { status: 'refused', stage: 'in-actor', reason: recheck.reason, steps };
|
|
80
83
|
}
|
|
@@ -91,19 +94,20 @@ export class UsbModeTransition {
|
|
|
91
94
|
let inhibit;
|
|
92
95
|
let reactivated = false;
|
|
93
96
|
const forceUninhibit = async () => {
|
|
94
|
-
if (inhibit === undefined) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
97
|
const held = inhibit;
|
|
98
98
|
inhibit = undefined;
|
|
99
|
-
steps
|
|
100
|
-
await this.#modemManager.uninhibit(held).catch(() => undefined);
|
|
99
|
+
await releaseInhibit(this.#modemManager, held, steps);
|
|
101
100
|
};
|
|
102
|
-
const lease =
|
|
101
|
+
const lease = createTransitionAtLease({
|
|
103
102
|
sender: this.#atSender,
|
|
104
|
-
|
|
103
|
+
allowlistedCommands,
|
|
105
104
|
timeoutMs: this.#watchdogMs,
|
|
106
|
-
|
|
105
|
+
modemManager: this.#modemManager,
|
|
106
|
+
currentInhibit: () => inhibit,
|
|
107
|
+
clearInhibit: () => {
|
|
108
|
+
inhibit = undefined;
|
|
109
|
+
},
|
|
110
|
+
steps,
|
|
107
111
|
...(this.#audit !== undefined ? { audit: this.#audit } : {}),
|
|
108
112
|
});
|
|
109
113
|
steps.push('nm-quiesce');
|
|
@@ -119,7 +123,7 @@ export class UsbModeTransition {
|
|
|
119
123
|
await lease.run(plan.applyCommand, { inhibitUid: request.inhibitUid });
|
|
120
124
|
}
|
|
121
125
|
steps.push('await-port-drop');
|
|
122
|
-
await this.#awaitPortDrop(request.cachedPhysicalUid);
|
|
126
|
+
await this.#waiter.awaitPortDrop(request.cachedPhysicalUid);
|
|
123
127
|
steps.push('uninhibit');
|
|
124
128
|
if (inhibit !== undefined) {
|
|
125
129
|
const held = inhibit;
|
|
@@ -127,34 +131,11 @@ export class UsbModeTransition {
|
|
|
127
131
|
await this.#modemManager.uninhibit(held);
|
|
128
132
|
}
|
|
129
133
|
steps.push('await-reenumeration');
|
|
130
|
-
const device = await this.#
|
|
134
|
+
const device = await this.#waiter.awaitDevice(request.cachedPhysicalUid);
|
|
131
135
|
steps.push('postcondition');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (observedMode !== plan.proof.transition.to || !descriptorsOk) {
|
|
136
|
-
return {
|
|
137
|
-
status: 'failed',
|
|
138
|
-
degraded: true,
|
|
139
|
-
reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`,
|
|
140
|
-
steps,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
steps.push('postcondition-runtime-read');
|
|
146
|
-
const response = await lease.run(plan.proof.currentQuery, {
|
|
147
|
-
inhibitUid: request.inhibitUid,
|
|
148
|
-
});
|
|
149
|
-
const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw);
|
|
150
|
-
if (!Object.is(observed, plan.proof.target)) {
|
|
151
|
-
return {
|
|
152
|
-
status: 'failed',
|
|
153
|
-
degraded: true,
|
|
154
|
-
reason: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`,
|
|
155
|
-
steps,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
136
|
+
const postconditionFailure = await transitionPostconditionFailure(plan, device, lease, request.inhibitUid, steps);
|
|
137
|
+
if (postconditionFailure !== undefined) {
|
|
138
|
+
return { status: 'failed', degraded: true, reason: postconditionFailure, steps };
|
|
158
139
|
}
|
|
159
140
|
steps.push('resolve-ifname');
|
|
160
141
|
const newIfname = this.#resolveIfname(device);
|
|
@@ -168,7 +149,7 @@ export class UsbModeTransition {
|
|
|
168
149
|
}
|
|
169
150
|
catch (error) {
|
|
170
151
|
await forceUninhibit();
|
|
171
|
-
await this.#reprobe();
|
|
152
|
+
await this.#waiter.reprobe();
|
|
172
153
|
return {
|
|
173
154
|
status: 'failed',
|
|
174
155
|
degraded: true,
|
|
@@ -186,31 +167,4 @@ export class UsbModeTransition {
|
|
|
186
167
|
}
|
|
187
168
|
}
|
|
188
169
|
}
|
|
189
|
-
async #awaitPortDrop(uid) {
|
|
190
|
-
const deadline = Date.now() + this.#reenumMs;
|
|
191
|
-
while (Date.now() < deadline) {
|
|
192
|
-
const devices = await this.#enumerate();
|
|
193
|
-
if (!devices.some((d) => d.physicalUid === uid)) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
await sleep(this.#pollMs);
|
|
197
|
-
}
|
|
198
|
-
throw new Error(`control port did not drop within ${this.#reenumMs}ms (uid ${uid})`);
|
|
199
|
-
}
|
|
200
|
-
async #awaitReenumeration(uid) {
|
|
201
|
-
const deadline = Date.now() + this.#reenumMs;
|
|
202
|
-
while (Date.now() < deadline) {
|
|
203
|
-
const devices = await this.#enumerate();
|
|
204
|
-
const device = devices.find((d) => d.physicalUid === uid);
|
|
205
|
-
if (device !== undefined) {
|
|
206
|
-
return device;
|
|
207
|
-
}
|
|
208
|
-
await sleep(this.#pollMs);
|
|
209
|
-
}
|
|
210
|
-
throw new Error(`device did not re-enumerate within ${this.#reenumMs}ms (uid ${uid})`);
|
|
211
|
-
}
|
|
212
|
-
/** Best-effort state re-read after a crash — the transaction still fails degraded. */
|
|
213
|
-
async #reprobe() {
|
|
214
|
-
await this.#enumerate().then(() => undefined, () => undefined);
|
|
215
|
-
}
|
|
216
170
|
}
|
|
@@ -10,3 +10,40 @@ export declare function decodeEsimStatus(value: number | undefined): 'no-profile
|
|
|
10
10
|
export declare function decodePacketServiceState(value: number | undefined): 'detached' | 'attached' | undefined;
|
|
11
11
|
export declare function decodeNetworkRejectionError(value: number | undefined): string | undefined;
|
|
12
12
|
export declare function runtimeIdFromPath(path: string): number | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The coarse registration context ModemManager's `3gpp-lac-ci` location source reports.
|
|
15
|
+
*
|
|
16
|
+
* Every field is kept as the SOURCE'S OWN TEXT. `locationAreaCode` / `cellId` /
|
|
17
|
+
* `trackingAreaCode` arrive as uppercase hexadecimal and a consumer that parsed them to
|
|
18
|
+
* a decimal number would render a different identifier than every other tool on the
|
|
19
|
+
* device shows; `mnc` is two OR three digits and its width is significant, so a numeric
|
|
20
|
+
* round-trip loses a leading zero that distinguishes two real operators.
|
|
21
|
+
*
|
|
22
|
+
* This is COARSE CELL location and is deliberately NOT a GNSS fix: it names a cell, not
|
|
23
|
+
* a position. It carries no coordinate, so it is outside the GNSS redaction class and
|
|
24
|
+
* outside `GNSS_SOURCES`, and decoding it enables nothing — `Location.Setup`'s
|
|
25
|
+
* `signal_location` argument stays false and this decoder never touches a mask.
|
|
26
|
+
*/
|
|
27
|
+
export interface Mm3gppLacCi {
|
|
28
|
+
readonly mcc: string;
|
|
29
|
+
readonly mnc: string;
|
|
30
|
+
readonly locationAreaCode: string;
|
|
31
|
+
readonly cellId: string;
|
|
32
|
+
readonly trackingAreaCode: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decode the `3gpp-lac-ci` location string, or `undefined` when it is not that shape.
|
|
36
|
+
*
|
|
37
|
+
* A token count other than five, or an empty MCC/MNC/CI, decodes to nothing rather than
|
|
38
|
+
* to a partially-populated record: a cell identifier that is wrong is worse than one
|
|
39
|
+
* that is missing, because only the second is visible as missing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function decode3gppLacCi(value: string | undefined): Mm3gppLacCi | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The PLMN id (`Modem3gpp.OperatorCode`'s spelling) for a decoded `3gpp-lac-ci` value.
|
|
44
|
+
*
|
|
45
|
+
* MCC and MNC are concatenated with no separator and no padding — MM splits the operator
|
|
46
|
+
* code back apart at exactly three characters, so this is the inverse of its own
|
|
47
|
+
* serialization rather than a guess about the MNC's width.
|
|
48
|
+
*/
|
|
49
|
+
export declare function lacCiOperatorCode(decoded: Mm3gppLacCi): string;
|
package/dist/domain/mm-enums.js
CHANGED
|
@@ -137,3 +137,37 @@ export function runtimeIdFromPath(path) {
|
|
|
137
137
|
const value = /\/(\d+)$/.exec(path)?.[1];
|
|
138
138
|
return value === undefined ? undefined : Number.parseInt(value, 10);
|
|
139
139
|
}
|
|
140
|
+
// ModemManager 1.24.2 `libmm-glib/mm-location-3gpp.c` builds this value with
|
|
141
|
+
// g_strdup_printf ("%.3s,%s,%lX,%lX,%lX", operator_code, operator_code + 3, lac, ci, tac)
|
|
142
|
+
// so it is a STRING of exactly five comma-separated tokens — MCC, MNC, then LAC, CI and
|
|
143
|
+
// TAC in uppercase hex. Its own parser reads exactly `split[0]`..`split[4]`, so a value
|
|
144
|
+
// with a different token count is not a shape this build should guess at.
|
|
145
|
+
const LAC_CI_TOKENS = 5;
|
|
146
|
+
/**
|
|
147
|
+
* Decode the `3gpp-lac-ci` location string, or `undefined` when it is not that shape.
|
|
148
|
+
*
|
|
149
|
+
* A token count other than five, or an empty MCC/MNC/CI, decodes to nothing rather than
|
|
150
|
+
* to a partially-populated record: a cell identifier that is wrong is worse than one
|
|
151
|
+
* that is missing, because only the second is visible as missing.
|
|
152
|
+
*/
|
|
153
|
+
export function decode3gppLacCi(value) {
|
|
154
|
+
if (value === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
const tokens = value.trim().split(',');
|
|
157
|
+
if (tokens.length !== LAC_CI_TOKENS)
|
|
158
|
+
return undefined;
|
|
159
|
+
const [mcc, mnc, locationAreaCode, cellId, trackingAreaCode] = tokens.map((token) => token.trim());
|
|
160
|
+
if (mcc === '' || mnc === '' || cellId === '')
|
|
161
|
+
return undefined;
|
|
162
|
+
return { mcc, mnc, locationAreaCode, cellId, trackingAreaCode };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The PLMN id (`Modem3gpp.OperatorCode`'s spelling) for a decoded `3gpp-lac-ci` value.
|
|
166
|
+
*
|
|
167
|
+
* MCC and MNC are concatenated with no separator and no padding — MM splits the operator
|
|
168
|
+
* code back apart at exactly three characters, so this is the inverse of its own
|
|
169
|
+
* serialization rather than a guess about the MNC's width.
|
|
170
|
+
*/
|
|
171
|
+
export function lacCiOperatorCode(decoded) {
|
|
172
|
+
return `${decoded.mcc}${decoded.mnc}`;
|
|
173
|
+
}
|
package/dist/fcc/coverage.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export declare const MM_FCC_UNLOCK_VENDOR_SCRIPTS: {
|
|
|
6
6
|
readonly '2c7c': 'qmicli';
|
|
7
7
|
};
|
|
8
8
|
export type MmFccUnlockVendorScript = keyof typeof MM_FCC_UNLOCK_VENDOR_SCRIPTS;
|
|
9
|
+
/** Provenance for the exact available-tier mapping mirrored below. */
|
|
10
|
+
export declare const MM_FCC_UNLOCK_SOURCE: {
|
|
11
|
+
readonly version: '1.24.2';
|
|
12
|
+
readonly commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19';
|
|
13
|
+
readonly path: 'data/dispatcher-fcc-unlock/meson.build';
|
|
14
|
+
readonly installedTier: 'fcc-unlock.available.d';
|
|
15
|
+
};
|
|
9
16
|
/** The interpreters those scripts invoke, and the packages that provide them. */
|
|
10
17
|
export declare const MM_FCC_UNLOCK_RUNTIME_PACKAGES: {
|
|
11
18
|
readonly qmicli: 'libqmi-utils';
|
package/dist/fcc/coverage.js
CHANGED
|
@@ -24,6 +24,13 @@ export const MM_FCC_UNLOCK_VENDOR_SCRIPTS = {
|
|
|
24
24
|
'14c3': 'mbimcli',
|
|
25
25
|
'2c7c': 'qmicli',
|
|
26
26
|
};
|
|
27
|
+
/** Provenance for the exact available-tier mapping mirrored below. */
|
|
28
|
+
export const MM_FCC_UNLOCK_SOURCE = {
|
|
29
|
+
version: '1.24.2',
|
|
30
|
+
commit: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19',
|
|
31
|
+
path: 'data/dispatcher-fcc-unlock/meson.build',
|
|
32
|
+
installedTier: 'fcc-unlock.available.d',
|
|
33
|
+
};
|
|
27
34
|
/** The interpreters those scripts invoke, and the packages that provide them. */
|
|
28
35
|
export const MM_FCC_UNLOCK_RUNTIME_PACKAGES = {
|
|
29
36
|
qmicli: 'libqmi-utils',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { parseJsonWith } from '../json-boundary.js';
|
|
2
3
|
import { parseHilinkXmlValue } from './hilink-protocol.js';
|
|
3
4
|
export * from './hilink-protocol.js';
|
|
4
5
|
const SIM_OBJECT_PATH = /^\/org\/freedesktop\/ModemManager1\/SIM\/\d+$/;
|
|
@@ -53,40 +54,10 @@ const ufiBodySchema = z.object({
|
|
|
53
54
|
params: flatRecordSchema.optional(),
|
|
54
55
|
});
|
|
55
56
|
function parseFlatRecord(body) {
|
|
56
|
-
|
|
57
|
-
.string()
|
|
58
|
-
.transform((value, context) => {
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(value);
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
if (!(error instanceof SyntaxError))
|
|
64
|
-
throw error;
|
|
65
|
-
context.addIssue({ code: 'custom', message: 'invalid JSON' });
|
|
66
|
-
return z.NEVER;
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
.pipe(flatRecordSchema)
|
|
70
|
-
.safeParse(body);
|
|
71
|
-
return parsed.success ? parsed.data : undefined;
|
|
57
|
+
return parseJsonWith(flatRecordSchema, body);
|
|
72
58
|
}
|
|
73
59
|
function parseUfiBody(body) {
|
|
74
|
-
|
|
75
|
-
.string()
|
|
76
|
-
.transform((value, context) => {
|
|
77
|
-
try {
|
|
78
|
-
return JSON.parse(value);
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
if (!(error instanceof SyntaxError))
|
|
82
|
-
throw error;
|
|
83
|
-
context.addIssue({ code: 'custom', message: 'invalid JSON' });
|
|
84
|
-
return z.NEVER;
|
|
85
|
-
}
|
|
86
|
-
})
|
|
87
|
-
.pipe(ufiBodySchema)
|
|
88
|
-
.safeParse(body);
|
|
89
|
-
return parsed.success ? parsed.data : undefined;
|
|
60
|
+
return parseJsonWith(ufiBodySchema, body);
|
|
90
61
|
}
|
|
91
62
|
export function parseHilinkSignal(input) {
|
|
92
63
|
const authStatus = parseHilinkXmlValue(input.status, 'code') === '125002';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export function parseJsonWith(schema, body) {
|
|
3
|
+
const parsed = z
|
|
4
|
+
.string()
|
|
5
|
+
.transform((value, context) => {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(value);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (!(error instanceof SyntaxError))
|
|
11
|
+
throw error;
|
|
12
|
+
context.addIssue({ code: 'custom', message: 'invalid JSON' });
|
|
13
|
+
return z.NEVER;
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
.pipe(schema)
|
|
17
|
+
.safeParse(body);
|
|
18
|
+
return parsed.success ? parsed.data : undefined;
|
|
19
|
+
}
|
|
@@ -17,6 +17,50 @@ export type NormalizedRadio = {
|
|
|
17
17
|
readonly registration: NormalizedMetric<string>;
|
|
18
18
|
readonly accessTechnologies: NormalizedMetric<readonly RadioAccessTechnology[]>;
|
|
19
19
|
readonly modeLabel: NormalizedMetric<string>;
|
|
20
|
+
/**
|
|
21
|
+
* The operator the modem is REGISTERED WITH right now — `Modem3gpp.OperatorName`.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT `Sim.OperatorName`, which is the HOME operator written into the
|
|
24
|
+
* SIM. The two agree on a home network and disagree the entire time a device is
|
|
25
|
+
* roaming, which is exactly when an operator is looking at this field.
|
|
26
|
+
*/
|
|
27
|
+
readonly operatorName: NormalizedMetric<string>;
|
|
28
|
+
/**
|
|
29
|
+
* The registered operator's PLMN id (MCC+MNC) — `Modem3gpp.OperatorCode`.
|
|
30
|
+
*
|
|
31
|
+
* Kept as text, not a number: the MNC is two OR three digits and the width is
|
|
32
|
+
* significant, so `732101` and `73201` are different networks and a numeric
|
|
33
|
+
* round-trip would lose the leading zero that separates them.
|
|
34
|
+
*/
|
|
35
|
+
readonly operatorCode: NormalizedMetric<string>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Coarse registration context — WHICH CELL, not where the device is.
|
|
39
|
+
*
|
|
40
|
+
* This is the `3gpp-lac-ci` location source's output, and it is a different class of
|
|
41
|
+
* datum from a GNSS fix: it names a cell in the operator's network and carries no
|
|
42
|
+
* coordinate. That is why it lives here rather than behind the GPS module's privacy
|
|
43
|
+
* fence, why `3gpp-lac-ci` stays outside `GNSS_SOURCES`, and why nothing on this path
|
|
44
|
+
* enables a location source or flips `Location.Setup`'s `signal_location`.
|
|
45
|
+
*
|
|
46
|
+
* Both fields are the SOURCE'S OWN TEXT. ModemManager emits them as uppercase hex, the
|
|
47
|
+
* ZTE and UFI admin APIs emit their own vendor spellings, and no radix is common to all
|
|
48
|
+
* three — so parsing to a number here would render an identifier that matches nothing
|
|
49
|
+
* an operator sees in `mmcli` or in the vendor's own web UI.
|
|
50
|
+
*
|
|
51
|
+
* EARFCN IS ABSENT ON PURPOSE and cannot be added from these sources. ModemManager
|
|
52
|
+
* publishes no generic ARFCN anywhere on the `Modem`, `Modem3gpp` or `Location`
|
|
53
|
+
* interfaces (checked against 1.24.2's introspection, not recalled); the only place one
|
|
54
|
+
* appears is inside a PER-CELL `GetCellInfo` dict, under two DIFFERENT keys — `earfcn`
|
|
55
|
+
* for LTE (`libmm-glib/mm-cell-info-lte.c`) and `nrarfcn` for 5GNR
|
|
56
|
+
* (`libmm-glib/mm-cell-info-nr5g.c`). A single normalized slot would therefore have to
|
|
57
|
+
* either merge two different quantities or silently pick a RAT, so this model makes no
|
|
58
|
+
* ARFCN claim at all and `backend/cell-info.ts` keeps the per-cell reading where it
|
|
59
|
+
* belongs.
|
|
60
|
+
*/
|
|
61
|
+
export type NormalizedCell = {
|
|
62
|
+
readonly cellId: NormalizedMetric<string>;
|
|
63
|
+
readonly tac: NormalizedMetric<string>;
|
|
20
64
|
};
|
|
21
65
|
/**
|
|
22
66
|
* Signal metrics.
|
|
@@ -75,6 +119,7 @@ export type NormalizedModemObservation = {
|
|
|
75
119
|
readonly radio: NormalizedRadio;
|
|
76
120
|
readonly signal: NormalizedSignal;
|
|
77
121
|
readonly sim: NormalizedSim;
|
|
122
|
+
readonly cell: NormalizedCell;
|
|
78
123
|
/** Everything the provider said, verbatim, plus what was and was not claimed. */
|
|
79
124
|
readonly diagnostics: ObservationDiagnostics;
|
|
80
125
|
};
|
|
@@ -20,6 +20,18 @@ export declare function hasRawField(record: RawFieldRecord, key: string): boolea
|
|
|
20
20
|
export declare function rawStructMember(record: RawFieldRecord, key: string, index: number): RawFieldValue | undefined;
|
|
21
21
|
export declare function rawNumberAt(record: RawFieldRecord, key: string, index: number): number | undefined;
|
|
22
22
|
export declare function rawBooleanAt(record: RawFieldRecord, key: string, index: number): boolean | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* One member of a D-Bus DICT (`a{sv}`) retained verbatim.
|
|
25
|
+
*
|
|
26
|
+
* A dict decodes to `[key, value][]`, so a retained `Modem.Signal.Lte` is a nested pair
|
|
27
|
+
* array rather than a flat scalar. Reading it by member name here — instead of flattening
|
|
28
|
+
* it at retention time — is what keeps `error-rate` and every future MM key in the
|
|
29
|
+
* diagnostics block while a metric claims only the member it names.
|
|
30
|
+
*/
|
|
31
|
+
export declare function rawDictMember(record: RawFieldRecord, key: string, member: string): RawFieldValue | undefined;
|
|
32
|
+
/** Whether a retained dict CARRIES a member — absent and undecodable stay separable. */
|
|
33
|
+
export declare function hasRawDictMember(record: RawFieldRecord, key: string, member: string): boolean;
|
|
34
|
+
export declare function rawDictNumber(record: RawFieldRecord, key: string, member: string): number | undefined;
|
|
23
35
|
/**
|
|
24
36
|
* Flatten every leaf element of an XML body into `<body>.<Tag>` entries.
|
|
25
37
|
*
|
package/dist/observations/raw.js
CHANGED
|
@@ -77,6 +77,37 @@ export function rawBooleanAt(record, key, index) {
|
|
|
77
77
|
const member = rawStructMember(record, key, index);
|
|
78
78
|
return typeof member === 'boolean' ? member : undefined;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* One member of a D-Bus DICT (`a{sv}`) retained verbatim.
|
|
82
|
+
*
|
|
83
|
+
* A dict decodes to `[key, value][]`, so a retained `Modem.Signal.Lte` is a nested pair
|
|
84
|
+
* array rather than a flat scalar. Reading it by member name here — instead of flattening
|
|
85
|
+
* it at retention time — is what keeps `error-rate` and every future MM key in the
|
|
86
|
+
* diagnostics block while a metric claims only the member it names.
|
|
87
|
+
*/
|
|
88
|
+
export function rawDictMember(record, key, member) {
|
|
89
|
+
const dict = record[key];
|
|
90
|
+
if (!Array.isArray(dict))
|
|
91
|
+
return undefined;
|
|
92
|
+
for (const entry of dict) {
|
|
93
|
+
if (Array.isArray(entry) && entry.length >= 2 && entry[0] === member)
|
|
94
|
+
return entry[1];
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/** Whether a retained dict CARRIES a member — absent and undecodable stay separable. */
|
|
99
|
+
export function hasRawDictMember(record, key, member) {
|
|
100
|
+
return rawDictMember(record, key, member) !== undefined;
|
|
101
|
+
}
|
|
102
|
+
export function rawDictNumber(record, key, member) {
|
|
103
|
+
const value = rawDictMember(record, key, member);
|
|
104
|
+
if (typeof value === 'number')
|
|
105
|
+
return Number.isFinite(value) ? value : undefined;
|
|
106
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
107
|
+
return undefined;
|
|
108
|
+
const parsed = Number.parseFloat(value);
|
|
109
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
110
|
+
}
|
|
80
111
|
const XML_LEAF = /<([A-Za-z_][\w.-]*)>([^<]*)<\/\1>/g;
|
|
81
112
|
/**
|
|
82
113
|
* Flatten every leaf element of an XML body into `<body>.<Tag>` entries.
|
|
@@ -17,7 +17,7 @@ import { freshObservation, metricProvenance } from '../envelope.js';
|
|
|
17
17
|
import { knownMetric, metricFromRouterSignal, metricUnknownReasonFromRouter, unknownMetric, } from '../metric.js';
|
|
18
18
|
import { createObservationDiagnostics } from '../provenance.js';
|
|
19
19
|
import { flattenXmlBody, hasRawField, mergeRawRecords, rawKey } from '../raw.js';
|
|
20
|
-
import { routerHardware, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
|
|
20
|
+
import { routerCell, routerHardware, routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
|
|
21
21
|
const SOURCE = 'huawei-hilink';
|
|
22
22
|
const STATUS = 'monitoring-status';
|
|
23
23
|
const SIGNAL = 'device-signal';
|
|
@@ -57,6 +57,9 @@ export function normalizeHilinkObservation(input, context) {
|
|
|
57
57
|
registration: unsupportedRadioMetric(provenance),
|
|
58
58
|
accessTechnologies: unknownMetric('unsupported', provenance([])),
|
|
59
59
|
modeLabel: normalizeModeLabel(capabilities.net_mode, provenance),
|
|
60
|
+
// The registered operator lives on `/api/net/current-plmn`, a body this
|
|
61
|
+
// source does not read and no migrated parser decodes.
|
|
62
|
+
...routerOperator(provenance, undefined),
|
|
60
63
|
},
|
|
61
64
|
signal: {
|
|
62
65
|
quality: unknownMetric('unsupported', provenance([])),
|
|
@@ -70,6 +73,9 @@ export function normalizeHilinkObservation(input, context) {
|
|
|
70
73
|
sinr: metricFromRouterSignal(signalModel.sinr, provenance([rawKey(SIGNAL, 'sinr')])),
|
|
71
74
|
},
|
|
72
75
|
sim: routerSim(provenance, hasRawField(raw, SIM_STATUS) ? SIM_STATUS : undefined),
|
|
76
|
+
// HiLink's `<cell_id>` is a raw tag no migrated parser reads; it stays verbatim
|
|
77
|
+
// in the diagnostics block rather than being lifted into a claim.
|
|
78
|
+
cell: routerCell(provenance, undefined),
|
|
73
79
|
diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }),
|
|
74
80
|
});
|
|
75
81
|
}
|
|
@@ -14,5 +14,30 @@ export type ModemManagerObservationInput = {
|
|
|
14
14
|
readonly modem3gpp?: Readonly<Record<string, RawFieldValue>>;
|
|
15
15
|
readonly sim?: Readonly<Record<string, RawFieldValue>>;
|
|
16
16
|
readonly signal?: Readonly<Record<string, RawFieldValue>>;
|
|
17
|
+
/**
|
|
18
|
+
* The `Modem.Location` reading, keyed by DECODED SOURCE NAME (`3gpp-lac-ci`).
|
|
19
|
+
*
|
|
20
|
+
* On the wire that property is an `a{uv}` keyed by the `MMModemLocationSource` BIT,
|
|
21
|
+
* and the bit-to-name vocabulary lives in `backend/mm-location.ts`. Naming the
|
|
22
|
+
* source here instead keeps this layer reading a flat named field like every other
|
|
23
|
+
* body, and keeps exactly one copy of that vocabulary.
|
|
24
|
+
*
|
|
25
|
+
* Supplying it enables nothing. A location SOURCE is switched on by
|
|
26
|
+
* `Location.Setup`, which this layer never calls; reading a value that is already
|
|
27
|
+
* being reported is a normalization step. `3gpp-lac-ci` in particular is coarse cell
|
|
28
|
+
* context rather than a GNSS fix, so it stays outside `GNSS_SOURCES` and the GNSS
|
|
29
|
+
* enable/disable path is untouched by it.
|
|
30
|
+
*
|
|
31
|
+
* NOTHING SUPPLIES IT TODAY, and the reason is the fence rather than an oversight.
|
|
32
|
+
* ModemManager masks the `Location` PROPERTY unless `Location.Setup` was called with
|
|
33
|
+
* `signal_location = true` — which broadcasts the value over `PropertiesChanged` and
|
|
34
|
+
* is therefore permanently forbidden here (`backend/mm-location.ts`). The value has
|
|
35
|
+
* to come from an explicit `GetLocation()` call instead, and the provider's snapshot
|
|
36
|
+
* path makes no such call. So an MM observation reads `not-observed` for `cell`:
|
|
37
|
+
* nobody looked, which is the honest answer and is distinct from a modem that
|
|
38
|
+
* reported nothing. Cell identity that IS wired today comes from `Modem.GetCellInfo`
|
|
39
|
+
* through `backend/cell-info.ts`, a different method that needs no location source.
|
|
40
|
+
*/
|
|
41
|
+
readonly location?: Readonly<Record<string, RawFieldValue>>;
|
|
17
42
|
};
|
|
18
43
|
export declare function normalizeModemManagerObservation(input: ModemManagerObservationInput, context: NormalizationContext): ObservationEnvelope<NormalizedModemObservation>;
|