HyperDeck IPs from Companion, Vista Art-Net heartbeat, quiet Tascam module

- The hold-to-fix deck probe takes each HyperDeck's IP from its Companion
  connection's "ip" variable, so the config no longer repeats them.
- APPS shows "VISTA SILENT" when the Lighting PC's Art-Net poll (every 0.5 s)
  is not heard for 5 s: a crashed or closed Vista shows in seconds, not the
  minute macOS takes to drop a network MIDI peer.
- tascam-quiet/ builds the Tascam DA-6400 module 1.3.1-quiet: the recorder's
  "Illegal Status" warning is logged once a minute, not 5 times a second.
- README: hold-to-fix table fixed and updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:46:49 -04:00
co-authored by Claude Opus 5.5
parent d06798adf0
commit 77daf7424d
5 changed files with 186 additions and 17 deletions
+55 -3
View File
@@ -147,6 +147,8 @@ class Snapshot:
self.remote = {} # (host, command) -> True / False / None (could not ask)
self.dante_map = {} # Dante device name -> {"ip", "mac", "switch", "port", "shared", "poe", "seen"}
self.dante_map_time = 0.0 # last successful survey of the Dante switches
self.artnet_last = {} # source IP -> time its last Art-Net packet arrived
self.artnet_since = None # when the Art-Net listener started (None: not listening)
def var(self, label, name):
return self.vars.get((label, name))
@@ -434,6 +436,18 @@ def check_midi_rx(chk, snap, ctx):
return [("rx", Result(OK, info="%s %s" % (name, fmt_age(age))))]
def check_artnet(chk, snap, ctx):
"""A lighting program's Art-Net traffic (Vista polls for nodes every 0.5 s): its heartbeat."""
limit = chk.get("max_silence_seconds", 5)
since = snap.artnet_since
if since is None or ctx["now"] - since < limit: # not listening, or only just started
return [("artnet", Result(OK))]
last = snap.artnet_last.get(chk["source"])
if last is None or ctx["now"] - last > limit:
return [("artnet", Result(ERR, chk.get("msg", "ART-NET SILENT"), named=True))]
return [("artnet", Result(OK))]
def remote_key(chk):
return "%s\0%s" % (chk["host"], chk["cmd"])
@@ -449,7 +463,7 @@ CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer,
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote,
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
}
# Checks whose connection a long press may restart (Dante is shared, so not that one).
@@ -471,6 +485,7 @@ def needed_variables(cfg):
t, label = chk["type"], chk.get("conn")
if t == "hyperdeck":
fast += [(label, "status"), (label, "slotId"), (label, "recordingTime")]
slow.append((label, "ip")) # the deck's address, from Companion
elif t == "dante":
fast.append((label, "devices"))
elif t == "tascam":
@@ -607,6 +622,7 @@ class Engine:
self.executor = execute_fix
self.poe_executor = execute_poe
self.notes = {} # tile id -> hold feedback {"text", "level", "until"}
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
self.now = 0.0
# raw results for every check key
@@ -779,9 +795,16 @@ class Engine:
if fix_id:
if chk["type"] == "hyperdeck" and hyperdeck_recording(snap, chk["conn"]):
continue
# One run per fix, except a HyperDeck reboot, which is per deck.
# One run per fix, except a HyperDeck fix, which is per deck. The deck's
# address comes from its Companion connection (remembered if it goes away).
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck"
ident = ("fix", fix_id, chk.get("ip", "") if per_deck else "")
if per_deck:
ip = chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"])
if not ip:
continue
self.deck_ips[chk["conn"]] = ip
chk = dict(chk, ip=ip)
ident = ("fix", fix_id, chk["conn"] if per_deck else "")
if ident not in seen:
seen.add(ident)
plan.append((ident, "fix", fix_id, chk))
@@ -1311,6 +1334,33 @@ def execute_fix(cfg, fix_id, chk):
return False
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."""
import select as _select
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
try:
s.bind(("", port))
except OSError as e:
log.warning("Art-Net listener could not start on port %d: %s", port, e)
return None
def loop():
snap.artnet_since = time.time()
while not stop.is_set():
if _select.select([s], [], [], 0.5)[0]:
data, (src, _) = s.recvfrom(2048)
if data[:8] == b"Art-Net\x00":
snap.artnet_last = dict(snap.artnet_last, **{src: time.time()})
s.close()
t = threading.Thread(target=loop, name="artnet", daemon=True)
t.start()
return t
class Periodic(threading.Thread):
def __init__(self, name, interval, fn, stop):
super().__init__(name=name, daemon=True)
@@ -1400,6 +1450,8 @@ def start_pollers(cfg, snap, stop, data_dir=None):
]
for t in threads:
t.start()
if any(c["type"] == "artnet" for t in cfg["tiles"] for c in t.get("checks", [])):
start_artnet_listener(snap, stop)
return threads