merging-ravenna 0.1.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.1.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/catalog.js CHANGED
@@ -91,11 +91,11 @@ function buildCatalog(tree) {
91
91
  }));
92
92
  }
93
93
 
94
- // out max level
94
+ // out max level (model-supplied enum: device gives only the int)
95
95
  if (typeof sec.out_max_level === 'number' && caps.out_max_level) {
96
96
  out.push(Object.assign({}, base, {
97
97
  key: 'out_max_level', raw: sec.out_max_level, value: sec.out_max_level,
98
- unit: 'int', min: null, max: null, step: null, enum: null,
98
+ unit: 'enum', min: null, max: null, step: null, enum: PARAMS.out_max_level.enum || null,
99
99
  settable: true, confidence: PARAMS.out_max_level.confidence
100
100
  }));
101
101
  }
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 ----------------------------------------------------------
@@ -266,6 +268,15 @@ class RavennaEngine extends EventEmitter {
266
268
  if (!desc) throw new Error(`Unknown parameter '${key}'`);
267
269
 
268
270
  let v = value;
271
+ if (desc.unit === 'enum') {
272
+ // Accept either the integer or a label string (e.g. 'Brickwall', '+24 dBu').
273
+ if (typeof v === 'string') {
274
+ const map = this._enumMapFor(moduleId, key);
275
+ const hit = map && Object.keys(map).find((k) => k.toLowerCase() === v.toLowerCase());
276
+ if (hit != null) v = map[hit];
277
+ if (typeof v === 'string') v = parseInt(v, 10); // numeric-string fallback ("3")
278
+ }
279
+ }
269
280
  if (desc.unit === 'tenths-db') {
270
281
  v = dbToTenths(value);
271
282
  const cap = this._capFor(moduleId, key);
@@ -287,6 +298,15 @@ class RavennaEngine extends EventEmitter {
287
298
  return null;
288
299
  }
289
300
 
301
+ /** Label->int map for an enum param: static (PARAMS) or device-provided (capabilities). */
302
+ _enumMapFor(moduleId, key) {
303
+ const desc = PARAMS[key];
304
+ if (desc && desc.enum) return desc.enum; // model-supplied (e.g. out_max_level)
305
+ const c = this.capabilities[moduleId];
306
+ if (key === 'roll_off_filter' && c && c.rollOff) return c.rollOff; // device-supplied
307
+ return null;
308
+ }
309
+
290
310
  _channelCountFor(moduleId, section) {
291
311
  if (!this.tree || !Array.isArray(this.tree._modules)) return 1;
292
312
  const m = this.tree._modules.find((x) => x.id === moduleId);
@@ -318,9 +338,47 @@ class RavennaEngine extends EventEmitter {
318
338
  }
319
339
  this.catalog = buildCatalog(tree);
320
340
  this.compat = checkCompat(tree);
341
+ this.system = readSystem(tree);
321
342
  this.emit('tree', tree);
322
343
  this.emit('catalog', this.catalog);
323
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;
324
382
  }
325
383
 
326
384
  _handleMessage(m) {
@@ -416,7 +474,7 @@ class RavennaEngine extends EventEmitter {
416
474
  }
417
475
  if (typeof outs.out_max_level === 'number' && outs.out_max_level !== prev.out_max_level) {
418
476
  prev.out_max_level = outs.out_max_level;
419
- this.emit('param', { moduleId, key: 'out_max_level', raw: outs.out_max_level, value: outs.out_max_level, unit: 'int' });
477
+ this.emit('param', { moduleId, key: 'out_max_level', raw: outs.out_max_level, value: outs.out_max_level, unit: 'enum' });
420
478
  }
421
479
  if (Array.isArray(outs.channels)) {
422
480
  if (!Array.isArray(prev.trims)) prev.trims = [];
package/src/paths.js CHANGED
@@ -60,11 +60,14 @@ const PARAMS = {
60
60
  capEnum: 'roll_off_filters',
61
61
  build: (v) => ({ roll_off_filter: v | 0 })
62
62
  },
63
- // out_max_level is a 0/1 toggle between the card's two output reference levels.
63
+ // out_max_level selects the card's output reference level. The device exposes only
64
+ // the raw int (0/1) with NO label map, so the meanings are MODEL knowledge encoded
65
+ // here (Hapi MkII D/A, sub_type 218; may differ on other cards). Confirmed mapping.
64
66
  out_max_level: {
65
- section: 'outs', unit: 'int', confidence: 'confirmed',
67
+ section: 'outs', unit: 'enum', confidence: 'confirmed',
66
68
  capFlag: 'out_max_level',
67
- build: (v) => ({ out_max_level: v ? 1 : 0 })
69
+ enum: { '+18 dBu': 0, '+24 dBu': 1 },
70
+ build: (v) => ({ out_max_level: v | 0 })
68
71
  },
69
72
  // Per-channel trim is an array within custom.outs; the partial-array shape (target
70
73
  // channel carries {trim}, others {}) is confirmed to apply on the device.
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 };