Never reboot a HyperDeck to fix a connection that is switched off; turn connections back on instead.

On 2026-10-08 a hold on HD1, whose Companion connection had been disabled,
sent the deck a reboot. Now a disabled connection on any button is fixed by
enabling it, before any other fix is considered. The HyperDeck fix asks the
deck itself (transport info on port 9993): if it answers - recording or not -
only Companion's connection is restarted; only a deck that accepts a
connection and says nothing at all is rebooted; an unreachable deck is left
alone and the button shows FIX FAILED. Tested against a fake deck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:23:05 -04:00
co-authored by Claude Opus 5.5
parent b8748be655
commit df4da8f42e
3 changed files with 164 additions and 21 deletions
+75 -12
View File
@@ -120,6 +120,10 @@ class Companion:
status, _ = self._request("POST", "/api/connections/%s/restart" % urllib.parse.quote(conn_id, safe=""))
return status == 200
def enable_connection(self, conn_id):
status, _ = self._request("POST", "/api/connections/%s/enable" % urllib.parse.quote(conn_id, safe=""))
return status == 200
# --- Shared snapshot written by the pollers --------------------------------
@@ -735,6 +739,15 @@ class Engine:
tr = self.trackers.get(key)
if tid not in ids or not tr or not tr.confirmed or tr.result.level not in (WARN, ERR):
continue
# A connection switched off in Companion is fixed by switching it on - never by
# acting on the device (2026-10-08: a disabled HyperDeck connection got a deck reboot).
label = chk.get("conn")
if label and label in snap.conns and not snap.conns[label].get("enabled"):
ident = ("enable", label)
if ident not in seen:
seen.add(ident)
plan.append((ident, "enable", label, chk))
continue
fix_id = chk.get("fix")
if isinstance(fix_id, dict):
fix_id = fix_id.get(sub)
@@ -742,7 +755,7 @@ class Engine:
if chk["type"] == "hyperdeck" and hyperdeck_recording(snap, chk["conn"]):
continue
# One run per fix, except a HyperDeck reboot, which is per deck.
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck_reboot"
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck"
ident = ("fix", fix_id, chk.get("ip", "") if per_deck else "")
if ident not in seen:
seen.add(ident)
@@ -783,7 +796,13 @@ class Engine:
waited = True
continue
self.fix_last[ident] = now
if kind == "restart":
if kind == "enable":
c = snap.conns.get(what)
if c:
ok = comp.enable_connection(c["id"])
log.info("ENABLE %s -> %s", what, "ok" if ok else "failed")
names.append("TURN ON")
elif kind == "restart":
c = snap.conns.get(what)
if c and c.get("enabled"):
ok = comp.restart_connection(c["id"])
@@ -1100,6 +1119,41 @@ def poll_remote(snap, cfg):
snap.remote = result
def hyperdeck_probe(ip, timeout=3, port=9993):
"""Ask a HyperDeck directly what it is doing, on its control port 9993.
Returns its transport status ("record", "preview", "stopped", ...), "busy" if it
refused another client, "answered" if it greeted but did not report a status,
"hung" if it accepted the connection and said nothing at all, or "unreachable".
Only "hung" justifies a reboot.
"""
try:
s = socket.create_connection((ip, port), timeout=timeout)
except OSError:
return "unreachable"
buf = b""
with s:
s.settimeout(timeout)
try:
s.sendall(b"transport info\r\n")
end = time.time() + timeout
while time.time() < end:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
m = re.search(rb"208 transport info:.*?status: (\w+)", buf, re.S)
if m:
return m.group(1).decode().lower()
if b"120 connection rejected" in buf:
return "busy"
except socket.timeout:
pass
except OSError:
return "unreachable" if not buf else "answered"
return "answered" if buf else "hung"
def execute_fix(cfg, fix_id, chk):
"""Run one entry from config "fixes" (in its own thread). Only listed commands ever run.
Returns True if the command ran and succeeded."""
@@ -1113,16 +1167,25 @@ def execute_fix(cfg, fix_id, chk):
log.info("FIX %s -> %s%s", label, "ok" if r.returncode == 0 else "exit %d" % r.returncode,
(": " + detail) if detail else "")
return r.returncode == 0
if kind == "hyperdeck_reboot":
with socket.create_connection((chk["ip"], 9993), timeout=3) as s:
s.settimeout(3)
try:
s.recv(1024) # the deck's greeting
except socket.timeout:
pass
s.sendall(b"reboot\n")
log.info("FIX %s %s -> reboot sent", label, chk["conn"])
return True
if kind == "hyperdeck":
port = chk.get("port", 9993)
verdict = hyperdeck_probe(chk["ip"], port=port)
if verdict == "hung":
with socket.create_connection((chk["ip"], port), timeout=3) as s:
s.sendall(b"reboot\r\n")
log.info("FIX %s %s -> deck accepted a connection but did not answer: reboot sent",
label, chk["conn"])
return True
if verdict == "unreachable":
log.info("FIX %s %s -> deck does not answer on the network: nothing sent", label, chk["conn"])
return False
# The deck answers (and may be recording): leave it alone, reconnect Companion instead.
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 %s -> deck is fine (%s): Companion connection restarted %s",
label, chk["conn"], verdict, "ok" if ok else "FAILED")
return bool(ok)
log.warning("FIX %s: unknown kind %r", label, kind)
except Exception as e:
log.warning("FIX %s failed: %s", label, e)