@ceralive/modem-control 1.2.1 → 1.4.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
@@ -8,35 +8,45 @@
8
8
  // is `not-reported`, an interface nobody read is `not-observed`, and a field that WAS
9
9
  // present but decoded to nothing is `malformed` with a diagnostic note. Collapsing
10
10
  // those three into one absent value is precisely the loss this layer exists to stop.
11
- import { decodeEsimStatus, decodeMmAccessTechnologies, decodeMmState, decodeRegistrationState, decodeSimType, decodeStateFailedReason, decodeUnlockRequired, modeMaskToLabel, modemHardwareLabel, modemHardwareName, } from '../../domain/index.js';
11
+ import { decode3gppLacCi, decodeEsimStatus, decodeMmAccessTechnologies, decodeMmState, decodeRegistrationState, decodeSimType, decodeStateFailedReason, decodeUnlockRequired, modeMaskToLabel, modemHardwareLabel, modemHardwareName, } from '../../domain/index.js';
12
12
  import { readSimPresence } from '../../hardware/router-parsers.js';
13
13
  import { freshObservation, metricProvenance } from '../envelope.js';
14
14
  import { knownMetric, unknownMetric } from '../metric.js';
15
15
  import { createObservationDiagnostics, } from '../provenance.js';
16
- import { hasRawField, mergeRawRecords, prefixRawRecord, rawBooleanAt, rawKey, rawNumber, rawNumberAt, rawString, rawStringArray, } from '../raw.js';
16
+ import { hasRawDictMember, hasRawField, mergeRawRecords, prefixRawRecord, rawBooleanAt, rawDictNumber, rawKey, rawNumber, rawNumberAt, rawString, rawStringArray, } from '../raw.js';
17
17
  const SOURCE = 'modemmanager';
18
18
  const MODEM = 'Modem';
19
19
  const MODEM3GPP = 'Modem3gpp';
20
20
  const SIM = 'Sim';
21
21
  const SIGNAL = 'Signal';
22
+ const LOCATION = 'Location';
22
23
  export function normalizeModemManagerObservation(input, context) {
23
- const raw = mergeRawRecords(prefixRawRecord(MODEM, input.modem), prefixRawRecord(MODEM3GPP, input.modem3gpp), prefixRawRecord(SIM, input.sim), prefixRawRecord(SIGNAL, input.signal));
24
+ const raw = mergeRawRecords(prefixRawRecord(MODEM, input.modem), prefixRawRecord(MODEM3GPP, input.modem3gpp), prefixRawRecord(SIM, input.sim), prefixRawRecord(SIGNAL, input.signal), prefixRawRecord(LOCATION, input.location));
24
25
  const notes = [];
25
26
  const consumed = [];
26
27
  const provenance = (...fields) => {
27
28
  consumed.push(...fields);
28
29
  return metricProvenance(SOURCE, context, fields);
29
30
  };
31
+ // A dict-sourced metric CONSUMES the whole `a{sv}` property (that is the raw key it
32
+ // actually has) while NAMING the exact member it read, so provenance stays precise
33
+ // without `unmapped` gaining a key that was never in the payload.
34
+ const dictProvenance = (dict, member) => {
35
+ consumed.push(dict);
36
+ return metricProvenance(SOURCE, context, [`${dict}.${member}`]);
37
+ };
30
38
  const hardware = normalizeHardware(raw, provenance);
31
39
  const radio = normalizeRadio(raw, provenance, notes);
32
- const signal = normalizeSignal(raw, input.signal !== undefined, provenance);
40
+ const signal = normalizeSignal(raw, input.signal !== undefined, provenance, dictProvenance, notes);
33
41
  const sim = normalizeSim(raw, input.sim !== undefined, provenance, notes);
42
+ const cell = normalizeCell(raw, input.location !== undefined, provenance, notes);
34
43
  return freshObservation(SOURCE, context, {
35
44
  source: SOURCE,
36
45
  hardware,
37
46
  radio,
38
47
  signal,
39
48
  sim,
49
+ cell,
40
50
  diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }),
41
51
  });
42
52
  }
@@ -53,12 +63,53 @@ const SIM_SLOTS = rawKey(MODEM, 'SimSlots');
53
63
  const FAILED_REASON = rawKey(MODEM, 'StateFailedReason');
54
64
  const UNLOCK_REQUIRED = rawKey(MODEM, 'UnlockRequired');
55
65
  const REGISTRATION_STATE = rawKey(MODEM3GPP, 'RegistrationState');
66
+ const OPERATOR_NAME = rawKey(MODEM3GPP, 'OperatorName');
67
+ const OPERATOR_CODE = rawKey(MODEM3GPP, 'OperatorCode');
56
68
  const SIM_TYPE = rawKey(SIM, 'SimType');
57
69
  const ESIM_STATUS = rawKey(SIM, 'EsimStatus');
58
- const RSSI = rawKey(SIGNAL, 'rssi');
59
- const RSRP = rawKey(SIGNAL, 'rsrp');
60
- const RSRQ = rawKey(SIGNAL, 'rsrq');
61
- const SNR = rawKey(SIGNAL, 'snr');
70
+ // The `Modem.Location` entry for MM's coarse-cell source. Named, not bit-keyed — see
71
+ // `ModemManagerObservationInput.location`.
72
+ const LAC_CI = rawKey(LOCATION, '3gpp-lac-ci');
73
+ // `Modem.Signal` publishes ONE `a{sv}` per RAT, and the member sets differ (MM 1.24.2
74
+ // `org.freedesktop.ModemManager1.Modem.Signal.xml`):
75
+ // Cdma rssi/ecio/error-rate Evdo rssi/ecio/SINR/io/error-rate
76
+ // Gsm rssi/error-rate Umts rssi/rscp/ecio/error-rate
77
+ // Lte rssi/rsrq/rsrp/snr/error-rate Nr5g rsrq/rsrp/snr/error-rate
78
+ const SIGNAL_CDMA = rawKey(SIGNAL, 'Cdma');
79
+ const SIGNAL_EVDO = rawKey(SIGNAL, 'Evdo');
80
+ const SIGNAL_GSM = rawKey(SIGNAL, 'Gsm');
81
+ const SIGNAL_UMTS = rawKey(SIGNAL, 'Umts');
82
+ const SIGNAL_LTE = rawKey(SIGNAL, 'Lte');
83
+ const SIGNAL_NR5G = rawKey(SIGNAL, 'Nr5g');
84
+ const RSSI = {
85
+ member: 'rssi',
86
+ dicts: [SIGNAL_LTE, SIGNAL_UMTS, SIGNAL_GSM, SIGNAL_EVDO, SIGNAL_CDMA],
87
+ flat: rawKey(SIGNAL, 'rssi'),
88
+ };
89
+ const RSRP = {
90
+ member: 'rsrp',
91
+ dicts: [SIGNAL_NR5G, SIGNAL_LTE],
92
+ flat: rawKey(SIGNAL, 'rsrp'),
93
+ };
94
+ const RSRQ = {
95
+ member: 'rsrq',
96
+ dicts: [SIGNAL_NR5G, SIGNAL_LTE],
97
+ flat: rawKey(SIGNAL, 'rsrq'),
98
+ };
99
+ const SNR = {
100
+ member: 'snr',
101
+ dicts: [SIGNAL_NR5G, SIGNAL_LTE],
102
+ flat: rawKey(SIGNAL, 'snr'),
103
+ };
104
+ // SINR is a member of the `Evdo` dict and of NO other, so an LTE/NR modem reporting no
105
+ // SINR is a READ-class `not-reported` — ModemManager CAN express it, this modem did not.
106
+ // The NR SINR a device may publish through `Modem.GetCellInfo` is a different call on a
107
+ // different interface and is deliberately not folded in here.
108
+ const SINR = {
109
+ member: 'sinr',
110
+ dicts: [SIGNAL_EVDO],
111
+ flat: rawKey(SIGNAL, 'sinr'),
112
+ };
62
113
  function hardwareIdentity(raw) {
63
114
  const model = rawString(raw, MODEL);
64
115
  const manufacturer = rawString(raw, MANUFACTURER);
@@ -93,6 +144,53 @@ function normalizeRadio(raw, provenance, notes) {
93
144
  registration: decodedLabel(raw, REGISTRATION_STATE, decodeRegistrationState, provenance, notes),
94
145
  accessTechnologies: normalizeAccessTechnologies(raw, provenance, notes),
95
146
  modeLabel: normalizeModeLabel(raw, provenance, notes),
147
+ // `Modem3gpp`, never `Sim` — the registered operator, not the SIM's home one.
148
+ // A modem that is not registered reports these empty, which is `not-reported`:
149
+ // the property exists and this read carried no value.
150
+ operatorName: registrationString(raw, OPERATOR_NAME, provenance),
151
+ operatorCode: registrationString(raw, OPERATOR_CODE, provenance),
152
+ };
153
+ }
154
+ /** A `Modem3gpp` registration string — present-and-empty is still no reading. */
155
+ function registrationString(raw, key, provenance) {
156
+ const source = provenance(key);
157
+ const value = rawString(raw, key);
158
+ return value === undefined
159
+ ? unknownMetric('not-reported', source)
160
+ : knownMetric(value, source);
161
+ }
162
+ /**
163
+ * Coarse cell context from the `3gpp-lac-ci` location reading.
164
+ *
165
+ * The whole value is ONE string, so a `cellId` and a `tac` read out of it necessarily
166
+ * agree with each other — they came from the same reported cell, never from two reads
167
+ * that raced. A value in an unrecognized shape is `malformed` for both rather than
168
+ * partially decoded; see `decode3gppLacCi`.
169
+ */
170
+ function normalizeCell(raw, locationRead, provenance, notes) {
171
+ const source = provenance(LAC_CI);
172
+ if (!hasRawField(raw, LAC_CI)) {
173
+ const reason = locationRead ? 'not-reported' : 'not-observed';
174
+ return {
175
+ cellId: unknownMetric(reason, source),
176
+ tac: unknownMetric(reason, source),
177
+ };
178
+ }
179
+ const decoded = decode3gppLacCi(rawString(raw, LAC_CI));
180
+ if (decoded === undefined) {
181
+ notes.push({ code: 'field-shape-unrecognized', field: LAC_CI });
182
+ return {
183
+ cellId: unknownMetric('malformed', source),
184
+ tac: unknownMetric('malformed', source),
185
+ };
186
+ }
187
+ return {
188
+ cellId: knownMetric(decoded.cellId, source),
189
+ // MM emits an EMPTY tracking-area code on a device with no LTE/NR attach — the
190
+ // reading arrived, this field of it did not.
191
+ tac: decoded.trackingAreaCode === ''
192
+ ? unknownMetric('not-reported', source)
193
+ : knownMetric(decoded.trackingAreaCode, source),
96
194
  };
97
195
  }
98
196
  /** A decoder whose "I could not place this" answer is the literal string `unknown`. */
@@ -143,13 +241,24 @@ function normalizeModeLabel(raw, provenance, notes) {
143
241
  }
144
242
  return knownMetric(label, source);
145
243
  }
146
- function normalizeSignal(raw, signalRead, provenance) {
244
+ function normalizeSignal(raw, signalRead, provenance, dictProvenance, notes) {
147
245
  const missing = signalRead ? 'not-reported' : 'not-observed';
148
- const extended = (key) => {
149
- const source = provenance(key);
150
- const value = rawNumber(raw, key);
246
+ const extended = (metric) => {
247
+ for (const dict of metric.dicts) {
248
+ if (!hasRawDictMember(raw, dict, metric.member))
249
+ continue;
250
+ const source = dictProvenance(dict, metric.member);
251
+ const value = rawDictNumber(raw, dict, metric.member);
252
+ if (value === undefined) {
253
+ notes.push({ code: 'field-shape-unrecognized', field: `${dict}.${metric.member}` });
254
+ return unknownMetric('malformed', source);
255
+ }
256
+ return knownMetric(value, source);
257
+ }
258
+ const source = provenance(metric.flat);
259
+ const value = rawNumber(raw, metric.flat);
151
260
  return value === undefined
152
- ? unknownMetric(hasRawField(raw, key) ? 'malformed' : missing, source)
261
+ ? unknownMetric(hasRawField(raw, metric.flat) ? 'malformed' : missing, source)
153
262
  : knownMetric(value, source);
154
263
  };
155
264
  // `SignalQuality` is a `(ub)`: percentage at 0, "measured recently" at 1. Both are
@@ -173,8 +282,7 @@ function normalizeSignal(raw, signalRead, provenance) {
173
282
  rsrp: extended(RSRP),
174
283
  rsrq: extended(RSRQ),
175
284
  snr: extended(SNR),
176
- // `Modem.Signal` exposes rssi/rsrp/rsrq/snr/ecio/io/rscp — there is no SINR member.
177
- sinr: unknownMetric('unsupported', metricProvenanceEmpty(provenance)),
285
+ sinr: extended(SINR),
178
286
  };
179
287
  }
180
288
  /** Provenance for a metric no raw field backs, which is what `unsupported` means. */
@@ -1,6 +1,6 @@
1
1
  import { type ObservationAuthority } from '../../domain/index.js';
2
2
  import { type NormalizedMetric } from '../metric.js';
3
- import type { NormalizedHardware, NormalizedSim } from '../model.js';
3
+ import type { NormalizedCell, NormalizedHardware, NormalizedSim } from '../model.js';
4
4
  import type { MetricProvenance } from '../provenance.js';
5
5
  export type RouterProvenance = (fields: readonly string[], authority?: ObservationAuthority) => MetricProvenance;
6
6
  export declare function unsupportedRadioMetric(provenance: RouterProvenance): NormalizedMetric<string>;
@@ -14,6 +14,40 @@ export declare function unsupportedRadioMetric(provenance: RouterProvenance): No
14
14
  * migrated decoder covers is precisely the invented reading this layer refuses.
15
15
  */
16
16
  export declare function routerSim(provenance: RouterProvenance, vendorPresenceField?: string): NormalizedSim;
17
+ /**
18
+ * The cell block a router source produces.
19
+ *
20
+ * `cellId` is claimed only when a MIGRATED parser decoded one — `parseZteDetails` and
21
+ * `parseUfiDetails` both do — and the raw vendor key is named in provenance. HiLink
22
+ * ships a `<cell_id>` tag that no migrated parser reads, so it stays verbatim in the
23
+ * diagnostics block rather than being lifted here: the same rule that keeps `SimStatus`
24
+ * out of `sim.presence`.
25
+ *
26
+ * `tac` is always `not-reported`, never `unsupported`. None of the three migrated
27
+ * parsers decodes a tracking-area code, but that is a fact about what THIS package
28
+ * reads, not a claim that the vendor's firmware cannot report one — and `unsupported`
29
+ * would be the second kind of statement.
30
+ */
31
+ export declare function routerCell(provenance: RouterProvenance, cell: {
32
+ readonly id: string;
33
+ readonly field: string;
34
+ } | undefined): NormalizedCell;
35
+ /**
36
+ * A registered-operator NAME a migrated parser decoded, or an honest silence.
37
+ *
38
+ * The matching CODE is deliberately never derived. `parseZteDetails` reports `mcc` and
39
+ * `mnc` as separate unpadded vendor fields, while `Modem3gpp.OperatorCode` is a
40
+ * fixed-width concatenation that MM itself splits back apart at exactly three
41
+ * characters — joining an unpadded 2-vs-3-digit MNC would name a different network
42
+ * roughly whenever the leading zero matters. So it reads `not-reported`.
43
+ */
44
+ export declare function routerOperator(provenance: RouterProvenance, operator: {
45
+ readonly name: string;
46
+ readonly field: string;
47
+ } | undefined): {
48
+ readonly operatorName: NormalizedMetric<string>;
49
+ readonly operatorCode: NormalizedMetric<string>;
50
+ };
17
51
  /** Router admin APIs report no measurement-recency flag; that is a source capability claim. */
18
52
  export declare function unsupportedQualityRecent(provenance: RouterProvenance): NormalizedMetric<boolean>;
19
53
  /**
@@ -40,6 +40,45 @@ export function routerSim(provenance, vendorPresenceField) {
40
40
  esimStatus: unknownMetric('unsupported', provenance([])),
41
41
  };
42
42
  }
43
+ /**
44
+ * The cell block a router source produces.
45
+ *
46
+ * `cellId` is claimed only when a MIGRATED parser decoded one — `parseZteDetails` and
47
+ * `parseUfiDetails` both do — and the raw vendor key is named in provenance. HiLink
48
+ * ships a `<cell_id>` tag that no migrated parser reads, so it stays verbatim in the
49
+ * diagnostics block rather than being lifted here: the same rule that keeps `SimStatus`
50
+ * out of `sim.presence`.
51
+ *
52
+ * `tac` is always `not-reported`, never `unsupported`. None of the three migrated
53
+ * parsers decodes a tracking-area code, but that is a fact about what THIS package
54
+ * reads, not a claim that the vendor's firmware cannot report one — and `unsupported`
55
+ * would be the second kind of statement.
56
+ */
57
+ export function routerCell(provenance, cell) {
58
+ return {
59
+ cellId: cell === undefined
60
+ ? unknownMetric('not-reported', provenance([]))
61
+ : knownMetric(cell.id, provenance([cell.field])),
62
+ tac: unknownMetric('not-reported', provenance([])),
63
+ };
64
+ }
65
+ /**
66
+ * A registered-operator NAME a migrated parser decoded, or an honest silence.
67
+ *
68
+ * The matching CODE is deliberately never derived. `parseZteDetails` reports `mcc` and
69
+ * `mnc` as separate unpadded vendor fields, while `Modem3gpp.OperatorCode` is a
70
+ * fixed-width concatenation that MM itself splits back apart at exactly three
71
+ * characters — joining an unpadded 2-vs-3-digit MNC would name a different network
72
+ * roughly whenever the leading zero matters. So it reads `not-reported`.
73
+ */
74
+ export function routerOperator(provenance, operator) {
75
+ return {
76
+ operatorName: operator === undefined
77
+ ? unknownMetric('not-reported', provenance([]))
78
+ : knownMetric(operator.name, provenance([operator.field])),
79
+ operatorCode: unknownMetric('not-reported', provenance([])),
80
+ };
81
+ }
43
82
  /** Router admin APIs report no measurement-recency flag; that is a source capability claim. */
44
83
  export function unsupportedQualityRecent(provenance) {
45
84
  return unknownMetric('unsupported', provenance([]));
@@ -16,7 +16,7 @@ import { freshObservation, metricProvenance } from '../envelope.js';
16
16
  import { metricFromRouterSignal, unknownMetric } from '../metric.js';
17
17
  import { createObservationDiagnostics, } from '../provenance.js';
18
18
  import { hasRawField, mergeRawRecords, normalizeRawValue, parseJsonObject, prefixRawRecord, rawKey, } from '../raw.js';
19
- import { routerHardware, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
19
+ import { routerCell, routerHardware, routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
20
20
  const SOURCE = 'ufi-himiapi';
21
21
  const SYSINFO = 'sysinfo';
22
22
  const OVERVIEW = 'overview';
@@ -72,6 +72,9 @@ export function normalizeUfiObservation(input, context) {
72
72
  registration: unsupportedRadioMetric(provenance),
73
73
  accessTechnologies: unknownMetric('unsupported', provenance([])),
74
74
  modeLabel: unknownMetric('not-reported', provenance([])),
75
+ // `parseUfiDetails` decodes no operator field from any of the three
76
+ // endpoints, so neither half is claimed.
77
+ ...routerOperator(provenance, undefined),
75
78
  },
76
79
  signal: {
77
80
  quality: unknownMetric('unsupported', provenance([])),
@@ -89,6 +92,9 @@ export function normalizeUfiObservation(input, context) {
89
92
  sinr: metricFromRouterSignal(signalModel.sinr, provenance([])),
90
93
  },
91
94
  sim: routerSim(provenance, hasRawField(raw, SIM_STATE) ? SIM_STATE : undefined),
95
+ cell: routerCell(provenance, details?.cell_id === undefined
96
+ ? undefined
97
+ : { id: details.cell_id, field: rawKey(SYSINFO, 'cellid') }),
92
98
  diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }),
93
99
  });
94
100
  }
@@ -15,7 +15,7 @@ import { freshObservation, metricProvenance } from '../envelope.js';
15
15
  import { knownMetric, metricFromRouterSignal, unknownMetric, } from '../metric.js';
16
16
  import { createObservationDiagnostics } from '../provenance.js';
17
17
  import { hasRawField, parseJsonRecord, prefixRawRecord, rawKey } from '../raw.js';
18
- import { routerHardware, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
18
+ import { routerCell, routerHardware, routerOperator, routerSim, unsupportedQualityRecent, unsupportedRadioMetric, } from './router-shared.js';
19
19
  const SOURCE = 'zte-goform';
20
20
  const BODY = 'goform';
21
21
  /** ZTE's own SIM presence code. Named as evidence, never decoded into a presence. */
@@ -44,6 +44,9 @@ export function normalizeZteObservation(input, context) {
44
44
  registration: unsupportedRadioMetric(provenance),
45
45
  accessTechnologies: unknownMetric('unsupported', provenance([])),
46
46
  modeLabel: normalizeModeLabel(details?.network_type, parsed !== undefined, provenance),
47
+ ...routerOperator(provenance, details?.provider === undefined
48
+ ? undefined
49
+ : { name: details.provider, field: rawKey(BODY, 'network_provider_fullname') }),
47
50
  },
48
51
  signal: {
49
52
  quality: unknownMetric('unsupported', provenance([])),
@@ -59,6 +62,9 @@ export function normalizeZteObservation(input, context) {
59
62
  sinr: metricFromRouterSignal(signalModel.sinr, provenance([])),
60
63
  },
61
64
  sim: routerSim(provenance, hasRawField(raw, SIM_CARD_STATE) ? SIM_CARD_STATE : undefined),
65
+ cell: routerCell(provenance, details?.cell_id === undefined
66
+ ? undefined
67
+ : { id: details.cell_id, field: rawKey(BODY, 'cell_id') }),
62
68
  diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }),
63
69
  });
64
70
  }
@@ -11,6 +11,16 @@ export interface ModemManagerProviderOptions {
11
11
  readonly destination?: string;
12
12
  readonly quiesce?: QuiesceHook;
13
13
  readonly now?: () => number;
14
+ /**
15
+ * `Signal.Setup` reporting rate in whole seconds, defaulting to
16
+ * `DEFAULT_SIGNAL_INTERVAL_SECONDS` (5).
17
+ *
18
+ * The backend has accepted this since it was written; the provider had no way to pass
19
+ * it, so an embedder constructing a provider — which is every embedder — was pinned to
20
+ * the default with no seam to change it. That is the gap this closes, and it is why
21
+ * the option is threaded rather than re-implemented here.
22
+ */
23
+ readonly signalIntervalSeconds?: number;
14
24
  /**
15
25
  * The band-lock certification catalog. Defaults to the one shipped in this package,
16
26
  * which is EMPTY — so a band write is refused on every device until a human-reviewed
@@ -33,6 +33,9 @@ export class ModemManagerProvider {
33
33
  destination: this.#destination,
34
34
  actor,
35
35
  now: this.#now,
36
+ ...(options.signalIntervalSeconds === undefined
37
+ ? {}
38
+ : { signalIntervalSeconds: options.signalIntervalSeconds }),
36
39
  });
37
40
  const location = new MmLocation({
38
41
  transport: this.#transport,
@@ -6,10 +6,16 @@ import { decodeStateFailedReason, epochMillis, sourceEpoch, stableKeyFromPhysica
6
6
  import { readSimPresence } from '../../hardware/router-parsers.js';
7
7
  import { normalizeModemManagerObservation } from '../../observations/index.js';
8
8
  import { describeBandWriteCertification, readRadioModeTruth } from '../../radio/index.js';
9
+ import { isVariant } from '../../transport/index.js';
9
10
  const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`;
10
11
  function rawValue(value) {
11
12
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
12
13
  return value;
14
+ // An `a{sv}` decodes to `[key, variant][]`, so a dict member's value arrives WRAPPED.
15
+ // Without this unwrap a `Modem.Signal` RAT dict retained as `[['rsrp'], ['rsrq'], …]`:
16
+ // the key survived and the reading did not.
17
+ if (isVariant(value))
18
+ return rawValue(value.value);
13
19
  if (Array.isArray(value)) {
14
20
  const result = [];
15
21
  for (const item of value) {
@@ -20,20 +20,13 @@
20
20
  // deactivate. There is deliberately no delete path here: profile removal is not in
21
21
  // that set, so this adapter cannot express it.
22
22
  import { epochMillis } from '../../domain/index.js';
23
- import { appliedConfiguration, describeStateDivergence, desiredProfile, freshObservation, observedState, unavailableObservation, } from '../../observations/index.js';
23
+ import { appliedConfiguration, desiredProfile } from '../../observations/index.js';
24
24
  import { receipt } from '../../ports/index.js';
25
- import { boundBearer, nmBearerStateEquals, unboundBearer } from './types.js';
26
- const SOURCE = 'networkmanager';
27
- /** The device states in which NM is still settling an activation, not losing it. */
28
- const TRANSITIONAL_STATES = new Set([
29
- 'prepare',
30
- 'config',
31
- 'need-auth',
32
- 'ip-config',
33
- 'ip-check',
34
- 'secondaries',
35
- 'deactivating',
36
- ]);
25
+ import { projectDivergence } from './divergence.js';
26
+ import { foldObservation } from './observe-fold.js';
27
+ import { projectStateView } from './projection.js';
28
+ import { slotsFor, targetIfname } from './state.js';
29
+ import { boundBearer, unboundBearer } from './types.js';
37
30
  export class NetworkManagerAdapter {
38
31
  #port;
39
32
  #now;
@@ -79,7 +72,7 @@ export class NetworkManagerAdapter {
79
72
  autoConfig: request.profile.autoConfig,
80
73
  homeOnly: request.profile.homeOnly,
81
74
  }), epochMillis(this.#now()), request.requestedBy);
82
- this.#slotsFor(id).desired = desired;
75
+ slotsFor(this.#slots, id).desired = desired;
83
76
  return { ok: true, connectionId: id, desired };
84
77
  }
85
78
  /**
@@ -100,7 +93,7 @@ export class NetworkManagerAdapter {
100
93
  };
101
94
  }
102
95
  const desired = desiredProfile(unboundBearer(ifname), epochMillis(this.#now()), requestedBy);
103
- this.#slotsFor(id).desired = desired;
96
+ slotsFor(this.#slots, id).desired = desired;
104
97
  return { ok: true, connectionId: id, desired };
105
98
  }
106
99
  desiredFor(id) {
@@ -151,40 +144,9 @@ export class NetworkManagerAdapter {
151
144
  * was.
152
145
  */
153
146
  observe(input) {
154
- const generation = input.context.generation;
155
- if (this.#observedGeneration !== null && generation < this.#observedGeneration) {
156
- return {
157
- kind: 'refused',
158
- reason: 'superseded-generation',
159
- currentGeneration: this.#observedGeneration,
160
- };
161
- }
162
- this.#observedGeneration = generation;
163
- const devices = new Map(input.devices.map((device) => [device.ifname, device]));
164
- const outcomes = [];
165
- const losses = [];
166
- for (const [id, slots] of this.#slots) {
167
- const ifname = targetIfname(slots);
168
- if (ifname === undefined) {
169
- continue;
170
- }
171
- const device = devices.get(ifname);
172
- slots.observed = observedState(device === undefined
173
- ? unavailableObservation(SOURCE, input.context, 'device-absent')
174
- : freshObservation(SOURCE, input.context, observedBearer(device)));
175
- const outcome = this.#classify(id, slots, ifname, device, input.context.observedAt);
176
- outcomes.push({ connectionId: id, outcome });
177
- if (outcome.status === 'lost') {
178
- losses.push(outcome.loss);
179
- }
180
- }
181
- return {
182
- kind: 'accepted',
183
- generation,
184
- observedAt: input.context.observedAt,
185
- outcomes,
186
- losses,
187
- };
147
+ const folded = foldObservation(this.#slots, this.#observedGeneration, input);
148
+ this.#observedGeneration = folded.generation;
149
+ return folded.result;
188
150
  }
189
151
  observedFor(id) {
190
152
  return this.#slots.get(id)?.observed ?? null;
@@ -195,21 +157,11 @@ export class NetworkManagerAdapter {
195
157
  * for observations, so it cannot manufacture one.
196
158
  */
197
159
  stateView(id, context) {
198
- const slots = this.#slots.get(id);
199
- if (slots === undefined) {
200
- return null;
201
- }
202
- return {
203
- desired: slots.desired,
204
- applied: slots.applied,
205
- observed: slots.observed ??
206
- observedState(unavailableObservation(SOURCE, context, 'provider-unavailable')),
207
- };
160
+ return projectStateView(this.#slots, id, context);
208
161
  }
209
162
  /** `desiredVsApplied` ("did our write happen") and `appliedVsObserved` ("did it stick"). */
210
163
  divergence(id, context) {
211
- const view = this.stateView(id, context);
212
- return view === null ? null : describeStateDivergence(view, nmBearerStateEquals);
164
+ return projectDivergence(this.#slots, id, context);
213
165
  }
214
166
  // ── internals ──────────────────────────────────────────────────────────────
215
167
  async #activate(id, desired, options) {
@@ -243,7 +195,7 @@ export class NetworkManagerAdapter {
243
195
  generation: options.generation,
244
196
  operationId: options.operationId,
245
197
  });
246
- this.#slotsFor(id).applied = applied;
198
+ slotsFor(this.#slots, id).applied = applied;
247
199
  return { ok: true, applied, receipt: activation };
248
200
  }
249
201
  async #deactivate(id, ifname, options) {
@@ -263,82 +215,9 @@ export class NetworkManagerAdapter {
263
215
  generation: options.generation,
264
216
  operationId: options.operationId,
265
217
  });
266
- this.#slotsFor(id).applied = applied;
218
+ slotsFor(this.#slots, id).applied = applied;
267
219
  return { ok: true, applied, receipt: result };
268
220
  }
269
- #classify(id, slots, ifname, device, observedAt) {
270
- const applied = slots.applied;
271
- if (applied === null) {
272
- return { status: 'unapplied' };
273
- }
274
- const lose = (reason) => {
275
- slots.applied = null;
276
- return {
277
- status: 'lost',
278
- loss: {
279
- connectionId: id,
280
- deviceIfname: ifname,
281
- reason,
282
- lostAt: observedAt,
283
- generation: applied.generation,
284
- previous: applied,
285
- },
286
- };
287
- };
288
- if (device === undefined) {
289
- return lose('interface-absent');
290
- }
291
- if (device.state === 'failed') {
292
- return lose('activation-failed');
293
- }
294
- if (applied.configuration.kind === 'unbound') {
295
- // We deliberately took the bearer down; anything active here is somebody else.
296
- return device.activeConnection === undefined
297
- ? { status: 'retained', applied }
298
- : lose('connection-replaced');
299
- }
300
- if (device.activeConnection === undefined) {
301
- return lose('interface-detached');
302
- }
303
- if (device.activeConnection.connectionId !== id) {
304
- return lose('connection-replaced');
305
- }
306
- if (device.state === 'activated') {
307
- return { status: 'retained', applied };
308
- }
309
- return TRANSITIONAL_STATES.has(device.state)
310
- ? { status: 'pending', applied, deviceState: device.state }
311
- : lose('interface-detached');
312
- }
313
- #slotsFor(id) {
314
- const existing = this.#slots.get(id);
315
- if (existing !== undefined) {
316
- return existing;
317
- }
318
- const created = { desired: null, applied: null, observed: null };
319
- this.#slots.set(id, created);
320
- return created;
321
- }
322
- }
323
- /** The device the slots are about: where the bearer IS, else where it was asked for. */
324
- function targetIfname(slots) {
325
- const state = slots.applied?.configuration ?? slots.desired?.profile;
326
- if (state === undefined) {
327
- return undefined;
328
- }
329
- return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname;
330
- }
331
- function observedBearer(device) {
332
- const active = device.activeConnection;
333
- return active === undefined
334
- ? unboundBearer(device.ifname)
335
- : boundBearer({
336
- connectionId: active.connectionId,
337
- deviceIfname: device.ifname,
338
- apn: active.apn,
339
- autoConfig: active.autoConfig,
340
- homeOnly: active.homeOnly,
341
- });
342
221
  }
343
222
  function refuse(reason, message) {
344
223
  return { ok: false, reason, receipt: receipt('connection', 'failed', message) };
@@ -0,0 +1,4 @@
1
+ import type { NormalizationContext, StateDivergence } from '../../observations/index.js';
2
+ import type { ConnectionId } from '../../ports/index.js';
3
+ import type { ConnectionSlots } from './state.js';
4
+ export declare function projectDivergence(slotsByConnection: ReadonlyMap<ConnectionId, ConnectionSlots>, id: ConnectionId, context: NormalizationContext): StateDivergence | null;
@@ -0,0 +1,7 @@
1
+ import { describeStateDivergence } from '../../observations/index.js';
2
+ import { projectStateView } from './projection.js';
3
+ import { nmBearerStateEquals } from './types.js';
4
+ export function projectDivergence(slotsByConnection, id, context) {
5
+ const view = projectStateView(slotsByConnection, id, context);
6
+ return view === null ? null : describeStateDivergence(view, nmBearerStateEquals);
7
+ }
@@ -0,0 +1,9 @@
1
+ import type { DeviceGeneration } from '../../domain/index.js';
2
+ import type { ConnectionId } from '../../ports/index.js';
3
+ import type { ConnectionSlots } from './state.js';
4
+ import type { NmObservationInput, NmObservationResult } from './types.js';
5
+ export interface ObservationFold {
6
+ readonly generation: DeviceGeneration;
7
+ readonly result: NmObservationResult;
8
+ }
9
+ export declare function foldObservation(slotsByConnection: Map<ConnectionId, ConnectionSlots>, observedGeneration: DeviceGeneration | null, input: NmObservationInput): ObservationFold;