Files
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

14 KiB
Raw Permalink Blame History

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 — 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 — 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.