Restart page (23), force restarts, Tascam reconnect, ProdCom, Resi encoder

- Page 23 "Restart": hold a key 2 s to restart DVS (4 Macs), ProPresenter,
  Ableton, Church Translate, ProdCom, ProdDash, captions, the bridge or the
  Tascam connection even when the status page says it is fine ("force",
  shared 60 s guard). RESTART PAGE key on page 19, STATUS PAGE back.
- Holds carry Companion's clock; ones queued while the watchdog was down
  are dropped instead of firing together later.
- TASCAM hold always reconnects and checks again after 20 s: HUNG - POWER
  CYCLE IT (pings, control port hung), NO NETWORK, or turns a disabled
  connection on. Tascam pinged at its Companion address.
- INGEST: ProdCom (:24480), ProdDash's page (:24500), the caption screen;
  DVS on Ingest restarts ProdCom afterwards. RESI: the encoder (10.1.10.202).
- Windows starts check the app really started (FIX FAILED otherwise); OBS's
  start task runs elevated (OBS is "Run as administrator"); setup script
  does that for any such app and gives sshd restart-on-failure.
- push_page.py checks what is on the target page, not its name (pages 22
  and 97 are named "PAGE" but hold buttons); --which restart.
- Companion custom variables are written in the request body (no URL limit).
Reviewed by a 3-lens review with per-finding verification; 7 confirmed
issues fixed. Tested live: OBS hold opens OBS; Restart page TASCAM key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:08:29 -04:00
co-authored by Claude Opus 5.5
parent 17347a5d9a
commit a0c72183bd
7 changed files with 544 additions and 72 deletions
+67 -15
View File
@@ -1,20 +1,23 @@
#!/usr/bin/env python3
"""Import status-page.companionconfig into Companion's page 19 without clicking through the UI.
"""Import the status page (19) or the Restart page (23) into Companion without clicking through the UI.
Does exactly what Import/Export -> Import -> "import into page 19" does, over the
Does exactly what Import/Export -> Import -> "import into page N" does, over the
same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc):
upload the file in chunks, check its SHA-1, then import it as a single page.
Safety: it refuses unless the target page is empty or already the status page
(its name contains "status"), so it cannot overwrite one of your other pages.
Safety: it looks at what is on the target page, not just its name. It refuses unless the
page holds nothing but page up / page number / page down keys, or is already the page this
file builds (same name). A page named "PAGE" can still hold real buttons (pages 22 and 97 do).
python3 push_page.py # na-ingest.local:8000, page and file from config.json
python3 push_page.py # the status page, from config.json
python3 push_page.py --which restart # the Restart page
python3 push_page.py --host 10.6.3.222 --page 19 --file status-page.companionconfig
Stdlib only (a minimal WebSocket client is included). Written against Companion 5.0.7.
"""
import argparse
import base64
import gzip
import hashlib
import json
import os
@@ -116,6 +119,46 @@ class Trpc:
return msg.get("result", {}).get("data")
NAV_TYPES = ("pageup", "pagedown", "pagenum")
NAV_MARKER = "STATUS nav key" # our coloured page number key (make_page.nav_key)
def fetch_page(host, port, page):
"""The page as Companion exports it ({"page": {"name", "controls": {row: {col: control}}}})."""
with urllib.request.urlopen("http://%s:%d/int/export/page/%d" % (host, port, page), timeout=10) as r:
raw = r.read()
try:
raw = gzip.decompress(raw)
except OSError:
pass
return json.loads(raw)
def real_buttons(export):
"""How many buttons a page holds besides its page up / number / down keys."""
page = export.get("page") or {}
n = 0
for row in (page.get("controls") or {}).values():
for ctl in (row or {}).values():
if not ctl or ctl.get("type") in NAV_TYPES:
continue
if NAV_MARKER in ((ctl.get("options") or {}).get("notes") or ""):
continue
n += 1
return n
def safe_target(export, expected_name):
"""(ok, reason). OK only for a page with nothing but nav keys, or the page this file builds."""
name = ((export.get("page") or {}).get("name") or "")
if name == expected_name:
return True, "it is already %r" % name
n = real_buttons(export)
if n == 0:
return True, "it holds only page keys"
return False, "it is called %r and holds %d buttons" % (name, n)
def page_name(host, port, page):
url = "http://%s:%d/api/variable/internal/page_number_%d_name/value" % (host, port, page)
with urllib.request.urlopen(url, timeout=5) as r:
@@ -125,21 +168,30 @@ def page_name(host, port, page):
def main():
cfg = json.load(open(os.path.join(HERE, "config.json")))
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--which", choices=("status", "restart"), default="status")
ap.add_argument("--host", default="na-ingest.local")
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--page", type=int, default=cfg["page"])
ap.add_argument("--file", default=os.path.join(HERE, "status-page.companionconfig"))
ap.add_argument("--force", action="store_true", help="overwrite the page whatever its name")
ap.add_argument("--page", type=int)
ap.add_argument("--file")
ap.add_argument("--force", action="store_true", help="overwrite the page whatever is on it")
args = ap.parse_args()
name = page_name(args.host, args.port, args.page)
plain = name.replace("\\n", " ").strip()
empty = plain.upper() in ("", "PAGE", "%d PAGE" % args.page)
if not (empty or "status" in plain.lower() or args.force):
sys.exit("page %d is called %r - not empty and not the status page; refusing (use --force)"
% (args.page, plain))
if args.which == "restart":
page, expected = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]
default_file = "restart-page.companionconfig"
else:
page, expected, default_file = cfg["page"], cfg["page_name"], "status-page.companionconfig"
args.page = args.page or page
args.file = args.file or os.path.join(HERE, default_file)
data = open(args.file, "rb").read()
built = json.loads(gzip.decompress(data))
if (built.get("page") or {}).get("name") != expected:
sys.exit("%s builds page %r, not %r; refusing" % (os.path.basename(args.file),
(built.get("page") or {}).get("name"), expected))
ok, why = safe_target(fetch_page(args.host, args.port, args.page), expected)
if not ok and not args.force:
sys.exit("page %d: %s - refusing (use --force only if you mean to replace it)" % (args.page, why))
plain = page_name(args.host, args.port, args.page).replace("\\n", " ").strip()
ws = WebSocket(args.host, args.port, "/trpc")
try:
api = Trpc(ws)