Restart page (23), force restarts, Tascam reconnect, ProdCom, Resi encoder
- Page 23 "Restart": hold a key 2 s to restart DVS (4 Macs), ProPresenter,
Ableton, Church Translate, ProdCom, ProdDash, captions, the bridge or the
Tascam connection even when the status page says it is fine ("force",
shared 60 s guard). RESTART PAGE key on page 19, STATUS PAGE back.
- Holds carry Companion's clock; ones queued while the watchdog was down
are dropped instead of firing together later.
- TASCAM hold always reconnects and checks again after 20 s: HUNG - POWER
CYCLE IT (pings, control port hung), NO NETWORK, or turns a disabled
connection on. Tascam pinged at its Companion address.
- INGEST: ProdCom (:24480), ProdDash's page (:24500), the caption screen;
DVS on Ingest restarts ProdCom afterwards. RESI: the encoder (10.1.10.202).
- Windows starts check the app really started (FIX FAILED otherwise); OBS's
start task runs elevated (OBS is "Run as administrator"); setup script
does that for any such app and gives sshd restart-on-failure.
- push_page.py checks what is on the target page, not its name (pages 22
and 97 are named "PAGE" but hold buttons); --which restart.
- Companion custom variables are written in the request body (no URL limit).
Reviewed by a 3-lens review with per-finding verification; 7 confirmed
issues fixed. Tested live: OBS hold opens OBS; Restart page TASCAM key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+131
-13
@@ -111,9 +111,14 @@ class Companion:
|
||||
return text if status == 200 else None
|
||||
|
||||
def set_custom(self, name, value):
|
||||
path = "/api/custom-variable/%s/value?value=%s" % (
|
||||
urllib.parse.quote(name, safe=""), urllib.parse.quote(value, safe=""))
|
||||
status, text = self._request("POST", path)
|
||||
path = "/api/custom-variable/%s/value" % urllib.parse.quote(name, safe="")
|
||||
if value == "":
|
||||
# Companion answers 400 to an empty body; an empty ?value= clears it.
|
||||
status, text = self._request("POST", path + "?value=")
|
||||
else:
|
||||
# In the body, so sp_state (several KB) never runs into a URL length limit.
|
||||
status, text = self._request("POST", path, body=value.encode("utf-8"),
|
||||
headers={"Content-Type": "text/plain; charset=utf-8"})
|
||||
return status == 200, status
|
||||
|
||||
def restart_connection(self, conn_id):
|
||||
@@ -641,6 +646,8 @@ class Engine:
|
||||
self.executor = execute_fix
|
||||
self.poe_executor = execute_poe
|
||||
self.notes = {} # tile id -> hold feedback {"text", "level", "until"}
|
||||
# The Restart page: one key per thing, hold 2 s = restart it even when it looks fine.
|
||||
self.restart_keys = {k["id"]: k for k in cfg.get("restart_page", {}).get("keys", [])}
|
||||
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
|
||||
self.now = 0.0
|
||||
|
||||
@@ -867,6 +874,10 @@ class Engine:
|
||||
|
||||
def run_fixes(self, tile_id, snap, comp, now, cooldown=60):
|
||||
plan = self.fix_plan(tile_id, snap)
|
||||
always = self.tiles.get(tile_id, {}).get("hold_always")
|
||||
if not plan and always:
|
||||
# e.g. TASCAM: its hold reconnects even when it looks fine (its control port hangs).
|
||||
return self.run_force(tile_id, snap, comp, now, cooldown, fix_id=always)
|
||||
if not plan:
|
||||
if self.failing(tile_id):
|
||||
log.info("HOLD %s: failing, but there is no automatic fix for it", tile_id)
|
||||
@@ -897,12 +908,12 @@ class Engine:
|
||||
elif kind == "poe":
|
||||
log.info("FIX %s: power-cycle %s's PoE port (found by MAC)", tile_id, what)
|
||||
names.append("POWER CYCLE")
|
||||
started.append((self.poe_executor, (self.cfg, what, dict(snap.dante_map.get(what, {})))))
|
||||
started.append((self.poe_executor, (self.cfg, what, dict(snap.dante_map.get(what, {}))), what))
|
||||
else:
|
||||
fix = self.cfg["fixes"][what]
|
||||
log.info("FIX %s: %s", tile_id, fix.get("label", what))
|
||||
names.append(fix.get("short", what.upper()))
|
||||
started.append((self.executor, (self.cfg, what, chk)))
|
||||
started.append((self.executor, (self.cfg, what, chk), fix.get("short", what.upper())))
|
||||
if not names:
|
||||
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
|
||||
return plan
|
||||
@@ -913,21 +924,63 @@ class Engine:
|
||||
self.note(tile_id, "FIXING\n" + summary, "fixing", now, 180)
|
||||
results = []
|
||||
|
||||
def work(fn, args):
|
||||
group = "members" in self.tiles.get(tile_id, {}) or len(started) > 1
|
||||
|
||||
def work(fn, args, short):
|
||||
ok = fn(*args)
|
||||
results.append(ok)
|
||||
if isinstance(ok, str) and ok != WAKING:
|
||||
said.append(((short + " ") if group else "") + ok) # on a group button, say which
|
||||
if len(results) == len(started): # the last one to finish reports for all
|
||||
done = time.time()
|
||||
if WAKING in results:
|
||||
self.note(tile_id, "WAKING PC\n" + summary, "fixing", done, 120)
|
||||
elif said: # the fix says what to do next
|
||||
self.note(tile_id, said[0], "failed", done, 60)
|
||||
elif all(r is not False for r in results):
|
||||
self.note(tile_id, "CHECKING\n" + summary, "fixing", done, 20)
|
||||
else:
|
||||
self.note(tile_id, "FIX FAILED\n" + summary, "failed", done, 20)
|
||||
for fn, args in started:
|
||||
threading.Thread(target=work, args=(fn, args), daemon=True).start()
|
||||
said = []
|
||||
for fn, args, short in started:
|
||||
threading.Thread(target=work, args=(fn, args, short), daemon=True).start()
|
||||
return plan
|
||||
|
||||
def run_force(self, key_id, snap, comp, now, cooldown=60, fix_id=None):
|
||||
"""Run one fix whatever the status says: a Restart page key, or a button's "hold_always".
|
||||
Shares the 60 s guard with holds, so a hold and a restart key never double up."""
|
||||
if fix_id is None:
|
||||
key = self.restart_keys.get(key_id)
|
||||
if not key:
|
||||
log.warning("FORCE %s: no such restart key", key_id)
|
||||
return None
|
||||
fix_id = key["fix"]
|
||||
ident = ("fix", fix_id, "")
|
||||
if now - self.fix_last.get(ident, 0) < cooldown:
|
||||
log.info("FORCE %s: %s ran less than %ds ago, skipped", key_id, fix_id, cooldown)
|
||||
cur = self.notes.get(key_id)
|
||||
if not (cur and cur["level"] == "fixing" and now < cur["until"]): # still running: keep saying so
|
||||
self.note(key_id, "JUST DONE\nWAIT 1 MIN", "idle", now, 4)
|
||||
return None
|
||||
self.fix_last[ident] = now
|
||||
fix = self.cfg["fixes"][fix_id]
|
||||
log.info("FORCE %s: %s", key_id, fix.get("label", fix_id))
|
||||
self.note(key_id, "RESTARTING", "fixing", now, 180)
|
||||
|
||||
def work():
|
||||
r = self.executor(self.cfg, fix_id, {})
|
||||
done = time.time()
|
||||
if r == WAKING:
|
||||
self.note(key_id, "WAKING PC", "fixing", done, 120)
|
||||
elif isinstance(r, str):
|
||||
self.note(key_id, r, "failed", done, 60)
|
||||
elif r is False:
|
||||
self.note(key_id, "FAILED", "failed", done, 30)
|
||||
else:
|
||||
self.note(key_id, "DONE", "ok", done, 20)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
return fix_id
|
||||
|
||||
|
||||
def display_level(res, acked, service_on):
|
||||
if res.level == OFF:
|
||||
@@ -996,6 +1049,12 @@ def colored(text, level):
|
||||
|
||||
def render(engine, service_on):
|
||||
out = {}
|
||||
for kid, key in engine.restart_keys.items():
|
||||
note = engine.notes.get(kid)
|
||||
if note and engine.now < note["until"]:
|
||||
out[kid] = colored(key["label"] + "\n" + note["text"], note["level"])
|
||||
else:
|
||||
out[kid] = colored(key["label"] + "\nHOLD 2s", "idle")
|
||||
for tile in engine.cfg["tiles"]:
|
||||
tid, kind = tile["id"], tile.get("kind")
|
||||
note = engine.notes.get(tid)
|
||||
@@ -1449,13 +1508,16 @@ def execute_fix(cfg, fix_id, chk):
|
||||
return WAKING
|
||||
log.info("FIX %s -> %s%s", label, "ok" if r.returncode == 0 else "exit %d" % r.returncode,
|
||||
(": " + detail) if detail else "")
|
||||
if r.returncode == 0 and fix.get("reconnect") and chk.get("conn"):
|
||||
conn = chk.get("conn") or fix.get("conn")
|
||||
if r.returncode == 0 and fix.get("reconnect") and conn:
|
||||
# The app is running now: make Companion reconnect to it instead of waiting for its retry.
|
||||
comp = Companion(cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000))
|
||||
ids = {c["label"]: c["id"] for c in comp.connections()}
|
||||
ok = chk["conn"] in ids and comp.restart_connection(ids[chk["conn"]])
|
||||
log.info("FIX %s -> Companion connection %s restarted %s", label, chk["conn"], "ok" if ok else "FAILED")
|
||||
ok = conn in ids and comp.restart_connection(ids[conn])
|
||||
log.info("FIX %s -> Companion connection %s restarted %s", label, conn, "ok" if ok else "FAILED")
|
||||
return r.returncode == 0
|
||||
if kind == "reconnect":
|
||||
return reconnect_and_verify(cfg, fix, label)
|
||||
if kind == "midi_connect":
|
||||
# Like Connect in Audio MIDI Setup: add the peer back to the session it dropped out of.
|
||||
args = (chk["session"], chk["peer"], chk["address"])
|
||||
@@ -1505,6 +1567,41 @@ def execute_fix(cfg, fix_id, chk):
|
||||
return False
|
||||
|
||||
|
||||
def reconnect_and_verify(cfg, fix, label, sleep=time.sleep):
|
||||
"""Restart one Companion connection, then look again after "verify_seconds". Returns True if it
|
||||
came back; otherwise what to tell the operator: "hung_note" when the device still answers ping
|
||||
(its control side has hung - only a power-cycle helps), else that it is not on the network."""
|
||||
comp = Companion(cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000))
|
||||
conn = {c["label"]: c for c in comp.connections()}.get(fix["conn"])
|
||||
if conn is None:
|
||||
log.info("FIX %s -> no Companion connection %s", label, fix["conn"])
|
||||
return False
|
||||
if not conn.get("enabled"):
|
||||
# Switched off in Companion: turn it on (never call that a hung device).
|
||||
ok = comp.enable_connection(conn["id"])
|
||||
log.info("FIX %s -> %s was disabled in Companion: turned on %s", label, fix["conn"], "ok" if ok else "FAILED")
|
||||
if not ok:
|
||||
return "DISABLED IN\nCOMPANION"
|
||||
elif not comp.restart_connection(conn["id"]):
|
||||
log.info("FIX %s -> Companion refused to restart %s", label, fix["conn"])
|
||||
return False
|
||||
sleep(fix.get("verify_seconds", 20))
|
||||
after = {c["label"]: c for c in comp.connections()}.get(fix["conn"]) or {}
|
||||
status = (after.get("status") or {}).get("category")
|
||||
if status == "good":
|
||||
log.info("FIX %s -> %s reconnected", label, fix["conn"])
|
||||
return True
|
||||
if status not in ("warning", "error"): # still starting up, or unknown: no verdict yet
|
||||
log.info("FIX %s -> %s is %s after %ss", label, fix["conn"], status, fix.get("verify_seconds", 20))
|
||||
return "STILL\nCONNECTING"
|
||||
host = companion_conn_hosts(companion_db_path(cfg)).get(fix["conn"])
|
||||
if host and ping_once(host):
|
||||
log.info("FIX %s -> %s still %s but %s answers ping: control port hung", label, fix["conn"], status, host)
|
||||
return fix.get("hung_note", "STILL DOWN")
|
||||
log.info("FIX %s -> %s still %s and %s does not answer ping", label, fix["conn"], status, host or "its address")
|
||||
return "NO NETWORK\nCHECK POWER"
|
||||
|
||||
|
||||
def start_artnet_listener(snap, stop, port=6454):
|
||||
"""Note when each source last sent Art-Net. Shares the port (SO_REUSEPORT) so it never
|
||||
keeps another program from listening too. Passive: nothing is ever sent."""
|
||||
@@ -1580,6 +1677,13 @@ def load_config(path):
|
||||
raise ValueError("fix %s: unknown host" % f)
|
||||
if c["type"] == "remote" and c["host"] not in cfg.get("hosts", {}):
|
||||
raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"]))
|
||||
if t.get("hold_always") and t["hold_always"] not in cfg.get("fixes", {}):
|
||||
raise ValueError("tile %s: unknown hold_always fix %s" % (t["id"], t["hold_always"]))
|
||||
for k in cfg.get("restart_page", {}).get("keys", []):
|
||||
if k["id"] in ids:
|
||||
raise ValueError("restart key %s has a tile's id" % k["id"])
|
||||
if k["fix"] not in cfg.get("fixes", {}):
|
||||
raise ValueError("restart key %s: unknown fix %s" % (k["id"], k["fix"]))
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -1641,6 +1745,9 @@ def post_webhook(url, text):
|
||||
log.warning("webhook failed: %s", e)
|
||||
|
||||
|
||||
HOLD_MAX_AGE = 15 # seconds: a hold older than this (queued while the watchdog was down) is dropped
|
||||
|
||||
|
||||
def handle_commands(text, engine, comp, snap, now):
|
||||
for part in text.split(";"):
|
||||
words = part.strip().split()
|
||||
@@ -1655,8 +1762,19 @@ def handle_commands(text, engine, comp, snap, now):
|
||||
target = {"on": True, "off": False}.get(arg, not engine.service.on(now))
|
||||
engine.service.set(target, now)
|
||||
log.info("SERVICE %s (button)", "ON" if target else "OFF")
|
||||
elif verb == "restart": # sent by a 2 second hold
|
||||
engine.run_fixes(arg, snap, comp, now)
|
||||
elif verb in ("restart", "force"):
|
||||
# Holds carry Companion's clock ("force rs_x 1791496225"). One pressed while the watchdog
|
||||
# was down must not fire minutes later in a burst with the others.
|
||||
stamp = words[2] if len(words) > 2 else None
|
||||
if stamp is not None and (not stamp.isdigit() or now - int(stamp) > HOLD_MAX_AGE):
|
||||
log.info("%s %s: pressed %s, too long ago - ignored", verb.upper(), arg,
|
||||
"at %s" % stamp if stamp.isdigit() else "at an unknown time")
|
||||
elif stamp is None and verb == "force":
|
||||
log.info("FORCE %s: no time on it - ignored", arg)
|
||||
elif verb == "restart": # a 2 second hold on a status button
|
||||
engine.run_fixes(arg, snap, comp, now)
|
||||
else: # a Restart page key, held 2 seconds
|
||||
engine.run_force(arg, snap, comp, now)
|
||||
else:
|
||||
log.warning("unknown command %r", part)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user