Files
companion-module-digico-s31/docs/design-v2.md
T
michaelandClaude Opus 5.5 57645be1e9 Write on/off switches as float 0/1, as the S31 requires
Verified on a live S31 against an unused, silent input (restored after):
a float fader write is accepted and echoed, but a mute written as int 0/1
or as an OSC True/False is silently ignored, while a float 1.0/0.0 works.
DiGiCo's 2014 SD command list says int, which is wrong for the S31.

Every switch (mute, solo, send on, group assign, EQ/dynamics/filter/
insert/delay switches, phantom, phase, pad, GEQ in) now goes out as a
float. The mock console now ignores int and T/F switch writes the same
way the console does, so a regression to int fails 38 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:32:17 -04:00

219 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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). Every 0/1 switch is reported AND
written as a float 0.0 / 1.0, as are levels and pans. Verified on an S31 (2026-10-15): int and T/F writes to a
switch are silently ignored (no echo, no change), so the 2014 DiGiCo list (which says Int) is wrong for the S31.
Snapshot triggers and meter subscriptions keep integer arguments.
## 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.