Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions app/panel_remote.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
37 changes: 29 additions & 8 deletions app/scr_touch_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -278,19 +280,32 @@ 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()
self.control_bridge.drain(
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
Expand All @@ -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],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 19 additions & 8 deletions browser/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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') ||
Expand Down Expand Up @@ -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));
}
Expand Down
24 changes: 18 additions & 6 deletions browser/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/test_scr_touch_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading