merging-ravenna 0.2.0 → 0.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.
package/README.md CHANGED
@@ -63,15 +63,56 @@ eng.on('catalog', (cat) => {
63
63
  `confidence` is `confirmed` (set-frame verified on a real device), `inferred`
64
64
  (derived from the state shape; very likely correct), or `unknown`.
65
65
 
66
+ Enum parameters (e.g. `roll_off_filter`, `out_max_level`) accept **either a label
67
+ string or the raw integer** — `setParam(60, 'out_max_level', '+24 dBu')` and
68
+ `setParam(60, 'out_max_level', 1)` are equivalent (label match is case-insensitive).
69
+
70
+ ## System domain (clock, PTP, sync, health)
71
+
72
+ Beyond the per-module audio controls, the engine also models the device-wide
73
+ **system** domain — sample rate, frame size, clock source, PTP, temperature,
74
+ uptime, and assorted device flags — read from the parts of the state tree the
75
+ audio catalog doesn't cover:
76
+
77
+ ```js
78
+ eng.on('system', (snap) => {
79
+ // [{ key, label, group, unit, value, raw, enum, settable, readonly, note }, ...]
80
+ // e.g. { key:'sample_rate', value:'44.1 kHz', raw:44100, enum:{'48 kHz':48000,...}, settable:true }
81
+ });
82
+
83
+ eng.getSystem(); // current snapshot (groups: Clock/Sync/PTP/Device/Health/Advanced)
84
+ eng.getSystemValue('sample_rate'); // one entry by key
85
+
86
+ // Set a system param by label or raw value. Clock/rate changes RE-CLOCK the device,
87
+ // so do these with audio off:
88
+ eng.setSystem('sample_rate', '48 kHz'); // or 48000
89
+ eng.setSystem('sync_source', 'Internal'); // or the raw input id
90
+ eng.setSystem('frame_size', 32);
91
+
92
+ // Generic raw access to any part of the tree:
93
+ eng.getTree(); // last full settings tree
94
+ eng.getSubtree('network.PTP.Status'); // any subtree by dotted / $ path
95
+ ```
96
+
97
+ Settable system params are **hardware-confirmed** by `scripts/probe-system.js`
98
+ (set → re-read → restore, behind a meter-silence gate). Pure telemetry (PTP lock,
99
+ temperature, uptime) is marked `readonly` and never settable. Some params carry a
100
+ `note` describing a dependency (e.g. PTP priorities apply only in manual-grandmaster
101
+ mode). Use `scripts/discover-system.js` (read-only) to dump the system tree on
102
+ unfamiliar firmware.
103
+
66
104
  ## API (summary)
67
105
 
68
106
  - `new RavennaEngine({ host, path?, livenessMs?, backoff? })`
69
107
  - `.connect()`, `.close()`
70
- - `.setParam(moduleId, key, value, { channelIndex? })` — high-level, unit-aware, clamped
108
+ - `.setParam(moduleId, key, value, { channelIndex? })` — high-level, unit-aware, clamped; enums accept a label or int
71
109
  - `.setModuleOuts(moduleId, valueObj)` — mid-level
72
110
  - `.publishSettings(path, value)` — raw escape hatch
111
+ - `.setSystem(key, value)` — set a system-domain param (clock/PTP/sync/flags) by label or raw value
112
+ - `.getSystem()`, `.getSystemValue(key)` — modeled system snapshot
113
+ - `.getTree()`, `.getSubtree(path)` — raw last tree / any subtree by dotted-or-`$` path
73
114
  - `.catalog`, `.tree`, `.capabilities`, `.online`
74
- - events: `online, offline, status, settings, statusmsg, errors, tree, catalog, param, error`
115
+ - events: `online, offline, status, settings, statusmsg, errors, tree, catalog, param, system, error`
75
116
 
76
117
  ## Testing
77
118
 
@@ -91,9 +132,12 @@ share. A meter pre-flight gate aborts if any output is passing audio.
91
132
 
92
133
  ## Extending to new parameters / devices
93
134
 
94
- Confirmed and inferred set-frame shapes live in `src/paths.js`. To add a parameter
95
- (or support an Anubis monitor control), capture one set frame from the device web UI
96
- and add a descriptor there — no engine changes needed.
135
+ Confirmed and inferred set-frame shapes for the **audio** params live in `src/paths.js`.
136
+ To add one (or support an Anubis monitor control), capture a set frame from the device
137
+ web UI and add a descriptor there — no engine changes needed. **System**-domain
138
+ descriptors (their read path, unit, enum source, and confirmed write shape) live in
139
+ `src/system.js`; use `scripts/discover-system.js` to dump an unfamiliar device's tree
140
+ and `scripts/probe-system.js` to confirm new write shapes.
97
141
 
98
142
  ## License
99
143
 
package/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { RavennaEngine } = require('./src/engine');
4
4
  const { buildCatalog, groupByModule } = require('./src/catalog');
5
5
  const { KNOWN, checkCompat, describeCompat } = require('./src/compat');
6
+ const { SYSTEM, readSystem, groupSystem, systemWriteFrame } = require('./src/system');
6
7
  const paths = require('./src/paths');
7
8
 
8
9
  module.exports = {
@@ -10,5 +11,6 @@ module.exports = {
10
11
  buildCatalog,
11
12
  groupByModule,
12
13
  paths,
14
+ system: { SYSTEM, readSystem, groupSystem, systemWriteFrame },
13
15
  compat: { KNOWN, checkCompat, describeCompat }
14
16
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merging-ravenna",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Standalone client for Merging Technologies RAVENNA devices (Hapi / Horus / Anubis) over their CometD/WebSocket control protocol. Unofficial; not affiliated with Merging Technologies.",
5
5
  "keywords": [
6
6
  "merging",
package/src/engine.js CHANGED
@@ -4,6 +4,7 @@ const EventEmitter = require('events');
4
4
  const WebSocket = require('ws');
5
5
  const { buildCatalog } = require('./catalog');
6
6
  const { checkCompat } = require('./compat');
7
+ const { readSystem, systemWriteFrame } = require('./system');
7
8
  const { moduleOutsPath, PARAMS, dbToTenths } = require('./paths');
8
9
 
9
10
  /**
@@ -70,6 +71,7 @@ class RavennaEngine extends EventEmitter {
70
71
  this.catalog = [];
71
72
  this.capabilities = {};
72
73
  this.compat = null;
74
+ this.system = [];
73
75
  }
74
76
 
75
77
  // ---- lifecycle ----------------------------------------------------------
@@ -336,9 +338,47 @@ class RavennaEngine extends EventEmitter {
336
338
  }
337
339
  this.catalog = buildCatalog(tree);
338
340
  this.compat = checkCompat(tree);
341
+ this.system = readSystem(tree);
339
342
  this.emit('tree', tree);
340
343
  this.emit('catalog', this.catalog);
341
344
  this.emit('compat', this.compat);
345
+ this.emit('system', this.system);
346
+ }
347
+
348
+ /** The last full settings tree received (raw), or null. */
349
+ getTree() { return this.tree; }
350
+
351
+ /** The modeled system-domain snapshot (clock/PTP/sync/health/…), or []. */
352
+ getSystem() { return this.system || []; }
353
+
354
+ /** One system snapshot entry by key, or null. */
355
+ getSystemValue(key) { return (this.system || []).find((e) => e.key === key) || null; }
356
+
357
+ /**
358
+ * Set a SYSTEM-domain parameter (clock / PTP / sync / device flags) by its key.
359
+ * Accepts an enum label string (e.g. 'Internal', '48 kHz') or the raw value;
360
+ * resolves it against the live tree's capabilities and publishes the probe-
361
+ * confirmed { path, value } shape. Throws for unknown or non-settable keys.
362
+ */
363
+ setSystem(key, value) {
364
+ const frame = systemWriteFrame(key, value, this.tree || {});
365
+ return this.publishSettings(frame.path, frame.value);
366
+ }
367
+
368
+ /**
369
+ * Read an arbitrary subtree of the last settings tree by a small dotted path,
370
+ * e.g. 'network.PTP.Status' or 'identity.serial'. Returns the whole tree for ''
371
+ * or '$'. Null if absent. Convenience for emitting raw subtrees (Node-RED).
372
+ */
373
+ getSubtree(path) {
374
+ let node = this.tree;
375
+ if (path == null || path === '' || path === '$') return node || null;
376
+ const parts = String(path).replace(/^\$\.?/, '').split('.').filter(Boolean);
377
+ for (const p of parts) {
378
+ if (node == null || typeof node !== 'object') return null;
379
+ node = node[p];
380
+ }
381
+ return node === undefined ? null : node;
342
382
  }
343
383
 
344
384
  _handleMessage(m) {
package/src/system.js ADDED
@@ -0,0 +1,353 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * system.js
5
+ * ---------
6
+ * Knowledge about the device's SYSTEM domain — everything outside the per-module
7
+ * audio controls in `custom.outs`. These values live at varied JSONPaths across the
8
+ * settings tree (top-level scalars, `network.PTP`, the Sync module, `ios[].configuration`),
9
+ * so each descriptor carries its own reader rather than assuming the outs path.
10
+ *
11
+ * Every descriptor is DESCRIPTIVE and DEFENSIVE: `read(tree)` returns `undefined`
12
+ * when the field is absent, so on unfamiliar firmware we simply surface fewer
13
+ * entries instead of throwing. `readSystem()` skips anything not present.
14
+ *
15
+ * `settable` starts FALSE for every entry. The set shape for system params is not
16
+ * yet confirmed on hardware; scripts/probe-system.js (run audio-off) will verify
17
+ * each one via set -> re-read and flip the confirmed ones to settable with a write
18
+ * descriptor. Until then this module is READ-ONLY telemetry + a labeled inventory.
19
+ *
20
+ * Snapshot entry shape (from readSystem):
21
+ * {
22
+ * key, label, group,
23
+ * unit, // 'Hz'|'samples'|'bool'|'enum'|'int'|'string'|'celsius'|'percent'|'datetime'|'seconds'
24
+ * raw, // value as stored on the device
25
+ * value, // display-friendly value (e.g. label for an enum, seconds for uptime)
26
+ * enum, // { label: rawValue } for enum params, else null
27
+ * settable, // false until probe-confirmed
28
+ * readonly // true for pure telemetry that can never be set (status/health)
29
+ * }
30
+ */
31
+
32
+ // ---- small tree helpers (all null-safe) -----------------------------------
33
+ function moduleById(tree, id) {
34
+ const mods = tree && tree._modules;
35
+ if (!Array.isArray(mods)) return null;
36
+ return mods.find((m) => m && m.id === id) || null;
37
+ }
38
+ function firstIoConfig(tree) {
39
+ const ios = tree && tree.ios;
40
+ if (!Array.isArray(ios)) return null;
41
+ const io = ios.find((x) => x && x.configuration && x.configuration.sampleRate != null);
42
+ return io ? io.configuration : null;
43
+ }
44
+ function ioSampleRateCaps(tree) {
45
+ const ios = tree && tree.ios;
46
+ if (!Array.isArray(ios)) return null;
47
+ const io = ios.find((x) => x && x.capabilities && Array.isArray(x.capabilities.sampleRate));
48
+ return io ? io.capabilities.sampleRate : null;
49
+ }
50
+
51
+ // ---- label formatters ------------------------------------------------------
52
+ /** 44100 -> "44.1 kHz", 48000 -> "48 kHz". */
53
+ function hzLabel(hz) {
54
+ const k = hz / 1000;
55
+ return (Number.isInteger(k) ? String(k) : k.toFixed(1)) + ' kHz';
56
+ }
57
+ /** Build { "44.1 kHz": 44100, ... } from a caps array of rates. */
58
+ function rateEnum(rates) {
59
+ if (!Array.isArray(rates)) return null;
60
+ const out = {};
61
+ for (const r of rates) out[hzLabel(r)] = r;
62
+ return out;
63
+ }
64
+ /** Build { "6": 6, ... } from a caps array of integer options. */
65
+ function intEnum(opts) {
66
+ if (!Array.isArray(opts)) return null;
67
+ const out = {};
68
+ for (const n of opts) out[String(n)] = n;
69
+ return out;
70
+ }
71
+ /** Build { signal_name: input_id } from a Sync module's advertised sync_sources. */
72
+ function syncSourceEnum(tree) {
73
+ const m = moduleById(tree, 2);
74
+ const list = m && m.state && m.state.sync_sources;
75
+ if (!Array.isArray(list)) return null;
76
+ const out = {};
77
+ for (const s of list) if (s && s.signal_name != null) out[s.signal_name] = s.input_id;
78
+ return out;
79
+ }
80
+
81
+ // ---- write-frame builders --------------------------------------------------
82
+ // Every shape below was CONFIRMED on hardware (Hapi MkII, fw 1.9.0b62872) by
83
+ // scripts/probe-system.js: set -> re-read -> restore. The device merges `value`
84
+ // (a partial object) into the node at `path`.
85
+ const moduleRootPath = (id) => `$._modules[?(@.id==${id})][0]`;
86
+ const topWrite = (field) => (v) => ({ path: '$', value: { [field]: v } });
87
+ const moduleWrite = (id, field) => (v) => ({ path: moduleRootPath(id), value: { [field]: v } });
88
+ const ptpWrite = (field) => (v) => ({ path: '$.network.PTP', value: { [field]: v } });
89
+ const ptpMasterWrite = (field) => (v) => ({ path: '$.network.PTP.Master', value: { [field]: v } });
90
+
91
+ // ---- descriptor table ------------------------------------------------------
92
+ // NOTE on enums: `enum(tree)` returns a { label: rawValue } map. For read-only status
93
+ // fields whose integer meanings we have NOT confirmed (e.g. PTP LockStatus), we expose
94
+ // the raw int with no guessed labels rather than inventing a mapping.
95
+ const SYSTEM = [
96
+ // ----- Clock -----
97
+ {
98
+ key: 'sample_rate', label: 'Sample rate', group: 'Clock', unit: 'Hz',
99
+ read: (t) => { const c = firstIoConfig(t); return c ? c.sampleRate : undefined; },
100
+ enum: (t) => rateEnum(ioSampleRateCaps(t)),
101
+ settable: true,
102
+ write: (v) => ({ path: '$.ios[?(@.id=="1")][0].configuration', value: { sampleRate: v } })
103
+ },
104
+ {
105
+ key: 'auto_sample_rate', label: 'Auto sample rate', group: 'Clock', unit: 'bool',
106
+ read: (t) => t._auto_sample_rate,
107
+ settable: true, write: topWrite('_auto_sample_rate')
108
+ },
109
+ {
110
+ key: 'frame_size', label: 'Frame size (latency)', group: 'Clock', unit: 'samples',
111
+ read: (t) => t._frame_size_at_1FS,
112
+ enum: (t) => intEnum(t && t.capabilities && t.capabilities._frame_size_at_1FS),
113
+ settable: true, write: topWrite('_frame_size_at_1FS')
114
+ },
115
+ {
116
+ key: 'asio_clock', label: 'ASIO clock', group: 'Clock', unit: 'int',
117
+ read: (t) => t._ASIO_clock,
118
+ settable: true, write: topWrite('_ASIO_clock')
119
+ },
120
+
121
+ // ----- Sync / clock source (Sync module, id 2) -----
122
+ {
123
+ key: 'sync_source', label: 'Clock source', group: 'Sync', unit: 'enum',
124
+ read: (t) => { const m = moduleById(t, 2); return m && m.sync_source ? m.sync_source.input_id : undefined; },
125
+ enum: (t) => syncSourceEnum(t),
126
+ settable: true,
127
+ write: (v) => ({ path: moduleRootPath(2), value: { sync_source: { module_id: 2, input_id: v } } })
128
+ },
129
+ {
130
+ key: 'wordclock_termination', label: 'Word clock termination (75Ω)', group: 'Sync', unit: 'bool',
131
+ read: (t) => { const m = moduleById(t, 2); return m ? m.wordclock_termination : undefined; },
132
+ settable: true, write: moduleWrite(2, 'wordclock_termination')
133
+ },
134
+ {
135
+ key: 'wordclock_out_follow_samplingrate', label: 'Word clock out follows sample rate', group: 'Sync', unit: 'bool',
136
+ read: (t) => { const m = moduleById(t, 2); return m ? m.wordclock_out_follow_samplingrate : undefined; },
137
+ settable: true, write: moduleWrite(2, 'wordclock_out_follow_samplingrate')
138
+ },
139
+ {
140
+ key: 'video_termination', label: 'Video ref termination (75Ω)', group: 'Sync', unit: 'bool',
141
+ read: (t) => { const m = moduleById(t, 2); return m ? m.video_termination : undefined; },
142
+ settable: true, write: moduleWrite(2, 'video_termination')
143
+ },
144
+
145
+ // ----- PTP policy (network.PTP.Master) -----
146
+ {
147
+ key: 'ptp_domain', label: 'PTP domain', group: 'PTP', unit: 'int',
148
+ read: (t) => t.network && t.network.PTP ? t.network.PTP.Domain : undefined,
149
+ settable: true, write: ptpWrite('Domain')
150
+ },
151
+ {
152
+ key: 'ptp_manual_master', label: 'PTP manual grandmaster', group: 'PTP', unit: 'bool',
153
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Master ? t.network.PTP.Master.Manual : undefined,
154
+ settable: true, write: ptpMasterWrite('Manual')
155
+ },
156
+ // Priorities are settable ONLY while ptp_manual_master is true (confirmed by
157
+ // scripts/probe-ptp-priority.js: they no-op under auto-GM, apply under manual).
158
+ // Marked settable with the confirmed shape; the note flags the dependency so a
159
+ // write while Manual=false is understood to be ignored by the device.
160
+ {
161
+ key: 'ptp_priority1', label: 'PTP priority 1', group: 'PTP', unit: 'int',
162
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Master ? t.network.PTP.Master.Prio1 : undefined,
163
+ settable: true, write: ptpMasterWrite('Prio1'),
164
+ note: 'only applied when ptp_manual_master is true (auto-GM ignores it)'
165
+ },
166
+ {
167
+ key: 'ptp_priority2', label: 'PTP priority 2', group: 'PTP', unit: 'int',
168
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Master ? t.network.PTP.Master.Prio2 : undefined,
169
+ settable: true, write: ptpMasterWrite('Prio2'),
170
+ note: 'only applied when ptp_manual_master is true (auto-GM ignores it)'
171
+ },
172
+
173
+ // ----- PTP status (read-only telemetry) -----
174
+ {
175
+ key: 'ptp_lock_status', label: 'PTP lock status', group: 'PTP', unit: 'int', readonly: true,
176
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Status ? t.network.PTP.Status.LockStatus : undefined
177
+ },
178
+ {
179
+ key: 'ptp_grandmaster_id', label: 'PTP grandmaster (GMID)', group: 'PTP', unit: 'string', readonly: true,
180
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Status ? t.network.PTP.Status.GMID : undefined
181
+ },
182
+ {
183
+ key: 'ptp_is_master', label: 'PTP is grandmaster', group: 'PTP', unit: 'bool', readonly: true,
184
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Status ? t.network.PTP.Status.Master : undefined
185
+ },
186
+ {
187
+ key: 'ptp_clock_jitter', label: 'PTP clock jitter', group: 'PTP', unit: 'int', readonly: true,
188
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Status ? t.network.PTP.Status.ClockJitter : undefined
189
+ },
190
+ {
191
+ key: 'ptp_network_jitter', label: 'PTP network jitter', group: 'PTP', unit: 'int', readonly: true,
192
+ read: (t) => t.network && t.network.PTP && t.network.PTP.Status ? t.network.PTP.Status.NetworkJitter : undefined
193
+ },
194
+
195
+ // ----- Device identity / firmware (read-only) -----
196
+ {
197
+ key: 'product', label: 'Product', group: 'Device', unit: 'string', readonly: true,
198
+ read: (t) => t.identity ? t.identity.product : undefined
199
+ },
200
+ {
201
+ key: 'serial', label: 'Serial', group: 'Device', unit: 'string', readonly: true,
202
+ read: (t) => t.identity ? t.identity.serial : undefined
203
+ },
204
+ {
205
+ key: 'device_name', label: 'Device name', group: 'Device', unit: 'string', readonly: true,
206
+ read: (t) => t.identity ? t.identity.name : undefined
207
+ },
208
+ {
209
+ key: 'firmware_version', label: 'Firmware version', group: 'Device', unit: 'string', readonly: true,
210
+ read: (t) => t._firmware_version
211
+ },
212
+ {
213
+ key: 'firmware_generation', label: 'Firmware generation', group: 'Device', unit: 'int', readonly: true,
214
+ read: (t) => t._firmware_generation
215
+ },
216
+ {
217
+ key: 'boot_time', label: 'Boot time', group: 'Device', unit: 'datetime', readonly: true,
218
+ read: (t) => t._boot_time
219
+ },
220
+ {
221
+ key: 'uptime', label: 'Uptime', group: 'Device', unit: 'seconds', readonly: true, derived: true,
222
+ // derived from boot_time at read time; `now` injectable for deterministic tests
223
+ read: (t) => t._boot_time // raw is the boot timestamp; value (seconds) computed in readSystem
224
+ },
225
+
226
+ // ----- Health (ZMAN module, id 0; read-only) -----
227
+ {
228
+ key: 'temperature', label: 'Temperature', group: 'Health', unit: 'celsius', readonly: true,
229
+ read: (t) => { const m = moduleById(t, 0); return m && m.state ? m.state.temperature : undefined; }
230
+ },
231
+ {
232
+ key: 'cpu_load', label: 'CPU load', group: 'Health', unit: 'percent', readonly: true,
233
+ read: (t) => { const m = moduleById(t, 0); return m && m.state ? m.state.cpu_load : undefined; }
234
+ },
235
+ {
236
+ key: 'memory_load', label: 'Memory load', group: 'Health', unit: 'percent', readonly: true,
237
+ read: (t) => { const m = moduleById(t, 0); return m && m.state ? m.state.memory_load : undefined; }
238
+ },
239
+ {
240
+ key: 'panic', label: 'Panic', group: 'Health', unit: 'bool', readonly: true,
241
+ read: (t) => { const m = moduleById(t, 0); return m && m.state ? m.state.panic : undefined; }
242
+ },
243
+
244
+ // ----- Advanced device flags (top-level) -----
245
+ // consumer_mode did NOT apply via the standard set in the probe (may require a
246
+ // reboot or a dedicated command); left non-settable until confirmed.
247
+ { key: 'consumer_mode', label: 'Consumer mode', group: 'Advanced', unit: 'bool', read: (t) => t._consumer_mode, note: 'did not apply via standard set (probe); may need reboot/dedicated command' },
248
+ { key: 'big_jitter_buffer', label: 'Big jitter buffer', group: 'Advanced', unit: 'bool', read: (t) => t._big_jitter_buffer, settable: true, write: topWrite('_big_jitter_buffer') },
249
+ { key: 'fixed_playout_delay', label: 'Fixed playout delay', group: 'Advanced', unit: 'bool', read: (t) => t._fixed_playout_delay, settable: true, write: topWrite('_fixed_playout_delay') },
250
+ { key: 'auto_connect_from_source', label: 'Auto-connect from source', group: 'Advanced', unit: 'bool', read: (t) => t._auto_connect_from_source, settable: true, write: topWrite('_auto_connect_from_source') },
251
+ { key: 'auto_save', label: 'Auto-save', group: 'Advanced', unit: 'bool', read: (t) => t._auto_save, settable: true, write: topWrite('_auto_save') },
252
+ { key: 'peer_filtering', label: 'Peer filtering', group: 'Advanced', unit: 'bool', read: (t) => t._peer_filtering, settable: true, write: topWrite('_peer_filtering') },
253
+ {
254
+ key: 'spdif_physical_mode', label: 'S/PDIF physical mode', group: 'Advanced', unit: 'int',
255
+ read: (t) => { const m = moduleById(t, 100); return m ? m.physical_mode : undefined; },
256
+ settable: true, write: moduleWrite(100, 'physical_mode')
257
+ }
258
+ ];
259
+
260
+ /**
261
+ * Parse the device's "YYYY-MM-DD HH:MM:SS" boot time to epoch ms, or null.
262
+ * The device reports this in UTC (confirmed on hardware: interpreting it as local
263
+ * placed boot in the future relative to wall-clock now). Parsed as UTC accordingly.
264
+ */
265
+ function bootTimeMs(s) {
266
+ if (typeof s !== 'string') return null;
267
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);
268
+ if (!m) return null;
269
+ const [, Y, Mo, D, H, Mi, S] = m.map(Number);
270
+ const t = Date.UTC(Y, Mo - 1, D, H, Mi, S);
271
+ return Number.isFinite(t) ? t : null;
272
+ }
273
+
274
+ /**
275
+ * Read every present system descriptor out of a full settings tree.
276
+ * @param tree the value of a path:"$" settings update
277
+ * @param opts.now epoch ms used to derive uptime (defaults to Date.now())
278
+ * @returns array of snapshot entries (only for fields actually present)
279
+ */
280
+ function readSystem(tree, opts = {}) {
281
+ if (!tree || typeof tree !== 'object') return [];
282
+ const now = opts.now != null ? opts.now : Date.now();
283
+ const out = [];
284
+ for (const d of SYSTEM) {
285
+ let raw;
286
+ try { raw = d.read(tree); } catch (e) { raw = undefined; }
287
+ if (raw === undefined) continue; // not present on this firmware
288
+
289
+ const enumMap = d.enum ? d.enum(tree) : null;
290
+ let value = raw;
291
+ if (d.key === 'uptime') {
292
+ const ms = bootTimeMs(raw);
293
+ value = ms != null ? Math.max(0, Math.round((now - ms) / 1000)) : null;
294
+ } else if (enumMap) {
295
+ // value = the label whose rawValue matches (fall back to raw if unmatched)
296
+ const hit = Object.keys(enumMap).find((k) => enumMap[k] === raw);
297
+ value = hit != null ? hit : raw;
298
+ }
299
+
300
+ out.push({
301
+ key: d.key, label: d.label, group: d.group, unit: d.unit,
302
+ raw, value, enum: enumMap || null,
303
+ settable: !!d.settable && typeof d.write === 'function', // probe-confirmed write shapes only
304
+ readonly: !!d.readonly,
305
+ note: d.note || null
306
+ });
307
+ }
308
+ return out;
309
+ }
310
+
311
+ /** Find a system descriptor by key, or null. */
312
+ function descFor(key) { return SYSTEM.find((d) => d.key === key) || null; }
313
+
314
+ /**
315
+ * Build the device { path, value } write frame for a SETTABLE system param.
316
+ * Resolves an enum LABEL string to its raw value (case-insensitive, using the live
317
+ * tree's enum maps for sample rate / clock source) and coerces by unit. Throws for
318
+ * unknown or not-(probe-)confirmed-settable keys, or a non-numeric numeric value.
319
+ */
320
+ function systemWriteFrame(key, value, tree) {
321
+ const d = descFor(key);
322
+ if (!d) throw new Error(`Unknown system parameter '${key}'`);
323
+ if (!d.settable || typeof d.write !== 'function') throw new Error(`System parameter '${key}' is not settable`);
324
+
325
+ let v = value;
326
+ if (d.enum) { // accept a label string OR the raw value
327
+ const map = d.enum(tree || {});
328
+ if (typeof v === 'string' && map) {
329
+ const hit = Object.keys(map).find((k) => k.toLowerCase() === v.toLowerCase());
330
+ if (hit != null) v = map[hit];
331
+ }
332
+ }
333
+ if (d.unit === 'bool') {
334
+ v = (v === true || v === 1 || v === 'true' || v === '1');
335
+ } else if (d.unit === 'Hz' || d.unit === 'samples' || d.unit === 'int') {
336
+ if (typeof v === 'string') v = parseInt(v, 10);
337
+ if (!Number.isFinite(v)) throw new Error(`Invalid numeric value for system parameter '${key}': ${JSON.stringify(value)}`);
338
+ }
339
+ return d.write(v);
340
+ }
341
+
342
+ /** Group a system snapshot by its `group` field, preserving descriptor order. */
343
+ function groupSystem(snapshot) {
344
+ const order = [];
345
+ const map = new Map();
346
+ for (const e of snapshot) {
347
+ if (!map.has(e.group)) { map.set(e.group, { group: e.group, params: [] }); order.push(e.group); }
348
+ map.get(e.group).params.push(e);
349
+ }
350
+ return order.map((g) => map.get(g));
351
+ }
352
+
353
+ module.exports = { SYSTEM, readSystem, groupSystem, bootTimeMs, systemWriteFrame, descFor };