Listens to tally on a Blackmagic ATEM switcher and drives the tally lamps on Canon XC-protocol cameras (CR-N700, EOS C400, C70, C80, C500 Mk II, C300 Mk III, XF605, CR-N500/N300/N100, CR-X300).
No SDK install, no native modules, no runtime dependencies — just Node 18+.
ATEM Constellation 4K ──UDP/9910──▶ bridge ──HTTP control.cgi──▶ 8× Canon cameras
(tally) (tally lamps)
npm install && npm run buildcp config.example.json config.jsonEdit config.json with your switcher and camera addresses, then check that
every camera answers before you rely on it:
npm run checknpm startOpen http://127.0.0.1:8080 for a live dashboard.
Both scripts read ./config.json. To use a different file, call the CLI
directly — it lives at dist/src/cli.js:
node dist/src/cli.js --config /path/to/other.jsonThe bridge speaks the ATEM's native UDP protocol directly. It completes the
session handshake, acknowledges what the switcher sends, and decodes three
commands: TlSr (tally by source), PrgI/PrvI (program/preview bus per M/E),
and InPr (input names, for the dashboard).
It is receive-only. The only thing it ever transmits is a protocol acknowledgement — there is no code path that can change what is on air.
Two tally sources are available, set by atem.tallySource:
| Mode | Behaviour |
|---|---|
switcher (default) |
The ATEM's own resolved tally, the same signal that drives its hardware tally outputs. A camera that is live inside an upstream/downstream key, a DVE box, SuperSource, or an M/E re-entry lights up correctly. |
me |
Watches one M/E's program and preview buses only (atem.mixEffect, 1-based). Simpler, but a camera that is live only through a keyer stays dark. |
switcher is the default because a camera operator with a dark tally who is
actually on air is the failure mode that matters. Switch to me if you
specifically want per-M/E tally.
Tally is set with a single sessionless request:
GET /-wvhttp-01-/control.cgi?f.tally.mode=program&f.tally=on
Per Canon's XC spec, control.cgi is mandatory on every XC-protocol camera,
needs no session, and needs no camera-control privileges — so the bridge never
holds a session that could be pre-empted by an operator's PTZ controller, and
nothing it does interferes with PTZ, focus, or menu control.
A few details that matter in practice:
- Capabilities are detected, not hard-coded. At startup each camera is asked
for
f.tally.mode.listviainfo.cgi. The CR-N series reportspreview,program; cinema bodies also reportpreview_program. New models work without a code change. - Program beats preview. When a source is on both buses, cameras that can
show both do (
combinedTally: "auto"); the rest show program red. - Mode is set before the lamp is switched on, so the lamp never briefly flashes the previous colour.
- Requests coalesce. Rapid takes never queue up stale commands — only the newest state is ever sent, with Canon's recommended ≥30 ms spacing per camera.
- Connections are kept alive, so a tally change costs one round trip.
- Digest auth is negotiated automatically on the first 401 and then pre-applied to every later request.
- Reconcile loop. Every
reconcileIntervalMs(default 5 s) the desired state is re-asserted on every camera. This repairs a lamp after a camera reboot, a dropped request, or someone changing tally in the camera's own web UI. - Auto-reconnect. Five seconds without traffic from the ATEM tears the session down and re-handshakes.
- Hold on disconnect. If the switcher goes away the lamps keep their last
state by default. Mid-show, every tally going dark at once is worse than a few
seconds of stale state. Set
behaviour.onAtemDisconnect: "clear"to prefer dark. - A busy status port never stops tally. If the dashboard can't bind, the bridge logs it and carries on.
See config.example.json — every option is documented
inline. The only thing you must fill in is the ATEM address and the
input → camera mapping.
{
"atem": { "address": "192.168.1.240" },
"cameras": [
{ "input": 1, "name": "Cam 1", "host": "192.168.1.101", "username": "admin", "password": "..." }
]
}input is the ATEM source id — for physical inputs, the number printed on the
switcher. Config errors are all reported at once at startup, so a show-day typo
fails loudly instead of leaving one camera dark.
node dist/src/cli.js [options]
-c, --config <path> Config file (default: ./config.json)
--log-level <l> debug | info | warn | error
--check Validate config, probe every camera, exit
-h, --help
| Endpoint | Purpose |
|---|---|
/ |
Live dashboard — colour-coded per camera, updates over SSE |
/api/status |
JSON snapshot |
/api/events |
Server-sent events, one per change |
/healthz |
200 when the ATEM is connected and every camera is reachable, 503 otherwise |
The repo ships simulators for both ends, so the whole chain can be exercised on a laptop.
Run everything at once — starts a fake switcher, three fake cameras (one behind digest auth, one cinema body with the combined tally mode), the real bridge, and asserts 16 behaviours end to end:
npm run selftestOr drive it by hand. In one terminal:
node dist/tools/fake-canon.js --port 18081 --model "Canon CR-N700"In another:
node dist/tools/fake-atem.js --port 19910 --inputs 8Then point a config at 127.0.0.1:19910 / 127.0.0.1:18081 and type pgm 3,
pvw 2, both 1, or off at the fake ATEM's prompt.
Unit tests:
npm testA launchd plist for macOS is in docs/. Install it with:
cp docs/com.tallybridge.plist ~/Library/LaunchAgents/ && launchctl load ~/Library/LaunchAgents/com.tallybridge.plistEdit the paths inside it first. Logs go to /tmp/tally-bridge.log.
- The bridge must reach the ATEM on UDP 9910 and each camera on TCP 80.
- Put the bridge on the same VLAN as the switcher and cameras if you can; tally latency is dominated by round-trip time.
- Cameras only need their guest or a normal user account to have camera control enabled — administrator rights are not required.
- Canon's XC spec has no push mechanism for setting tally, so each change is an HTTP request per affected camera. On a LAN this is a few milliseconds; over a routed WAN it will be visibly slower.
preview_programsupport is read from the camera. The EOS C400 postdates the published spec revision (BPE-7216-005, Sept 2023), so its modes are detected at runtime rather than assumed.- Sources above the physical inputs (colour bars, media players, M/E re-entry) can be mapped by their ATEM source id, but there is no lookup table for those ids in this tool — use the number the switcher reports.
docs/protocol-notes.md— the exact byte layouts this tool depends on, with the reasoning behind each design decision.ATEMSwitchersSDKManual.pdf— Blackmagic's SDK manual. Documents the tally model (IsProgramTallied/IsPreviewTallied, DSK tally override), which is whatTlSrcarries on the wire.canon-xc-control-protocol-specifications-005.pdf— Canon's XC spec. §3.2.3.1 coverscontrol.cgi; thef.tallyparameters are in Appendix iii.