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:
2026-10-20 14:31:32 -04:00
co-authored by Claude Opus 5.5
parent 348dc43b4d
commit 3976667bba
11 changed files with 1629 additions and 2 deletions
+517
View File
@@ -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