@ceralive/modem-control 1.2.0 → 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 (87) hide show
  1. package/README.md +84 -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/runtime-composition-operation.js +2 -0
  72. package/dist/providers/modem-manager/snapshot.js +6 -0
  73. package/dist/providers/network-manager/adapter.js +15 -136
  74. package/dist/providers/network-manager/divergence.d.ts +4 -0
  75. package/dist/providers/network-manager/divergence.js +7 -0
  76. package/dist/providers/network-manager/observe-fold.d.ts +9 -0
  77. package/dist/providers/network-manager/observe-fold.js +109 -0
  78. package/dist/providers/network-manager/projection.d.ts +5 -0
  79. package/dist/providers/network-manager/projection.js +14 -0
  80. package/dist/providers/network-manager/state.d.ts +10 -0
  81. package/dist/providers/network-manager/state.js +16 -0
  82. package/dist/providers/ufi-himi/session.js +2 -16
  83. package/dist/providers/zte-goform/session.js +2 -16
  84. package/dist/safety/flock-resource-ownership.js +73 -13
  85. package/dist/transport/transport.d.ts +20 -1
  86. package/dist/transport/transport.js +133 -16
  87. package/package.json +1 -1
package/README.md CHANGED
@@ -173,6 +173,11 @@ represented return path. Its suppression vocabulary is `unknown-vendor`,
173
173
  no targets. Unknown/disabled/blocked decisions happen before transport contact, and a
174
174
  capability read sends only the named READ/TEST forms, never a SET.
175
175
 
176
+ The current and enumeration replies must both carry a successful AT result before their raw
177
+ bodies are parsed. If either query fails, the operation returns the existing `no-return-path`
178
+ suppression with an empty offer; parseable-looking data followed by `ERROR` cannot make a
179
+ disruptive target writable.
180
+
176
181
  A reviewed catalog transition still provides the strongest success proof: canonical mode
177
182
  and USB descriptors must both match. Without one, the weaker fallback proof is the
178
183
  re-enumerated device's own post-switch READ reporting the target. AT `OK` is never success.
@@ -194,6 +199,85 @@ are, so the preferred mode and the measurement-recency flag survive normalizatio
194
199
  `NormalizedSignal.qualityRecent` claims that flag, and the router sources answer
195
200
  `unsupported` for it.
196
201
 
202
+ ### Extended signal — the `Modem.Signal` per-RAT dicts
203
+
204
+ `rsrp`, `rsrq`, `snr` and `sinr` are claimed for MM-managed modems from the
205
+ `Modem.Signal` interface's own `a{sv}` properties, so a ModemManager device reports the
206
+ same detail a Huawei or ZTE dongle always did. `rsrp` / `rsrq` / `snr` read `Nr5g` first
207
+ and `Lte` second — on a 5G NSA attach both are populated with different measurements, so
208
+ `MetricProvenance.rawFields` names the dict that answered (`Signal.Nr5g.rsrp`) rather than
209
+ merging the two. `dbm` reads `rssi` across `Lte → Umts → Gsm → Evdo → Cdma`.
210
+
211
+ `sinr` comes from the `Evdo` dict and from no other, because that is the only dict
212
+ ModemManager defines it on; `Lte` and `Nr5g` publish `snr`, a different quantity that
213
+ never populates it. An LTE/5G modem reporting no SINR therefore answers `not-reported` —
214
+ a claim about this read — and not `unsupported`, which would be a false claim about the
215
+ source. An exported-but-silent `Modem.Signal` yields `not-reported` for every extended
216
+ metric and a modem without the interface yields `not-observed`; neither ever yields a
217
+ zero. Every member the normalized model has no slot for (`error-rate`, `ecio`, `io`,
218
+ `rscp`) stays verbatim in the diagnostics block.
219
+
220
+ The `Signal.Setup` reporting rate is injectable — `signalIntervalSeconds` on
221
+ `createModemManagerProvider`, on `createMmDbusBackend`, or `intervalSeconds` on
222
+ `SignalSetupManager` — and defaults to `DEFAULT_SIGNAL_INTERVAL_SECONDS` (5 seconds) at
223
+ each. `Setup` takes an unsigned integer, so a fractional or non-positive rate is refused
224
+ at construction rather than marshalled.
225
+
226
+ ### Registration and cell context
227
+
228
+ `NormalizedRadio` carries `operatorName` and `operatorCode`, and every observation carries
229
+ an additive `cell` block (`cellId` + `tac`). Both operator fields come from
230
+ `Modem3gpp` — the operator the modem is **registered with** — and never from
231
+ `Sim.OperatorName`, which is the *home* operator written into the SIM and differs for the
232
+ whole time a device is roaming. `operatorCode` stays text: the MNC is two or three digits
233
+ and the width is significant, so `31001` and `310001` are different networks.
234
+
235
+ `cellId` and `tac` come from the existing `3gpp-lac-ci` location source, whose value is one
236
+ five-token string (`MCC,MNC,LAC,CI,TAC`, the last three in uppercase hex). Both fields are
237
+ read out of that single value, so they always describe the same reported cell; a value in
238
+ any other shape reads `malformed` for both rather than being partially decoded, and the hex
239
+ is kept as text so an identifier matches what `mmcli` shows. This is **coarse cell context,
240
+ not a GNSS fix** — it carries no coordinate, `3gpp-lac-ci` is not a member of
241
+ `GNSS_SOURCES`, and nothing on this path enables a location source or sets
242
+ `Location.Setup`'s `signal_location`.
243
+
244
+ Because ModemManager masks the `Location` property unless `signal_location` is true — which
245
+ this package never sets — nothing populates the optional `location` input today, and an MM
246
+ observation honestly reads `not-observed` for `cell`. Cell identity that is wired comes from
247
+ `Modem.GetCellInfo`, where `CellReading` now also reports `tac` and reads ModemManager's real
248
+ `ci` key (with the older `cell-id` spelling kept as a fallback).
249
+
250
+ **No EARFCN is claimed.** ModemManager exposes no generic ARFCN; the only occurrences are
251
+ inside per-cell `GetCellInfo` dicts under two different keys for two different quantities —
252
+ `earfcn` for LTE and `nrarfcn` for 5GNR. One normalized field would have to merge them or
253
+ pick a RAT, so neither `NormalizedCell` nor `CellReading` claims one and the raw keys stay
254
+ available.
255
+
256
+ Router sources claim only what their migrated parsers already decode: ZTE reports the
257
+ operator name and its cell id, UFI reports its cell id, HiLink reports neither. No router
258
+ source derives an operator code from separate unpadded MCC/MNC fields, and `tac` reads
259
+ `not-reported` rather than `unsupported` — nothing here decodes one, which says nothing
260
+ about what the firmware could report.
261
+
262
+ ### Data-usage throughput — absent is not zero
263
+
264
+ `SlotUsageSnapshot.rateBytesPerSecond` reports throughput over the last **measured**
265
+ sampling interval, and the key is **omitted** rather than set to `0` whenever there was no
266
+ interval to measure. That happens on a first sample, a rebaseline, a paused (ambiguous
267
+ identity) slot, a remap or reboot, an interface missing from `/proc/net/dev`, a clock that
268
+ did not advance — and, most importantly, on a **counter reset**.
269
+
270
+ Interface counters restart at zero when the interface is re-created. Clamping the resulting
271
+ negative delta to zero would show an idle link that was in fact carrying traffic, and using
272
+ the raw post-reset value would show every byte since the interface came up as if it moved
273
+ inside one interval. So a backwards counter reports **no rate at all**, and the baseline is
274
+ rebased in the same pass so the *next* interval is measured correctly rather than inheriting
275
+ the gap.
276
+
277
+ Rates are never persisted. A same-boot reload resumes the counter baseline — a cumulative
278
+ total is still true after a restart — but the rate restarts unmeasured, because this process
279
+ did not observe the start of that interval.
280
+
197
281
  ### Mutation safety ports
198
282
 
199
283
  The root export includes `MutationAdmissionPort`, `ResourceOwnershipPort`,
@@ -12,6 +12,8 @@ export interface CellReading {
12
12
  readonly serving: boolean;
13
13
  /** The cell identifier, when supplied (serving-cell tiebreak key). */
14
14
  readonly cellId?: string;
15
+ /** Tracking-area code — from the `tac` key ONLY, never derived from the cell id. */
16
+ readonly tac?: string;
15
17
  /** Physical cell id — from the `physical-ci` key ONLY. */
16
18
  readonly pci?: number;
17
19
  readonly rsrp?: number;
@@ -8,11 +8,24 @@
8
8
  // - `rsrp`, `rsrq` pass through as numbers
9
9
  // - `sinr` the REAL NR SINR key. A dict carrying `snr` (the
10
10
  // WRONG name) is IGNORED — `sinr` stays undefined.
11
- // - `cell-id` the cell identifier (serving-cell tiebreak)
11
+ // - `ci`, else `cell-id` the cell identifier (serving-cell tiebreak)
12
+ // - `tac` the tracking-area code, when the RAT reports one
12
13
  // - `band` surfaced ONLY when the source supplies it directly;
13
14
  // never inferred from earfcn / frequency / anything.
14
15
  // - `serving` / `cell-type` whether this is the serving cell.
15
16
  //
17
+ // `ci` IS THE REAL MM KEY and `cell-id` is the fallback, not the other way round.
18
+ // ModemManager 1.24.2 spells it `PROPERTY_CI "ci"` in both `libmm-glib/mm-cell-info-lte.c`
19
+ // and `mm-cell-info-nr5g.c`; `cell-id` is kept behind it so a source that already
20
+ // flattened to that older spelling still decodes rather than silently losing its id.
21
+ //
22
+ // NO ARFCN IS READ HERE, and that is deliberate rather than pending. MM does publish
23
+ // one — but under TWO different keys for two different quantities: `earfcn` in an LTE
24
+ // cell dict and `nrarfcn` in a 5GNR one. There is no generic ARFCN property anywhere on
25
+ // the `Modem`, `Modem3gpp` or `Location` interfaces, so a single `earfcn` field on this
26
+ // reading would have to either merge the two or quietly pick a RAT. `CellReading` makes
27
+ // no ARFCN claim instead, and an unread key stays available to a caller that needs it.
28
+ //
16
29
  // Every reading also carries `source` + `observedAt` provenance, so a consumer can
17
30
  // tell a fresh reading from a cached one and know where it came from. Pure — no I/O.
18
31
  import { numberProp, stringProp } from './managed-objects.js';
@@ -27,7 +40,8 @@ function readServing(cell) {
27
40
  }
28
41
  /** Normalize ONE cell's `a{sv}` dict into a `CellReading`, pinning the known keys. */
29
42
  export function normalizeCellReading(cell, provenance) {
30
- const cellId = stringProp(cell, 'cell-id');
43
+ const cellId = stringProp(cell, 'ci') ?? stringProp(cell, 'cell-id');
44
+ const tac = stringProp(cell, 'tac');
31
45
  const pci = numberProp(cell, 'physical-ci');
32
46
  const rsrp = numberProp(cell, 'rsrp');
33
47
  const rsrq = numberProp(cell, 'rsrq');
@@ -38,6 +52,7 @@ export function normalizeCellReading(cell, provenance) {
38
52
  return {
39
53
  serving: readServing(cell),
40
54
  ...(cellId !== undefined ? { cellId } : {}),
55
+ ...(tac !== undefined ? { tac } : {}),
41
56
  ...(pci !== undefined ? { pci } : {}),
42
57
  ...(rsrp !== undefined ? { rsrp } : {}),
43
58
  ...(rsrq !== undefined ? { rsrq } : {}),
@@ -105,6 +120,7 @@ function stableTag(reading) {
105
120
  return JSON.stringify([
106
121
  reading.serving,
107
122
  reading.cellId ?? null,
123
+ reading.tac ?? null,
108
124
  reading.pci ?? null,
109
125
  reading.rsrp ?? null,
110
126
  reading.rsrq ?? null,
@@ -13,6 +13,61 @@ export interface UsbNetClassification {
13
13
  readonly deviceClass: UsbNetClass;
14
14
  readonly reason: string;
15
15
  }
16
+ export type CellularModelEvidenceTier = 'modemmanager-1.24.2-fcc' | 'mainline-kernel' | 'usb-ids-registry';
17
+ /**
18
+ * A POSITIVE, sourced claim that a labelled family is a router appliance whose only
19
+ * control surface is a vendor web UI — no ModemManager plugin, no kernel modem driver
20
+ * claiming its application PID. ABSENCE OF THIS FIELD IS NOT A CLAIM: it does not say
21
+ * a family is a modem module, only that nothing here asserts otherwise. Same tri-state
22
+ * discipline as `fcc/coverage.ts` — `unknown` is never folded into `absent`.
23
+ *
24
+ * It also does not, and must not, decide a DEVICE CLASS. `classifyDevice` reads live
25
+ * interfaces and bound drivers; this label is model-family evidence sitting beside it.
26
+ */
27
+ export type CellularModelFamilyKind = 'router-webui';
28
+ export interface CellularModelEvidence {
29
+ readonly vendor: string;
30
+ readonly family: string;
31
+ readonly evidenceTier: CellularModelEvidenceTier;
32
+ readonly familyKind?: CellularModelFamilyKind;
33
+ }
34
+ /** Where each evidence tier's rows were read from, so a reviewer can re-check them. */
35
+ export declare const CELLULAR_MODEL_EVIDENCE_SOURCES: {
36
+ readonly 'modemmanager-1.24.2-fcc': {
37
+ readonly source: 'ModemManager 1.24.2 data/dispatcher-fcc-unlock/meson.build';
38
+ readonly pin: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19';
39
+ };
40
+ readonly 'mainline-kernel': {
41
+ readonly source: 'Linux drivers/net/usb/qmi_wwan.c + drivers/usb/serial/option.c';
42
+ readonly pin: '45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229';
43
+ };
44
+ readonly 'usb-ids-registry': {
45
+ readonly source: 'The USB ID Repository (linux-usb.org/usb.ids)';
46
+ readonly pin: '2026.06.26';
47
+ };
48
+ };
49
+ /**
50
+ * Exact model-family evidence. This table may label a known VID:PID, but it never
51
+ * decides whether the device is MM-managed: live interface/driver evidence below
52
+ * remains authoritative. The pinned ModemManager FCC map is the strongest tier;
53
+ * `mainline-kernel` rows are application-mode PIDs named by Linux's own qmi_wwan /
54
+ * qcserial / option device tables; `usb-ids-registry` is the weakest and is used only
55
+ * where NO kernel modem driver claims the id at all — which is itself the evidence
56
+ * that the device is a router appliance rather than a controllable module.
57
+ *
58
+ * EVERY ROW IS AN EXACT VID:PID. A vendor range is never inferred, and that is not a
59
+ * stylistic preference: NETGEAR's `0846` and u-blox's `1546` both carry non-cellular
60
+ * products (Wi-Fi/Ethernet adapters and GNSS receivers respectively), so a
61
+ * vendor-keyed rule on either would label hardware that has no radio.
62
+ */
63
+ export declare const CELLULAR_USB_MODEL_ROWS: ReadonlyMap<string, CellularModelEvidence>;
64
+ /**
65
+ * Vendor ids whose ENTIRE USB range is cellular, so the vendor id alone is honest
66
+ * evidence of a radio. Membership is a claim about the whole range, which is why
67
+ * NETGEAR's `0846` is deliberately ABSENT despite having a row above: the USB ID
68
+ * Repository lists that vendor's Wi-Fi and Ethernet adapters under it, so a
69
+ * vendor-keyed rule there would report a Wi-Fi dongle as a cellular uplink.
70
+ */
16
71
  export declare const CELLULAR_USB_VENDOR_IDS: ReadonlyMap<string, string>;
17
72
  /**
18
73
  * Classify one USB device from its udev/sysfs snapshot. Precedence:
@@ -23,6 +78,7 @@ export declare const CELLULAR_USB_VENDOR_IDS: ReadonlyMap<string, string>;
23
78
  */
24
79
  export declare function classifyDevice(snapshot: UsbDeviceSnapshot): DeviceClassification;
25
80
  export declare function cellularVendorName(vendorId: string): string | undefined;
81
+ export declare function cellularModelEvidence(device: Pick<UsbDeviceSnapshot, 'vendorId' | 'productId'>): CellularModelEvidence | undefined;
26
82
  export declare function cellularEvidence(device: UsbDeviceSnapshot): string | undefined;
27
83
  export declare function classifyUsbNetDevice(device: UsbDeviceSnapshot): UsbNetClassification;
28
84
  export declare function publishesGenericIdentity(device: UsbDeviceSnapshot): boolean;
@@ -35,6 +35,90 @@ const AT_DRIVERS = new Set(['option', 'qcserial', 'cdc_acm']);
35
35
  const ECM_NCM_DRIVERS = new Set(['cdc_ether', 'cdc_ncm']);
36
36
  const RNDIS_DRIVERS = new Set(['rndis_host']);
37
37
  const STORAGE_DRIVERS = new Set(['usb-storage', 'uas']);
38
+ /** Where each evidence tier's rows were read from, so a reviewer can re-check them. */
39
+ export const CELLULAR_MODEL_EVIDENCE_SOURCES = {
40
+ 'modemmanager-1.24.2-fcc': {
41
+ source: 'ModemManager 1.24.2 data/dispatcher-fcc-unlock/meson.build',
42
+ pin: 'f2b9ab1ad78d322f32134a444b5b54c6e8160e19',
43
+ },
44
+ 'mainline-kernel': {
45
+ source: 'Linux drivers/net/usb/qmi_wwan.c + drivers/usb/serial/option.c',
46
+ pin: '45c13f3f9e3bb15fd89ff2864c6f627a3b4b4229',
47
+ },
48
+ 'usb-ids-registry': {
49
+ source: 'The USB ID Repository (linux-usb.org/usb.ids)',
50
+ pin: '2026.06.26',
51
+ },
52
+ };
53
+ /**
54
+ * Exact model-family evidence. This table may label a known VID:PID, but it never
55
+ * decides whether the device is MM-managed: live interface/driver evidence below
56
+ * remains authoritative. The pinned ModemManager FCC map is the strongest tier;
57
+ * `mainline-kernel` rows are application-mode PIDs named by Linux's own qmi_wwan /
58
+ * qcserial / option device tables; `usb-ids-registry` is the weakest and is used only
59
+ * where NO kernel modem driver claims the id at all — which is itself the evidence
60
+ * that the device is a router appliance rather than a controllable module.
61
+ *
62
+ * EVERY ROW IS AN EXACT VID:PID. A vendor range is never inferred, and that is not a
63
+ * stylistic preference: NETGEAR's `0846` and u-blox's `1546` both carry non-cellular
64
+ * products (Wi-Fi/Ethernet adapters and GNSS receivers respectively), so a
65
+ * vendor-keyed rule on either would label hardware that has no radio.
66
+ */
67
+ export const CELLULAR_USB_MODEL_ROWS = new Map([
68
+ [
69
+ '03f0:4e1d',
70
+ { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' },
71
+ ],
72
+ ['1199:9071', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }],
73
+ [
74
+ '1199:9079',
75
+ { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' },
76
+ ],
77
+ ['1199:907b', { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'mainline-kernel' }],
78
+ ['1199:9091', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }],
79
+ ['1199:90d3', { vendor: 'Sierra Wireless', family: 'EM919x', evidenceTier: 'mainline-kernel' }],
80
+ ['1199:c081', { vendor: 'Sierra Wireless', family: 'EM75xx', evidenceTier: 'mainline-kernel' }],
81
+ [
82
+ '413c:81a3',
83
+ { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' },
84
+ ],
85
+ [
86
+ '413c:81a8',
87
+ { vendor: 'Sierra Wireless', family: 'EM74xx', evidenceTier: 'modemmanager-1.24.2-fcc' },
88
+ ],
89
+ ['1bc7:1031', { vendor: 'Telit', family: 'LE910C1-EUX', evidenceTier: 'mainline-kernel' }],
90
+ ['1bc7:1034', { vendor: 'Telit', family: 'LE910C4-WWX', evidenceTier: 'mainline-kernel' }],
91
+ ['1bc7:1040', { vendor: 'Telit', family: 'LE922A', evidenceTier: 'mainline-kernel' }],
92
+ ['1bc7:1050', { vendor: 'Telit', family: 'FN980', evidenceTier: 'mainline-kernel' }],
93
+ ['1bc7:1060', { vendor: 'Telit', family: 'LN920', evidenceTier: 'mainline-kernel' }],
94
+ ['1bc7:1070', { vendor: 'Telit', family: 'FN990A', evidenceTier: 'mainline-kernel' }],
95
+ ['1bc7:1080', { vendor: 'Telit', family: 'FE990A', evidenceTier: 'mainline-kernel' }],
96
+ ['1bc7:10a0', { vendor: 'Telit', family: 'FN920C04', evidenceTier: 'mainline-kernel' }],
97
+ ['1bc7:1100', { vendor: 'Telit', family: 'ME910', evidenceTier: 'mainline-kernel' }],
98
+ ['1bc7:1200', { vendor: 'Telit', family: 'LE920', evidenceTier: 'mainline-kernel' }],
99
+ ['1546:1311', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }],
100
+ ['1546:1312', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }],
101
+ ['1546:1313', { vendor: 'u-blox', family: 'LARA-R6', evidenceTier: 'mainline-kernel' }],
102
+ ['1546:1341', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }],
103
+ ['1546:1342', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }],
104
+ ['1546:1343', { vendor: 'u-blox', family: 'LARA-L6', evidenceTier: 'mainline-kernel' }],
105
+ [
106
+ '0846:68e1',
107
+ {
108
+ vendor: 'NETGEAR',
109
+ family: 'LB1120',
110
+ evidenceTier: 'usb-ids-registry',
111
+ familyKind: 'router-webui',
112
+ },
113
+ ],
114
+ ]);
115
+ /**
116
+ * Vendor ids whose ENTIRE USB range is cellular, so the vendor id alone is honest
117
+ * evidence of a radio. Membership is a claim about the whole range, which is why
118
+ * NETGEAR's `0846` is deliberately ABSENT despite having a row above: the USB ID
119
+ * Repository lists that vendor's Wi-Fi and Ethernet adapters under it, so a
120
+ * vendor-keyed rule there would report a Wi-Fi dongle as a cellular uplink.
121
+ */
38
122
  export const CELLULAR_USB_VENDOR_IDS = new Map([
39
123
  ['05c6', 'Qualcomm'],
40
124
  ['0af0', 'Option'],
@@ -43,6 +127,7 @@ export const CELLULAR_USB_VENDOR_IDS = new Map([
43
127
  ['1546', 'u-blox'],
44
128
  ['19d2', 'ZTE'],
45
129
  ['1bbb', 'TCL/Alcatel'],
130
+ ['1bc7', 'Telit'],
46
131
  ['1c9e', 'Longcheer'],
47
132
  ['1e0e', 'SIMCom'],
48
133
  ['2c7c', 'Quectel'],
@@ -154,6 +239,9 @@ export function classifyDevice(snapshot) {
154
239
  export function cellularVendorName(vendorId) {
155
240
  return CELLULAR_USB_VENDOR_IDS.get(vendorId.toLowerCase());
156
241
  }
242
+ export function cellularModelEvidence(device) {
243
+ return CELLULAR_USB_MODEL_ROWS.get(`${device.vendorId}:${device.productId}`.toLowerCase());
244
+ }
157
245
  export function cellularEvidence(device) {
158
246
  const vendor = cellularVendorName(device.vendorId);
159
247
  if (vendor !== undefined)
@@ -0,0 +1,4 @@
1
+ import type { BandReadResult, ModemRef, Receipt } from '../../ports/index.js';
2
+ import type { MmMutationContext } from './context.js';
3
+ export declare function readBands(context: MmMutationContext, modem: ModemRef): Promise<BandReadResult>;
4
+ export declare function setCurrentBands(context: MmMutationContext, modem: ModemRef, bands: readonly string[]): Promise<Receipt>;
@@ -0,0 +1,49 @@
1
+ import { decodeBandList, encodeBandList, isResetSelection } from '../../band/index.js';
2
+ import { receipt } from '../../ports/index.js';
3
+ import { MODEM_IFACE } from '../constants.js';
4
+ import { fetchManagedObjects, findInterface, propValue } from '../managed-objects.js';
5
+ import { describeMutationError } from './context.js';
6
+ export async function readBands(context, modem) {
7
+ try {
8
+ const tree = await fetchManagedObjects(context.transport, context.destination);
9
+ const props = findInterface(tree, modem, MODEM_IFACE);
10
+ if (props === undefined) {
11
+ return { ok: false, reason: 'the modem exports no Modem interface' };
12
+ }
13
+ return {
14
+ ok: true,
15
+ bands: {
16
+ supported: decodeBandList(propValue(props, 'SupportedBands')),
17
+ current: decodeBandList(propValue(props, 'CurrentBands')),
18
+ },
19
+ };
20
+ }
21
+ catch (error) {
22
+ return { ok: false, reason: `reading bands failed: ${describeMutationError(error)}` };
23
+ }
24
+ }
25
+ export function setCurrentBands(context, modem, bands) {
26
+ if (bands.length === 0) {
27
+ return Promise.resolve(receipt('band', 'failed', 'no bands were requested'));
28
+ }
29
+ const encoded = encodeBandList(bands);
30
+ if (!encoded.ok) {
31
+ return Promise.resolve(receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`));
32
+ }
33
+ return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => {
34
+ try {
35
+ await context.transport.callMethod({
36
+ destination: context.destination,
37
+ path: modem,
38
+ interface: MODEM_IFACE,
39
+ member: 'SetCurrentBands',
40
+ signature: 'au',
41
+ args: [encoded.values],
42
+ });
43
+ return receipt('band', 'applied', isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`);
44
+ }
45
+ catch (error) {
46
+ return receipt('band', 'failed', `SetCurrentBands failed: ${describeMutationError(error)}`);
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,10 @@
1
+ import type { ModemRef } from '../../ports/index.js';
2
+ import type { DbusTransport } from '../../transport/index.js';
3
+ import type { ModemActor } from '../modem-actor.js';
4
+ export interface MmMutationContext {
5
+ readonly transport: DbusTransport;
6
+ readonly actor: ModemActor;
7
+ readonly destination: string;
8
+ readonly resolveStableKey: (modem: ModemRef) => string;
9
+ }
10
+ export declare function describeMutationError(error: unknown): string;
@@ -0,0 +1,3 @@
1
+ export function describeMutationError(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -0,0 +1,4 @@
1
+ import type { InhibitLease } from '../../ports/index.js';
2
+ import type { MmMutationContext } from './context.js';
3
+ export declare function inhibitDevice(context: MmMutationContext, uid: string, now: () => number): Promise<InhibitLease>;
4
+ export declare function uninhibitDevice(context: MmMutationContext, lease: InhibitLease): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { epochMillis } from '../../domain/index.js';
2
+ import { MM_MANAGER_IFACE, MM_ROOT_PATH } from '../constants.js';
3
+ export async function inhibitDevice(context, uid, now) {
4
+ await setInhibited(context, uid, true);
5
+ return { uid, acquiredAt: epochMillis(now()) };
6
+ }
7
+ export async function uninhibitDevice(context, lease) {
8
+ await setInhibited(context, lease.uid, false);
9
+ }
10
+ function setInhibited(context, uid, inhibit) {
11
+ return context.transport.callMethod({
12
+ destination: context.destination,
13
+ path: MM_ROOT_PATH,
14
+ interface: MM_MANAGER_IFACE,
15
+ member: 'InhibitDevice',
16
+ signature: 'sb',
17
+ args: [uid, inhibit],
18
+ });
19
+ }
@@ -0,0 +1,5 @@
1
+ import type { DesiredRadio } from '../../domain/index.js';
2
+ import type { ModemRef, Receipt } from '../../ports/index.js';
3
+ import type { MmMutationContext } from './context.js';
4
+ export declare function setRadioModes(context: MmMutationContext, modem: ModemRef, preference: DesiredRadio): Promise<Receipt>;
5
+ export declare function setModeCombination(context: MmMutationContext, modem: ModemRef, allowed: number, preferred: number): Promise<Receipt>;
@@ -0,0 +1,44 @@
1
+ import { receipt } from '../../ports/index.js';
2
+ import { MODEM_IFACE } from '../constants.js';
3
+ import { describeMutationError } from './context.js';
4
+ const MODE_BIT = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 };
5
+ export function setRadioModes(context, modem, preference) {
6
+ const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered));
7
+ const preferred = preference.preferenceOrdered[0];
8
+ const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0;
9
+ if (allowed === 0) {
10
+ return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested'));
11
+ }
12
+ return setModeMasks(context, modem, allowed, preferredMask, 'radio mode preference applied');
13
+ }
14
+ export function setModeCombination(context, modem, allowed, preferred) {
15
+ if (allowed === 0) {
16
+ return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested'));
17
+ }
18
+ return setModeMasks(context, modem, allowed, preferred, 'radio mode combination applied');
19
+ }
20
+ function setModeMasks(context, modem, allowed, preferred, successMessage) {
21
+ return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => {
22
+ try {
23
+ await context.transport.callMethod({
24
+ destination: context.destination,
25
+ path: modem,
26
+ interface: MODEM_IFACE,
27
+ member: 'SetCurrentModes',
28
+ signature: '(uu)',
29
+ args: [[allowed, preferred]],
30
+ });
31
+ return receipt('radio', 'applied', successMessage);
32
+ }
33
+ catch (error) {
34
+ return receipt('radio', 'failed', `SetCurrentModes failed: ${describeMutationError(error)}`);
35
+ }
36
+ });
37
+ }
38
+ function maskOf(rats) {
39
+ let mask = 0;
40
+ for (const rat of rats) {
41
+ mask |= MODE_BIT[rat];
42
+ }
43
+ return mask;
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { ModemRef, NetworkScanResult } from '../../ports/index.js';
2
+ import type { MmMutationContext } from './context.js';
3
+ export declare function scanNetworks(context: MmMutationContext, modem: ModemRef, timeoutMs: number): Promise<NetworkScanResult>;
@@ -0,0 +1,46 @@
1
+ import { MODEM3GPP_IFACE } from '../constants.js';
2
+ import { numberProp, stringProp } from '../managed-objects.js';
3
+ import { describeMutationError } from './context.js';
4
+ const AVAILABILITY = {
5
+ 0: 'unknown',
6
+ 1: 'available',
7
+ 2: 'current',
8
+ 3: 'forbidden',
9
+ };
10
+ export function scanNetworks(context, modem, timeoutMs) {
11
+ return context.actor.run(context.resolveStableKey(modem), async () => {
12
+ try {
13
+ const reply = await context.transport.callMethod({
14
+ destination: context.destination,
15
+ path: modem,
16
+ interface: MODEM3GPP_IFACE,
17
+ member: 'Scan',
18
+ timeoutMs,
19
+ });
20
+ return { ok: true, networks: parseScan(reply.body[0]) };
21
+ }
22
+ catch (error) {
23
+ return { ok: false, reason: `network scan failed: ${describeMutationError(error)}` };
24
+ }
25
+ });
26
+ }
27
+ function parseScan(value) {
28
+ if (!Array.isArray(value)) {
29
+ return [];
30
+ }
31
+ const networks = [];
32
+ for (const entry of value) {
33
+ const operatorCode = stringProp(entry, 'operator-code');
34
+ if (operatorCode === undefined) {
35
+ continue;
36
+ }
37
+ const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short');
38
+ const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown';
39
+ networks.push({
40
+ operatorCode,
41
+ ...(name !== undefined ? { operatorName: name } : {}),
42
+ availability,
43
+ });
44
+ }
45
+ return networks;
46
+ }
@@ -0,0 +1,5 @@
1
+ import type { ModemRef, Receipt, SimPukUnlockResult, SimUnlockResult } from '../../ports/index.js';
2
+ import type { MmMutationContext } from './context.js';
3
+ export declare function setPrimarySimSlot(context: MmMutationContext, modem: ModemRef, slotIndex: number): Promise<Receipt>;
4
+ export declare function unlockWithPin(context: MmMutationContext, modem: ModemRef, pin: string): Promise<SimUnlockResult>;
5
+ export declare function unlockWithPuk(context: MmMutationContext, modem: ModemRef, puk: string, newPin: string): Promise<SimPukUnlockResult>;
@@ -0,0 +1,49 @@
1
+ import { receipt } from '../../ports/index.js';
2
+ import { MODEM_IFACE } from '../constants.js';
3
+ import { fetchManagedObjects, findInterface, propValue } from '../managed-objects.js';
4
+ import { sendSimPin, sendSimPuk } from '../sim-unlock.js';
5
+ import { describeMutationError } from './context.js';
6
+ export async function setPrimarySimSlot(context, modem, slotIndex) {
7
+ const slots = await readSlotCount(context, modem);
8
+ if (slots === undefined) {
9
+ return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list');
10
+ }
11
+ if (slots <= 1) {
12
+ return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select');
13
+ }
14
+ if (slotIndex < 1 || slotIndex > slots) {
15
+ return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`);
16
+ }
17
+ return context.actor.runQuiesced({ stableKey: context.resolveStableKey(modem) }, async () => {
18
+ try {
19
+ await context.transport.callMethod({
20
+ destination: context.destination,
21
+ path: modem,
22
+ interface: MODEM_IFACE,
23
+ member: 'SetPrimarySimSlot',
24
+ signature: 'u',
25
+ args: [slotIndex],
26
+ });
27
+ return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`);
28
+ }
29
+ catch (error) {
30
+ return receipt('simSlot', 'failed', `SetPrimarySimSlot failed: ${describeMutationError(error)}`);
31
+ }
32
+ });
33
+ }
34
+ export function unlockWithPin(context, modem, pin) {
35
+ return context.actor.run(context.resolveStableKey(modem), () => sendSimPin(context.transport, context.destination, modem, pin));
36
+ }
37
+ export function unlockWithPuk(context, modem, puk, newPin) {
38
+ return context.actor.run(context.resolveStableKey(modem), () => sendSimPuk(context.transport, context.destination, modem, puk, newPin));
39
+ }
40
+ async function readSlotCount(context, modem) {
41
+ try {
42
+ const tree = await fetchManagedObjects(context.transport, context.destination);
43
+ const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots');
44
+ return Array.isArray(slots) ? slots.length : undefined;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
@@ -6,30 +6,14 @@ export interface MmMutationsDeps {
6
6
  readonly transport: DbusTransport;
7
7
  readonly actor: ModemActor;
8
8
  readonly destination?: string;
9
- /** Map a live modem path to its stable actor key (survives replug). */
10
9
  readonly resolveStableKey: (modem: ModemRef) => string;
11
10
  readonly scanTimeoutMs?: number;
12
11
  readonly now?: () => number;
13
12
  }
14
- /** The disruptive + SIM mutations of `ModemManagerPort`, serialized per modem. */
15
13
  export declare class MmMutations {
16
14
  #private;
17
15
  constructor(deps: MmMutationsDeps);
18
16
  setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise<Receipt>;
19
- /**
20
- * `SetCurrentModes` over the RAW `(uu)` masks the modem itself advertised.
21
- *
22
- * `setRadioModes` above speaks the reconciler's vocabulary — an ordered RAT
23
- * preference — and structurally cannot express `MM_MODEM_MODE_NONE`: its preferred
24
- * mask is derived from `preferenceOrdered[0]`, so "allow this set and prefer
25
- * nothing within it" has no spelling. That is the combination the bench FM350-GL
26
- * actually advertises, so a caller selecting an advertised combination verbatim
27
- * needs this entry point. It quiesces for the same reason `setRadioModes` does: a
28
- * mode change re-registers the radio and drops the bearer underneath NM.
29
- *
30
- * An `allowed` mask of 0 is refused — that is not a selection, it is a radio with
31
- * nothing switched on — while a `preferred` mask of 0 is passed through untouched.
32
- */
33
17
  setModeCombination(modem: ModemRef, allowed: number, preferred: number): Promise<Receipt>;
34
18
  readBands(modem: ModemRef): Promise<BandReadResult>;
35
19
  setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise<Receipt>;