READY: LIGHT TIMING (flash and FX cues against the drum hits); auto-fix on for testing
- LIGHT TIMING: every blinder flash / FX cue is timed against the Drums (Live) stem under it (deactivated clips count) on the Tracks Mac; cues a strong hit is more than 25 ms away from are named with song and bar - Settings Overview: "Auto-fix on for testing" for 15 min to 2 h, outside the hours and over a pause; the AUTO-FIX key says TESTING, and it goes back to the hours by itself Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
||||
|
||||
It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`.
|
||||
- **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`).
|
||||
- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix.
|
||||
- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix, and **Auto-fix on for testing** (15 min to 2 h): auto-fix runs now, outside its hours and even when paused, the key says `TESTING / TIL 10:45AM`, and it goes back to the hours by itself when the time is up (**Stop testing** ends it early). The day's auto-fix log says when testing started.
|
||||
- **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's LOCATION page works too. `"conns"` can also switch Companion connections off for the operator.
|
||||
|
||||
## Auto-fix on Sundays (06:30–13:30)
|
||||
@@ -129,7 +129,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
|
||||
| | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 |
|
||||
|--------|-------|-------|-------|-------|-------|-------|-------|
|
||||
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE |
|
||||
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | | | |
|
||||
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | | |
|
||||
| row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
|
||||
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY |
|
||||
|
||||
@@ -151,6 +151,7 @@ everything on the page at once.
|
||||
| TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again |
|
||||
| FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | looks again |
|
||||
| LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again |
|
||||
| LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens around it (less than half a 16th note either way) for the strongest hit. **3 CUES OFF** (amber): a strong hit more than 25 ms before or after the cue while the cue itself sits on a quiet spot; the details say `JEHOVAH bar 329.2.1: Blinder Flash 2 is 37 ms before the hit` (bars as Live shows them, 4/4). Listened to once per saved set. | looks again (move the note in Live by hand: Live's control script cannot edit notes in the arrangement) |
|
||||
| SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. **JEHOVAH ONLY Db** (amber): Finished Songs has the song, but not in this key (save the new version there). A song that is not in Finished Songs at all is only noted. | looks again |
|
||||
| W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) |
|
||||
| ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; **NEW STEM BANJO** (amber): a stem name the table does not know. | puts every stem back where the table says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. |
|
||||
|
||||
+17
-1
@@ -1179,7 +1179,8 @@
|
||||
"Ext. Out",
|
||||
"11/12 VOX"
|
||||
]
|
||||
}
|
||||
},
|
||||
"light_library": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI"
|
||||
},
|
||||
"ready_page": {
|
||||
"back_pos": [
|
||||
@@ -1400,6 +1401,21 @@
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rd_light_time",
|
||||
"label": "LIGHT TIMING",
|
||||
"pos": [
|
||||
1,
|
||||
5
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"type": "ready",
|
||||
"item": "light_timing",
|
||||
"fix": "ready_recheck"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rd_keys",
|
||||
"label": "SONG KEYS",
|
||||
|
||||
+161
-5
@@ -14,6 +14,8 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
|
||||
routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live
|
||||
from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved
|
||||
ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way
|
||||
light_timing every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it
|
||||
(a hit a few ms before or after the cue, with the cue itself on a quiet spot, is named)
|
||||
keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad
|
||||
clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]")
|
||||
prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
|
||||
@@ -471,14 +473,41 @@ def summary(path):
|
||||
pg = c.find("ProgramChange")
|
||||
tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
||||
c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None])
|
||||
cues, hit_src = [], []
|
||||
for t in list(ls.find("Tracks")):
|
||||
name = t.find("Name/EffectiveName").get("Value").strip()
|
||||
if name.upper() == P["lighting_track"].strip().upper():
|
||||
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
||||
if c.find("Disabled") is not None and c.find("Disabled").get("Value") == "true":
|
||||
continue
|
||||
start, end = float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value"))
|
||||
offset = float(c.find("Loop/LoopStart").get("Value"))
|
||||
for kt in c.iter("KeyTrack"):
|
||||
k = int(kt.find("MidiKey").get("Value"))
|
||||
if k not in P["hit_notes"]:
|
||||
continue
|
||||
for n in kt.iter("MidiNoteEvent"):
|
||||
b = start + float(n.get("Time")) - offset
|
||||
if n.get("IsEnabled") != "false" and start <= b < end:
|
||||
cues.append([b, k])
|
||||
stem = re.sub(r"^\s*\d+\s*-\s*", "", re.sub(r"\[.*?\]", " ", name.upper())).strip()
|
||||
if t.tag == "AudioTrack" and any(stem.startswith(h.upper()) for h in P["hit_stems"]):
|
||||
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
||||
# a deactivated clip counts: the drummer plays live, and the stem still says where the hits are
|
||||
fr = c.find("SampleRef/FileRef")
|
||||
rel = fr.find("RelativePath").get("Value") if fr.find("RelativePath") is not None else ""
|
||||
absp = fr.find("Path").get("Value") if fr.find("Path") is not None else ""
|
||||
f = os.path.join(os.path.dirname(path), rel) if rel and os.path.exists(os.path.join(os.path.dirname(path), rel)) else absp
|
||||
hit_src.append([t.get("Id"), float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
||||
f, c.find("IsWarped").get("Value") == "true", float(c.find("Loop/LoopStart").get("Value"))])
|
||||
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
|
||||
"ltc": ltc, "pads": pads, "tune": tune}
|
||||
"ltc": ltc, "pads": pads, "tune": tune, "cues": cues, "hit_src": hit_src}
|
||||
out = {"live_set": live_set(), "bundles": {}}
|
||||
path = newest_als(folder(P["site"]))
|
||||
out["set"] = path
|
||||
if path:
|
||||
st = os.stat(path)
|
||||
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], 4] # 4: the summary's layout
|
||||
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], P["lighting_track"], P["hit_notes"], P["hit_stems"], 6]
|
||||
cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
|
||||
data = None
|
||||
try:
|
||||
@@ -498,6 +527,12 @@ if path:
|
||||
out["mtime"] = st.st_mtime
|
||||
out["summary"] = data
|
||||
out["finished"] = {}
|
||||
out["light_names"] = {}
|
||||
llib = os.path.expanduser(P["light_library"]) if P["light_library"] else ""
|
||||
for n in sorted(os.listdir(llib)) if llib and os.path.isdir(llib) else []:
|
||||
m = re.match(r"^(\d+)\s+(.+)\.alc$", n)
|
||||
if m:
|
||||
out["light_names"][m.group(1)] = m.group(2)
|
||||
lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else ""
|
||||
out["tune_library"] = {}
|
||||
for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []:
|
||||
@@ -522,7 +557,9 @@ def ableton_params(rc, plan):
|
||||
"date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []),
|
||||
"ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"),
|
||||
"finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"),
|
||||
"tune_library": rc.get("tune_library", "")}
|
||||
"tune_library": rc.get("tune_library", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"),
|
||||
"hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]),
|
||||
"light_library": rc.get("light_library", "")}
|
||||
|
||||
|
||||
def ableton_look(cfg, rc, plan):
|
||||
@@ -807,6 +844,118 @@ def mix_plan(rc, live):
|
||||
return routing, ears, sorted(changes.values(), key=lambda change: change["index"])
|
||||
|
||||
|
||||
# --- Lighting cue timing -------------------------------------------------------------------
|
||||
|
||||
HIT_SCRIPT = r"""
|
||||
import aifc, audioop, json, wave
|
||||
P = json.loads(%r)
|
||||
FRAME = 0.005
|
||||
def look(path, sec, span):
|
||||
aiff = path.lower().endswith((".aif", ".aiff"))
|
||||
f = aifc.open(path, "rb") if aiff else wave.open(path, "rb")
|
||||
try:
|
||||
sr, w, ch = f.getframerate(), f.getsampwidth(), f.getnchannels()
|
||||
a = max(0, int((sec - span - 0.03) * sr))
|
||||
f.setpos(a)
|
||||
hop, n = int(FRAME * sr), int((2 * span + 0.03) / FRAME)
|
||||
data = f.readframes(hop * (n + 1))
|
||||
finally:
|
||||
f.close()
|
||||
if aiff:
|
||||
data = audioop.byteswap(data, w) # AIFF is big-endian
|
||||
size = hop * w * ch
|
||||
rms = [audioop.rms(data[i * size:(i + 1) * size], w) for i in range(n)]
|
||||
rise = [max(0, rms[i] - sum(rms[max(0, i - 4):i]) / max(1, min(4, i))) for i in range(n)]
|
||||
times = [a / sr + i * FRAME for i in range(n)]
|
||||
near = [i for i in range(n) if abs(times[i] - sec) <= span]
|
||||
at = max([rise[i] for i in near if abs(times[i] - sec) <= 0.012] or [0])
|
||||
best = max(near, key=lambda i: rise[i]) if near else None
|
||||
return [round(times[best] - sec, 4), rise[best], at] if best is not None else None
|
||||
out = []
|
||||
for path, sec, span in P["cues"]:
|
||||
try:
|
||||
out.append(look(path, sec, span))
|
||||
except Exception as e:
|
||||
out.append(str(e)[:60])
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again
|
||||
|
||||
|
||||
def cue_times(rc, summary):
|
||||
"""[(beat, note, song group, file, second in the file, search span)] for every flash and FX cue over a drum stem."""
|
||||
groups = song_groups(summary)
|
||||
owner = {t["id"]: g for g, members in groups.items() for t in members}
|
||||
tempo = summary["tempo"]
|
||||
out, seen, wanted = [], set(), set(rc.get("light_hit_notes", range(30, 40)))
|
||||
for beat, note in sorted(summary.get("cues") or []):
|
||||
if beat in seen or note not in wanted:
|
||||
continue # a flash and an FX on the same 16th: one hit
|
||||
src = next((h for h in summary.get("hit_src") or [] if h[1] <= beat < h[2] and not h[4]), None)
|
||||
if not src:
|
||||
continue
|
||||
seen.add(beat)
|
||||
sec = seconds_at(tempo, beat) - seconds_at(tempo, src[1]) + src[5]
|
||||
sixteenth = seconds_at(tempo, beat + 0.25) - seconds_at(tempo, beat)
|
||||
out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.08, 0.45 * sixteenth)))
|
||||
return out
|
||||
|
||||
|
||||
def bar_of(beat):
|
||||
"""Live's position: beat 1315.75 -> "329.4.4" (4/4)."""
|
||||
return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1)
|
||||
|
||||
|
||||
def eval_light_timing(rc, look, hits):
|
||||
"""Cues whose hit is clearly a few ms off: the strongest hit near the cue is at least `light_tolerance_ms`
|
||||
away, is a real hit for that song, and is clearly stronger than what is under the cue itself."""
|
||||
line = Line()
|
||||
if not look.get("set"):
|
||||
line.fault(ERR, "NO SET", "no set to check")
|
||||
return line
|
||||
cues = cue_times(rc, look["summary"])
|
||||
if not cues:
|
||||
line.info = "NO CUES ON DRUMS"
|
||||
return line
|
||||
names = look.get("light_names") or {}
|
||||
tol = rc.get("light_tolerance_ms", 25) / 1000.0
|
||||
by_file = {}
|
||||
for c, h in zip(cues, hits):
|
||||
if isinstance(h, list):
|
||||
by_file.setdefault(c[3], []).append(h[1])
|
||||
typical = {f: sorted(v)[len(v) // 2] for f, v in by_file.items()}
|
||||
off, broken = [], set()
|
||||
for c, h in zip(cues, hits):
|
||||
if not isinstance(h, list):
|
||||
broken.add(os.path.basename(c[3]))
|
||||
continue
|
||||
dt, best, at = h
|
||||
if abs(dt) > tol and best >= 0.3 * typical.get(c[3], 0) and best > 0 and (at == 0 or best / at >= 1.5):
|
||||
off.append((c, dt))
|
||||
said = ["%s bar %s: %s is %d ms %s the hit" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note),
|
||||
abs(dt) * 1000, "before" if dt > 0 else "after")
|
||||
for (beat, note, song, _, _, _), dt in off]
|
||||
if off:
|
||||
line.fault(WARN, "%d CUES OFF" % len(off) if len(off) > 1 else "CUE OFF %s" % bar_of(off[0][0][0]), said[0])
|
||||
line.notes += said[1:]
|
||||
for f in sorted(broken):
|
||||
line.notes.append("could not read %s" % f)
|
||||
line.info = "%d CUES ON TIME" % (len(cues) - len(off) - sum(1 for h in hits if not isinstance(h, list)))
|
||||
return line
|
||||
|
||||
|
||||
def light_hits(cfg, rc, look):
|
||||
"""The hits under every cue, listened to on the Tracks Mac (once per saved set)."""
|
||||
key = (look.get("set"), look.get("mtime"))
|
||||
if key not in HITS:
|
||||
cues = cue_times(rc, look["summary"])
|
||||
hits = run_remote(cfg, rc["tracks_host"], HIT_SCRIPT % json.dumps({"cues": [[c[3], c[4], c[5]] for c in cues]}),
|
||||
timeout=rc.get("remote_timeout", 90)) if cues else []
|
||||
HITS.clear()
|
||||
HITS[key] = hits
|
||||
return HITS[key]
|
||||
|
||||
|
||||
# --- Song keys --------------------------------------------------------------------------
|
||||
|
||||
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
@@ -1440,6 +1589,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
|
||||
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
|
||||
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
|
||||
hits=lambda al: light_hits(cfg, rc, al),
|
||||
prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
|
||||
{"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
||||
src.update(sources or {})
|
||||
@@ -1458,7 +1608,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
plan = None
|
||||
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")),
|
||||
("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")),
|
||||
("tune", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
||||
("tune", rc.get("tracks_host")), ("light_timing", rc.get("tracks_host")),
|
||||
("prop", rc.get("prop_url")),
|
||||
("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")),
|
||||
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
||||
("lineup", rc.get("lineup_url")), ("mics", True),
|
||||
@@ -1493,6 +1644,11 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
lines.update(eval_ableton(rc, plan, al))
|
||||
lines["keys"] = eval_keys(rc, plan, al)
|
||||
lines["tune"] = eval_tune(rc, plan, al)
|
||||
if rc.get("light_timing_check", True):
|
||||
try:
|
||||
lines["light_timing"] = eval_light_timing(rc, al, src["hits"](al) if al.get("set") else [])
|
||||
except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest
|
||||
failed(["light_timing"], e)
|
||||
if rc.get("ltc_note", "LTC"):
|
||||
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
|
||||
na_songs = song_ltc(rc, plan, al)
|
||||
@@ -1515,7 +1671,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
if rc.get("locations"):
|
||||
lines["track_bundle"] = eval_track_bundle(rc, al)
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["tracks", "mix_foh", "lights", "keys", "tune"] + (["track_bundle"] if rc.get("locations") else []), e)
|
||||
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e)
|
||||
if rc.get("tracks_host") and rc.get("ableton_osc"):
|
||||
try:
|
||||
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())
|
||||
|
||||
Binary file not shown.
@@ -398,6 +398,16 @@ field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecod
|
||||
field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready")
|
||||
field("ready.pad_track", "str", "AMBIENT PADS", "The Ableton track whose pad clips are named by key (\"B - WARM - "
|
||||
"CHURCHFRONT PADS\"): the pad under each song must be in the song's key.", "Ready")
|
||||
field("ready.light_timing_check", "bool", True, "LIGHT TIMING: check every flash and FX cue against the hits in "
|
||||
"the drum stem under it.", "Ready")
|
||||
field("ready.light_hit_notes", "list", list(range(30, 40)), "The lighting notes that should land on a hit (30-32 Blinder "
|
||||
"Flash, 35-39 Blinder FX).", "Ready")
|
||||
field("ready.hit_stems", "list", ["DRUMS"], "Stems whose hits the cues are timed against (names starting with these: "
|
||||
"\"Drums (Live)\").", "Ready")
|
||||
field("ready.light_tolerance_ms", "int", 25, "How far off a cue may be before it is named.", "Ready", lo=10, hi=100)
|
||||
field("ready.light_library", "path", "", "The folder of lighting cue clips (\"30 Blinder Flash 1.alc\"), for the "
|
||||
"cues' names.", "Ready",
|
||||
example="~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/LIGHTING CUES MIDI")
|
||||
field("ready.tune_track", "str", "WAVES TUNE", "The Ableton track whose MIDI clips set Waves Tune's key per song "
|
||||
"(clips named like \"Ebmaj\").", "Ready")
|
||||
field("ready.tune_library", "path", "", "The folder of Waves Tune key clips (Ebmaj.alc ...), as the tracks computer sees "
|
||||
|
||||
+6
-3
@@ -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(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(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)" : ""))}
|
||||
${chip(null, "Auto-fix " + (af.paused === true ? "paused" : af.test_until ? "testing until " + new Date(af.test_until * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : 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.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>` : ""}
|
||||
@@ -292,7 +292,8 @@
|
||||
<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>
|
||||
${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.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="autofixbtn" ${dis} data-hint="${af.paused ? "Same as the AUTO-FIX key: back to the auto-fix hours now" : af.test_until ? "Stop testing: 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" : af.test_until ? "Stop testing" : "Pause auto-fix"}</button>
|
||||
${af.test_until ? "" : `<span class="inline" data-hint="Turn auto-fix on now, outside its hours, to try a fix out: it goes back to the hours by itself when the time is up (the AUTO-FIX key says TESTING)"><select id="aftestmins" ${dis}>${[15, 30, 60, 120].map((m) => `<option value="${m}" ${m === 60 ? "selected" : ""}>${m < 60 ? m + " min" : m / 60 + " h"}</option>`).join("")}</select> <button id="aftest" ${dis}>Auto-fix on for testing</button></span>`}`
|
||||
: `<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>` : ""}
|
||||
</div></div>
|
||||
@@ -301,7 +302,8 @@
|
||||
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");
|
||||
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.paused ? "autofix resume" : "autofix pause" }); 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 || af.test_until ? "autofix resume" : "autofix pause" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
|
||||
const aft = main.querySelector("#aftest"); if (aft) aft.onclick = async () => { try { await post("/api/command", { text: "autofix test " + main.querySelector("#aftestmins").value }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
|
||||
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
|
||||
if (!quiet) window.scrollTo(0, 0);
|
||||
}
|
||||
@@ -581,6 +583,7 @@
|
||||
tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
|
||||
mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
|
||||
lights: "The lighting cue track has a clip under every song.",
|
||||
light_timing: "Every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it. A cue whose hit is a few ms before or after it (while the cue itself sits on a quiet spot) is named with its song and bar, to be moved by hand.",
|
||||
tune: "The Waves Tune clip that starts each song is the song's Planning Center key, and sends that key's program. Swap the clip by hand (the hold says which and where); a minor song is fine on its relative major.",
|
||||
routing: "Read live from Ableton (AbletonOSC): every stem goes where the routing template says (Keys → Ext. Out 5/6 Keys). Hold to put them back.",
|
||||
ears: "Read live from Ableton: no song stem has its in-ear send (send A) at -inf, as dragged-in songs do. Hold to set them to 0 dB.",
|
||||
|
||||
+32
-7
@@ -817,7 +817,7 @@ def check_ready_manual(chk, snap, ctx):
|
||||
|
||||
|
||||
# What readiness.py looks at (check type "ready") and what a "ready" fix can do.
|
||||
READY_ITEMS = ("tracks", "mix_foh", "lights", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
|
||||
READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
|
||||
"track_bundle", "prop_bundle")
|
||||
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix")
|
||||
|
||||
@@ -1132,6 +1132,7 @@ class ServiceMode:
|
||||
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
|
||||
self.test = bool(saved.get("test")) # AUTO-FIX turned on for testing: on until its edge, then the hours again
|
||||
|
||||
def scheduled(self, now):
|
||||
return in_windows(self.windows, now)
|
||||
@@ -1153,6 +1154,12 @@ class ServiceMode:
|
||||
self._save()
|
||||
return sched
|
||||
return False
|
||||
if self.test:
|
||||
if self.edge is None or now >= self.edge:
|
||||
self.override, self.test, self.edge, self.last_sched = None, False, None, sched
|
||||
self._save()
|
||||
return sched
|
||||
return True
|
||||
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()
|
||||
@@ -1168,7 +1175,7 @@ class ServiceMode:
|
||||
Looked up once a minute."""
|
||||
if self.override is None:
|
||||
return None
|
||||
if self.hold:
|
||||
if self.hold or self.test:
|
||||
return self.edge
|
||||
minute = int(now // 60)
|
||||
cached = getattr(self, "_back", None)
|
||||
@@ -1191,7 +1198,7 @@ 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.hold = self.test = False
|
||||
self._save()
|
||||
|
||||
def pause(self, now):
|
||||
@@ -1199,13 +1206,19 @@ class ServiceMode:
|
||||
(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.override, self.hold, self.test, self.last_sched = False, True, False, 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.override, self.hold, self.test, self.edge, self.last_sched = None, False, False, None, self.scheduled(now)
|
||||
self._save()
|
||||
|
||||
def test_on(self, now, minutes):
|
||||
"""On for testing, whatever the hours, for `minutes` (5 to 240); then back to the hours by itself."""
|
||||
self.override, self.hold, self.test, self.last_sched = True, False, True, self.scheduled(now)
|
||||
self.edge = now + max(5, min(240, int(minutes))) * 60
|
||||
self._save()
|
||||
|
||||
@property
|
||||
@@ -1213,7 +1226,8 @@ class ServiceMode:
|
||||
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, "hold": self.hold})
|
||||
self.state.set(self.name, {"override": self.override, "last_sched": self.last_sched, "edge": self.edge, "hold": self.hold,
|
||||
"test": self.test})
|
||||
|
||||
def until(self, now):
|
||||
"""When the state shown now next changes by itself (an override's end, else the schedule's next edge),
|
||||
@@ -2379,6 +2393,8 @@ def render(engine, service_on):
|
||||
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 engine.autofix.test and on:
|
||||
text = "%s\nTESTING" % tile["label"] + ("\nTIL " + when if when else "")
|
||||
elif on:
|
||||
text = "%s\nON" % tile["label"] + ("\nTIL " + when if when else "")
|
||||
else:
|
||||
@@ -3751,7 +3767,8 @@ def snapshot_summary(snap, engine, now):
|
||||
"service_on": engine.service.on(now),
|
||||
"alarms_override": engine.service.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},
|
||||
"switch": engine.autofix.on(now), "override": engine.autofix.override, "paused": engine.autofix.paused,
|
||||
"test_until": engine.autofix.edge if engine.autofix.test else None},
|
||||
"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"]),
|
||||
@@ -3797,6 +3814,14 @@ def handle_commands(text, engine, comp, snap, now):
|
||||
target = {"on": True, "off": False}.get(arg, not engine.service.on(now))
|
||||
engine.service.set(target, now)
|
||||
log.info("SERVICE %s (button)", "ON" if target else "OFF")
|
||||
elif verb == "autofix" and arg == "test":
|
||||
# The settings page: "autofix test <minutes>" turns auto-fix on now, outside its hours, to try it out.
|
||||
mins = int(words[2]) if len(words) > 2 and words[2].isdigit() else 60
|
||||
engine.autofix.test_on(now, mins)
|
||||
log.info("AUTO-FIX ON FOR TESTING until %s", datetime.fromtimestamp(engine.autofix.edge).strftime("%H:%M"))
|
||||
if engine.autolog:
|
||||
engine.autolog.write(now, "PANEL", "auto-fix on for testing (settings page) until %s"
|
||||
% datetime.fromtimestamp(engine.autofix.edge).strftime("%H:%M"))
|
||||
elif verb == "autofix":
|
||||
# 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)
|
||||
|
||||
+51
-1
@@ -434,6 +434,56 @@ class TestWavesTune(unittest.TestCase):
|
||||
self.assertEqual(msgs(rd.eval_tune(RC, keyed(GOOD), ableton())), ["NO TUNE CLIPS"])
|
||||
|
||||
|
||||
def with_cues(al=None):
|
||||
"""Flashes over Can't Help But Praise's drums (beats 100-190 of the fixture, at 151 BPM there)."""
|
||||
al = al or ableton()
|
||||
al["summary"]["tracks"].append(track("25", "42-Drums (Live)", group="20", clips=((100, 190, None),)))
|
||||
al["summary"]["cues"] = [[104, 30], [104, 36], [108, 31], [112, 35], [116, 30], [120, 99]]
|
||||
al["summary"]["hit_src"] = [["25", 100, 190, "/Projects/NA/261018 NA Project/MultiTracks/Drums (Live).wav", False, 0.0]]
|
||||
al["light_names"] = {"30": "Blinder Flash 1", "31": "Blinder Flash 2", "35": "Blinder FX 1", "36": "Blinder FX 2"}
|
||||
return al
|
||||
|
||||
|
||||
class TestLightTiming(unittest.TestCase):
|
||||
def test_cue_times_in_the_stem(self):
|
||||
cues = rd.cue_times(RC, with_cues()["summary"])
|
||||
self.assertEqual([(c[0], c[1], c[2]) for c in cues], [(104, 30, "CAN'T HELP BUT PRAISE"), (108, 31, "CAN'T HELP BUT PRAISE"),
|
||||
(112, 35, "CAN'T HELP BUT PRAISE"), (116, 30, "CAN'T HELP BUT PRAISE")])
|
||||
tempo = with_cues()["summary"]["tempo"]
|
||||
self.assertAlmostEqual(cues[0][4], rd.seconds_at(tempo, 104) - rd.seconds_at(tempo, 100))
|
||||
self.assertAlmostEqual(cues[0][5], 0.45 * 60 / 151 / 4, places=4) # under half a 16th at 151 BPM
|
||||
|
||||
def test_off_cues_are_named(self):
|
||||
# [hit - cue (s), the hit's strength, strength under the cue]
|
||||
hits = [[0.005, 5000, 5000], # on time
|
||||
[0.037, 5200, 2000], # 37 ms early, a strong hit, little under the cue
|
||||
[-0.053, 900, 300], # off, but a weak hit for this song: left alone
|
||||
[0.040, 5100, 4800]] # off, but the cue already sits on a hit as strong: left alone
|
||||
line = rd.eval_light_timing(RC, with_cues(), hits)
|
||||
self.assertEqual(msgs(line), ["CUE OFF 28.1.1"])
|
||||
self.assertEqual(line.as_dict()["details"], ["CAN'T HELP BUT PRAISE bar 28.1.1: Blinder Flash 2 is 37 ms before the hit"])
|
||||
self.assertEqual(line.info, "3 CUES ON TIME")
|
||||
|
||||
def test_several_off(self):
|
||||
hits = [[-0.04, 5000, 0], [0.037, 5200, 2000], [0.0, 5000, 5000], [0.0, 5000, 5000]]
|
||||
line = rd.eval_light_timing(RC, with_cues(), hits)
|
||||
self.assertEqual(msgs(line), ["2 CUES OFF"])
|
||||
self.assertEqual(len(line.as_dict()["details"]), 2)
|
||||
self.assertIn("Blinder Flash 1 is 40 ms after the hit", line.as_dict()["details"][0])
|
||||
|
||||
def test_a_stem_it_cannot_read(self):
|
||||
line = rd.eval_light_timing(RC, with_cues(), ["unknown format: 3"] * 4)
|
||||
self.assertEqual(msgs(line), [])
|
||||
self.assertEqual(line.as_dict()["details"], ["could not read Drums (Live).wav"])
|
||||
|
||||
def test_bars_as_live_shows_them(self):
|
||||
self.assertEqual(rd.bar_of(1315.75), "329.4.4")
|
||||
self.assertEqual(rd.bar_of(0), "1.1.1")
|
||||
|
||||
def test_the_listener_runs(self):
|
||||
compile(rd.HIT_SCRIPT % json.dumps({"cues": [["/x.wav", 1.5, 0.05]]}), "remote", "exec")
|
||||
|
||||
|
||||
class TestTimecode(unittest.TestCase):
|
||||
def test_seconds_through_the_tempo_map(self):
|
||||
tempo = ableton()["summary"]["tempo"]
|
||||
@@ -567,7 +617,7 @@ class TestLook(unittest.TestCase):
|
||||
cfg["ready"].pop(k)
|
||||
cfg["ready"]["prop_url"] = ""
|
||||
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
|
||||
self.assertEqual(sorted(r["items"]), ["ears", "keys", "lights", "mics", "mix_foh", "routing", "timecode", "tracks", "tune"]) # LTC: tracks vs PCO
|
||||
self.assertEqual(sorted(r["items"]), ["ears", "keys", "light_timing", "lights", "mics", "mix_foh", "routing", "timecode", "tracks", "tune"]) # LTC: tracks vs PCO
|
||||
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
|
||||
snap.ready = r
|
||||
tiles = engine.step(snap, WEDNESDAY)
|
||||
|
||||
@@ -2521,6 +2521,26 @@ class TestSwitches(unittest.TestCase):
|
||||
self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes))
|
||||
self.assertTrue(any("pause auto-fix" in n for n in notes))
|
||||
|
||||
def test_auto_fix_on_for_testing(self):
|
||||
ran = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||
self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed
|
||||
thursday = SUNDAY - 3 * 86400 # outside the hours
|
||||
run_steps(self.engine, self.snap, thursday, 25)
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), thursday + 30), [])
|
||||
sp.handle_commands("autofix test 30", self.engine, mock_comp(), self.snap, thursday + 31)
|
||||
tiles = self.engine.step(self.snap, thursday + 32)
|
||||
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nTESTING\nTIL 8:30AM")
|
||||
self.engine.auto_fix(self.snap, mock_comp(), thursday + 40)
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(ran, ["ableton"]) # fixed by itself on a Thursday
|
||||
self.assertTrue(self.new_engine().autofix.test) # survives a restart
|
||||
self.assertFalse(self.engine.autofix.on(thursday + 31 + 30 * 60)) # half an hour later: back to the hours
|
||||
self.assertEqual(self.engine.step(self.snap, thursday + 31 + 30 * 60 + 1)["autofix"]["x"], "AUTO-FIX\nOFF\nON SUN 6:30AM")
|
||||
sp.handle_commands("autofix test 30", self.engine, mock_comp(), self.snap, thursday + 4000)
|
||||
sp.handle_commands("autofix resume", self.engine, mock_comp(), self.snap, thursday + 4060) # Stop testing
|
||||
self.assertFalse(self.engine.autofix.on(thursday + 4061))
|
||||
|
||||
def test_a_hold_from_north_attleboro_flips_a_switch(self):
|
||||
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY)
|
||||
self.assertTrue(self.engine.autofix.paused)
|
||||
|
||||
Reference in New Issue
Block a user