v2: generic S-Series module with live discovery, dynamic options and meters

Rewrite of the module around what the console itself reports:

- Settings cut to console address/ports, the iPad relay and debug logging;
  nothing site-specific remains. An upgrade script carries v1 configs and
  remaps v1 actions/feedbacks whose ids were reused.
- On connect the module reads every name, buss type (aux/group), stereo
  mode, fader/mute/solo/pan and the snapshot list; entities that never
  answer are hidden. Definitions rebuild (debounced, skipped when
  identical) when names, buss types or snapshots change.
- Every entity option is a searchable dropdown of live names. 70 actions
  cover inputs, aux sends, group assigns, busses, matrix outputs and sends,
  control groups, graphic EQs, channel-strip switches, trim/gain/filters and
  snapshots, each level with set and encoder-friendly adjust variants.
- Boolean, value and bar feedbacks for every state, input level and
  gain-reduction meters via the console's meter subscription protocol, and
  about 2800 presets including rotary knob presets with live level text.
- A write guard keeps fast encoder turns from being undone by delayed
  echoes; feedbacks are re-checked per instance.
- Relay: the client table survives config changes, idle iPads are kept for
  an hour by default (0 = forever), bind failures retry and show in status.

npm test: 326 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:11:17 -04:00
co-authored by Claude Opus 5.5
parent 8693fe924a
commit dad8010239
20 changed files with 5102 additions and 1327 deletions
+165 -68
View File
@@ -1,96 +1,193 @@
# DiGiCo S31 (S-Series) — Studio Mon control + iPad relay
# DiGiCo S-Series (S21 / S31): control and iPad relay
This connection does two things:
1. **Controls which channels feed one buss** — by default the *Studio* buss — plus any aux send
level/on/pan, channel mute/fader, buss master fader/mute and snapshot recall, with feedbacks,
variables and ready-made presets.
2. **Relays the DiGiCo iPad app.** An S-Series console talks to exactly one iPad at a time.
Companion takes that slot; the iPad apps point at the Companion computer instead of the
console. Everything an app sends is forwarded to the console unchanged and every console
message is copied to every app seen recently, so several iPads can be used at once.
## What the console speaks (verified on "S31 AB", 2026-10-15)
The S31's **iPad** extension speaks the DiGiCo Pad / SD-style OSC tree over UDP:
| Address | Reply | Notes |
| --- | --- | --- |
| `/Console/Name/?` | `/Console/Name "S31 AB"` | used as keepalive |
| `/Console/Input_Channels/?`, `/Console/Aux_Outputs/?` | int counts (60, 25) | 25 busses, each in aux *or* group mode |
| `/Console/Aux_Outputs/modes/?` | 25 ints, 2 = stereo, 1 = mono | |
| `/Input_Channels/N/Channel_Input/name/?` | string | |
| `/Input_Channels/N/fader/?`, `/mute/?` | float dB (−150 = −∞), float 0/1 | |
| `/Input_Channels/N/Aux_Send/M/send_level` | float dB, −150 = off | write a float |
| `/Input_Channels/N/Aux_Send/M/send_on` | float 0/1 | write int 0/1 |
| `/Input_Channels/N/Aux_Send/M/send_pan` | float 0..1, 0.5 centre | |
| `/Input_Channels/N/Group_Send/M/group` | float 0/1 | group assign; write int 0/1 |
| `/Aux_Outputs/M/Buss_Trim/name/?`, `/Group_Outputs/M/Buss_Trim/name/?` | string | a buss is named on one side only |
| `/Aux_Outputs/M/fader`, `/mute` (and `/Group_Outputs/M/…`) | float | buss masters |
| `/Snapshots/Current_Snapshot/?`, `/Snapshots/names/?` | int; `/Snapshots/name [i, cue×100, 0, name]` | |
A query is the address plus `/?` with no argument; the reply is the same address without `/?`.
Every reply goes to the controller IP and **Send Port** configured on the console, never to the
asking socket, which is why a fixed local port is bound. The lowercase S-Series *OSC Control*
command list (`/channel/N/…`, `/console/resend`) is **not** answered on the iPad leg; it belongs
to the separate *OSC Control* extension and is available here as an alternative command set.
1. **Controls the console over OSC.** On connect it asks the console what it has (inputs, busses,
matrix outputs, control groups, graphic EQs, snapshots) and builds its dropdowns, variables and
presets from the live names. Faders, mutes, solos, pans, aux sends, group assigns, matrix sends,
control groups, graphic EQs, per-channel switches (phantom, EQ in, filters, inserts ...) and
snapshots are covered, with feedbacks, variables, rotary-encoder presets and input meters.
2. **Relays the DiGiCo iPad app.** An S-Series console talks to exactly one iPad at a time. Companion
takes that slot and the iPad apps connect to this computer instead; everything an app sends is
forwarded to the console unchanged, and everything the console sends is copied to every app, so
several iPads can be used together with Companion.
## Console setup (once, at the surface)
Main Menu → **Extensions → iPad**:
Main Menu, **Extensions > iPad**:
| Console field | Value |
| --- | --- |
| iPad IP address | the Companion computer (AB-ProPresenter: `10.3.11.152`) |
| Send port *(console → iPad)* | this connection's **Local receive port** (default `9000`) |
| Receive port *(iPad → console)* | this connection's **Console receive port** (default `8000`) |
| iPad connection enabled | yes |
| iPad IP address | the computer running Companion |
| Send port *(console to this computer)* | this connection's **Local receive port** (default `9000`) |
| Receive port *(this computer to the console)* | this connection's **Console receive port** (default `8000`) |
| iPad connection | enabled |
The console sends every reply and every change to that one IP address and Send port, never to the
port a query came from. That is why the connection binds a fixed local port.
## iPad app setup
In the DiGiCo S app connect page, for every iPad:
In the DiGiCo S app, for every iPad:
| iPad field | Value |
| --- | --- |
| Console IP | the Companion computer, **not** the console |
| Console IP | the computer running Companion, **not** the console |
| Send port | this connection's **Relay port** (default `8000`) |
| Receive port | this connection's **iPad receive port** (default `9000`) |
An iPad that goes quiet for *Forget an iPad after* seconds is dropped from the fan-out list.
`$(S31:relay_clients)` shows how many are live.
An iPad that stays quiet for *Forget an iPad after* seconds (0 = never) is dropped from the fan-out list, and at most
16 are kept. `$(label:relay_clients)` shows how many are live. Changing only the idle timeout, the iPad receive port or
Debug logging does not interrupt the relay or the console link; changing the relay port reopens the relay and keeps the
list of iPads. If the relay port is already in use the connection shows the warning "iPad relay: port N in use" and tries
again every 10 seconds (a console-link port that cannot be bound shows a connection failure and is retried the same way). Other controllers share the console through the relay,
and anything they change reaches this module too, so feedbacks follow moves made on the surface or on an iPad.
## Studio Mon buss
## Settings
Set **Studio Mon buss number** (1-25, the console's buss order). The module reads the buss
names and works out whether it is an **aux** (send level + on/off per channel) or a **group**
(assign on/off per channel); override with *Studio Mon buss type* if needed. On this console:
| Buss | Aux name | Group name |
| Setting | Default | Meaning |
| --- | --- | --- |
| 1-8 | VOCAL VERB, VOCAL SLAP, TRACKS, CLK-GD-MD, VOCAL DELAY, VOCALS, TB, MC/SpkrBcastOut | |
| 9-13 | | BAND, VOCALS, MISC, DRUMS, DRUM CRUSH |
| 14-17 | DRUM PLATE, AG VERB, FOH Spkr, Studio TB | |
| 18 | | Broadcast mix |
| 19-21 | FX, Prop & Spot, Drum Room Verb | |
| 23-25 | | CAFE, **Studio**, Master |
| Console IP | | The console's address |
| Console receive port | 8000 | UDP port the module sends to |
| Local receive port | 9000 | UDP port the module listens on (the console's Send port) |
| Enable iPad relay | on | Turns the relay on |
| Relay port | 8000 | Where iPad apps send |
| iPad receive port | 9000 | Where relayed console traffic goes (0 = the app's own source port) |
| Forget an iPad after | 3600 s | Idle time before an iPad is dropped (0 = never; an iPad that only watches sends nothing). At most 16 iPads are kept; the least recently seen is dropped first |
| Debug logging | off | Logs every new address pattern once and all traffic at debug level |
The default is buss **24 "Studio"** (stereo group). Presets under *Studio Mon* give one
on/off button per input (green when assigned/on, label = channel name); for an aux buss the
+1 dB / −1 dB nudge presets appear as well.
## What it discovers
On every connect (and on the **Reload** action, and automatically after the console was silent for
20 seconds and comes back) the module asks for the console name, the matrix, control group and graphic EQ
counts, the buss types and modes, every name, the session name, the snapshot list, and the fader, mute,
solo and pan of everything that answered. The console does not answer the input and buss counts, so an
entity exists when its **name** answered; entities that never answer are hidden from dropdowns, variables
and presets. Dropdown entries read `1 · KICK In`, `24 · Monitors (group)`, `3 · Message · cue 4`.
When a name, a buss mode or the snapshot list changes, the definitions are rebuilt a moment later.
Sends, group assigns, matrix sends, graphic EQ gains and the per-channel switches are **not** bulk-loaded:
each is read the first time a button or action needs it.
The 25 busses are each an **aux** or a **group**, according to `/Console/Aux_Outputs/types` (1 = aux,
0 = group). Aux busses take sends (level, on, pan); group busses take input assigns. A buss's
fader, mute and solo are on the Aux_Outputs or Group_Outputs side that matches its mode.
## Actions
Level actions take dB (`-inf` at the bottom of the number field). Every level has a **set** and a
relative **adjust** version for rotary encoders; adjusting from -inf lands at -60 dB on the first step up.
Toggles read the current state first when it is not known yet.
| Group | Actions |
| --- | --- |
| Inputs | mute, solo (several inputs at once), fader set/adjust, pan set/adjust, set name |
| Aux sends | on/off, level set/adjust, pan set/adjust |
| Groups | assign input on/off |
| Busses | mute, solo (several), fader set/adjust |
| Matrix | output mute, fader set/adjust; matrix send on/off, level set/adjust |
| Control groups | mute, solo, fader set/adjust |
| Graphic EQ | in/out, band gain set/adjust (bands 1-32) |
| Channel strip | per input and per buss: phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in (on/off); trim (-40 to +40 dB), analog gain (0 to 60 dB), filter frequencies (20 to 20000 Hz) (set/adjust; trim also on matrix outputs) |
| Snapshots | fire (pick from the list or type an index), next, previous |
| Console | reload, raw OSC, relay: forget all iPads |
The channel-strip values are what the console reports (trim and gain in dB, filters in Hz). In the console's
own naming `lo_filter` is the high-pass filter and `hi_filter` the low-pass filter; the actions are named for
what they do. Delay times and the send pre/post fader switch are not offered: their units and meaning were
never verified.
**Encoders and writes.** Every write updates the module's state at once, so a fast encoder turn accumulates. For
0.4 seconds after the last write to a value, a different value arriving for it is treated as a late echo of an
earlier step and ignored; then one query confirms what the console has. The snapshot *Fire* action sends the
snapshot's list position (0 = the first); verify it once on your console before using it live.
**Macros:** the console never answered a macro query, so there is no macro action. If your console supports it, the
*Send raw OSC* action can send `/Macros/Buttons/press` with an integer argument (0-255).
## Feedbacks
* Boolean (button colour): input muted / solo, aux send on, group assigned, buss muted /
solo, matrix muted, control group muted, snapshot is current, fader level above/below a threshold, send
level above/below, console link alive, relay has an iPad, every channel-strip switch above, and the meter
feedbacks below.
* Value (use with a preset's local variable to put live numbers in button text): input fader, input pan,
send level, send On/Off, buss / matrix / control group fader, current snapshot name, input meter value.
* Advanced bars: input fader, aux send level and buss fader draw a horizontal bar along the bottom of the
button (-60 to +10 dB); the input meter draws a vertical bar at the right edge (green, amber, red).
## Variables
`$(S31:ch_N_name)`, `$(S31:aux_N_name)`, `$(S31:group_N_name)`, `$(S31:buss_N_mode)`,
`$(S31:mon_N_on)`, `$(S31:mon_N_level)`, `$(S31:mon_name)`, `$(S31:mon_mode)`,
`$(S31:snapshot_name)`, `$(S31:connected)`, `$(S31:relay_clients)`, `$(S31:last_send_touched)`,
`$(S31:last_rx)`.
`input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`,
`cg_N_name|fader|mute` (only for entities that exist), `snapshot_index|name|cue|count`, `console_name`,
`session_name`, `connected`, `relay_clients`, `relay_client_ips` and `last_change`. Use them as
`$(label:input_1_name)` with the connection's label. There are no per-send variables: use the value feedbacks.
## Presets
Presets are named with the live entity names and rebuilt when names load.
| Preset group | What the buttons do |
| --- | --- |
| Inputs: Mute | One button per input; red when muted |
| Inputs: Fader (knob) | Rotary: turn = fader -/+1 dB, press = mute; shows name and dB, with a level bar |
| Inputs: Solo | One button per input |
| Busses | Master mute, and master fader knob (turn = fader, press = mute) per named buss |
| Sends to *aux name* | One section per named aux buss: per input an **On/off** button (green when on, shows the level) and a **Level (knob)** (turn = send level, press = on/off, with a bar) |
| Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned |
| Matrix, Control groups | Mute and fader knob per output / group |
| Snapshots | **Fire** per snapshot (green when current), next, previous, current (display) |
| Input meters | Level, gate gain reduction and compressor gain reduction buttons per input |
| Console | Reload, connection status, relay status |
Busses and control groups with no name are unused slots and get no presets. If the preset list would exceed
3000 entries or 4 MB, the per-input send level knobs are left out and a warning is logged.
## Meters
Input meters are subscribed on the console the first time a meter feedback renders (ids start at 5000 so
they never collide with an iPad's). The console then streams values 0-127 several times a second; the
module re-checks the meter feedbacks at most 10 times a second. Level is the post-fader input meter; gate and
compressor gain reduction are the two reduction meters. There is no known buss meter address, so busses
have no meters. The console keeps one subscription table: an iPad connecting clears it (`/Meters/clear`),
and the module re-subscribes half a second after it sees that pass through the relay. Two iPads watching
different banks both receive the mixed stream. The module never sends `/Meters/clear` itself.
## Troubleshooting
- **No reply from console**: the console only sends to the iPad IP + Send Port set on the
console. Check those, and that nothing else on this computer owns the local receive port
(on AB-ProPresenter UDP 8002 belongs to Dante Via; 8000 and 9000 are free).
- **Learn mode** logs every new address pattern once (Connections → log). Touch a control on
the surface and the matching address appears; `$(S31:last_send_touched)` shows the last
send/assign that changed.
- Press **Reload from console** after a session change to refresh names and levels.
* **No reply from the console.** The console only answers to the iPad IP address and Send port set on the
console. Check them, and that nothing else on this computer owns the local receive port.
* **Dropdowns show generic names.** The load has not finished or the console did not answer the names;
press **Reload**. The log shows `load complete` with the counts when it is done.
* **Something changed on the console is not shown.** Press Reload after a session change; sends and
per-channel values are only read when first needed.
* **Debug logging** lists every new address pattern once (Connections log), so touching a control on the
surface shows its address; `$(label:last_change)` shows the last change reported.
## Protocol (condensed)
A query is the address plus `/?` with no argument; the reply is the same address without `/?`. All 0/1
switches are **reported as floats** but written as integers 0/1; levels, sends and pans are floats. Numbers
are 1-based, snapshot indexes 0-based.
| Address | Reply | Notes |
| --- | --- | --- |
| `/Console/Name` | s | also the keepalive (every 5 s) |
| `/Console/{Matrix_Outputs,Matrix_Inputs,Control_Groups,Graphic_EQ}` | i | counts that answer |
| `/Console/{Input_Channels,Aux_Outputs,Group_Outputs}` | i | pushed once after first contact, never answered |
| `/Console/Aux_Outputs/types` | 25 x i | 1 = aux, 0 = group |
| `/Console/{Aux_Outputs,Group_Outputs,Input_Channels}/modes` | i per buss / input | 2 = stereo, 1 = mono |
| `/Console/Session/Filename` | s | |
| `/Input_Channels/N/Channel_Input/name`, `/Aux_Outputs/N/Buss_Trim/name`, `/Group_Outputs/N/Buss_Trim/name`, `/Matrix_Outputs/N/Buss_Trim/name`, `/Control_Groups/N/name`, `/Graphic_EQ/N/name` | s | matrix inputs have no name address |
| `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/{fader,mute,solo}` | f | fader in dB, -150 = -inf |
| `/Input_Channels/N/Panner/pan` | f | 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}` | f | |
| `/Input_Channels/N/Group_Send/M/group` | f | |
| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}` | f | |
| `/Graphic_EQ/N/{geq_in,geq_gain_1..32}` | f | |
| `/Input_Channels/N/{Channel_Input,Filters,Channel_Delay,EQ,Dynamics,Insert}/...` and the buss equivalents | f | the channel-strip switches and values above |
| `/Snapshots/Current_Snapshot`, `/Snapshots/count` | i | |
| `/Snapshots/names` | one `/Snapshots/name` `iiis` per snapshot: index, cue x 100, ?, name | |
| `/Snapshots/Fire_Snapshot_number`, `/Snapshots/Fire_Next_Snapshot`, `/Snapshots/Fire_Prev_Snapshot` | none | write-only int |
| `/Input_Channels/N/Channel_Input/post_meter`, `/Input_Channels/N/Dynamics/GR_meter` | none | send `,ii [id, kind]` (kind 1 level and gate, 4 compressor) to subscribe |
| `/Meters/values` | `ii...` | pushed pairs `[id, value]`, value 0..127 |
| `/Meters/clear` | none | clears every subscription on the console; sent by iPad apps, never by this module |