@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
@@ -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 { type AtAuditSink, type AtCommandSender } from './at-lease.js';
4
- import { type UsbDeviceSnapshot } from './device-classifier.js';
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, readRuntimeCompositionCurrent, } from '../usb-mode/index.js';
24
- import { AtCommandLease, computeAtAllowlist, } from './at-lease.js';
25
- import { descriptorsMatch, detectUsbMode } from './device-classifier.js';
26
- import { ALLOW_ALL_TRANSITION_INTERLOCK, checkTransitionPreconditions, } from './transition-preconditions.js';
27
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
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 checkTransitionPreconditions(request, this.#catalog, this.#interlock);
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 checkTransitionPreconditions(request, this.#catalog, this.#interlock);
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.push('force-uninhibit');
100
- await this.#modemManager.uninhibit(held).catch(() => undefined);
99
+ await releaseInhibit(this.#modemManager, held, steps);
101
100
  };
102
- const lease = new AtCommandLease({
101
+ const lease = createTransitionAtLease({
103
102
  sender: this.#atSender,
104
- allowlist: computeAtAllowlist(allowlistedCommands),
103
+ allowlistedCommands,
105
104
  timeoutMs: this.#watchdogMs,
106
- onWatchdog: forceUninhibit,
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.#awaitReenumeration(request.cachedPhysicalUid);
134
+ const device = await this.#waiter.awaitDevice(request.cachedPhysicalUid);
131
135
  steps.push('postcondition');
132
- if (plan.proof.tier === 'catalog-descriptors') {
133
- const observedMode = detectUsbMode(device);
134
- const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors);
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;
@@ -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
+ }
@@ -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';
@@ -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
- const parsed = z
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
- const parsed = z
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,4 @@
1
+ import { z } from 'zod';
2
+ type JsonSchema<T> = z.ZodType<T>;
3
+ export declare function parseJsonWith<T>(schema: JsonSchema<T>, body: string): T | undefined;
4
+ export {};
@@ -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
  *
@@ -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>;