node-red-contrib-merging
v0.3.0
Published
Node-RED nodes to monitor and control Merging Technologies RAVENNA devices (Hapi / Horus / Anubis). Thin wrapper over the merging-ravenna library. Unofficial; not affiliated with Merging Technologies.
Maintainers
Readme
node-red-contrib-merging
Node-RED nodes to monitor and control Merging Technologies RAVENNA devices
(Hapi, Horus, Anubis) over their web-UI control protocol — CometD/Bayeux over
WebSocket. A thin wrapper over the
merging-ravenna library.
Unofficial. Not affiliated with or endorsed by Merging Technologies. The protocol is undocumented and may change between firmware versions. Always keep a way to restore your settings.
With these nodes you can:
- read any parameter the device exposes — gain, mute, trims, roll-off filter, output reference level — and react when it changes (knob, web UI, front panel, or your own automation all echo back);
- set those parameters from a flow (a Stream Deck, a schedule, MQTT, …);
- read and write the device's system domain — sample rate, frame size, clock source, PTP, plus health telemetry (temperature, uptime, PTP lock);
- inspect device state on demand — modeled values, or the raw tree (RTP sessions, network, NMOS, …);
- know when the device goes online/offline.
Contents
- Install
- Quick start
- Core concepts — how the device talks, modules, the catalog, the System pseudo-module, topics, units & enums
- The nodes — config, set, state, inspect, device
- Message reference
- Examples
- Troubleshooting
- Relationship to
merging-ravenna
Install
From your Node-RED user directory (usually ~/.node-red):
npm install node-red-contrib-mergingThen restart Node-RED. A Merging category appears in the palette with five nodes.
Node version note. Node-RED's palette installer runs with
--engine-strict. If you're on a very new Node (e.g. 24) you may hitEBADENGINEfrom an unrelated transitive dependency. Pinning Node-RED to Node 20/22 is the clean fix; see Troubleshooting.
Quick start
- Drag in a merging-state node and double-click it.
- Next to Device, click the pencil and add a merging-config: enter your
device's IP (e.g.
192.168.0.150) and deploy. - Power on the device. The state node's status dot goes green (online), and on connect it emits the current value of everything in scope.
- Turn a knob on the unit (or change something in its web UI) — the state node emits a message describing the change.
To set something, add a merging-set node, pick a Module and Parameter, and feed
its input the value as msg.payload.
Core concepts
How the device talks
Everything runs over one WebSocket session per device (CometD/Bayeux multiplexes several logical channels onto it). The state you care about lives in one big JSON tree — the same object the unit's web UI returns from System → Get Device Status. The device delivers it two ways:
- Whole tree — the entire state in one object,
{ path: "$", value: {…} }. Sent on request: on connect, and whenever you ask (arefresh). - Narrow deltas — when one thing changes, just the changed slice is pushed (e.g.
{ path: "…id==60…outs", value: { attenuation: -300 } }). Pushed on any change — a knob, the web UI, the front panel, another client, or your own set echoing back.
The library turns those into what these nodes emit:
- audio changes arrive as narrow deltas → streamed per-key as they happen;
- the catalog (the audio parameter list) and the system snapshot (clock/PTP/health) are rebuilt from the whole tree → read on connect / refresh.
That asymmetry is why the two domains behave differently below: audio controls stream
(there's a delta for every change), but system values only ever appear in a whole-tree
snapshot — so you poll a refresh to read them. (A stable value like sample_rate
never "streams", because nothing emits a delta for it.)
The device (config) node
merging-config holds one shared, self-healing WebSocket session to a device. Every set/state/device node references it, so they share a single connection. It reconnects automatically (exponential backoff) if the device drops, and a data-liveness watchdog tears down and re-establishes a wedged-but-open socket — so after a power-cycle, state is always re-read from the device rather than assumed.
You usually have one config node per physical device.
Modules and parameters
A RAVENNA device is organised into modules — the cards and functional blocks it contains. Each module has a numeric id and a name. On a Hapi MkII, for example:
| id | name | what it is |
|-----|-------------|------------------------------------|
| 60 | D/A 1 | the analog output card (8 channels)|
| 30 | Headphone | the headphone output (2 channels) |
| 0 | ZMAN | clock/network + device health |
| 2 | Sync | PTP / clock source |
| 100 | SPDIF | S/PDIF I/O |
Within a module, each controllable setting is a parameter with a stable key:
attenuation (output gain), mute, channel_trim (per-channel), roll_off_filter,
out_max_level, and so on.
The catalog
The catalog is the list of controllable parameters that the library discovered live from your specific device by reading its own capability metadata. It's not a fixed list baked into this package — it reflects whatever cards and channels your unit actually has, at its current firmware.
Each catalog entry describes one parameter:
{
moduleId: 60, moduleName: "D/A 1",
key: "attenuation",
value: -20, // current value, in display units (dB here)
raw: -200, // raw device value (tenths of a dB)
unit: "dB", // dB | bool | enum | int
min: -120, max: 0, step: 0.5, // advertised range, where applicable
enum: null, // { label: intValue } for enum params
channelIndex: undefined, // set for per-channel params
settable: true,
confidence: "confirmed"
}The editor dropdowns (Module → Parameter → Channel) are populated from the catalog,
and you can fetch it into a flow with msg.query = "catalog" on a merging-state
node. Audio parameter keys repeat across modules/channels, so the catalog is an
array.
The System pseudo-module
Beyond per-module audio controls, the device has a system domain: sample rate,
frame size, clock source, PTP settings, and read-only health telemetry (CPU
temperature, uptime, PTP lock status, grandmaster ID). These don't belong to one
audio card, so this package surfaces them as an always-present System entry in
the Module dropdown of both the set and state nodes.
System parameters are grouped (Clock / Sync / PTP / Device / Health / Advanced)
and keyed by a unique name — sample_rate, frame_size, sync_source, ptp_domain,
temperature, uptime, … Because their keys are unique, the system snapshot is an
object keyed by name (see queries).
⚠️ Writing system parameters re-clocks the device. Changing sample rate, frame size, or clock source interrupts audio. Do it with audio stopped. Read-only telemetry (temperature, PTP lock, uptime) can never be set. Some settables carry a dependency note (e.g. PTP priorities apply only in manual-grandmaster mode).
Topics and messages
These nodes don't talk to MQTT themselves — each emits a plain Node-RED message on
its output, which you wire wherever you like (mqtt out, a function, a dashboard…).
Topics are neutral identifiers with no project-specific prefix:
<moduleId>/<key> e.g. 60/attenuation
<moduleId>/<key>/<channel> e.g. 60/channel_trim/2 (per-channel params)
system/<key> e.g. system/sample_rate (system params)Add your own prefix downstream (a change node, or the topic on your mqtt out).
Units and enums
- dB (
attenuation,channel_trim): you work in dB; the library converts to the device's internal tenths and clamps to the advertised range. - bool (
mute, many system flags):true/false. - enum (
roll_off_filter,out_max_level,sync_source,sample_rate, …): send either the label string or the integer — e.g.setting out_max_levelaccepts"+24 dBu"or1;sample_rateaccepts"48 kHz"or48000. Label matching is case-insensitive, and the editor lists the allowed labels for the selected parameter. - int / string / celsius / percent / seconds / datetime: system telemetry units.
The nodes
merging-config (Device)
The connection. Fields: Host (IP or hostname) and an optional CometD path
(default /cometd/handshake). One per device. Not placed in a flow — it's the shared
config the other nodes reference.
merging-set
Sets a parameter. Two modes:
By parameter (default) — choose from cascading dropdowns populated live from your device:
- Module — an audio module or the System pseudo-module.
- Parameter — the keys available on that module (system params are grouped by section).
- Channel — appears only for per-channel params like
channel_trim, as a 1-based dropdown bounded to the module's channel count.
The value to set comes from msg.payload (or msg.payload.value if you send an
object). For enum parameters the editor shows the accepted labels.
An incoming message can override the target, so one node can drive anything dynamically:
msg = { moduleId: 60, param: "attenuation", payload: -18 }; // audio
msg = { moduleId: 60, param: "channel_trim", channelIndex: 2, payload: -3 };
msg = { moduleId: "system", param: "sample_rate", payload: "48 kHz" }; // systemRaw mode — a different operation: publishes msg.path + msg.value straight to
the device (no unit conversion, no clamping), for parameters not yet modelled.
merging-set does not emit a confirmation. Wire a merging-state node to observe the device's echo (the change comes back through it like any other).
merging-state
The read side. It emits messages two ways:
On change (streaming). Whenever any in-scope parameter changes — from any source:
a physical knob, the web UI, the front panel, or your own merging-set — it emits a
message (<moduleId>/<key> or system/<key>). This is edge-driven; nothing is emitted
while values sit still.
On connect (snapshot). With the On connect option (default on), when the device
connects — or when you deploy the flow while it's already connected — the node emits the
current value of everything in its scope as a one-time snapshot. Each snapshot
message carries retained: true but is otherwise identical to a live change, so
downstream logic treats them uniformly (this is the MQTT "retained message" pattern).
Turn it off to emit on change only.
Scope. Filter by Module (a specific audio module, the System pseudo-module, or all) and Parameter (a specific key or all). System telemetry also flows under "all modules".
This is the reactive read node. Its only input action is msg.query = "refresh" (below).
For ad-hoc snapshots of state — the whole tree, a subtree, or the system/catalog as one
object — use the merging-inspect node instead.
Live telemetry (temperature, uptime, PTP lock/jitter)
As noted under How the device talks, system values only appear in
a whole-tree snapshot, and the streaming output only fires on a change. So to read one
live — a stable value like sample_rate, or read-only telemetry like temperature/uptime
— poll a refresh on an interval:
inject (Repeat: interval, e.g. every 5 s; msg.query = "refresh")
│
▼
merging-state (Module: System; Parameter: sample_rate / temperature / uptime — or "all")
│
▼
your dashboard / chart / mqtt outEach refresh re-reads the device and emits the current value(s) of whatever the node is
scoped to — changed or not — as system/<key> messages marked retained: true. One
polling inject refreshes the whole device, so other state nodes get fresh readings too.
(For a one-shot whole-snapshot object, use merging-inspect with Fresh on.)
merging-inspect
The imperative read node — for looking at device state on demand. Send it any message and it replies once with a snapshot (pull, not stream). Return picks the shape:
Raw subtree (by path) — a slice of the device's native state tree at Path (a dotted/
$path:$for the whole tree, or e.g.network.PTP.Status,identity,_modules). This is the only way to reach parts the model doesn't cover — RTPsessions/SDPs,network,NMOS,_connections, factory info.msg.topic= the path,msg.payload= the raw value.System (modeled object) — the system snapshot keyed by name (so
msg.payload.sample_rate.value), because system keys are unique:{ sample_rate: { value: "44.1 kHz", raw: 44100, unit: "Hz", group: "Clock", settable: true, readonly: false, note: null }, ptp_lock_status: { value: 3, raw: 3, unit: "int", group: "PTP", settable: false, readonly: true, note: null }, … }Catalog (modeled list) — the discovered audio parameters as an array (units, ranges, enum labels,
settableflags) — for enumerating what the device offers.
Fresh re-reads the device first (one round-trip) so the reply is current; otherwise it
returns the last-known state instantly. An incoming message can override msg.mode
(subtree/system/catalog), msg.path, and msg.fresh, so one node serves different
reads dynamically.
state vs inspect. merging-state is reactive — it pushes you scoped changes as they happen (and
refreshre-emits its scope). merging-inspect is imperative — you ask, it returns one snapshot. Use state to track/echo live values; use inspect to grab a chunk of state when you need it.
merging-device
Emits online / offline as a message once per reachability transition
(edge-triggered, de-duplicated), with retain: true and a reason. Topic is
configurable (default device). Wire it to MQTT for a "device present?" light, or to
trigger re-sync logic. It also re-emits the current state once on deploy so a fresh
consumer learns the truth.
The set and state nodes already show online/offline as their status dot in the editor; merging-device is only needed when you want that state as a message.
Message reference
merging-state — on change / on connect:
audio: topic = "<moduleId>/<key>" or "<moduleId>/<key>/<channel>"
payload = value (dB / bool / int)
+ moduleId, key, raw, unit (+ channelIndex for per-channel)
+ retained: true (on-connect snapshot only)
system: topic = "system/<key>"
payload = value (string label / number / bool)
+ system: true, key, raw, unit, group, readonly, settable
+ retained: true (on-connect snapshot only)merging-set — input:
payload = the value (or { value }) required
moduleId = audio module id, or "system" optional override
param = parameter key optional override
channelIndex = 0-based channel optional override (per-channel params)
# Raw mode: path + value sent verbatimmerging-inspect — one reply per input:
input: msg.mode = "subtree" | "system" | "catalog" (optional override of the node)
msg.path = dotted/$ path (subtree mode)
msg.fresh = true|false (re-read device first?)
reply: subtree → topic = <path> (or "tree"), payload = raw subtree
system → topic = "system", payload = object keyed by name
catalog → topic = "catalog", payload = array of entriesmerging-device — output:
topic = "device" (configurable)
payload = "online" | "offline"
retain = true
reason = string | nullExamples
Mirror device volume to MQTT, and set it from MQTT
mqtt in(your control topic) → merging-set (ModuleD/A 1, Parameterattenuation) — sets output gain in dB frommsg.payload.- merging-state (Module
D/A 1, Parameterattenuation) → change node (setmsg.topicto your path) →mqtt out— publishes the device's echo so your UI and any DSP fader track the real value, no matter who changed it.
Dashboard of clock + health
Scope a state node to System (leave On connect on) for clock/PTP settings on
connect and as they change. For read-only telemetry that drifts (temperature, uptime,
PTP lock/jitter), drive it with an interval inject sending msg.query = "refresh" — see
Live telemetry above.
Inspect device state on demand
inject(a button) → merging-inspect (Return: Raw subtree, Path:sessions, Fresh on) →debug— dump the live RTP sources/sinks and their SDPs (data the model doesn't expose). Swap Path fornetwork.PTP.Status,identity, or$for the whole tree.- For modeled state, set Return to System and read
msg.payload.sample_rate.value(etc.) in a function node; or Catalog to enumerate every settable audio parameter and its range.
"Device present?" indicator
- merging-device (topic
device) →mqtt out→ a Stream Deck button that lights when the unit is powered on.
Troubleshooting
- Editor shows "Loading device parameters…". When you open a set/state node, it fetches the device's parameter list and only reveals the Module/Parameter fields once it has them — so you never pick from a stale or half-loaded list. If the device is unreachable, the set node falls back to a built-in parameter list (so you can still configure offline); the state node waits until it can reach the device. Either way, Refresh re-pulls. The result is memoized for the editor session, so reopening is instant; the hardware's module/parameter set only changes if it's physically re-carded.
EBADENGINEwhen installing via the palette (newer Node). Node-RED's installer uses--engine-strict, and a transitive dependency may not yet declare your Node version. Cleanest fix: run Node-RED on Node 20/22. Quick unblock: install the tarball from a shell without engine-strict (npm install <tarball>).- A system write didn't take. Some system params have conditions — e.g. PTP
priorities only apply when
ptp_manual_masteris true (the editor notes this). - Audio glitched when I changed sample rate / frame size / clock source. Expected — those re-clock the device. Change them with audio stopped.
- I set a dB value and it didn't go as low/high as I asked. Values are clamped to the device's advertised range for that parameter.
Relationship to merging-ravenna
This package is a thin Node-RED layer over the
merging-ravenna library, which does
the real work: the resilient session, parameter discovery (the catalog), unit
conversion, the system-domain read/write model, and device-fingerprinting tools. If
you want the same control from plain Node.js (no Node-RED), use the library directly.
The package keywords include hapi, horus, and anubis so it stays discoverable by
device name, even though it's family-generic.
License
MIT
