On a Mac without the Command Line Tools, /usr/bin/python3 is a stub that opens Apple's installer on that Mac's screen: Woonsocket's Tracks and FOH Macs showed it after joining (and would every ten minutes). Python now runs on another Mac only when xcode-select says it is there (MIDI reads, MIDI reconnects, the Computers probe); without it those Macs' own MIDI sessions are simply not read. The app list used a glob that zsh (the Macs' login shell) turns into an error when one pattern matches nothing, so no app was ever found there: find instead, and a running program's app path is taken from the program itself. New recipes: SuperRack SoundGrid and X32-Edit (AUDIO). Woonsocket now has ABLETON, SUPERRACK and X32 EDIT keys, all OK, and NA's page 99 shows them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
522 lines
28 KiB
Python
522 lines
28 KiB
Python
"""Auto-setup: what a campus has (settings/discover.py's inventory) becomes its keys, checks, fixes
|
|
and page layout - the same building blocks North Attleboro's were made of by hand.
|
|
|
|
Pure functions, no I/O:
|
|
|
|
build(base, inv, campus, pin) a whole config for a new campus, from the empty template
|
|
extend(cfg, inv, seen) a running campus: only what is new (never removes anything)
|
|
|
|
Everything that comes out passes schema.validate and can be changed on the settings page like any
|
|
hand-made setting. Gear auto-setup does not know (a generic connection, an app it has no recipe
|
|
for) is reported, not guessed at.
|
|
"""
|
|
import copy
|
|
import ipaddress
|
|
import re
|
|
|
|
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
|
from settings import builders as b
|
|
import status_panel as sp
|
|
|
|
# --- what auto-setup knows -------------------------------------------------------------
|
|
|
|
# Group keys sum up their members on a big campus, in this order along the top row.
|
|
GROUPS = ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"]
|
|
# Who gets a cell of its own when not everything fits (the rest show inside their group key only):
|
|
# a row of wireless receivers folds first, as at North Attleboro.
|
|
PRIORITY = ["REC", "SHOW", "AUDIO", "STREAM", "VIDEO", "RF"]
|
|
|
|
# Companion modules: which group, what kind of key, the key's text when there is only one.
|
|
COMPANION = {
|
|
"bmd-hyperdeck": {"group": "REC", "kind": "hyperdeck", "label": "HD"},
|
|
"tascam-da-6400": {"group": "REC", "kind": "tascam", "label": "TASCAM"},
|
|
"audinate-dantecontroller": {"group": "AUDIO", "kind": "dante", "label": "DANTE"},
|
|
"behringer-x32": {"group": "AUDIO", "kind": "conn", "label": "X32"},
|
|
"behringer-wing": {"group": "AUDIO", "kind": "conn", "label": "WING"},
|
|
"midas-m32": {"group": "AUDIO", "kind": "conn", "label": "M32"},
|
|
"allenheath-sq": {"group": "AUDIO", "kind": "conn", "label": "SQ"},
|
|
"allenheath-dlive": {"group": "AUDIO", "kind": "conn", "label": "DLIVE"},
|
|
"allenheath-avantis": {"group": "AUDIO", "kind": "conn", "label": "AVANTIS"},
|
|
"yamaha-rcp": {"group": "AUDIO", "kind": "conn", "label": "YAMAHA"},
|
|
"soundcraft-ui": {"group": "AUDIO", "kind": "conn", "label": "UI24"},
|
|
"digico-sd": {"group": "AUDIO", "kind": "conn", "label": "DIGICO"},
|
|
"shure-wireless": {"group": "RF", "kind": "shure", "label": "MICS"},
|
|
"shure-psm1000": {"group": "RF", "kind": "psm", "label": "PSM"},
|
|
"renewedvision-propresenter-api": {"group": "SHOW", "kind": "conn", "label": "PROP", "app": "propresenter"},
|
|
"ableton-osc": {"group": "SHOW", "kind": "conn", "label": "ABLETON", "app": "ableton"},
|
|
"leolabs-ableset": {"group": "SHOW", "kind": "conn", "label": "ABLESET", "app": "ableton"},
|
|
"bmd-atem": {"group": "VIDEO", "kind": "conn", "label": "ATEM"},
|
|
"bmd-videohub": {"group": "VIDEO", "kind": "conn", "label": "VIDEOHUB"},
|
|
"resolume-arena": {"group": "VIDEO", "kind": "conn", "label": "ARENA", "app": "arena"},
|
|
"novastar-controller": {"group": "VIDEO", "kind": "conn", "label": "LED", "ping": True},
|
|
"obs-studio": {"group": "STREAM", "kind": "conn", "label": "OBS", "app": "obs"},
|
|
"resi-decoders": {"group": "STREAM", "kind": "conn", "label": "RESI", "ping": True},
|
|
}
|
|
|
|
# Programs on the computers: by OS, the process (Mac, as pgrep sees it) or program file (Windows).
|
|
# "app" ties a program to a Companion connection of the same name; "restart" adds a Restart page key.
|
|
MAC_APPS = [
|
|
{"app": "propresenter", "process": "ProPresenter", "label": "PROP", "open": "/Applications/ProPresenter*.app", "restart": True},
|
|
{"app": "ableton", "process": "Live", "label": "ABLETON", "open": "/Applications/Ableton Live*.app", "ableton": True, "restart": True},
|
|
{"app": "arena", "process": "Arena", "label": "ARENA", "open": "/Applications/Resolume Arena*/Arena.app", "restart": True},
|
|
{"app": "obs", "process": "obs", "label": "OBS", "open": "/Applications/OBS.app", "restart": True},
|
|
{"app": "translate", "process": "Church Translate", "label": "TRANSLATE", "open": "/Applications/Church Translate.app", "restart": True},
|
|
{"app": "protools", "process": "Pro Tools", "label": "PRO TOOLS", "open": "/Applications/Pro Tools.app", "restart": True},
|
|
{"app": "superrack", "process": "SuperRack SoundGrid", "label": "SUPERRACK", "open": "/Applications/*SuperRack*.app", "restart": True, "group": "AUDIO"},
|
|
{"app": "x32edit", "process": "X32-Edit", "label": "X32 EDIT", "open": "/Applications/X32-Edit.app", "restart": False, "group": "AUDIO"},
|
|
]
|
|
WIN_APPS = [
|
|
{"app": "vista", "exe": "Vista.exe", "label": "VISTA", "task": "Start Vista", "restart": True},
|
|
{"app": "arena", "exe": "Arena.exe", "label": "ARENA", "task": "Start Arena", "restart": True},
|
|
{"app": "obs", "exe": "obs64.exe", "label": "OBS", "task": "Start OBS", "restart": True},
|
|
{"app": "propresenter", "exe": "ProPresenter.exe", "label": "PROP", "task": "Start ProPresenter", "restart": True},
|
|
{"app": "rtpmidi", "exe": "rtpMIDI.exe", "label": "RTPMIDI", "task": "Start rtpMIDI", "restart": False, "fold": "midi_wired"},
|
|
]
|
|
# The task names the Windows join script makes, by program file (it reads WIN_APPS through campus.py).
|
|
WIN_TASKS = {a["exe"]: a["task"] for a in WIN_APPS}
|
|
|
|
# Fixes shared by every key of a kind (the same words North Attleboro's carry).
|
|
SHARED_FIXES = {
|
|
"hyperdeck": {"kind": "hyperdeck", "template": "hyperdeck", "params": {}, "short": "HYPERDECK", "verify_seconds": 15,
|
|
"hung_note": "STILL OFFLINE\nHOLD AGAIN",
|
|
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck"},
|
|
"midi_reconnect": {"kind": "midi_connect", "template": "midi_connect", "params": {}, "short": "RECONNECT",
|
|
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address"},
|
|
}
|
|
|
|
STATUS_ROWS, COLS = 4, 8
|
|
DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
|
|
"restart_connections": True, "windows": [{"days": ["sun"], "start": "05:00", "end": "14:00"}]}
|
|
|
|
|
|
# --- small helpers ---------------------------------------------------------------------
|
|
|
|
def slug(text, taken=()):
|
|
base = re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_") or "key"
|
|
out, n = base, 2
|
|
while out in taken:
|
|
out, n = "%s_%d" % (base, n), n + 1
|
|
return out
|
|
|
|
|
|
def key_text(label, limit=10):
|
|
t = re.sub(r"[_\s]+", " ", str(label)).strip().upper()
|
|
return t if len(t) <= limit else t[:limit].rstrip()
|
|
|
|
|
|
def tag_for(name):
|
|
return key_text(name, 12)
|
|
|
|
|
|
def ip_of(address):
|
|
ip = sp.peer_ip(address)
|
|
return str(ip) if ip else None
|
|
|
|
|
|
def in_nets(nets, address):
|
|
ip = sp.peer_ip(address)
|
|
return ip is not None and any(ip in ipaddress.ip_network(n, strict=False) for n in nets)
|
|
|
|
|
|
def computer_by_ip(inv, ip):
|
|
for alias, c in (inv.get("computers") or {}).items():
|
|
if ip and ip in (c.get("ips") or []):
|
|
return alias
|
|
return None
|
|
|
|
|
|
def referenced(cfg):
|
|
"""Everything a config already watches, by the same identities discovery uses."""
|
|
out = set()
|
|
for t in sp.all_tiles(cfg):
|
|
for c in t.get("checks", []):
|
|
if c.get("conn"):
|
|
out.add("conn:" + c["conn"])
|
|
if c.get("type") == "dante":
|
|
out.update("dante:" + d for d in (c.get("devices") or {}))
|
|
if c.get("type") == "midi_peer":
|
|
out.add("midi:%s|%s|%s" % (c.get("host", ""), c.get("session"), c.get("peer")))
|
|
if c.get("type") == "midi_wired":
|
|
out.add("midi_wired")
|
|
p = c.get("params") or {}
|
|
if c.get("type") == "remote" and p.get("host") and (p.get("process") or p.get("exe")):
|
|
out.add("app:%s|%s" % (p["host"], p.get("process") or p.get("exe")))
|
|
for alias, h in (cfg.get("hosts") or {}).items():
|
|
out.add("host:" + alias)
|
|
ip = str(h.get("ssh", "")).split("@")[-1]
|
|
if ip:
|
|
out.add("hostip:" + ip)
|
|
return out
|
|
|
|
|
|
# --- from the inventory to items ------------------------------------------------------
|
|
|
|
def items(inv, nets):
|
|
"""[{ident, group, label, checks, fixes, restart, also_idents}] for everything auto-setup can watch,
|
|
plus the computer records ({alias: host}). Each item is one key."""
|
|
out, hosts = [], {}
|
|
comps = inv.get("computers") or {}
|
|
for alias, c in comps.items():
|
|
if not c.get("reachable"):
|
|
continue
|
|
hosts[alias] = {"name": c.get("name") or alias, "os": c.get("os", "mac"), "ssh": "%s@%s" % (c["user"], c["ip"]),
|
|
"role": "found by auto-setup", "runs": [{"kind": "sshd"}]}
|
|
if c.get("macs"):
|
|
hosts[alias]["mac"] = c["macs"][0]
|
|
# Programs on the computers, by app, so a Companion connection to the same program joins its key.
|
|
apps_at = {} # (app, ip) -> item
|
|
for alias, c in comps.items():
|
|
if not c.get("reachable"):
|
|
continue
|
|
procs = set(c.get("procs") or [])
|
|
catalog = MAC_APPS if c.get("os", "mac") == "mac" else WIN_APPS
|
|
for a in catalog:
|
|
if c.get("os", "mac") == "mac":
|
|
if a["process"] not in procs:
|
|
continue
|
|
app_path = (c.get("paths") or {}).get(a["process"]) or newest_app(c.get("apps") or [], a["open"])
|
|
if not app_path:
|
|
continue
|
|
check = {"type": "remote", "template": "mac_program", "params": {"host": alias, "process": a["process"]},
|
|
"msg": "%s NOT RUNNING" % a["label"]}
|
|
if a.get("ableton"):
|
|
start = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": False}}
|
|
again = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": True}}
|
|
else:
|
|
start = {"template": "mac_start", "params": {"host": alias, "process": a["process"], "app": app_path}}
|
|
again = {"template": "mac_restart", "params": {"host": alias, "process": a["process"], "app": app_path,
|
|
"wait_seconds": 10, "force": True}}
|
|
ident = "app:%s|%s" % (alias, a["process"])
|
|
else:
|
|
if a["exe"] not in procs or a["task"] not in (c.get("tasks") or []):
|
|
continue # no start task on that PC: a hold could not start it
|
|
check = {"type": "remote", "template": "win_program", "params": {"host": alias, "exe": a["exe"]},
|
|
"msg": "%s NOT RUNNING" % a["label"]}
|
|
start = {"template": "win_start", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
|
|
again = {"template": "win_restart", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
|
|
ident = "app:%s|%s" % (alias, a["exe"])
|
|
fid = slug("%s_%s" % (a["app"], alias))
|
|
pretty = {"propresenter": "ProPresenter", "ableton": "Ableton", "arena": "Resolume Arena", "obs": "OBS", "translate": "Church Translate",
|
|
"protools": "Pro Tools", "vista": "Vista", "rtpmidi": "rtpMIDI", "superrack": "SuperRack", "x32edit": "X32-Edit"}.get(a["app"], a["label"].title())
|
|
where = c.get("name") or alias
|
|
start = dict(start, label="start %s on %s if it is not running" % (pretty, where), short=a["label"])
|
|
again = dict(again, label="restart %s on %s (closes it first)" % (pretty, where), short=a["label"])
|
|
item = {"ident": ident, "group": "SHOW" if a["app"] not in ("obs",) else "STREAM", "label": a["label"],
|
|
"checks": [dict(check, fix=fid)], "fixes": {fid: start}, "restart": [], "also": [], "fold": a.get("fold")}
|
|
if a.get("restart"):
|
|
rid = "restart_" + fid
|
|
item["fixes"][rid] = again
|
|
item["restart"].append({"label": a["label"], "fix": rid})
|
|
if a["app"] == "arena":
|
|
item["group"] = "VIDEO"
|
|
if a.get("group"):
|
|
item["group"] = a["group"]
|
|
out.append(item)
|
|
apps_at[(a["app"], c.get("ip"))] = item
|
|
for ip in c.get("ips") or []:
|
|
apps_at.setdefault((a["app"], ip), item)
|
|
# Companion connections.
|
|
counts = {}
|
|
for conn in inv.get("connections") or []:
|
|
counts[conn.get("module")] = counts.get(conn.get("module"), 0) + 1
|
|
hd_n = 0
|
|
for conn in sorted(inv.get("connections") or [], key=lambda c: c["label"]):
|
|
spec = COMPANION.get(conn.get("module"))
|
|
if not spec or not conn.get("enabled", True):
|
|
continue
|
|
label = conn["label"]
|
|
ident = "conn:" + label
|
|
many = counts.get(conn["module"], 0) > 1
|
|
text = spec["label"] if not many else key_text(label)
|
|
if spec["kind"] == "hyperdeck":
|
|
hd_n += 1
|
|
text = "HD%d" % hd_n if many else "HD"
|
|
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
|
|
"checks": [{"type": "hyperdeck", "conn": label, "fix": {"conn": "hyperdeck"}}],
|
|
"fixes": {"hyperdeck": dict(SHARED_FIXES["hyperdeck"])}})
|
|
continue
|
|
if spec["kind"] == "tascam":
|
|
fid = slug("reconnect_" + label)
|
|
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
|
|
"checks": [{"type": "tascam", "conn": label, "fix": fid}, {"type": "ping", "conn": label, "name": text}],
|
|
"fixes": {fid: {"kind": "reconnect", "template": "reconnect", "params": {}, "conn": label, "short": text,
|
|
"verify_seconds": 20, "label": "reconnect Companion to %s" % label}}})
|
|
continue
|
|
if spec["kind"] == "dante":
|
|
devices = {d: key_text(d, 12) for d in (inv.get("dante") or {}).get("devices") or []}
|
|
out.append({"ident": ident, "group": "AUDIO", "label": "DANTE", "restart": [], "also": ["dante:" + d for d in devices],
|
|
"checks": [{"type": "dante", "conn": label, "devices": devices, "fix": {"*": sp.DANTE_AUTO}}], "fixes": {}})
|
|
continue
|
|
if spec["kind"] in ("shure", "psm"):
|
|
n = int(conn.get("channels") or (4 if spec["kind"] == "shure" else 2))
|
|
out.append({"ident": ident, "group": "RF", "label": text if many else ("MICS" if spec["kind"] == "shure" else "PSM"),
|
|
"restart": [], "also": [],
|
|
"checks": [{"type": spec["kind"], "conn": label, "channels": n}, {"type": "ping", "conn": label}], "fixes": {}})
|
|
continue
|
|
checks = [{"type": "conn", "conn": label}]
|
|
if spec.get("ping") and conn.get("host"):
|
|
checks.append({"type": "ping", "conn": label})
|
|
app_item = apps_at.get((spec.get("app"), ip_of(conn.get("host")))) if spec.get("app") else None
|
|
if app_item: # the program's own key carries its connection too
|
|
app_item["checks"] = checks + app_item["checks"]
|
|
app_item["also"].append(ident)
|
|
continue
|
|
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": {}, "restart": [], "also": []})
|
|
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
|
|
midi_checks = []
|
|
for host, sessions in (inv.get("midi") or {}).items():
|
|
for sname, sess in (sessions or {}).items():
|
|
for p in sess.get("peers") or []:
|
|
addr = p.get("address") or ""
|
|
if not in_nets(nets, addr):
|
|
alias = computer_named(inv, p.get("name"))
|
|
ip = alias and next((i for i in comps[alias].get("ips") or [] if in_nets(nets, i)), None)
|
|
if not ip:
|
|
continue # no wired address known: the wired check will name it
|
|
addr = "%s:5004" % ip
|
|
chk = {"type": "midi_peer", "session": sname, "peer": p["name"], "address": addr, "fix": "midi_reconnect",
|
|
"tag": tag_for(p["name"])}
|
|
if host:
|
|
chk["host"] = host
|
|
midi_checks.append(("midi:%s|%s|%s" % (host, sname, p["name"]), chk))
|
|
if midi_checks:
|
|
checks = [c for _, c in midi_checks] + [{"type": "midi_wired", "nets": list(nets), "fix": "midi_reconnect"}]
|
|
out.append({"ident": "midi_wired", "group": "SHOW", "label": "MIDI", "checks": checks,
|
|
"fixes": {"midi_reconnect": dict(SHARED_FIXES["midi_reconnect"])}, "restart": [],
|
|
"also": [i for i, _ in midi_checks]})
|
|
# A helper program (rtpMIDI) rides inside the key it serves when that key exists.
|
|
for it in [x for x in out if x.get("fold")]:
|
|
home = next((x for x in out if x["ident"] == it["fold"]), None)
|
|
if home is not None:
|
|
home["checks"] += it["checks"]
|
|
home["fixes"].update(it["fixes"])
|
|
home["also"].append(it["ident"])
|
|
out.remove(it)
|
|
return out, hosts
|
|
|
|
|
|
def computer_named(inv, name):
|
|
n = str(name or "").lower()
|
|
for alias, c in (inv.get("computers") or {}).items():
|
|
if n and n in (str(c.get("name", "")).lower(), str(c.get("hostname", "")).lower().replace(".local", "")):
|
|
return alias
|
|
return None
|
|
|
|
|
|
def newest_app(apps, pattern):
|
|
"""The newest /Applications entry matching a glob-ish pattern ("/Applications/ProPresenter*.app")."""
|
|
rx = re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
|
|
hits = sorted(a for a in apps if rx.match(a))
|
|
return hits[-1] if hits else None
|
|
|
|
|
|
# --- layout ----------------------------------------------------------------------------
|
|
|
|
def free_cells(taken, rows=STATUS_ROWS):
|
|
return [(r, c) for r in range(rows) for c in range(1, COLS) if (r, c) not in taken]
|
|
|
|
|
|
def layout(cfg, new_tiles, restart_items):
|
|
"""Put the keys on the Status page: everything fits, so no group keys; otherwise group keys along
|
|
the top row and the members after them (a member with no room left shows inside its group only)."""
|
|
fixed = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
|
|
if restart_items:
|
|
fixed.add(tuple(cfg["restart_page"]["jump_pos"]))
|
|
cells = free_cells(fixed)
|
|
groups = [g for g in GROUPS if any(t["_group"] == g for t in new_tiles)]
|
|
if len(new_tiles) > len(cells) and len(groups) > 1:
|
|
for g in groups:
|
|
members = [t["id"] for t in new_tiles if t["_group"] == g]
|
|
cfg["tiles"].append({"id": slug(g, {t["id"] for t in cfg["tiles"]} | {t["id"] for t in new_tiles}),
|
|
"label": g, "members": members, "_group": g})
|
|
group_tiles = [t for t in cfg["tiles"] if t.get("_group")]
|
|
for t in group_tiles:
|
|
if cells:
|
|
t["pos"] = list(cells.pop(0))
|
|
# Who gets a cell: by priority while cells last; then placed in group order, so a group reads as a block.
|
|
chosen = set()
|
|
for g in PRIORITY:
|
|
for t in new_tiles:
|
|
if t["_group"] == g and len(chosen) < len(cells):
|
|
chosen.add(t["id"])
|
|
for t in new_tiles:
|
|
if t["id"] in chosen:
|
|
t["pos"] = list(cells.pop(0))
|
|
cfg["tiles"].append(t)
|
|
for t in cfg["tiles"]:
|
|
t.pop("_group", None)
|
|
|
|
|
|
def place_new(cfg, tile):
|
|
"""One more key on a running campus: the first free cell, else inside its group, else on ALL only."""
|
|
taken = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
|
|
if cfg.get("restart_page"):
|
|
taken.add(tuple(cfg["restart_page"]["jump_pos"]))
|
|
cells = free_cells(taken)
|
|
group = next((t for t in cfg["tiles"] if t.get("label") == tile.get("_group") and t.get("members") is not None), None)
|
|
if cells:
|
|
tile["pos"] = list(cells[0])
|
|
if group is not None:
|
|
group["members"].append(tile["id"])
|
|
tile.pop("_group", None)
|
|
cfg["tiles"].append(tile)
|
|
|
|
|
|
def restart_layout(cfg, restart_items):
|
|
if not restart_items:
|
|
cfg.pop("restart_page", None)
|
|
return
|
|
rp = cfg["restart_page"]
|
|
taken = {tuple(rp["back_pos"])} | {tuple(k["pos"]) for k in rp.get("keys", [])}
|
|
cells = free_cells(taken)
|
|
ids = {k["id"] for k in rp.get("keys", [])}
|
|
for it in restart_items:
|
|
kid = slug("rs_" + it["label"], ids)
|
|
ids.add(kid)
|
|
if not cells:
|
|
break
|
|
rp.setdefault("keys", []).append({"id": kid, "label": key_text(it["label"]), "fix": it["fix"], "pos": list(cells.pop(0))})
|
|
|
|
|
|
# --- the two entry points --------------------------------------------------------------
|
|
|
|
def nets_of(inv):
|
|
w = (inv.get("ingest") or {}).get("wired") or {}
|
|
return [w["net"]] if w.get("net") else []
|
|
|
|
|
|
def build(base, inv, campus, pin, pages=(19, 20)):
|
|
"""A whole config for a new campus: the template, this Mac, and a key for everything found."""
|
|
cfg = copy.deepcopy(base)
|
|
ing = inv.get("ingest") or {}
|
|
wired = ing.get("wired") or {}
|
|
site = cfg["site"]
|
|
site.update({"id": campus["id"], "name": campus["name"], "ingest_name": ing.get("name") or "%s Ingest" % campus["name"],
|
|
"ingest_hostname": ing.get("hostname") or "", "ingest_ips": [ip for ip in [wired.get("ip")] + list(ing.get("ips") or []) if ip],
|
|
"editing_code": pin})
|
|
site["ingest_ips"] = list(dict.fromkeys(site["ingest_ips"]))
|
|
cfg["discover"] = {"auto": True}
|
|
cfg["page"], cfg["page_name"] = pages[0], "%d\\nStatus" % pages[0]
|
|
cfg["restart_page"] = {"page": pages[1], "page_name": "%d\\nRestart" % pages[1], "jump_pos": [3, 6], "back_pos": [3, 0], "keys": []}
|
|
cfg["network"] = dict(cfg.get("network") or {}, interface=wired.get("interface") or "en0", gateway=wired.get("gateway") or "")
|
|
cfg["service_windows"] = [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}]
|
|
cfg["hosts"]["ingest"]["name"] = site["ingest_name"]
|
|
for t in cfg["tiles"]:
|
|
if t.get("kind") == "network":
|
|
t["pos"] = [3, 7]
|
|
found, hosts = items(inv, nets_of(inv))
|
|
cfg["hosts"].update(hosts)
|
|
tiles, restart, taken = [], [], {t["id"] for t in cfg["tiles"]}
|
|
for it in found:
|
|
tid = slug(it["label"], taken)
|
|
taken.add(tid)
|
|
tiles.append({"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
|
|
for fid, f in it["fixes"].items():
|
|
cfg["fixes"].setdefault(fid, f)
|
|
restart += it["restart"]
|
|
tiles.sort(key=lambda t: GROUPS.index(t["_group"]))
|
|
layout(cfg, tiles, restart)
|
|
restart_layout(cfg, restart)
|
|
finish(cfg)
|
|
a = dict(DEFAULT_AUTO_FIX, **{k: v for k, v in (cfg.get("auto_fix") or {}).items() if k not in ("windows", "fixes")})
|
|
a["fixes"] = gentle(cfg)
|
|
cfg["auto_fix"] = a
|
|
return cfg, [it["ident"] for it in found] + ["host:" + h for h in hosts] + [x for it in found for x in it["also"]]
|
|
|
|
|
|
def extend(cfg, inv, seen):
|
|
"""What is new since the last look, added to a running campus. Never removes or changes what is
|
|
there; a thing seen before (and so deliberately taken off by someone) is not added again.
|
|
Returns (new config, [what was added, in words], [identities now seen])."""
|
|
cfg = copy.deepcopy(cfg)
|
|
have = referenced(cfg)
|
|
skip = have | set(seen)
|
|
nets = nets_of(inv) or [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", [])]
|
|
found, hosts = items(inv, nets)
|
|
added, now_seen = [], []
|
|
for alias, h in hosts.items():
|
|
if "host:" + alias in skip or "hostip:" + h["ssh"].split("@")[-1] in have:
|
|
continue
|
|
cfg["hosts"][alias] = h
|
|
added.append("computer %s" % h["name"])
|
|
now_seen.append("host:" + alias)
|
|
taken = {t["id"] for t in sp.all_tiles(cfg)}
|
|
for it in found:
|
|
# A key that already exists for this thing: only its new parts (a Dante device, a MIDI peer).
|
|
if it["ident"] in skip:
|
|
fresh = [x for x in it["also"] if x not in skip]
|
|
if fresh:
|
|
added += merge_parts(cfg, it, fresh)
|
|
now_seen += fresh
|
|
continue
|
|
if any(c.get("params", {}).get("host") not in cfg["hosts"] for c in it["checks"] if c.get("template")):
|
|
continue
|
|
tid = slug(it["label"], taken)
|
|
taken.add(tid)
|
|
for fid, f in it["fixes"].items():
|
|
cfg["fixes"].setdefault(fid, f)
|
|
place_new(cfg, {"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
|
|
if it["restart"] and cfg.get("restart_page"):
|
|
restart_layout(cfg, it["restart"])
|
|
added.append("%s key" % key_text(it["label"]))
|
|
now_seen += [it["ident"]] + it["also"]
|
|
if added:
|
|
finish(cfg)
|
|
auto = cfg.setdefault("auto_fix", dict(DEFAULT_AUTO_FIX, fixes=[]))
|
|
auto["fixes"] = list(dict.fromkeys(list(auto.get("fixes") or []) + [f for f in gentle(cfg) if f not in auto.get("fixes", [])
|
|
and any(f in it["fixes"] for it in found)]))
|
|
return cfg, added, now_seen
|
|
|
|
|
|
def merge_parts(cfg, it, fresh):
|
|
"""New Dante devices and MIDI peers go into the key that already watches the rest."""
|
|
words = []
|
|
for t in sp.all_tiles(cfg):
|
|
for c in t.get("checks", []):
|
|
if c.get("type") == "dante" and it["ident"] == "conn:" + c.get("conn", ""):
|
|
for x in fresh:
|
|
if x.startswith("dante:"):
|
|
name = x[6:]
|
|
c.setdefault("devices", {})[name] = key_text(name, 12)
|
|
words.append("Dante device %s" % name)
|
|
if it["ident"] == "midi_wired":
|
|
midi = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None)
|
|
if midi is not None:
|
|
wired_at = next(i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired")
|
|
for c in it["checks"]:
|
|
if c["type"] == "midi_peer" and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) in fresh:
|
|
midi["checks"].insert(wired_at, c)
|
|
wired_at += 1
|
|
words.append("MIDI link %s" % c["peer"])
|
|
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
|
|
return words
|
|
|
|
|
|
def finish(cfg):
|
|
"""Fixes and remote checks built by their builders, words filled in, as the page would."""
|
|
for fid, f in list(cfg["fixes"].items()):
|
|
if f.get("template") and "run" not in f and b.TEMPLATES.get(f["template"], {}).get("render"):
|
|
built = b.build(cfg, f)
|
|
built.setdefault("label", b.suggest_label(cfg, built) or fid)
|
|
short = key_text(fid.replace("restart_", "").split("_")[0], 10)
|
|
built.setdefault("short", short)
|
|
cfg["fixes"][fid] = built
|
|
elif f.get("template") and not f.get("label"):
|
|
f["label"] = b.suggest_label(cfg, f) or fid
|
|
for t in cfg["tiles"]:
|
|
for i, c in enumerate(t.get("checks", [])):
|
|
if c.get("template"):
|
|
t["checks"][i] = b.build_check(cfg, c)
|
|
|
|
|
|
def gentle(cfg):
|
|
"""The fixes auto-fix may run by itself: starting what is closed and reconnecting - never a restart."""
|
|
out = []
|
|
for fid, f in cfg["fixes"].items():
|
|
if fid.startswith("restart_") or f.get("restart_ok") or f.get("never_auto"):
|
|
continue
|
|
if f.get("kind") in ("hyperdeck", "midi_connect", "reconnect") or (f.get("template") in ("mac_start", "win_start", "ableton")
|
|
and not (f.get("params") or {}).get("restart")):
|
|
out.append(fid)
|
|
return out
|