Companion module for the DiGiCo S31 with an iPad relay (v1)

Bitfocus Companion 5 connection module (ESM, @companion-module/base ~2.1.3,
runtime node22) for the DiGiCo S21/S31. It occupies the console's single
iPad slot, controls per-channel sends/assigns to one buss, aux send
level/on/pan, mutes, faders and snapshots, and relays any number of DiGiCo
iPad apps through to the console byte-for-byte.

The S31's iPad extension was probed live: it speaks the SD/DiGiCo Pad OSC
tree (console receives on 8000, replies only to the configured iPad port
9000). tools/mock-s31.js emulates the console and test/selftest.js runs the
link + relay end to end against it. docs/design-v2.md is the brief for the
generic v2 rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:55:18 -04:00
co-authored by Claude Fable 5.1
commit 131cdfd81f
23 changed files with 10066 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# companion-module-digico-s31 — v2 design (generic S-Series module)
Status: decided 2026-10-15. This is the brief for the v2 implementation. Where this document and
`tools/catalog/s31-catalog.md` (what the real console answers) disagree, the catalog wins and the
design note is updated.
## Goals
1. Generic: nothing church-specific in settings, names, help or defaults. No "Studio Mon".
2. Minimal settings: console address/ports, relay, debug. Everything else is discovered.
3. Rich, discoverable control: every option that identifies a channel/buss/matrix/CG/snapshot is a
**dropdown populated with the live names** from the console, never a bare integer field.
4. Encoder-friendly: relative "adjust" actions for every level, and presets that use rotary steps.
5. Feedback everywhere: boolean feedbacks for every switch, dynamic-text and bar feedbacks for
every level, and presets wired with them.
## Settings (connection config) — the complete list
| id | type | default | label |
| --- | --- | --- | --- |
| `host` | textinput (Regex.IP) | `` | Console IP |
| `consolePort` | number | 8000 | Console receive port (module sends here) |
| `localPort` | number | 9000 | Local receive port (console sends here; = console "Send port") |
| `relayEnable` | checkbox | true | Enable iPad relay |
| `relayPort` | number | 8000 | Relay port (iPad apps send here) |
| `relayClientPort` | number | 9000 | iPad receive port (0 = reply to the app's source port) |
| `relayTimeout` | number | 180 | Forget an iPad after this many idle seconds |
| `debugLog` | checkbox | false | Debug logging (log every new address pattern + all traffic at debug level) |
Two `static-text` blocks explain the console-side iPad extension settings and the iPad-app settings
(generic wording: "this computer", no IPs). Nothing else. Keepalive is always on. Dialect is fixed to
the DiGiCo Pad / SD tree (the only one the iPad extension speaks). On/off values are written as
int 0/1, levels and pans as floats (DiGiCo SD OSC list).
## Topology discovery
Maxima probed (S31 limits; S21 simply answers fewer): inputs 60, busses 25 (`Aux_Outputs` and
`Group_Outputs` are the same 25 busses, named on the side matching the buss mode), matrix outputs
8, matrix inputs 10, control groups 10, graphic EQs 16. On connect the module queries the counts
(`/Console/Input_Channels/?` etc.; the S31 answers these only sometimes) and the **name** of every
index; an entity whose name query never answered within the load is treated as absent and hidden
from dropdowns, variables and presets. Buss mode: `group` when the Group side name is non-empty
and the Aux side is empty, otherwise `aux`. Stereo/mono from `/Console/Aux_Outputs/modes`.
Use exactly the addresses the catalog marks as answered. Never send an address the catalog does
not list as answered (except `raw_osc`).
## State store (`src/state.js`)
Maps keyed by entity index: `inputs[n] = {name, fader, mute, solo, pan}`, `busses[n] = {name,
mode, stereo, fader, mute, solo}`, `matrix[n]`, `cgs[n]`, `geqs[n]`; `sends["ch:buss"] = {level,
on, pan, prepost}`; `groups["ch:buss"] = bool`; `matrixSends["in:out"]`; `snapshots = {current,
list: Map(index -> {name, cue})}`; `macros`; `console = {name}`. One `applyMessage(address, args)`
entry point returning the normalised event; one `choices` section that builds dropdown choice arrays:
- inputs: `{ id: n, label: "n · Name" }` (name falls back to `Input n`)
- busses: `"n · Name (aux)"` / `"n · Name (group)"`; filtered variants `auxChoices`, `groupChoices`
- matrix / cgs / geqs: `"n · Name"`
- snapshots: `"index · Name"` (+ ` · cue X` when a cue number exists)
Definitions are rebuilt (actions, feedbacks, variables, presets) whenever a name, mode or the
snapshot list changes, debounced ~750 ms, and once when the initial load finishes. Option ids stay
numeric so existing buttons survive renames.
## Actions
Option conventions: `channel` = inputs dropdown; `channels` = inputs multidropdown (for bulk
mute/solo); `buss` = busses dropdown (aux-only or group-only where it matters); `state` = dropdown
on/off/toggle; `level` = number dB (−150..10, step 0.5); `delta` = number dB (−30..30, default 1,
for encoders); `pan` = number 0..1 (0.5 centre), `pdelta` = number.
| id | options | notes |
| --- | --- | --- |
| `input_mute` | channels, state | |
| `input_solo` | channels, state | if solo answered in the catalog |
| `input_fader_set` | channel, level | |
| `input_fader_adjust` | channel, delta | clamps; from −∞ the first step lands at −60 dB |
| `input_pan_set` / `input_pan_adjust` | channel, pan / pdelta | |
| `input_name_set` | channel, name (textinput, useVariables) | |
| `send_on` | channel, buss(aux), state | |
| `send_level_set` / `send_level_adjust` | channel, buss(aux), level / delta | |
| `send_pan_set` / `send_pan_adjust` | channel, buss(aux), pan / pdelta | |
| `send_prepost` | channel, buss(aux), mode pre/post/toggle | if answered |
| `group_assign` | channel, buss(group), state | |
| `buss_mute` / `buss_solo` | busses (multi), state | |
| `buss_fader_set` / `buss_fader_adjust` | buss, level / delta | |
| `matrix_mute` / `matrix_fader_set` / `matrix_fader_adjust` | matrix, … | |
| `matrix_send_on` / `matrix_send_level_set` / `matrix_send_level_adjust` | matrixIn, matrixOut, … | only if the catalog shows Matrix_Send answering |
| `cg_mute` / `cg_solo` / `cg_fader_set` / `cg_fader_adjust` | cg, … | |
| `geq_band_set` / `geq_band_adjust` / `geq_on` | geq, band, gain | only if answered |
| `snapshot_fire` | snapshot (dropdown, allowCustom for a raw number) | |
| `snapshot_next` / `snapshot_prev` | — | |
| `macro_press` | macro | only if macros answer |
| `reload` | — | re-query everything |
| `raw_osc` | address, argType, value | experiments |
| `relay_forget` | — | |
Toggle semantics need the current value: if unknown, query it first and apply the toggle when the
reply arrives (or default to "on"). Every write is followed by a query of the same address so the
state is confirmed even if the console does not echo.
## Feedbacks
Boolean (with sensible `defaultStyle`): `input_muted`, `input_solo`, `send_on`, `group_assigned`,
`send_pre` (pre-fade), `buss_muted`, `buss_solo`, `matrix_muted`, `cg_muted`, `snapshot_current`
(snapshot dropdown), `level_compare` (entity kind dropdown input/buss/matrix/cg + index + above/below
+ threshold), `send_level_compare`, `connected`, `relay_clients`.
Value feedbacks (`type: 'value'`, return a JsonValue) feed preset **local variables** so button
text can show live numbers without thousands of module variables: `input_fader_value` (dB string,
`-inf` at the floor), `input_pan_value`, `send_level_value`, `send_on_value` (`On`/`Off`),
`buss_fader_value`, `matrix_fader_value`, `cg_fader_value`, `snapshot_name_value`,
`input_meter_value`. Presets declare `localVariables: [{ variableName: 'level', variableType:
'feedback', feedbackId: 'send_level_value', options: {…} }]` and use `$(local:level)` in the text.
Advanced (bar): `input_fader_bar`, `send_level_bar`, `buss_fader_bar` — returns an `imageBuffer`
(base64 RGBA, `imageBufferEncoding: { pixelFormat: 'RGBA' }`, `imageBufferPosition` sized from
`event.image`) drawing a horizontal bar along the bottom edge of the button proportional to the
level (−60..+10 mapped to 0..100 %), colour option. Implemented with a small RGBA helper in
`src/image.js`. Verify bytes-per-pixel on the real Companion (4 for RGBA).
Base 2.1 has **no feedback `subscribe`**. Send-pair state is loaded on first use instead: when a
feedback or action callback finds the pair's state unknown, enqueue the queries for that pair
(de-duplicated per pair) and return the unknown state; the console's reply updates state and
`checkFeedbacks` re-renders. Sends are never bulk-loaded (60 × 25 × 3 queries would take a minute).
Number options for levels use `showMinAsNegativeInfinity: true`; every entity dropdown sets
`minChoicesForSearch: 0` so lists are searchable. Visibility of conditional options uses
`isVisibleExpression` strings (the only form in base 2.1).
## Variables
Only for entities that exist: `input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`,
`matrix_N_name|fader|mute`, `cg_N_name|fader|mute`, `snapshot_index|name|cue|count`,
`console_name`, `connected`, `relay_clients`, `relay_client_ips`, `last_change`. No per-send
variables (use the advanced text feedbacks).
## Presets (type `simple`; names carry the live entity name; rebuilt when names load)
Structure groups and their definitions:
1. **Inputs** — per input: *Mute* (toggle; feedback muted = red; text `$(label:input_N_name)`),
*Fader (knob)* (rotary: left/right = `input_fader_adjust` ∓1 dB; press = `input_mute` toggle;
feedbacks: `input_text` with `{name}\n{fader}`, `input_fader_bar`, muted = red), *Solo*.
2. **Busses** — per buss: *Master mute*, *Master fader (knob)* (rotary ±1 dB, press = mute toggle,
text + bar).
3. **Sends to <aux name>** — one group per aux buss, per input: *On/off* (toggle; green when on;
`send_text` `{name}\n{level}`), *Level (knob)* (rotary ±1 dB, press toggles on/off; text + bar).
4. **Group <name>** — one group per group buss, per input: *Assign* (toggle, green when assigned).
5. **Matrix** — per output: mute, fader knob. **Control groups** — per CG: mute, fader knob.
6. **Snapshots** — per snapshot: *Fire* (green when current, text = name); *Next*, *Previous*,
*Current* (display only, `snapshot_text`).
7. **Console** — Reload, Connection status, Relay status (count).
Rotary presets set `options.rotaryActions: true` and use the `rotate_left` / `rotate_right` step
arrays (verify exact field names in `@companion-module/base` 2.1.3 types).
## Loading, keepalive, relay
As in v1: fixed local port; keepalive `/Console/Name/?` every 5 s; 20 s silence → Disconnected;
reconnect → full reload; paced query queue (20 ms); learn log of new address patterns when debug
is on; verbatim relay with fan-out and idle timeout. The console pushes changes made on the surface
unsolicited, so after the initial load the state stays current without polling.
## Protocol facts learned from a live iPad session (2026-10-15)
Addresses the DiGiCo S app itself uses on this console (all verified answering):
- `/Console/Aux_Outputs/types/?` → 25 ints, **1 = aux, 0 = group**: the authoritative buss-mode
source (use it; fall back to the name heuristic only if it does not answer).
`/Console/Input_Channels/modes/?` → 60 ints (stereo modes per input), `/Console/Group_Outputs/modes/?`.
- `/Console/Session/Filename/?` → session file name (expose as `session_name`).
- `/Input_Channels/N/Panner/pan` (float 0..1) is the pan address (not `/Input_Channels/N/pan`).
- `/Input_Channels/N/solo` (float 0/1); `/Input_Channels/N/CGs_level`, `/CGs_mute` (control-group
contribution, read-only display); `/Input_Channels/N/Channel_Input/main_alt_in`, `stereo_mode`,
`analog_gain`, `trim`; `/Input_Channels/N/total_gain` (read-only, pushed with fader moves).
- `/Control_Groups/N/fader|mute|solo` — the iPad queried `Control_Groups/0`, so CG indexing may be
**0-based**; the catalog decides (probe both 0 and 1).
- `/Group_Outputs/N/fader|mute|solo`, `/Aux_Outputs/N/fader|mute`, `/Matrix_Outputs/N/fader`.
- `/Snapshots/name/? [i]` (int arg) returns one snapshot's name; `/Snapshots/Surface_Snapshot`.
- `/Layout/Layout/Banks` (surface bank layout; read-only, ignore).
- The console pushes every change made at the surface unsolicited (fader, mute, solo, sends, EQ…).
## Metering (captured from a live iPad session through the relay, 2026-10-15)
The console streams meters only to subscribed ids, using the same iPad leg:
- Subscribe: send `/<Entity>/N/<meter point> ,ii [meterId, kind]` where the **client chooses
`meterId`** (int) and `kind` selects the meter: seen `/Input_Channels/N/Channel_Input/post_meter
[id, 1]` (post-fader input level) and `/Input_Channels/N/Dynamics/GR_meter [id, 1]` plus
`[id2, 4]` (two gain-reduction meters per channel, presumably gate and compressor). The iPad uses
ids 0-19 (its visible bank), 200+, and 300+.
- Stream: `/Meters/values ,ii… [id, value, id, value, …]` — only ids whose value changed, several
times a second; values are integers 0..127 (127 seen as full scale / peak).
- `/Meters/clear` (no args) clears every subscription on the console, including ours. The iPad
sends it on connect. When the relay sees an iPad send `/Meters/clear`, the module must re-send its
own subscriptions after ~500 ms. Never send `/Meters/clear` from the module.
- The module uses ids from **5000** upward (5000 + input index for post meters; 6000 + index for
buss output meters if `/Aux_Outputs/N/.../post_meter`-style addresses answer — the builder probes
the mock only; the real address for buss meters is unknown and must be learned from the iPad log)
so they never collide with an iPad's ids. Subscriptions are made lazily: the first time a meter
feedback for an entity runs, subscribe; re-subscribe after reconnect and after `/Meters/clear`.
- Exposed as: value feedback `input_meter_value` (0-127 and percent), boolean `input_signal`
(value above a threshold option, default 5) and `input_peak` (≥ 127, with a hold time option),
advanced `input_meter_bar` (vertical bar at the right edge, green/amber/red zones), and the same
trio for busses once their address is known. Meter updates must NOT trigger definition rebuilds
or variable updates for all channels; only `checkFeedbacks` for the meter feedback ids, throttled
to at most ~10 Hz.
Known relay limitation: the console holds one meter subscription table; two iPads subscribing the
same ids for different banks will both receive mixed data. Document it; nothing to fix in v2.
## Out of scope for v2
Per-channel EQ/dynamics/filters (available via `raw_osc`), S-Series OSC Control dialect
(removed), snapshot editing.