NA: ALL AUTO-FIX key pauses or resumes auto-fix at every location; page keys keep their words under the band; rc4 ProP module at WS/SC

- LOCATIONS page key at 3,1: shows ALL ON or the paused locations; tap pauses all, tap again sends each back to its hours
- autofix auto: back to the auto-fix hours
- the page name and number shrink into the top 60% while the locations band shows
- WS and SC Propresenter-API on renewedvision-propresenter-api 1.2.0-rc4 (pinned manual, as NA)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 15:12:10 -04:00
co-authored by Claude Opus 5.5
parent 2a37cee10a
commit 3428bd81a2
7 changed files with 96 additions and 6 deletions
+10 -1
View File
@@ -269,13 +269,18 @@ REMOTE_VAR = "sp_remote" # North Attleboro's summary of the other locat
BAND_BLUE, BAND_DARK = 0x1E6FFF, 0x0B2A66 BAND_BLUE, BAND_DARK = 0x1E6FFF, 0x0B2A66
def band_on():
r = "$(custom:%s)" % REMOTE_VAR
return "$(internal:time_unix) - jsonpath(%s, '$.t') < 30 && jsonpath(%s, '$.n') > 0" % (r, r)
def locations_band(flash_var): def locations_band(flash_var):
"""A blue band across the bottom of NA's page-number key naming the other locations with a problem """A blue band across the bottom of NA's page-number key naming the other locations with a problem
("SC", "SC WS"): it flashes until that location acknowledges, then stays. Red stays NA's own ("SC", "SC WS"): it flashes until that location acknowledges, then stays. Red stays NA's own
(2026-10-21, the user: "i dont want the NA page buttons to flash red, that should he reserved for on site (2026-10-21, the user: "i dont want the NA page buttons to flash red, that should he reserved for on site
problems").""" problems")."""
r = "$(custom:%s)" % REMOTE_VAR r = "$(custom:%s)" % REMOTE_VAR
on = "$(internal:time_unix) - jsonpath(%s, '$.t') < 30 && jsonpath(%s, '$.n') > 0" % (r, r) on = band_on()
color = "jsonpath(%s, '$.f') == 1 && $(custom:%s) == 1 ? %d : %d" % (r, flash_var, BAND_DARK, BAND_BLUE) color = "jsonpath(%s, '$.f') == 1 && $(custom:%s) == 1 ? %d : %d" % (r, flash_var, BAND_DARK, BAND_BLUE)
box = {"id": stable_id("nav", "band_box"), "name": "Locations", "usage": "auto", "type": "box", "enabled": prop(on, True), box = {"id": stable_id("nav", "band_box"), "name": "Locations", "usage": "auto", "type": "box", "enabled": prop(on, True),
"opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0), "opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0),
@@ -314,6 +319,10 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
paint(layer.get("children", [])) paint(layer.get("children", []))
paint(key["style"]["layers"]) paint(key["style"]["layers"])
if kind == "pagenum" and (cfg.get("nav_locations_band") or False): if kind == "pagenum" and (cfg.get("nav_locations_band") or False):
# While the band shows, the page's own name and number shrink into the top 60%, so nothing is hidden.
for layer in key["style"]["layers"]:
if layer["type"] == "group":
layer["height"] = prop("(%s) ? 60 : 100" % band_on(), True)
key["style"]["layers"] += locations_band(opts["flash"]) key["style"]["layers"] += locations_band(opts["flash"])
if kind == "pagenum" and status_page: if kind == "pagenum" and status_page:
step = key["steps"]["0"] step = key["steps"]["0"]
Binary file not shown.
Binary file not shown.
+69 -3
View File
@@ -74,9 +74,9 @@ def me(cfg, url=None):
"user": pwd.getpwuid(os.getuid()).pw_name} "user": pwd.getpwuid(os.getuid()).pw_name}
# Bumped when the pages are built differently (2: "campus" became "location", 2026-10-21), so the hub # Bumped when the pages are built differently (2: "campus" became "location"; 3: ALL AUTO-FIX key, 2026-10-21), so the hub
# re-imports them. # re-imports them.
PAGES_VERSION = 2 PAGES_VERSION = 3
def layout_sha(cfg): def layout_sha(cfg):
@@ -105,6 +105,9 @@ def http_json(url, body=None, timeout=3):
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"} VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
REMOTE = make_page.REMOTE_VAR # which other locations have a problem: the blue band on NA's page-number keys REMOTE = make_page.REMOTE_VAR # which other locations have a problem: the blue band on NA's page-number keys
ALL_CMD = "sp_all_cmd" # a tap on NA's ALL AUTO-FIX key (LOCATIONS page)
ALL_AF = "sp_all_af" # what that key shows: {"t", "x", "paused"}
ALL_AF_POS = (3, 1) # where AUTO-FIX sits on every location's own status page
SELECT = "sp_view" SELECT = "sp_view"
VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog
MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page
@@ -199,14 +202,51 @@ def build_overview(local_cfg, campuses, page, view_page):
make_page.place(controls, (0, col), key) make_page.place(controls, (0, col), key)
if restart: if restart:
make_page.place(controls, (1, col), restart) make_page.place(controls, (1, col), restart)
make_page.place(controls, ALL_AF_POS, all_autofix_key(local_cfg))
return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg) return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg)
def all_autofix_key(local_cfg):
"""Tap: pause auto-fix at every location at once (NA too); tap again: every location back to its hours
(2026-10-21, the user: "add a key on NA to pause auto-fix at all locations"). Blue while none is paused,
grey naming the paused ones."""
v = "$(custom:%s)" % ALL_AF
stale = "$(internal:time_unix) - jsonpath(%s, '$.t') > 30" % v
key = make_page.static_button("all_autofix", "AUTO-FIX\\nALL", 0x0B57D0, 0xFFFFFF, 1,
"Tap: pause auto-fix at every location (alarms stay on, holds still fix); tap again: all "
"back to their auto-fix hours.", "STATUS tile 'all autofix'")
for layer in key["style"]["layers"]:
if layer["type"] == "box":
layer["color"] = make_page.prop("%s ? %d : (jsonpath(%s, '$.paused') > 0 ? %d : %d)" % (stale, make_page.PURPLE, v, 0x262626, 0x0B57D0), True)
elif layer["type"] == "text":
layer["text"] = make_page.prop("%s ? `AUTO-FIX\\nALL ?` : `AUTO-FIX\\n${jsonpath(%s, '$.x')}`" % (stale, v), True)
key["steps"]["0"]["action_sets"]["up"] = [make_page.command_action("all_autofix", "press", ALL_CMD, "toggle $(internal:time_unix)")]
return key
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."""
try:
af = json.loads(state_json or "{}").get("autofix")
except ValueError:
return None
if not af:
return None
lines = str(af.get("x") or "").split("\n")
return len(lines) > 2 and lines[1] == "OFF" and lines[2].startswith("TIL")
def all_autofix_summary(states, now):
"""states: {id: that location's /api/state "state" JSON}. -> what NA's ALL AUTO-FIX key shows."""
paused = sorted(cid.upper() for cid, s in states.items() if autofix_paused(s))
return {"t": int(now), "paused": len(paused), "x": ("%s\nPAUSED" % " ".join(paused)) if paused else "ALL ON"}
def ensure_variables(cfg, ids): def ensure_variables(cfg, ids):
from settings import campus from settings import campus
have, _ = campus.companion_state(cfg) have, _ = campus.companion_state(cfg)
want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have] want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have]
want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE, REMOTE) if n not in have] want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE, REMOTE, ALL_CMD, ALL_AF) if n not in have]
if not want: if not want:
return return
comp = cfg.get("companion") or {} comp = cfg.get("companion") or {}
@@ -342,6 +382,31 @@ class Mirror:
save(self.site.data_dir, data) save(self.site.data_dir, data)
return data return data
def all_autofix(self, cfg, mine, others):
"""NA's ALL AUTO-FIX key: show which locations are paused; a tap pauses them all, or resumes them all."""
states = {cid: (self.remote.get(cid) or {}).get("state") for cid in others}
states[mine] = self.site.comp.get_custom((cfg.get("variables") or {}).get("state", "sp_state"))
summary = all_autofix_summary(states, time.time())
self.site.comp.set_custom(ALL_AF, json.dumps(summary, separators=(",", ":")))
text = (self.site.comp.get_custom(ALL_CMD) or "").strip(" ;")
if not text:
return
self.site.comp.set_custom(ALL_CMD, "")
stamps = [w for w in text.replace(";", " ").split() if w.isdigit()]
if not stamps or time.time() - int(stamps[-1]) > 30:
return # pressed while this was down: not acted on later
word = "auto" if summary["paused"] else "off"
hosts = {cid: urllib.parse.urlparse(e["url"]).hostname for cid, e in others.items()}
hosts[mine] = "127.0.0.1"
for cid, host in sorted(hosts.items()):
try:
comp = self.site.comp if cid == mine else sp.Companion(host, 8000, timeout=3)
before = (comp.get_custom("sp_cmd") or "").strip()
comp.set_custom("sp_cmd", ";".join([before, "autofix " + word] if before else ["autofix " + word]))
log.info("ALL AUTO-FIX: %s %s", cid, "paused" if word == "off" else "back to its hours")
except OSError as e:
log.warning("ALL AUTO-FIX: %s not reached: %s", cid, e)
def pass_on(self, pick, others, restart=False): def pass_on(self, pick, others, restart=False):
"""A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion.""" """A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion."""
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip() text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
@@ -388,6 +453,7 @@ class Mirror:
t.join(2) t.join(2)
if (data.get("pages") or {}).get("overview"): if (data.get("pages") or {}).get("overview"):
self.site.comp.set_custom(REMOTE, json.dumps(remote_summary(others, self.remote, time.time()), separators=(",", ":"))) self.site.comp.set_custom(REMOTE, json.dumps(remote_summary(others, self.remote, time.time()), separators=(",", ":")))
self.all_autofix(cfg, mine, others)
# The LOCATION page shows the tapped location: its keys moved onto their cells. # The LOCATION page shows the tapped location: its keys moved onto their cells.
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
if pick not in others and others: if pick not in others and others:
Binary file not shown.
+3 -2
View File
@@ -3694,9 +3694,10 @@ 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":
target = {"on": True, "off": False}.get(arg, not engine.autofix.on(now)) # "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) engine.autofix.set(target, now)
log.info("AUTO-FIX %s (button)", "ON" if target else "OFF") log.info("AUTO-FIX %s (%s)", "ON" if target else "OFF", "back to its hours" if arg == "auto" else "button")
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.
+14
View File
@@ -602,3 +602,17 @@ class TestLocationsBand(unittest.TestCase):
band = [l for l in make_page.nav_key("pagenum", na["variables"], 19, cfg=dict(na, nav_locations_band=True))["style"]["layers"] band = [l for l in make_page.nav_key("pagenum", na["variables"], 19, cfg=dict(na, nav_locations_band=True))["style"]["layers"]
if l.get("name") == "Locations"][0] if l.get("name") == "Locations"][0]
self.assertNotIn(str(0xFF0000), json.dumps(band)) # never red self.assertNotIn(str(0xFF0000), json.dumps(band)) # never red
class TestAllAutoFix(unittest.TestCase):
def test_the_key_names_the_paused_locations_and_sits_where_auto_fix_does(self):
from settings import campuses as cv
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}
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")
na = json.load(open(os.path.join(ROOT, "config.json")))
page = cv.build_overview(na, {"na": {"name": "North Attleboro"}, "sc": {"name": "Southcoast"}}, 98, 99)
key = page["page"]["controls"]["3"]["1"]
self.assertIn("all autofix", key["options"]["notes"])
self.assertIn("sp_all_cmd", json.dumps(key["steps"]))