2026-10-08 09:30:56 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-10-08 18:38:19 -04:00
|
|
|
"""Import the status page (19) or the Restart page (20; both numbers come from config.json) into Companion without clicking through the UI.
|
2026-10-08 09:30:56 -04:00
|
|
|
|
2026-10-08 18:08:29 -04:00
|
|
|
Does exactly what Import/Export -> Import -> "import into page N" does, over the
|
2026-10-08 09:30:56 -04:00
|
|
|
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.
|
|
|
|
|
|
2026-10-08 18:08:29 -04:00
|
|
|
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).
|
2026-10-08 09:30:56 -04:00
|
|
|
|
2026-10-08 18:08:29 -04:00
|
|
|
python3 push_page.py # the status page, from config.json
|
|
|
|
|
python3 push_page.py --which restart # the Restart page
|
2026-10-08 09:30:56 -04:00
|
|
|
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
|
2026-10-08 18:08:29 -04:00
|
|
|
import gzip
|
2026-10-08 09:30:56 -04:00
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import socket
|
|
|
|
|
import struct
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
CHUNK = 256 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebSocket:
|
|
|
|
|
def __init__(self, host, port, path, timeout=15):
|
|
|
|
|
self.sock = socket.create_connection((host, port), timeout=timeout)
|
|
|
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
|
|
|
self.sock.sendall(("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
|
|
|
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
|
|
|
% (path, host, port, key)).encode())
|
|
|
|
|
buf = b""
|
|
|
|
|
while b"\r\n\r\n" not in buf:
|
|
|
|
|
chunk = self.sock.recv(4096)
|
|
|
|
|
if not chunk:
|
|
|
|
|
raise ConnectionError("connection closed during WebSocket handshake")
|
|
|
|
|
buf += chunk
|
|
|
|
|
head, self.buf = buf.split(b"\r\n\r\n", 1)
|
|
|
|
|
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
|
|
|
|
raise ConnectionError("WebSocket handshake refused: %s" % head.split(b"\r\n", 1)[0].decode())
|
|
|
|
|
|
|
|
|
|
def _read(self, n):
|
|
|
|
|
while len(self.buf) < n:
|
|
|
|
|
chunk = self.sock.recv(65536)
|
|
|
|
|
if not chunk:
|
|
|
|
|
raise ConnectionError("connection closed")
|
|
|
|
|
self.buf += chunk
|
|
|
|
|
data, self.buf = self.buf[:n], self.buf[n:]
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
def _send(self, opcode, payload):
|
|
|
|
|
n = len(payload)
|
|
|
|
|
header = bytes([0x80 | opcode])
|
|
|
|
|
if n < 126:
|
|
|
|
|
header += bytes([0x80 | n])
|
|
|
|
|
elif n < 65536:
|
|
|
|
|
header += bytes([0x80 | 126]) + struct.pack(">H", n)
|
|
|
|
|
else:
|
|
|
|
|
header += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
|
|
|
|
mask = os.urandom(4)
|
|
|
|
|
masked = bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
|
|
|
|
|
self.sock.sendall(header + mask + masked)
|
|
|
|
|
|
|
|
|
|
def send(self, text):
|
|
|
|
|
self._send(0x1, text.encode())
|
|
|
|
|
|
|
|
|
|
def recv(self):
|
|
|
|
|
message = b""
|
|
|
|
|
while True:
|
|
|
|
|
b1, b2 = self._read(2)
|
|
|
|
|
fin, opcode, n = b1 & 0x80, b1 & 0x0F, b2 & 0x7F
|
|
|
|
|
if n == 126:
|
|
|
|
|
n = struct.unpack(">H", self._read(2))[0]
|
|
|
|
|
elif n == 127:
|
|
|
|
|
n = struct.unpack(">Q", self._read(8))[0]
|
|
|
|
|
payload = self._read(n)
|
|
|
|
|
if opcode == 0x9: # ping
|
|
|
|
|
self._send(0xA, payload)
|
|
|
|
|
continue
|
|
|
|
|
if opcode == 0x8:
|
|
|
|
|
raise ConnectionError("server closed the WebSocket")
|
|
|
|
|
message += payload
|
|
|
|
|
if fin:
|
|
|
|
|
return message.decode()
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
try:
|
|
|
|
|
self._send(0x8, b"")
|
|
|
|
|
finally:
|
|
|
|
|
self.sock.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Trpc:
|
|
|
|
|
def __init__(self, ws):
|
|
|
|
|
self.ws, self.next_id = ws, 1
|
|
|
|
|
|
|
|
|
|
def mutation(self, path, data):
|
|
|
|
|
rid = self.next_id
|
|
|
|
|
self.next_id += 1
|
|
|
|
|
self.ws.send(json.dumps({"id": rid, "jsonrpc": "2.0", "method": "mutation",
|
|
|
|
|
"params": {"path": path, "input": data}}))
|
|
|
|
|
while True:
|
|
|
|
|
msg = json.loads(self.ws.recv())
|
|
|
|
|
if msg.get("id") != rid:
|
|
|
|
|
continue
|
|
|
|
|
if "error" in msg:
|
|
|
|
|
err = msg["error"]
|
|
|
|
|
raise RuntimeError("%s failed: %s" % (path, err.get("message", err)))
|
|
|
|
|
return msg.get("result", {}).get("data")
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 18:08:29 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 09:30:56 -04:00
|
|
|
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:
|
|
|
|
|
return r.read().decode()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
|
|
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
2026-10-08 18:08:29 -04:00
|
|
|
ap.add_argument("--which", choices=("status", "restart"), default="status")
|
2026-10-08 09:30:56 -04:00
|
|
|
ap.add_argument("--host", default="na-ingest.local")
|
|
|
|
|
ap.add_argument("--port", type=int, default=8000)
|
2026-10-08 18:08:29 -04:00
|
|
|
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")
|
2026-10-08 09:30:56 -04:00
|
|
|
args = ap.parse_args()
|
2026-10-08 18:08:29 -04:00
|
|
|
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)
|
2026-10-08 09:30:56 -04:00
|
|
|
|
|
|
|
|
data = open(args.file, "rb").read()
|
2026-10-08 18:08:29 -04:00
|
|
|
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()
|
2026-10-08 09:30:56 -04:00
|
|
|
ws = WebSocket(args.host, args.port, "/trpc")
|
|
|
|
|
try:
|
|
|
|
|
api = Trpc(ws)
|
|
|
|
|
# Only one upload at a time; an abandoned one expires after a short idle time.
|
|
|
|
|
for attempt in range(40):
|
|
|
|
|
try:
|
|
|
|
|
session = api.mutation("importExport.prepareImport.start",
|
|
|
|
|
{"name": os.path.basename(args.file), "size": len(data)})
|
|
|
|
|
break
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
if "already exists" not in str(e) or attempt == 39:
|
|
|
|
|
raise
|
|
|
|
|
if attempt == 0:
|
|
|
|
|
print("another upload is still open in Companion; waiting for it to expire...")
|
|
|
|
|
time.sleep(3)
|
|
|
|
|
for offset in range(0, len(data), CHUNK):
|
|
|
|
|
api.mutation("importExport.prepareImport.uploadChunk",
|
|
|
|
|
{"sessionId": session, "offset": offset,
|
|
|
|
|
"data": base64.b64encode(data[offset:offset + CHUNK]).decode()})
|
|
|
|
|
summary = api.mutation("importExport.prepareImport.complete",
|
|
|
|
|
{"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(),
|
|
|
|
|
"userData": None})
|
|
|
|
|
if isinstance(summary, list) and summary and summary[0]:
|
|
|
|
|
sys.exit("Companion could not read the file: %s" % summary[0])
|
|
|
|
|
api.mutation("importExport.importSinglePage",
|
|
|
|
|
{"targetPage": args.page, "sourcePage": 1, "connectionIdRemapping": {}})
|
|
|
|
|
finally:
|
|
|
|
|
ws.close()
|
|
|
|
|
print("imported %s into page %d (was %r, now %r)" % (os.path.basename(args.file), args.page, plain,
|
|
|
|
|
page_name(args.host, args.port, args.page)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|