diff --git a/app/panel_remote.py b/app/panel_remote.py index eaf81f6..fb71ab8 100644 --- a/app/panel_remote.py +++ b/app/panel_remote.py @@ -1,8 +1,5 @@ """Shared presentation/state and actions, always called on the panel's Tk thread.""" -from panel_guides import DEMO, SUPPORT -from command_reference import command_tokens -from help_reference import reference import time from panel_version import VERSION, BUILD_COMMIT @@ -93,19 +90,6 @@ def snapshot(panel): "annotation": panel.annotation_text.get(), "recording": False, }, - "guide": [ - {"title": title, "icon": kind, "text": text} for title, kind, text in DEMO - ], - "help": [ - { - "title": title, - "text": text, - "commands": commands, - "command_tokens": command_tokens(commands), - "reference": reference(title), - } - for title, kind, text, commands in SUPPORT - ], "weather": panel.weather_reading, "weather_age_s": ( None diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index 675f64f..85662f5 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -19,6 +19,7 @@ import sys import threading import time +import traceback import uuid from io import BytesIO from scr_touch_panel_lights import PRESETS, setter_words @@ -183,6 +184,7 @@ def __init__(self, args): 800, ) and not args.windowed: self.root.destroy() + self.journal.close() raise ValueError("Expected 1280x800; use --windowed for local review") self.root.geometry("1280x800+0+0") self.root.overrideredirect(not args.windowed) @@ -258,7 +260,7 @@ def __init__(self, args): self.set_theme() if args.live_lights and self.mode != "original": self.start_worker() - self.tick() + self.repeat(self.tick, 1000) self.root.after(100, self.poll_worker) self.emit( "panel_opened", @@ -278,11 +280,25 @@ def __init__(self, args): ) self.emit("browser_bridge_unavailable", detail=str(error)) else: - self.poll_control() + self.repeat(self.poll_control, 80) + + def repeat(self, step, delay): + """Run one loop step, then schedule the next unless it returned False or the + panel closed. An unexpected error is logged once per step; the loop goes on.""" + again = True + try: + again = step() is not False + except Exception: + failed = self.__dict__.setdefault("loop_failures", set()) + if step.__name__ not in failed: + failed.add(step.__name__) + traceback.print_exc() + if again and not self.closed: + self.root.after(delay, lambda: self.repeat(step, delay)) def poll_control(self): if self.closed or self.closing or self.control_bridge is None: - return + return False import panel_remote self.refresh_resources() @@ -290,7 +306,6 @@ def poll_control(self): lambda: panel_remote.snapshot(self), lambda request: panel_remote.execute(self, request), ) - self.root.after(80, self.poll_control) def emit(self, event, **values): self.sequence += 1 @@ -301,7 +316,10 @@ def emit(self, event, **values): panel_run_id=self.run_id, boot_id=self.boot_id, sequence=self.sequence, - utc=datetime.datetime.utcnow().isoformat() + "Z", + utc=datetime.datetime.now(datetime.timezone.utc) + .replace(tzinfo=None) + .isoformat() + + "Z", elapsed_s=round(monotonic() - self.started, 3), instruction_index=self.stage, instruction=self.profile["stages"][self.stage], @@ -1353,6 +1371,10 @@ def power_snapshot(self): message = power.message if power else "Power controls unavailable." if power and power.phase == "ready" and not clear: message = "Pause instructions and wait for confirmed lighting before restarting or shutting down." + if power and power.phase == "failed": + message += " Power controls stay off until the panel restarts." + if self.light_state == "disconnected" and self.worker is None: + message += " Open Legacy, then Home, to reconnect lighting." return { "available": available, "actions": actions, @@ -2034,7 +2056,7 @@ def cancel_hold(self): def tick(self): if self.closed: - return + return False remaining = ( self.trial_deadline - monotonic() if self.trial_deadline is not None @@ -2043,7 +2065,7 @@ def tick(self): if remaining is not None and remaining <= 0 and not self.closing: self.close("timeout") if self.closed: - return + return False self.set_theme() clock = ( time.strftime("%a %d %b") @@ -2086,7 +2108,6 @@ def tick(self): ) self.update_session() self.update_health() - self.root.after(1000, self.tick) def close(self, reason): if self.closed or self.closing: diff --git a/browser/room.js b/browser/room.js index 1060294..067ff04 100644 --- a/browser/room.js +++ b/browser/room.js @@ -96,7 +96,8 @@ function command(action, args = {}, revision = state?.revision) { message('Command was rejected. Refresh the page before trying a new action.'); return false; } - if (!reply) { + // A 503 reply means the panel may still have acted, so treat it like no reply. + if (!reply || response.status === 503 || reply.code === 503) { unknown = id; unknownSession = state?.session; message( @@ -688,7 +689,7 @@ function commitRange(key) { ); } function guide(root, index) { - const topics = state?.guide || content.guide; + const topics = content.guide; root.append(el('h2', 'Demo & guide')); if (index < 0 || !topics[index]) { root.append( @@ -756,7 +757,7 @@ function system(parent) { select.id = 'help-topic'; row.append(select); parent.append(row); - const topics = state?.help || content.help; + const topics = content.help; for (const [i, t] of topics.entries()) { const option = el('option', t.title); option.value = i; @@ -892,7 +893,7 @@ function syncPower() { } function renderHelp() { if (!$('help-detail')) return; - const topic = (state?.help || content.help)[chosenHelp]; + const topic = (content.help)[chosenHelp]; $('help-detail').replaceChildren(); if (!topic) return; const card = el('section', undefined, 'card'); @@ -1362,7 +1363,6 @@ async function refresh() { : 'The panel is back online. This website is connected again.', ); } - if (state.guide) content = { ...content, guide: state.guide, help: state.help }; if ( !view || (!$('help-topic') && view === 'tools-system') || @@ -1397,13 +1397,24 @@ window.addEventListener('scr-preview-change', () => { view = ''; layout(); }); -(async () => { +// Guide and help text come from room-content.json, generated from the panel's own text. +async function loadContent() { try { - content = await (await fetch('/room-content.json')).json(); - } catch (_) {} + content = await (await fetch('/room-content.json', { cache: 'no-store' })).json(); + return true; + } catch (_) { + return false; + } +} +(async () => { + await loadContent(); layout(); // Poll every second; slow to every 5 s while the website is not answering. for (;;) { + if (!content.guide.length && (await loadContent())) { + view = ''; + layout(); + } await refresh(); await new Promise((done) => setTimeout(done, failures > 2 ? 5000 : 1000)); } diff --git a/browser/serve.py b/browser/serve.py index 3782a85..851e483 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -238,8 +238,10 @@ def bounded_graph(master): class Snapshot: - def __init__(self, label, master=None, weather_location=None): - self.label, self.master = label, master + def __init__( + self, label, master=None, weather_location=None, timezone="America/New_York" + ): + self.label, self.master, self.timezone = label, master, timezone self.lock = threading.Lock() self.data = None self.sample_time = self.graph_time = None @@ -267,7 +269,7 @@ def collect_weather(self): "-B", str(ROOT.parent / "app/weather.py"), *map(str, self.weather_location), - "America/New_York", + self.timezone, ], capture_output=True, text=True, @@ -344,8 +346,13 @@ def run(self, stopped): while not stopped.wait(30): try: self.collect() - except OSError: - # Keep the dated previous sample; clients mark it stale. + except Exception as error: + # Keep the dated previous sample; clients mark it stale. Never stop sampling. + print( + "%s status sample failed: %r" % (utc(), error), + file=sys.stderr, + flush=True, + ) continue @@ -547,6 +554,11 @@ def main(): help="Optional public outdoor-weather location", ) parser.add_argument("--weather-longitude", type=float) + parser.add_argument( + "--timezone", + default="America/New_York", + help="Weather day boundaries; match the panel's --timezone", + ) parser.add_argument("--probe-master", help=argparse.SUPPRESS) args = parser.parse_args() try: @@ -576,7 +588,7 @@ def main(): if args.weather_latitude is None else (args.weather_latitude, args.weather_longitude) ) - snapshot = Snapshot(args.label, args.master, location) + snapshot = Snapshot(args.label, args.master, location, args.timezone) snapshot.collect() server = RoomServer((args.bind, args.port), Handler) server.allowed_clients = allowed diff --git a/docs/interface.md b/docs/interface.md index bc16630..61c7281 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -156,8 +156,8 @@ describe availability and About information without changing schema version 1. `app/design_tokens.py` owns theme roles. `app/export_room_content.py` exports the panel-owned guide/help and Lucide paths for browser fallback and offline demos. -The live snapshot supplies current preferences, controls, receipts, guide/help and -experiment state. Original Legacy controls are external to this common Beta API. +The website reads guide/help only from that export. The live snapshot supplies +current preferences, controls, receipts and experiment state. Original Legacy controls are external to this common Beta API. `app/help_reference.py` owns each command step's computer, effect, purpose, expected result and exact copy text. A help topic exports its `reference` object diff --git a/tests/test_scr_touch_panel.py b/tests/test_scr_touch_panel.py index bff9bd8..d965dd4 100644 --- a/tests/test_scr_touch_panel.py +++ b/tests/test_scr_touch_panel.py @@ -952,3 +952,35 @@ def fsync(unused): assert "Saved on panel" not in state.annotation_text.value assert "Written to panel output" not in state.annotation_text.value assert "Panel journal shutdown failed:" in stderr + + +def test_panel_loops_keep_running_after_one_unexpected_error(capsys): + scheduled = [] + state = panel.Panel.__new__(panel.Panel) + state.closed = False + state.root = SimpleNamespace(after=lambda delay, callback: scheduled.append(callback)) + calls = [] + + def step(): + calls.append(1) + if len(calls) < 3: + raise KeyError("broken reading") + return False if len(calls) == 4 else None + + state.repeat(step, 1000) + scheduled.pop()() + assert capsys.readouterr().err.count("KeyError:") == 1 # logged once + scheduled.pop()() + scheduled.pop()() # returned False: the loop ends + assert len(calls) == 4 and not scheduled + state.closed = True + state.repeat(lambda: None, 1000) + assert not scheduled + + +def test_live_snapshot_leaves_guide_and_help_to_the_shared_export(): + import inspect + import panel_remote + + source = inspect.getsource(panel_remote.snapshot) + assert '"guide"' not in source and '"help"' not in source