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 +49 -5
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/catalog.js +2 -2
- package/src/engine.js +59 -1
- package/src/paths.js +6 -3
- package/src/system.js +353 -0
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`.
|
|
95
|
-
(or support an Anubis monitor control), capture
|
|
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.
|
|
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: '
|
|
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: '
|
|
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
|
|
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: '
|
|
67
|
+
section: 'outs', unit: 'enum', confidence: 'confirmed',
|
|
66
68
|
capFlag: 'out_max_level',
|
|
67
|
-
|
|
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 };
|