"""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": "vista", "process": "Vista", "label": "VISTA", "open": "/Applications/Vista*.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 # North Attleboro's auto-fix hours, for every campus: Tuesday and Wednesday rehearsals, Sunday services. DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60, "restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"}, {"days": ["wed"], "start": "09:00", "end": "21:00"}, {"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"] if a["app"] == "superrack": # and its link to the Waves SoundGrid server item["checks"].append({"type": "remote", "template": "soundgrid", "params": {"host": alias}, "msg": "SOUNDGRID LOST"}) 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) # Programs on this Mac itself (the Companion Mac): a local process check and local start/restart fixes. ing = inv.get("ingest") or {} for a in MAC_APPS: exe = (ing.get("exes") or {}).get(a["process"]) app_path = (ing.get("paths") or {}).get(a["process"]) if not exe or not app_path: continue fid = slug("%s_ingest" % a["app"]) pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]] start = {"template": "mac_start", "params": {"host": "ingest", "process": a["process"], "app": app_path}, "label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]} again = {"template": "mac_restart", "params": {"host": "ingest", "process": a["process"], "app": app_path, "wait_seconds": 10, "force": True}, "label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]} item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"), "label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}], "fixes": {fid: start, "restart_" + fid: again}, "restart": [{"label": a["label"], "fix": "restart_" + fid}] if a.get("restart") else [], "also": [], "fold": a.get("fold")} out.append(item) for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) + ["127.0.0.1"]: if ip: 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 conn.get("module") == "behringer-x32" and "m32" in label.lower() and not many: text = "M32" # a Midas M32 on the same module (Woonsocket) 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 = [] # Whose session a peer is, by its name: a peer joined by name or by an IPv6 address gets that Mac's wired address. owner = {sname: alias for alias, sessions in (inv.get("midi") or {}).items() if alias for sname in (sessions or {})} 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 = owner.get(p.get("name")) or 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 port = (((inv.get("midi") or {}).get(alias) or {}).get(p.get("name")) or {}).get("port") or 5004 addr = "%s:%d" % (ip, port) 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)) # One check per link, not one per end (2026-10-20: two links pulled at Woonsocket read "4 DOWN"): # keep the end whose Mac says whether the link is live (newer macOS reports a peer's state). def end_of(host): return host or "ingest" def reports_state(host, sname): return any("state" in p for p in (((inv.get("midi") or {}).get(host) or {}).get(sname) or {}).get("peers") or []) by_pair = {} for ident, c in midi_checks: other = owner.get(c["peer"]) or computer_by_ip(inv, ip_of(c["address"])) or c["peer"] pair = frozenset((end_of(c.get("host", "")), other)) best = by_pair.get(pair) if best is None or (reports_state(c.get("host", ""), c["session"]) and not reports_state(best[1].get("host", ""), best[1]["session"])): by_pair[pair] = (ident, c) midi_checks = [x for x in midi_checks if x in by_pair.values()] 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 cfg.get("restart_page"): 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): """The Restart page always exists (a campus set up before its computers joined gets their keys later).""" rp = cfg.get("restart_page") if not rp: return 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 # A program whose Companion connection already has a key (Woonsocket's PROP got a second key): # its own checks join that key. home = next((t for t in cfg["tiles"] if any(("conn:" + c.get("conn", "")) in it["also"] for c in t.get("checks", []) if c.get("conn"))), None) if home is not None: for fid, f in it["fixes"].items(): cfg["fixes"].setdefault(fid, f) home["checks"] += [c for c in it["checks"] if not c.get("conn")] if it["restart"] and cfg.get("restart_page"): restart_layout(cfg, it["restart"]) added.append("%s checks on the %s key" % (key_text(it["label"]), home["label"])) now_seen += [it["ident"]] + it["also"] 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