"""A location that sets itself up. On the location's Mac that runs Companion, one line installs the panel from another location's settings page and runs `setup`: Companion gets the panel's custom variables and blink clock, two free pages are picked, the gear is found (settings/discover.py) and turned into keys (settings/autoconf.py), and that config is applied like any Apply on the page. Each other computer runs one join line (Terminal on a Mac, an administrator PowerShell on a PC); from then on the settings page looks again every ten minutes and adds what is new by itself (Settings > Site: "Add new gear by itself"). python3 -m settings.location setup --id ab [--name "Apollo Beach"] python3 -m settings.location look what it finds (nothing changes) python3 -m settings.location add add what is new now """ import argparse import base64 import gzip import hashlib import io import json import os import random import re import tarfile import threading import time from settings import PARENT # noqa: F401 (puts the project folder on sys.path) from settings import autoconf, discover, scripts import push_page import schema import status_panel as sp CAMPUSES = {"na": "North Attleboro", "ab": "Apollo Beach", "sc": "Southcoast", "ws": "Woonsocket"} VARIABLES = ("sp_state", "sp_hb", "sp_cmd", "flash_tick") TRIGGER_NAME = "flash_tick_500" LOOK_EVERY = 600 SEEN, LAST = "discover-seen.json", "discover-last.json" # What the program kit carries (no settings, keys or history of the location that serves it). KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py", "nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json") KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup") # --- Companion: the panel's variables, its blink clock, and two free pages --------------- def flash_trigger_export(build="5.0.7", name=TRIGGER_NAME, enabled=True): """A Companion export holding one trigger: flash_tick 1 for half a second, every second.""" def nid(): return base64.urlsafe_b64encode(os.urandom(15)).decode().rstrip("=")[:21] def setvar(v): return {"id": nid(), "definitionId": "custom_variable_set_value", "connectionId": "internal", "options": {"name": {"isExpression": False, "value": "flash_tick"}, "value": {"isExpression": False, "value": v}}, "type": "action", "children": {}} tid = nid() trig = {"type": "trigger", "options": {"name": name, "enabled": enabled, "sortOrder": 0}, "actions": [setvar("1"), {"id": nid(), "definitionId": "wait", "connectionId": "internal", "options": {"time": {"isExpression": False, "value": "500"}}, "type": "action", "children": {}}, setvar("0")], "condition": [], "events": [{"id": nid(), "type": "interval", "enabled": True, "options": {"seconds": 1}}], "localVariables": []} return tid, {"version": 12, "type": "full", "companionBuild": build, "triggers": {tid: trig}, "triggerCollections": [], "custom_variables": {}, "customVariablesCollections": [], "instances": {}, "connectionCollections": []} def companion_state(cfg): raw = discover.companion_get(cfg, "/int/export/custom?buttons=false&connections=false&surfaces=&triggers=true&customVariables=true" "&expressionVariables=false&includeSecrets=false&imageLibrary=false&format=json", 10) d = json.loads(raw) names = {(t.get("options") or {}).get("name") for t in (d.get("triggers") or {}).values()} return set(d.get("custom_variables") or {}), names def companion_prepare(cfg): """Create what the pages need inside Companion and is otherwise made by hand in its UI. Returns words.""" comp = cfg.get("companion") or {} host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)) have_vars, triggers = companion_state(cfg) done = [] missing = [v for v in VARIABLES if v not in have_vars] if not missing and TRIGGER_NAME in triggers: return done ws = push_page.WebSocket(host, port, "/trpc") try: api = push_page.Trpc(ws) for v in missing: api.mutation("customVariables.create", {"name": v, "defaultVal": "0" if v == "flash_tick" else ""}) done.append("custom variable %s" % v) if TRIGGER_NAME not in triggers: tid, export = flash_trigger_export() data = gzip.compress(json.dumps(export).encode()) session = api.mutation("importExport.prepareImport.start", {"name": "status-panel-blink.companionconfig", "size": len(data)}) api.mutation("importExport.prepareImport.uploadChunk", {"sessionId": session, "offset": 0, "data": base64.b64encode(data).decode()}) summary = api.mutation("importExport.prepareImport.complete", {"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(), "userData": None}) if isinstance(summary, list) and summary and summary[0]: raise RuntimeError("Companion could not read the blink trigger: %s" % summary[0]) api.mutation("importExport.importTriggers", {"selectedTriggerIds": [tid], "connectionIdRemapping": {}, "replaceExisting": False}) done.append("the %s trigger (the keys' blink clock)" % TRIGGER_NAME) finally: ws.close() return done def pick_pages(cfg, count=2, start=19): """Pages that hold nothing a person made (or only this panel's keys): the first `count` from `start`.""" comp = cfg.get("companion") or {} host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)) out = [] for page in list(range(start, 100)) + list(range(2, start)): try: ok, _ = push_page.safe_target(push_page.fetch_page(host, port, page)) except (OSError, ValueError): continue if ok: out.append(page) if len(out) == count: return out raise RuntimeError("Companion has no %d empty pages for the status panel" % count) # --- the first setup -------------------------------------------------------------------- def template(data_dir): for path in (os.path.join(data_dir, "kit", "sites", "_template", "config.json"), os.path.join(PARENT, "sites", "_template", "config.json")): if os.path.exists(path): with open(path) as f: return json.load(f) raise RuntimeError("no sites/_template/config.json to start from") def is_fresh(cfg): return (cfg.get("site") or {}).get("id") in ("new", "", None) and len(cfg.get("tiles") or []) <= 3 def load_seen(data_dir): try: with open(os.path.join(data_dir, SEEN)) as f: return set(json.load(f)) except (OSError, ValueError): return set() def save_seen(data_dir, seen): sp.write_json(os.path.join(data_dir, SEEN), sorted(seen)) def wait_applied(applier, limit=240): t0 = time.time() while time.time() - t0 < limit: st = applier.status or {} if st.get("state") not in ("running",): return st time.sleep(1) return applier.status or {} def companion_version_problem(cfg, standalone=False): """None when this Companion can take the panel (5.x), else what to tell the person.""" try: discover.companion_get(cfg, "/api/connections", 5) return None except OSError: pass try: discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5) if standalone: return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n" " https://bitfocus.io/companion (5.0.7, the .dmg), then on this Mac run\n" " cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion --from ~/Downloads/\n" "(its settings come along; the old version stays installed), then run the installer again.") return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North " "Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n" " cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion\n" "then paste the install line again.") except OSError: return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again." def setup(data_dir, cid, name=None, say=print, home=None, standalone=False): """The first setup of a location (or, when it is set up already, a look for what is new). Standalone: it reports to nobody and nobody else sees it (Apollo Beach runs its own).""" from settings import server, campuses if home or standalone: # where it was installed from: the location list lives there data = campuses.load(data_dir) if standalone: data.pop("home", None) data["standalone"] = True else: data["home"] = home data.pop("standalone", None) campuses.save(data_dir, data) site = server.Site(data_dir) cfg = site.load() if not is_fresh(cfg): say("%s is set up already: looking for what is new." % (cfg.get("site") or {}).get("name", "This location")) return look_and_add(site, say=say, force=True) name = name or CAMPUSES.get(cid) or cid.upper() problem = companion_version_problem(cfg, standalone) if problem: raise RuntimeError(problem) for line in companion_prepare(cfg): say("Companion: added %s" % line) pages = pick_pages(cfg) say("Companion pages %d (status) and %d (restart) are free: using them." % tuple(pages)) inv = discover.look(cfg, data_dir) if inv.get("companion_error"): raise RuntimeError("Companion's HTTP API does not answer (%s): turn it on in Companion's settings, then run this again" % inv["companion_error"]) pin = "%04d" % random.SystemRandom().randrange(1000, 10000) new, seen = autoconf.build(template(data_dir), inv, {"id": cid, "name": name}, pin, pages) errors, _ = schema.validate(new) if errors: raise RuntimeError("the config auto-setup made does not pass the rules: %s" % "; ".join(errors[:4])) r = site.applier.start(new, "auto-setup: first setup of %s" % name, "auto-setup", "127.0.0.1") if not r.get("ok"): raise RuntimeError(r.get("error") or "the Apply did not start") st = wait_applied(site.applier) save_seen(data_dir, seen) keys = [t["label"] for t in new["tiles"] if not t.get("kind")] record(data_dir, {"at": int(time.time()), "added": ["%d keys: %s" % (len(keys), ", ".join(keys))], "inventory": summary(inv)}) say("%s: %s" % (st.get("state"), st.get("outcome", ""))) say("Keys: %s" % ", ".join(keys)) say("Editing code (PIN) for the settings page: %s" % pin) return new def rebuild(data_dir, say=print, pages=None): """A location's setup made again from what is found now (today's recipes), keeping its name, PIN, pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one).""" from settings import server site = server.Site(data_dir) cfg = site.load() s = cfg.get("site") or {} inv = discover.look(cfg, data_dir) if inv.get("companion_error"): raise RuntimeError("Companion does not answer: %s" % inv["companion_error"]) rp = (cfg.get("restart_page") or {}).get("page") if not rp: rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"]) pages = tuple(pages or (cfg["page"], rp)) if pages != (cfg["page"], rp): # moving (19/20 like every location): only onto pages that are free or ours comp = cfg.get("companion") or {} for p in pages: ok, why = push_page.safe_target(push_page.fetch_page(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), p)) if not ok: raise RuntimeError("page %d: %s" % (p, why)) new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), pages) new, kept = autoconf.keep_from(new, cfg) if kept: say("Kept (down right now, so not found): %s" % ", ".join(kept)) for k in ("contacts", "live_services", "ingest_name", "settings_port"): if k in s: new["site"][k] = s[k] new["discover"] = dict(new.get("discover") or {}, **(cfg.get("discover") or {})) errors, _ = schema.validate(new) if errors: raise RuntimeError("the rebuilt config does not pass the rules: %s" % "; ".join(errors[:4])) r = site.applier.start(new, "auto-setup: rebuilt with today's recipes", "auto-setup", "127.0.0.1") if not r.get("ok"): raise RuntimeError(r.get("error") or "the Apply did not start") st = wait_applied(site.applier) save_seen(data_dir, load_seen(data_dir) | set(seen)) say("%s: %s" % (st.get("state"), st.get("outcome", ""))) if st.get("state") == "done": for p in sorted({cfg["page"], rp} - set(pages)): say("Old page %d: %s" % (p, clear_old_page(cfg, p))) say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind"))) say("Restart page %s: %s" % (new.get("restart_page", {}).get("page"), ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet")) return new def clear_old_page(cfg, page): """A page left behind by a move is emptied, but only when it holds nothing a person made.""" comp = cfg.get("companion") or {} host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)) ok, why = push_page.safe_target(push_page.fetch_page(host, port, page)) if not ok: return "left as it is (%s)" % why ws = push_page.WebSocket(host, port, "/trpc") try: push_page.Trpc(ws).mutation("pages.clearPage", {"pageNumber": page}) finally: ws.close() return "cleared" def upgrade_companion(base, say=print, source=None): """Companion 5.0.7 from `base` (North Attleboro's settings page), as Woonsocket was upgraded on 2026-10-20: the running Companion is quit, the newest settings folder is copied for 5.0 (and marked newest, or the launcher stops on "Config version mismatch"), 5.0.7 is installed beside the old one and started.""" import glob import shutil import subprocess import urllib.request root = os.path.expanduser("~/Library/Application Support/companion") app = "/Applications/Companion 5.0.7.app" # The watchdog's CompanionKeeper must not reopen the old Companion in the middle of this. with open(os.path.join(PARENT, "companion-upgrading"), "w") as f: f.write(str(time.time())) if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io install_companion_from(source, app, say) if not os.path.exists(app): say("Downloading Companion 5.0.7 from %s ..." % base) tmp = "/tmp/companion-5.0.7.zip" with urllib.request.urlopen(base.rstrip("/") + "/campus/companion.zip", timeout=600) as r, open(tmp, "wb") as f: shutil.copyfileobj(r, f) subprocess.run(["/usr/bin/ditto", "-x", "-k", tmp, "/Applications/"], check=True) os.remove(tmp) subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", app], check=True) dbs = sorted(glob.glob(os.path.join(root, "v*", "db.sqlite")), key=os.path.getmtime) if not os.path.exists(os.path.join(root, "v5.0", "db.sqlite")): if not dbs: raise RuntimeError("no Companion settings found to bring along") newest = os.path.dirname(dbs[-1]) subprocess.run(["/usr/bin/pkill", "-TERM", "-f", "Companion.*\\.app/Contents/MacOS/Companion"]) for _ in range(40): if subprocess.run(["/usr/bin/pgrep", "-f", "Companion.*\\.app/Contents/MacOS/Companion"], capture_output=True).returncode: break time.sleep(0.5) shutil.copytree(newest, os.path.join(root, "v5.0")) say("Settings copied from %s to v5.0 (the old folder stays as it was)" % os.path.basename(newest)) for f in ("db.sqlite", "cache.sqlite"): p = os.path.join(root, "v5.0", f) if os.path.exists(p): os.utime(p) subprocess.run(["/usr/bin/open", "-g", app], check=True) for _ in range(90): try: discover.companion_get({"companion": {}}, "/api/connections", 2) say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.") try: os.remove(os.path.join(PARENT, "companion-upgrading")) except OSError: pass return True except OSError: time.sleep(1) raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac") def install_companion_from(source, app, say=print): """Companion copied from a downloaded .dmg or .zip to `app` (beside any older one).""" import shutil import subprocess import tempfile source = os.path.expanduser(source) tmp = tempfile.mkdtemp(prefix="companion-") try: if source.lower().endswith(".dmg"): mnt = os.path.join(tmp, "mnt") subprocess.run(["/usr/bin/hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mnt, source], check=True, capture_output=True) try: found = [n for n in os.listdir(mnt) if n.startswith("Companion") and n.endswith(".app")] if not found: raise RuntimeError("no Companion app inside %s" % source) subprocess.run(["/usr/bin/ditto", os.path.join(mnt, found[0]), app], check=True) finally: subprocess.run(["/usr/bin/hdiutil", "detach", mnt], capture_output=True) else: subprocess.run(["/usr/bin/ditto", "-x", "-k", source, tmp], check=True) found = [n for n in os.listdir(tmp) if n.startswith("Companion") and n.endswith(".app")] if not found: raise RuntimeError("no Companion app inside %s" % source) subprocess.run(["/usr/bin/ditto", os.path.join(tmp, found[0]), app], check=True) say("Companion installed as %s" % app) finally: shutil.rmtree(tmp, ignore_errors=True) def summary(inv): known = set(autoconf.COMPANION) return {"connections": len(inv.get("connections") or []), "not_known": sorted("%s (%s)" % (c["label"], c["module"]) for c in inv.get("connections") or [] if c["module"] not in known and c["module"] != "internal"), "dante": len((inv.get("dante") or {}).get("devices") or []), "computers": {a: {"name": c.get("name"), "os": c.get("os"), "ip": c.get("ip"), "reachable": bool(c.get("reachable")), "error": c.get("error", "")} for a, c in (inv.get("computers") or {}).items()}, "midi_sessions": sum(len(s or {}) for s in (inv.get("midi") or {}).values()), "wired": ((inv.get("ingest") or {}).get("wired") or {}).get("net")} def record(data_dir, result): sp.write_json(os.path.join(data_dir, LAST), result) # --- every ten minutes: what is new ------------------------------------------------------ def blocked(cfg, applier, now=None, asked=False): """Why auto-setup may not apply right now, or None: never during a recording; by itself, never in the auto-fix hours either (a person asking - Look now, setting up on site - may).""" now = now or time.time() live, why = applier.live() if live: return "live now (%s)" % why if not asked and sp.in_windows((cfg.get("auto_fix") or {}).get("windows") or [], now): return "inside the auto-fix hours (a service day): waits until they end" if applier.busy(): return "an Apply is running" return None def look_and_add(site, say=lambda s: None, force=False): """Look, and add what is new (when the location allows it, and not during a service).""" cfg = site.load() inv = discover.look(cfg, site.data_dir) seen = load_seen(site.data_dir) new, added, now_seen = autoconf.extend(cfg, inv, seen) result = {"at": int(time.time()), "added": [], "waiting": [], "inventory": summary(inv)} if added: auto = (cfg.get("discover") or {}).get("auto") why = blocked(cfg, site.applier, asked=force) if not (auto or force): result["waiting"] = added result["why"] = "adding by itself is off (Settings > Site)" elif why: result["waiting"] = added result["why"] = why else: errors, _ = schema.validate(new) if errors: result["waiting"], result["why"] = added, "would not pass the rules: %s" % "; ".join(errors[:3]) else: r = site.applier.start(new, "auto-setup found: %s" % ", ".join(added)[:180], "auto-setup", "127.0.0.1") if r.get("ok"): st = wait_applied(site.applier) if st.get("state") == "done": save_seen(site.data_dir, seen | set(now_seen)) result["added"] = added else: result["waiting"], result["why"] = added, st.get("outcome") or "the Apply did not finish" else: result["waiting"], result["why"] = added, r.get("error", "the Apply did not start") record(site.data_dir, result) say("added: %s" % (", ".join(result["added"]) or "nothing")) if result["waiting"]: say("waiting: %s (%s)" % (", ".join(result["waiting"]), result.get("why"))) return result def loop(site, stop, poke): """The settings page's own background look: every ten minutes, and soon after a computer joins.""" stop.wait(60) while not stop.is_set(): try: if (site.load().get("discover") or {}).get("auto") and not is_fresh(site.cfg): look_and_add(site) except Exception as e: # noqa: BLE001 - the page must keep running record(site.data_dir, dict(load_last(site.data_dir), error=str(e), at=int(time.time()))) poke.wait(LOOK_EVERY) if poke.is_set(): poke.clear() stop.wait(20) # a computer just joined: give its SSH a moment def load_campuses_home(data_dir): try: with open(os.path.join(data_dir, "campuses.json")) as f: return json.load(f).get("home") except (OSError, ValueError): return None def load_last(data_dir): try: with open(os.path.join(data_dir, LAST)) as f: return json.load(f) except (OSError, ValueError): return {} # --- the lines people run ---------------------------------------------------------------- def program_kit(data_dir): """The program as a .tgz: the installed code, the settings page, the templates, install.sh.""" buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: for f in KIT_FILES: p = os.path.join(data_dir, f) if os.path.exists(p): tar.add(p, f) for d in KIT_DIRS: p = os.path.join(data_dir, d) if os.path.isdir(p): tar.add(p, d, filter=lambda ti: None if "__pycache__" in ti.name or ti.name.endswith(".pyc") else ti) kit = os.path.join(data_dir, "kit") for name in ("install.sh", "README.md"): p = os.path.join(kit, name) if os.path.exists(p): tar.add(p, name) p = os.path.join(kit, "sites", "_template") if os.path.isdir(p): tar.add(p, "sites/_template") return buf.getvalue() def shq(s): return "'" + str(s).replace("'", "'\"'\"'") + "'" def install_script(base, cid, support_key=None): """What a new location's Companion Mac runs: install from `base` (this page), then set itself up.""" m = re.match(r"[a-z0-9]+", str(cid).lower()) cid = m.group(0)[:8] if m else "new" name = CAMPUSES.get(cid, cid.upper()) key = support_key or "" return """#!/bin/zsh # Status panel for %(name)s: installs on this Mac (the one that runs Companion) and sets itself up. # Safe to run again: it then updates the program and looks for new gear. set -e SRC=%(base)s if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this line again." /usr/bin/xcode-select --install 2>/dev/null || true exit 1 fi DIR="$HOME/status-panel-install" mkdir -p "$DIR" && cd "$DIR" echo "Downloading the status panel from $SRC ..." /usr/bin/curl -fsSL "$SRC/location/program.tgz" | /usr/bin/tar -xz [[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; } SUPPORT=%(key)s if [[ -n "$SUPPORT" ]]; then touch "$HOME/.ssh/authorized_keys" && chmod 600 "$HOME/.ssh/authorized_keys" grep -qF "$SUPPORT" "$HOME/.ssh/authorized_keys" || echo "$SUPPORT" >> "$HOME/.ssh/authorized_keys" fi zsh install.sh --site %(cid)s cd "$HOME/Library/Application Support/status-panel" /usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --home "$SRC" if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then echo echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely." fi # This Mac's wired address (the default route's port, not en0: at Southcoast en0 is a Dante-style link). IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}') ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname) echo echo "Next, on each other computer here (ProPresenter, Tracks, Lighting...):" echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh" echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)" echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex" """ % {"name": name, "base": shq(base), "key": shq(key), "cid": cid, "qname": shq(name)} def join_mac(cfg, base): key, _ = scripts.public_key(cfg) if not key: raise RuntimeError("this Mac has no login key yet (run the install line again)") ingest = (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac" return """#!/bin/sh # Joins this Mac to %(ingest)s's status panel: lets it log in with its key (no password) and says this Mac is here. KEY=%(key)s # Run as root (Apple Remote Desktop > Send UNIX Command): it is for whoever is logged in on this Mac. U=$(id -un); H="$HOME" if [ "$(id -u)" = 0 ]; then U=$(stat -f%%Su /dev/console) [ "$U" = root ] && { echo "Nobody is logged in on $(scutil --get ComputerName): log in, then send this again."; exit 1; } H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}') fi mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys" grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys" [ "$(id -u)" = 0 ] && chown -R "$U:staff" "$H/.ssh" [ "$(id -u)" = 0 ] && { /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1 || /bin/launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1; } if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then echo "Remote Login is off. Turning it on (it asks for this Mac's administrator password):" if ! sudo /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1; then echo "Please turn it on in System Settings > General > Sharing > Remote Login, then paste this line again." open "x-apple.systempreferences:com.apple.Sharing-Settings.extension" 2>/dev/null exit 1 fi fi esc() { printf '%%s' "$1" | sed 's/[\\\\"]//g'; } IPS=$(/sbin/ifconfig | awk '/inet /{print $2}' | grep -v '^127\\.' | sed 's/.*/"&"/' | paste -sd, -) BODY="{\\"user\\":\\"$(esc "$U")\\",\\"name\\":\\"$(esc "$(scutil --get ComputerName)")\\",\\"hostname\\":\\"$(esc "$(scutil --get LocalHostName).local")\\",\\"os\\":\\"mac\\",\\"ips\\":[$IPS]}" if /usr/bin/curl -fsS -X POST -H 'Content-Type: application/json' -d "$BODY" %(hello)s >/dev/null; then echo "Joined. %(ingest)s sets this Mac up in about a minute: watch its status page." else echo "Could not reach %(ingest)s at %(base)s - is this Mac on the wired network?" exit 1 fi """ % {"ingest": ingest, "key": shq(key), "hello": shq(base + "/api/hello"), "base": base} def join_windows(cfg, base): """The Windows set-up script, with the programs found running on that PC instead of a list.""" text = scripts.fill(scripts.template("windows-setup.ps1.template"), dict( scripts.site_values(cfg), PC_NAME="this PC", APPS=" # filled in below", PROGRAMS="the ones running now", SERVICES="'rtpMIDIService'")) known = "; ".join("'%s' = '%s'" % (a["exe"], a["task"].replace("Start ", "", 1)) for a in autoconf.WIN_APPS) detect = ("$known = @{ %s }\n" "$apps = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $known.ContainsKey((Split-Path $_.Path -Leaf)) } |\n" " Sort-Object Path -Unique | ForEach-Object { @{ Name = $known[(Split-Path $_.Path -Leaf)]; Exe = $_.Path } })" % known) text = re.sub(r"\$apps = @\(\n # filled in below\n\)", lambda m: detect, text) if "$known = @{" not in text: raise RuntimeError("the Windows template changed: cannot put the program finder in") text = text.replace("# Status panel remote access", "# Run in PowerShell as administrator: irm %s/join.ps1 | iex\n# Status panel remote access" % base, 1) # Not elevated: say how, before anything runs (SC's Lighting PC got a DISM error instead, 2026-10-21). text = text.replace("# Status panel remote access", """# Status panel remote access if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this as administrator: close this window, right-click Windows PowerShell > Run as administrator, then paste the line again. Nothing was changed.' }""", 1) text += """ # 5. Tell %(ingest)s this PC is here: it sets it up within a minute. $hello = @{ user = $env:USERNAME; name = $env:COMPUTERNAME; os = 'windows' ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike '127.*' } | ForEach-Object IPAddress) macs = @($nics | ForEach-Object MacAddress); tasks = @($apps | ForEach-Object { 'Start ' + $_.Name }) } try { Invoke-RestMethod -Method Post -Uri '%(hello)s' -ContentType 'application/json' -Body ($hello | ConvertTo-Json) | Out-Null Write-Host 'Joined: %(ingest)s sets this PC up in about a minute.' } catch { Write-Host ('Could not reach %(ingest)s: ' + $_.Exception.Message) } """ % {"ingest": (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac", "hello": base + "/api/hello"} return text # --- command line ------------------------------------------------------------------------ def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("what", choices=("setup", "look", "add", "rebuild", "upgrade-companion")) ap.add_argument("--id", default="new") ap.add_argument("--name") ap.add_argument("--data-dir", default=PARENT) ap.add_argument("--home", help="the settings page this location was installed from (the location list lives there)") ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every location)") ap.add_argument("--standalone", action="store_true", help="a location that runs on its own: reports to no other location") ap.add_argument("--from", dest="source", help="upgrade-companion: a Companion .dmg or .zip downloaded from bitfocus.io") a = ap.parse_args(argv) if a.what == "setup": setup(a.data_dir, a.id, a.name, home=a.home, standalone=a.standalone) elif a.what == "rebuild": rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None) elif a.what == "upgrade-companion": upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"), source=a.source) elif a.what == "look": cfg = sp.load_config(os.path.join(a.data_dir, "config.json")) inv = discover.look(cfg, a.data_dir) print(json.dumps(summary(inv), indent=1)) new, added, _ = autoconf.extend(cfg, inv, load_seen(a.data_dir)) print("would add:", ", ".join(added) or "nothing") else: from settings import server look_and_add(server.Site(a.data_dir), say=print, force=True) if __name__ == "__main__": main()