Files
status-indicators/settings/discover.py
T
christianandClaude Opus 5.5 6d1cfea2f7 Every campus matches NA, tailored: Restart page, Companion-Mac programs, NA's auto-fix hours, nightly backups, restart from NA
- Auto-setup: programs on the Companion Mac itself (ProPresenter at Woonsocket) get a local process
  check and local start/restart fixes; every campus gets a Restart page (keys arrive as computers
  join); NA's auto-fix hours (Tue, Wed, Sun); a program found after its Companion connection joins
  that key instead of making a second one. settings.campus rebuild remakes a campus from what is
  found now, keeping its name, PIN and pages; upgrade-companion brings Companion to 5.0.7 from NA's
  copy (/campus/companion.zip), as Woonsocket was; setup stops with that advice on an older one.
- NA's CAMPUSES page has a RESTART key per campus; the CAMPUS page shows that campus's Restart page
  and a hold there restarts it at that campus.
- Backups: backup/backup-campuses.sh, nightly 04:30 on NA (installed everywhere, acts on the hub),
  into Drive > Waters Creative > Apps > Status Panel Backups/<campus>, 30 kept, alert email via the
  checklist's alert.js. Campuses report the account to back up with. sites/ws/config.json in the repo.
- Woonsocket rebuilt: 9 keys (PROP with ProPresenter on its Mac), Restart page 20 (SUPERRACK, VISTA,
  ABLETON, PROP), auto-fix on NA's hours; all OK. Both campuses backed up to Drive by the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:17:04 -04:00

325 lines
14 KiB
Python

"""Auto-setup's eyes: what this campus has, read from Companion, Dante, the network MIDI sessions,
this Mac's own network, and the computers that joined (each one ran the join line once).
look(cfg, data_dir) the inventory autoconf.py turns into keys
register(data_dir, hello) a computer's join report, kept in computers.json
Read-only everywhere: nothing here changes Companion, a session or a computer. Every remote
command is a fixed string; names from the network only ever come back as data.
"""
import ipaddress
import json
import os
import re
import subprocess
import time
import urllib.parse
import urllib.request
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
import status_panel as sp
TIMEOUT = 8
# find, not a glob: in zsh (the Macs' login shell) a pattern that matches nothing aborts the whole line.
MAC_LOOK = "echo SP_PROCS; ps -axo comm= 2>/dev/null; echo SP_APPS; find /Applications -maxdepth 3 -name '*.app' -prune 2>/dev/null; echo SP_END"
WIN_LOOK = "echo SP_PROCS & tasklist /FO CSV /NH 2>nul & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_END"
HOST_FIELDS = ("host", "bonjourHost", "serverHost", "ip", "address")
# --- the computers that joined ---------------------------------------------------------
def registry_path(data_dir):
return os.path.join(data_dir, "computers.json")
def registered(data_dir):
try:
with open(registry_path(data_dir)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def clean_hello(hello, peer_ip):
"""A join report, kept to the fields auto-setup uses and checked: it comes from the network."""
def s(v, n=80):
return re.sub(r"[^\w .@:\-()'+]", "", str(v or ""))[:n]
ips = [str(ipaddress.ip_address(i)) for i in (hello.get("ips") or []) if _is_ip(i)][:12]
if peer_ip and _is_ip(peer_ip) and peer_ip not in ips:
ips.insert(0, peer_ip)
user = str(hello.get("user") or "")
if not re.fullmatch(r"[A-Za-z0-9._\-][A-Za-z0-9._\- ]{0,39}", user): # as given: a name to log in with is never "cleaned up"
raise ValueError("no usable user name")
return {"name": s(hello.get("name")) or user, "hostname": s(hello.get("hostname"), 60), "user": user,
"os": "windows" if str(hello.get("os", "")).lower().startswith("win") else "mac", "ips": ips,
"macs": [m for m in (str(x).lower().replace("-", ":") for x in hello.get("macs") or []) if re.fullmatch(r"([0-9a-f]{2}:){5}[0-9a-f]{2}", m)][:4],
"tasks": [s(t, 60) for t in hello.get("tasks") or []][:20], "at": int(time.time())}
def _is_ip(v):
try:
ipaddress.ip_address(str(v))
return True
except ValueError:
return False
def register(data_dir, hello, peer_ip=None):
entry = clean_hello(hello, peer_ip)
reg = registered(data_dir)
key = entry["name"]
reg[key] = entry
sp.write_json(registry_path(data_dir), reg)
return entry
# --- this Mac --------------------------------------------------------------------------
def run(argv, timeout=TIMEOUT):
try:
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def wifi_ports():
out = run(["/usr/sbin/networksetup", "-listallhardwareports"])
ports, last = set(), ""
for line in out.splitlines():
if line.startswith("Hardware Port:"):
last = line.split(":", 1)[1].strip()
elif line.startswith("Device:") and last in ("Wi-Fi", "AirPort"):
ports.add(line.split(":", 1)[1].strip())
return ports
def interfaces():
"""{interface: (ip, network)} for every IPv4 address on this Mac, from ifconfig."""
out, cur = {}, None
for line in run(["/sbin/ifconfig"]).splitlines():
m = re.match(r"^(\S+?):", line)
if m:
cur = m.group(1)
continue
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+) netmask 0x([0-9a-f]{8})", line)
if m and cur:
mask = ipaddress.ip_address(int(m.group(2), 16))
net = ipaddress.ip_network("%s/%s" % (m.group(1), mask), strict=False)
out.setdefault(cur, (m.group(1), str(net)))
return out
def gateway_of(interface):
out = run(["/usr/sbin/netstat", "-rn", "-f", "inet"])
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == "default" and parts[-1] == interface:
return parts[1]
return ""
def this_mac():
"""Its names and addresses, and which network is the wired one: the interface with a gateway that
is neither Wi-Fi nor a link-local (Dante) address, preferring the one the default route uses."""
ifs = interfaces()
wifi = wifi_ports()
route = run(["/sbin/route", "-n", "get", "default"])
m = re.search(r"interface: (\S+)", route)
default_if = m.group(1) if m else ""
wired = None
for name in [default_if] + sorted(ifs):
if name in ifs and name not in wifi and not ifs[name][0].startswith(("127.", "169.254.")) and gateway_of(name):
wired = name
break
if wired is None:
wired = next((n for n in sorted(ifs) if n not in wifi and not ifs[n][0].startswith(("127.", "169.254."))), None)
info = {"name": run(["/usr/sbin/scutil", "--get", "ComputerName"]).strip(),
"hostname": (run(["/usr/sbin/scutil", "--get", "LocalHostName"]).strip() + ".local").lstrip("."),
"ips": [ip for n, (ip, _) in sorted(ifs.items()) if not ip.startswith("127.")]}
# What runs on this Mac itself (ProPresenter on Woonsocket's Companion Mac), with each app's path.
ps = run(["/bin/ps", "-axo", "comm="]).splitlines()
info["procs"] = sorted({os.path.basename(p.strip()) for p in ps if p.strip()})
info["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app" for p in ps if ".app/Contents/MacOS/" in p}
info["exes"] = {os.path.basename(p.strip()): p.strip() for p in ps if ".app/Contents/MacOS/" in p}
if wired:
ip, net = ifs[wired]
# A /24 or smaller on a campus usually sits inside a /16 the rest of the gear shares: use what is configured.
info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
return info
# --- Companion -------------------------------------------------------------------------
def companion_get(cfg, path, timeout=5):
c = cfg.get("companion") or {}
url = "http://%s:%s%s" % (c.get("host", "127.0.0.1"), c.get("port", 8000), path)
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
def companion_var(cfg, label, name):
try:
return companion_get(cfg, "/api/variable/%s/%s/value" % (urllib.parse.quote(label, safe=""), urllib.parse.quote(name, safe="")), 3)
except OSError:
return ""
def connection_hosts(cfg):
"""{label: address} from Companion's own database, opened read-only (the host field differs by module)."""
import sqlite3
path = sp.companion_db_path(cfg)
if not 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()
out = {}
for (value,) in rows:
try:
inst = json.loads(value)
except (TypeError, ValueError):
continue
if not isinstance(inst, dict) or not inst.get("label"):
continue
conf = inst.get("config") or {}
host = next((str(conf[k]).strip() for k in HOST_FIELDS if isinstance(conf.get(k), str) and conf[k].strip()), "")
out[inst["label"]] = host.split("|")[-1].rsplit(":", 1)[0] if host.count(":") == 1 else host
return out
def connections(cfg):
raw = json.loads(companion_get(cfg, "/api/connections"))
items = raw if isinstance(raw, list) else list(raw.values()) if isinstance(raw, dict) else []
hosts = connection_hosts(cfg)
out = []
for c in items:
if not isinstance(c, dict) or not c.get("label"):
continue
entry = {"label": c["label"], "module": c.get("moduleId") or c.get("instance_type") or "", "enabled": c.get("enabled", True) is not False,
"host": hosts.get(c["label"], ""), "status": ((c.get("status") or {}).get("category") if isinstance(c.get("status"), dict) else "")}
if entry["module"] in ("shure-wireless", "shure-psm1000"):
probe = "ch_%d_battery_bars" if entry["module"] == "shure-wireless" else "ch_%d_rf_mute"
n = 0
for ch in range(1, 9):
if companion_var(cfg, c["label"], probe % ch).strip() in ("", "undefined"):
break
n = ch
entry["channels"] = n or None
out.append(entry)
return out
def dante(cfg, conns):
label = next((c["label"] for c in conns if c["module"] == "audinate-dantecontroller" and c["enabled"]), None)
if not label:
return {}
try:
devices = json.loads(companion_var(cfg, label, "devices") or "[]")
except ValueError:
devices = []
return {"conn": label, "devices": sorted(d for d in devices if isinstance(d, str))}
# --- the computers ---------------------------------------------------------------------
def sections(out):
parts, cur = {}, None
for line in out.splitlines():
line = line.strip()
if line.startswith("SP_") and line[3:].isupper():
cur = line[3:]
parts[cur] = []
elif cur:
parts[cur].append(line)
return parts
def look_at(cfg, alias, host):
"""What runs on one computer: its programs (and, on a Mac, its network MIDI sessions)."""
os_name = host.get("os", "mac")
try:
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [MAC_LOOK if os_name == "mac" else WIN_LOOK], capture_output=True, text=True, timeout=TIMEOUT)
except subprocess.TimeoutExpired:
return {"reachable": False}
if r.returncode != 0 or "SP_END" not in r.stdout:
return {"reachable": False, "error": (r.stderr or r.stdout).strip()[-160:]}
got = sections(r.stdout)
out = {"reachable": True}
if os_name == "mac":
out["procs"] = sorted({os.path.basename(p.strip()) for p in got.get("PROCS", []) if p.strip()})
# Where each running program's app is, from its own path (".../SuperRack SoundGrid.app/Contents/MacOS/...").
out["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app"
for p in got.get("PROCS", []) if ".app/Contents/MacOS/" in p}
out["apps"] = sorted(a.strip() for a in got.get("APPS", []) if a.strip().endswith(".app"))
try:
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
out["python"] = m.returncode != 97
if not out["python"]:
out["midi"] = sp.jxa_midi(cfg, alias) or {}
except subprocess.TimeoutExpired:
out["midi"] = {}
else:
out["procs"] = sorted({row.split('","')[0].strip('"') for row in got.get("PROCS", []) if row.startswith('"')})
out["tasks"] = sorted({row.split('","')[0].strip('"').split("\\")[-1] for row in got.get("TASKS", [])
if row.startswith('"\\StatusPanel\\')})
return out
def computers(cfg, data_dir):
"""The joined computers and the ones already in the settings, each looked at over SSH."""
found = {}
hosts = dict(cfg.get("hosts") or {})
for alias, h in hosts.items():
if h.get("self") or h.get("disabled") or not h.get("ssh"):
continue
ip = str(h["ssh"]).split("@")[-1]
found[alias] = {"name": h.get("name", alias), "os": h.get("os", "mac"), "user": str(h["ssh"]).split("@")[0], "ip": ip,
"ips": [ip], "macs": [h["mac"]] if h.get("mac") else []}
known_ips = {c["ip"] for c in found.values()}
taken = set(found) | set(hosts)
nets = [(this_mac().get("wired") or {}).get("net")]
for name, j in registered(data_dir).items():
ip = next((i for i in j["ips"] if any(n and ipaddress.ip_address(i) in ipaddress.ip_network(n) for n in nets)), j["ips"][0] if j["ips"] else None)
if not ip or ip in known_ips:
continue
from settings.autoconf import slug
alias = slug(j["name"].replace(" ", "_"), taken)
taken.add(alias)
found[alias] = {"name": j["name"], "hostname": j.get("hostname", ""), "os": j["os"], "user": j["user"], "ip": ip, "ips": j["ips"],
"macs": j.get("macs") or [], "tasks": j.get("tasks") or []}
probe_cfg = dict(cfg, hosts=dict(hosts, **{a: {"ssh": "%s@%s" % (c["user"], c["ip"]), "os": c["os"]} for a, c in found.items()}))
for alias, c in found.items():
seen = look_at(probe_cfg, alias, probe_cfg["hosts"][alias])
if c.get("tasks") and "tasks" in seen:
seen["tasks"] = sorted(set(seen["tasks"]) | set(c["tasks"]))
c.update(seen)
return found
# --- everything ------------------------------------------------------------------------
def look(cfg, data_dir):
inv = {"ingest": this_mac(), "at": int(time.time())}
# A campus whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
rule = [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
if rule and inv["ingest"].get("wired"):
inv["ingest"]["wired"]["net"] = rule[0]
try:
inv["connections"] = connections(cfg)
except (OSError, ValueError) as e:
inv["connections"], inv["companion_error"] = [], str(e)
inv["dante"] = dante(cfg, inv["connections"])
try:
import coremidi
inv["midi"] = {"": coremidi.sessions()}
except Exception as e: # noqa: BLE001 - no CoreMIDI (tests, Linux): no sessions here
inv["midi"], inv["midi_error"] = {}, str(e)
inv["computers"] = computers(cfg, data_dir)
for alias, c in inv["computers"].items():
if c.get("midi"):
inv["midi"][alias] = c.pop("midi")
return inv