Add push_page.py to import the status page without the UI.
It uploads the page file over Companion's own API (tRPC over WebSocket, the same calls the web UI makes) and imports it into page 19, refusing unless that page is empty or already the status page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -117,12 +117,20 @@ Then run the installer there. It backs up the old config and restarts the watchd
|
||||
ssh checklist-ingest 'zsh ~/status-panel-install/install.sh'
|
||||
```
|
||||
|
||||
If you add or move tiles, also rebuild the page and import it into page 19 again:
|
||||
If you add or move tiles, rebuild the page:
|
||||
|
||||
```bash
|
||||
python3 make_page.py
|
||||
```
|
||||
|
||||
Then import it into page 19, either by hand (Import/Export → Import) or with:
|
||||
|
||||
```bash
|
||||
python3 push_page.py
|
||||
```
|
||||
|
||||
`push_page.py` does the same import over Companion's own API. It refuses to overwrite page 19 unless that page is empty or already the status page.
|
||||
|
||||
Other commands:
|
||||
|
||||
```bash
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import status-page.companionconfig into Companion's page 19 without clicking through the UI.
|
||||
|
||||
Does exactly what Import/Export -> Import -> "import into page 19" 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.
|
||||
|
||||
python3 push_page.py # na-ingest.local:8000, page and file from config.json
|
||||
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 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")
|
||||
|
||||
|
||||
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])
|
||||
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")
|
||||
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))
|
||||
|
||||
data = open(args.file, "rb").read()
|
||||
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()
|
||||
Reference in New Issue
Block a user