diff --git a/README.md b/README.md index 0cb0011..caceda0 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The **page number key** on every page follows the ALL button. It's green when ev | RESI | decoder (RossTalk connection and ping); resi-studio-cues; Resi cloud reachable | | NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21) | | INGEST | Resi bridge and lobby captions services running; ProdDash running; free disk | -| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted | +| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. | A problem has to last 3 seconds before it shows, so one bad reading doesn't trigger an alarm. Recovery shows immediately. diff --git a/config.json b/config.json index 14c3ca6..fec900b 100644 --- a/config.json +++ b/config.json @@ -147,11 +147,17 @@ ] }, { "id": "arena", "label": "ARENA", "pos": [3, 7], "checks": [{ "type": "conn", "conn": "arena", "fix": "arena" }] }, - { "id": "axient1", "label": "AXIENT 1", "checks": [{ "type": "shure", "conn": "Axient_1", "channels": 4 }] }, - { "id": "axient2", "label": "AXIENT 2", "checks": [{ "type": "shure", "conn": "Axient_2", "channels": 4 }] }, - { "id": "ulxd", "label": "ULXD", "checks": [{ "type": "shure", "conn": "ULXD-4", "channels": 1 }] }, - { "id": "psm1", "label": "PSM 1", "checks": [{ "type": "psm", "conn": "PSM_1", "channels": 2 }] }, - { "id": "psm2", "label": "PSM 2", "checks": [{ "type": "psm", "conn": "PSM_2", "channels": 2 }] }, - { "id": "psm3", "label": "PSM 3", "checks": [{ "type": "psm", "conn": "PSM_3", "channels": 2 }] } + { "id": "axient1", "label": "AXIENT 1", "checks": [{ "type": "shure", "conn": "Axient_1", "channels": 4 }, + { "type": "ping", "conn": "Axient_1", "host": "10.1.1.101", "name": "AXIENT 1" }] }, + { "id": "axient2", "label": "AXIENT 2", "checks": [{ "type": "shure", "conn": "Axient_2", "channels": 4 }, + { "type": "ping", "conn": "Axient_2", "host": "10.1.1.102", "name": "AXIENT 2" }] }, + { "id": "ulxd", "label": "ULXD", "checks": [{ "type": "shure", "conn": "ULXD-4", "channels": 1 }, + { "type": "ping", "conn": "ULXD-4", "host": "10.1.1.106", "name": "ULXD" }] }, + { "id": "psm1", "label": "PSM 1", "checks": [{ "type": "psm", "conn": "PSM_1", "channels": 2 }, + { "type": "ping", "conn": "PSM_1", "host": "10.1.1.103", "name": "PSM 1" }] }, + { "id": "psm2", "label": "PSM 2", "checks": [{ "type": "psm", "conn": "PSM_2", "channels": 2 }, + { "type": "ping", "conn": "PSM_2", "host": "10.1.1.104", "name": "PSM 2" }] }, + { "id": "psm3", "label": "PSM 3", "checks": [{ "type": "psm", "conn": "PSM_3", "channels": 2 }, + { "type": "ping", "conn": "PSM_3", "host": "10.1.1.105", "name": "PSM 3" }] } ] } diff --git a/status_panel.py b/status_panel.py index 7116998..7ed7d54 100644 --- a/status_panel.py +++ b/status_panel.py @@ -135,6 +135,7 @@ class Snapshot: self.midi = {} # session name -> {"peers": [...]} self.remote_midi = {} # host -> the same, read on that Mac over SSH (None: unreachable) self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at + self.conn_hosts = {} # Companion connection label -> its "host" setting self.midi_time = 0.0 self.mdns = set() # Bonjour _apple-midi._udp instance names self.mdns_time = 0.0 @@ -357,8 +358,14 @@ def check_tascam(chk, snap, ctx): return out +def ping_target(chk, snap): + """A ping check names a "host", or a Companion "conn" whose own address is used (so a new IP set + in Companion needs no config change); "host" is then only the fallback.""" + return (snap.conn_hosts.get(chk["conn"]) if chk.get("conn") else None) or chk.get("host") + + def check_ping(chk, snap, ctx): - fails = snap.ping.get(chk["host"]) + fails = snap.ping.get(ping_target(chk, snap)) if fails is not None and fails >= chk.get("failures", 2): msg = chk.get("msg") or chk.get("name", chk["host"]) + " NO PING" return [("ping", Result(ERR, msg, service_only=chk.get("service_only", False), named=True))] @@ -1154,6 +1161,47 @@ def remember_peers(snap, host, sessions): snap.peer_addr[(host, name, p["name"])] = p["address"] +def companion_db_path(cfg): + if cfg["companion"].get("db"): + return os.path.expanduser(cfg["companion"]["db"]) + root = os.path.expanduser("~/Library/Application Support/companion") + vers = [d for d in os.listdir(root) if re.fullmatch(r"v\d+\.\d+", d)] if os.path.isdir(root) else [] + if not vers: + return None + newest = max(vers, key=lambda d: tuple(int(x) for x in d[1:].split("."))) + return os.path.join(root, newest, "db.sqlite") + + +def companion_conn_hosts(path): + """{connection label: host} from Companion's own settings database, opened read-only.""" + import sqlite3 + if not path or not os.path.exists(path): + return {} + db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2) + try: + rows = db.execute("SELECT value FROM instances").fetchall() + finally: + db.close() + hosts = {} + for (value,) in rows: + try: + inst = json.loads(value) + except (TypeError, ValueError): + continue + host = (inst.get("config") or {}).get("host") if isinstance(inst, dict) else None + if inst.get("label") and isinstance(host, str) and host.strip(): + hosts[inst["label"]] = host.strip() + return hosts + + +def poll_conn_hosts(snap, cfg): + if any(c["type"] == "ping" and c.get("conn") for t in cfg["tiles"] for c in t.get("checks", [])): + try: + snap.conn_hosts = companion_conn_hosts(companion_db_path(cfg)) or snap.conn_hosts + except Exception as e: # Companion's schema is its own business: fall back to "host" + log.warning("could not read connection addresses from Companion: %s", e) + + def poll_midi(snap, cfg): import coremidi snap.midi, snap.midi_time = coremidi.sessions(), time.time() @@ -1529,8 +1577,10 @@ def load_config(path): return cfg -def ping_hosts(cfg): - hosts = [c["host"] for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "ping"] +def ping_hosts(cfg, snap=None): + hosts = [ping_target(c, snap) if snap else c.get("host") + for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "ping"] + hosts = [h for h in hosts if h] gw = cfg.get("network", {}).get("gateway") return list(dict.fromkeys(hosts + ([gw] if gw else []))) @@ -1557,7 +1607,8 @@ def start_pollers(cfg, snap, stop, data_dir=None): Periodic("connections", 1.0, lambda: poll_connections(c1, snap), stop), Periodic("variables", 1.0, lambda: poll_variables(c2, snap, fast), stop), Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), stop), - Periodic("pings", 3.0, lambda: poll_pings(snap, ping_hosts(cfg), iface), stop), + Periodic("conn-hosts", 60.0, lambda: poll_conn_hosts(snap, cfg), stop), + Periodic("pings", 3.0, lambda: poll_pings(snap, ping_hosts(cfg, snap), iface), stop), Periodic("midi", 2.0, lambda: poll_midi(snap, cfg), stop), Periodic("system", 10.0, lambda: poll_system(snap, cfg), stop), Periodic("bonjour", 15.0, lambda: poll_mdns(snap), stop), diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index de2b475..8be7910 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -251,6 +251,36 @@ class TestChecks(unittest.TestCase): finally: stop.set() + def test_shure_receivers_are_pinged_at_companions_address(self): + chk = {"type": "ping", "conn": "Axient_1", "host": "10.1.1.101", "name": "AXIENT 1"} + self.assertEqual(sp.ping_target(chk, self.snap), "10.1.1.101") # fallback + self.snap.conn_hosts = {"Axient_1": "10.1.1.201"} # moved in Companion + self.assertEqual(sp.ping_target(chk, self.snap), "10.1.1.201") + self.assertIn("10.1.1.201", sp.ping_hosts(CONFIG, self.snap)) + self.snap.ping["10.1.1.201"] = 2 + self.assertEqual(sp.check_ping(chk, self.snap, self.ctx)[0][1].msg, "AXIENT 1 NO PING") + # every Shure receiver has one, and the RF group says which + self.snap.conn_hosts = {} + self.snap.ping["10.1.1.105"] = 3 + tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4) + self.assertEqual(tiles["rf"]["x"], "RF\nPSM 3 NO PING") + + def test_companion_conn_hosts_reads_its_database_read_only(self): + import sqlite3 + d = tempfile.mkdtemp() + path = os.path.join(d, "db.sqlite") + db = sqlite3.connect(path) + db.execute("CREATE TABLE instances (id STRING, value STRING)") + rows = [("a", {"label": "Axient_1", "config": {"host": "10.1.1.101", "port": 2202}}), + ("b", {"label": "OBS-Youtube", "config": {"host": " 10.1.9.126 "}}), + ("c", {"label": "no-host", "config": {}}), ("d", "not json")] + db.executemany("INSERT INTO instances VALUES (?, ?)", + [(i, v if isinstance(v, str) else json.dumps(v)) for i, v in rows]) + db.commit() + db.close() + self.assertEqual(sp.companion_conn_hosts(path), {"Axient_1": "10.1.1.101", "OBS-Youtube": "10.1.9.126"}) + self.assertEqual(sp.companion_conn_hosts(os.path.join(d, "missing.sqlite")), {}) + def test_fx_lights_midi_on_the_tracks_mac(self): engine = sp.Engine(CONFIG) self.assertEqual(engine.step(self.snap, self.now)["midi"]["x"], "MIDI\nOK") # not read yet: no verdict