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:
+94
-17
@@ -499,17 +499,56 @@ def check_midi_peer(chk, snap, ctx):
|
||||
sess = sessions.get(chk["session"])
|
||||
if sess is None:
|
||||
return [("peer", Result(ERR, "NO SESSION"))]
|
||||
seen = midi_presence(snap, chk.get("host", ""), sessions, ctx["now"])
|
||||
for p in sess["peers"]:
|
||||
if p["name"] == chk["peer"]:
|
||||
# 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:
|
||||
# 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))]
|
||||
return [("peer", Result(ERR, "MIDI " + (p["state"] or "?").upper()[:8]))]
|
||||
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(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):
|
||||
"""Run a coremidi call on another Mac (python there, else the JavaScript for Automation `jxa`):
|
||||
(result, error text)."""
|
||||
@@ -1071,6 +1110,7 @@ class ServiceMode:
|
||||
saved = self.state.get(name) or {}
|
||||
self.override, self.last_sched = saved.get("override"), saved.get("last_sched")
|
||||
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):
|
||||
return in_windows(self.windows, now)
|
||||
@@ -1086,6 +1126,12 @@ class ServiceMode:
|
||||
|
||||
def on(self, 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:
|
||||
self.edge = self.next_change(now) # an override saved before edges were kept
|
||||
self._save()
|
||||
@@ -1101,6 +1147,8 @@ class ServiceMode:
|
||||
Looked up once a minute."""
|
||||
if self.override is None:
|
||||
return None
|
||||
if self.hold:
|
||||
return self.edge
|
||||
minute = int(now // 60)
|
||||
cached = getattr(self, "_back", None)
|
||||
if cached and cached[0] == (minute, self.override):
|
||||
@@ -1122,10 +1170,29 @@ class ServiceMode:
|
||||
self.last_sched = sched
|
||||
self.override = None if value == sched else value
|
||||
self.edge = self.next_change(now) if self.override is not None else None
|
||||
self.hold = False
|
||||
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):
|
||||
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):
|
||||
"""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)
|
||||
return []
|
||||
kind = self.tiles.get(tile_id, {}).get("kind")
|
||||
if kind in ("service", "autofix"): # from North Attleboro's LOCATION page: the hold is the press
|
||||
mode = self.service if kind == "service" else self.autofix
|
||||
mode.set(not mode.on(now), now)
|
||||
log.info("%s %s (hold)", "SERVICE" if kind == "service" else "AUTO-FIX", "ON" if mode.on(now) else "OFF")
|
||||
if kind == "service": # from North Attleboro's LOCATION page: the hold is the press
|
||||
self.service.set(not self.service.on(now), now)
|
||||
log.info("SERVICE %s (hold)", "ON" if self.service.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 []
|
||||
if kind == "bypass":
|
||||
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 hours, the gentle fixes run until the hours next start or end.
|
||||
hours = self.autofix.on(now)
|
||||
muted = self.autofix.override is False
|
||||
muted = self.autofix.paused
|
||||
else:
|
||||
# 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).
|
||||
@@ -2278,10 +2348,16 @@ def render(engine, service_on):
|
||||
continue
|
||||
on = engine.autofix.on(engine.now)
|
||||
until = engine.autofix.until(engine.now)
|
||||
text = "%s\n%s" % (tile["label"], "ON" if on else "OFF")
|
||||
if until and (on or engine.autofix.override is not None):
|
||||
when = ""
|
||||
if until:
|
||||
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")
|
||||
continue
|
||||
if kind == "bypass":
|
||||
@@ -3649,8 +3725,8 @@ def snapshot_summary(snap, engine, now):
|
||||
for name, l in snap.listeners.items()},
|
||||
"service_on": engine.service.on(now),
|
||||
"alarms_override": engine.service.override,
|
||||
"auto_fix": {"on": engine.auto_on, "paused": engine.auto_paused, "split": engine.split,
|
||||
"switch": engine.autofix.on(now), "override": engine.autofix.override},
|
||||
"auto_fix": {"on": engine.auto_on, "net_wait": engine.auto_paused, "split": engine.split,
|
||||
"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()},
|
||||
"failover": dict(engine.failed),
|
||||
"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)
|
||||
log.info("SERVICE %s (button)", "ON" if target else "OFF")
|
||||
elif verb == "autofix":
|
||||
# "auto": back to the auto-fix hours (NA's ALL AUTO-FIX key resuming every location).
|
||||
target = {"on": True, "off": False, "auto": engine.autofix.scheduled(now)}.get(arg, not engine.autofix.on(now))
|
||||
engine.autofix.set(target, now)
|
||||
log.info("AUTO-FIX %s (%s)", "ON" if target else "OFF", "back to its hours" if arg == "auto" else "button")
|
||||
# The AUTO-FIX key pauses and resumes; "off"/"pause" and "on"/"auto"/"resume" say which (NA's ALL AUTO-FIX key).
|
||||
pause = {"off": True, "pause": True, "on": False, "auto": False, "resume": False}.get(arg, not engine.autofix.paused)
|
||||
(engine.autofix.pause if pause else engine.autofix.resume)(now)
|
||||
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"):
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user