@ceralive/modem-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/backend/at-lease.test.ts +106 -0
- package/src/backend/at-lease.ts +158 -0
- package/src/backend/cell-info.test.ts +154 -0
- package/src/backend/cell-info.ts +160 -0
- package/src/backend/constants.ts +35 -0
- package/src/backend/device-classifier.test.ts +168 -0
- package/src/backend/device-classifier.ts +240 -0
- package/src/backend/enrichment.ts +96 -0
- package/src/backend/features.test.ts +158 -0
- package/src/backend/features.ts +179 -0
- package/src/backend/identity-ladder.test.ts +117 -0
- package/src/backend/identity-ladder.ts +221 -0
- package/src/backend/identity-registry.test.ts +89 -0
- package/src/backend/identity-registry.ts +151 -0
- package/src/backend/index.ts +221 -0
- package/src/backend/lifecycle-interlock.ts +38 -0
- package/src/backend/managed-objects.ts +108 -0
- package/src/backend/mapping.ts +160 -0
- package/src/backend/mm-backend.ts +191 -0
- package/src/backend/mm-mutations.ts +228 -0
- package/src/backend/modem-actor.test.ts +95 -0
- package/src/backend/modem-actor.ts +112 -0
- package/src/backend/nm-auto-apn.ts +161 -0
- package/src/backend/nm-gsm-fields.ts +122 -0
- package/src/backend/nmcli-nm-port.ts +228 -0
- package/src/backend/nmcli-runner.ts +52 -0
- package/src/backend/observer.ts +297 -0
- package/src/backend/power-contract.test.ts +40 -0
- package/src/backend/power-contract.ts +83 -0
- package/src/backend/recovery-attribution.test.ts +102 -0
- package/src/backend/recovery-attribution.ts +86 -0
- package/src/backend/recovery-budget.test.ts +64 -0
- package/src/backend/recovery-budget.ts +84 -0
- package/src/backend/recovery-ladder.test.ts +257 -0
- package/src/backend/recovery-ladder.ts +249 -0
- package/src/backend/router-ethernet.test.ts +71 -0
- package/src/backend/router-ethernet.ts +90 -0
- package/src/backend/row-store.ts +105 -0
- package/src/backend/signal-setup.ts +112 -0
- package/src/backend/sim-unlock.ts +193 -0
- package/src/backend/transition-preconditions.ts +149 -0
- package/src/backend/usage/accounting.test.ts +147 -0
- package/src/backend/usage/accounting.ts +123 -0
- package/src/backend/usage/billing-cycle.test.ts +62 -0
- package/src/backend/usage/billing-cycle.ts +45 -0
- package/src/backend/usage/boot-id.ts +18 -0
- package/src/backend/usage/index.ts +37 -0
- package/src/backend/usage/proc-net-dev.test.ts +56 -0
- package/src/backend/usage/proc-net-dev.ts +77 -0
- package/src/backend/usage/sampler.test.ts +219 -0
- package/src/backend/usage/sampler.ts +228 -0
- package/src/backend/usage/store.test.ts +148 -0
- package/src/backend/usage/store.ts +177 -0
- package/src/backend/usb-enumerator.test.ts +87 -0
- package/src/backend/usb-enumerator.ts +181 -0
- package/src/backend/usb-mode-transition.test.ts +323 -0
- package/src/backend/usb-mode-transition.ts +253 -0
- package/src/domain/brand.ts +29 -0
- package/src/domain/errors.ts +77 -0
- package/src/domain/guards.test.ts +218 -0
- package/src/domain/guards.ts +144 -0
- package/src/domain/identity.test.ts +83 -0
- package/src/domain/identity.ts +165 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/policy.ts +137 -0
- package/src/domain/snapshot.test.ts +266 -0
- package/src/domain/snapshot.ts +120 -0
- package/src/domain/state.ts +189 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +15 -0
- package/src/ports/README.md +61 -0
- package/src/ports/forbidden-surface.test.ts +80 -0
- package/src/ports/index.ts +16 -0
- package/src/ports/modem-manager.ts +72 -0
- package/src/ports/network-manager.ts +87 -0
- package/src/ports/observation.ts +48 -0
- package/src/ports/ops.ts +60 -0
- package/src/ports/ops.type-test.ts +39 -0
- package/src/ports/receipts.test.ts +153 -0
- package/src/ports/receipts.ts +43 -0
- package/src/ports/reconcile.test.ts +152 -0
- package/src/ports/reconcile.ts +338 -0
- package/src/ports/router.ts +29 -0
- package/src/redact.test.ts +82 -0
- package/src/redact.ts +73 -0
- package/src/transport/README.md +65 -0
- package/src/transport/codec.test.ts +118 -0
- package/src/transport/codec.ts +240 -0
- package/src/transport/conformance-python.test.ts +152 -0
- package/src/transport/conformance-same-lib.test.ts +115 -0
- package/src/transport/dbus-native-lib.d.ts +19 -0
- package/src/transport/dbus-native.ts +85 -0
- package/src/transport/errors.ts +74 -0
- package/src/transport/index.ts +30 -0
- package/src/transport/no-library-leak.test.ts +50 -0
- package/src/transport/reliability.test.ts +173 -0
- package/src/transport/signature.ts +110 -0
- package/src/transport/test-support/fake-service.ts +143 -0
- package/src/transport/test-support/independent-producer.py +110 -0
- package/src/transport/test-support/private-bus.ts +66 -0
- package/src/transport/transport.ts +439 -0
- package/src/transport/types.ts +118 -0
- package/src/usb-mode/catalog-schema.test.ts +181 -0
- package/src/usb-mode/catalog-schema.ts +113 -0
- package/src/usb-mode/catalog.ts +60 -0
- package/src/usb-mode/certified-catalog.json +67 -0
- package/src/usb-mode/index.ts +27 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ceralive/modem-control",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.",
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/CERALIVE/modem-stack.git",
|
|
10
|
+
"directory": "control"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"registry": "https://registry.npmjs.org/",
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./transport": "./src/transport/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@httptoolkit/dbus-native": "0.1.5",
|
|
29
|
+
"zod": "4.4.3"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The AT lease's three guarantees: the allowlist rejects anything but ATI + catalog
|
|
2
|
+
// commands (and never touches the sender when it does); the watchdog fires + rejects
|
|
3
|
+
// a hung command; every attempt is audited through `redact`, so a secret in the
|
|
4
|
+
// context is stripped before it is stored.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import {
|
|
8
|
+
AT_BASELINE_ALLOWLIST,
|
|
9
|
+
AtCommandLease,
|
|
10
|
+
AtCommandNotAllowedError,
|
|
11
|
+
type AtCommandSender,
|
|
12
|
+
AtCommandTimeoutError,
|
|
13
|
+
type AtResponse,
|
|
14
|
+
computeAtAllowlist,
|
|
15
|
+
} from './at-lease';
|
|
16
|
+
|
|
17
|
+
const CATALOG_COMMAND = 'AT+QCFG="usbnet",2';
|
|
18
|
+
|
|
19
|
+
function recordingSender(): { sender: AtCommandSender; sent: string[] } {
|
|
20
|
+
const sent: string[] = [];
|
|
21
|
+
return {
|
|
22
|
+
sent,
|
|
23
|
+
sender: {
|
|
24
|
+
send(command: string): Promise<AtResponse> {
|
|
25
|
+
sent.push(command);
|
|
26
|
+
return Promise.resolve({ ok: true, raw: 'OK' });
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('AtCommandLease — allowlist', () => {
|
|
33
|
+
test('the baseline allowlist is exactly {ATI}; catalog commands union in', () => {
|
|
34
|
+
expect([...AT_BASELINE_ALLOWLIST]).toEqual(['ATI']);
|
|
35
|
+
const allowlist = computeAtAllowlist([CATALOG_COMMAND]);
|
|
36
|
+
expect(allowlist.has('ATI')).toBe(true);
|
|
37
|
+
expect(allowlist.has(CATALOG_COMMAND)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('ATI and the catalog command are allowed', async () => {
|
|
41
|
+
const { sender, sent } = recordingSender();
|
|
42
|
+
const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([CATALOG_COMMAND]) });
|
|
43
|
+
await lease.run('ATI');
|
|
44
|
+
await lease.run(CATALOG_COMMAND);
|
|
45
|
+
expect(sent).toEqual(['ATI', CATALOG_COMMAND]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a command outside the allowlist is rejected WITHOUT touching the sender', async () => {
|
|
49
|
+
const { sender, sent } = recordingSender();
|
|
50
|
+
const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([CATALOG_COMMAND]) });
|
|
51
|
+
await expect(lease.run('AT+DANGEROUS')).rejects.toBeInstanceOf(AtCommandNotAllowedError);
|
|
52
|
+
expect(sent).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('AtCommandLease — watchdog', () => {
|
|
57
|
+
test('a hung command fires the watchdog and rejects with a timeout', async () => {
|
|
58
|
+
const hangingSender: AtCommandSender = { send: () => new Promise<AtResponse>(() => undefined) };
|
|
59
|
+
const watchdogHits: string[] = [];
|
|
60
|
+
const lease = new AtCommandLease({
|
|
61
|
+
sender: hangingSender,
|
|
62
|
+
allowlist: computeAtAllowlist([]),
|
|
63
|
+
timeoutMs: 20,
|
|
64
|
+
onWatchdog: (command) => {
|
|
65
|
+
watchdogHits.push(command);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await expect(lease.run('ATI')).rejects.toBeInstanceOf(AtCommandTimeoutError);
|
|
69
|
+
expect(watchdogHits).toEqual(['ATI']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('AtCommandLease — audit + redaction', () => {
|
|
74
|
+
test('a sensitive value in the audit context is redacted before recording', async () => {
|
|
75
|
+
const { sender } = recordingSender();
|
|
76
|
+
const entries: unknown[] = [];
|
|
77
|
+
const lease = new AtCommandLease({
|
|
78
|
+
sender,
|
|
79
|
+
allowlist: computeAtAllowlist([]),
|
|
80
|
+
audit: { record: (entry) => entries.push(entry) },
|
|
81
|
+
});
|
|
82
|
+
await lease.run('ATI', { subscriptionId: 'SECRET-ICCID', note: 'keep-me' });
|
|
83
|
+
expect(entries).toHaveLength(1);
|
|
84
|
+
const recorded = entries[0] as {
|
|
85
|
+
command: string;
|
|
86
|
+
outcome: string;
|
|
87
|
+
context: Record<string, unknown>;
|
|
88
|
+
};
|
|
89
|
+
expect(recorded.command).toBe('ATI');
|
|
90
|
+
expect(recorded.outcome).toBe('sent');
|
|
91
|
+
expect(recorded.context.subscriptionId).toBe('[redacted]');
|
|
92
|
+
expect(recorded.context.note).toBe('keep-me');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a rejected command is audited with outcome "rejected"', async () => {
|
|
96
|
+
const { sender } = recordingSender();
|
|
97
|
+
const entries: Array<{ outcome: string }> = [];
|
|
98
|
+
const lease = new AtCommandLease({
|
|
99
|
+
sender,
|
|
100
|
+
allowlist: computeAtAllowlist([]),
|
|
101
|
+
audit: { record: (entry) => entries.push(entry as { outcome: string }) },
|
|
102
|
+
});
|
|
103
|
+
await expect(lease.run('AT+NOPE')).rejects.toBeInstanceOf(AtCommandNotAllowedError);
|
|
104
|
+
expect(entries[0]?.outcome).toBe('rejected');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The AT-command lease baseline — the ONLY channel raw AT commands may travel.
|
|
2
|
+
//
|
|
3
|
+
// Three non-negotiable safety properties (draft §rounds 5/6, §84 raw-AT lease):
|
|
4
|
+
// 1. ALLOWLIST — only `ATI` (identify) plus the exact commands a certified catalog
|
|
5
|
+
// entry declares may ever be sent. Anything else is rejected BEFORE the sender
|
|
6
|
+
// is touched. There is no escape hatch.
|
|
7
|
+
// 2. WATCHDOG — a command that does not return within the timeout fires the
|
|
8
|
+
// `onWatchdog` hook (the transition wires this to force-uninhibit) and rejects,
|
|
9
|
+
// so a hung AT write can never wedge the transaction forever.
|
|
10
|
+
// 3. AUDIT + REDACTION — every attempt is recorded through A2.2's `redact` (never a
|
|
11
|
+
// reimplementation), so an identifier that lands in an audit entry's context is
|
|
12
|
+
// stripped before it is stored.
|
|
13
|
+
|
|
14
|
+
import { type EpochMillis, epochMillis } from '../domain';
|
|
15
|
+
import { redact } from '../redact';
|
|
16
|
+
|
|
17
|
+
/** The baseline allowlist — identify only. Catalog commands are unioned in per SKU. */
|
|
18
|
+
export const AT_BASELINE_ALLOWLIST: ReadonlySet<string> = new Set(['ATI']);
|
|
19
|
+
|
|
20
|
+
/** Union the baseline allowlist with a catalog entry's declared transition commands. */
|
|
21
|
+
export function computeAtAllowlist(commands: Iterable<string>): ReadonlySet<string> {
|
|
22
|
+
return new Set<string>([...AT_BASELINE_ALLOWLIST, ...commands]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An AT command's response. `ok` (an `OK` terminator) is NEVER transition-success alone. */
|
|
26
|
+
export interface AtResponse {
|
|
27
|
+
readonly ok: boolean;
|
|
28
|
+
readonly raw: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The raw AT transport — a serial write, injected so tests need no hardware. */
|
|
32
|
+
export interface AtCommandSender {
|
|
33
|
+
send(command: string): Promise<AtResponse>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One audited AT attempt. Recorded only after passing through `redact`. */
|
|
37
|
+
export interface AtAuditEntry {
|
|
38
|
+
readonly command: string;
|
|
39
|
+
readonly outcome: 'sent' | 'rejected' | 'timeout' | 'error';
|
|
40
|
+
readonly at: EpochMillis;
|
|
41
|
+
readonly ok?: boolean;
|
|
42
|
+
readonly reason?: string;
|
|
43
|
+
readonly context?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where audit entries go — receives a REDACTED copy of each `AtAuditEntry`. */
|
|
47
|
+
export interface AtAuditSink {
|
|
48
|
+
record(entry: unknown): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Thrown when a command outside the allowlist is attempted — the sender is never called. */
|
|
52
|
+
export class AtCommandNotAllowedError extends Error {
|
|
53
|
+
constructor(command: string) {
|
|
54
|
+
super(`AT command not in allowlist: ${command}`);
|
|
55
|
+
this.name = 'AtCommandNotAllowedError';
|
|
56
|
+
Object.setPrototypeOf(this, AtCommandNotAllowedError.prototype);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Thrown when a command exceeds the watchdog timeout. */
|
|
61
|
+
export class AtCommandTimeoutError extends Error {
|
|
62
|
+
constructor(command: string, timeoutMs: number) {
|
|
63
|
+
super(`AT command timed out after ${timeoutMs}ms: ${command}`);
|
|
64
|
+
this.name = 'AtCommandTimeoutError';
|
|
65
|
+
Object.setPrototypeOf(this, AtCommandTimeoutError.prototype);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Construction dependencies for an `AtCommandLease`. */
|
|
70
|
+
export interface AtCommandLeaseDeps {
|
|
71
|
+
readonly sender: AtCommandSender;
|
|
72
|
+
readonly allowlist: ReadonlySet<string>;
|
|
73
|
+
readonly audit?: AtAuditSink;
|
|
74
|
+
readonly now?: () => EpochMillis;
|
|
75
|
+
readonly timeoutMs?: number;
|
|
76
|
+
/** Fired when a command exceeds the timeout — the transition wires force-uninhibit here. */
|
|
77
|
+
readonly onWatchdog?: (command: string) => void | Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_AT_TIMEOUT_MS = 10_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A held AT-command lease. `run` enforces the allowlist, bounds the send with a
|
|
84
|
+
* watchdog, and audits every attempt (redacted). The allowlist is fixed at
|
|
85
|
+
* construction from `computeAtAllowlist(entry)`, so a lease can only ever emit the
|
|
86
|
+
* commands one certified SKU permits.
|
|
87
|
+
*/
|
|
88
|
+
export class AtCommandLease {
|
|
89
|
+
readonly #sender: AtCommandSender;
|
|
90
|
+
readonly #allowlist: ReadonlySet<string>;
|
|
91
|
+
readonly #audit: AtAuditSink | undefined;
|
|
92
|
+
readonly #now: () => EpochMillis;
|
|
93
|
+
readonly #timeoutMs: number;
|
|
94
|
+
readonly #onWatchdog: ((command: string) => void | Promise<void>) | undefined;
|
|
95
|
+
|
|
96
|
+
constructor(deps: AtCommandLeaseDeps) {
|
|
97
|
+
this.#sender = deps.sender;
|
|
98
|
+
this.#allowlist = deps.allowlist;
|
|
99
|
+
this.#audit = deps.audit;
|
|
100
|
+
this.#now = deps.now ?? ((): EpochMillis => epochMillis(Date.now()));
|
|
101
|
+
this.#timeoutMs = deps.timeoutMs ?? DEFAULT_AT_TIMEOUT_MS;
|
|
102
|
+
this.#onWatchdog = deps.onWatchdog;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Send one AT command through the lease. `context` is redacted into the audit entry. */
|
|
106
|
+
async run(command: string, context?: Record<string, unknown>): Promise<AtResponse> {
|
|
107
|
+
const ctx = context !== undefined ? { context } : {};
|
|
108
|
+
if (!this.#allowlist.has(command)) {
|
|
109
|
+
this.#record({
|
|
110
|
+
command,
|
|
111
|
+
outcome: 'rejected',
|
|
112
|
+
at: this.#now(),
|
|
113
|
+
reason: 'not in allowlist',
|
|
114
|
+
...ctx,
|
|
115
|
+
});
|
|
116
|
+
throw new AtCommandNotAllowedError(command);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const response = await this.#sendWithWatchdog(command);
|
|
120
|
+
this.#record({ command, outcome: 'sent', at: this.#now(), ok: response.ok, ...ctx });
|
|
121
|
+
return response;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const timedOut = error instanceof AtCommandTimeoutError;
|
|
124
|
+
if (timedOut) {
|
|
125
|
+
await this.#onWatchdog?.(command);
|
|
126
|
+
}
|
|
127
|
+
this.#record({
|
|
128
|
+
command,
|
|
129
|
+
outcome: timedOut ? 'timeout' : 'error',
|
|
130
|
+
at: this.#now(),
|
|
131
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
132
|
+
...ctx,
|
|
133
|
+
});
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async #sendWithWatchdog(command: string): Promise<AtResponse> {
|
|
139
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
140
|
+
const watchdog = new Promise<never>((_, reject) => {
|
|
141
|
+
timer = setTimeout(
|
|
142
|
+
() => reject(new AtCommandTimeoutError(command, this.#timeoutMs)),
|
|
143
|
+
this.#timeoutMs,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([this.#sender.send(command), watchdog]);
|
|
148
|
+
} finally {
|
|
149
|
+
if (timer !== undefined) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#record(entry: AtAuditEntry): void {
|
|
156
|
+
this.#audit?.record(redact(entry));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Cell-info normalization — pure fixtures, no bus.
|
|
2
|
+
//
|
|
3
|
+
// Locks the review-corrected mappings (draft round 10/11): the real NR key is `sinr`
|
|
4
|
+
// (a `snr`-carrying dict is IGNORED), `physical-ci` → `pci`, `rsrp`/`rsrq` pass
|
|
5
|
+
// through, `band` only when directly supplied, provenance is always carried, and the
|
|
6
|
+
// serving-cell TOTAL order is permutation-invariant.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { epochMillis } from '../domain';
|
|
10
|
+
import type { DbusVariant } from '../transport';
|
|
11
|
+
import { type CellReading, normalizeCellReading, selectServingCell } from './cell-info';
|
|
12
|
+
import type { DecodedProps } from './managed-objects';
|
|
13
|
+
|
|
14
|
+
type Scalar = number | string | boolean;
|
|
15
|
+
|
|
16
|
+
function v(value: Scalar): DbusVariant {
|
|
17
|
+
const signature = typeof value === 'number' ? 'i' : typeof value === 'boolean' ? 'b' : 's';
|
|
18
|
+
return { signature, value };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cell(record: Record<string, Scalar>): DecodedProps {
|
|
22
|
+
return Object.entries(record).map(([key, value]) => [key, v(value)] as const);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PROVENANCE = {
|
|
26
|
+
source: '/org/freedesktop/ModemManager1/Modem/0',
|
|
27
|
+
observedAt: epochMillis(1000),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const read = (record: Record<string, Scalar>): CellReading =>
|
|
31
|
+
normalizeCellReading(cell(record), PROVENANCE);
|
|
32
|
+
|
|
33
|
+
/** Every fixed permutation of a small array — exhaustive, deterministic shuffle. */
|
|
34
|
+
function permutations<T>(items: readonly T[]): T[][] {
|
|
35
|
+
if (items.length <= 1) {
|
|
36
|
+
return [[...items]];
|
|
37
|
+
}
|
|
38
|
+
const result: T[][] = [];
|
|
39
|
+
items.forEach((item, index) => {
|
|
40
|
+
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
|
|
41
|
+
for (const tail of permutations(rest)) {
|
|
42
|
+
result.push([item, ...tail]);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('normalizeCellReading — pinned key mappings', () => {
|
|
49
|
+
test('the real NR key is `sinr`', () => {
|
|
50
|
+
expect(read({ sinr: 12 }).sinr).toBe(12);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a `snr`-carrying dict is IGNORED — sinr stays undefined', () => {
|
|
54
|
+
const reading = read({ snr: 9, rsrp: -95 });
|
|
55
|
+
expect(reading.sinr).toBeUndefined();
|
|
56
|
+
expect(reading.rsrp).toBe(-95);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('`sinr` wins even when `snr` is also present (wrong key never leaks in)', () => {
|
|
60
|
+
expect(read({ snr: 9, sinr: 12 }).sinr).toBe(12);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('`physical-ci` maps to `pci`; rsrp/rsrq pass through', () => {
|
|
64
|
+
const reading = read({ 'physical-ci': 42, rsrp: -95, rsrq: -10 });
|
|
65
|
+
expect(reading.pci).toBe(42);
|
|
66
|
+
expect(reading.rsrp).toBe(-95);
|
|
67
|
+
expect(reading.rsrq).toBe(-10);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('`band` is surfaced ONLY when directly supplied', () => {
|
|
71
|
+
expect(read({ rsrp: -90 }).band).toBeUndefined();
|
|
72
|
+
expect(read({ rsrp: -90, band: 'n78' }).band).toBe('n78');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every reading carries `source` + `observedAt` provenance', () => {
|
|
76
|
+
const reading = read({ 'cell-id': 'A', rsrp: -80 });
|
|
77
|
+
expect(reading.source).toBe(PROVENANCE.source);
|
|
78
|
+
expect(reading.observedAt).toBe(PROVENANCE.observedAt);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('serving flag reads a `serving` bool OR a serving `cell-type`', () => {
|
|
82
|
+
expect(read({ serving: true }).serving).toBe(true);
|
|
83
|
+
expect(read({ 'cell-type': 'lte-serving' }).serving).toBe(true);
|
|
84
|
+
expect(read({ 'cell-type': 'lte-neighbor' }).serving).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('selectServingCell — TOTAL order', () => {
|
|
89
|
+
test('a serving-marked cell wins even with a LOWER rsrp', () => {
|
|
90
|
+
const marked = read({ 'cell-id': 'A', serving: true, rsrp: -110 });
|
|
91
|
+
const strong = read({ 'cell-id': 'B', rsrp: -70 });
|
|
92
|
+
expect(selectServingCell([strong, marked])?.cellId).toBe('A');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('with no serving mark, the HIGHEST rsrp wins', () => {
|
|
96
|
+
const cells = [
|
|
97
|
+
read({ 'cell-id': 'A', rsrp: -95 }),
|
|
98
|
+
read({ 'cell-id': 'B', rsrp: -70 }),
|
|
99
|
+
read({ 'cell-id': 'C', rsrp: -110 }),
|
|
100
|
+
];
|
|
101
|
+
expect(selectServingCell(cells)?.cellId).toBe('B');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('cells lacking rsrp sort LAST', () => {
|
|
105
|
+
const withRsrp = read({ 'cell-id': 'A', rsrp: -120 });
|
|
106
|
+
const noRsrp = read({ 'cell-id': 'B' });
|
|
107
|
+
expect(selectServingCell([noRsrp, withRsrp])?.cellId).toBe('A');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('an rsrp tie breaks lexicographically by cell-id', () => {
|
|
111
|
+
const cells = [
|
|
112
|
+
read({ 'cell-id': 'zeta', rsrp: -80 }),
|
|
113
|
+
read({ 'cell-id': 'alpha', rsrp: -80 }),
|
|
114
|
+
read({ 'cell-id': 'mike', rsrp: -80 }),
|
|
115
|
+
];
|
|
116
|
+
expect(selectServingCell(cells)?.cellId).toBe('alpha');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('empty input has no serving cell', () => {
|
|
120
|
+
expect(selectServingCell([])).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('selectServingCell — permutation invariance (MANDATORY)', () => {
|
|
125
|
+
test('every permutation of a mixed cell set picks the SAME serving cell', () => {
|
|
126
|
+
const cells = [
|
|
127
|
+
read({ 'cell-id': 'A', rsrp: -95, 'physical-ci': 1 }),
|
|
128
|
+
read({ 'cell-id': 'B', serving: true, rsrp: -108, 'physical-ci': 2 }),
|
|
129
|
+
read({ 'cell-id': 'C', rsrp: -70, 'physical-ci': 3 }),
|
|
130
|
+
read({ 'cell-id': 'D', 'physical-ci': 4 }),
|
|
131
|
+
read({ 'cell-id': 'E', rsrp: -88, 'physical-ci': 5 }),
|
|
132
|
+
];
|
|
133
|
+
const perms = permutations(cells);
|
|
134
|
+
expect(perms.length).toBe(120);
|
|
135
|
+
// The serving-marked cell B wins regardless of order.
|
|
136
|
+
for (const perm of perms) {
|
|
137
|
+
expect(selectServingCell(perm)?.cellId).toBe('B');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('without a serving mark, the highest-rsrp winner is permutation-invariant', () => {
|
|
142
|
+
const cells = [
|
|
143
|
+
read({ 'cell-id': 'A', rsrp: -95 }),
|
|
144
|
+
read({ 'cell-id': 'B', rsrp: -70 }),
|
|
145
|
+
read({ 'cell-id': 'C', rsrp: -110 }),
|
|
146
|
+
read({ 'cell-id': 'D', rsrp: -70 }),
|
|
147
|
+
read({ 'cell-id': 'E' }),
|
|
148
|
+
];
|
|
149
|
+
// Tie at -70 between B and D → lexicographic → B.
|
|
150
|
+
for (const perm of permutations(cells)) {
|
|
151
|
+
expect(selectServingCell(perm)?.cellId).toBe('B');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Cell-info normalization — a decoded ModemManager cell dict → a stable reading.
|
|
2
|
+
//
|
|
3
|
+
// `Modem.GetCellInfo` returns `aa{sv}`: one `a{sv}` dict per visible cell. The keys
|
|
4
|
+
// vary by RAT and MM version, so this module pins EXACTLY the mappings the rest of
|
|
5
|
+
// the stack depends on and ignores everything else:
|
|
6
|
+
//
|
|
7
|
+
// - `physical-ci` → `pci` (the real MM key; never guessed from anything else)
|
|
8
|
+
// - `rsrp`, `rsrq` pass through as numbers
|
|
9
|
+
// - `sinr` the REAL NR SINR key. A dict carrying `snr` (the
|
|
10
|
+
// WRONG name) is IGNORED — `sinr` stays undefined.
|
|
11
|
+
// - `cell-id` the cell identifier (serving-cell tiebreak)
|
|
12
|
+
// - `band` surfaced ONLY when the source supplies it directly;
|
|
13
|
+
// never inferred from earfcn / frequency / anything.
|
|
14
|
+
// - `serving` / `cell-type` whether this is the serving cell.
|
|
15
|
+
//
|
|
16
|
+
// Every reading also carries `source` + `observedAt` provenance, so a consumer can
|
|
17
|
+
// tell a fresh reading from a cached one and know where it came from. Pure — no I/O.
|
|
18
|
+
|
|
19
|
+
import type { EpochMillis } from '../domain';
|
|
20
|
+
import type { DecodedProps } from './managed-objects';
|
|
21
|
+
import { numberProp, stringProp } from './managed-objects';
|
|
22
|
+
|
|
23
|
+
/** Where a batch of cell readings came from, and when it was observed. */
|
|
24
|
+
export interface CellInfoProvenance {
|
|
25
|
+
/** A human/source tag, e.g. the modem path or `'Modem.GetCellInfo'`. */
|
|
26
|
+
readonly source: string;
|
|
27
|
+
readonly observedAt: EpochMillis;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One normalized cell reading — only the pinned fields, plus provenance. */
|
|
31
|
+
export interface CellReading {
|
|
32
|
+
/** `true` when this dict is marked as the serving cell. */
|
|
33
|
+
readonly serving: boolean;
|
|
34
|
+
/** The cell identifier, when supplied (serving-cell tiebreak key). */
|
|
35
|
+
readonly cellId?: string;
|
|
36
|
+
/** Physical cell id — from the `physical-ci` key ONLY. */
|
|
37
|
+
readonly pci?: number;
|
|
38
|
+
readonly rsrp?: number;
|
|
39
|
+
readonly rsrq?: number;
|
|
40
|
+
/** NR SINR — from the `sinr` key ONLY (a `snr`-keyed dict is ignored). */
|
|
41
|
+
readonly sinr?: number;
|
|
42
|
+
/** Radio band — present ONLY when the source supplied it directly. */
|
|
43
|
+
readonly band?: string;
|
|
44
|
+
readonly source: string;
|
|
45
|
+
readonly observedAt: EpochMillis;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read a `serving` flag: an explicit `serving` bool, else a `cell-type` naming it. */
|
|
49
|
+
function readServing(cell: DecodedProps): boolean {
|
|
50
|
+
const flag = cell.find(([key]) => key === 'serving')?.[1]?.value;
|
|
51
|
+
if (typeof flag === 'boolean') {
|
|
52
|
+
return flag;
|
|
53
|
+
}
|
|
54
|
+
const cellType = stringProp(cell, 'cell-type');
|
|
55
|
+
return cellType?.toLowerCase().includes('serving') ?? false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Normalize ONE cell's `a{sv}` dict into a `CellReading`, pinning the known keys. */
|
|
59
|
+
export function normalizeCellReading(
|
|
60
|
+
cell: DecodedProps,
|
|
61
|
+
provenance: CellInfoProvenance,
|
|
62
|
+
): CellReading {
|
|
63
|
+
const cellId = stringProp(cell, 'cell-id');
|
|
64
|
+
const pci = numberProp(cell, 'physical-ci');
|
|
65
|
+
const rsrp = numberProp(cell, 'rsrp');
|
|
66
|
+
const rsrq = numberProp(cell, 'rsrq');
|
|
67
|
+
// `sinr` ONLY — a dict carrying `snr` must never populate this field.
|
|
68
|
+
const sinr = numberProp(cell, 'sinr');
|
|
69
|
+
// `band` ONLY when directly supplied; never inferred.
|
|
70
|
+
const band = stringProp(cell, 'band');
|
|
71
|
+
return {
|
|
72
|
+
serving: readServing(cell),
|
|
73
|
+
...(cellId !== undefined ? { cellId } : {}),
|
|
74
|
+
...(pci !== undefined ? { pci } : {}),
|
|
75
|
+
...(rsrp !== undefined ? { rsrp } : {}),
|
|
76
|
+
...(rsrq !== undefined ? { rsrq } : {}),
|
|
77
|
+
...(sinr !== undefined ? { sinr } : {}),
|
|
78
|
+
...(band !== undefined ? { band } : {}),
|
|
79
|
+
source: provenance.source,
|
|
80
|
+
observedAt: provenance.observedAt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Normalize a whole `GetCellInfo` reply (`aa{sv}` → cell dicts) into readings. */
|
|
85
|
+
export function normalizeCellInfo(
|
|
86
|
+
cells: readonly DecodedProps[],
|
|
87
|
+
provenance: CellInfoProvenance,
|
|
88
|
+
): readonly CellReading[] {
|
|
89
|
+
return cells.map((cell) => normalizeCellReading(cell, provenance));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serving-cell TOTAL order — a strict, permutation-invariant ranking of readings.
|
|
94
|
+
* Returns < 0 when `a` outranks `b` (should sort first / is the better serving cell):
|
|
95
|
+
*
|
|
96
|
+
* 1. a `serving`-marked cell outranks a non-serving one;
|
|
97
|
+
* 2. then the HIGHER `rsrp` outranks the lower — a cell with NO `rsrp` sorts LAST;
|
|
98
|
+
* 3. ties break lexicographically by `cell-id` (a cell with none sorts last);
|
|
99
|
+
* 4. final deterministic tiebreaks (`pci`, then a stable serialization) guarantee
|
|
100
|
+
* the order is total even for otherwise-identical distinct cells, so the winner
|
|
101
|
+
* never depends on input order.
|
|
102
|
+
*/
|
|
103
|
+
export function compareServing(a: CellReading, b: CellReading): number {
|
|
104
|
+
if (a.serving !== b.serving) {
|
|
105
|
+
return a.serving ? -1 : 1;
|
|
106
|
+
}
|
|
107
|
+
const rsrpRank = compareOptionalDesc(a.rsrp, b.rsrp);
|
|
108
|
+
if (rsrpRank !== 0) {
|
|
109
|
+
return rsrpRank;
|
|
110
|
+
}
|
|
111
|
+
const cellRank = compareOptionalAsc(a.cellId, b.cellId);
|
|
112
|
+
if (cellRank !== 0) {
|
|
113
|
+
return cellRank;
|
|
114
|
+
}
|
|
115
|
+
const pciRank = compareOptionalAsc(a.pci, b.pci);
|
|
116
|
+
if (pciRank !== 0) {
|
|
117
|
+
return pciRank;
|
|
118
|
+
}
|
|
119
|
+
return stableTag(a) < stableTag(b) ? -1 : stableTag(a) > stableTag(b) ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Higher value first; `undefined` last. */
|
|
123
|
+
function compareOptionalDesc(a: number | undefined, b: number | undefined): number {
|
|
124
|
+
if (a === b) return 0;
|
|
125
|
+
if (a === undefined) return 1;
|
|
126
|
+
if (b === undefined) return -1;
|
|
127
|
+
return b - a;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Lower value first; `undefined` last. Works for numbers and strings. */
|
|
131
|
+
function compareOptionalAsc<T extends number | string>(a: T | undefined, b: T | undefined): number {
|
|
132
|
+
if (a === b) return 0;
|
|
133
|
+
if (a === undefined) return 1;
|
|
134
|
+
if (b === undefined) return -1;
|
|
135
|
+
return a < b ? -1 : 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A stable, order-independent fingerprint used only as the final total-order tiebreak. */
|
|
139
|
+
function stableTag(reading: CellReading): string {
|
|
140
|
+
return JSON.stringify([
|
|
141
|
+
reading.serving,
|
|
142
|
+
reading.cellId ?? null,
|
|
143
|
+
reading.pci ?? null,
|
|
144
|
+
reading.rsrp ?? null,
|
|
145
|
+
reading.rsrq ?? null,
|
|
146
|
+
reading.sinr ?? null,
|
|
147
|
+
reading.band ?? null,
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Select the serving cell under the total order above. Permutation-invariant: the
|
|
153
|
+
* same set of readings always yields the same winner regardless of their order.
|
|
154
|
+
*/
|
|
155
|
+
export function selectServingCell(cells: readonly CellReading[]): CellReading | undefined {
|
|
156
|
+
if (cells.length === 0) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return cells.reduce((best, cell) => (compareServing(cell, best) < 0 ? cell : best));
|
|
160
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Well-known D-Bus names the ModemManager observer talks to.
|
|
2
|
+
//
|
|
3
|
+
// These mirror the real ModemManager bus topology. They are duplicated here (rather
|
|
4
|
+
// than imported from the A2.3 test fake) on purpose: `control/test-support/` is not
|
|
5
|
+
// published in the npm package, so `src` must never depend on it — the observer that
|
|
6
|
+
// SHIPS owns its own copy of the constants it needs.
|
|
7
|
+
|
|
8
|
+
/** The well-known bus name ModemManager owns. */
|
|
9
|
+
export const MM_BUS_NAME = 'org.freedesktop.ModemManager1';
|
|
10
|
+
|
|
11
|
+
/** The root ObjectManager object path. */
|
|
12
|
+
export const MM_ROOT_PATH = '/org/freedesktop/ModemManager1';
|
|
13
|
+
|
|
14
|
+
/** `org.freedesktop.DBus.ObjectManager` — `GetManagedObjects`, `InterfacesAdded/Removed`. */
|
|
15
|
+
export const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager';
|
|
16
|
+
|
|
17
|
+
/** `org.freedesktop.DBus.Properties` — `PropertiesChanged`. */
|
|
18
|
+
export const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties';
|
|
19
|
+
|
|
20
|
+
/** The root manager interface — `InhibitDevice`, `ScanDevices`, `Version`. */
|
|
21
|
+
export const MM_MANAGER_IFACE = 'org.freedesktop.ModemManager1';
|
|
22
|
+
|
|
23
|
+
/** The core `Modem` interface. */
|
|
24
|
+
export const MODEM_IFACE = 'org.freedesktop.ModemManager1.Modem';
|
|
25
|
+
|
|
26
|
+
/** The separate `Modem.Modem3gpp` interface (never merged into `Modem`). */
|
|
27
|
+
export const MODEM3GPP_IFACE = 'org.freedesktop.ModemManager1.Modem.Modem3gpp';
|
|
28
|
+
|
|
29
|
+
/** A SIM object's `Sim` interface (SIMs are separate `/SIM/<n>` objects). */
|
|
30
|
+
export const SIM_IFACE = 'org.freedesktop.ModemManager1.Sim';
|
|
31
|
+
|
|
32
|
+
/** The bus daemon itself — `NameOwnerChanged`, `GetNameOwner`. */
|
|
33
|
+
export const DBUS_IFACE = 'org.freedesktop.DBus';
|
|
34
|
+
export const DBUS_PATH = '/org/freedesktop/DBus';
|
|
35
|
+
export const DBUS_DESTINATION = 'org.freedesktop.DBus';
|