2026-10-20 14:31:32 -04:00
|
|
|
"""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
|
2026-10-20 16:33:28 -04:00
|
|
|
# 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"
|
2026-10-20 14:31:32 -04:00
|
|
|
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.")]}
|
|
|
|
|
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()})
|
2026-10-20 16:33:28 -04:00
|
|
|
# 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}
|
2026-10-20 14:31:32 -04:00
|
|
|
out["apps"] = sorted(a.strip() for a in got.get("APPS", []) if a.strip().endswith(".app"))
|
|
|
|
|
try:
|
2026-10-20 16:33:28 -04:00
|
|
|
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
|
2026-10-20 14:31:32 -04:00
|
|
|
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
|
2026-10-20 16:33:28 -04:00
|
|
|
out["python"] = m.returncode != 97
|
2026-10-20 16:48:28 -04:00
|
|
|
if not out["python"]:
|
|
|
|
|
out["midi"] = sp.jxa_midi(cfg, alias) or {}
|
2026-10-20 14:31:32 -04:00
|
|
|
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
|