Settings page: ON/OFF switches everywhere, the Auto switch screen, a Companion button picker

- ON/OFF on every key, check, fix, Restart and READY key, computer, MIDI link, HyperDeck,
  Dante device, watched Companion connection, switcher and Spotify check, outlet fix, and one
  main switch each for auto-fix, the auto switch and the READY page. Switched off = kept in
  the settings, grey OFF on the deck, never checked or run (effective_config in the watchdog).
- Settings › Auto switch: a plain summary, what counts as down (each trigger editable in place:
  address, program and computer, Dante device, the button it presses, timings), the hours
  folded away, the buttons; + Button, + What counts as down, Remove.
- Pick a Companion button by clicking it on its page, drawn like Companion (read-only API:
  /api/companion/pages and /api/companion/page).
- STREAM STB / STREAM FOH Restart keys removed (the Studio B page has its own); the alert names
  the switch-back button instead.
- Dante screen: shows the DANTE key's device list again (it picked STB AUDIO's one device).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:30:41 -04:00
co-authored by Claude Opus 5.5
parent 14fce2cf9a
commit 5adab6c518
11 changed files with 663 additions and 82 deletions
+79 -1
View File
@@ -148,6 +148,69 @@ class Site:
self.page_cache[name] = (now, value)
return value
# --- Companion's own pages, for picking a button by looking at it -----------------
def companion_pages(self):
"""[{"page", "name"}] for every page Companion has (read-only), all asked at once."""
def one(n):
try:
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % n, timeout=3)
except (OSError, urllib.error.URLError):
return None
if status != 200:
return None
name = " ".join(raw.decode("utf-8", "replace").replace("\\n", " ").split())
return {"page": n, "name": re.sub(r"^%d\s+" % n, "", name) or name} # "4 Audio" -> "Audio"
def look():
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=16) as pool:
found = list(pool.map(one, range(1, 100)))
out = []
for f in found: # up to the first page Companion does not have
if f is None:
break
out.append(f)
return out
return self.cached("companion-pages", 60, look)
def companion_page(self, n):
"""One Companion page as its buttons look: text, colours and image, by row and column (read-only)."""
def look():
status, raw = self.companion_get("/int/export/page/%d?includeSecrets=false" % n, timeout=6)
try:
raw = gzip.decompress(raw)
except OSError:
pass
page = (json.loads(raw).get("page") or {})
val = lambda p: p.get("value") if isinstance(p, dict) and not p.get("isExpression") else None
buttons = []
for r, row in (page.get("controls") or {}).items():
for c, ctl in (row or {}).items():
if not ctl:
continue
b = {"row": int(r), "col": int(c), "type": ctl.get("type")}
st = ctl.get("style") or {}
if ctl.get("type") == "button-layered":
lay = {}
for l in st.get("layers") or []:
lay.setdefault(l.get("type"), l)
text = (lay.get("text") or {}).get("text") or {}
b["text"] = val(text) if val(text) is not None else ("…" if text.get("isExpression") else "")
b["bg"] = val((lay.get("box") or {}).get("color") or {})
b["fg"] = val((lay.get("text") or {}).get("color") or {})
img = val((lay.get("image") or {}).get("base64Image") or {})
if img and len(img) < 60000:
b["img"] = img if img.startswith("data:") else "data:image/png;base64," + img
elif ctl.get("type") == "button":
b["text"], b["bg"], b["fg"] = st.get("text") or "", st.get("bgcolor"), st.get("color")
if st.get("png64") and len(st["png64"]) < 60000:
b["img"] = st["png64"] if st["png64"].startswith("data:") else "data:image/png;base64," + st["png64"]
if isinstance(b.get("text"), str):
b["text"] = b["text"].replace("\\n", "\n")
buttons.append(b)
return {"page": n, "name": (page.get("name") or "").replace("\\n", " "), "grid": page.get("gridSize"), "buttons": buttons}
return self.cached("companion-page-%d" % n, 10, look)
# --- what the screens show ---------------------------------------------
def overview(self):
@@ -286,7 +349,9 @@ class Site:
for f in fs:
if f["level"] == "err" and alias in owners.get((tid, f.get("check")), set()):
faults.append("%s %s" % (labels.get(tid, tid), f["msg"]))
if probe and not probe.get("reachable") and not h.get("self"):
if h.get("disabled"):
word, reason = "Off", "switched off in the settings: not looked at, its checks and fixes skipped"
elif probe and not probe.get("reachable") and not h.get("self"):
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
elif faults:
word, reason = "Problem", "; ".join(faults[:2])
@@ -629,6 +694,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, site.overview())
if path == "/api/companion":
return self.send(200, site.companion_view())
if path == "/api/companion/pages":
try:
return self.send(200, {"pages": site.companion_pages()})
except (OSError, ValueError) as e:
return self.send(200, {"pages": [], "error": "Companion did not answer: %s" % e})
if path == "/api/companion/page":
n = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("n") or ["0"])[0]
if not n.isdigit() or not 1 <= int(n) <= 99:
return self.send(400, {"error": "which page?"})
try:
return self.send(200, site.companion_page(int(n)))
except (OSError, ValueError) as e:
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
if path == "/api/machines":
return self.send(200, site.machines_view())
if path == "/api/gear":