AUTO-FIX pauses through the hours (also when pressed outside them); MIDI links without a state judged by the Mac

- AUTO-FIX: tap = pause / resume; a pause lasts through the current or the next auto-fix hours; key says ON TIL,
  OFF / ON <start>, PAUSED TIL; forcing auto-fix on outside the hours is gone
- MIDI: macOS 26 reports every live link's state, so no state there = NO ANSWER; on macOS 14/15 a link put back after
  a drop counts only after 20 s (macOS drops an unanswered one at ~12 s): no more OK/LOST flicker
- WS and SC PROP ignore the rc4 module's harmless warnings, as NA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 15:38:56 -04:00
co-authored by Claude Opus 5.5
parent bf9afcbc84
commit 99a25e5da2
11 changed files with 156 additions and 37 deletions
+2 -2
View File
@@ -208,8 +208,8 @@ def button(tid, tile, cfg):
elif kind == "autofix": elif kind == "autofix":
stale_text = "'%s\\n?'" % label stale_text = "'%s\\n?'" % label
press, hold = "autofix toggle", None press, hold = "autofix toggle", None
note = ("Press: pause auto-fix (alarms stay on); press again to turn it back on - or on outside its hours. It goes " note = ("Press: pause auto-fix (alarms stay on) through the hours it is in now, or through the next ones when pressed "
"back to its hours by itself when they next start or end. Holds always fix.") "outside them; press again to resume. It resumes by itself when those hours end. Holds always fix.")
elif kind == "bypass": elif kind == "bypass":
stale_text = "'%s\\n?'" % label stale_text = "'%s\\n?'" % label
press, hold = "ack " + tid, "restart " + tid press, hold = "ack " + tid, "restart " + tid
Binary file not shown.
+1 -1
View File
@@ -502,7 +502,7 @@ GLOSSARY = {
"ON FOH BACKUP", "BUSY", "TAKING MIDI OUT", "PUTTING MIDI BACK", "TAKING", "PUTTING", "MIDI OUT", "MIDI BACK", "ON FOH BACKUP", "BUSY", "TAKING MIDI OUT", "PUTTING MIDI BACK", "TAKING", "PUTTING", "MIDI OUT", "MIDI BACK",
], ],
"a switch key (AUTO-FIX, or one like LIGHTS that hands a device to an operator)": [ "a switch key (AUTO-FIX, or one like LIGHTS that hands a device to an operator)": [
"OPERATOR", "MIDI OFF", "OFF IN SETTINGS", "SETTINGS", "TIL", "OPERATOR", "MIDI OFF", "OFF IN SETTINGS", "SETTINGS", "TIL", "PAUSED",
], ],
} }
+2 -2
View File
@@ -225,7 +225,7 @@ def all_autofix_key(local_cfg):
def autofix_paused(state_json): def autofix_paused(state_json):
"""True when that location's AUTO-FIX key says it was paused by hand ("AUTO-FIX\\nOFF\\nTIL ..."), None without one.""" """True when that location's AUTO-FIX key says it is paused ("AUTO-FIX\\nPAUSED\\nTIL ..."), None without one."""
try: try:
af = json.loads(state_json or "{}").get("autofix") af = json.loads(state_json or "{}").get("autofix")
except ValueError: except ValueError:
@@ -233,7 +233,7 @@ def autofix_paused(state_json):
if not af: if not af:
return None return None
lines = str(af.get("x") or "").split("\n") lines = str(af.get("x") or "").split("\n")
return len(lines) > 2 and lines[1] == "OFF" and lines[2].startswith("TIL") return len(lines) > 1 and lines[1] == "PAUSED"
def all_autofix_summary(states, now): def all_autofix_summary(states, now):
+2 -2
View File
@@ -164,8 +164,8 @@ def key_guide(cfg):
if t.get("kind") == "service": if t.get("kind") == "service":
parts.append("<p>%s</p>" % e(ph["alarms"])) parts.append("<p>%s</p>" % e(ph["alarms"]))
if t.get("kind") == "autofix": if t.get("kind") == "autofix":
parts.append("<p>Press: pause auto-fix, or run it outside its hours (auto-fix %s). It goes back to its hours by itself " parts.append("<p>Press: pause auto-fix through the hours it is in now, or through the next ones (auto-fix %s); press "
"when they next start or end. Holds always fix.</p>" % e(ph["auto_fix"])) "again to resume. It resumes by itself when those hours end. Holds always fix.</p>" % e(ph["auto_fix"]))
if t.get("kind") == "bypass": if t.get("kind") == "bypass":
b = t.get("bypass") or {} b = t.get("bypass") or {}
parts.append("<p>Hold: hand it to an operator - the MIDI links to %s are taken out%s, and the panel stops putting them " parts.append("<p>Hold: hand it to an operator - the MIDI links to %s are taken out%s, and the panel stops putting them "
+3 -3
View File
@@ -282,7 +282,7 @@
${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))} ${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))}
${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")} ${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")}
${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)} ${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)}
${chip(null, "Auto-fix " + (af.override === false ? "paused" : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.override === false ? " (paused with the AUTO-FIX key)" : af.paused ? " (waiting out a network blip)" : ""))} ${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))}
${Object.keys(o.bypass || {}).length ? chip(null, Object.keys(o.bypass).map((k) => ((cfg().tiles.find((t) => t.id === k) || {}).label || k)).join(", ") + ": operator", "Handed to an operator with its key: its MIDI is out until the key is held again") : ""} ${Object.keys(o.bypass || {}).length ? chip(null, Object.keys(o.bypass).map((k) => ((cfg().tiles.find((t) => t.id === k) || {}).label || k)).join(", ") + ": operator", "Handed to an operator with its key: its MIDI is out until the key is held again") : ""}
${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""} ${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""}
${o.last_change ? `<a class="chip" href="#history" data-hint="${esc(o.last_change.when + " · " + (o.last_change.by || "") + " · " + (o.last_change.note || ""))}">Last change ${esc(o.last_change.when || "")}</a>` : ""} ${o.last_change ? `<a class="chip" href="#history" data-hint="${esc(o.last_change.when + " · " + (o.last_change.by || "") + " · " + (o.last_change.note || ""))}">Last change ${esc(o.last_change.when || "")}</a>` : ""}
@@ -292,7 +292,7 @@
<div class="row" style="margin-top:10px"> <div class="row" style="margin-top:10px">
<button id="restartwd" ${dis} data-hint="${w.running ? "Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first" : "Start the watchdog"}">${w.running ? "Restart watchdog" : "Start watchdog"}</button> <button id="restartwd" ${dis} data-hint="${w.running ? "Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first" : "Start the watchdog"}">${w.running ? "Restart watchdog" : "Start watchdog"}</button>
${af.split ? `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms back on now" : "Same as the ALARMS key: no alarms until midnight (auto-fix carries on)"}">${o.service_on === false ? "Alarms on" : "Silence alarms"}</button> ${af.split ? `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms back on now" : "Same as the ALARMS key: no alarms until midnight (auto-fix carries on)"}">${o.service_on === false ? "Alarms on" : "Silence alarms"}</button>
<button id="autofixbtn" ${dis} data-hint="${af.switch ? "Same as the AUTO-FIX key: nothing is fixed by itself until the hours next start or end (holds still fix)" : "Same as the AUTO-FIX key: gentle fixes run by themselves until the hours next start or end"}">${af.switch ? "Pause auto-fix" : "Auto-fix on"}</button>` <button id="autofixbtn" ${dis} data-hint="${af.paused ? "Same as the AUTO-FIX key: back to the auto-fix hours now" : "Same as the AUTO-FIX key: nothing is fixed by itself through these (or the next) auto-fix hours; holds still fix"}">${af.paused ? "Resume auto-fix" : "Pause auto-fix"}</button>`
: `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms and auto-fix back on now" : "Same as the ALARMS key: no alarms and no auto-fix until midnight"}">${o.service_on === false ? "Alarms + auto-fix on" : "Silence + stop auto-fix"}</button>`} : `<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms and auto-fix back on now" : "Same as the ALARMS key: no alarms and no auto-fix until midnight"}">${o.service_on === false ? "Alarms + auto-fix on" : "Silence + stop auto-fix"}</button>`}
${w.running ? `<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>` : ""} ${w.running ? `<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>` : ""}
</div></div> </div></div>
@@ -301,7 +301,7 @@
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } }); main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP"); const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
const afb = main.querySelector("#autofixbtn"); if (afb) afb.onclick = async () => { try { await post("/api/command", { text: af.switch ? "autofix off" : "autofix on" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; const afb = main.querySelector("#autofixbtn"); if (afb) afb.onclick = async () => { try { await post("/api/command", { text: af.paused ? "autofix resume" : "autofix pause" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|")); for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
if (!quiet) window.scrollTo(0, 0); if (!quiet) window.scrollTo(0, 0);
} }
+2 -1
View File
@@ -151,7 +151,8 @@
{ {
"type": "conn", "type": "conn",
"conn": "Propresenter-API", "conn": "Propresenter-API",
"fix": "prop_port_propresenter_api" "fix": "prop_port_propresenter_api",
"ignore_warning": true
}, },
{ {
"type": "process", "type": "process",
+2 -1
View File
@@ -486,7 +486,8 @@
"checks": [ "checks": [
{ {
"conn": "Propresenter-API", "conn": "Propresenter-API",
"type": "conn" "type": "conn",
"ignore_warning": true
}, },
{ {
"fix": "propresenter_ingest", "fix": "propresenter_ingest",
+94 -17
View File
@@ -499,17 +499,56 @@ def check_midi_peer(chk, snap, ctx):
sess = sessions.get(chk["session"]) sess = sessions.get(chk["session"])
if sess is None: if sess is None:
return [("peer", Result(ERR, "NO SESSION"))] return [("peer", Result(ERR, "NO SESSION"))]
seen = midi_presence(snap, chk.get("host", ""), sessions, ctx["now"])
for p in sess["peers"]: for p in sess["peers"]:
if p["name"] == chk["peer"]: if p["name"] == chk["peer"]:
# Measured 2026-10-07: a joining peer reads "connected" for ~6 s until # Measured 2026-10-07: a joining peer reads "connected" for ~6 s until
# clock sync, then "running". A peer that dies is removed from the list: # clock sync, then "running". A peer that dies is removed from the list:
# at once if it says goodbye, after ~60 s if it crashes or its cable is pulled. # at once if it says goodbye, after ~60 s if it crashes or its cable is pulled.
if (p.get("state") or "running") in ("running", "connected"): # no state: older macOS, configured state = p.get("state") or ""
if state in ("running", "connected"):
return [("peer", Result(OK))]
if state:
return [("peer", Result(ERR, "MIDI " + state.upper()[:8]))]
# No state. macOS 26 reports one for every live link, so there it is not answering; macOS 14/15
# report none, so a link put back after it dropped only counts once it has stayed: macOS drops
# an unanswered one after ~12 s (Southcoast's Lighting flickered OK/LOST all afternoon, 2026-10-21).
since, dropped = seen.get((chk["session"], chk["peer"]), (-1e18, None))
young = ctx["now"] - since < MIDI_SETTLE
if seen.get("reports"):
return [("peer", Result(ERR, "MIDI CONNECTING" if young else "MIDI NO ANSWER"))]
if young and dropped is not None and since - dropped < 300:
return [("peer", Result(ERR, "MIDI CONNECTING"))]
return [("peer", Result(OK))] return [("peer", Result(OK))]
return [("peer", Result(ERR, "MIDI " + (p["state"] or "?").upper()[:8]))]
return [("peer", Result(ERR, "MIDI LOST"))] return [("peer", Result(ERR, "MIDI LOST"))]
MIDI_SETTLE = 20 # seconds a re-added link must stay listed before it counts (macOS drops a dead one at ~12 s)
def midi_presence(snap, host, sessions, now):
"""For one Mac's sessions: when each peer appeared {(session, peer): (since, dropped_at)}, and "reports":
whether that Mac gives links a state (any non-empty one seen there, ever). Peers there at the first
reading count as long established."""
mem = snap.__dict__.setdefault("midi_mem", {})
m = mem.setdefault(host, {"since": {}, "dropped": {}, "reports": False, "first": True, "read": None})
if m["read"] is not sessions: # a new reading: update once, not per check
m["read"] = sessions
present = {(s, p.get("name")) for s, sess in sessions.items() for p in (sess or {}).get("peers") or []}
if any(p.get("state") for sess in sessions.values() for p in (sess or {}).get("peers") or []):
m["reports"] = True
for key in present:
if key not in m["since"]:
m["since"][key] = -1e18 if m["first"] else now
for key in [k for k in m["since"] if k not in present]:
del m["since"][key]
m["dropped"][key] = now
m["first"] = False
out = {k: (v, m["dropped"].get(k)) for k, v in m["since"].items()}
out["reports"] = m["reports"]
return out
def midi_on(cfg, host, call, jxa): def midi_on(cfg, host, call, jxa):
"""Run a coremidi call on another Mac (python there, else the JavaScript for Automation `jxa`): """Run a coremidi call on another Mac (python there, else the JavaScript for Automation `jxa`):
(result, error text).""" (result, error text)."""
@@ -1071,6 +1110,7 @@ class ServiceMode:
saved = self.state.get(name) or {} saved = self.state.get(name) or {}
self.override, self.last_sched = saved.get("override"), saved.get("last_sched") self.override, self.last_sched = saved.get("override"), saved.get("last_sched")
self.edge = saved.get("edge") self.edge = saved.get("edge")
self.hold = bool(saved.get("hold")) # a pause that lasts through the hours, not just to the next edge
def scheduled(self, now): def scheduled(self, now):
return in_windows(self.windows, now) return in_windows(self.windows, now)
@@ -1086,6 +1126,12 @@ class ServiceMode:
def on(self, now): def on(self, now):
sched = self.scheduled(now) sched = self.scheduled(now)
if self.hold: # paused: until its edge, whatever the schedule does meanwhile
if self.edge is not None and now >= self.edge:
self.override, self.hold, self.edge, self.last_sched = None, False, None, sched
self._save()
return sched
return False
if self.override is not None and self.edge is None: if self.override is not None and self.edge is None:
self.edge = self.next_change(now) # an override saved before edges were kept self.edge = self.next_change(now) # an override saved before edges were kept
self._save() self._save()
@@ -1101,6 +1147,8 @@ class ServiceMode:
Looked up once a minute.""" Looked up once a minute."""
if self.override is None: if self.override is None:
return None return None
if self.hold:
return self.edge
minute = int(now // 60) minute = int(now // 60)
cached = getattr(self, "_back", None) cached = getattr(self, "_back", None)
if cached and cached[0] == (minute, self.override): if cached and cached[0] == (minute, self.override):
@@ -1122,10 +1170,29 @@ class ServiceMode:
self.last_sched = sched self.last_sched = sched
self.override = None if value == sched else value self.override = None if value == sched else value
self.edge = self.next_change(now) if self.override is not None else None self.edge = self.next_change(now) if self.override is not None else None
self.hold = False
self._save() self._save()
def pause(self, now):
"""AUTO-FIX paused: through the hours it is in now, or, pressed outside them, through the next ones
(2026-10-21: a press on Saturday night used to do nothing for Sunday)."""
sched = self.scheduled(now)
start = now if sched else self.next_change(now)
self.override, self.hold, self.last_sched = False, True, sched
self.edge = self.next_change(start) if start else None
self._save()
def resume(self, now):
"""Back to the hours."""
self.override, self.hold, self.edge, self.last_sched = None, False, None, self.scheduled(now)
self._save()
@property
def paused(self):
return self.hold and self.override is False
def _save(self): def _save(self):
self.state.set(self.name, {"override": self.override, "last_sched": self.last_sched, "edge": self.edge}) self.state.set(self.name, {"override": self.override, "last_sched": self.last_sched, "edge": self.edge, "hold": self.hold})
def until(self, now): def until(self, now):
"""When the state shown now next changes by itself (an override's end, else the schedule's next edge), """When the state shown now next changes by itself (an override's end, else the schedule's next edge),
@@ -1686,10 +1753,13 @@ class Engine:
self.note(tile_id, "RESTARTING\nSOON", "idle", now, 4) self.note(tile_id, "RESTARTING\nSOON", "idle", now, 4)
return [] return []
kind = self.tiles.get(tile_id, {}).get("kind") kind = self.tiles.get(tile_id, {}).get("kind")
if kind in ("service", "autofix"): # from North Attleboro's LOCATION page: the hold is the press if kind == "service": # from North Attleboro's LOCATION page: the hold is the press
mode = self.service if kind == "service" else self.autofix self.service.set(not self.service.on(now), now)
mode.set(not mode.on(now), now) log.info("SERVICE %s (hold)", "ON" if self.service.on(now) else "OFF")
log.info("%s %s (hold)", "SERVICE" if kind == "service" else "AUTO-FIX", "ON" if mode.on(now) else "OFF") return []
if kind == "autofix":
(self.autofix.resume if self.autofix.paused else self.autofix.pause)(now)
log.info("AUTO-FIX %s (hold)", "PAUSED" if self.autofix.paused else "back to its hours")
return [] return []
if kind == "bypass": if kind == "bypass":
self.toggle_bypass(tile_id, comp, now) self.toggle_bypass(tile_id, comp, now)
@@ -1824,7 +1894,7 @@ class Engine:
# The AUTO-FIX key: paused, nothing is fixed by itself (holds still work); pressed outside # The AUTO-FIX key: paused, nothing is fixed by itself (holds still work); pressed outside
# the hours, the gentle fixes run until the hours next start or end. # the hours, the gentle fixes run until the hours next start or end.
hours = self.autofix.on(now) hours = self.autofix.on(now)
muted = self.autofix.override is False muted = self.autofix.paused
else: else:
# The ALARMS key is the on-the-fly switch: muted, nothing is fixed by itself at all (holds # The ALARMS key is the on-the-fly switch: muted, nothing is fixed by itself at all (holds
# still work). It comes back by itself with the alarms (2026-10-19: the user's choice). # still work). It comes back by itself with the alarms (2026-10-19: the user's choice).
@@ -2278,10 +2348,16 @@ def render(engine, service_on):
continue continue
on = engine.autofix.on(engine.now) on = engine.autofix.on(engine.now)
until = engine.autofix.until(engine.now) until = engine.autofix.until(engine.now)
text = "%s\n%s" % (tile["label"], "ON" if on else "OFF") when = ""
if until and (on or engine.autofix.override is not None): if until:
same_day = datetime.fromtimestamp(until).date() == datetime.fromtimestamp(engine.now).date() same_day = datetime.fromtimestamp(until).date() == datetime.fromtimestamp(engine.now).date()
text += "\nTIL " + ("" if same_day else datetime.fromtimestamp(until).strftime("%a ").upper()) + clock12(until) when = ("" if same_day else datetime.fromtimestamp(until).strftime("%a ").upper()) + clock12(until)
if engine.autofix.paused:
text = "%s\nPAUSED" % tile["label"] + ("\nTIL " + when if when else "")
elif on:
text = "%s\nON" % tile["label"] + ("\nTIL " + when if when else "")
else:
text = "%s\nOFF" % tile["label"] + ("\nON " + when if when else "") # outside the hours: when they start
out[tid] = colored(text, "service" if on else "off") out[tid] = colored(text, "service" if on else "off")
continue continue
if kind == "bypass": if kind == "bypass":
@@ -3649,8 +3725,8 @@ def snapshot_summary(snap, engine, now):
for name, l in snap.listeners.items()}, for name, l in snap.listeners.items()},
"service_on": engine.service.on(now), "service_on": engine.service.on(now),
"alarms_override": engine.service.override, "alarms_override": engine.service.override,
"auto_fix": {"on": engine.auto_on, "paused": engine.auto_paused, "split": engine.split, "auto_fix": {"on": engine.auto_on, "net_wait": engine.auto_paused, "split": engine.split,
"switch": engine.autofix.on(now), "override": engine.autofix.override}, "switch": engine.autofix.on(now), "override": engine.autofix.override, "paused": engine.autofix.paused},
"bypass": {tid: {"since": b.get("since")} for tid, b in engine.bypass.items()}, "bypass": {tid: {"since": b.get("since")} for tid, b in engine.bypass.items()},
"failover": dict(engine.failed), "failover": dict(engine.failed),
"burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]), "burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]),
@@ -3697,10 +3773,11 @@ def handle_commands(text, engine, comp, snap, now):
engine.service.set(target, now) engine.service.set(target, now)
log.info("SERVICE %s (button)", "ON" if target else "OFF") log.info("SERVICE %s (button)", "ON" if target else "OFF")
elif verb == "autofix": elif verb == "autofix":
# "auto": back to the auto-fix hours (NA's ALL AUTO-FIX key resuming every location). # The AUTO-FIX key pauses and resumes; "off"/"pause" and "on"/"auto"/"resume" say which (NA's ALL AUTO-FIX key).
target = {"on": True, "off": False, "auto": engine.autofix.scheduled(now)}.get(arg, not engine.autofix.on(now)) pause = {"off": True, "pause": True, "on": False, "auto": False, "resume": False}.get(arg, not engine.autofix.paused)
engine.autofix.set(target, now) (engine.autofix.pause if pause else engine.autofix.resume)(now)
log.info("AUTO-FIX %s (%s)", "ON" if target else "OFF", "back to its hours" if arg == "auto" else "button") log.info("AUTO-FIX %s", ("PAUSED until %s" % datetime.fromtimestamp(engine.autofix.edge).strftime("%a %H:%M")
if engine.autofix.edge else "PAUSED") if pause else "back to its hours")
elif verb in ("restart", "force"): elif verb in ("restart", "force"):
# Holds carry Companion's clock ("force rs_x 1791496225"). One pressed while the watchdog # Holds carry Companion's clock ("force rs_x 1791496225"). One pressed while the watchdog
# was down must not fire minutes later in a burst with the others. # was down must not fire minutes later in a burst with the others.
+1 -1
View File
@@ -608,7 +608,7 @@ class TestAllAutoFix(unittest.TestCase):
def test_the_key_names_the_paused_locations_and_sits_where_auto_fix_does(self): def test_the_key_names_the_paused_locations_and_sits_where_auto_fix_does(self):
from settings import campuses as cv from settings import campuses as cv
st = lambda x: json.dumps({"autofix": {"x": x}}) st = lambda x: json.dumps({"autofix": {"x": x}})
states = {"na": st("AUTO-FIX\nON\nTIL 9PM"), "sc": st("AUTO-FIX\nOFF\nTIL SUN 5AM"), "ws": st("AUTO-FIX\nOFF"), "ab": None} states = {"na": st("AUTO-FIX\nON\nTIL 9PM"), "sc": st("AUTO-FIX\nPAUSED\nTIL SUN 2PM"), "ws": st("AUTO-FIX\nOFF\nON SUN 5AM"), "ab": None}
self.assertEqual(cv.all_autofix_summary(states, 100.0), {"t": 100, "paused": 1, "x": "SC\nPAUSED"}) # WS: outside its hours self.assertEqual(cv.all_autofix_summary(states, 100.0), {"t": 100, "paused": 1, "x": "SC\nPAUSED"}) # WS: outside its hours
self.assertEqual(cv.all_autofix_summary({"na": st("AUTO-FIX\nON\nTIL 9PM")}, 100.0)["x"], "ALL ON") self.assertEqual(cv.all_autofix_summary({"na": st("AUTO-FIX\nON\nTIL 9PM")}, 100.0)["x"], "ALL ON")
na = json.load(open(os.path.join(ROOT, "config.json"))) na = json.load(open(os.path.join(ROOT, "config.json")))
+47 -7
View File
@@ -2493,16 +2493,25 @@ class TestSwitches(unittest.TestCase):
self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 30) self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 30)
time.sleep(0.2) time.sleep(0.2)
self.assertEqual(ran, ["ableton"]) # auto-fix carried on self.assertEqual(ran, ["ableton"]) # auto-fix carried on
self.engine.autofix.set(False, SUNDAY + 40) # AUTO-FIX pressed: paused self.engine.autofix.pause(SUNDAY + 40) # AUTO-FIX pressed in the hours: paused
tiles = self.engine.step(self.snap, SUNDAY + 41) tiles = self.engine.step(self.snap, SUNDAY + 41)
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nOFF\nTIL WED 4PM") # back on with Wednesday's hours self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL 1:30PM") # through Sunday's hours
self.assertEqual(level(tiles["autofix"]), "off") self.assertEqual(level(tiles["autofix"]), "off")
self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 200), []) self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 200), [])
self.assertFalse(self.new_engine().autofix.on(SUNDAY + 300)) # survives a restart self.assertTrue(self.new_engine().autofix.paused) # survives a restart
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # Wednesday's hours: back by itself self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # Wednesday's hours: back by itself
weekday = SUNDAY - 3 * 86400 # Thursday: pressed = runs anyway self.assertFalse(self.engine.autofix.paused)
self.engine.autofix.set(True, weekday) # Pressed outside the hours (Thursday): the key said OFF / ON SUN 6:30AM; the pause holds through Sunday's hours.
self.assertTrue(self.engine.autofix.on(weekday + 60)) thursday = SUNDAY - 3 * 86400
tiles = self.engine.step(self.snap, thursday)
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nOFF\nON SUN 6:30AM")
self.engine.autofix.pause(thursday)
self.assertEqual(self.engine.step(self.snap, thursday + 1)["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL SUN 1:30PM")
self.assertFalse(self.engine.autofix.on(SUNDAY)) # Sunday 8:00, inside the hours: still paused
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # and over after them
self.engine.autofix.pause(thursday)
self.engine.autofix.resume(thursday + 60) # pressed again: back to the hours
self.assertTrue(self.engine.autofix.on(SUNDAY))
page = make_page.build(self.cfg)["page"]["controls"] page = make_page.build(self.cfg)["page"]["controls"]
notes = {(ctl.get("options") or {}).get("notes", "") for row in page.values() for ctl in row.values() if ctl} notes = {(ctl.get("options") or {}).get("notes", "") for row in page.values() for ctl in row.values() if ctl}
self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes)) self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes))
@@ -2510,8 +2519,9 @@ class TestSwitches(unittest.TestCase):
def test_a_hold_from_north_attleboro_flips_a_switch(self): def test_a_hold_from_north_attleboro_flips_a_switch(self):
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY) self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY)
self.assertFalse(self.engine.autofix.on(SUNDAY + 1)) self.assertTrue(self.engine.autofix.paused)
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY + 2) self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY + 2)
self.assertFalse(self.engine.autofix.paused)
self.assertTrue(self.engine.autofix.on(SUNDAY + 3)) self.assertTrue(self.engine.autofix.on(SUNDAY + 3))
def test_the_operator_key_takes_the_midi_out_and_puts_it_back(self): def test_the_operator_key_takes_the_midi_out_and_puts_it_back(self):
@@ -2622,3 +2632,33 @@ class TestCompanionKeeper(unittest.TestCase):
open(os.path.join(self.data, "companion-upgrading"), "w").write("x") open(os.path.join(self.data, "companion-upgrading"), "w").write("x")
self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on
self.assertEqual(self.opened, []) self.assertEqual(self.opened, [])
class TestMidiNoState(unittest.TestCase):
"""macOS 26 gives each live link a state, macOS 14/15 none: a link with no state is judged by which (2026-10-21)."""
def snap(self, peers, now):
s = healthy_snapshot(now)
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": peers}}}
s.remote_midi_time = {"tracks": now}
return s
def check(self, s, now):
chk = {"type": "midi_peer", "host": "tracks", "session": "T", "peer": "Lighting", "address": "10.5.1.168:5004"}
s.remote_midi_time = {"tracks": now} # a fresh reading every 2 s
return sp.check_midi_peer(chk, s, {"now": now})[0][1]
def test_on_a_mac_that_reports_states_no_state_means_no_answer(self):
s = self.snap([{"name": "FOH", "address": "x", "state": "running"}, {"name": "Lighting", "address": "y", "state": ""}], 100.0)
self.assertEqual(self.check(s, 100.0).level, sp.ERR)
self.assertEqual(self.check(s, 100.0).msg, "MIDI NO ANSWER") # there at the first reading: long-standing
def test_on_an_older_mac_a_link_put_back_counts_only_once_it_stays(self):
s = self.snap([{"name": "Lighting", "address": "y"}], 100.0)
self.assertEqual(self.check(s, 100.0).level, sp.OK) # there from the start: configured, fine
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": []}}}
self.assertEqual(self.check(s, 101.0).msg, "MIDI LOST") # dropped
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": [{"name": "Lighting", "address": "y"}]}}}
self.assertEqual(self.check(s, 110.0).msg, "MIDI CONNECTING") # put back: not trusted yet
self.assertEqual(self.check(s, 125.0).msg, "MIDI CONNECTING")
self.assertEqual(self.check(s, 131.0).level, sp.OK) # stayed 20 s: it answered