Other campuses set themselves up: one install line, one join line per computer, keys made from what is found
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>
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
"""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},
|
||||
]
|
||||
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 = 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"}.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"
|
||||
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
|
||||
Reference in New Issue
Block a user