2026-10-08 09:30:56 -04:00
#!/usr/bin/env python3
2026-10-14 20:28:43 -04:00
"""Import the status page (19), the Restart page (20) or the READY page (24; the 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-13 16:00:41 -04:00
Safety: it looks at what is on the target page, not just its name. It refuses unless every
key on the page (besides Companion's own page up / number / down keys) carries one of the
markers make_page.py writes into its notes, i.e. the page holds nothing a person made by hand.
A key someone added is named by its position, so it can be moved first.
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-14 20:28:43 -04:00
python3 push_page.py --which ready # the READY page (the weekly checklist)
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__ ))
2026-10-13 16:00:41 -04:00
sys . path . insert ( 0 , HERE )
from make_page import MARKERS # noqa: E402
2026-10-08 09:30:56 -04:00
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\n Host: %s : %d \r\n Upgrade: websocket \r\n Connection: Upgrade \r\n "
"Sec-WebSocket-Key: %s \r\n Sec-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 ):
2026-10-13 16:00:41 -04:00
"""The page as Companion exports it ({"page": {"name", "controls": {row: {col: control}}}}),
without any connection secrets."""
url = "http:// %s : %d /int/export/page/ %d ?includeSecrets=false" % ( host , port , page )
with urllib . request . urlopen ( url , timeout = 10 ) as r :
2026-10-08 18:08:29 -04:00
raw = r . read ()
try :
raw = gzip . decompress ( raw )
except OSError :
pass
return json . loads ( raw )
2026-10-13 16:00:41 -04:00
def foreign_buttons ( export ):
"""[(row, col)] of every key on the page that neither Companion nor this tool made."""
2026-10-08 18:08:29 -04:00
page = export . get ( "page" ) or {}
2026-10-13 16:00:41 -04:00
found = []
for r , row in sorted (( page . get ( "controls" ) or {}) . items (), key = lambda kv : int ( kv [ 0 ])):
for c , ctl in sorted (( row or {}) . items (), key = lambda kv : int ( kv [ 0 ])):
2026-10-08 18:08:29 -04:00
if not ctl or ctl . get ( "type" ) in NAV_TYPES :
continue
2026-10-13 16:00:41 -04:00
notes = ( ctl . get ( "options" ) or {}) . get ( "notes" ) or ""
if any ( m in notes for m in MARKERS ):
2026-10-08 18:08:29 -04:00
continue
2026-10-13 16:00:41 -04:00
found . append (( int ( r ), int ( c )))
return found
2026-10-08 18:08:29 -04:00
2026-10-13 16:00:41 -04:00
def real_buttons ( export ):
"""How many keys a page holds that this tool did not make."""
return len ( foreign_buttons ( export ))
def safe_target ( export , expected_name = None ):
"""(ok, reason). OK only when every key on the page is Companion's own page key or one of ours,
whatever the page is called: nothing a person made by hand is ever overwritten."""
2026-10-08 18:08:29 -04:00
name = (( export . get ( "page" ) or {}) . get ( "name" ) or "" )
2026-10-13 16:00:41 -04:00
foreign = foreign_buttons ( export )
if not foreign :
return True , "it holds only page keys and keys made by this tool"
where = ", " . join ( "row %d col %d " % rc for rc in foreign [: 6 ]) + ( " ..." if len ( foreign ) > 6 else "" )
return False , "it is called %r and holds %d key %s not made by this tool ( %s ) - move or delete them first" % (
name , len ( foreign ), "" if len ( foreign ) == 1 else "s" , where )
2026-10-08 18:08:29 -04:00
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 ()
2026-10-14 14:06:42 -04:00
def push ( host , port , page , file_path , expected_name ):
"""Upload a built page file and import it into `page` (the caller has checked safe_target)."""
data = open ( file_path , "rb" ) . read ()
built = json . loads ( gzip . decompress ( data ))
if ( built . get ( "page" ) or {}) . get ( "name" ) != expected_name :
raise ValueError ( " %s builds page %r , not %r " % ( os . path . basename ( file_path ), ( built . get ( "page" ) or {}) . get ( "name" ), expected_name ))
ws = WebSocket ( host , port , "/trpc" )
try :
api = Trpc ( ws )
for attempt in range ( 40 ):
try :
session = api . mutation ( "importExport.prepareImport.start" , { "name" : os . path . basename ( file_path ), "size" : len ( data )})
break
except RuntimeError as e :
if "already exists" not in str ( e ) or attempt == 39 :
raise
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 ]:
raise RuntimeError ( "Companion could not read the file: %s " % summary [ 0 ])
api . mutation ( "importExport.importSinglePage" , { "targetPage" : page , "sourcePage" : 1 , "connectionIdRemapping" : {}})
finally :
ws . close ()
2026-10-08 09:30:56 -04:00
def main ():
cfg = json . load ( open ( os . path . join ( HERE , "config.json" )))
ap = argparse . ArgumentParser ( description = __doc__ . splitlines ()[ 0 ])
2026-10-14 20:28:43 -04:00
ap . add_argument ( "--which" , choices = ( "status" , "restart" , "ready" ), default = "status" )
2026-10-13 16:00:41 -04:00
ap . add_argument ( "--host" , default = cfg . get ( "site" , {}) . get ( "ingest_hostname" ) or cfg [ "companion" ] . get ( "host" , "127.0.0.1" ))
ap . add_argument ( "--port" , type = int , default = cfg [ "companion" ] . get ( "port" , 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"
2026-10-14 20:28:43 -04:00
elif args . which == "ready" :
page , expected = cfg [ "ready_page" ][ "page" ], cfg [ "ready_page" ][ "page_name" ]
default_file = "ready-page.companionconfig"
2026-10-08 18:08:29 -04:00
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 ()