@ceralive/modem-control 0.2.0 → 1.0.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.
@@ -0,0 +1,196 @@
1
+ // Parsing `usb-devices` text — the ONLY per-interface descriptor source inside a
2
+ // certification bundle.
3
+ //
4
+ // A base certification bundle (`certify <slot>` with no `--transition`) carries no
5
+ // structured descriptors at all: it holds `lsusb -v` and `usb-devices` as raw text plus
6
+ // the slot's udev property map. Authoring a classifier fixture or a catalog entry from
7
+ // such a bundle therefore requires reading the descriptors back out of that text, and
8
+ // `usb-devices` is the right half to read — it is line-oriented, one fixed-width record
9
+ // per device, and it names each interface's BOUND KERNEL DRIVER, which `lsusb -v` does
10
+ // not. The driver is not optional detail here: `classifyDevice` decides `mm-managed` vs
11
+ // `router-mode` partly on `qmi_wwan` / `cdc_ether` / `option` bindings.
12
+ //
13
+ // The parser is pure and total: unparseable lines are SKIPPED, never guessed at, and a
14
+ // device that yields no interfaces still yields a record (callers decide whether an
15
+ // interface-less device is usable — this file never makes that judgement).
16
+ //
17
+ // Record shape (`usb-devices`, one blank-line-separated block per device):
18
+ // T: Bus=04 Lev=03 Prnt=03 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0
19
+ // D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1
20
+ // P: Vendor=2c7c ProdID=0801 Rev=05.04
21
+ // S: Manufacturer=Quectel
22
+ // S: Product=RM530N-GL
23
+ // I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan
24
+
25
+ /** One interface line of a `usb-devices` record. */
26
+ export interface ParsedUsbInterface {
27
+ readonly interfaceClass: number;
28
+ readonly interfaceSubClass: number;
29
+ readonly interfaceProtocol: number;
30
+ /** The bound kernel driver, omitted when `usb-devices` reports `(none)`. */
31
+ readonly driver?: string;
32
+ }
33
+
34
+ /** One device block of a `usb-devices` capture. */
35
+ export interface ParsedUsbDevice {
36
+ /** Lowercase hex `xxxx:xxxx`, exactly the catalog's `vidPid` discriminator shape. */
37
+ readonly vidPid: string;
38
+ /** The `D:` line's `Cls=` byte — the device-descriptor `bDeviceClass`. */
39
+ readonly bDeviceClass: number;
40
+ readonly manufacturer?: string;
41
+ readonly product?: string;
42
+ readonly interfaces: readonly ParsedUsbInterface[];
43
+ }
44
+
45
+ /** Read `Key=value` from a `usb-devices` line; `undefined` when the key is absent. */
46
+ function field(line: string, key: string): string | undefined {
47
+ // Values are whitespace-delimited and may be preceded by padding spaces (`Dev#= 7`).
48
+ // `Cls=ff(vend.)` carries a trailing gloss, stripped by the hex/number parsers below.
49
+ const match = new RegExp(`${key}=\\s*(\\S+)`).exec(line);
50
+ return match?.[1];
51
+ }
52
+
53
+ /** Parse a hex byte field, tolerating `usb-devices`' `ff(vend.)` gloss suffix. */
54
+ function hexByte(line: string, key: string): number | undefined {
55
+ const raw = field(line, key);
56
+ if (raw === undefined) {
57
+ return undefined;
58
+ }
59
+ const digits = /^[0-9a-fA-F]{1,2}/.exec(raw)?.[0];
60
+ if (digits === undefined) {
61
+ return undefined;
62
+ }
63
+ const value = Number.parseInt(digits, 16);
64
+ return Number.isNaN(value) ? undefined : value;
65
+ }
66
+
67
+ /** Parse an `S: Manufacturer=…` style line into its `[key, value]` pair. */
68
+ function stringField(line: string): readonly [string, string] | undefined {
69
+ const eq = line.indexOf('=');
70
+ if (eq < 0) {
71
+ return undefined;
72
+ }
73
+ const key = line.slice(3, eq).trim();
74
+ const value = line.slice(eq + 1).trim();
75
+ return key === '' || value === '' ? undefined : [key, value];
76
+ }
77
+
78
+ interface DeviceAccumulator {
79
+ vidPid?: string;
80
+ bDeviceClass?: number;
81
+ manufacturer?: string;
82
+ product?: string;
83
+ interfaces: ParsedUsbInterface[];
84
+ }
85
+
86
+ function emptyAccumulator(): DeviceAccumulator {
87
+ return { interfaces: [] };
88
+ }
89
+
90
+ function finish(acc: DeviceAccumulator, out: ParsedUsbDevice[]): void {
91
+ // A record with no `P:` line is not a device — never synthesise an identity for it.
92
+ if (acc.vidPid === undefined) {
93
+ return;
94
+ }
95
+ out.push({
96
+ vidPid: acc.vidPid,
97
+ bDeviceClass: acc.bDeviceClass ?? 0,
98
+ interfaces: acc.interfaces,
99
+ ...(acc.manufacturer !== undefined ? { manufacturer: acc.manufacturer } : {}),
100
+ ...(acc.product !== undefined ? { product: acc.product } : {}),
101
+ });
102
+ }
103
+
104
+ function applyProductLine(line: string, acc: DeviceAccumulator): void {
105
+ const vendor = field(line, 'Vendor');
106
+ const product = field(line, 'ProdID');
107
+ if (vendor !== undefined && product !== undefined) {
108
+ acc.vidPid = `${vendor.toLowerCase()}:${product.toLowerCase()}`;
109
+ }
110
+ }
111
+
112
+ function applyStringLine(line: string, acc: DeviceAccumulator): void {
113
+ const pair = stringField(line);
114
+ if (pair === undefined) {
115
+ return;
116
+ }
117
+ const [key, value] = pair;
118
+ if (key === 'Manufacturer') {
119
+ acc.manufacturer = value;
120
+ } else if (key === 'Product') {
121
+ acc.product = value;
122
+ }
123
+ }
124
+
125
+ function applyInterfaceLine(line: string, acc: DeviceAccumulator): void {
126
+ const interfaceClass = hexByte(line, 'Cls');
127
+ const interfaceSubClass = hexByte(line, 'Sub');
128
+ const interfaceProtocol = hexByte(line, 'Prot');
129
+ if (
130
+ interfaceClass === undefined ||
131
+ interfaceSubClass === undefined ||
132
+ interfaceProtocol === undefined
133
+ ) {
134
+ return;
135
+ }
136
+ const driver = field(line, 'Driver');
137
+ acc.interfaces.push({
138
+ interfaceClass,
139
+ interfaceSubClass,
140
+ interfaceProtocol,
141
+ ...(driver !== undefined && driver !== '(none)' ? { driver } : {}),
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Parse `usb-devices` output into one record per device. Pure and total: malformed
147
+ * lines are skipped rather than guessed at, and a block with no `P:` line yields no
148
+ * record (it has no identity, so inventing one would be a lie).
149
+ */
150
+ export function parseUsbDevices(text: string): ParsedUsbDevice[] {
151
+ const out: ParsedUsbDevice[] = [];
152
+ let acc = emptyAccumulator();
153
+ for (const line of text.split('\n')) {
154
+ // A `T:` line opens a new device record; `usb-devices` also blank-line-separates
155
+ // them, but the topology line is the reliable delimiter (blank lines are optional
156
+ // in some kernels' output).
157
+ if (line.startsWith('T:')) {
158
+ finish(acc, out);
159
+ acc = emptyAccumulator();
160
+ continue;
161
+ }
162
+ if (line.startsWith('D:')) {
163
+ const deviceClass = hexByte(line, 'Cls');
164
+ if (deviceClass !== undefined) {
165
+ acc.bDeviceClass = deviceClass;
166
+ }
167
+ } else if (line.startsWith('P:')) {
168
+ applyProductLine(line, acc);
169
+ } else if (line.startsWith('S:')) {
170
+ applyStringLine(line, acc);
171
+ } else if (line.startsWith('I:')) {
172
+ applyInterfaceLine(line, acc);
173
+ }
174
+ }
175
+ finish(acc, out);
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Find the single device matching `vidPid` in a parsed capture. Returns `undefined`
181
+ * when there is NO match, and — deliberately — also when there is more than one: a
182
+ * duplicate VID:PID (this bench has two identical Huawei HiLink units) makes the
183
+ * selection ambiguous, and an ambiguous selection must refuse rather than pick the
184
+ * first. The caller turns that into a typed refusal.
185
+ */
186
+ export function selectUniqueDevice(
187
+ devices: readonly ParsedUsbDevice[],
188
+ vidPid: string,
189
+ ): { readonly device: ParsedUsbDevice } | { readonly ambiguousMatches: number } {
190
+ const matches = devices.filter((d) => d.vidPid === vidPid);
191
+ const only = matches[0];
192
+ if (matches.length === 1 && only !== undefined) {
193
+ return { device: only };
194
+ }
195
+ return { ambiguousMatches: matches.length };
196
+ }