dragon-iso/docs/CONTROL-SURFACE.md
Zac Gaetano 6d9407a61f
Some checks failed
CI / build-and-test (push) Failing after 27s
Add LAN-reachable mode to control surface and OSC bridge
When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC.

Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
2026-05-10 10:01:32 -04:00

9.6 KiB

TeamsISO Control Surface — REST API

TeamsISO can expose a localhost HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows, command-line scripts) can drive it without a UI binding.

Enabling

  1. Open TeamsISO → Settings → DISPLAY tab.
  2. Tick "Control surface (Stream Deck / Companion)".
  3. Default port is 9755; change it via the port textbox if needed.
  4. By default the server binds to 127.0.0.1 only — it is NOT reachable from the LAN.
  5. To allow other machines on the same network to drive TeamsISO (the "headless host PC + thin client" scenario), tick the nested "LAN-reachable" checkbox underneath. The settings panel will display the LAN URL (e.g. http://192.168.1.42:9755/ui) with a Copy button.

When enabled, the toast confirms Control surface listening on http://127.0.0.1:9755/ (or the all-interfaces equivalent in LAN mode).

One-time setup for LAN-reachable mode

Windows requires elevated permission to bind a non-loopback HTTP listener. If you turn on LAN-reachable mode and don't see a connection from another machine, run this once in an Administrator PowerShell (replace 9755 if you've changed the port):

netsh http add urlacl url=http://+:9755/ user=Everyone

Also confirm the Windows Firewall is letting inbound traffic to that port through — New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow in an elevated PowerShell, or add it through Windows Defender Firewall → Advanced Settings → Inbound Rules.

Authentication

None — by design. In localhost-only mode, the loopback bind is the security model: any process on the operator's machine can hit these endpoints, the same threat model as a Stream Deck's USB connection.

In LAN-reachable mode, the assumption is a closed/trusted network (a production-control LAN, a dedicated show subnet, a private vlan). Any machine that can route to the host on the listener port can drive TeamsISO. Do not enable LAN-reachable mode on an untrusted network.

Response shape

All responses are application/json with Access-Control-Allow-Origin: * so a browser-based control panel served from another origin can call the endpoints. Most successful responses include "ok": true plus operation- specific fields. Errors return HTTP 4xx/5xx with {"error": "..."}.

Endpoints

GET /ui

Self-contained HTML control panel. Open this in a browser to drive TeamsISO from a phone, tablet, or second monitor. Lists participants live via the same /ws WebSocket the rest of the doc describes, and posts to the REST endpoints when you click. Single page, no external dependencies, loads in <50KB.

GET /

Returns server info and an endpoint summary. Useful for "is the surface alive?" probes.

{
  "product": "TeamsISO",
  "version": "1.0.0.0",
  "endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
}

GET /participants

Snapshot of the current participant list as the UI sees it.

{
  "participants": [
    {
      "id": "1c3e2a8b-...-...",
      "displayName": "Jane",
      "isOnline": true,
      "isEnabled": false,
      "customName": null,
      "stateLabel": "—"
    }
  ]
}

POST /participants/{id}/iso

Enable or disable an ISO by participant Id. Body or query string:

{ "enabled": true, "customName": "Host" }

enabled is optional — omitting it toggles the current state. customName is optional and overrides the auto-generated NDI output name.

curl -X POST 'http://127.0.0.1:9755/participants/1c3e2a8b-.../iso?enabled=true&customName=Host'

POST /participants/iso

Same as above but resolves by display name instead of Id. The Id varies across meetings; the display name is the operator-stable identifier.

{ "displayName": "Jane", "enabled": true }

POST /presets/{name}/apply

Apply a saved preset to the live participant list. Walks every participant in the meeting, matches by display name, sets the custom output name, and reconciles each enable/disable via the engine. Same code path as the Presets dialog and the auto-apply-on-launch flow (PresetApplier.ApplyAsync).

{
  "ok": true,
  "name": "Friday Show",
  "matched": 4,
  "changed": 2,
  "skipped": 1
}

matched is how many participants in the preset were live in the meeting; changed is how many actually flipped state; skipped is preset entries with no live counterpart.

POST /presets/refresh-discovery

Force NDI discovery to rebuild its finder. Useful after Apply Transcoder Topology or when Teams restarts mid-show. Returns immediately; the rebuild happens on the next poll tick.

curl -X POST http://127.0.0.1:9755/presets/refresh-discovery

POST /presets/stop-all

Disable every running ISO. Equivalent to clicking "Stop all ISOs" in the header. Returns the count that were running.

POST /teams/mute / /camera / /share / /leave / /raise-hand

Drive the corresponding Microsoft Teams in-call control via UIAutomation. Returns one of Invoked / TeamsNotRunning / ControlNotFound / InvokeFailed in the result field.

curl -X POST http://127.0.0.1:9755/teams/mute

POST /recording

Toggle per-output recording on or off. Body or query string:

{ "enabled": true, "directory": "D:/recordings/show-2026-05-09" }

directory is optional when enabled=false. Already-running ISOs are not retroactively recorded — the operator should disable + re-enable a participant to start recording it.

POST /recording/marker

Drop a timestamped marker into every active recording. Body or query string optionally carries a label; if omitted, the label defaults to Marker @ HH:mm:ss. Markers land in each recording's manifest.json under the markers[] array as { "offsetMs": 12345.6, "label": "Guest answer" }.

curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'

POST /notes

Append a timestamped line to today's show-notes file at %LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md. Body or query string carries text. Each line is prefixed with **HH:mm:ss** —; the file is markdown so it renders nicely in any editor.

curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'

POST /recording/roll

Roll every active recording into a new chunk. Each running pipeline is disabled (recorder finalizes its manifest.json), waits ~150ms, then re- enabled (recorder opens a fresh subdirectory keyed by display name + timestamp). Useful for chaptering between show segments — a Stream Deck button mapped to this gives operators "next segment" without losing the already-recorded footage.

curl -X POST http://127.0.0.1:9755/recording/roll

Response:

{ "ok": true, "action": "roll-recording", "rolled": 4 }

WebSocket — live state push

For controllers that want to light a button when an ISO goes LIVE without polling, connect to:

ws://127.0.0.1:9755/ws

On connect, the server sends a participants snapshot. Whenever the snapshot changes (participant joins/leaves, ISO toggled, custom name edited), a fresh snapshot is pushed within 250ms. Format:

{
  "type": "participants",
  "participants": [
    { "id": "...", "displayName": "Jane", "isOnline": true,
      "isEnabled": true, "customName": "Host", "stateLabel": "LIVE" }
  ]
}

Client→server messages are ignored for v1 — all commands go through REST.

OSC over UDP

Same command surface, different transport. Enable the OSC bridge in the DISPLAY tab (default port 9000 — TouchOSC's default). Bound to 127.0.0.1 by default; honors the same LAN-reachable toggle as the REST surface — when LAN mode is on, OSC binds to 0.0.0.0 so a TouchOSC tablet on the same network can talk to the host directly.

Address vocabulary:

/teamsiso/iso "DisplayName" {0|1}        — toggle/set ISO by display name
/teamsiso/iso/by-id "guid" {0|1}         — toggle/set by Id
/teamsiso/preset "Name"                  — apply preset
/teamsiso/teams/mute                     — UIA toggle mute
/teamsiso/teams/camera                   — UIA toggle camera
/teamsiso/teams/leave                    — UIA leave
/teamsiso/teams/share                    — UIA share tray
/teamsiso/teams/raise-hand               — UIA raise hand
/teamsiso/refresh-discovery              — rebuild NDI finder
/teamsiso/stop-all                       — disable every ISO
/teamsiso/recording {0|1}                — recording on/off (default dir)
/teamsiso/recording/marker "Label"       — drop a marker on every active recording
/teamsiso/recording/roll                 — roll every active recording into a new chunk
/teamsiso/notes "Free-form note"         — append a timestamped line to today's notes

Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button press to e.g. /teamsiso/iso "Jane" 1. TouchOSC layouts can use the same addresses on the same UDP port.

Bitfocus Companion recipe

Companion ships a generic HTTP module. Configure a button:

  • Action: HTTP: HTTP POST request
  • URL: http://127.0.0.1:9755/teams/mute
  • Body type: None

Or for a participant-specific toggle:

  • URL: http://127.0.0.1:9755/participants/iso?displayName=Jane&enabled=true

Stream Deck XL recipe (without Companion)

Use the "Web Requests" plugin (or any equivalent). Set the action to a POST on the appropriate endpoint above.

Future work

  • HTTPS / token auth — for deployments that don't have a closed network, layer TLS termination + a shared bearer token in front of the HttpListener. Out of scope for v1; the LAN-reachable mode is a trusted-network feature only.