@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
@@ -2,15 +2,32 @@ import type { DbusTransport } from '../transport/index.js';
2
2
  import type { DecodedManagedObjects } from './managed-objects.js';
3
3
  /** Whether periodic signal reporting is configured for a modem. */
4
4
  export type SignalCadence = 'active' | 'unsupported' | 'unknown';
5
- /** MM's default reporting rate is seconds; callers pass whole seconds. */
5
+ /**
6
+ * MM's default reporting rate is seconds; callers pass whole seconds.
7
+ *
8
+ * It is a DEFAULT and not a constant: extended signal costs a modem-side poll per tick,
9
+ * so an embedding process running a bonded uplink may want it faster than a bench tool
10
+ * does. The rate is therefore injectable end to end — `SignalSetupManagerOptions`,
11
+ * `MmDbusBackendOptions.signalIntervalSeconds` and
12
+ * `ModemManagerProviderOptions.signalIntervalSeconds` are the same seam at three levels
13
+ * — and this value is what an absent injection resolves to, at every one of them.
14
+ */
6
15
  export declare const DEFAULT_SIGNAL_INTERVAL_SECONDS = 5;
7
16
  export interface SignalSetupManagerOptions {
8
17
  readonly transport: DbusTransport;
9
18
  /** MM bus name override (defaults to `org.freedesktop.ModemManager1`). */
10
19
  readonly destination?: string;
11
- /** Reporting interval in seconds passed to `Signal.Setup`. */
20
+ /**
21
+ * Reporting interval in seconds passed to `Signal.Setup`.
22
+ *
23
+ * `Setup` takes a `u`, so a fractional or negative rate has no wire representation
24
+ * and is refused HERE rather than marshalled into whatever the codec makes of it —
25
+ * a modem silently polling at the wrong cadence is a defect nothing downstream can
26
+ * see. An absent value is not a refusal; it takes the default.
27
+ */
12
28
  readonly intervalSeconds?: number;
13
29
  }
30
+ export declare function resolveSignalInterval(intervalSeconds: number | undefined): number;
14
31
  /**
15
32
  * Drives `Signal.Setup` across the modem fleet, keyed to the observer's epochs. Feed
16
33
  * it every `onEpochRefresh` event; it applies setup to each modem exactly once per
@@ -19,6 +36,8 @@ export interface SignalSetupManagerOptions {
19
36
  export declare class SignalSetupManager {
20
37
  #private;
21
38
  constructor(options: SignalSetupManagerOptions);
39
+ /** The rate every `Signal.Setup` on this manager carries — injected, or the default. */
40
+ get intervalSeconds(): number;
22
41
  /** The last-known cadence for a modem path (`'unknown'` until first applied). */
23
42
  cadenceFor(modemPath: string): SignalCadence;
24
43
  /**
@@ -19,8 +19,26 @@ import { MM_BUS_NAME, MODEM_IFACE } from './constants.js';
19
19
  import { hasInterface, pathsWithInterface } from './managed-objects.js';
20
20
  /** The `Modem.Signal` interface — absent means signal cadence is unsupported. */
21
21
  const SIGNAL_IFACE = 'org.freedesktop.ModemManager1.Modem.Signal';
22
- /** MM's default reporting rate is seconds; callers pass whole seconds. */
22
+ /**
23
+ * MM's default reporting rate is seconds; callers pass whole seconds.
24
+ *
25
+ * It is a DEFAULT and not a constant: extended signal costs a modem-side poll per tick,
26
+ * so an embedding process running a bonded uplink may want it faster than a bench tool
27
+ * does. The rate is therefore injectable end to end — `SignalSetupManagerOptions`,
28
+ * `MmDbusBackendOptions.signalIntervalSeconds` and
29
+ * `ModemManagerProviderOptions.signalIntervalSeconds` are the same seam at three levels
30
+ * — and this value is what an absent injection resolves to, at every one of them.
31
+ */
23
32
  export const DEFAULT_SIGNAL_INTERVAL_SECONDS = 5;
33
+ export function resolveSignalInterval(intervalSeconds) {
34
+ if (intervalSeconds === undefined) {
35
+ return DEFAULT_SIGNAL_INTERVAL_SECONDS;
36
+ }
37
+ if (!Number.isInteger(intervalSeconds) || intervalSeconds <= 0) {
38
+ throw new RangeError(`Signal.Setup interval must be a positive whole number of seconds, got ${intervalSeconds}`);
39
+ }
40
+ return intervalSeconds;
41
+ }
24
42
  /**
25
43
  * Drives `Signal.Setup` across the modem fleet, keyed to the observer's epochs. Feed
26
44
  * it every `onEpochRefresh` event; it applies setup to each modem exactly once per
@@ -37,7 +55,11 @@ export class SignalSetupManager {
37
55
  constructor(options) {
38
56
  this.#transport = options.transport;
39
57
  this.#destination = options.destination ?? MM_BUS_NAME;
40
- this.#interval = options.intervalSeconds ?? DEFAULT_SIGNAL_INTERVAL_SECONDS;
58
+ this.#interval = resolveSignalInterval(options.intervalSeconds);
59
+ }
60
+ /** The rate every `Signal.Setup` on this manager carries — injected, or the default. */
61
+ get intervalSeconds() {
62
+ return this.#interval;
41
63
  }
42
64
  /** The last-known cadence for a modem path (`'unknown'` until first applied). */
43
65
  cadenceFor(modemPath) {
@@ -30,6 +30,13 @@ export interface SampleInput {
30
30
  }
31
31
  /** A fresh account for a slot first seen in `cycleStartMs`. */
32
32
  export declare function initialAccount(cycleStartMs: number): SlotAccount;
33
+ /**
34
+ * Whether two baseline keys name the SAME counter — a difference in any field is a
35
+ * remap. Exported because `sampling.ts` must ask the identical question before it
36
+ * divides a delta by an interval; two spellings of it would eventually disagree, and
37
+ * the rate is the half that would then be wrong silently.
38
+ */
39
+ export declare function sameBaselineKey(a: BaselineKey, b: BaselineKey): boolean;
33
40
  /**
34
41
  * Fold one observation into a slot's account, returning the NEXT account. Pure —
35
42
  * no clock, no I/O; the caller supplies `current`, `confidence` and `cycleStartMs`.
@@ -22,7 +22,13 @@
22
22
  export function initialAccount(cycleStartMs) {
23
23
  return { cycleBytes: 0, cycleStartMs, paused: false };
24
24
  }
25
- function sameKey(a, b) {
25
+ /**
26
+ * Whether two baseline keys name the SAME counter — a difference in any field is a
27
+ * remap. Exported because `sampling.ts` must ask the identical question before it
28
+ * divides a delta by an interval; two spellings of it would eventually disagree, and
29
+ * the rate is the half that would then be wrong silently.
30
+ */
31
+ export function sameBaselineKey(a, b) {
26
32
  return (a.logicalSlotId === b.logicalSlotId &&
27
33
  a.mappingGeneration === b.mappingGeneration &&
28
34
  a.ifname === b.ifname &&
@@ -50,7 +56,7 @@ export function applySample(prior, input) {
50
56
  if (base.paused || base.key === undefined || base.lastObserved === undefined) {
51
57
  return { ...base, paused: false, key: input.key, lastObserved: input.current };
52
58
  }
53
- if (!sameKey(base.key, input.key)) {
59
+ if (!sameBaselineKey(base.key, input.key)) {
54
60
  return { ...base, paused: false, key: input.key, lastObserved: input.current };
55
61
  }
56
62
  // Same key: a decrease is a counter reset → clamp the negative delta and rebase.
@@ -0,0 +1,4 @@
1
+ import type { SlotAccount } from './accounting.js';
2
+ import type { PersistedUsage } from './store.js';
3
+ export declare function hydrateUsageAccounts(initial: PersistedUsage, bootId: string): Map<string, SlotAccount>;
4
+ export declare function persistedUsageState(bootId: string, savedAtMs: number, accounts: ReadonlyMap<string, SlotAccount>): PersistedUsage;
@@ -0,0 +1,59 @@
1
+ // The persisted usage document — what survives a restart, and what deliberately does not.
2
+ //
3
+ // THROUGHPUT IS NOT PERSISTED, and its absence from `PersistedSlot` is a decision
4
+ // rather than an omission. A rate is a measurement over an interval whose two ends this
5
+ // process observed; a restart observed neither. Writing the last rate down would
6
+ // republish a figure measured before the gap as though it described now, and writing
7
+ // the baseline's sample TIME down would invite the next sample to divide a whole
8
+ // downtime's bytes by one sampling interval — the same invented spike `sampling.ts`
9
+ // refuses for a missing interface. So the counter BASELINE resumes across a same-boot
10
+ // reload (that is a cumulative total, and it is still true) while the rate restarts
11
+ // unmeasured. `persistence.test.ts` pins the negative.
12
+ import { USAGE_SCHEMA_VERSION } from './store.js';
13
+ export function hydrateUsageAccounts(initial, bootId) {
14
+ const accounts = new Map();
15
+ const sameBoot = initial.bootId === bootId;
16
+ for (const slot of initial.slots) {
17
+ const canResume = sameBoot &&
18
+ slot.ifname !== undefined &&
19
+ slot.mappingGeneration !== undefined &&
20
+ slot.lastObserved !== undefined;
21
+ if (canResume) {
22
+ accounts.set(slot.logicalSlotId, {
23
+ cycleBytes: slot.cycleBytes,
24
+ cycleStartMs: slot.cycleStartMs,
25
+ paused: false,
26
+ key: {
27
+ logicalSlotId: slot.logicalSlotId,
28
+ mappingGeneration: slot.mappingGeneration,
29
+ ifname: slot.ifname,
30
+ bootId,
31
+ },
32
+ lastObserved: slot.lastObserved,
33
+ });
34
+ }
35
+ else {
36
+ accounts.set(slot.logicalSlotId, {
37
+ cycleBytes: slot.cycleBytes,
38
+ cycleStartMs: slot.cycleStartMs,
39
+ paused: false,
40
+ });
41
+ }
42
+ }
43
+ return accounts;
44
+ }
45
+ export function persistedUsageState(bootId, savedAtMs, accounts) {
46
+ const slots = [];
47
+ for (const [logicalSlotId, account] of accounts) {
48
+ slots.push({
49
+ logicalSlotId,
50
+ cycleBytes: account.cycleBytes,
51
+ cycleStartMs: account.cycleStartMs,
52
+ ...(account.key !== undefined
53
+ ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname }
54
+ : {}),
55
+ ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}),
56
+ });
57
+ }
58
+ return { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs, slots };
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { DesiredUsage } from '../../domain/index.js';
2
+ import type { SlotAccount } from './accounting.js';
3
+ import type { UsageSnapshot } from './sampler.js';
4
+ import type { SlotRate } from './sampling.js';
5
+ export interface UsagePolicyState {
6
+ readonly bootId: string;
7
+ readonly defaultCycleDay: number;
8
+ readonly accounts: Map<string, SlotAccount>;
9
+ readonly policies: Map<string, DesiredUsage>;
10
+ readonly policyOverrides: Map<string, DesiredUsage>;
11
+ }
12
+ export interface UsagePolicyApplicationResult {
13
+ readonly cycleStartMs: number;
14
+ readonly cycleReset: boolean;
15
+ readonly dirty: boolean;
16
+ }
17
+ export declare function applyPolicy(state: UsagePolicyState, logicalSlotId: string, usage: DesiredUsage, now: number): UsagePolicyApplicationResult;
18
+ export interface UsageProjectionState {
19
+ readonly bootId: string;
20
+ readonly accounts: ReadonlyMap<string, SlotAccount>;
21
+ readonly policies: ReadonlyMap<string, DesiredUsage>;
22
+ readonly rates: ReadonlyMap<string, SlotRate>;
23
+ }
24
+ export declare function projectUsageSnapshot(state: UsageProjectionState, generatedAtMs: number): UsageSnapshot;
@@ -0,0 +1,38 @@
1
+ import { epochMillis } from '../../domain/index.js';
2
+ import { initialAccount } from './accounting.js';
3
+ import { cycleStart } from './billing-cycle.js';
4
+ export function applyPolicy(state, logicalSlotId, usage, now) {
5
+ state.policyOverrides.set(logicalSlotId, usage);
6
+ state.policies.set(logicalSlotId, usage);
7
+ const cycleStartMs = cycleStart(epochMillis(now), usage.cycleDay ?? state.defaultCycleDay);
8
+ const account = state.accounts.get(logicalSlotId);
9
+ if (account === undefined) {
10
+ state.accounts.set(logicalSlotId, initialAccount(cycleStartMs));
11
+ return { cycleStartMs, cycleReset: false, dirty: true };
12
+ }
13
+ if (account.cycleStartMs === cycleStartMs) {
14
+ return { cycleStartMs, cycleReset: false, dirty: false };
15
+ }
16
+ state.accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs });
17
+ return { cycleStartMs, cycleReset: true, dirty: true };
18
+ }
19
+ export function projectUsageSnapshot(state, generatedAtMs) {
20
+ const slots = [];
21
+ for (const [slotId, account] of state.accounts) {
22
+ const policy = state.policies.get(slotId);
23
+ const thresholdBytes = policy?.thresholdBytes;
24
+ // An unmeasured interval OMITS the key rather than reporting 0 — see `SlotRate`.
25
+ const bytesPerSecond = state.rates.get(slotId)?.bytesPerSecond;
26
+ slots.push({
27
+ logicalSlotId: slotId,
28
+ cycleBytes: account.cycleBytes,
29
+ cycleStartMs: account.cycleStartMs,
30
+ paused: account.paused,
31
+ ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}),
32
+ ...(thresholdBytes !== undefined ? { thresholdBytes } : {}),
33
+ thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes,
34
+ ...(bytesPerSecond !== undefined ? { rateBytesPerSecond: bytesPerSecond } : {}),
35
+ });
36
+ }
37
+ return { bootId: state.bootId, generatedAtMs, slots };
38
+ }
@@ -1,4 +1,4 @@
1
- import { type DesiredUsage, type LogicalSlotId } from '../../domain/index.js';
1
+ import type { DesiredUsage, LogicalSlotId } from '../../domain/index.js';
2
2
  import type { CounterSource } from './proc-net-dev.js';
3
3
  import type { UsageStore } from './store.js';
4
4
  /** One slot's observation for a sampling pass — identity + mapping + local policy. */
@@ -23,6 +23,12 @@ export interface SlotUsageSnapshot {
23
23
  readonly thresholdBytes?: number;
24
24
  /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */
25
25
  readonly thresholdExceeded: boolean;
26
+ /**
27
+ * Throughput over the last measured sampling interval. ABSENT — never 0 — when
28
+ * this pass had no interval to measure: a first sample, a rebaseline, a paused
29
+ * slot, a missing interface, or a counter that went BACKWARDS. See `SlotRate`.
30
+ */
31
+ readonly rateBytesPerSecond?: number;
26
32
  }
27
33
  /** The sampler's current state, per slot, at a point in time. */
28
34
  export interface UsageSnapshot {
@@ -10,23 +10,11 @@
10
10
  // `flush()` shutdown hook. A crash therefore loses AT MOST ~1 minute of unpersisted
11
11
  // deltas (the window since the last rate-limited write); a clean shutdown calls
12
12
  // `flush()` and loses effectively nothing.
13
- import { epochMillis } from '../../domain/index.js';
14
- import { applySample, initialAccount } from './accounting.js';
15
- import { cycleStart } from './billing-cycle.js';
16
- import { USAGE_SCHEMA_VERSION } from './store.js';
13
+ import { hydrateUsageAccounts, persistedUsageState } from './persistence.js';
14
+ import { applyPolicy, projectUsageSnapshot } from './policy.js';
15
+ import { applyUsageSamples } from './sampling.js';
17
16
  const DEFAULT_PERSIST_INTERVAL_MS = 60_000;
18
17
  const DEFAULT_CYCLE_DAY = 1;
19
- function toPersistedSlot(logicalSlotId, account) {
20
- return {
21
- logicalSlotId,
22
- cycleBytes: account.cycleBytes,
23
- cycleStartMs: account.cycleStartMs,
24
- ...(account.key !== undefined
25
- ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname }
26
- : {}),
27
- ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}),
28
- };
29
- }
30
18
  export class UsageSampler {
31
19
  #bootId;
32
20
  #source;
@@ -34,7 +22,7 @@ export class UsageSampler {
34
22
  #now;
35
23
  #persistIntervalMs;
36
24
  #defaultCycleDay;
37
- #accounts = new Map();
25
+ #accounts;
38
26
  #policies = new Map();
39
27
  // Policies written through `applyUsagePolicy` OUTRANK whatever an observation
40
28
  // carries, for the life of the process. Without this, the next `sample()` would
@@ -43,6 +31,10 @@ export class UsageSampler {
43
31
  // revert. The durable store is the source of truth for both, so an override and
44
32
  // an observation can only ever disagree inside that window.
45
33
  #policyOverrides = new Map();
34
+ // Rates are IN-MEMORY ONLY and start empty on every construction — deliberately,
35
+ // see `persistence.ts`. A throughput is a measurement over an interval this
36
+ // process observed both ends of; a restart has observed neither.
37
+ #rates = new Map();
46
38
  #lastPersistMs;
47
39
  #dirty = false;
48
40
  constructor(options, initial) {
@@ -53,7 +45,7 @@ export class UsageSampler {
53
45
  this.#persistIntervalMs = options.persistIntervalMs ?? DEFAULT_PERSIST_INTERVAL_MS;
54
46
  this.#defaultCycleDay = options.defaultCycleDay ?? DEFAULT_CYCLE_DAY;
55
47
  this.#lastPersistMs = this.#now();
56
- this.#hydrate(initial);
48
+ this.#accounts = hydrateUsageAccounts(initial, this.#bootId);
57
49
  }
58
50
  /** Load persisted state (recreating a fresh file if absent/corrupt) then build the sampler. */
59
51
  static async create(options) {
@@ -61,91 +53,29 @@ export class UsageSampler {
61
53
  const initial = await options.store.load(options.bootId, now());
62
54
  return new UsageSampler(options, initial);
63
55
  }
64
- /** Rebuild in-memory accounts. A reboot (differing boot id) drops the baselines. */
65
- #hydrate(initial) {
66
- const sameBoot = initial.bootId === this.#bootId;
67
- for (const slot of initial.slots) {
68
- const canResume = sameBoot &&
69
- slot.ifname !== undefined &&
70
- slot.mappingGeneration !== undefined &&
71
- slot.lastObserved !== undefined;
72
- if (canResume) {
73
- const key = {
74
- logicalSlotId: slot.logicalSlotId,
75
- mappingGeneration: slot.mappingGeneration,
76
- ifname: slot.ifname,
77
- bootId: this.#bootId,
78
- };
79
- this.#accounts.set(slot.logicalSlotId, {
80
- cycleBytes: slot.cycleBytes,
81
- cycleStartMs: slot.cycleStartMs,
82
- paused: false,
83
- key,
84
- lastObserved: slot.lastObserved,
85
- });
86
- }
87
- else {
88
- this.#accounts.set(slot.logicalSlotId, {
89
- cycleBytes: slot.cycleBytes,
90
- cycleStartMs: slot.cycleStartMs,
91
- paused: false,
92
- });
93
- }
94
- }
95
- }
96
56
  /** Take one sampling pass over the current counters for the given observations. */
97
57
  async sample(observations) {
98
58
  const counters = await this.#source.read();
99
59
  const now = this.#now();
100
- for (const obs of observations) {
101
- const slotId = obs.logicalSlotId;
102
- const usage = this.#policyOverrides.get(slotId) ?? obs.usage;
103
- this.#policies.set(slotId, usage);
104
- const cycleDay = usage.cycleDay ?? this.#defaultCycleDay;
105
- const cycleStartMs = cycleStart(epochMillis(now), cycleDay);
106
- const current = counters.get(obs.ifname);
107
- if (current === undefined) {
108
- // No reading for this interface — ensure the slot exists, attribute nothing.
109
- if (!this.#accounts.has(slotId)) {
110
- this.#accounts.set(slotId, initialAccount(cycleStartMs));
111
- }
112
- continue;
113
- }
114
- const key = {
115
- logicalSlotId: slotId,
116
- mappingGeneration: obs.mappingGeneration,
117
- ifname: obs.ifname,
118
- bootId: this.#bootId,
119
- };
120
- const next = applySample(this.#accounts.get(slotId), {
121
- key,
122
- current,
123
- confidence: obs.confidence,
124
- cycleStartMs,
125
- });
126
- this.#accounts.set(slotId, next);
127
- }
60
+ applyUsageSamples({
61
+ bootId: this.#bootId,
62
+ defaultCycleDay: this.#defaultCycleDay,
63
+ accounts: this.#accounts,
64
+ policies: this.#policies,
65
+ policyOverrides: this.#policyOverrides,
66
+ rates: this.#rates,
67
+ }, observations, counters, now);
128
68
  this.#dirty = true;
129
69
  await this.#maybePersist(now);
130
70
  }
131
71
  /** Current per-slot usage — the queryable snapshot the CLI and platform read. */
132
72
  snapshot() {
133
- const generatedAtMs = this.#now();
134
- const slots = [];
135
- for (const [slotId, account] of this.#accounts) {
136
- const policy = this.#policies.get(slotId);
137
- const thresholdBytes = policy?.thresholdBytes;
138
- slots.push({
139
- logicalSlotId: slotId,
140
- cycleBytes: account.cycleBytes,
141
- cycleStartMs: account.cycleStartMs,
142
- paused: account.paused,
143
- ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}),
144
- ...(thresholdBytes !== undefined ? { thresholdBytes } : {}),
145
- thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes,
146
- });
147
- }
148
- return { bootId: this.#bootId, generatedAtMs, slots };
73
+ return projectUsageSnapshot({
74
+ bootId: this.#bootId,
75
+ accounts: this.#accounts,
76
+ policies: this.#policies,
77
+ rates: this.#rates,
78
+ }, this.#now());
149
79
  }
150
80
  /**
151
81
  * Apply an operator's usage policy to this slot immediately, without waiting
@@ -161,22 +91,15 @@ export class UsageSampler {
161
91
  * moves no anchor and therefore resets nothing.
162
92
  */
163
93
  applyUsagePolicy(logicalSlotId, usage, atMs) {
164
- const now = atMs ?? this.#now();
165
- this.#policyOverrides.set(logicalSlotId, usage);
166
- this.#policies.set(logicalSlotId, usage);
167
- const cycleStartMs = cycleStart(epochMillis(now), usage.cycleDay ?? this.#defaultCycleDay);
168
- const account = this.#accounts.get(logicalSlotId);
169
- if (account === undefined) {
170
- this.#accounts.set(logicalSlotId, initialAccount(cycleStartMs));
171
- this.#dirty = true;
172
- return { cycleStartMs, cycleReset: false };
173
- }
174
- if (account.cycleStartMs === cycleStartMs) {
175
- return { cycleStartMs, cycleReset: false };
176
- }
177
- this.#accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs });
178
- this.#dirty = true;
179
- return { cycleStartMs, cycleReset: true };
94
+ const result = applyPolicy({
95
+ bootId: this.#bootId,
96
+ defaultCycleDay: this.#defaultCycleDay,
97
+ accounts: this.#accounts,
98
+ policies: this.#policies,
99
+ policyOverrides: this.#policyOverrides,
100
+ }, logicalSlotId, usage, atMs ?? this.#now());
101
+ this.#dirty ||= result.dirty;
102
+ return { cycleStartMs: result.cycleStartMs, cycleReset: result.cycleReset };
180
103
  }
181
104
  /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */
182
105
  async flush() {
@@ -190,16 +113,7 @@ export class UsageSampler {
190
113
  }
191
114
  }
192
115
  async #persist(now) {
193
- const slots = [];
194
- for (const [slotId, account] of this.#accounts) {
195
- slots.push(toPersistedSlot(slotId, account));
196
- }
197
- const state = {
198
- schemaVersion: USAGE_SCHEMA_VERSION,
199
- bootId: this.#bootId,
200
- savedAtMs: now,
201
- slots,
202
- };
116
+ const state = persistedUsageState(this.#bootId, now, this.#accounts);
203
117
  await this.#store.save(state);
204
118
  this.#lastPersistMs = now;
205
119
  this.#dirty = false;
@@ -0,0 +1,38 @@
1
+ import type { DesiredUsage } from '../../domain/index.js';
2
+ import { type SlotAccount } from './accounting.js';
3
+ import type { UsageObservation } from './sampler.js';
4
+ /**
5
+ * One slot's throughput measurement.
6
+ *
7
+ * `sampledAtMs` is when the counter behind the current baseline was read; without it
8
+ * there is no interval to divide a delta by. `bytesPerSecond` is ABSENT — never zero —
9
+ * whenever this pass had no measurable interval, and that is a frequent, real answer:
10
+ * the first sample, a rebaseline, a paused slot, an interface missing from the counter
11
+ * table, and a counter that went BACKWARDS each produce one.
12
+ *
13
+ * The backwards case is the one worth spelling out. `/proc/net/dev` counters are
14
+ * per-interface and cumulative, and they restart at zero when the interface is
15
+ * re-created — a modem replug, a `wwan0` teardown, a driver reload. Subtracting across
16
+ * that boundary yields a negative number, and both obvious repairs report something
17
+ * untrue: clamping to zero shows an idle link that was in fact carrying traffic, while
18
+ * taking the raw post-reset value shows every byte since the interface came up as if it
19
+ * had all moved inside one sampling interval. Reporting NOTHING is the honest answer,
20
+ * and `applySample` rebases the baseline in the same pass so the NEXT interval measures
21
+ * correctly rather than inheriting the gap.
22
+ *
23
+ * Idea provenance: `irlserver/modem-metrics` (MIT) — concepts adopted, no source code
24
+ * copied. See `docs/adr/ADR-STAY-TYPESCRIPT.md`.
25
+ */
26
+ export interface SlotRate {
27
+ readonly sampledAtMs: number;
28
+ readonly bytesPerSecond?: number;
29
+ }
30
+ export interface UsageSamplingState {
31
+ readonly bootId: string;
32
+ readonly defaultCycleDay: number;
33
+ readonly accounts: Map<string, SlotAccount>;
34
+ readonly policies: Map<string, DesiredUsage>;
35
+ readonly policyOverrides: ReadonlyMap<string, DesiredUsage>;
36
+ readonly rates: Map<string, SlotRate>;
37
+ }
38
+ export declare function applyUsageSamples(state: UsageSamplingState, observations: readonly UsageObservation[], counters: ReadonlyMap<string, number>, now: number): void;
@@ -0,0 +1,72 @@
1
+ import { epochMillis } from '../../domain/index.js';
2
+ import { applySample, initialAccount, sameBaselineKey, } from './accounting.js';
3
+ import { cycleStart } from './billing-cycle.js';
4
+ /**
5
+ * Measure one interval, or state that there was none. Pure — the caller supplies the
6
+ * prior account, the prior rate sample and `now`. Every early return is a case where a
7
+ * number could be produced but would not be a measurement; see `SlotRate`.
8
+ */
9
+ function measureRate(prior, priorRate, input, now) {
10
+ const unmeasured = { sampledAtMs: now };
11
+ if (input.confidence === 'low') {
12
+ return unmeasured;
13
+ }
14
+ if (prior === undefined ||
15
+ prior.paused ||
16
+ prior.key === undefined ||
17
+ prior.lastObserved === undefined) {
18
+ return unmeasured;
19
+ }
20
+ if (!sameBaselineKey(prior.key, input.key)) {
21
+ return unmeasured;
22
+ }
23
+ // No prior rate sample means no interval was ever measured under this baseline —
24
+ // it was restored from disk, or the interface was absent from the last pass. The
25
+ // elapsed wall time is then a gap, not a sampling interval.
26
+ if (priorRate === undefined) {
27
+ return unmeasured;
28
+ }
29
+ if (input.current < prior.lastObserved) {
30
+ return unmeasured;
31
+ }
32
+ const elapsedMs = now - priorRate.sampledAtMs;
33
+ if (elapsedMs <= 0) {
34
+ return unmeasured;
35
+ }
36
+ return {
37
+ sampledAtMs: now,
38
+ bytesPerSecond: ((input.current - prior.lastObserved) * 1000) / elapsedMs,
39
+ };
40
+ }
41
+ export function applyUsageSamples(state, observations, counters, now) {
42
+ for (const observation of observations) {
43
+ const slotId = observation.logicalSlotId;
44
+ const usage = state.policyOverrides.get(slotId) ?? observation.usage;
45
+ state.policies.set(slotId, usage);
46
+ const cycleDay = usage.cycleDay ?? state.defaultCycleDay;
47
+ const cycleStartMs = cycleStart(epochMillis(now), cycleDay);
48
+ const current = counters.get(observation.ifname);
49
+ if (current === undefined) {
50
+ if (!state.accounts.has(slotId)) {
51
+ state.accounts.set(slotId, initialAccount(cycleStartMs));
52
+ }
53
+ // The counter was not readable this pass. Dropping the rate sample costs
54
+ // the NEXT pass its rate too, which is the point: whatever moved while the
55
+ // interface was missing did not move inside one sampling interval, and
56
+ // dividing it by one would render an invented spike.
57
+ state.rates.delete(slotId);
58
+ continue;
59
+ }
60
+ const key = {
61
+ logicalSlotId: slotId,
62
+ mappingGeneration: observation.mappingGeneration,
63
+ ifname: observation.ifname,
64
+ bootId: state.bootId,
65
+ };
66
+ const prior = state.accounts.get(slotId);
67
+ const rateInput = { key, current, confidence: observation.confidence };
68
+ state.rates.set(slotId, measureRate(prior, state.rates.get(slotId), rateInput, now));
69
+ const next = applySample(prior, { ...rateInput, cycleStartMs });
70
+ state.accounts.set(slotId, next);
71
+ }
72
+ }
@@ -0,0 +1,5 @@
1
+ import type { InhibitLease, ModemManagerPort } from '../../ports/index.js';
2
+ import type { CertifiedCatalog } from '../../usb-mode/index.js';
3
+ import { type TransitionInterlock, type UsbModeTransitionRequest } from '../transition-preconditions.js';
4
+ export declare function checkTransitionAdmission(request: UsbModeTransitionRequest, catalog: CertifiedCatalog, interlock: TransitionInterlock): Promise<import("..").PreconditionResult>;
5
+ export declare function releaseInhibit(modemManager: Pick<ModemManagerPort, 'uninhibit'>, lease: InhibitLease | undefined, steps: string[]): Promise<undefined>;
@@ -0,0 +1,12 @@
1
+ import { checkTransitionPreconditions, } from '../transition-preconditions.js';
2
+ export function checkTransitionAdmission(request, catalog, interlock) {
3
+ return checkTransitionPreconditions(request, catalog, interlock);
4
+ }
5
+ export async function releaseInhibit(modemManager, lease, steps) {
6
+ if (lease === undefined) {
7
+ return undefined;
8
+ }
9
+ steps.push('force-uninhibit');
10
+ await modemManager.uninhibit(lease).catch(() => undefined);
11
+ return undefined;
12
+ }
@@ -0,0 +1,13 @@
1
+ import type { InhibitLease, ModemManagerPort } from '../../ports/index.js';
2
+ import { type AtAuditSink, AtCommandLease, type AtCommandSender } from '../at-lease.js';
3
+ export interface TransitionAtLeaseOptions {
4
+ readonly sender: AtCommandSender;
5
+ readonly allowlistedCommands: readonly string[];
6
+ readonly timeoutMs: number;
7
+ readonly modemManager: Pick<ModemManagerPort, 'uninhibit'>;
8
+ readonly currentInhibit: () => InhibitLease | undefined;
9
+ readonly clearInhibit: () => void;
10
+ readonly steps: string[];
11
+ readonly audit?: AtAuditSink;
12
+ }
13
+ export declare function createTransitionAtLease(options: TransitionAtLeaseOptions): AtCommandLease;
@@ -0,0 +1,15 @@
1
+ import { AtCommandLease, computeAtAllowlist, } from '../at-lease.js';
2
+ import { releaseInhibit } from './admission.js';
3
+ export function createTransitionAtLease(options) {
4
+ return new AtCommandLease({
5
+ sender: options.sender,
6
+ allowlist: computeAtAllowlist(options.allowlistedCommands),
7
+ timeoutMs: options.timeoutMs,
8
+ onWatchdog: async () => {
9
+ const held = options.currentInhibit();
10
+ options.clearInhibit();
11
+ await releaseInhibit(options.modemManager, held, options.steps);
12
+ },
13
+ ...(options.audit !== undefined ? { audit: options.audit } : {}),
14
+ });
15
+ }
@@ -0,0 +1,4 @@
1
+ import type { AtCommandLease } from '../at-lease.js';
2
+ import { type UsbDeviceSnapshot } from '../device-classifier.js';
3
+ import type { UsbModeTransitionPlan } from '../transition-preconditions.js';
4
+ export declare function transitionPostconditionFailure(plan: UsbModeTransitionPlan, device: UsbDeviceSnapshot, lease: AtCommandLease, inhibitUid: string, steps: string[]): Promise<string | undefined>;