Step 0 of the settings page: registry, safe restart, remembered state, test verb, page markers

- schema.py: every setting with type, default, help, owner screen and the rules a config must
  pass; tests/test_schema.py keeps it in step with the code (every key the code reads is declared,
  every default matches, check types and fix kinds agree, the glossary covers every word a key can
  show, today's config still builds today's pages apart from the key notes)
- status_panel.py: VERSION + started.json; SIGTERM lets a running fix finish and starts nothing new
  (RESTARTING SOON); fixes-running.json and live-now.json for the settings page; state.json keeps
  the ALARMS override, acknowledgements, auto-fix tries and fix times across restarts, keyed by
  what a check is about rather than its position; 'test' and 'resurvey' commands; queued commands
  dropped at start; overnight windows; a HyperDeck switched off is always switched back on;
  the spotify fix kind (playlist set once, in config 'spotify'); Windows remote checks batched for
  cmd.exe with conditional echoes; every fix's kind and host validated; never_auto; wording and
  numbers from config (site block, timing, page_hold_ms, auto-fix keep_months); hyperdeck_probe
  removed; --check and --exit-timeout
- make_page.py: numbers from config, markers on every key, nav templates per Companion version,
  --out; push_page.py: a page is overwritten only when every key carries a marker (foreign keys
  named by position), secret-free fetch, host from the site; nav_keys.py: host from the site;
  netgear.py: switches and the shared-port limit from config
- install.sh: validates with the new code first, keeps prev/ for --rollback, --keep-config,
  derived ExitTimeOut, wider deploy list, waits for the start stamp
- config.json: site, timing, spotify sections; Windows hosts marked; deployed to NA Ingest and
  both pages re-imported once (--force) so every key carries its marker

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 16:00:41 -04:00
co-authored by Claude Fable 5.1
parent a3934b7c62
commit 5d66805687
13 changed files with 1822 additions and 307 deletions
+36 -23
View File
@@ -5,9 +5,10 @@ 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 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).
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.
python3 push_page.py # the status page, from config.json
python3 push_page.py --which restart # the Restart page
@@ -29,6 +30,9 @@ import urllib.parse
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from make_page import MARKERS # noqa: E402
CHUNK = 256 * 1024
@@ -124,8 +128,10 @@ NAV_MARKER = "STATUS nav key" # our coloured page number key (make_page
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:
"""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:
raw = r.read()
try:
raw = gzip.decompress(raw)
@@ -134,29 +140,36 @@ def fetch_page(host, port, page):
return json.loads(raw)
def real_buttons(export):
"""How many buttons a page holds besides its page up / number / down keys."""
def foreign_buttons(export):
"""[(row, col)] of every key on the page that neither Companion nor this tool made."""
page = export.get("page") or {}
n = 0
for row in (page.get("controls") or {}).values():
for ctl in (row or {}).values():
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])):
if not ctl or ctl.get("type") in NAV_TYPES:
continue
if NAV_MARKER in ((ctl.get("options") or {}).get("notes") or ""):
notes = (ctl.get("options") or {}).get("notes") or ""
if any(m in notes for m in MARKERS):
continue
n += 1
return n
found.append((int(r), int(c)))
return found
def safe_target(export, expected_name):
"""(ok, reason). OK only for a page with nothing but nav keys, or the page this file builds."""
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."""
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)
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)
def page_name(host, port, page):
@@ -169,8 +182,8 @@ 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("--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))
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")