Files
christianandClaude Opus 5.5 d5ede0c91e READY page: the weekly worship checklist, checked against Planning Center
- readiness.py looks every 2 minutes (never while a HyperDeck records) at this weekend's PCO plan
  and what is in place for it: the Ableton set (songs, mix, routing, lighting cues), ProPresenter
  (every PCO item, MC announcements, baptism names, timelines, backgrounds), the LTC note of every
  song against the timecode the tracks play, the Band Lineup TV, Resolume's LED cross, and the
  tracks and ProP bundles for the locations.
- READY key on the Status page (row 2, col 7; SPOTIFY now shows inside SHOW) opens page 24.
  Amber until done, blinking red from Saturday noon; not part of ALL. Tap keys for BOH mix,
  mics and stage reset every Monday.
- Holds fix what can be fixed: re-sync the lineup, put the cross back up, write LTC into PCO,
  copy tracks to locations, look again. Hold READY fixes everything.
- Settings page: a READY page tab (live keys, drag to move, why each key is amber), and Apply
  re-imports page 24.
- Auto-fix: "while_muted" fixes keep running with ALARMS muted (Spotify playlist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:43 -04:00

202 lines
8.7 KiB
Bash

#!/bin/zsh
# Install or update the status panel watchdog on this Mac (the Ingest Mac) as a LaunchAgent.
# Re-run after changing any file in this folder.
#
# zsh install.sh install / update and (re)start
# zsh install.sh --keep-config update the code but leave the live config.json as it is
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
#
# The config is validated with the NEW code before anything is touched, so a stricter check can
# never leave the watchdog crash-looping. The previous code is kept in prev/ for --rollback.
set -e
SRC=${0:A:h}
LABEL=org.waterschurch.status-panel
SETTINGS_LABEL=org.waterschurch.status-settings
# App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents.
DEST="$HOME/Library/Application Support/status-panel"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
CODE=(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)
KEEP_CONFIG=0
SITE=""
SITE_NEXT=0
for arg in "$@"; do
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
case "$arg" in
--keep-config) KEEP_CONFIG=1 ;;
--site) SITE_NEXT=1 ;;
--remove|--rollback|--now) ;;
*) echo "unknown option $arg" >&2; exit 2 ;;
esac
done
# The config to install: the campus folder when asked for, else this folder's config.json.
SRC_CONFIG="$SRC/config.json"
if [[ -n "$SITE" ]]; then
if [[ -f "$SRC/sites/$SITE/config.json" ]]; then SRC_CONFIG="$SRC/sites/$SITE/config.json"
elif [[ -f "$SRC/sites/_template/config.json" ]]; then SRC_CONFIG="$SRC/sites/_template/config.json"; echo "no sites/$SITE/ in the folder: starting from the template"
else echo "no sites/ folder here" >&2; exit 2; fi
[[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New campus from a bundle' replaces it)"
fi
restart_agent() { # $1 = label, $2 = plist
local label=${1:-$LABEL} plist=${2:-$PLIST}
launchctl bootout "$DOMAIN/$label" 2>/dev/null || true
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
for i in {1..50}; do launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || break; sleep 0.2; done
for attempt in 1 2 3; do
launchctl bootstrap "$DOMAIN" "$plist" && break
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed for $label" >&2; exit 1; }
sleep 1
done
}
SETTINGS_PLIST="$HOME/Library/LaunchAgents/$SETTINGS_LABEL.plist"
write_settings_plist() {
cat > "$SETTINGS_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$SETTINGS_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-m</string>
<string>settings.server</string>
<string>--data-dir</string>
<string>$DEST</string>
</array>
<key>WorkingDirectory</key><string>$DEST</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>AbandonProcessGroup</key><true/>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-settings.err.log</string>
</dict>
</plist>
EOF
}
write_plist() { # $1 = exit timeout in seconds
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$DEST/status_panel.py</string>
<string>--data-dir</string>
<string>$DEST</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>ExitTimeOut</key><integer>$1</integer>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel.err.log</string>
</dict>
</plist>
EOF
}
wait_for_start() { # $1 = install time (unix): wait for a start stamp newer than that
for i in {1..60}; do
if [[ -f "$DEST/started.json" ]] && /usr/bin/python3 - "$DEST/started.json" "$1" <<'PY' 2>/dev/null
import json, sys
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("started_at", 0) >= float(sys.argv[2]) else 1)
PY
then
echo "running (code $(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version"))' "$DEST/started.json"))"
return 0
fi
sleep 0.5
done
echo "WARNING: no start stamp after 30 s - check ~/Library/Logs/status-panel.log and status-panel.err.log" >&2
return 1
}
if [[ "$1" == "--remove" ]]; then
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootout "$DOMAIN/$SETTINGS_LABEL" 2>/dev/null || true
rm -f "$PLIST" "$SETTINGS_PLIST"
echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST)"
exit 0
fi
if [[ "$1" == "--rollback" ]]; then
[[ -d "$DEST/prev" ]] || { echo "nothing to roll back to ($DEST/prev is missing)" >&2; exit 1; }
for f in "$DEST"/prev/*(N); do cp "$f" "$DEST/"; done
/usr/bin/python3 "$DEST/status_panel.py" --check --config "$DEST/config.json"
T=$(date +%s)
write_plist "$(/usr/bin/python3 "$DEST/status_panel.py" --exit-timeout --config "$DEST/config.json")"
restart_agent
echo "rolled back to the previous code (version $(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import status_panel; print(status_panel.VERSION)' "$DEST"))"
wait_for_start "$T" || true
exit 0
fi
# 0. Not while a HyperDeck is recording (the watchdog restarts for a few seconds); --now overrides.
if [[ -f "$DEST/live-now.json" && " $* " != *" --now "* ]] && /usr/bin/python3 - "$DEST/live-now.json" <<'PY'
import json, sys, time
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("live") and time.time() - d.get("time", 0) < 60 else 1)
PY
then
echo "a HyperDeck is recording right now ($(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("reason"))' "$DEST/live-now.json")) - not installing; use --now to override" >&2
exit 3
fi
# 1. The config that will run, checked with the code that will run it.
mkdir -p "$DEST"
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
CFG="$DEST/config.json"
else
CFG="$SRC_CONFIG"
fi
/usr/bin/python3 "$SRC/status_panel.py" --check --config "$CFG"
EXIT_TIMEOUT=$(/usr/bin/python3 "$SRC/status_panel.py" --exit-timeout --config "$CFG")
# 2. Keep the code that runs now, for --rollback.
mkdir -p "$DEST/prev"
for f in $CODE; do [[ -f "$DEST/$f" ]] && cp "$DEST/$f" "$DEST/prev/"; done
# 3. Files. The live config is backed up when it changes; --keep-config leaves it alone.
if [[ $KEEP_CONFIG == 0 ]]; then
if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC_CONFIG" "$DEST/config.json"; then
cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)"
echo "config.json replaced (the old one is kept as config.json.bak-...; use --keep-config to leave it)"
fi
cp "$SRC_CONFIG" "$DEST/config.json"
fi
for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done
[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/"
# The set-up script templates the page fills in for each computer.
[[ -d "$SRC/remote-access" ]] && rm -rf "$DEST/remote-access" && cp -R "$SRC/remote-access" "$DEST/"
# The settings page (its own LaunchAgent) and the record of coloured page-number keys (site-owned: never overwritten).
if [[ -d "$SRC/settings" ]]; then
rm -rf "$DEST/settings" && cp -R "$SRC/settings" "$DEST/" && find "$DEST/settings" -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null
fi
[[ -f "$SRC/nav_locations.json" && ! -f "$DEST/nav_locations.json" ]] && cp "$SRC/nav_locations.json" "$DEST/"
if [[ -d "$SRC/companion-objects" ]]; then
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
fi
# 4. The LaunchAgent, with the exit timeout this config needs (a running fix finishes before a restart).
T=$(date +%s)
write_plist "$EXIT_TIMEOUT"
restart_agent
echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)"
echo "log: ~/Library/Logs/status-panel.log"
wait_for_start "$T" || true
if [[ -d "$DEST/settings" ]]; then
write_settings_plist
restart_agent "$SETTINGS_LABEL" "$SETTINGS_PLIST"
PORT=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("site",{}).get("settings_port",8020))' "$DEST/config.json")
echo "settings page: http://$(hostname -s).local:$PORT/ (log: ~/Library/Logs/status-settings.log)"
fi