settings/discover.py reads Companion's connections (and their addresses and channel counts), the Dante devices, the network MIDI sessions here and on joined Macs, this Mac's wired network, and what runs on each joined computer. settings/autoconf.py turns that into keys, checks, start and restart fixes and a page layout the way North Attleboro's were made (group keys only when not everything fits; receivers fold first), all through the existing builders and rules. settings/campus.py prepares Companion through its internal API (the panel's custom variables and the blink trigger), picks free pages, applies the first config, serves the program kit, the campus install line and the Mac/PC join lines, and looks every ten minutes to add new gear (discover.auto; never during a service or a recording, never removes, never re-adds what someone took off). Settings > Auto-setup shows what was found and the lines to paste. install.sh keeps the kit and makes the panel's login key. Checked on North Attleboro read-only: discovery finds all 43 connections, 19 Dante devices, four Macs' MIDI sessions and seven computers, and a from-scratch build reproduces NA's layout. The Companion calls were tried with a throwaway variable and a disabled trigger, then removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
13 KiB
Python
313 lines
13 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
|
|
MAC_LOOK = "echo SP_PROCS; ps -axo comm= 2>/dev/null; echo SP_APPS; ls -d /Applications/*.app /Applications/*/*.app 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.")]}
|
|
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()})
|
|
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) + ["/usr/bin/python3", "-"], input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
|
|
out["midi"] = sp.parse_remote_midi(m.stdout) 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
|