From 8f2555f144d3592e2195c6caa4538d2f7e8845f0 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 13:56:41 -0400 Subject: [PATCH 1/9] Keep room navigation visible and responsive across display modes --- .github/workflows/tests.yml | 4 +- WORKFLOWS.md | 7 +- app/panel_views.py | 3 - app/scr_touch_panel.py | 58 +++++++++-- docs/interface.md | 3 + tests/test_panel_navigation.py | 179 +++++++++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 tests/test_panel_navigation.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a095375..ffa0888 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' - - run: sudo apt-get update && sudo apt-get install -y python3-tk + - run: sudo apt-get update && sudo apt-get install -y python3-tk xvfb - run: python -m pip install pytest -r app/requirements.txt - - run: python -m pytest tests + - run: xvfb-run -a python -m pytest tests diff --git a/WORKFLOWS.md b/WORKFLOWS.md index d59b428..950866c 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -417,5 +417,8 @@ Common actions and their rejection rules are owned by [the interface contract](docs/interface.md#optional-browser-gateway-version-1). Changing a website page does not move room devices. Explicit Legacy/display controls affect the cabinet. A running instruction session blocks lighting on -both interfaces until paused. Blind movement and external Legacy actuator buttons -remain outside this gateway. Reference commands may be copied, never executed. +both interfaces until paused. Pause instructions before opening Legacy, Blank or +clock view on either interface. Home keeps the instruction session running; use +Room tools → Experiment mode to pause it. Blind movement and external Legacy +actuator buttons remain outside this gateway. Reference commands may be copied, +never executed. diff --git a/app/panel_views.py b/app/panel_views.py index e7f5318..d60701e 100644 --- a/app/panel_views.py +++ b/app/panel_views.py @@ -331,9 +331,6 @@ def lighting_contents(self): button._scene_label = "Apply scene" self.light_buttons.append(button) self.scene_buttons.append((button, scene["id"])) - self.wrap_label(self.label(self.body, UNAVAILABLE_LIGHTING, 16, True)).pack( - fill="x", pady=(4, 8) - ) self.feedback_card(self.body) elif self.lighting_tab == "programmes": self.wrap_label(self.label(self.body, UNAVAILABLE_LIGHTING, 18, True)).pack( diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index 675f64f..4559222 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -542,7 +542,13 @@ def button(self, parent, text, action, accent=False): return button def show_tip(self, text): - if self.help_on and not self.pending and self.light_state != "failed": + if ( + self.help_on + and not self.pending + and not self.release_target + and not self.closing + and self.light_state != "failed" + ): tip = TIPS.get(text) if text in ("Fid", "Sat", "Dul"): tip = ( @@ -611,6 +617,7 @@ def set_theme(self): for name, value in THEMES[chosen].items(): setattr(self, name, value) self.connection_badges = [] + self.navigation_buttons = [] self.body_cache.clear() self.detail_cache.clear() self.body_key = self.advanced_detail_key = None @@ -655,8 +662,11 @@ def set_theme(self): padx=6, ) button.pack(fill="both", expand=True) + self.navigation_buttons.append(button) if column == 0: self.home_button = button + else: + self.legacy_button = button self.add_connection_badge(navigation, column=1) self.footer = tk.Frame(self.root, bg=self.bg, height=80) self.footer.pack_propagate(False) @@ -683,6 +693,7 @@ def set_theme(self): else: self.guide_footer_button = button button.pack(side="right", padx=(0, gap), pady=8) + self.navigation_buttons.append(button) status = self.label(self.footer, size=17, muted=True) status.config(textvariable=self.status_text) self.wrap_label(status) @@ -731,6 +742,7 @@ def build_dock(self): compound="left", ) home.pack(fill="both", expand=True) + self.navigation_buttons.append(home) mode = tk.Label( navigation, text="Legacy view", @@ -760,6 +772,7 @@ def build_dock(self): compound="left", ) button.pack(fill="both", expand=True) + self.navigation_buttons.append(button) self.label( row, "Legacy controls below · Home returns to the main screen", 16, True ).pack(side="left", fill="y") @@ -774,6 +787,7 @@ def add_connection_badge(self, parent, legacy=False, column=None): button = self.button(frame, "", lambda: self.open_advanced("system")) button.config(font=(FONT, 15), padx=6, compound="left") button.pack(fill="both", expand=True) + self.navigation_buttons.append(button) self.connection_badges.append((button, legacy)) self.update_connection_badges() @@ -805,6 +819,9 @@ def update_connection_badges(self): def show(self, mode, advanced_page=None): if self.release_target is not None or self.closing or self.power_busy(): return + if getattr(self, "running", False) and mode in ("original", "black", "visitor"): + self.status_text.set("Pause the instructions before switching the panel.") + return if mode == "advanced": self.open_advanced() return @@ -820,6 +837,7 @@ def show(self, mode, advanced_page=None): self.worker.stdin.close() elif not self.pending: self.request("release") + self.update_light_buttons() return if self.mode == "original" and mode != "original" and self.args.live_lights: if not self.worker: @@ -846,7 +864,11 @@ def show(self, mode, advanced_page=None): if self.light_state != "failed" and not self.pending: messages = { "health": "Live panel readings. No system settings change.", - "room": "", + "room": ( + "Instructions are running. Pause Experiment mode before using lighting." + if self.running + else "" + ), "advanced": "Fine adjustments and room tools. Opening a page does not operate devices.", "visitor": "Visitor view does not change lighting. Recording status is unavailable here.", "experiment": "Experiment mode: instructions and annotations. Recorder not connected.", @@ -860,8 +882,22 @@ def show(self, mode, advanced_page=None): if mode in messages: self.status_text.set(messages[mode]) self.render() + self.update_navigation_buttons() self.emit("mode_changed", target=mode) + def update_navigation_buttons(self): + """Make a pending handoff visible instead of accepting ignored touches.""" + busy = bool(self.release_target or self.closing or self.power_busy()) + state = "disabled" if busy else "normal" + for button in getattr(self, "navigation_buttons", []): + if button.winfo_exists() and button.cget("state") != state: + button.config(state=state) + button = getattr(self, "legacy_button", None) + if button is not None and button.winfo_exists(): + text = "Opening" if self.release_target == "original" else "Legacy" + if button.cget("text") != text: + button.config(text=text) + def render(self): self.cancel_intent() if getattr(self, "blank_hint", None) is not None: @@ -869,9 +905,8 @@ def render(self): self.dock.withdraw() if self.mode == "original": self.root.withdraw() - self.dock.deiconify() - self.dock.update_idletasks() self.dock.geometry("1280x144+0+0") + self.dock.deiconify() self.dock.lift() return if self.root.state() == "withdrawn": @@ -939,6 +974,8 @@ def render(self): if getattr(self, "advanced_page", None) else getattr(self, self.mode + "_view") ) + # Blank display unmaps the shell too. Retained tool pages still need it shown. + self.body.pack(fill="both", expand=True, padx=24) if self.idle_active: self.install_wake_bindings() @@ -946,10 +983,6 @@ def rebuild_body(self, build): """Retain the everyday pages so returning does not rebuild their layout.""" previous = self.body old_key = self.body_key - if old_key is not None: - if old_key[0] == "tools": - self.remember_detail() - self.body_cache[old_key] = (previous, self.page_references()) key = None if self.advanced_page: key = ("tools",) @@ -959,6 +992,13 @@ def rebuild_body(self, build): self.temperature_unit, self.blinds_display, ) + if key is not None and key == old_key: + self.refresh_page() + return + if old_key is not None: + if old_key[0] == "tools": + self.remember_detail() + self.body_cache[old_key] = (previous, self.page_references()) cached = ( self.body_cache.pop(key, None) if key is not None and key != old_key @@ -1246,6 +1286,7 @@ def dispatch_intent(self): self.update_light_buttons() def update_light_buttons(self): + self.update_navigation_buttons() for button in self.light_buttons: if button.winfo_exists(): button.config( @@ -2063,6 +2104,7 @@ def tick(self): set_changed(self.visitor_date, time.strftime("%A, %d %B %Y")) set_changed(self.preference_text, self.preference_summary()) self.update_connection_badges() + self.update_navigation_buttons() self.refresh_confirmation_age() self.update_idle() if self.weather_reading: diff --git a/docs/interface.md b/docs/interface.md index bc16630..cc4cac5 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -8,6 +8,9 @@ service is implemented. A JSON object may override `title`, `sensors`, `stages` and `annotations`. Unknown keys are rejected. No paths, callbacks or executable code are accepted. +The panel loads this file through `--profile` at startup. The browser/API can +operate its instructions and annotations; changing the profile through the API +is not implemented. ```json { diff --git a/tests/test_panel_navigation.py b/tests/test_panel_navigation.py new file mode 100644 index 0000000..ecba5d0 --- /dev/null +++ b/tests/test_panel_navigation.py @@ -0,0 +1,179 @@ +"""Exercise real Tk navigation with every external process and device call blocked.""" + +import argparse +from io import StringIO +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) +import scr_touch_panel as panel + + +@pytest.fixture +def view(monkeypatch): + original = panel.tk.Tk + + def invisible_root(): + try: + root = original() + except panel.tk.TclError: + pytest.skip("Tk display unavailable; run with a display or xvfb-run") + root.withdraw() + root.attributes("-alpha", 0) + root.tk.call("tk", "scaling", 96 / 72) + return root + + def blocked(*args, **kwargs): + raise AssertionError("Navigation must never launch a device or subprocess") + + monkeypatch.setattr(panel.tk, "Tk", invisible_root) + monkeypatch.setattr(panel.subprocess, "Popen", blocked) + monkeypatch.setattr(panel.Panel, "start_weather", lambda self: None) + monkeypatch.setattr(panel.Panel, "refresh_resources", lambda self: None) + monkeypatch.setattr(panel.Panel, "poll_worker", lambda self: None) + monkeypatch.setattr(sys, "stdout", StringIO()) + args = argparse.Namespace( + profile=None, + preferences=None, + journal=None, + duration=0, + keep_switcher=True, + start_mode="room", + theme="light", + light_at=420, + dark_at=1140, + windowed=True, + logo=None, + live_lights=False, + tips="on", + clock_format="24", + weather_latitude=None, + weather_longitude=None, + temperature_unit="f", + idle_timeout=300, + ) + instance = panel.Panel(args) + # Legacy's strip is a separate window, also kept invisible during these checks. + instance.dock.attributes("-alpha", 0) + instance.root.update() + errors = [] + instance.root.report_callback_exception = lambda kind, value, tb: errors.append(value) + yield instance + instance.journal.close() + instance.root.destroy() + assert instance.journal.done.wait(2) + assert not errors + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +def test_home_and_tools_survive_legacy_idle_and_blank_display(view, theme): + view.choose_theme(theme) + view.root.update() + home = view.body + header = view.banner + for unused in range(5): + view.show("original") + view.root.update() + assert view.root.state() == "withdrawn" + assert view.dock.winfo_height() == 144 + view.open_home() + view.root.update() + assert view.body is home # Returning from Legacy must not reload Home. + assert view.body.winfo_ismapped() + assert view.banner is header and view.banner.winfo_y() == 8 + assert view.banner.winfo_y() + view.banner.winfo_height() < view.body.winfo_y() + view.open_advanced("lighting") + view.root.update() + tools, detail = view.body, view.advanced_detail + view.show("black") + view.root.update() + assert not tools.winfo_ismapped() + view.return_from_blank() + view.root.update() + assert view.body is tools and tools.winfo_ismapped() + assert view.advanced_detail is detail and detail.winfo_ismapped() + view.show("visitor") + view.root.update() + view.return_from_idle() + view.root.update() + assert view.body is home and home.winfo_ismapped() + assert view.tools_footer_button.winfo_ismapped() + + +def test_pending_legacy_handoff_is_visible_and_cannot_dispatch_more_commands(view): + view.light_state = "ready" + requests = [] + view.worker = SimpleNamespace(stdin=StringIO()) + view.request = lambda action: requests.append(action) + view.show("original") + view.root.update() + assert view.mode == "room" and view.release_target == "original" + assert requests == ["release"] + assert view.legacy_button.cget("text") == "Opening" + assert all(button.cget("state") == "disabled" for button in view.navigation_buttons) + feedback = view.status_text.get() + for button in view.navigation_buttons: + button.invoke() + button.event_generate("") + button.event_generate("") + view.root.update() + assert requests == ["release"] and view.mode == "room" + assert view.status_text.get() == feedback + # Simulate confirmed cleanup and child exit; no actual helper exists. + view.worker = view.release_target = None + view.show("original") + view.open_home() + view.root.update() + assert view.body.winfo_ismapped() + assert view.legacy_button.cget("text") == "Legacy" + assert all(button.cget("state") == "normal" for button in view.navigation_buttons) + + +def test_header_and_footer_commands_open_their_labeled_pages(view): + view.connection_badges[0][0].invoke() + view.root.update() + assert view.advanced_page == "system" and view.body.winfo_ismapped() + view.home_button.invoke() + view.root.update() + assert view.mode == "room" and view.advanced_page is None + view.guide_footer_button.invoke() + view.root.update() + assert view.mode == "guide" and view.guide_page == -1 + view.tools_footer_button.invoke() + view.root.update() + assert view.advanced_page == "system" and view.body.winfo_ismapped() + view.legacy_button.invoke() + view.root.update() + assert view.mode == "original" and view.dock.winfo_ismapped() + dock_home = next( + button + for button in view.navigation_buttons + if button.cget("text") == "Home" and button is not view.home_button + ) + dock_home.invoke() + view.root.update() + assert view.mode == "room" and view.body.winfo_ismapped() + + +def test_home_explains_why_running_instructions_disable_lighting(view): + view.open_advanced("experiment") + view.start_preview() + view.open_home() + view.root.update() + assert "Pause Experiment mode" in view.status_text.get() + assert all(button.cget("state") == "disabled" for button in view.light_buttons) + + +@pytest.mark.parametrize("mode", ["original", "black", "visitor"]) +def test_native_display_switches_require_paused_instructions(mode): + view = panel.Panel.__new__(panel.Panel) + view.release_target = view.closing = None + view.running, view.mode = True, "experiment" + messages = [] + view.status_text = SimpleNamespace(set=messages.append) + view.show(mode) + assert view.mode == "experiment" + assert messages == ["Pause the instructions before switching the panel."] From e237f903c0fdf8eea3feff5bc3ce1f519d0f9cad Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 13:56:41 -0400 Subject: [PATCH 2/9] Make browser controls recover clearly across screen sizes --- browser/index.html | 2 +- browser/room.css | 3 +++ browser/room.js | 26 ++++++++++++++++++++------ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/browser/index.html b/browser/index.html index 287f4d2..de5450d 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file +Smart Conference Room
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
diff --git a/browser/room.css b/browser/room.css index 1ac8a60..20cf238 100644 --- a/browser/room.css +++ b/browser/room.css @@ -1092,6 +1092,9 @@ summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; } + .white-grid .value-group { + display: contents; + } .white-grid .range-value small { grid-row: 2; grid-column: 1/-1; diff --git a/browser/room.js b/browser/room.js index 1060294..600388a 100644 --- a/browser/room.js +++ b/browser/room.js @@ -1,6 +1,7 @@ 'use strict'; // Shared state and commands come from the existing panel owner. No device driver lives here. const $ = (id) => document.getElementById(id); +const phoneLayout = matchMedia('(max-width:650px)'); let state = null, content = { guide: [], help: [] }, posting = false, @@ -224,11 +225,11 @@ function home(root) { group.id = isBlind ? 'blind-group' : 'light-group'; let actions = group; if (isBlind) { - group.open = !matchMedia('(max-width:650px)').matches; + group.open = !phoneLayout.matches; const summary = el('summary'); // Blinds fold away only on phones; on wider screens the heading is not a toggle. summary.onclick = (event) => { - if (!matchMedia('(max-width:650px)').matches) event.preventDefault(); + if (!phoneLayout.matches) event.preventDefault(); }; summary.append(el('h2', title)); const note = el('span', 'Disabled', 'muted'); @@ -543,7 +544,7 @@ function lighting(detail, tab) { card.append(el('h3', scene.label), el('p', scene.detail, 'muted'), b); grid.append(card); } - detail.append(grid, el('p', content.unavailable_lighting, 'muted')); + detail.append(grid); feedback(detail); } else if (tab === 'programmes') { detail.append(el('p', content.unavailable_lighting, 'muted')); @@ -1032,7 +1033,16 @@ function sync() { : state.can_control ? 'ready' : 'waiting'; - $('legacy-banner').hidden = !(available && state.mode === 'original'); + const panelMessage = + state?.mode === 'original' + ? 'The touch panel is showing Legacy, the original app. Return it to Home to use website controls.' + : state?.mode === 'black' + ? 'The touch panel is blank. Return it to Home to use lighting.' + : state?.mode === 'experiment' && !state.experiment?.running + ? 'Experiment mode is paused. Return the touch panel to Home to use lighting.' + : ''; + $('legacy-banner').hidden = !(available && panelMessage); + update('panel-mode-message', panelMessage); if ($('blind-group')) { $('blind-group').hidden = p.blinds_display === 'hidden'; $('essentials').classList.toggle('single', p.blinds_display === 'hidden'); @@ -1052,6 +1062,7 @@ function sync() { Boolean(unknown) || !available || state.busy || + (['legacy', 'blank', 'clock_view'].includes(a) && state.experiment?.running) || (a === 'blind' ? !state?.blinds?.available : lighting @@ -1097,8 +1108,8 @@ function sync() { : state?.detail || 'Panel unavailable. Controls are disabled.' : state.experiment?.running ? 'Instructions are running. Pause Experiment mode before using lighting.' - : state.mode === 'original' - ? 'Use Home to return from Legacy.' + : ['original', 'black', 'experiment'].includes(state.mode) + ? 'Return the touch panel to Home to use lighting.' : s.message || 'Lighting unavailable.', ); update( @@ -1390,6 +1401,9 @@ async function refresh() { // Only explicit buttons change the panel; moving around this website never does. $('legacy-return').onclick = () => command('home'); window.addEventListener('hashchange', layout); +phoneLayout.addEventListener('change', () => { + if ($('blind-group')) $('blind-group').open = !phoneLayout.matches; +}); document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); From 64beefaadcc48432beb2e9947d726e40610369b9 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 15:41:54 -0400 Subject: [PATCH 3/9] Keep room settings while switching and recovering panel views --- README.md | 9 +- WORKFLOWS.md | 40 ++-- app/panel_common.py | 6 +- app/panel_guides.py | 2 +- app/panel_remote.py | 2 +- app/panel_version.py | 2 +- app/scr_touch_panel.py | 260 +++++++++++++++-------- app/scr_touch_panel_lights.py | 38 +++- browser/room-content.json | 4 +- docs/interface.md | 14 +- tests/test_light_handoff.py | 374 ++++++++++++++++++++++++++++++++++ tests/test_panel_control.py | 57 ++++++ tests/test_panel_helpers.py | 102 ++++++++++ tests/test_scr_touch_panel.py | 4 +- 14 files changed, 791 insertions(+), 123 deletions(-) create mode 100644 tests/test_light_handoff.py create mode 100644 tests/test_panel_helpers.py diff --git a/README.md b/README.md index 2d31ce5..b01c736 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ No blind adapter is installed. Screen & controls explains this and can show or h the Home controls. The saved movement permission defaults to Locked; no unlock control is offered while the adapter is disconnected. Changing visibility never moves equipment or grants permission. Room tools keeps detailed controls together. -The header identifies Beta and provides a direct Legacy shortcut with its existing -restoration handover. In Legacy, the matching top bar identifies Legacy view; +The header identifies Beta and provides a direct Legacy shortcut that keeps the +current light settings. In Legacy, the matching top bar identifies Legacy view; Home returns to the main controls. Room tools opens each tool directly beside the sidebar and remembers the last tool. Experiment mode runs instructions and panel annotations; its recorder is not connected. @@ -99,7 +99,10 @@ offers Off, 1, 5 or 15 minutes. Idle does not interrupt Experiment mode, Legacy, touches, pending commands or uncertain lighting. Preferences save appearance and idle choices, not light settings or recording state. -Opening Legacy restores the panel's starting light scene before handing over control. +Beta and Legacy are two views of the room controls. Switching between them keeps +the current light settings, including Gradient and custom mixtures. Only an +explicit lighting action changes the lights. The separate bounded-trial command +below retains its starting-scene restoration behavior. If a result is uncertain, stop changing lights and ask the room host. **Room tools → System & help** shows checks and operator instructions; it does not execute commands. The visitor guide also operates no devices. A two-second stationary diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 950866c..74c4c64 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -24,10 +24,12 @@ python -B app/scr_touch_panel.py --duration 600 --live-lights \ --journal /tmp/scr-panel-connected-unique.jsonl ``` -Acquire requires two matching scans and one uniform Fid, Sat, Dul or Off preset. -Arbitrary drive words and Standard white baselines are refused because their -exact restore path is not qualified. Use Legacy to establish an -admitted baseline when no other study holds lighting, then re-enter Room. +The bounded trial above requires two matching scans and one uniform Fid, Sat, Dul +or Off preset, because it restores that preset when finished. Arbitrary drive +words cannot be restored exactly by the existing integer-percent setter. +The installed persistent launcher uses `--keep-switcher`; its helper uses +`--keep-settings` instead. It accepts any stable ten-fixture snapshot, including +Gradient, and never restores lighting on navigation, shutdown or parent loss. One Tk process owns display interaction. One helper owns this trial's ROS calls. The helper checks all fixture words before each write and after its response. @@ -49,21 +51,24 @@ step or the complete topic and provides documentation links. Neither interface executes these references. Service-start commands use `cd ... && python ...` so a failed directory change cannot launch the script from another directory. -Legacy waits for confirmed restoration and helper exit before showing -the legacy program. Its matching header marks Legacy view; Home returns to the +Legacy waits for confirmed ownership release and helper exit before showing +the legacy program. Neither direction changes lighting. Its matching header marks Legacy view; Home returns to the main controls. Room tools and Demo & guide remain directly available. The strip keeps the existing banner footprint and does not cover actuator buttons. The helper has a ten-second parent lease and 30-second operation budget, with -individual service calls bounded to at most twelve seconds. Parent loss triggers -cleanup only when the last confirmed drive state still matches. A lost response -or external change disables further writes and automatic restoration. Never retry +individual service calls bounded to at most twelve seconds. Persistent control +releases without setters; bounded trials restore only while the last confirmed +drive state still matches. A lost response or external change disables further +writes and automatic trial restoration. Never retry an uncertain write. Inspect the private `.lights-*.jsonl` receipt and coordinate with staff before controlling again. -Deadlines do not bound filesystem stalls or ROS initialization. The normal UI -waits for restoration; process exit alone does not prove that lights were restored. -`SIGKILL` or host power loss cannot guarantee restoration. +Deadlines do not bound filesystem stalls. An initializing helper is cancelled +after 30 seconds without sending a lighting command. The normal UI +waits for ownership release; a process exit alone cannot confirm an in-flight +lighting result. A helper still initializing can be cancelled without a write. +`SIGKILL` or host power loss cannot guarantee bounded-trial restoration. Blank screen displays black pixels. A stationary two-second press returns to the previous view; movement beyond 40 pixels cancels. The backlight remains powered. @@ -189,8 +194,9 @@ Terminal=false The launcher uses `--keep-switcher --duration 0`. Legacy, Escape and a window close return to Legacy after helper cleanup; the touch strip remains. Returning -to Room checks a fresh baseline. If restoration is uncertain, inspect the receipt -before changing lights. Signal shutdown still stops the switcher. +to Room reads the current settings without a write. If a result is uncertain, +inspect the receipt before changing lights. Signal shutdown stops the switcher +and leaves confirmed light settings in place. User autostart runs only after desktop login. Check the actual display manager and session before enabling autologin; use that OS's supported configuration. @@ -240,9 +246,9 @@ challenge alone is insufficient for unattended execution. Existing narrowly authorized noninteractive operator commands may supply an alternative. An unavailable action requires the operator's normal desktop or administration workflow. -Before requesting OS power changes, finish lighting operations, confirm restoration +Before requesting OS power changes, finish lighting operations, confirm ownership release and helper exit, and wait for saved preferences and the event journal to finish. -Uncertain restoration or a persistence error blocks the action. Do not use forced +An uncertain lighting result or a persistence error blocks the action. Do not use forced shutdown or a direct power cut as the normal procedure. An accepted restart is not proof of a successful boot: check desktop autostart, one panel/controller, website availability and the new release identity afterward. @@ -304,7 +310,7 @@ packages with pip. The room server remains unchanged. `app/run_original.py` supplies Tk/Pillow aliases without modifying original code. 9. Add the owned XDG autostart entry shown above. Bookworm's labwc desktop calls `lxsession-xdg-autostart`; existing desktop autologin must also be enabled. -10. Check Beta → Legacy → Beta, settings, dynamic lighting and baseline restoration. +10. Check Beta → Legacy → Beta without lighting changes, settings and dynamic lighting. Confirm one process of each application and test duplicate launcher rejection. An unattended reboot is a separate final acceptance check. diff --git a/app/panel_common.py b/app/panel_common.py index d573c28..918e757 100644 --- a/app/panel_common.py +++ b/app/panel_common.py @@ -74,7 +74,7 @@ "Visitor": "Room introduction and a large clock. Opening this page does not change lighting.", "Experiment mode": "Instructions, elapsed time and panel annotations. No sensor recording starts.", "System": "Panel resources and advertised room services. Presence does not prove sensor health.", - "Legacy": "Restores the starting lighting, then shows the original room controls.", + "Legacy": "Hands control to the original interface without changing the lights.", "Blank screen": "Shows black pixels. Hold still for 2 seconds to return; the backlight remains on.", "Settings": "Appearance, date/time and screen options. Appearance choices can be saved on this device.", "About panel": "Panel credits and source repository. No room settings change.", @@ -216,7 +216,9 @@ def confirmation_age(now, confirmed_at): def confirmed_power(response): - """Describe uniform controller readback, never optical or continuously live state.""" + """Describe confirmed drive readback, never optical or continuously live state.""" + if response.get("state") == "ready" and response.get("power") in ("on", "off"): + return response["power"] if response.get("state") == "ready" and response.get("baseline") in PRESETS: return "off" if response["baseline"] == "Off" else "on" values = response.get("words") diff --git a/app/panel_guides.py b/app/panel_guides.py index afa638c..53f51bd 100644 --- a/app/panel_guides.py +++ b/app/panel_guides.py @@ -14,7 +14,7 @@ "Room controls", "home", "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\n" - "Legacy opens the original interface after restoring the starting lighting scene. Use Home in the Legacy bar to return.\n\n" + "Legacy opens the original interface without changing the lights. Use Home in the Legacy bar to return; the current settings stay in place.\n\n" "Lift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller.", ), ( diff --git a/app/panel_remote.py b/app/panel_remote.py index eaf81f6..e404f0a 100644 --- a/app/panel_remote.py +++ b/app/panel_remote.py @@ -21,7 +21,7 @@ def snapshot(panel): and panel.light_state == "ready" and not busy and not panel.running - and (panel.mode == "room" or panel.idle_active) + and panel.mode not in ("original", "black") ) elapsed = panel.elapsed + ( time.monotonic() - panel.run_started if panel.running else 0 diff --git a/app/panel_version.py b/app/panel_version.py index 3cb28ac..6a31aef 100644 --- a/app/panel_version.py +++ b/app/panel_version.py @@ -1,6 +1,6 @@ """Human-readable panel version; Git commits identify the exact deployed source.""" -VERSION = "0.2.0" +VERSION = "0.2.1" def build_commit(): diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index 4559222..e939096 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -134,6 +134,7 @@ def __init__(self, args): self.health_at, self.health_cache = -10, {} self.health_worker, self.health_pending = None, None self.worker, self.pending, self.worker_buffer = None, None, "" + self.release_confirmed = self.light_changes_requested = False self.queued_intent, self.intent_timer, self.slider_drag = None, None, None self.view_generation = 0 self.light_state, self.release_target = "disconnected", None @@ -313,7 +314,7 @@ def emit(self, event, **values): line, self.sequence, durable=event in ("annotation", "power_requested") ) except (IOError, OSError) as error: - if event == "annotation": + if event in ("annotation", "power_requested"): raise self.status_text.set("Panel journal unavailable: " + str(error)) return self.sequence @@ -829,10 +830,18 @@ def show(self, mode, advanced_page=None): self.cancel_intent() if mode == "original": self.trial_deadline = None + if self.light_state == "failed" and getattr(self, "light_changes_requested", False): + self.status_text.set( + "Light result unconfirmed. Check the receipt before switching controllers." + ) + return if mode == "original" and self.worker: self.release_target = "original" self.status_text.set("Finishing lighting control before opening Legacy...") - if self.light_state not in ("ready", "applying", "connecting"): + if self.light_state == "connecting" and not self.light_changes_requested: + # Startup/acquire only reads. Do not wait for slow ROS imports to hand back Legacy. + self.worker.terminate() + elif self.light_state not in ("ready", "applying", "connecting"): if not self.worker.stdin.closed: self.worker.stdin.close() elif not self.pending: @@ -1221,7 +1230,7 @@ def channels_apply(self): def apply(self, action, immediate=False, **arguments): if ( - self.mode != "room" + self.mode in ("original", "black") or self.light_state not in ("ready", "applying") or self.release_target or self.closing @@ -1263,10 +1272,11 @@ def dispatch_intent(self): if ( not intent or intent[2] != self.view_generation - or self.mode != "room" + or self.mode in ("original", "black") or self.light_state != "ready" or self.release_target or self.closing + or getattr(self, "running", False) ): return action, arguments = intent[:2] @@ -1305,6 +1315,10 @@ def update_light_buttons(self): def start_preview(self): if self.power_busy(): return + if self.pending: + self.status_text.set("Wait for lighting confirmation before starting instructions.") + return + self.cancel_intent() if not self.running: self.run_started, self.running = monotonic(), True self.emit("practice_started") @@ -1647,12 +1661,17 @@ def start_status(self): path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "scr_touch_panel_lights.py" ) - self.status_worker = subprocess.Popen( - [sys.executable, "-B", path, "--status-only", "--expected-master", master], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - close_fds=True, - ) + try: + self.status_worker = subprocess.Popen( + [sys.executable, "-B", path, "--status-only", "--expected-master", master], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=True, + ) + except OSError: + self.status_worker = None + self.room_status = "Room service availability could not be checked. No sensor data was queried." + return self.status_started = monotonic() self.room_status = "Checking advertised room services..." @@ -1668,8 +1687,19 @@ def poll_status(self): worker.terminate() return try: - result = json.loads(worker.stdout.read().decode("utf-8")) + raw = worker.stdout.read(4097) + if len(raw) > 4096: + raise ValueError("Status reply too large") + result = json.loads(raw.decode("utf-8")) services = result["services"] + if not isinstance(services, list) or len(services) > 20 or any( + not isinstance(item, (list, tuple)) + or len(item) != 2 + or not isinstance(item[0], str) + or not isinstance(item[1], bool) + for item in services + ): + raise ValueError("Invalid service availability reply") self.room_status = ( "ROS master reachable / advertised services at " + time.strftime("%H:%M:%S") @@ -1679,7 +1709,7 @@ def poll_status(self): name + ": " + ("present" if present else "missing") for name, present in services ) - except (ValueError, KeyError): + except (IOError, OSError, ValueError, KeyError, TypeError, RecursionError): self.room_status = "Room service availability could not be checked. No sensor data was queried." worker.stdout.close() worker.stderr.close() @@ -1795,24 +1825,36 @@ def start_worker(self): os.path.dirname(os.path.abspath(__file__)), "scr_touch_panel_lights.py" ) receipt = self.args.journal + ".lights-" + uuid.uuid4().hex[:8] + ".jsonl" - self.worker = subprocess.Popen( - [ - getattr(self.args, "adapter_python", "/usr/bin/python2.7"), - "-u", - "-B", - path, - "--receipt", - receipt, - "--parent-pid", - str(os.getpid()), - "--expected-master", - self.args.expected_master, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=sys.stderr, - close_fds=True, - ) + command = [ + getattr(self.args, "adapter_python", "/usr/bin/python2.7"), + "-u", + "-B", + path, + "--receipt", + receipt, + "--parent-pid", + str(os.getpid()), + "--expected-master", + self.args.expected_master, + ] + if self.keep_switcher: + command.append("--keep-settings") + self.release_confirmed = self.light_changes_requested = False + self.worker_started = monotonic() + try: + self.worker = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=sys.stderr, + close_fds=True, + ) + except OSError: + self.worker = None + self.light_state = "failed" + self.status_text.set("Lighting helper could not start. Use Legacy or ask staff.") + self.lighting_feedback("Lighting unavailable; helper could not start.") + return self.light_state = "connecting" self.confirmed_light = "Not checked" self.confirmed_at = None @@ -1839,6 +1881,11 @@ def request(self, action, arguments=None): "sent_monotonic_s": monotonic(), } self.emit("light_requested", request=item) + if action in ("cct", "channels"): + # A lost pipe response must never permit an unverified controller handoff. + self.light_changes_requested = True + elif action == "release": + self.release_confirmed = False try: self.worker.stdin.write((json.dumps(item) + "\n").encode("utf-8")) self.worker.stdin.flush() @@ -1877,6 +1924,21 @@ def poll_journal(self): self.pending_annotation = None self.status_text.set("Panel journal unavailable: " + error) + def fail_worker(self, message): + self.cancel_intent() + self.pending = None + self.light_state = "failed" + self.worker_ready = False + self.worker_buffer = "" + self.status_text.set(message) + self.lighting_feedback("Result unconfirmed. Controls disabled; ask staff.") + if self.worker and not self.worker.stdin.closed: + try: + self.worker.stdin.close() + except (IOError, OSError): + pass + self.update_light_buttons() + def poll_worker(self): if self.closed: return @@ -1891,6 +1953,17 @@ def poll_worker(self): self.poll_weather() worker = self.worker if worker: + if ( + self.light_state == "connecting" + and not self.worker_ready + and monotonic() - getattr(self, "worker_started", monotonic()) > 30 + ): + # No acquire or setter has been sent before initialization. + worker.terminate() + self.fail_worker("Lighting helper startup timed out. Legacy remains available.") + self.lighting_feedback("Lighting unavailable; helper startup timed out.") + if self.pending and monotonic() - self.pending.get("sent_monotonic_s", monotonic()) > 35: + self.fail_worker("Lighting response timed out. Result unconfirmed; no retry.") if ( worker.poll() is None and not worker.stdin.closed @@ -1913,26 +1986,31 @@ def poll_worker(self): self.last_heartbeat = monotonic() except (IOError, OSError): pass - while select.select([worker.stdout], [], [], 0)[0]: - chunk = os.read(worker.stdout.fileno(), 8192) - if not chunk: - break - self.worker_buffer += chunk.decode("utf-8") - if len(self.worker_buffer) > 8192: - self.cancel_intent() - self.light_state = "failed" - self.status_text.set( - "Adapter reply too large; lighting disabled. Ask staff." - ) - worker.stdin.close() - self.worker_buffer = "" - break + try: + while select.select([worker.stdout], [], [], 0)[0]: + chunk = os.read(worker.stdout.fileno(), 8192) + if not chunk: + break + self.worker_buffer += chunk.decode("utf-8") + if len(self.worker_buffer) > 8192: + raise ValueError("Adapter reply too large") + except (IOError, OSError, ValueError, UnicodeError): + self.fail_worker("Invalid or unreadable adapter reply; controls disabled. Ask staff.") while "\n" in self.worker_buffer: line, self.worker_buffer = self.worker_buffer.split("\n", 1) try: response = json.loads(line) - except ValueError: - continue + except (ValueError, RecursionError): + self.fail_worker("Invalid adapter reply; controls disabled. Ask staff.") + break + if not isinstance(response, dict) or ( + "detail" in response and not isinstance(response["detail"], str) + ) or ( + response.get("baseline") is not None + and not isinstance(response["baseline"], str) + ): + self.fail_worker("Invalid adapter reply; controls disabled. Ask staff.") + break if ( response.get("version") == 1 and response.get("state") == "initialized" @@ -1949,22 +2027,14 @@ def poll_worker(self): or response.get("request_id") != self.pending["request_id"] ): continue - if response.get("version") != 1 or response.get("state") not in ( - "ready", - "completed", - "released", - "failed", - ): - self.pending = None - self.cancel_intent() - self.light_state = "failed" - self.status_text.set( - "Invalid adapter reply; lights disabled. Ask staff." - ) - self.lighting_feedback( - "Invalid reply. Controls disabled; ask staff." - ) - worker.stdin.close() + expected = { + "acquire": ("ready", "failed"), + "release": ("released", "failed"), + "cct": ("completed", "failed"), + "channels": ("completed", "failed"), + }.get(self.pending["action"], ()) + if response.get("version") != 1 or response.get("state") not in expected: + self.fail_worker("Invalid adapter reply; controls disabled. Ask staff.") continue completed_request, self.pending = self.pending, None action = completed_request["action"] @@ -1974,10 +2044,7 @@ def poll_worker(self): "completed": "ready", "released": "disconnected", }.get(state, state) - detail = response.get( - "detail", - "Lighting ready; starting preset: " + response.get("baseline", "?"), - ) + detail = response.get("detail", "Lighting ready.") if state == "failed": # Full exception text remains in the emitted result/receipt. # Keep the touch feedback bounded, even for verbose ROS errors. @@ -1992,7 +2059,10 @@ def poll_worker(self): detail = "Light settings confirmed." elif state == "ready": detail = "Lighting ready." - self.confirmed_light = response.get("baseline", "?") + " scene" + baseline = response.get("baseline") + self.confirmed_light = ( + baseline + " scene" if baseline in PRESETS else "Current room settings" + ) self.confirmed_at = monotonic() self.confirmed_power = confirmed_power(response) self.confirmed_scene = confirmed_scene(response) @@ -2004,17 +2074,23 @@ def poll_worker(self): self.lighting_feedback(detail) self.status_text.set("" if state in ("ready", "completed") else detail) if action == "release": + self.release_confirmed = state == "released" and ( + response.get("preserved") is True + if self.keep_switcher + else response.get("restored") is True + ) if getattr(self, "power", None) and self.power.phase == "restoring": - if state == "released" and response.get("restored") is True: + if self.release_confirmed: self.power_cleanup_confirmed = True else: self.power.fail( "Lighting cleanup is unconfirmed. Power action cancelled." ) - if state == "failed": + if not self.release_confirmed: + self.light_state = "failed" self.release_target = None self.status_text.set( - "Restore unconfirmed. Stay here; inspect the receipt before further control." + "Lighting release unconfirmed. Stay here; inspect the receipt before further control." ) elif self.release_target and state != "failed": self.request("release") @@ -2042,7 +2118,16 @@ def poll_worker(self): self.finish_close() return if target == "original": - self.show("original") + safe = ( + getattr(self, "release_confirmed", False) + or not getattr(self, "light_changes_requested", False) + ) + if safe: + self.show("original") + else: + self.status_text.set( + "Lighting release unconfirmed. Check the receipt before switching controllers." + ) elif target == "power": self.power_after_release(worker.returncode) self.update_light_buttons() @@ -2154,7 +2239,11 @@ def close(self, reason): self.cancel_intent() if self.worker and self.light_state in ("ready", "applying", "connecting"): self.release_target = "close" - self.status_text.set("Ending trial. Restoring starting light settings...") + self.status_text.set( + "Closing panel. Keeping current light settings..." + if self.keep_switcher + else "Ending trial. Restoring starting light settings..." + ) if not self.pending: self.request("release") elif self.worker: @@ -2189,18 +2278,21 @@ def finish_close(self): self.root.after(50, self.finish_close) return self.poll_journal() - if self.journal.error: - sys.stderr.write( - "Panel journal shutdown failed: " + self.journal.error + "\n" - ) - sys.stderr.flush() - if getattr(self, "preferences", None) and self.preferences.error: - sys.stderr.write( - "Appearance settings shutdown failed: " - + self.preferences.error - + "\n" - ) - sys.stderr.flush() + try: + if sys.stderr is not None: + if self.journal.error: + sys.stderr.write( + "Panel journal shutdown failed: " + self.journal.error + "\n" + ) + if getattr(self, "preferences", None) and self.preferences.error: + sys.stderr.write( + "Appearance settings shutdown failed: " + + self.preferences.error + + "\n" + ) + sys.stderr.flush() + except (IOError, OSError, ValueError): + pass # A broken diagnostic stream must not interrupt completed cleanup. self.closed = True if self.worker: self.worker.stdin.close() diff --git a/app/scr_touch_panel_lights.py b/app/scr_touch_panel_lights.py index c8b1adb..b02b5bd 100644 --- a/app/scr_touch_panel_lights.py +++ b/app/scr_touch_panel_lights.py @@ -1,9 +1,8 @@ #!/usr/bin/env python """Optional SCR light adapter. Author: Diyanko Bhowmik, with Codex assistance. -Uses the existing ROS owner. No discovery, scene control, blinds or raw sockets. -The temporary trial admits only uniform, known integer-percent baselines because -the installed ROS setter cannot restore arbitrary 16-bit drive words exactly. +Uses the existing ROS owner. Persistent room control keeps settings on release; +bounded trials restore only exactly reproducible, uniform starting presets. """ from __future__ import print_function @@ -67,8 +66,9 @@ def restore_preset(snapshot): class Lights(object): - def __init__(self, call, can_write=lambda: None): + def __init__(self, call, can_write=lambda: None, keep_settings=False): self.call, self.can_write = call, can_write + self.keep_settings = keep_settings self.baseline = self.current = None self.baseline_name = None self.changed = self.failed = False @@ -89,11 +89,21 @@ def acquire(self): if self.baseline is not None: raise ValueError("Lighting trial already acquired") state = self.read() - name = restore_preset(state) + try: + name = restore_preset(state) + except ValueError: + if not self.keep_settings: + raise + name = None if self.read() != state: raise ValueError("Light settings are changing; live controls stay off") self.baseline, self.current, self.baseline_name = state, state, name - return {"state": "ready", "baseline": name, "fixtures": 10} + return { + "state": "ready", + "baseline": name, + "power": "on" if any(any(values) for values in state.values()) else "off", + "fixtures": 10, + } def apply(self, action, arguments): if self.failed or self.baseline is None: @@ -153,6 +163,15 @@ def release(self): raise ValueError( "Outcome unknown; no automatic restore or retry. Inspect the retained receipt" ) + if self.keep_settings: + self.baseline = self.current = None + self.changed = False + return { + "state": "released", + "restored": False, + "preserved": True, + "detail": "Lighting ownership released; current settings unchanged", + } if self.baseline is None or not self.changed: self.baseline = self.current = None return { @@ -201,6 +220,11 @@ def main(): action="store_true", help="Read advertised services only; no ROS node or fixture queries", ) + parser.add_argument( + "--keep-settings", + action="store_true", + help="Release ownership without restoring lights (persistent room control)", + ) args = parser.parse_args() if ( not args.status_only @@ -324,7 +348,7 @@ def can_write(): ): raise ValueError("Panel lease ended before dispatch; no command sent") - lights = Lights(call, can_write) + lights = Lights(call, can_write, keep_settings=args.keep_settings) def record(event, **values): values.update(event=event, epoch_s=time.time(), pid=os.getpid()) diff --git a/browser/room-content.json b/browser/room-content.json index f7ec7a9..22c8603 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -1,6 +1,6 @@ { "version": 1, - "app_version": "0.2.0", + "app_version": "0.2.1", "icons": { "tools": [ { @@ -778,7 +778,7 @@ { "title": "Room controls", "icon": "home", - "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface after restoring the starting lighting scene. Use Home in the Legacy bar to return.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." + "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface without changing the lights. Use Home in the Legacy bar to return; the current settings stay in place.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." }, { "title": "Sensors and experiments", diff --git a/docs/interface.md b/docs/interface.md index cc4cac5..a3e44b1 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -54,7 +54,7 @@ another experiment owns import, clock association and participant handling. The UI launches one subprocess using inherited, validated deployment ROS settings. After initialization, the helper sends `{"version": 1, "state": "initialized"}`. The UI then sends a fresh acquire request, or release if navigation already ended -the trial. It ignores duplicate initialization messages. Pipes carry JSON lines. +the session. It ignores duplicate initialization messages. Pipes carry JSON lines. Requests have: ```json @@ -75,7 +75,12 @@ Heartbeat needs no request ID and produces no response. Replies echo `version` and `request_id`. States are `ready`, `completed`, `released` or `failed`. Success includes fixture count and relevant baseline, -words or restoration flag. Failure includes detail and whether outcome is unknown. +words or release flags. Persistent `--keep-settings` acquisition admits any stable +snapshot and reports its confirmed `power`; `baseline` is null for a custom mix. +Its release returns `preserved: true, restored: false` without a fixture setter. +This applies to Legacy navigation, normal shutdown and parent loss. Bounded trials +omit that flag, require an exactly restorable preset and retain `restored: true` +on successful cleanup. Failure includes detail and whether outcome is unknown. Unknown extra fields are ignored; unsupported versions are refused. Only one request is in flight. Requests are capped at 4096 bytes; buffers at @@ -150,7 +155,7 @@ references are display/copy text, never command actions. Pi power actions use `power` with exactly `operation` (`reboot` or `shutdown`) and `confirmed: true`. They share the existing network and revision checks. The owner refuses busy, running-instruction, Legacy and uncertain-light states, -then requires confirmed restoration, helper exit, saved preferences and a durable +then requires confirmed ownership release, helper exit, saved preferences and a durable power-request record before one fixed OS command. Existing noninteractive permission and OS inhibitors are respected; no new privilege rule or automatic retry is installed. These actions affect this Pi and website, not room computers @@ -185,7 +190,8 @@ target. Window groups need an explicit mapping; client list order is not a room Every queued intent and receipt must include its immutable target. Invalid or missing target identities must be rejected rather than falling back to All. Per-fixture writes must verify unaffected fixtures, preserve the owner's expected -state and retain exact starting-state restoration. Blind ownership, naming, +state and preserve current settings during view changes. Exact restoration remains +a separate bounded-trial requirement. Blind ownership, naming, response/readback and completion semantics need qualification before wiring its controller; a setting alone cannot establish those capabilities. diff --git a/tests/test_light_handoff.py b/tests/test_light_handoff.py new file mode 100644 index 0000000..a8287af --- /dev/null +++ b/tests/test_light_handoff.py @@ -0,0 +1,374 @@ +"""Switching room interfaces must preserve drive settings and uncertain results.""" + +from io import BytesIO +import json +from types import SimpleNamespace + +import pytest + +from test_scr_touch_panel import Room, adapter, panel + + +def gradient_room(): + room = Room() + room.state = { + xy: tuple(65535 if channel == index % 8 else 0 for channel in range(8)) + for index, xy in enumerate(sorted(adapter.COORDINATES)) + } + return room + + +def test_gradient_can_be_acquired_and_released_repeatedly_without_any_setter(): + room = gradient_room() + original = dict(room.state) + lights = adapter.Lights(room.call, keep_settings=True) + for unused in range(5): + acquired = lights.acquire() + assert acquired["state"] == "ready" and acquired["baseline"] is None + assert acquired["power"] == "on" + released = lights.release() + assert released["preserved"] and not released["restored"] + assert room.state == original and room.writes == [] + + +def test_explicit_selection_survives_release_and_next_interface_acquisition(): + room = gradient_room() + lights = adapter.Lights(room.call, keep_settings=True) + lights.acquire() + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + selected = dict(room.state) + writes = list(room.writes) + assert lights.release()["preserved"] + assert room.state == selected and room.writes == writes + assert lights.acquire()["baseline"] == "Sat" + assert lights.release()["preserved"] + assert room.state == selected and room.writes == writes + + +def test_release_does_not_overwrite_new_external_settings(): + room = Room() + lights = adapter.Lights(room.call, keep_settings=True) + lights.acquire() + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + room.state = gradient_room().state + external = dict(room.state) + writes = list(room.writes) + assert lights.release()["preserved"] + assert room.state == external and room.writes == writes + + +def test_changing_baseline_cannot_be_acquired_or_written(): + room = gradient_room() + reads = [] + + def changing_call(name, *args): + if name == "get_lights": + reads.append(name) + if len(reads) == 2: + xy = min(room.state) + room.state[xy] = (0,) * 8 + return room.call(name, *args) + + lights = adapter.Lights(changing_call, keep_settings=True) + with pytest.raises(ValueError, match="changing"): + lights.acquire() + with pytest.raises(ValueError, match="unavailable"): + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + assert room.writes == [] + + +@pytest.mark.parametrize("lost_response", [False, True]) +def test_unknown_write_cannot_be_released_or_retried_even_if_readback_later_matches( + lost_response, +): + room = Room() + room.lose_response = lost_response + + def call(name, *args): + result = room.call(name, *args) + if name == "sources_all" and not lost_response: + # The response claims Sat; one fixture reports a different drive. + room.state[min(room.state)] = (0,) * 8 + return result + + lights = adapter.Lights(call, keep_settings=True) + lights.acquire() + with pytest.raises((TimeoutError, ValueError)): + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + room.state = dict.fromkeys(room.state, adapter.setter_words(adapter.PRESETS["Sat"])) + with pytest.raises(ValueError, match="unknown"): + lights.release() + with pytest.raises(ValueError, match="unavailable"): + lights.apply("channels", {"values": list(adapter.PRESETS["Off"])}) + assert len(room.writes) == 1 + + +def test_external_change_before_explicit_selection_blocks_write_and_release(): + room = Room() + lights = adapter.Lights(room.call, keep_settings=True) + lights.acquire() + room.state = gradient_room().state + with pytest.raises(ValueError, match="ownership lost"): + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + with pytest.raises(ValueError, match="unknown"): + lights.release() + assert room.writes == [] + + +def test_bounded_trials_still_reject_gradient_and_restore_qualified_presets(): + gradient = gradient_room() + with pytest.raises(ValueError, match="restorable"): + adapter.Lights(gradient.call).acquire() + assert gradient.writes == [] + room = Room() + initial = dict(room.state) + trial = adapter.Lights(room.call) + trial.acquire() + trial.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + assert trial.release()["restored"] + assert room.state == initial and len(room.writes) == 2 + + +@pytest.fixture +def controller(monkeypatch): + """Run the real owner poller without Tk, processes, sockets or device access.""" + state = panel.Panel.__new__(panel.Panel) + state.closed, state.closing, state.keep_switcher = False, None, True + state.mode, state.light_state = "room", "connecting" + state.worker_ready = True + state.release_confirmed = state.light_changes_requested = False + state.release_target = state.queued_intent = state.intent_timer = None + state.pending = {"request_id": "test:1", "action": "acquire", "arguments": {}} + state.worker_buffer = "" + state.last_heartbeat = panel.monotonic() + state.root = SimpleNamespace(after=lambda *args: state.scheduled.append(args)) + state.scheduled, state.messages, state.events, state.shown = [], [], [], [] + state.status_text = SimpleNamespace(set=state.messages.append) + state.lighting_feedback = state.messages.append + state.emit = lambda *args, **kwargs: state.events.append((args, kwargs)) + state.cancel_intent = lambda: setattr(state, "queued_intent", None) + state.update_light_buttons = lambda: None + state.poll_journal = state.poll_power = state.poll_status = state.poll_weather = ( + lambda: None + ) + state.show = state.shown.append + state.worker = SimpleNamespace( + stdin=BytesIO(), stdout=SimpleNamespace(fileno=lambda: 99), poll=lambda: None + ) + monkeypatch.setattr(panel.select, "select", lambda *args: ([], [], [])) + return state + + +def test_real_gradient_acquisition_response_keeps_poller_running(controller): + room = gradient_room() + response = adapter.Lights(room.call, keep_settings=True).acquire() + controller.worker_buffer = json.dumps( + dict(response, version=1, request_id="test:1") + ) + "\n" + controller.poll_worker() + assert controller.light_state == "ready" and controller.pending is None + assert controller.confirmed_light == "Current room settings" + assert controller.confirmed_scene is None and controller.confirmed_power == "on" + assert controller.scheduled and room.writes == [] + + +def test_stalled_helper_startup_is_cancelled_before_any_light_command( + controller, monkeypatch +): + controller.pending = None + controller.worker_ready = False + controller.worker_started = 0 + terminated = [] + controller.worker.terminate = lambda: terminated.append(True) + monkeypatch.setattr(panel, "monotonic", lambda: 31) + controller.poll_worker() + assert terminated == [True] and controller.light_state == "failed" + assert not controller.light_changes_requested + assert controller.worker.stdin.closed and controller.scheduled + + +@pytest.mark.parametrize("reply", ["null", "[]", "42", '"ready"', "{broken"]) +def test_invalid_adapter_json_latches_failure_and_keeps_poller_running(controller, reply): + controller.worker_buffer = reply + "\n" + controller.poll_worker() + assert controller.light_state == "failed" + assert controller.worker.stdin.closed + assert controller.scheduled and controller.shown == [] + + +def test_invalid_adapter_utf8_latches_failure_and_keeps_poller_running( + controller, monkeypatch +): + readable = [[controller.worker.stdout], [], []] + monkeypatch.setattr(panel.select, "select", lambda *args: readable) + monkeypatch.setattr(panel.os, "read", lambda *args: b"\xff\n") + controller.poll_worker() + assert controller.light_state == "failed" + assert controller.worker.stdin.closed + assert controller.scheduled and controller.shown == [] + + +@pytest.mark.parametrize("baseline", [[], {}, 42, True]) +def test_invalid_baseline_type_latches_failure(controller, baseline): + controller.worker_buffer = json.dumps( + dict(version=1, request_id="test:1", state="ready", baseline=baseline) + ) + "\n" + controller.poll_worker() + assert controller.light_state == "failed" and controller.worker.stdin.closed + assert controller.scheduled and controller.shown == [] + + +@pytest.mark.parametrize( + "action, reply", + [ + ("acquire", "completed"), + ("acquire", "released"), + ("release", "ready"), + ("release", "completed"), + ("channels", "ready"), + ("channels", "released"), + ("cct", "ready"), + ], +) +def test_reply_must_match_the_pending_action(controller, action, reply): + controller.pending["action"] = action + controller.worker_buffer = json.dumps( + dict(version=1, request_id="test:1", state=reply) + ) + "\n" + controller.poll_worker() + assert controller.light_state == "failed" and controller.worker.stdin.closed + assert not controller.release_confirmed + assert controller.scheduled and controller.shown == [] + + +def test_response_deadline_disables_controls_without_retry(controller, monkeypatch): + monkeypatch.setattr(panel, "monotonic", lambda: 100.0) + controller.pending["sent_monotonic_s"] = 64.0 + controller.light_changes_requested = True + worker = controller.worker + controller.poll_worker() + assert controller.light_state == "failed" and worker.stdin.closed + assert controller.pending is None and not controller.worker_ready + assert controller.worker is worker # Retain ownership until the child exits. + assert controller.scheduled and controller.shown == [] + controller.release_target = "original" + worker.poll = lambda: 1 + controller.poll_worker() + assert controller.worker is None and controller.shown == [] + + +def test_startup_cancellation_waits_for_child_exit_before_showing_legacy(controller): + terminated = [] + controller.worker.terminate = lambda: terminated.append(True) + controller.pending = None + panel.Panel.show(controller, "original") + assert terminated == [True] and controller.shown == [] + assert controller.release_target == "original" + controller.poll_worker() + assert controller.shown == [] and controller.worker is not None + controller.worker.poll = lambda: -15 + controller.poll_worker() + assert controller.worker is None and controller.shown == ["original"] + + +def test_failed_relaunch_after_confirmed_old_session_keeps_legacy_available( + controller, monkeypatch +): + controller.args = SimpleNamespace(journal="unused", expected_master="http://unused") + controller.worker = None + controller.release_confirmed = controller.light_changes_requested = True + controller.cancel_hold = lambda: None + controller.render = lambda: controller.shown.append(controller.mode) + controller.update_navigation_buttons = lambda: None + + def unavailable(*args, **kwargs): + raise OSError("helper interpreter unavailable") + + monkeypatch.setattr(panel.subprocess, "Popen", unavailable) + controller.start_worker() + assert controller.worker is None and controller.light_state == "failed" + assert not controller.light_changes_requested and not controller.release_confirmed + panel.Panel.show(controller, "original") + assert controller.mode == "original" and controller.shown == ["original"] + + +@pytest.mark.parametrize("persistent", [False, True]) +def test_helper_mode_argument_matches_persistent_or_bounded_trial( + controller, monkeypatch, persistent +): + controller.keep_switcher = persistent + controller.args = SimpleNamespace(journal="unused", expected_master="http://unused") + commands = [] + worker = controller.worker + + def launch(command, **kwargs): + commands.append(command) + return worker + + monkeypatch.setattr(panel.subprocess, "Popen", launch) + controller.start_worker() + assert ("--keep-settings" in commands[0]) is persistent + assert controller.light_state == "connecting" + assert not controller.light_changes_requested and not controller.release_confirmed + + +@pytest.mark.parametrize( + "persistent, acknowledgement, accepted", + [ + (True, "preserved", True), + (True, "restored", False), + (False, "restored", True), + (False, "preserved", False), + ], +) +def test_power_requires_release_acknowledgement_for_the_current_mode( + controller, persistent, acknowledgement, accepted +): + failures = [] + controller.keep_switcher = persistent + controller.power = SimpleNamespace(phase="restoring", fail=failures.append) + controller.power_cleanup_confirmed = False + controller.pending["action"] = "release" + controller.release_target = "power" + controller.worker_buffer = json.dumps( + dict(version=1, request_id="test:1", state="released", **{acknowledgement: True}) + ) + "\n" + controller.poll_worker() + assert controller.release_confirmed is accepted + assert controller.power_cleanup_confirmed is accepted + assert bool(failures) == (not accepted) + assert controller.worker is not None and controller.shown == [] + + +@pytest.mark.parametrize("changes_requested", [False, True]) +def test_helper_exit_without_release_never_hands_off_unconfirmed_writes( + controller, changes_requested +): + controller.release_target = "original" + controller.light_changes_requested = changes_requested + controller.worker.poll = lambda: 1 + controller.poll_worker() + assert controller.worker is None + assert controller.shown == ([] if changes_requested else ["original"]) + assert controller.light_state == "failed" and controller.scheduled + + +def test_confirmed_release_still_waits_for_exact_helper_to_exit(controller): + controller.pending["action"] = "release" + controller.light_changes_requested = True + controller.release_target = "original" + controller.worker_buffer = json.dumps( + { + "version": 1, + "request_id": "test:1", + "state": "released", + "preserved": True, + "restored": False, + } + ) + "\n" + controller.poll_worker() + assert controller.release_confirmed and controller.shown == [] + assert controller.worker is not None + controller.worker.poll = lambda: 0 + controller.poll_worker() + assert controller.worker is None and controller.shown == ["original"] diff --git a/tests/test_panel_control.py b/tests/test_panel_control.py index cfd75ac..da99cf9 100644 --- a/tests/test_panel_control.py +++ b/tests/test_panel_control.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) from panel_control import command, Bridge from panel_remote import execute +from panel_remote import snapshot from design_tokens import THEMES @@ -222,3 +223,59 @@ def test_remote_blind_action_uses_owner_readiness_and_never_bypasses_setting( else: assert execute(owner, envelope("blind", {"name": "lift"}))["ok"] assert calls == ["lift"] + + +@pytest.fixture +def remote_owner(): + """Complete cached presentation state; every action stays inside this fake owner.""" + def variable(value): + return SimpleNamespace(get=lambda: value) + + owner = SimpleNamespace( + args=SimpleNamespace(live_lights=True, timezone="America/New_York", light_at=420, dark_at=1140), + pending=None, queued_intent=None, release_target=None, closing=None, + press_active=False, running=False, idle_active=False, + light_state="ready", mode="room", elapsed=0, run_started=time.monotonic(), + run_id="owner", sequence=1, view_generation=1, + confirmed_power="on", confirmed_scene="Fid", confirmed_light="Fid scene", + confirmed_at=None, light_message="Light settings confirmed.", + cct=variable(3500), intensity=variable(1000), + channel_values=[variable(25) for _ in range(8)], + theme_choice="light", theme="light", clock_24=False, temperature_unit="f", + idle_timeout=300, help_on=True, blinds_display="disabled", blinds_enabled=False, + profile={"title":"Practice", "stages":["Pause"], "annotations":[]}, stage=0, + annotation_text=variable(""), weather_reading=None, weather_good=None, + health_cache={}, room_status="Not checked", + blind_state=lambda: {"available":False}, + emit=lambda *args, **kwargs: None, activity=lambda *args: None, + ) + for name, value in THEMES["light"].items(): + setattr(owner, name, value) + owner.calls = [] + owner.lights_on = lambda: owner.calls.append(owner.mode) + return owner + + +@pytest.mark.parametrize("mode", ["room", "guide", "settings", "health", "about", "advanced", "experiment", "visitor"]) +def test_remote_lighting_uses_current_owner_without_changing_beta_page(remote_owner, mode): + remote_owner.mode = mode + assert snapshot(remote_owner)["can_control"] is True + assert execute(remote_owner, envelope("on"))["ok"] + assert remote_owner.calls == [mode] + assert remote_owner.mode == mode + + +@pytest.mark.parametrize("field,value", [ + ("mode", "original"), ("mode", "black"), ("running", True), + ("pending", {"request_id":"pending"}), ("queued_intent", {"action":"on"}), + ("release_target", "original"), ("closing", "exit"), ("press_active", True), + ("light_state", "failed"), ("light_state", "connecting"), + ("power_busy", lambda: True), +]) +def test_remote_lighting_keeps_safety_locks_on_every_beta_page(remote_owner, field, value): + remote_owner.mode = "guide" + setattr(remote_owner, field, value) + assert snapshot(remote_owner)["can_control"] is False + with pytest.raises(RuntimeError): + execute(remote_owner, envelope("on")) + assert not remote_owner.calls diff --git a/tests/test_panel_helpers.py b/tests/test_panel_helpers.py new file mode 100644 index 0000000..d2b74eb --- /dev/null +++ b/tests/test_panel_helpers.py @@ -0,0 +1,102 @@ +"""Optional status failures must not interrupt the shared UI polling loop.""" + +from io import BytesIO, StringIO +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) +import scr_touch_panel as panel +from test_scr_touch_panel import journal_panel + + +def test_status_helper_launch_failure_is_reported_without_raising(monkeypatch): + state = panel.Panel.__new__(panel.Panel) + state.args = SimpleNamespace(expected_master="http://approved-room-master") + monkeypatch.setattr(panel.sys, "platform", "linux") + + def failed(*args, **kwargs): + raise OSError("Cannot start process") + + monkeypatch.setattr(panel.subprocess, "Popen", failed) + state.start_status() + assert state.status_worker is None + assert "could not be checked" in state.room_status + + +@pytest.mark.parametrize("reply", [ + b"[]", b"null", b'{"services":null}', b'{"services":[1]}', + b'{"services":[["Lighting","yes"]]}', b"\xff", b"x" * 4097, +]) +def test_bad_optional_status_reply_closes_pipes_without_crashing(reply): + state = panel.Panel.__new__(panel.Panel) + state.status_started = panel.monotonic() + output, errors = BytesIO(reply), BytesIO() + state.status_worker = SimpleNamespace( + poll=lambda: 0, stdout=output, stderr=errors, + ) + state.poll_status() + assert state.status_worker is None and output.closed and errors.closed + assert "could not be checked" in state.room_status + + +def test_power_checkpoint_cannot_claim_acceptance_after_journal_failure(): + def failed(*args, **kwargs): + raise OSError("Journal is unavailable") + + state = journal_panel(SimpleNamespace(append=failed), "unused") + with pytest.raises(OSError, match="unavailable"): + state.emit("power_requested", operation="reboot") + # Ordinary UI events can report the fault without crashing navigation. + state.emit("mode_changed", target="room") + assert "unavailable" in state.status_text.value + + +@pytest.mark.parametrize("diagnostic", ["closed", "missing", "failed"]) +def test_broken_diagnostics_cannot_interrupt_completed_shutdown(monkeypatch, diagnostic): + class FailedOutput: + def write(self, text): + raise OSError("Diagnostic output failed") + + output = StringIO() + output.close() + monkeypatch.setattr(sys, "stderr", { + "closed": output, "missing": None, "failed": FailedOutput(), + }[diagnostic]) + state = panel.Panel.__new__(panel.Panel) + state.closed, state.worker = False, None + state.preferences = SimpleNamespace( + close=lambda: None, done=SimpleNamespace(is_set=lambda: True), + error="Settings write failed", + ) + state.journal = SimpleNamespace( + closing=True, done=SimpleNamespace(is_set=lambda: True), + error="Journal write failed", + ) + destroyed = [] + state.root = SimpleNamespace(destroy=lambda: destroyed.append(True)) + state.poll_journal = lambda: None + state.finish_close() + assert state.closed and destroyed == [True] + + +def test_starting_instructions_waits_for_inflight_lighting(): + state = panel.Panel.__new__(panel.Panel) + state.pending, state.running = {"request_id": "inflight"}, False + messages = [] + state.status_text = SimpleNamespace(set=messages.append) + state.start_preview() + assert not state.running and "lighting confirmation" in messages[-1] + + +def test_queued_light_selection_cannot_dispatch_after_instructions_start(): + state = panel.Panel.__new__(panel.Panel) + state.pending = state.release_target = state.closing = None + state.mode, state.light_state, state.running = "settings", "ready", True + state.view_generation = 1 + state.queued_intent = ("cct", {"cct": 3500, "intensity": 1000}, 1) + state.request = lambda *args: pytest.fail("Instructions must block queued setters") + state.dispatch_intent() + assert state.queued_intent is None diff --git a/tests/test_scr_touch_panel.py b/tests/test_scr_touch_panel.py index bff9bd8..f573d87 100644 --- a/tests/test_scr_touch_panel.py +++ b/tests/test_scr_touch_panel.py @@ -452,7 +452,9 @@ def test_slow_adapter_initialization_sends_fresh_request_once_and_honors_exit( monkeypatch.setattr(panel.select, "select", lambda *args: ([], [], [])) for leave in (None, "original", "close"): now[0] = 100.0 - worker = SimpleNamespace(stdin=BytesIO(), stdout=object(), poll=lambda: None) + worker = SimpleNamespace( + stdin=BytesIO(), stdout=object(), poll=lambda: None, terminate=lambda: None + ) monkeypatch.setattr(panel.subprocess, "Popen", lambda *args, **kwargs: worker) state = panel.Panel.__new__(panel.Panel) state.args = SimpleNamespace( From 576419cb4241ae5e026a0db6f641a85b3d963be9 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 15:41:55 -0400 Subject: [PATCH 4/9] Keep logging and settings failures visible without stopping the panel --- app/event_journal.py | 29 +++-- app/preferences.py | 19 +++- tests/test_storage_reliability.py | 182 ++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 tests/test_storage_reliability.py diff --git a/app/event_journal.py b/app/event_journal.py index 3bcc494..6691549 100644 --- a/app/event_journal.py +++ b/app/event_journal.py @@ -3,6 +3,7 @@ from __future__ import print_function import os import threading +from preferences import sync_directory try: import Queue as queue @@ -23,6 +24,8 @@ def __init__(self, path, output, capacity=128): self.thread.start() def append(self, line, sequence, durable=False): + if self.done.is_set() and not self.error: + self.error = "Panel journal writer stopped" if self.error or self.closing: raise IOError(self.error or "Panel journal is closing") try: @@ -44,11 +47,17 @@ def close(self): def _run(self): stream = None + finished = False try: if self.path: stream = os.fdopen( os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w" ) + sync_directory(os.path.dirname(os.path.abspath(self.path))) + if self.output is None: + self.output_error = "Panel output is unavailable" + if not stream: + raise IOError(self.output_error) while not self.closing or not self.pending.empty(): try: batch = [self.pending.get(timeout=0.05)] @@ -74,8 +83,8 @@ def _run(self): try: self.output.write(lines) self.output.flush() - except (IOError, OSError) as error: - self.output_error = str(error) + except (IOError, OSError, ValueError) as error: + self.output_error = str(error) or "Panel output write failed" if not stream: self.error = self.output_error if not stream and not self.output_error: @@ -85,12 +94,16 @@ def _run(self): if stream: stream.flush() os.fsync(stream.fileno()) - except (IOError, OSError, queue.Full) as error: + finished = True + except (IOError, OSError, ValueError, queue.Full) as error: self.error = str(error) or "Panel journal write failed" finally: - if stream: - try: + try: + if stream: stream.close() - except (IOError, OSError) as error: - self.error = self.error or str(error) - self.done.set() + except (IOError, OSError, ValueError) as error: + self.error = self.error or str(error) or "Panel journal close failed" + finally: + if not finished and not self.error: + self.error = "Panel journal writer stopped before shutdown" + self.done.set() diff --git a/app/preferences.py b/app/preferences.py index 95385ee..bac84c5 100644 --- a/app/preferences.py +++ b/app/preferences.py @@ -70,7 +70,7 @@ def load(path, defaults=None): merged.update(value) validated(merged) return value, None - except (IOError, OSError, ValueError) as error: + except (IOError, OSError, ValueError, RecursionError) as error: return {}, str(error) @@ -96,6 +96,9 @@ def save(self, value): with self.condition: if self.closing: return + if self.done.is_set(): + self.error = self.error or "Appearance settings writer stopped" + return self.pending = value self.settled.clear() self.condition.notify() @@ -106,12 +109,14 @@ def close(self): self.condition.notify() def _run(self): + finished = False try: while True: with self.condition: while self.pending is None and not self.closing: self.condition.wait() if self.pending is None: + finished = True return value, self.pending = self.pending, None self.writing = True @@ -132,17 +137,23 @@ def _run(self): temporary = None sync_directory(directory) self.error = None - except (IOError, OSError) as error: - self.error = str(error) + except (IOError, OSError, ValueError) as error: + self.error = str(error) or "Appearance settings write failed" finally: if temporary: try: os.unlink(temporary) except OSError as error: - self.error = str(error) + self.error = ( + self.error or str(error) + or "Appearance temporary file cleanup failed" + ) with self.condition: self.writing = False if self.pending is None: self.settled.set() finally: + if not finished and not self.error: + self.error = "Appearance settings writer stopped before shutdown" + self.settled.set() self.done.set() diff --git a/tests/test_storage_reliability.py b/tests/test_storage_reliability.py new file mode 100644 index 0000000..11a3b84 --- /dev/null +++ b/tests/test_storage_reliability.py @@ -0,0 +1,182 @@ +"""Storage failures remain visible without silently losing later panel events.""" + +from io import StringIO +from pathlib import Path +import sys +import threading + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) +import event_journal +import preferences + + +def finish(writer): + writer.close() + assert writer.done.wait(3) + writer.thread.join(1) + assert not writer.thread.is_alive() + + +@pytest.mark.parametrize("output_kind", ["closed", "absent"]) +def test_optional_console_failure_does_not_stop_private_journal(tmp_path, output_kind): + output = StringIO() if output_kind == "closed" else None + if output is not None: + output.close() + path = tmp_path / "events.jsonl" + journal = event_journal.EventJournal(str(path), output) + try: + journal.append("first\n", 1, durable=True) + journal.append("second\n", 2, durable=True) + finally: + finish(journal) + assert journal.error is None and journal.output_error + assert journal.poll() == [1, 2] + assert path.read_text() == "first\nsecond\n" + + +@pytest.mark.parametrize("output_kind", ["closed", "absent"]) +def test_output_only_logging_reports_unavailable_output(output_kind): + output = StringIO() if output_kind == "closed" else None + if output is not None: + output.close() + journal = event_journal.EventJournal(None, output) + try: + if output is not None: + journal.append("annotation\n", 1, durable=True) + else: + assert journal.done.wait(3) + finally: + finish(journal) + assert journal.error and journal.output_error + assert journal.poll() == [] + with pytest.raises(IOError): + journal.append("later\n", 2) + + +@pytest.mark.parametrize("private_file", [False, True]) +def test_console_error_without_message_never_claims_output_was_written( + tmp_path, private_file +): + class FailedOutput: + def write(self, value): + raise OSError() + + path = str(tmp_path / "events.jsonl") if private_file else None + journal = event_journal.EventJournal(path, FailedOutput()) + journal.append("annotation\n", 1, durable=True) + finish(journal) + assert journal.output_error + assert journal.poll() == ([1] if private_file else []) + assert bool(journal.error) is not private_file + + +def test_new_journal_name_is_synced_before_annotation_acknowledgement( + tmp_path, monkeypatch +): + entered, release = threading.Event(), threading.Event() + directories = [] + + def sync(directory): + directories.append(directory) + entered.set() + if not release.wait(3): + raise OSError("Test directory sync timed out") + + monkeypatch.setattr(event_journal, "sync_directory", sync) + path = tmp_path / "events.jsonl" + journal = event_journal.EventJournal(str(path), StringIO()) + try: + journal.append("annotation\n", 1, durable=True) + assert entered.wait(3) + assert directories == [str(tmp_path)] and not journal.poll() + assert path.read_text() == "" + finally: + release.set() + finish(journal) + assert journal.error is None and journal.poll() == [1] + assert path.read_text() == "annotation\n" + + +def test_failed_journal_directory_sync_never_acknowledges_annotation( + tmp_path, monkeypatch +): + def failed_sync(directory): + raise OSError("Journal filename persistence failed") + + monkeypatch.setattr(event_journal, "sync_directory", failed_sync) + journal = event_journal.EventJournal(str(tmp_path / "events.jsonl"), StringIO()) + assert journal.done.wait(3) + finish(journal) + assert journal.error == "Journal filename persistence failed" + assert journal.poll() == [] + with pytest.raises(IOError, match="filename persistence"): + journal.append("annotation\n", 1, durable=True) + + +def test_unexpected_journal_failure_is_latched_and_rejects_later_events(monkeypatch): + errors = [] + + class BrokenOutput: + def write(self, value): + raise RuntimeError("Unexpected output failure") + + monkeypatch.setattr(threading, "excepthook", lambda args: errors.append(args.exc_value)) + journal = event_journal.EventJournal(None, BrokenOutput()) + journal.append("first\n", 1) + assert journal.done.wait(3) + journal.thread.join(1) + assert len(errors) == 1 and isinstance(errors[0], RuntimeError) + assert "stopped" in journal.error + with pytest.raises(IOError, match="stopped"): + journal.append("later\n", 2) + assert journal.pending.empty() + finish(journal) + + +def test_deeply_nested_corrupt_preferences_report_error(tmp_path): + path = tmp_path / "appearance.json" + path.write_text('{"theme":' + "[" * 1500 + "0" + "]" * 1500 + "}") + saved, error = preferences.load(str(path)) + assert saved == {} and error + + +def test_unexpected_preference_failure_is_latched_and_rejects_later_saves( + tmp_path, monkeypatch +): + errors = [] + + def broken_dump(*args, **kwargs): + raise RuntimeError("Unexpected serializer failure") + + monkeypatch.setattr(preferences.json, "dump", broken_dump) + monkeypatch.setattr(threading, "excepthook", lambda args: errors.append(args.exc_value)) + writer = preferences.Preferences(str(tmp_path / "appearance.json")) + writer.save({"theme": "light"}) + assert writer.done.wait(3) + writer.thread.join(1) + assert len(errors) == 1 and isinstance(errors[0], RuntimeError) + assert "stopped" in writer.error and writer.settled.is_set() + writer.save({"theme": "dark"}) + assert writer.pending is None and "stopped" in writer.error + assert not list(tmp_path.glob(".appearance-*")) + finish(writer) + + +def test_preference_error_without_message_remains_visible(tmp_path, monkeypatch): + path = tmp_path / "appearance.json" + path.write_text('{"theme":"light"}') + + def failed_replace(*args): + raise OSError() + + monkeypatch.setattr(preferences.os, "replace", failed_replace) + writer = preferences.Preferences(str(path)) + try: + writer.save({"theme": "dark"}) + assert writer.settled.wait(3) and writer.error + assert path.read_text() == '{"theme":"light"}' + assert not list(tmp_path.glob(".appearance-*")) + finally: + finish(writer) From 08ed818d067e718d06552b44a2c5dc378291ce0a Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 15:41:55 -0400 Subject: [PATCH 5/9] Keep browser commands locked until their outcome is known --- browser/controller.py | 31 +++++++++++-- browser/room.js | 17 ++++--- tests/browser_command_checks.cjs | 71 +++++++++++++++++++++++++++++ tests/test_browser_commands.py | 20 +++++++++ tests/test_browser_controller.py | 77 ++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/browser_command_checks.cjs create mode 100644 tests/test_browser_commands.py create mode 100644 tests/test_browser_controller.py diff --git a/browser/controller.py b/browser/controller.py index 379f663..1acdca8 100644 --- a/browser/controller.py +++ b/browser/controller.py @@ -2,6 +2,7 @@ import json import socket +import time class Controller: @@ -13,14 +14,38 @@ def exchange(self, value): if len(raw) > 4096: raise ValueError("Command too large") with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + deadline = time.monotonic() + 2.8 client.settimeout(2.8) client.connect(self.path) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Panel reply deadline exceeded") + client.settimeout(remaining) client.sendall(raw) - with client.makefile("rb") as stream: - reply = stream.readline(65537) + reply = bytearray() + while len(reply) <= 65536: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Panel reply deadline exceeded") + client.settimeout(remaining) + part = client.recv(min(4096, 65537 - len(reply))) + if not part: + break + reply.extend(part) + newline = reply.find(b"\n") + if newline >= 0: + reply = reply[:newline + 1] + break if len(reply) > 65536 or not reply.endswith(b"\n"): raise ValueError("Invalid panel reply") - return json.loads(reply) + + def invalid_constant(value): + raise ValueError("Invalid panel number") + + value = json.loads(reply, parse_constant=invalid_constant) + if not isinstance(value, dict): + raise ValueError("Invalid panel reply") + return value def read(self): try: diff --git a/browser/room.js b/browser/room.js index 600388a..4ca151f 100644 --- a/browser/room.js +++ b/browser/room.js @@ -80,6 +80,7 @@ function command(action, args = {}, revision = state?.revision) { const id = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0'), ).join(''); + const sentSession = state.session; if (action === 'power') powerRequest = { operation: args.operation, session: state.session, id, accepted: false }; posting = true; @@ -97,9 +98,15 @@ function command(action, args = {}, revision = state?.revision) { message('Command was rejected. Refresh the page before trying a new action.'); return false; } - if (!reply) { + if ( + !reply || + typeof reply.ok !== 'boolean' || + reply.code !== response.status || + ![202, 400, 403, 405, 408, 409].includes(response.status) || + (reply.ok && (response.status !== 202 || reply.id !== id)) + ) { unknown = id; - unknownSession = state?.session; + unknownSession = sentSession; message( action === 'power' ? 'Power request status is unknown. Check the touch panel before trying again.' @@ -133,7 +140,7 @@ function command(action, args = {}, revision = state?.revision) { }) .catch(() => { unknown = id; - unknownSession = state?.session; + unknownSession = sentSession; message( action === 'power' ? 'Power request status is unknown. Check the touch panel before trying again.' @@ -1038,7 +1045,7 @@ function sync() { ? 'The touch panel is showing Legacy, the original app. Return it to Home to use website controls.' : state?.mode === 'black' ? 'The touch panel is blank. Return it to Home to use lighting.' - : state?.mode === 'experiment' && !state.experiment?.running + : state?.mode === 'experiment' && !state.experiment?.running && !state.can_control ? 'Experiment mode is paused. Return the touch panel to Home to use lighting.' : ''; $('legacy-banner').hidden = !(available && panelMessage); @@ -1108,7 +1115,7 @@ function sync() { : state?.detail || 'Panel unavailable. Controls are disabled.' : state.experiment?.running ? 'Instructions are running. Pause Experiment mode before using lighting.' - : ['original', 'black', 'experiment'].includes(state.mode) + : !state.can_control && ['original', 'black', 'experiment'].includes(state.mode) ? 'Return the touch panel to Home to use lighting.' : s.message || 'Lighting unavailable.', ); diff --git a/tests/browser_command_checks.cjs b/tests/browser_command_checks.cjs new file mode 100644 index 0000000..0c9cfc0 --- /dev/null +++ b/tests/browser_command_checks.cjs @@ -0,0 +1,71 @@ +// Exercise the shipped command/receipt logic using Node's standard library only. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const source = fs.readFileSync(path.join(__dirname, '../browser/room.js'), 'utf8'); + +function website(respond, snapshot = () => ({available:true, session:'owner-one', revision:'one'})) { + const elements = new Map(); + for (const id of ['legacy-return', 'message', 'connection', 'legacy-banner', 'panel-mode-message', 'light-message', 'light-recent']) + elements.set(id, {dataset:{}, textContent:''}); + const commands = []; + const context = vm.createContext({ + document: {getElementById:id => elements.get(id) || null, querySelectorAll:() => [], addEventListener() {}, hidden:false}, + window: {addEventListener() {}}, matchMedia:() => ({addEventListener() {}}), + setInterval() {}, setTimeout() {return 1;}, clearTimeout() {}, + crypto: require('node:crypto').webcrypto, AbortSignal, + fetch:async (url, options) => { + if (url === '/room-content.json') return new Promise(() => {}); // Leave initial rendering idle. + if (url === '/api/controller') return {ok:true, json:async () => snapshot()}; + const request = JSON.parse(options.body); + commands.push(request); + return respond(request, context); + }, + }); + vm.runInContext(source, context); + context.shippedSync = vm.runInContext('sync', context); + vm.runInContext("sync = () => {}; state = {available:true, session:'owner-one', revision:'one', csrf:'local'}; view='home';", context); + return {context, commands, run:script => vm.runInContext(script, context)}; +} + +(async () => { + for (const [status, body] of [[503,{ok:false,code:503,detail:'Receipt unknown; do not retry.'}], [200,{}], [202,{ok:true,code:202,id:'other-command'}]]) { + const site = website(() => ({status, ok:status<400, json:async () => body})); + assert.equal(await site.run("command('off')"), false); + assert.equal(await site.run("command('on')"), false); + assert.equal(site.commands.length, 1, 'An ambiguous reply must not enable a second command'); + assert.match(site.run("$('message').textContent"), /unknown/); + } + const rejected = website(() => ({status:409,ok:false,json:async () => ({ok:false,code:409,detail:'Review the current settings.'})})); + await rejected.run("command('off')"); + await rejected.run("command('off')"); + assert.equal(rejected.commands.length, 2, 'A known rejection allows a newly reviewed action'); + + let receipt; + const recovered = website(request => {receipt={id:request.id,ok:true,code:202};return {status:503,ok:false,json:async () => ({ok:false,code:503})};}, () => ({available:true,session:'owner-one',revision:'two',receipts:receipt?[receipt]:[]})); + await recovered.run("command('off')"); + assert.equal(recovered.run('unknown'), null, 'A matching owner receipt resolves the uncertainty'); + + const restarted = website((request, context) => { + vm.runInContext("state = {available:true, session:'owner-two', revision:'new'}", context); + return Promise.reject(new Error('Lost reply while owner restarted')); + }, () => ({available:true,session:'owner-two',revision:'new'})); + await restarted.run("command('off')"); + assert.equal(restarted.run('unknown'), null, 'Restart recovery uses the session at send time'); + assert.match(restarted.run("$('message').textContent"), /restarted/); + + const display = website(() => {throw Error('Presentation must not send commands');}); + for (const mode of ['guide', 'settings', 'health', 'about', 'experiment']) { + display.run(`state = {available:true, mode:${JSON.stringify(mode)}, can_control:true, lights:{message:'Light settings confirmed.'}}; shippedSync();`); + assert.equal(display.run("$('legacy-banner').hidden"), true); + assert.equal(display.run("$('light-message').textContent"), 'Light settings confirmed.'); + } + for (const mode of ['original', 'black']) { + display.run(`state = {available:true, mode:${JSON.stringify(mode)}, can_control:false, lights:{}}; shippedSync();`); + assert.equal(display.run("$('legacy-banner').hidden"), false); + assert.match(display.run("$('light-message').textContent"), /Return the touch panel to Home/); + } + assert.equal(display.commands.length, 0); + console.log('Browser receipt ambiguity, rejection, recovery and restart checks passed'); +})().catch(error => {console.error(error);process.exitCode=1;}); diff --git a/tests/test_browser_commands.py b/tests/test_browser_commands.py new file mode 100644 index 0000000..e6de988 --- /dev/null +++ b/tests/test_browser_commands.py @@ -0,0 +1,20 @@ +"""Run the shipped frontend's receipt checks without browser packages.""" + +from pathlib import Path +import shutil +import subprocess + +import pytest + + +def test_browser_does_not_repeat_ambiguous_commands_and_recovers_owner_receipts(): + node = shutil.which("node") + if node is None: + pytest.skip("Node.js unavailable; frontend command checks need its standard library") + subprocess.run( + [node, str(Path(__file__).with_name("browser_command_checks.cjs"))], + check=True, + capture_output=True, + text=True, + timeout=10, + ) diff --git a/tests/test_browser_controller.py b/tests/test_browser_controller.py new file mode 100644 index 0000000..a24a9de --- /dev/null +++ b/tests/test_browser_controller.py @@ -0,0 +1,77 @@ +"""Bounded reads from the existing owner; no hardware or Unix socket is needed.""" + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "browser")) +import controller + + +@pytest.fixture +def transport(monkeypatch): + class Socket: + def __init__(self): + self.parts = [] + self.now = 0 + self.advance = 1 + self.timeouts = [] + self.sent = [] + + def __enter__(self): + return self + + def __exit__(self, *unused): + pass + + def settimeout(self, value): + self.timeouts.append(value) + + def connect(self, path): + self.path = path + + def sendall(self, value): + self.sent.append(value) + + def recv(self, size): + self.now += self.advance + value = self.parts.pop(0) if self.parts else b"" + assert len(value) <= size + return value + + connection = Socket() + monkeypatch.setattr(controller.socket, "AF_UNIX", 1, raising=False) + monkeypatch.setattr(controller.socket, "socket", lambda *args: connection) + monkeypatch.setattr(controller.time, "monotonic", lambda: connection.now) + return connection + + +def test_trickled_owner_reply_has_one_absolute_deadline(transport): + transport.parts = [b'{', b'"', b'a', b'"', b':1}\n'] + with pytest.raises(TimeoutError, match="deadline"): + controller.Controller("owner.sock").exchange({"op": "read"}) + assert len(transport.parts) == 2 + assert transport.timeouts[-3:] == pytest.approx([2.8, 1.8, 0.8]) + assert transport.sent == [b'{"op": "read"}\n'] + + +def test_complete_owner_reply_stops_at_first_line(transport): + transport.parts = [b'{"available":', b'true}\nunused'] + assert controller.Controller("owner.sock").read() == {"available": True} + + +@pytest.mark.parametrize("raw", [b'null\n', b'[]\n', b'{"x":NaN}\n', b'{"x":Infinity}\n', b'{}']) +def test_unusable_owner_snapshot_disables_controls(transport, raw): + transport.parts = [raw] + value = controller.Controller("owner.sock").read() + assert value["available"] is False + assert "unavailable" in value["detail"] + + +def test_owner_reply_size_limit_remains_bounded(transport): + transport.advance = 0 + transport.parts = [b' ' * 4096] * 16 + [b' '] + assert controller.Controller("owner.sock").read()["available"] is False + assert len(transport.sent) == 1 + assert transport.parts == [] From 91062373ac169572c2e1715584ebe0abccae6ee4 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 15:41:55 -0400 Subject: [PATCH 6/9] Keep expected weather failures concise and recoverable --- app/weather.py | 19 ++++++++++----- tests/test_weather_errors.py | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tests/test_weather_errors.py diff --git a/app/weather.py b/app/weather.py index 1d1d355..066b4c2 100644 --- a/app/weather.py +++ b/app/weather.py @@ -196,16 +196,23 @@ def fetch(latitude, longitude, timezone): return summary(json.loads(body.decode("utf-8"))) -if __name__ == "__main__": +def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("latitude", type=float) parser.add_argument("longitude", type=float) parser.add_argument("timezone") - args = parser.parse_args() + args = parser.parse_args(argv) try: - print(json.dumps(fetch(args.latitude, args.longitude, args.timezone))) + result = fetch(args.latitude, args.longitude, args.timezone) + except (OSError, ValueError, KeyError, TypeError) as error: + # An optional forecast may be unavailable; reserve tracebacks for defects. + logging.warning("Weather unavailable: %s", error) + result = {"error": "Weather unavailable; the room controls still work."} except Exception: logging.exception("Weather refresh failed") - print( - json.dumps({"error": "Weather unavailable; the room controls still work."}) - ) + result = {"error": "Weather unavailable; the room controls still work."} + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_weather_errors.py b/tests/test_weather_errors.py new file mode 100644 index 0000000..6638f35 --- /dev/null +++ b/tests/test_weather_errors.py @@ -0,0 +1,45 @@ +"""Optional forecast failures must stay distinguishable from panel crashes.""" + +import json +from pathlib import Path +import sys +from urllib.error import HTTPError, URLError + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) +import weather + + +@pytest.mark.parametrize( + "error", + [ + URLError("network unavailable"), + HTTPError("https://weather.invalid", 503, "Unavailable", {}, None), + TimeoutError("forecast timed out"), + ValueError("invalid forecast"), + KeyError("current"), + ], +) +def test_expected_forecast_failure_emits_json_without_traceback( + monkeypatch, capsys, caplog, error +): + def fail(*args): + raise error + + monkeypatch.setattr(weather, "fetch", fail) + weather.main(["0", "0", "UTC"]) + assert "room controls still work" in json.loads(capsys.readouterr().out)["error"] + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" + assert caplog.records[0].exc_info is None + + +def test_unexpected_forecast_defect_keeps_diagnostic_traceback(monkeypatch, capsys, caplog): + def fail(*args): + raise RuntimeError("unexpected defect") + + monkeypatch.setattr(weather, "fetch", fail) + weather.main(["0", "0", "UTC"]) + assert "error" in json.loads(capsys.readouterr().out) + assert caplog.records[0].exc_info[0] is RuntimeError From 5b6e520f60e284efe3786542a2eb1c89f162902c Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 16:12:36 -0400 Subject: [PATCH 7/9] Keep original room controls available in Beta --- README.md | 60 ++++--- WORKFLOWS.md | 36 ++++- app/export_room_content.py | 4 + app/panel_common.py | 12 ++ app/panel_control.py | 5 +- app/panel_remote.py | 13 +- app/panel_version.py | 2 +- app/panel_views.py | 32 ++-- app/room_controls.py | 10 +- app/scr_touch_panel.py | 82 +++++++++- app/scr_touch_panel_lights.py | 251 +++++++++++++++++++++++++++-- app/start_panel.sh | 3 + browser/room-content.json | 21 ++- browser/room.css | 9 ++ browser/room.js | 131 +++++++++++++--- docs/interface.md | 66 ++++++-- tests/browser_command_checks.cjs | 35 ++++- tests/test_legacy_parity.py | 262 +++++++++++++++++++++++++++++++ tests/test_light_handoff.py | 8 +- tests/test_panel_control.py | 25 +++ tests/test_shared_content.py | 10 +- 21 files changed, 968 insertions(+), 109 deletions(-) create mode 100644 tests/test_legacy_parity.py diff --git a/README.md b/README.md index b01c736..d9e585d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ # SCR room touch panel Touch controls and room guidance for the LESA Smart Conference Room. **Legacy** -is the original room application. The current touch interface adds direct room controls and touch access -to Legacy. It does not yet cover every Legacy feature. - -The fresh Raspberry Pi OS setup, touch interface and trusted-network website are -reviewed together -in [PR #1](https://github.com/LESA-RPI/scr.touch_panel/pull/1), on -`experiment/fresh-pi-setup-20261013`. `main` remains the earlier baseline until -promotion. An installed cabinet may run a different commit; check its release -identity before maintenance. - -The current version is **0.2.0**. Keep it in -[app/panel_version.py](app/panel_version.py); small fixes can use `0.2.1`. +is the original room application. Beta provides touch and browser room controls, +with Legacy preserved as the fallback. + +The current navigation, reliability and Legacy-parity changes are reviewed in +[PR #3](https://github.com/LESA-RPI/scr.touch_panel/pull/3), on +`fix/panel-navigation-transitions`. The earlier setup is recorded in +[PR #1](https://github.com/LESA-RPI/scr.touch_panel/pull/1). An installed cabinet may +run a different commit; check its release identity before maintenance. + +The current source version is **0.2.2**, owned by +[app/panel_version.py](app/panel_version.py). It is for About and internal development/GitHub tracking; everyday headers say Beta. Legacy is the preserved older baseline for backtracking, identified by Git history rather than an invented release number. The Git commit identifies exactly @@ -23,21 +22,26 @@ separate release process are required. | Screen | Purpose | |---|---| -| Home | Stacked Lights on/off beside clock/weather; disabled blinds can be shown or hidden | +| Home | Lights on/off, optional blind controls and clock/weather | | Room tools | Lighting, Experiment mode, system/help and device preferences in one sidebar | | Demo & guide | Four directly selectable topics, with Previous/Next and All topics; no equipment or recording starts | Home is the default screen. Room tools → Lighting groups **White light, Scenes, Programmes and Channels**. Fid, Sat and Dul retain their original mixtures. -Gradient is the original spatial pattern; Sun is a travelling light cycle and -Circ cycles the room together. Their fixture/programme adapters are not connected -in this interface yet; one unavailable note replaces their inactive controls. Legacy remains -available for the original implementations. Empty Dynamic CCT, Dynamic Intensity -and Auto placeholders and the old Enable Sliders gate are omitted. - -Home includes Lift blinds, Open slats and Close & lower as disabled references. -No blind adapter is installed. Screen & controls explains this and can show or hide -the Home controls. The saved movement permission defaults to Locked; no unlock +Scenes also offers the original ten-light Gradient when its service is available. +Programmes provides the original Sun and Circ Start controls and Stop programmes +when the existing programme service is configured. Sun staggers its curve across +the room; Circ applies its curve to the room together. A programme acknowledgement +confirms only that the request was accepted, not that the programme is running. +An explicit manual lighting action stops programmes and checks stable drive settings +before applying. Opening another page or switching Beta/Legacy does not stop a programme. +Empty Dynamic CCT, Dynamic Intensity and Auto placeholders and the old Enable +Sliders gate are omitted. + +Home includes Lift blinds, Open slats and Close & lower through the shared room +controller when its blind services are available. Service acceptance does not +measure physical blind position. Screen & controls can show or hide the Home +controls. The saved movement permission defaults to Locked; no unlock control is offered while the adapter is disconnected. Changing visibility never moves equipment or grants permission. Room tools keeps detailed controls together. The header identifies Beta and provides a direct Legacy shortcut that keeps the @@ -69,6 +73,9 @@ slow reads do not hold up touches. Home, the screen saver and Lighting tabs reuse their existing widgets when you return. Readback, availability and slider bindings refresh before controls are shown; this does not cache lighting commands or bypass controller checks. +Browsing Guide, Settings, About or paused Experiment mode does not disable lighting +on the other interface. Running instructions, pending work, Legacy, a blank panel +and uncertain results retain their control guards. White light shows the minimum and maximum beside each slider. System & help separates panel health, room connection and guidance. Reference commands are @@ -118,7 +125,7 @@ needed. The commands below select the reviewed development branch explicitly: ```sh git clone https://github.com/LESA-RPI/scr.touch_panel.git cd scr.touch_panel -git switch experiment/fresh-pi-setup-20261013 +git switch fix/panel-navigation-transitions python3 -m venv .venv . .venv/bin/activate python -m pip install -r app/requirements.txt @@ -169,9 +176,10 @@ Run `python -m pytest tests` with pytest installed. Tests cover contracts, restoration, uncertain operations, preferences and idle guards. UI changes also need target font, internal text-fit, touch and failure-state checks. -Bounded Pi navigation, lighting and autostart trials have passed. Undervoltage, -prolonged reliability, untested Legacy demos/blinds and replacement hardware -remain acceptance limits. Service presence is not physical device health. Experiment mode +Earlier bounded Pi navigation, lighting and autostart trials are historical evidence; +qualify the current release against the installed configuration before use. +Prolonged reliability, physical blind motion and replacement hardware remain +acceptance checks. Service presence is not physical device health. Experiment mode journals have `recording: false`; recorder integration needs a separate adapter. General panel code belongs here. Project-X owns its optional recorder integration. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 74c4c64..4b90988 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -31,7 +31,7 @@ The installed persistent launcher uses `--keep-switcher`; its helper uses `--keep-settings` instead. It accepts any stable ten-fixture snapshot, including Gradient, and never restores lighting on navigation, shutdown or parent loss. -One Tk process owns display interaction. One helper owns this trial's ROS calls. +One Tk process owns display interaction. One helper owns Beta's ROS calls. The helper checks all fixture words before each write and after its response. It does not claim optical accuracy. The existing setter accepts integer percentages, which cannot reproduce arbitrary fractional drive settings. @@ -40,11 +40,21 @@ percentages, which cannot reproduce arbitrary fractional drive settings. Home combines stacked Lights on/off with time and outdoor weather. Blind callbacks check the saved permission and controller connection; movement defaults -to disabled and no blind adapter is installed. Room tools → Lighting groups +to Locked. The helper exposes whole-room blinds when the existing lift/tilt +services are advertised. Room tools → Lighting groups white-light adjustments, scenes, programmes and the channel editor. Home returns to everyday controls. All light controls share one latest selection queue. Wait for confirmation to distinguish a selection from a result. +Scenes includes the original ten-fixture Gradient when the coordinate service is +available. Programmes exposes the original Sun/Circ requests through the existing +Status service when configured below. Start/Stop acceptance is not programme +running or completion confirmation. Before an explicit manual lighting action, +the helper requests both programmes stop and qualifies stable drive readback; +uncertain results block further changes. Opening pages or switching Beta/Legacy +does not stop programmes or send lighting setters. Blind commands use the same +helper, with service acknowledgements rather than measured physical positions. + System & help explains each reference command's computer, purpose and expected result. The panel shows one command step at a time; the browser can copy each step or the complete topic and provides documentation links. Neither interface @@ -85,8 +95,9 @@ or restore light settings. Remove this private file to reset appearance. A direct preview saves preferences only when `--preferences PATH` is supplied. `blinds_enabled` is a strict boolean, defaults to false and is saved separately from Home visibility. Changing this setting never sends a blind command. -No blind adapter is installed. Screen & controls shows one unavailable note and -omits unlocking until a controller is connected; movement remains disabled. +Screen & controls omits unlocking until the helper reports a connected controller. +Its permission controls update when connection state changes, including while the +page remains open. Unlocking changes permission only; explicit Home buttons move blinds. Room tools → Date & weather selects Celsius or Fahrenheit. Old preference files default to Fahrenheit. Direct previews can use `--temperature-unit c` or `f`. Switching units converts cached data; it does not fetch again or change room controls. @@ -170,11 +181,21 @@ ROS_HOSTNAME=panel-hostname DISPLAY=:0 SCR_START_DELAY=8 SCR_PANEL_PYTHON=/usr/bin/python3 +# Optional: existing approved programme service; omit to hide Sun/Circ controls. +SCR_STATUS_URL=http://approved-status-service:5000 ``` Use existing, verified deployment values. Keep this file mode 0600 and outside Git. Beta requires Python 3.11+, Tk and Pillow; the launcher checks these before sourcing ROS or starting Legacy. `SCR_PANEL_PYTHON` defaults to `/usr/bin/python3`. +`SCR_STATUS_URL` is the private base URL of the existing Status owner, not a new +service to install or restart. The launcher passes it as `--status-url` to Beta +and its helper. Direct launches can use the same option. Use an approved HTTP(S) +address without embedded credentials, query or fragment; keep real addresses out +of Git. Only the fixed original `sun`/`circ` Run/Kill requests are admitted. +Omitting it leaves programme controls unavailable while ordinary lighting remains +usable. Capability advertisement identifies configured services, not their health; +qualify request acceptance and stable readback before a live change. Legacy and the light adapter use independent interpreter settings. The launcher sources the installed ROS setup, sets the same original environment, and checks process working directories before starting `main.py`. @@ -425,6 +446,9 @@ Changing a website page does not move room devices. Explicit Legacy/display controls affect the cabinet. A running instruction session blocks lighting on both interfaces until paused. Pause instructions before opening Legacy, Blank or clock view on either interface. Home keeps the instruction session running; use -Room tools → Experiment mode to pause it. Blind movement and external Legacy -actuator buttons remain outside this gateway. Reference commands may be copied, +Room tools → Experiment mode to pause it. Ordinary Beta pages and paused +instructions leave lighting available through the other interface. The gateway +also relays Gradient, original programme requests and permitted whole-room blind +actions through the same owner; it does not add independent hardware drivers. +Individual fixture/window selectors remain unimplemented. Reference commands may be copied, never executed. diff --git a/app/export_room_content.py b/app/export_room_content.py index 37ac2cd..1ac28ad 100644 --- a/app/export_room_content.py +++ b/app/export_room_content.py @@ -10,6 +10,8 @@ from room_controls import ( LIGHTING_TABS, SCENES, + GRADIENT, + PROGRAMMES, BLIND_ACTIONS, PRESETS, UNAVAILABLE_LIGHTING, @@ -69,6 +71,8 @@ def content(): "weather_accents": WEATHER_ACCENTS, "lighting_tabs": LIGHTING_TABS, "scenes": SCENES, + "gradient": GRADIENT, + "programmes": PROGRAMMES, "presets": PRESETS, "unavailable_lighting": UNAVAILABLE_LIGHTING, "blinds_unavailable": BLINDS_UNAVAILABLE, diff --git a/app/panel_common.py b/app/panel_common.py index 918e757..82bd5ad 100644 --- a/app/panel_common.py +++ b/app/panel_common.py @@ -1,6 +1,7 @@ """Shared panel values and pure presentation helpers; no controller instance.""" from scr_touch_panel_lights import PRESETS +from room_controls import BLIND_ACTIONS from design_tokens import THEMES import argparse import json @@ -35,6 +36,7 @@ # Only presentation references travel with retained pages. Controller state, intent # generations and preferences always belong to the live Panel, never a cached page. PAGE_REFERENCES = ( + "programme_buttons", "light_buttons", "home_power_buttons", "scene_buttons", @@ -193,6 +195,12 @@ def scheduled_theme(now, light_at, dark_at): def light_selection(action, arguments): """Describe requested controller inputs; never imply optical measurement.""" + if action == "gradient": + return "Gradient scene" + if action == "programme": + return "Stop programmes" if arguments["name"] == "Stop" else arguments["name"] + " programme" + if action == "blind": + return dict(BLIND_ACTIONS)[arguments["name"]] if action == "cct": return "%d K input / level %d" % (arguments["cct"], arguments["intensity"]) values = tuple(arguments["values"]) @@ -217,6 +225,8 @@ def confirmation_age(now, confirmed_at): def confirmed_power(response): """Describe confirmed drive readback, never optical or continuously live state.""" + if response.get("state") == "completed" and response.get("scene") == "Gradient" and response.get("fixtures") == 10: + return "on" if response.get("state") == "ready" and response.get("power") in ("on", "off"): return response["power"] if response.get("state") == "ready" and response.get("baseline") in PRESETS: @@ -237,6 +247,8 @@ def confirmed_power(response): def confirmed_scene(response): """Match an admitted baseline or exact confirmed drive words, never a selection.""" + if response.get("state") == "completed" and response.get("scene") == "Gradient" and response.get("fixtures") == 10: + return "Gradient" if response.get("state") == "ready" and response.get("baseline") in PRESETS: return response["baseline"] if response.get("state") == "completed" and confirmed_power(response) is not None: diff --git a/app/panel_control.py b/app/panel_control.py index 89d53a9..1d7d6de 100644 --- a/app/panel_control.py +++ b/app/panel_control.py @@ -47,6 +47,8 @@ def command(value): "cct": ("cct", "intensity"), "preset": ("name",), "channels": ("values",), + "gradient": (), + "programme": ("name",), "theme": ("value",), "clock": ("value",), "temperature": ("value",), @@ -79,6 +81,7 @@ def integer(key, low, high): ) choices = { "preset": tuple(PRESETS), + "programme": ("Sun", "Circ", "Stop"), "theme": ("light", "dark", "auto"), "clock": ("12", "24"), "temperature": ("c", "f"), @@ -89,7 +92,7 @@ def integer(key, low, high): "tips": (True, False), } if action in choices: - key = "name" if action in ("preset", "blind") else "value" + key = "name" if action in ("preset", "blind", "programme") else "value" valid = ( type(args[key]) is type(choices[action][0]) and args[key] in choices[action] ) diff --git a/app/panel_remote.py b/app/panel_remote.py index e404f0a..26f0a5e 100644 --- a/app/panel_remote.py +++ b/app/panel_remote.py @@ -37,8 +37,12 @@ def snapshot(panel): "mode": panel.mode, "busy": busy, "can_control": lighting, + "can_programme": getattr(panel, "programme_available", lambda: False)(), "can_session": not busy and panel.mode != "original", "lights": { + "capabilities": getattr(panel, "light_capabilities", {}), + "programme": getattr(panel, "programme", None), + "programme_confirmed": False, "state": panel.light_state, "power": panel.confirmed_power if lighting else None, "scene": panel.confirmed_scene if lighting else None, @@ -126,7 +130,7 @@ def execute(panel, request): raise RuntimeError( "The panel is busy. Wait for the current touch or command to finish." ) - if action in ("on", "off", "preset", "cct", "channels"): + if action in ("on", "off", "preset", "cct", "channels", "gradient"): if not state["can_control"]: raise RuntimeError( "Lighting is unavailable, uncertain, in Legacy or held by an experiment. Check the panel." @@ -143,10 +147,17 @@ def execute(panel, request): panel.cct.set(args["cct"]) panel.intensity.set(args["intensity"]) panel.apply("cct", immediate=True, **args) + elif action == "gradient": + if not state["lights"]["capabilities"].get("gradient"): + raise RuntimeError("Gradient is unavailable from this controller.") + panel.apply("gradient", immediate=True) else: for var, value in zip(panel.channel_values, args["values"]): var.set(value) panel.apply("channels", immediate=True, **args) + elif action == "programme": + if not panel.programme_command(args["name"]): + raise RuntimeError("Programme controls are unavailable. Check the panel.") elif action == "blind": if not state["blinds"]["available"]: raise RuntimeError("Blinds · " + state["blinds"]["detail"]) diff --git a/app/panel_version.py b/app/panel_version.py index 6a31aef..cd58980 100644 --- a/app/panel_version.py +++ b/app/panel_version.py @@ -1,6 +1,6 @@ """Human-readable panel version; Git commits identify the exact deployed source.""" -VERSION = "0.2.1" +VERSION = "0.2.2" def build_commit(): diff --git a/app/panel_views.py b/app/panel_views.py index d60701e..ee6a71f 100644 --- a/app/panel_views.py +++ b/app/panel_views.py @@ -4,7 +4,7 @@ from panel_version import BUILD_COMMIT from room_controls import LIGHTING_TABS from room_controls import UNAVAILABLE_LIGHTING, BLINDS_UNAVAILABLE -from room_controls import SCENES, WEBSITE_URL +from room_controls import SCENES, GRADIENT, WEBSITE_URL from touch_slider import TouchSlider from panel_version import VERSION import os @@ -303,7 +303,7 @@ def lighting_contents(self): ).pack(anchor="w", pady=(2, 8)) grid = tk.Frame(self.body, bg=self.bg) grid.pack(fill="x") - for index, scene in enumerate(SCENES): + for index, scene in enumerate(SCENES + (GRADIENT,)): grid.columnconfigure(index % 2, weight=1, uniform="scene") card = tk.Frame( grid, @@ -318,24 +318,38 @@ def lighting_contents(self): padx=(0, 10) if index % 2 == 0 else 0, pady=(0, 10), ) - self.label(card, scene["label"], 21).pack( - anchor="w", padx=16, pady=(8, 0) + self.label(card, scene["label"], 20).pack( + anchor="w", padx=16, pady=(4, 0) ) - note = self.label(card, scene["detail"], 15, True) + note = self.label(card, scene["detail"], 14, True) self.wrap_label(note).pack(fill="x", padx=16, pady=(0, 6)) button = self.button( card, "Apply scene", lambda name=scene["id"]: self.preset(name) ) - button.config(font=(self.font_family, 15), pady=8) - button.pack(fill="x", padx=16, pady=(0, 10), ipady=3) + button.config(font=(self.font_family, 15), pady=5) + button.pack(fill="x", padx=16, pady=(0, 8), ipady=3) button._scene_label = "Apply scene" + button._gradient = scene["id"] == "Gradient" self.light_buttons.append(button) self.scene_buttons.append((button, scene["id"])) self.feedback_card(self.body) elif self.lighting_tab == "programmes": - self.wrap_label(self.label(self.body, UNAVAILABLE_LIGHTING, 18, True)).pack( - fill="x", pady=16 + self.programme_buttons = self.row_buttons( + self.body, + [("Original " + name, lambda name=name: self.programme_command(name), False) + for name in ("Sun", "Circ")], height=74, ) + self.programme_buttons.extend(self.row_buttons( + self.body, [("Stop programmes", lambda: self.programme_command("Stop"), False)], height=64, + )) + for button, name in zip(self.programme_buttons, ("Sun", "Circ", "Stop")): + button._programme = name + self.wrap_label(self.label( + self.body, + "Uses the original room programmes. The service acknowledges requests; live programme status is unavailable. Manual lighting stops programmes before applying.", + 16, True, + )).pack(fill="x", pady=(12, 8)) + self.feedback_card(self.body) else: self.channels_view(embedded=True) self.update_light_buttons() diff --git a/app/room_controls.py b/app/room_controls.py index 7a9c454..72274b8 100644 --- a/app/room_controls.py +++ b/app/room_controls.py @@ -13,7 +13,15 @@ for name in PRESETS if name != "Off" ) -UNAVAILABLE_LIGHTING = "Gradient, Sun and Circ are not available yet. Their adapters are not connected; use Legacy for the original controls." +GRADIENT = {"id": "Gradient", "label": "Gradient", + "detail": "Original spatial pattern across the ten room lights."} +PROGRAMMES = ( + {"id": "Sun", "label": "Original Sun programme", + "detail": "Original colour and light-level curve, staggered across the room."}, + {"id": "Circ", "label": "Original Circ programme", + "detail": "Original colour and light-level curve across the whole room."}, +) +UNAVAILABLE_LIGHTING = "Original programmes are unavailable while the Status connection is not configured." BLINDS_UNAVAILABLE = "Not available yet: the blind adapter is not connected." BLIND_ACTIONS = ( ("lift", "Lift blinds"), diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index e939096..adffec1 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -109,6 +109,10 @@ def __init__(self, args): self.blinds_display = getattr(args, "blinds_display", "disabled") self.blinds_enabled = getattr(args, "blinds_enabled", False) self.blind_controller = None + self.blinds_connected = False + self.light_capabilities = {} + self.programme = None + self.programme_buttons = [] self.blind_buttons = [] self.idle_active, self.press_active = False, False self.clock_24 = getattr(args, "clock_format", "24") == "24" @@ -1052,7 +1056,8 @@ def clear_page_references(self): self.home_power_buttons, self.scene_buttons, self.blind_buttons, - ) = ([], [], [], []) + self.programme_buttons, + ) = ([], [], [], [], []) def remember_detail(self): key = self.advanced_detail_key @@ -1091,7 +1096,11 @@ def blind_state(self): ) return blind_readiness( getattr(self, "blinds_enabled", False), - bool(controller and controller.connected), + bool(controller and controller.connected) or bool( + getattr(self, "blinds_connected", False) + and getattr(self, "worker_ready", False) + and self.worker and self.worker.poll() is None + ), busy, ) @@ -1102,8 +1111,38 @@ def blind_command(self, name): if not readiness["available"]: self.status_text.set("Blinds · " + readiness["detail"]) return False - # An optional owner adapter must enqueue without blocking Tk. No driver is installed here. - self.blind_controller.submit(name) + if self.blind_controller is not None: + self.blind_controller.submit(name) + return True + if not self.request("blind", {"name": name}): + return False + self.light_state = "applying" + self.lighting_feedback("Blind movement requested. Waiting for controller response.") + self.update_light_buttons() + return True + + def programme_available(self): + return bool( + getattr(self, "light_capabilities", {}).get("programmes") + and getattr(self, "worker_ready", False) + and self.worker and self.worker.poll() is None + and not self.pending and not self.release_target and not self.closing + and not self.running and self.mode not in ("original", "black") + and self.light_state != "disconnected" + and (self.light_state != "failed" or not self.light_changes_requested) + ) + + def programme_command(self, name): + if name not in ("Sun", "Circ", "Stop"): + raise ValueError("Unknown programme") + if not self.programme_available() or (self.light_state == "failed" and name != "Stop"): + return False + self.cancel_intent() + if not self.request("programme", {"name": name}): + return False + self.light_state = "applying" + self.lighting_feedback("Programme request sent. Waiting for the original service.") + self.update_light_buttons() return True def update_blind_buttons(self): @@ -1211,6 +1250,9 @@ def slider_released(self, event): self.cct_apply() if drag[1] == "cct" else self.channels_apply() def preset(self, name): + if name == "Gradient": + self.apply("gradient", immediate=True) + return for variable, value in zip(self.channel_values, PRESETS[name]): variable.set(value) self.apply("channels", immediate=True, values=list(PRESETS[name])) @@ -1303,6 +1345,8 @@ def update_light_buttons(self): state=( "normal" if self.light_state in ("ready", "applying") + and (not getattr(button, "_gradient", False) + or getattr(self, "light_capabilities", {}).get("gradient")) and not self.release_target and not self.closing and not getattr(self, "running", False) @@ -1311,6 +1355,12 @@ def update_light_buttons(self): ) self.update_power_highlights() self.update_blind_buttons() + for button in getattr(self, "programme_buttons", []): + if button.winfo_exists(): + allowed = self.programme_available() and ( + self.light_state != "failed" or getattr(button, "_programme", None) == "Stop" + ) + button.config(state="normal" if allowed else "disabled") def start_preview(self): if self.power_busy(): @@ -1839,6 +1889,8 @@ def start_worker(self): ] if self.keep_switcher: command.append("--keep-settings") + if getattr(self.args, "status_url", None): + command.extend(["--status-url", self.args.status_url]) self.release_confirmed = self.light_changes_requested = False self.worker_started = monotonic() try: @@ -1863,6 +1915,8 @@ def start_worker(self): self.lighting_feedback("Checking the current light settings…") self.worker_buffer = "" self.worker_ready = False + self.light_capabilities = {} + self.blinds_connected = False def request(self, action, arguments=None): if ( @@ -1881,7 +1935,7 @@ def request(self, action, arguments=None): "sent_monotonic_s": monotonic(), } self.emit("light_requested", request=item) - if action in ("cct", "channels"): + if action in ("cct", "channels", "gradient", "programme", "blind"): # A lost pipe response must never permit an unverified controller handoff. self.light_changes_requested = True elif action == "release": @@ -2017,6 +2071,8 @@ def poll_worker(self): and not self.worker_ready and self.light_state == "connecting" ): + self.light_capabilities = response.get("capabilities", {}) + self.blinds_connected = response.get("blinds_connected") is True self.worker_ready = True self.request( "release" if self.release_target or self.closing else "acquire" @@ -2032,6 +2088,9 @@ def poll_worker(self): "release": ("released", "failed"), "cct": ("completed", "failed"), "channels": ("completed", "failed"), + "gradient": ("completed", "failed"), + "programme": ("completed", "failed"), + "blind": ("completed", "failed"), }.get(self.pending["action"], ()) if response.get("version") != 1 or response.get("state") not in expected: self.fail_worker("Invalid adapter reply; controls disabled. Ask staff.") @@ -2040,6 +2099,9 @@ def poll_worker(self): action = completed_request["action"] self.emit("light_result", result=response) state = response["state"] + self.light_capabilities = response.get("capabilities", getattr(self, "light_capabilities", {})) + self.blinds_connected = response.get("blinds_connected", getattr(self, "blinds_connected", False)) is True + self.programme = response.get("programme", getattr(self, "programme", None)) self.light_state = { "completed": "ready", "released": "disconnected", @@ -2049,7 +2111,7 @@ def poll_worker(self): # Full exception text remains in the emitted result/receipt. # Keep the touch feedback bounded, even for verbose ROS errors. detail = "Result unconfirmed. Controls disabled; ask staff." - if state == "completed": + if state == "completed" and action not in ("programme", "blind"): self.confirmed_light = light_selection( action, completed_request["arguments"] ) @@ -2057,6 +2119,9 @@ def poll_worker(self): self.confirmed_power = confirmed_power(response) self.confirmed_scene = confirmed_scene(response) detail = "Light settings confirmed." + elif state == "completed" and action == "programme": + self.confirmed_light = "Programme request accepted; live state unavailable" + self.confirmed_at = self.confirmed_power = self.confirmed_scene = None elif state == "ready": detail = "Lighting ready." baseline = response.get("baseline") @@ -2413,6 +2478,11 @@ def main(argv=None): default="/usr/bin/python2.7", help="Installed ROS interpreter, independent of the UI", ) + parser.add_argument( + "--status-url", + default=os.environ.get("SCR_STATUS_URL"), + help="Existing Legacy programme service, from private deployment configuration", + ) parser.add_argument( "--expected-master", default=os.environ.get("SCR_PANEL_MASTER"), diff --git a/app/scr_touch_panel_lights.py b/app/scr_touch_panel_lights.py index b02b5bd..6a955b1 100644 --- a/app/scr_touch_panel_lights.py +++ b/app/scr_touch_panel_lights.py @@ -35,6 +35,66 @@ (5, 0), (5, 2), } +GRADIENT = dict( + (xy, tuple(100 if channel == colour else 0 for channel in range(8))) + for xy, colour in zip(sorted(COORDINATES), (0, 0, 1, 2, 3, 4, 5, 6, 7, 7)) +) + + +class StatusProgrammes(object): + """Use the existing Status owner; an HTTP acknowledgement is only acceptance.""" + + def __init__(self, base_url, can_write=lambda: None, deadline=None): + try: + from urlparse import urlparse + except ImportError: + from urllib.parse import urlparse + url = urlparse(base_url) + if (url.scheme not in ("http", "https") or not url.hostname + or url.username or url.password or url.query or url.fragment): + raise ValueError("A configured Status HTTP address without credentials is required") + self.base_url = base_url.rstrip("/") + self.can_write, self.deadline = can_write, deadline + + def request(self, operation, name): + if operation not in ("Run", "Kill") or name not in ("sun", "circ"): + raise ValueError("Only original Sun/Circ programme requests are permitted") + try: + from urllib2 import Request, urlopen + except ImportError: + from urllib.request import Request, urlopen + request = Request( + self.base_url + "/Script_" + operation, + json.dumps({"name": name}).encode("utf-8"), + {"Content-Type": "application/json"}, + ) + self.can_write() + if self.deadline is not None: + remaining = self.deadline() - clock() + if remaining <= 0: + raise RuntimeError("Whole programme operation exceeded 30 seconds") + signal.alarm(max(1, int(math.ceil(min(3, remaining))))) + response = None + try: + response = urlopen(request, timeout=3) + body = response.read(4097) + if response.getcode() != 202 or len(body) > 4096: + raise ValueError("Status did not accept the programme request") + result = json.loads(body.decode("utf-8")) + if not isinstance(result, dict) or result.get("name") != name: + raise ValueError("Status programme acknowledgement did not match") + finally: + if self.deadline is not None: + signal.alarm(0) + if response is not None: + response.close() + + def stop(self): + self.request("Kill", "sun") + self.request("Kill", "circ") + + def start(self, name): + self.request("Run", {"Sun": "sun", "Circ": "circ"}[name]) def words(percent): @@ -66,12 +126,28 @@ def restore_preset(snapshot): class Lights(object): - def __init__(self, call, can_write=lambda: None, keep_settings=False): + def __init__(self, call, can_write=lambda: None, keep_settings=False, + programmes=None, sleep=time.sleep, blinds_connected=False, + gradient_connected=True): self.call, self.can_write = call, can_write self.keep_settings = keep_settings self.baseline = self.current = None self.baseline_name = None self.changed = self.failed = False + self.programmes, self.sleep = programmes, sleep + self.programme = None + self.blinds_connected, self.blinds_failed = blinds_connected, False + self.gradient_connected = gradient_connected + + def capabilities(self): + return { + "capabilities": {"gradient": self.gradient_connected, + "programmes": bool(self.programmes and self.keep_settings)}, + "programme": self.programme, + "programme_confirmed": False, + "blinds_connected": self.keep_settings and self.blinds_connected and not self.blinds_failed, + "manual_blocked": self.programme is not None or self.baseline is None, + } def read(self): flat = list(self.call("get_lights").lights) @@ -96,17 +172,125 @@ def acquire(self): raise name = None if self.read() != state: + if self.keep_settings: + result = {"state": "ready", "baseline": None, "fixtures": 10, + "detail": "Lights are changing. Stop the programme before manual control."} + result.update(self.capabilities()) + return result raise ValueError("Light settings are changing; live controls stay off") self.baseline, self.current, self.baseline_name = state, state, name - return { + result = { "state": "ready", "baseline": name, "power": "on" if any(any(values) for values in state.values()) else "off", "fixtures": 10, } + result.update(self.capabilities()) + return result + + def stop_programmes(self): + """Qualify quiescent drive after stop acceptance, without claiming job health.""" + self.can_write() + self.programmes.stop() + self.sleep(0.25) + state = self.read() + self.sleep(0.25) + if self.read() != state: + raise ValueError("Programme stop accepted but lights are still changing") + self.programme = None + if self.baseline is None: + self.baseline = state + self.baseline_name = None + self.current = state + + def run_programme(self, name): + if name not in ("Sun", "Circ", "Stop"): + raise ValueError("Unknown original programme") + if not self.keep_settings or not self.programmes or self.failed: + raise ValueError("Original programmes are unavailable") + try: + self.stop_programmes() + if name != "Stop": + self.can_write() + self.changed = True + self.current = None + self.programmes.start(name) + self.programme = name + result = { + "state": "completed", + "detail": ("Programme stop accepted; drive settings stable." + if name == "Stop" else name + " start request accepted; running status unavailable."), + "manual_blocked": name != "Stop", + } + result.update(self.capabilities()) + return result + except Exception: + self.failed = True + raise + + def apply_gradient(self): + if not self.gradient_connected: + raise ValueError("Gradient service is unavailable") + if self.failed or (self.baseline is None and not (self.programmes and self.keep_settings)): + raise ValueError("Lighting trial is unavailable") + try: + self.prepare_manual() + self.can_write() + self.changed = True + self.current = None + for xy, values in sorted(GRADIENT.items()): + self.can_write() + self.call("sources", *(list(values) + list(xy))) + expected = dict((xy, setter_words(values)) for xy, values in GRADIENT.items()) + actual = self.read() + if actual != expected: + raise ValueError("Gradient fixture drive readback differs from the command") + self.current = actual + result = { + "state": "completed", "scene": "Gradient", "power": "on", "fixtures": 10, + "detail": "Gradient drive settings confirmed on 10 lights", + "fixture_words": [[list(xy), list(values)] for xy, values in sorted(actual.items())], + } + result.update(self.capabilities()) + return result + except Exception: + self.failed = True + raise + + def prepare_manual(self): + if self.programmes and self.keep_settings: + previous, active = self.current, self.programme is not None or self.baseline is None + self.stop_programmes() + if not active and self.current != previous: + raise ValueError("Another controller changed the lights; trial ownership lost") + elif self.read() != self.current: + raise ValueError("Another controller changed the lights; trial ownership lost") + + def move_blinds(self, name): + if name not in ("lift", "open", "close"): + raise ValueError("Unknown blind action") + if not self.keep_settings or not self.blinds_connected or self.blinds_failed: + raise ValueError("Blind controller is unavailable") + try: + actions = {"lift": [("lift_all", 100)], "open": [("tilt_all", 50)], + "close": [("tilt_all", 100), ("lift_all", 0)]}[name] + for index, (action, value) in enumerate(actions): + if index: + self.sleep(2) + self.can_write() + response = self.call(action, value) + if getattr(response, "lift" if action == "lift_all" else "tilt", None) != value: + raise ValueError("Blind service acknowledgement did not match") + result = {"state": "completed", "blind": name, + "detail": "Blind command accepted; physical position is not measured."} + result.update(self.capabilities()) + return result + except Exception: + self.blinds_failed = True + raise def apply(self, action, arguments): - if self.failed or self.baseline is None: + if self.failed or (self.baseline is None and not (self.programmes and self.keep_settings)): raise ValueError("Lighting trial is unavailable") if action == "channels": values = arguments["values"] @@ -131,10 +315,7 @@ def apply(self, action, arguments): else: raise ValueError("Unknown lighting action") try: - if self.read() != self.current: - raise ValueError( - "Another controller changed the lights; trial ownership lost" - ) + self.prepare_manual() self.can_write() self.changed = True # Until a matching readback arrives, the result is uncertain. @@ -148,18 +329,20 @@ def apply(self, action, arguments): if any(value != expected for value in actual.values()): raise ValueError("Fixture drive readback differs from the command") self.current = actual - return { + result = { "state": "completed", "fixtures": 10, "detail": "Drive settings confirmed on 10 lights", "words": list(expected), } + result.update(self.capabilities()) + return result except Exception: self.failed = True raise def release(self): - if self.failed: + if self.failed or self.blinds_failed: raise ValueError( "Outcome unknown; no automatic restore or retry. Inspect the retained receipt" ) @@ -225,6 +408,8 @@ def main(): action="store_true", help="Release ownership without restoring lights (persistent room control)", ) + parser.add_argument("--status-url", default=os.environ.get("SCR_STATUS_URL"), + help="Approved existing Status HTTP service for Sun/Circ") args = parser.parse_args() if ( not args.status_only @@ -276,6 +461,15 @@ def main(): journal = os.fdopen( os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w" ) + # Persist both the receipt name and its newly created run directory before + # any hardware command can be accepted. File fsync alone misses these entries. + receipt_dir = os.path.dirname(os.path.abspath(args.receipt)) + for directory in (receipt_dir, os.path.dirname(receipt_dir)): + descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) socket.setdefaulttimeout(10) import rospy from scr_control.srv import ( @@ -283,6 +477,9 @@ def main(): GetSources, OctaLight_sourcesAll, OctaLight_CCTAll, + OctaLight_sources, + BlindLiftAll, + BlindTiltAll, ) rospy.init_node("scr_panel_lights", anonymous=True, disable_signals=True) @@ -291,6 +488,9 @@ def main(): get_sources=GetSources, sources_all=OctaLight_sourcesAll, cct_all=OctaLight_CCTAll, + sources=OctaLight_sources, + lift_all=BlindLiftAll, + tilt_all=BlindTiltAll, ) def expired(signum, frame): @@ -348,7 +548,23 @@ def can_write(): ): raise ValueError("Panel lease ended before dispatch; no command sent") - lights = Lights(call, can_write, keep_settings=args.keep_settings) + try: + try: + from xmlrpclib import ServerProxy + except ImportError: + from xmlrpc.client import ServerProxy + socket.setdefaulttimeout(3) + graph = ServerProxy(args.expected_master).getSystemState("scr_panel_capabilities") + services = {name for name, nodes in graph[2][2]} if graph[0] == 1 else set() + except Exception: + services = set() + finally: + socket.setdefaulttimeout(10) + programmes = StatusProgrammes(args.status_url, can_write, lambda: deadline[0]) if args.status_url else None + lights = Lights(call, can_write, keep_settings=args.keep_settings, + programmes=programmes, + blinds_connected={"/lift_all", "/tilt_all"} <= services, + gradient_connected="/sources" in services) def record(event, **values): values.update(event=event, epoch_s=time.time(), pid=os.getpid()) @@ -361,6 +577,7 @@ def record(event, **values): os.fsync(journal.fileno()) def respond(request_id, result): + result.update(lights.capabilities()) record("result", request_id=request_id, result=result) print(json.dumps(dict(result, version=1, request_id=request_id))) sys.stdout.flush() @@ -368,7 +585,9 @@ def respond(request_id, result): stopping = [False] signal.signal(signal.SIGTERM, lambda signum, frame: stopping.__setitem__(0, True)) signal.signal(signal.SIGINT, lambda signum, frame: stopping.__setitem__(0, True)) - print(json.dumps({"version": 1, "state": "initialized"})) + initialized = {"version": 1, "state": "initialized"} + initialized.update(lights.capabilities()) + print(json.dumps(initialized)) sys.stdout.flush() try: while not stopping[0]: @@ -394,6 +613,12 @@ def respond(request_id, result): result = lights.acquire() elif action == "release": result = lights.release() + elif action == "gradient": + result = lights.apply_gradient() + elif action == "programme": + result = lights.run_programme(item.get("arguments", {}).get("name")) + elif action in ("blind", "blinds"): + result = lights.move_blinds(item.get("arguments", {}).get("name")) else: result = lights.apply(action, item.get("arguments", {})) respond(request_id, result) @@ -405,9 +630,11 @@ def respond(request_id, result): { "state": "failed", "detail": str(error), - "outcome_unknown": lights.failed, + "outcome_unknown": lights.failed or lights.blinds_failed, }, ) + if action == "acquire" and not lights.failed: + continue # Status Stop remains usable when read-only acquisition fails. break finally: deadline[0] = clock() + 30 diff --git a/app/start_panel.sh b/app/start_panel.sh index 4209392..875c950 100644 --- a/app/start_panel.sh +++ b/app/start_panel.sh @@ -63,6 +63,9 @@ fi if test -n "${SCR_CONTROL_SOCKET:-}"; then set -- "$@" --control-socket "$SCR_CONTROL_SOCKET" fi +if test -n "${SCR_STATUS_URL:-}"; then + set -- "$@" --status-url "$SCR_STATUS_URL" +fi exec "$panel_python" -B "$app_dir/scr_touch_panel.py" \ --keep-switcher --start-mode "$start_mode" --live-lights --theme auto --duration 0 \ --adapter-python "$adapter_python" \ diff --git a/browser/room-content.json b/browser/room-content.json index 22c8603..211943f 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -1,6 +1,6 @@ { "version": 1, - "app_version": "0.2.1", + "app_version": "0.2.2", "icons": { "tools": [ { @@ -710,6 +710,23 @@ "detail": "Original eight-channel room mixture." } ], + "gradient": { + "id": "Gradient", + "label": "Gradient", + "detail": "Original spatial pattern across the ten room lights." + }, + "programmes": [ + { + "id": "Sun", + "label": "Original Sun programme", + "detail": "Original colour and light-level curve, staggered across the room." + }, + { + "id": "Circ", + "label": "Original Circ programme", + "detail": "Original colour and light-level curve across the whole room." + } + ], "presets": { "Fid": [ 39, @@ -752,7 +769,7 @@ 0 ] }, - "unavailable_lighting": "Gradient, Sun and Circ are not available yet. Their adapters are not connected; use Legacy for the original controls.", + "unavailable_lighting": "Original programmes are unavailable while the Status connection is not configured.", "blinds_unavailable": "Not available yet: the blind adapter is not connected.", "generated_from": "app/export_room_content.py", "blind_actions": [ diff --git a/browser/room.css b/browser/room.css index 20cf238..501e14f 100644 --- a/browser/room.css +++ b/browser/room.css @@ -598,6 +598,15 @@ input[type='range'] { gap: 12px; padding: 20px; } +.scene-grid .card[hidden] { + display: none; +} +#programme-status { + margin-top: 16px; +} +#blind-permission { + width: 100%; +} .scene-grid .card > h3 + p { margin-top: 0; } diff --git a/browser/room.js b/browser/room.js index 4ca151f..56de1ec 100644 --- a/browser/room.js +++ b/browser/room.js @@ -130,8 +130,16 @@ function command(action, args = {}, revision = state?.revision) { ); return true; } + if (action === 'programme') { + message( + args.name === 'Stop' + ? 'Stop requested. Programme running state is not confirmed.' + : args.name + ' start requested. Programme running state is not confirmed.', + ); + return true; + } message( - action === 'on' || action === 'off' || ['cct', 'channels', 'preset'].includes(action) + ['on', 'off', 'cct', 'channels', 'preset', 'gradient'].includes(action) ? 'Sent. Waiting for controller confirmation.' : 'Accepted by the panel.', true, @@ -496,11 +504,14 @@ function tools(root, page) { display.append(actions); const blinds = setting(detail, 'Blind controls', ''); blinds.previousElementSibling.querySelector('p').id = 'blind-reason'; - if (state?.blinds?.connected) - choices(blinds, [ - ['Locked', 'blinds_enabled', false], - ['Unlocked', 'blinds_enabled', true], - ]); + const permission = el('div'); + permission.id = 'blind-permission'; + permission.hidden = !state?.blinds?.connected; + choices(permission, [ + ['Locked', 'blinds_enabled', false], + ['Unlocked', 'blinds_enabled', true], + ]); + blinds.append(permission); choices(setting(detail, 'Blinds on Home', 'Show the blind buttons on Home.'), [ ['Show', 'blinds_display', 'disabled'], ['Hide', 'blinds_display', 'hidden'], @@ -551,10 +562,69 @@ function lighting(detail, tab) { card.append(el('h3', scene.label), el('p', scene.detail, 'muted'), b); grid.append(card); } + const gradient = el('section', undefined, 'card'); + gradient.id = 'gradient-controls'; + gradient.hidden = !state?.lights?.capabilities?.gradient; + const apply = button('Apply gradient', () => command('gradient'), { + action: 'gradient', + }); + apply.dataset.preset = 'Gradient'; + gradient.append( + el('h3', content.gradient?.label || 'Gradient'), + el( + 'p', + content.gradient?.detail || 'Original spatial pattern across the ten room lights.', + 'muted', + ), + apply, + ); + grid.append(gradient); detail.append(grid); feedback(detail); } else if (tab === 'programmes') { - detail.append(el('p', content.unavailable_lighting, 'muted')); + const unavailable = el( + 'p', + content.unavailable_lighting || 'Original lighting programmes are unavailable on this panel.', + 'muted', + ); + unavailable.id = 'programmes-unavailable'; + const controls = el('div'); + controls.id = 'programme-controls'; + controls.hidden = !state?.lights?.capabilities?.programmes; + const grid = el('div', undefined, 'scene-grid'); + for (const programme of content.programmes || [ + { + id: 'Sun', + label: 'Original Sun programme', + detail: 'A spatially staggered colour-temperature curve.', + }, + { + id: 'Circ', + label: 'Original Circ programme', + detail: 'A whole-room colour-temperature curve.', + }, + ]) { + const card = el('section', undefined, 'card'); + const start = button( + 'Start ' + programme.id, + () => command('programme', { name: programme.id }), + { action: 'programme' }, + ); + start.dataset.programme = programme.id; + card.append(el('h3', programme.label), el('p', programme.detail, 'muted'), start); + grid.append(card); + } + const stop = button( + 'Stop programmes', + () => command('programme', { name: 'Stop' }), + { action: 'programme' }, + ); + stop.dataset.programme = 'Stop'; + const status = el('p', '', 'muted'); + status.id = 'programme-status'; + status.setAttribute('role', 'status'); + controls.append(grid, stop, status); + detail.append(unavailable, controls); } else if (tab === 'channels') { detail.append(el('p', 'Adjust the eight room-wide channel percentages.', 'muted')); const grid = el('div', undefined, 'channel-grid'); @@ -1060,9 +1130,20 @@ function sync() { 'blind-reason', state?.blinds?.connected ? state.blinds.reason : content.blinds_unavailable, ); + if ($('blind-permission')) $('blind-permission').hidden = !state?.blinds?.connected; + if ($('gradient-controls')) $('gradient-controls').hidden = !s.capabilities?.gradient; + if ($('programme-controls')) $('programme-controls').hidden = !s.capabilities?.programmes; + if ($('programmes-unavailable')) + $('programmes-unavailable').hidden = !!s.capabilities?.programmes; + update( + 'programme-status', + s.programme + ? s.programme + ' programme requested. Running state is not confirmed.' + : 'No programme selected by this panel. Running state is not confirmed.', + ); for (const b of document.querySelectorAll('[data-action]')) { const a = b.dataset.action, - lighting = ['on', 'off', 'cct', 'preset', 'channels'].includes(a), + lighting = ['on', 'off', 'cct', 'preset', 'channels', 'gradient'].includes(a), session = ['start', 'pause', 'previous', 'next', 'mark'].includes(a); b.disabled = posting || @@ -1070,13 +1151,18 @@ function sync() { !available || state.busy || (['legacy', 'blank', 'clock_view'].includes(a) && state.experiment?.running) || - (a === 'blind' - ? !state?.blinds?.available - : lighting - ? !state.can_control - : session - ? !state.can_session - : false); + (a === 'programme' + ? state.can_programme === false || !s.capabilities?.programmes || + (b.dataset.programme !== 'Stop' && !state.can_control) + : a === 'gradient' + ? !s.capabilities?.gradient || !state.can_control + : a === 'blind' + ? !state?.blinds?.available + : lighting + ? !state.can_control + : session + ? !state.can_session + : false); } for (const input of document.querySelectorAll('[data-range]')) { const number = $('value-' + input.dataset.range); @@ -1102,11 +1188,12 @@ function sync() { 'selected', available && !posting && !unknown && s.power === b.dataset.action && state.can_control, ); - for (const b of document.querySelectorAll('[data-preset]')) - b.classList.toggle( - 'selected', - available && !posting && !unknown && s.scene === b.dataset.preset && state.can_control, - ); + for (const b of document.querySelectorAll('[data-preset]')) { + const selected = + available && !posting && !unknown && s.scene === b.dataset.preset && state.can_control; + b.classList.toggle('selected', selected); + b.setAttribute('aria-pressed', String(selected)); + } update( 'light-message', !available @@ -1117,7 +1204,9 @@ function sync() { ? 'Instructions are running. Pause Experiment mode before using lighting.' : !state.can_control && ['original', 'black', 'experiment'].includes(state.mode) ? 'Return the touch panel to Home to use lighting.' - : s.message || 'Lighting unavailable.', + : s.programme + ? 'Manual lighting stops programmes before applying.' + : s.message || 'Lighting unavailable.', ); update( 'light-recent', diff --git a/docs/interface.md b/docs/interface.md index a3e44b1..0f89f99 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -67,12 +67,31 @@ Requests have: } ``` -Actions are `acquire`, `cct`, `channels`, `release` and `heartbeat`. +Actions are `acquire`, `cct`, `channels`, `gradient`, `programme`, `blind`, +`release` and `heartbeat`. Channels takes eight integer percentages in installed channel order: Blue 1, Blue 2, Blue 3, Lime, Amber, Orange, Red 1, Red 2. CCT permits 1600–10000 K; intensity permits 0–1900 lookup units. Heartbeat needs no request ID and produces no response. +`gradient` has empty arguments and applies the fixed original ten-fixture spatial +pattern, verifying the complete fixture-word map. Its reply identifies +`scene: "Gradient"`, confirmed `power`, and `fixture_words`; it does not invent +uniform channel words. `programme` accepts only `name: "Sun"`, `"Circ"` or +`"Stop"`. In persistent control it relays fixed Run/Kill requests to the existing +Status service supplied by private `SCR_STATUS_URL`/`--status-url` configuration. +HTTP acceptance is not process-running or completion confirmation. Before an +explicit manual lighting action, the helper requests both programmes stop and +requires two stable drive snapshots. Navigation and ownership release do not +start, stop or restore a programme. + +`blind` accepts `name: "lift"`, `"open"` or `"close"` and uses the original +whole-room service sequence. Lift requests lift 100; Open requests tilt 50; +Close requests tilt 100 followed after two seconds by lift 0. Returned values +acknowledge service acceptance, not measured physical position. This is available +only through the persistent shared owner; an uncertain result disables further +commands until checked. + Replies echo `version` and `request_id`. States are `ready`, `completed`, `released` or `failed`. Success includes fixture count and relevant baseline, words or release flags. Persistent `--keep-settings` acquisition admits any stable @@ -110,7 +129,8 @@ The panel remains the only lighting command owner. `--control-socket` enables a private mode-0600 Unix socket; a same-user browser service may relay requests. Socket threads only read immutable snapshots or enqueue bounded requests. The Tk thread validates current ownership/state and calls the existing panel methods. -No new hardware adapter, shell executor or recording adapter exists. +The gateway adds no hardware driver, shell executor or recording adapter; the +existing helper owns the qualified room-service calls. A command has exactly `id`, `revision`, `action`, `arguments`. IDs are unique, 16–80 alphanumeric/hyphen characters. The revision identifies the panel session, @@ -119,31 +139,42 @@ Duplicate IDs return their original receipt; differing payloads with a reused ID are refused. Queued requests expire after two seconds. Lost acceptance is uncertain: clients query receipts and never automatically repeat commands. -The shared actions cover on/off, CCT/level, eight channels, admitted presets, +The shared actions cover on/off, CCT/level, eight channels, admitted presets, Gradient, +original programme requests, permitted whole-room blinds, instruction start/pause/previous/next/known annotations, panel appearance/clock/ weather/idle preferences, blind permission/visibility, Home, Legacy, clock view and blank display. All inputs are bounded enums or integers. Lighting requires ready -readback, no pending/queued/held touch and no running instructions. Legacy and -uncertain states block lighting. Panel and browser use these same study guards. +readback, no pending/queued/held touch and no running instructions. Legacy, +blank and uncertain states block lighting. Ordinary Beta pages, including Guide, +Settings, About and paused Experiment mode, do not restrict lighting availability. +Panel and browser use these same study guards. Acceptance is not lighting confirmation, annotation persistence or recording. Blind readiness separates the short user-facing `detail` (Disabled, Busy or Ready) -from a diagnostic `reason`. When no controller is connected, settings show one unavailable note and omit -unlocking. The saved `blinds_enabled` permission and controller +from a diagnostic `reason`. When no controller is connected, settings show one +unavailable note and omit unlocking. The saved `blinds_enabled` permission and controller connection remain independent; unlocking never connects a controller or moves blinds. `blinds_enabled` accepts a boolean `value`; `blind` accepts only a `name` of `lift`, `open` or `close`. The owner rechecks the saved permission, controller connection and busy/Legacy/experiment guards at dispatch. Visibility does not -grant permission. No blind adapter is installed, so commands are rejected even -when the preference is enabled. A future adapter must own its resource and -enqueue through `submit(name)` without blocking Tk; this is not a qualified -hardware implementation. Window-group addressing is not implemented. +grant permission. The existing helper advertises connection when the whole-room +lift/tilt services are present and persistent control owns the resource. Tk +enqueues work without blocking for movement. The browser updates permission +visibility when this connection changes. Window-group addressing and physical +position measurement are not implemented. Lighting navigation contains White light, Scenes, Programmes and Channels. -`app/room_controls.py` owns their shared labels and descriptions. Gradient, Sun -and Circ are represented by one unavailable note, with no inactive Apply/Start/Stop -controls and no executable actions. No HTTP script launcher or independent programme runner is added. +`app/room_controls.py` owns their shared labels and descriptions. The gateway +accepts `gradient` with empty arguments and `programme` with exactly the fixed +`name` enum above. `lights.capabilities.gradient` and `.programmes` advertise +available controls; absent capabilities keep those controls hidden. Scenes adds +Gradient separately from the admitted uniform presets. Programmes provides +original Sun/Circ Start and Stop controls. Stop does not require lighting-ready +state, but ownership, busy and uncertain-result guards still apply. +`lights.programme` is the last selected name or null, and +`lights.programme_confirmed` is false: the UI reports requests, never confirmed +running status. There is no general HTTP script launcher or second programme owner. The opt-in HTTP gateway exposes `/api/controller` and `/api/command`. Commands require the exact configured Host, matching Origin, a per-service nonce and bounded @@ -165,7 +196,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. +experiment state. The Legacy UI remains separate; Beta relays its qualified +programme and whole-room blind operations through the shared helper. `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 @@ -178,8 +210,8 @@ display/copy references, not executable gateway actions. The legacy room clients expose coordinate-based light `cct`/`sources` operations and named-blind `lift`/`tilt` operations in addition to whole-room commands. This is source-level capability evidence, not qualification of installed services. -Beta's current light helper only admits whole-room commands, and its blind -adapter is not implemented. No individual target picker is exposed yet. +Beta exposes whole-room manual controls, the fixed per-fixture Gradient and +whole-room blind actions. No arbitrary individual target picker is exposed yet. The intended extension keeps All as the default. An optional individual-controls setting would reveal only verified device names/groups reported by the owning diff --git a/tests/browser_command_checks.cjs b/tests/browser_command_checks.cjs index 0c9cfc0..04c6b8d 100644 --- a/tests/browser_command_checks.cjs +++ b/tests/browser_command_checks.cjs @@ -7,11 +7,12 @@ const source = fs.readFileSync(path.join(__dirname, '../browser/room.js'), 'utf8 function website(respond, snapshot = () => ({available:true, session:'owner-one', revision:'one'})) { const elements = new Map(); - for (const id of ['legacy-return', 'message', 'connection', 'legacy-banner', 'panel-mode-message', 'light-message', 'light-recent']) + for (const id of ['legacy-return', 'message', 'connection', 'legacy-banner', 'panel-mode-message', 'light-message', 'light-recent', 'blind-permission', 'gradient-controls', 'programme-controls', 'programmes-unavailable', 'programme-status']) elements.set(id, {dataset:{}, textContent:''}); const commands = []; + const actionButtons = []; const context = vm.createContext({ - document: {getElementById:id => elements.get(id) || null, querySelectorAll:() => [], addEventListener() {}, hidden:false}, + document: {getElementById:id => elements.get(id) || null, querySelectorAll:selector => selector === '[data-action]' ? actionButtons : [], addEventListener() {}, hidden:false}, window: {addEventListener() {}}, matchMedia:() => ({addEventListener() {}}), setInterval() {}, setTimeout() {return 1;}, clearTimeout() {}, crypto: require('node:crypto').webcrypto, AbortSignal, @@ -26,7 +27,7 @@ function website(respond, snapshot = () => ({available:true, session:'owner-one' vm.runInContext(source, context); context.shippedSync = vm.runInContext('sync', context); vm.runInContext("sync = () => {}; state = {available:true, session:'owner-one', revision:'one', csrf:'local'}; view='home';", context); - return {context, commands, run:script => vm.runInContext(script, context)}; + return {context, commands, actionButtons, run:script => vm.runInContext(script, context)}; } (async () => { @@ -66,6 +67,34 @@ function website(respond, snapshot = () => ({available:true, session:'owner-one' assert.equal(display.run("$('legacy-banner').hidden"), false); assert.match(display.run("$('light-message').textContent"), /Return the touch panel to Home/); } + display.run("state = {available:true, mode:'room', can_control:true, lights:{}, blinds:{connected:true}}; shippedSync();"); + assert.equal(display.run("$('blind-permission').hidden"), false, 'Connected blind settings appear without rebuilding the page'); + display.run('state.blinds.connected = false; shippedSync();'); + assert.equal(display.run("$('blind-permission').hidden"), true, 'Disconnected blind settings hide without rebuilding the page'); + const start = {dataset:{action:'programme', programme:'Sun'}}, + stop = {dataset:{action:'programme', programme:'Stop'}}, + gradient = {dataset:{action:'gradient'}}; + display.actionButtons.push(start, stop, gradient); + display.run("state = {available:true, mode:'room', can_control:false, lights:{state:'failed', capabilities:{gradient:true, programmes:true}}}; shippedSync();"); + assert.equal(start.disabled, true); + assert.equal(gradient.disabled, true); + assert.equal(stop.disabled, false, 'Stop remains usable without lighting readiness'); + assert.equal(display.run("$('programme-controls').hidden"), false); + display.run('state.can_control = true; shippedSync();'); + assert.equal(start.disabled, false); + assert.equal(gradient.disabled, false); + display.run('state.lights.capabilities = {}; shippedSync();'); + assert.equal(display.run("$('gradient-controls').hidden"), true); + assert.equal(display.run("$('programme-controls').hidden"), true); + assert.equal(stop.disabled, true); + display.run("state.lights.programme = 'Sun'; shippedSync();"); + assert.equal(display.run("$('light-message').textContent"), 'Manual lighting stops programmes before applying.'); + for (const name of ['Sun', 'Circ', 'Stop']) { + const programme = website(request => ({status:202,ok:true,json:async () => ({ok:true,code:202,id:request.id})})); + assert.equal(await programme.run(`command('programme', {name:${JSON.stringify(name)}})`), true); + assert.equal(programme.commands[0].arguments.name, name); + assert.match(programme.run("$('message').textContent"), /requested.*not confirmed/); + } assert.equal(display.commands.length, 0); console.log('Browser receipt ambiguity, rejection, recovery and restart checks passed'); })().catch(error => {console.error(error);process.exitCode=1;}); diff --git a/tests/test_legacy_parity.py b/tests/test_legacy_parity.py new file mode 100644 index 0000000..ecc0cb0 --- /dev/null +++ b/tests/test_legacy_parity.py @@ -0,0 +1,262 @@ +"""Legacy functions share one bounded owner, with no work during navigation.""" + +from types import SimpleNamespace +import json + +import pytest + +from test_scr_touch_panel import Room, adapter + + +class Devices(Room): + def __init__(self): + super().__init__() + self.actions = [] + self.fail_at = None + + def call(self, name, *args): + if name == "sources": + self.actions.append((name, args)) + values, xy = args[:8], tuple(args[8:]) + self.state[xy] = adapter.setter_words(values) + if len(self.actions) == self.fail_at: + raise TimeoutError("fixture reply lost after dispatch") + return SimpleNamespace(cmdstr=str([v / 100 for v in values])) + if name in ("lift_all", "tilt_all"): + self.actions.append((name, args)) + return SimpleNamespace(**{"lift" if name == "lift_all" else "tilt": args[0]}) + return super().call(name, *args) + + +class Programmes: + def __init__(self): + self.requests = [] + + def stop(self): + self.requests.append("stop both") + + def start(self, name): + self.requests.append(name) + + +def owner(room, **kwargs): + return adapter.Lights(room.call, keep_settings=True, sleep=lambda seconds: None, **kwargs) + + +def test_gradient_reproduces_original_ten_fixture_pattern_and_release_does_nothing(): + room = Devices() + lights = owner(room) + lights.acquire() + result = lights.apply_gradient() + expected = dict((xy, adapter.setter_words(values)) for xy, values in adapter.GRADIENT.items()) + assert room.state == expected and len(room.actions) == 10 + assert not room.writes # Never replace the spatial pattern with sources_all. + assert result["scene"] == "Gradient" and result["power"] == "on" + assert "words" not in result and len(result["fixture_words"]) == 10 + assert result["manual_blocked"] is False + assert lights.release()["preserved"] + assert room.state == expected and len(room.actions) == 10 + + +def test_partial_gradient_failure_blocks_retry_and_release(): + room = Devices() + room.fail_at = 3 + lights = owner(room) + lights.acquire() + with pytest.raises(TimeoutError): + lights.apply_gradient() + with pytest.raises(ValueError, match="unavailable"): + lights.apply_gradient() + with pytest.raises(ValueError, match="unknown"): + lights.release() + assert len(room.actions) == 3 + + +def test_gradient_still_restores_starting_scene_in_bounded_trial(): + room = Devices() + initial = dict(room.state) + lights = adapter.Lights(room.call) + lights.acquire() + lights.apply_gradient() + assert lights.release()["restored"] and room.state == initial + + +@pytest.mark.parametrize("name", ["Sun", "Circ"]) +def test_programme_uses_existing_owner_and_survives_interface_release(name): + room, programmes = Devices(), Programmes() + lights = owner(room, programmes=programmes) + lights.acquire() + result = lights.run_programme(name) + assert programmes.requests == ["stop both", name] + assert result["programme"] == name and result["programme_confirmed"] is False + assert result["manual_blocked"] and "accepted" in result["detail"] + assert room.actions == room.writes == [] + lights.release() + assert programmes.requests == ["stop both", name] + + +def test_stop_is_available_without_baseline_and_qualifies_static_controls(): + room, programmes = Devices(), Programmes() + lights = owner(room, programmes=programmes) + result = lights.run_programme("Stop") + assert result["programme"] is None and not result["manual_blocked"] + assert not result["programme_confirmed"] and lights.baseline == room.state + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + assert programmes.requests == ["stop both", "stop both"] + assert len(room.writes) == 1 + + +def test_manual_command_stops_original_programme_and_rebases_after_quiescence(): + room, programmes = Devices(), Programmes() + lights = owner(room, programmes=programmes) + lights.acquire() + lights.run_programme("Sun") + room.state = dict.fromkeys(room.state, adapter.setter_words(adapter.PRESETS["Dul"])) + result = lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + assert programmes.requests == ["stop both", "Sun", "stop both"] + assert result["programme"] is None and not result["manual_blocked"] + assert len(room.writes) == 1 + + +def test_moving_readback_after_stop_blocks_manual_write_and_automatic_retry(): + room, programmes = Devices(), Programmes() + count = [] + + def changing(name, *args): + if name == "get_lights": + count.append(True) + room.state[min(room.state)] = (len(count),) * 8 + return room.call(name, *args) + + lights = adapter.Lights(changing, keep_settings=True, programmes=programmes, + sleep=lambda seconds: None) + with pytest.raises(ValueError, match="still changing"): + lights.run_programme("Stop") + with pytest.raises(ValueError, match="unavailable"): + lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) + assert programmes.requests == ["stop both"] and not room.writes + + +@pytest.mark.parametrize( + "name, expected", [("lift", [("lift_all", (100,))]), + ("open", [("tilt_all", (50,))]), + ("close", [("tilt_all", (100,)), ("lift_all", (0,))])], +) +def test_blinds_match_original_actions_without_changing_lights_or_programmes(name, expected): + room, programmes = Devices(), Programmes() + sleeps = [] + lights = adapter.Lights(room.call, keep_settings=True, programmes=programmes, + sleep=sleeps.append, blinds_connected=True) + result = lights.move_blinds(name) + assert room.actions == expected and room.writes == programmes.requests == [] + assert sleeps == ([2] if name == "close" else []) + assert "physical position is not measured" in result["detail"] + + +def test_partial_blind_movement_blocks_second_action_and_release(): + room = Devices() + + def lost_lease(): + if room.actions: + raise ValueError("lease ended") + + lights = adapter.Lights(room.call, can_write=lost_lease, keep_settings=True, + sleep=lambda seconds: None, blinds_connected=True) + with pytest.raises(ValueError, match="lease ended"): + lights.move_blinds("close") + with pytest.raises(ValueError, match="unavailable"): + lights.move_blinds("close") + with pytest.raises(ValueError, match="unknown"): + lights.release() + assert room.actions == [("tilt_all", (100,))] and not lights.failed + + +def test_invalid_programme_and_blind_actions_have_no_side_effects(): + room, programmes = Devices(), Programmes() + lights = owner(room, programmes=programmes, blinds_connected=True) + for name in ("sun;anything", "auto", ""): + with pytest.raises(ValueError, match="Unknown"): + lights.run_programme(name) + with pytest.raises(ValueError, match="Unknown"): + lights.move_blinds(name) + assert not lights.failed and not lights.blinds_failed + assert room.actions == room.writes == programmes.requests == [] + + +def test_status_requests_are_bounded_allowlisted_and_validate_acceptance(monkeypatch): + import urllib.request + + requests = [] + closed = [] + + def accepted(request, timeout): + name = json.loads(request.data)["name"] + requests.append((request.full_url, name, timeout)) + return SimpleNamespace(getcode=lambda: 202, read=lambda size: json.dumps({"name": name}).encode(), + close=lambda: closed.append(True)) + + monkeypatch.setattr(urllib.request, "urlopen", accepted) + client = adapter.StatusProgrammes("http://status.invalid:5000/") + client.stop() + client.start("Sun") + assert [request[1] for request in requests] == ["sun", "circ", "sun"] + assert all(request[2] == 3 for request in requests) and len(closed) == 3 + with pytest.raises(ValueError, match="Only original"): + client.request("Run", "sun;anything") + assert len(requests) == 3 + + +def test_programme_stop_rechecks_lease_before_each_http_request(monkeypatch): + import urllib.request + requests = [] + def accepted(request, timeout): + name = json.loads(request.data)["name"] + requests.append(name) + return SimpleNamespace(getcode=lambda: 202, read=lambda size: json.dumps({"name": name}).encode(), close=lambda: None) + def lease(): + if requests: + raise ValueError("lease ended") + monkeypatch.setattr(urllib.request, "urlopen", accepted) + with pytest.raises(ValueError, match="lease ended"): + adapter.StatusProgrammes("http://status.invalid", can_write=lease).stop() + assert requests == ["sun"] + + +@pytest.mark.skipif(not hasattr(__import__("signal"), "SIGALRM"), reason="Pi uses POSIX operation deadlines") +def test_trickled_http_reply_cannot_outlive_whole_programme_budget(): + import http.server + import signal + import threading + import time + class Trickle(http.server.BaseHTTPRequestHandler): + def do_POST(self): + self.send_response(202) + self.send_header("Content-Length", "100") + self.end_headers() + try: + for unused in range(10): + self.wfile.write(b" ") + self.wfile.flush() + time.sleep(.4) + except OSError: + pass + def log_message(self, *args): + pass + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Trickle) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def expired(*args): + raise TimeoutError("operation budget expired") + previous = signal.signal(signal.SIGALRM, expired) + started = adapter.clock() + try: + client = adapter.StatusProgrammes("http://127.0.0.1:%d" % server.server_port, + deadline=lambda: started + .2) + with pytest.raises(TimeoutError, match="operation budget"): + client.start("Sun") + assert adapter.clock() - started < 2 + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous) + server.shutdown() + server.server_close() diff --git a/tests/test_light_handoff.py b/tests/test_light_handoff.py index a8287af..8232dca 100644 --- a/tests/test_light_handoff.py +++ b/tests/test_light_handoff.py @@ -70,8 +70,9 @@ def changing_call(name, *args): return room.call(name, *args) lights = adapter.Lights(changing_call, keep_settings=True) - with pytest.raises(ValueError, match="changing"): - lights.acquire() + result = lights.acquire() + assert result["state"] == "ready" and result["manual_blocked"] + assert lights.baseline is None with pytest.raises(ValueError, match="unavailable"): lights.apply("channels", {"values": list(adapter.PRESETS["Sat"])}) assert room.writes == [] @@ -136,6 +137,9 @@ def controller(monkeypatch): state.closed, state.closing, state.keep_switcher = False, None, True state.mode, state.light_state = "room", "connecting" state.worker_ready = True + state.light_capabilities = {} + state.light_programme, state.light_manual_blocked = None, False + state.blinds_connected = False state.release_confirmed = state.light_changes_requested = False state.release_target = state.queued_intent = state.intent_timer = None state.pending = {"request_id": "test:1", "action": "acquire", "arguments": {}} diff --git a/tests/test_panel_control.py b/tests/test_panel_control.py index da99cf9..841ed64 100644 --- a/tests/test_panel_control.py +++ b/tests/test_panel_control.py @@ -279,3 +279,28 @@ def test_remote_lighting_keeps_safety_locks_on_every_beta_page(remote_owner, fie with pytest.raises(RuntimeError): execute(remote_owner, envelope("on")) assert not remote_owner.calls + + +def test_extended_lighting_commands_keep_bounded_public_shapes(): + assert command(envelope("gradient"))["action"] == "gradient" + for name in ("Sun", "Circ", "Stop"): + assert command(envelope("programme", {"name": name}))["arguments"] == {"name": name} + for value in ( + envelope("gradient", {"target": "all"}), + envelope("programme", {"name": "arbitrary script"}), + envelope("programme", {"name": True}), + envelope("programme", {"name": "Sun", "path": "/tmp/programme"}), + ): + with pytest.raises(ValueError): + command(value) + + +def test_remote_gradient_and_programme_call_existing_owner_without_navigation(remote_owner): + remote_owner.mode = "guide" + remote_owner.light_capabilities = {"gradient": True, "programmes": True} + remote_owner.apply = lambda action, **values: remote_owner.calls.append((action, values)) + remote_owner.programme_command = lambda name: remote_owner.calls.append(("programme", name)) or True + assert execute(remote_owner, envelope("gradient"))["ok"] + assert execute(remote_owner, envelope("programme", {"name": "Stop"}))["ok"] + assert remote_owner.calls == [("gradient", {"immediate": True}), ("programme", "Stop")] + assert remote_owner.mode == "guide" diff --git a/tests/test_shared_content.py b/tests/test_shared_content.py index e062ff9..07f3001 100644 --- a/tests/test_shared_content.py +++ b/tests/test_shared_content.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) from export_room_content import content from panel_version import VERSION -from room_controls import SCENES +from room_controls import SCENES, GRADIENT, PROGRAMMES from scr_touch_panel_lights import PRESETS @@ -23,3 +23,11 @@ def test_browser_and_panel_scenes_use_the_qualified_mixtures(): exported = content() assert set(scene["id"] for scene in SCENES) == set(PRESETS) - {"Off"} assert exported["presets"] == PRESETS + + +def test_browser_extended_lighting_uses_shared_metadata_and_separate_actions(): + exported = content() + assert exported["gradient"] == GRADIENT + assert exported["programmes"] == PROGRAMMES + assert GRADIENT["id"] not in PRESETS + assert {programme["id"] for programme in PROGRAMMES} == {"Sun", "Circ"} From bbf063217b5a5288b501c5f2c169354891244f11 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 16:16:06 -0400 Subject: [PATCH 8/9] Show blind permissions when the room connection becomes ready --- app/scr_touch_panel.py | 24 +++++++++++++++++++----- tests/test_panel_navigation.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index adffec1..58f2f5d 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -1154,6 +1154,14 @@ def update_blind_buttons(self): if label is not None and label.winfo_exists(): label.config(text=readiness["detail"]) + def adapter_metadata(self, response): + previous = getattr(self, "blinds_connected", False) + self.light_capabilities = response.get("capabilities", getattr(self, "light_capabilities", {})) + self.blinds_connected = response.get("blinds_connected", previous) is True + self.programme = response.get("programme", getattr(self, "programme", None)) + if previous != self.blinds_connected and getattr(self, "advanced_page", None) == "screen": + self.rebuild_advanced_detail() + def start_weather(self): now = monotonic() if ( @@ -2062,6 +2070,15 @@ def poll_worker(self): ) or ( response.get("baseline") is not None and not isinstance(response["baseline"], str) + ) or ( + "capabilities" in response and ( + not isinstance(response["capabilities"], dict) + or any(type(value) is not bool for value in response["capabilities"].values()) + ) + ) or ( + "programme" in response and response["programme"] not in (None, "Sun", "Circ") + ) or ( + "blinds_connected" in response and type(response["blinds_connected"]) is not bool ): self.fail_worker("Invalid adapter reply; controls disabled. Ask staff.") break @@ -2071,9 +2088,8 @@ def poll_worker(self): and not self.worker_ready and self.light_state == "connecting" ): - self.light_capabilities = response.get("capabilities", {}) - self.blinds_connected = response.get("blinds_connected") is True self.worker_ready = True + self.adapter_metadata(response) self.request( "release" if self.release_target or self.closing else "acquire" ) @@ -2099,9 +2115,7 @@ def poll_worker(self): action = completed_request["action"] self.emit("light_result", result=response) state = response["state"] - self.light_capabilities = response.get("capabilities", getattr(self, "light_capabilities", {})) - self.blinds_connected = response.get("blinds_connected", getattr(self, "blinds_connected", False)) is True - self.programme = response.get("programme", getattr(self, "programme", None)) + self.adapter_metadata(response) self.light_state = { "completed": "ready", "released": "disconnected", diff --git a/tests/test_panel_navigation.py b/tests/test_panel_navigation.py index ecba5d0..3a2a255 100644 --- a/tests/test_panel_navigation.py +++ b/tests/test_panel_navigation.py @@ -68,6 +68,29 @@ def blocked(*args, **kwargs): assert not errors +def test_blind_permissions_appear_when_helper_connects_on_open_settings(view): + view.open_advanced("screen") + view.root.update() + def choices(): + def walk(widget): + yield widget + for child in widget.winfo_children(): + yield from walk(child) + return [widget for widget in walk(view.body) + if isinstance(widget, panel.tk.Button) and widget.winfo_ismapped() + and widget.cget("text") in ("Locked", "Unlocked")] + assert not choices() + view.worker_ready = True + view.worker = SimpleNamespace(poll=lambda: None) + view.adapter_metadata({"blinds_connected": True, "capabilities": {"gradient": True}}) + view.root.update() + assert len(choices()) == 2 and not view.blinds_enabled + view.adapter_metadata({"blinds_connected": False}) + view.root.update() + assert not choices() + view.worker = None + + @pytest.mark.parametrize("theme", ["light", "dark"]) def test_home_and_tools_survive_legacy_idle_and_blank_display(view, theme): view.choose_theme(theme) From 4345d325a92215c2f675b0695b28af2e91159c8e Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 22 Oct 2026 16:35:20 -0400 Subject: [PATCH 9/9] Keep one controller connected across Beta and Legacy layouts --- README.md | 36 +++++--- WORKFLOWS.md | 48 ++++++---- app/panel_classic.py | 94 +++++++++++++++++++ app/panel_common.py | 5 +- app/panel_control.py | 1 + app/panel_guidance.py | 4 + app/panel_guides.py | 4 +- app/panel_remote.py | 6 +- app/panel_version.py | 2 +- app/scr_touch_panel.py | 40 +++++--- app/start_panel.sh | 2 +- browser/room-content.json | 6 +- browser/room.js | 42 +++++++-- docs/interface.md | 25 +++-- tests/browser_command_checks.cjs | 17 +++- tests/test_panel_control.py | 26 +++++- tests/test_panel_helpers.py | 13 +++ tests/test_panel_navigation.py | 153 +++++++++++++++++++++++++++++-- 18 files changed, 438 insertions(+), 86 deletions(-) create mode 100644 app/panel_classic.py diff --git a/README.md b/README.md index d9e585d..4c72eed 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # SCR room touch panel -Touch controls and room guidance for the LESA Smart Conference Room. **Legacy** -is the original room application. Beta provides touch and browser room controls, -with Legacy preserved as the fallback. +Touch controls and room guidance for the LESA Smart Conference Room. **Beta** and +**Legacy** are two layouts of the same room controller. Legacy keeps the familiar +controls; **Original fallback** opens the preserved separate original application +when it is needed for emergency recovery. The current navigation, reliability and Legacy-parity changes are reviewed in [PR #3](https://github.com/LESA-RPI/scr.touch_panel/pull/3), on @@ -10,10 +11,10 @@ The current navigation, reliability and Legacy-parity changes are reviewed in [PR #1](https://github.com/LESA-RPI/scr.touch_panel/pull/1). An installed cabinet may run a different commit; check its release identity before maintenance. -The current source version is **0.2.2**, owned by +The current source version is **0.2.3**, owned by [app/panel_version.py](app/panel_version.py). It is for About and internal development/GitHub tracking; everyday headers say -Beta. Legacy is the preserved older baseline for backtracking, identified by Git +Beta. Original fallback is the preserved older baseline for backtracking, identified by Git history rather than an invented release number. The Git commit identifies exactly what is deployed. No automatic tags or separate release process are required. @@ -45,8 +46,11 @@ controls. The saved movement permission defaults to Locked; no unlock control is offered while the adapter is disconnected. Changing visibility never moves equipment or grants permission. Room tools keeps detailed controls together. The header identifies Beta and provides a direct Legacy shortcut that keeps the -current light settings. In Legacy, the matching top bar identifies Legacy view; -Home returns to the main controls. Room tools opens each tool directly beside the sidebar and +same controller, current light settings and active programmes. Legacy uses a +familiar layout inside the same panel process; Home returns immediately without +restarting ROS or acquiring another controller. System & help offers **Original +fallback** separately; this releases the helper before opening the original app, +and website controls pause until the panel returns. Room tools opens each tool directly beside the sidebar and remembers the last tool. Experiment mode runs instructions and panel annotations; its recorder is not connected. @@ -74,7 +78,7 @@ Home, the screen saver and Lighting tabs reuse their existing widgets when you return. Readback, availability and slider bindings refresh before controls are shown; this does not cache lighting commands or bypass controller checks. Browsing Guide, Settings, About or paused Experiment mode does not disable lighting -on the other interface. Running instructions, pending work, Legacy, a blank panel +on the other interface. Running instructions, pending work, Original fallback, a blank panel and uncertain results retain their control guards. White light shows the minimum and maximum beside each slider. System & help @@ -106,8 +110,9 @@ offers Off, 1, 5 or 15 minutes. Idle does not interrupt Experiment mode, Legacy, touches, pending commands or uncertain lighting. Preferences save appearance and idle choices, not light settings or recording state. -Beta and Legacy are two views of the room controls. Switching between them keeps -the current light settings, including Gradient and custom mixtures. Only an +Beta and Legacy are two views of the room controls, sharing one panel process and +the same lighting helper. Switching between them keeps the current light settings, +including Gradient and custom mixtures, and does not reinitialize ROS. Only an explicit lighting action changes the lights. The separate bounded-trial command below retains its starting-scene restoration behavior. If a result is uncertain, stop changing lights and ask the room host. **Room tools → @@ -154,14 +159,14 @@ The fresh Pi 3 deployment uses Raspberry Pi OS Legacy 32-bit Bookworm with distribution Python 3.11, Tk 8.6 and Pillow 9.4. Desktop preview dependencies are separate; do not install them over system packages. The touch panel defaults to `/usr/bin/python3` and checks for Python 3.11+, Tk and Pillow before startup. -`SCR_PANEL_PYTHON` selects another supported interpreter. Legacy and the ROS +`SCR_PANEL_PYTHON` selects another supported interpreter. Original fallback and the ROS adapter retain their separate interpreter settings: the older Ubuntu installation used Python 2.7, while the fresh setup explicitly selects Python 3 for each. -Legacy starts from [main.py](main.py); the new interface starts from +Original fallback starts from [main.py](main.py); Beta and Legacy layouts start from [app/scr_touch_panel.py](app/scr_touch_panel.py). The installed [launcher](app/start_panel.sh) reads private deployment settings and starts one -Legacy application plus the new interface. The compatibility wrapper preserves the original +original fallback application plus the shared panel. The compatibility wrapper preserves the original source. Existing room ROS services and custom bindings are required for control; a local ROS master on the panel is not required. @@ -186,8 +191,9 @@ General panel code belongs here. Project-X owns its optional recorder integratio The browser and touch interface share one command owner and experiment ownership. The optional gateway permits controls from the configured trusted subnet; it has no separate staff sign-in. Guide/help content, appearance roles and common actions -come from the panel. Legacy is opened only on the touch panel; if the panel is in Legacy, -the website pauses its controls and offers Return panel to Home. Browsing the website +come from the panel. System & help can switch the panel to Legacy without changing +controller ownership. Original fallback is an explicit emergency action; while it +is open, the website pauses controls and offers Return panel to Home. Browsing the website never wakes or switches the panel; only its explicit buttons do. Technical contracts remain available to maintainers and agents through the links above. Private configuration, captures and incident history stay outside Git. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 4b90988..2b52993 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -1,7 +1,7 @@ # Panel operation -The root `main.py` is the Legacy room application; `app/` provides Beta and its -touch switcher. Keep the installed Legacy checkout, working directory and ROS +The root `main.py` is the separate Original fallback; `app/` provides the shared +Beta and familiar Legacy layouts. Keep the installed original checkout, working directory and ROS environment intact. README owns the user overview; this guide owns operating procedures. `docs/interface.md` owns data and adapter contracts. @@ -61,10 +61,17 @@ step or the complete topic and provides documentation links. Neither interface executes these references. Service-start commands use `cd ... && python ...` so a failed directory change cannot launch the script from another directory. -Legacy waits for confirmed ownership release and helper exit before showing -the legacy program. Neither direction changes lighting. Its matching header marks Legacy view; Home returns to the -main controls. Room tools and Demo & guide remain directly available. The strip -keeps the existing banner footprint and does not cover actuator buttons. +Normal Legacy is `Panel.mode == "classic"`: a familiar layout inside the same Tk +process using the same helper. Beta → Legacy → Home changes presentation only; +it does not release ownership, reinitialize ROS, change light settings or stop +programmes. Website lighting remains available in this view under the usual guards. +Normal Legacy navigation can continue while instructions run; actuators stay locked. + +System & help exposes Original fallback separately. It waits for confirmed +ownership release and exact helper exit before showing the untouched original +application. Website controls then pause and offer Return panel to Home. This is +the emergency path, not the normal Legacy shortcut. Its matching top strip +identifies Original fallback and retains access to Home. The helper has a ten-second parent lease and 30-second operation budget, with individual service calls bounded to at most twelve seconds. Persistent control @@ -76,7 +83,8 @@ with staff before controlling again. Deadlines do not bound filesystem stalls. An initializing helper is cancelled after 30 seconds without sending a lighting command. The normal UI -waits for ownership release; a process exit alone cannot confirm an in-flight +waits for ownership release only for Original fallback, shutdown and power actions; +a process exit alone cannot confirm an in-flight lighting result. A helper still initializing can be cancelled without a write. `SIGKILL` or host power loss cannot guarantee bounded-trial restoration. @@ -187,7 +195,7 @@ SCR_STATUS_URL=http://approved-status-service:5000 Use existing, verified deployment values. Keep this file mode 0600 and outside Git. Beta requires Python 3.11+, Tk and Pillow; the launcher checks these before -sourcing ROS or starting Legacy. `SCR_PANEL_PYTHON` defaults to `/usr/bin/python3`. +sourcing ROS or starting Original fallback. `SCR_PANEL_PYTHON` defaults to `/usr/bin/python3`. `SCR_STATUS_URL` is the private base URL of the existing Status owner, not a new service to install or restart. The launcher passes it as `--status-url` to Beta and its helper. Direct launches can use the same option. Use an approved HTTP(S) @@ -196,7 +204,7 @@ of Git. Only the fixed original `sun`/`circ` Run/Kill requests are admitted. Omitting it leaves programme controls unavailable while ordinary lighting remains usable. Capability advertisement identifies configured services, not their health; qualify request acceptance and stable readback before a live change. -Legacy and the light adapter use independent interpreter settings. The launcher +Original fallback and the light adapter use independent interpreter settings. The launcher sources the installed ROS setup, sets the same original environment, and checks process working directories before starting `main.py`. Its file lock prevents two switchers. Journals and logs stay under @@ -213,9 +221,12 @@ Exec=/bin/bash /absolute/path/to/current/app/start_panel.sh Terminal=false ``` -The launcher uses `--keep-switcher --duration 0`. Legacy, Escape and a window -close return to Legacy after helper cleanup; the touch strip remains. Returning -to Room reads the current settings without a write. If a result is uncertain, +The launcher uses `--keep-switcher --duration 0`. The normal Legacy shortcut, +Escape and a window close show the shared Legacy layout and keep the existing +helper. Original fallback requires its explicit System & help action or startup +configuration; it retains the guarded original-application path after helper cleanup. +Returning from Original fallback reads the current settings without a write. +If a result is uncertain, inspect the receipt before changing lights. Signal shutdown stops the switcher and leaves confirmed light settings in place. @@ -360,7 +371,7 @@ trusted subnet. Enforce roles in the backend, not only by hiding buttons. Do not expose personal experiment metadata to visitors. These proposed roles do not secure the existing -legacy ROS services or change the physical Legacy application's access policy. +legacy ROS services or change the Original fallback application's access policy. Use one shared, documented command/state contract and one command owner. Multiple clients may observe; independent browser ROS owners must not compete with Tk. @@ -415,8 +426,9 @@ types and wire MD5s before enabling writes. The ROS master, native light/blind servers and HTTP demo/status endpoints are external dependencies. A Pi image does not back them up. Use the room's existing deployment owner and contracts. -Restore acceptance requires desktop login, touch alignment, exactly one original -and beta process, touch-only switching, read-only service/status checks, and an +Restore acceptance requires desktop login, touch alignment, one shared Beta/Legacy +process and the retained separate original fallback process, touch-only switching, +read-only service/status checks, and an authorized light-control/restoration trial. Verify original blinds and HTTP demo controls through their existing owners when the room permits those actions. Confirm power/storage health before an unattended reboot test. Record untested @@ -434,8 +446,8 @@ browser control disabled. The fixed bind address and trusted-client subnet still determine who can access the site. No separate staff login is implemented. Deploy and qualify both compatible releases. Keep the original application and -room servers running. The panel gracefully releases lighting before its selector -changes; preserve its receipts. Restart only the owned browser service after +room servers running. Normal Beta/Legacy navigation retains the helper; Original +fallback waits for safe release and helper exit. Preserve its receipts. Restart only the owned browser service after selecting its release. Check controller availability and receipt handling with a same-value display preference, not an actuator command. If the panel bridge is unavailable, web controls disable while the panel and guide/help remain usable. @@ -444,7 +456,7 @@ Common actions and their rejection rules are owned by [the interface contract](docs/interface.md#optional-browser-gateway-version-1). Changing a website page does not move room devices. Explicit Legacy/display controls affect the cabinet. A running instruction session blocks lighting on -both interfaces until paused. Pause instructions before opening Legacy, Blank or +both interfaces until paused. Pause instructions before opening Original fallback, Blank or clock view on either interface. Home keeps the instruction session running; use Room tools → Experiment mode to pause it. Ordinary Beta pages and paused instructions leave lighting available through the other interface. The gateway diff --git a/app/panel_classic.py b/app/panel_classic.py new file mode 100644 index 0000000..6b73d3f --- /dev/null +++ b/app/panel_classic.py @@ -0,0 +1,94 @@ +"""Familiar controls rendered by the same Tk owner as the Beta layout.""" + +import tkinter as tk + +from touch_slider import TouchSlider + + +class PanelClassic: + def classic_view(self): + self.classic_buttons, self.classic_sliders = {}, [] + heading = tk.Frame(self.body, bg=self.bg) + heading.pack(fill="x", pady=(8, 4)) + self.label(heading, "Classic controls", 25).pack(side="left") + self.label(heading, "Same room controls · familiar layout", 16, True).pack( + side="right" + ) + self.feedback_card(self.body) + self.light_feedback_card.pack_configure(side="bottom", pady=(10, 4)) + row = tk.Frame(self.body, bg=self.bg) + row.pack(fill="both", expand=True, pady=(8, 0)) + row.columnconfigure(0, weight=34, uniform="classic") + row.columnconfigure(1, weight=66, uniform="classic") + row.rowconfigure(0, weight=1) + sliders = tk.Frame(row, bg=self.bg) + sliders.grid(row=0, column=0, sticky="nsew", padx=(0, 20)) + sliders.rowconfigure(0, weight=1) + for index, (title, variable, high, low, step, unit) in enumerate( + ( + ("Colour temperature", self.cct, 10000, 1600, 100, "K"), + ("Light level", self.intensity, 1900, 0, 25, "controller units"), + ) + ): + sliders.columnconfigure(index, weight=1, uniform="classic-sliders") + group = tk.Frame(sliders, bg=self.surface) + group.grid(row=0, column=index, sticky="nsew", padx=(0, 10)) + title_label = self.label(group, title, 16) + title_label.config(anchor="center", height=2) + self.wrap_label(title_label).pack(fill="x", padx=8, pady=(8, 0)) + number = self.label(group, size=26) + number.config(textvariable=variable, anchor="center") + number.pack(fill="x", pady=(4, 0)) + unit_label = self.label(group, unit, 12, True) + unit_label.config(anchor="center") + unit_label.pack(fill="x") + self.label(group, str(high), 12, True).pack(pady=(6, 0)) + scale = TouchSlider( + group, variable=variable, from_=high, to=low, resolution=step, + orient="vertical", background=self.surface, track=self.control, + accent=self.accent, border=self.border, ink=self.ink, length=240, + ) + scale._action = "cct" + scale.pack(fill="both", expand=True, padx=12) + self.label(group, str(low), 12, True).pack(pady=(0, 8)) + self.bind_slider(scale, "cct") + self.light_buttons.append(scale) + self.classic_sliders.append(scale) + controls = tk.Frame(row, bg=self.bg) + controls.grid(row=0, column=1, sticky="nsew") + actions = ( + ("on", "Lights on", self.lights_on, "light"), + ("off", "Lights off", lambda: self.preset("Off"), "light"), + ("stop", "Stop programmes", lambda: self.programme_command("Stop"), "programme"), + ("gradient", "Gradient", lambda: self.preset("Gradient"), "light"), + ("sun", "Sun", lambda: self.programme_command("Sun"), "programme"), + ("circ", "Circ", lambda: self.programme_command("Circ"), "programme"), + ("sat", "Sat", lambda: self.preset("Sat"), "light"), + ("fid", "Fid", lambda: self.preset("Fid"), "light"), + ("dul", "Dul", lambda: self.preset("Dul"), "light"), + ("lift", "Lift blinds", lambda: self.blind_command("lift"), "blind"), + ("open", "Open slats", lambda: self.blind_command("open"), "blind"), + ("close", "Close & lower", lambda: self.blind_command("close"), "blind"), + ) + for index, (key, title, action, kind) in enumerate(actions): + line, column = divmod(index, 3) + controls.rowconfigure(line, weight=1, uniform="classic-buttons") + controls.columnconfigure(column, weight=1, uniform="classic-buttons") + button = self.button(controls, title, action) + button.config(font=(self.font_family, 19), wraplength=230, padx=8, pady=10, compound="left") + button.grid(row=line, column=column, sticky="nsew", padx=(0, 10), pady=(0, 10)) + self.classic_buttons[key] = button + if kind == "programme": + button._programme = "Stop" if key == "stop" else title + self.programme_buttons.append(button) + elif kind == "blind": + self.blind_buttons.append(button) + else: + self.light_buttons.append(button) + if key in ("on", "off"): + self.home_power_buttons.append((button, key, title)) + else: + button._scene_label = title + button._gradient = key == "gradient" + self.scene_buttons.append((button, title)) + self.update_light_buttons() diff --git a/app/panel_common.py b/app/panel_common.py index 82bd5ad..f3cc703 100644 --- a/app/panel_common.py +++ b/app/panel_common.py @@ -36,6 +36,8 @@ # Only presentation references travel with retained pages. Controller state, intent # generations and preferences always belong to the live Panel, never a cached page. PAGE_REFERENCES = ( + "classic_buttons", + "classic_sliders", "programme_buttons", "light_buttons", "home_power_buttons", @@ -76,7 +78,8 @@ "Visitor": "Room introduction and a large clock. Opening this page does not change lighting.", "Experiment mode": "Instructions, elapsed time and panel annotations. No sensor recording starts.", "System": "Panel resources and advertised room services. Presence does not prove sensor health.", - "Legacy": "Hands control to the original interface without changing the lights.", + "Legacy": "Shows the familiar layout with the same room controller; lighting stays unchanged.", + "Original fallback": "Opens the untouched original application after releasing the shared controller. Return with Home.", "Blank screen": "Shows black pixels. Hold still for 2 seconds to return; the backlight remains on.", "Settings": "Appearance, date/time and screen options. Appearance choices can be saved on this device.", "About panel": "Panel credits and source repository. No room settings change.", diff --git a/app/panel_control.py b/app/panel_control.py index 1d7d6de..01083de 100644 --- a/app/panel_control.py +++ b/app/panel_control.py @@ -37,6 +37,7 @@ def command(value): "off": (), "home": (), "legacy": (), + "original_fallback": (), "blank": (), "clock_view": (), "start": (), diff --git a/app/panel_guidance.py b/app/panel_guidance.py index 247766b..135638f 100644 --- a/app/panel_guidance.py +++ b/app/panel_guidance.py @@ -16,6 +16,10 @@ def system_contents(self): heading.pack_propagate(False) self.label(heading, "System & help", 27).pack(side="left") self.power_buttons = [] + fallback = self.button(heading, "Original fallback", lambda: self.show("original")) + fallback.config(font=(self.font_family, 15), pady=8, padx=12) + fallback.pack(side="right", padx=(12, 0), fill="y") + self.original_fallback_button = fallback for operation, title in ( ("shutdown", "Shut down Pi"), ("reboot", "Restart Pi"), diff --git a/app/panel_guides.py b/app/panel_guides.py index 53f51bd..3bb2003 100644 --- a/app/panel_guides.py +++ b/app/panel_guides.py @@ -14,7 +14,7 @@ "Room controls", "home", "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\n" - "Legacy opens the original interface without changing the lights. Use Home in the Legacy bar to return; the current settings stay in place.\n\n" + "Legacy shows the familiar layout using the same controller. Switching layouts keeps the connection and current settings. The untouched original application is available under System & help → Original fallback.\n\n" "Lift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller.", ), ( @@ -27,7 +27,7 @@ ( "How it connects", "health", - "The touch panel and trusted-network website share the same room controls. Legacy’s original controls remain on the touch panel.\n\n" + "The touch panel, its Legacy layout and the trusted-network website share the same room controller. The original application remains available as a separate emergency fallback.\n\n" "The lighting badge shows this panel’s connection: Ready, Sending, Offline or Uncertain. Tap it for System & help.\n\n" "Weather is an outdoor model estimate, separate from indoor measurements. Source details are under Room tools → Date & weather.", ), diff --git a/app/panel_remote.py b/app/panel_remote.py index 26f0a5e..323f8e6 100644 --- a/app/panel_remote.py +++ b/app/panel_remote.py @@ -179,14 +179,14 @@ def execute(panel, request): panel.next_stage() else: panel.mark(args["label"]) - elif action in ("home", "legacy", "blank", "clock_view"): - if panel.running and action != "home": + elif action in ("home", "legacy", "original_fallback", "blank", "clock_view"): + if panel.running and action not in ("home", "legacy"): raise RuntimeError("Pause the instructions before switching the panel.") if action == "home": panel.open_home() else: panel.show( - {"legacy": "original", "blank": "black", "clock_view": "visitor"}[ + {"legacy": "classic", "original_fallback": "original", "blank": "black", "clock_view": "visitor"}[ action ] ) diff --git a/app/panel_version.py b/app/panel_version.py index cd58980..06b7abd 100644 --- a/app/panel_version.py +++ b/app/panel_version.py @@ -1,6 +1,6 @@ """Human-readable panel version; Git commits identify the exact deployed source.""" -VERSION = "0.2.2" +VERSION = "0.2.3" def build_commit(): diff --git a/app/scr_touch_panel.py b/app/scr_touch_panel.py index 58f2f5d..49ec56c 100644 --- a/app/scr_touch_panel.py +++ b/app/scr_touch_panel.py @@ -73,9 +73,10 @@ from panel_views import PanelViews from panel_screensaver import PanelScreensaver from panel_guidance import PanelGuidance +from panel_classic import PanelClassic -class Panel(PanelViews, PanelScreensaver, PanelGuidance): +class Panel(PanelViews, PanelScreensaver, PanelGuidance, PanelClassic): def __init__(self, args): self.args, self.profile = args, profile(args.profile) saved, preference_error = load_preferences( @@ -654,7 +655,7 @@ def set_theme(self): navigation.pack_propagate(False) for column, text, action in [ (0, "Home", self.open_home), - (2, "Legacy", lambda: self.show("original")), + (2, "Legacy", lambda: self.show("classic")), ]: slot = tk.Frame(navigation, bg=self.bg, width=140, height=64) slot.grid(row=0, column=column, padx=(0, 12) if column == 0 else 0) @@ -750,7 +751,7 @@ def build_dock(self): self.navigation_buttons.append(home) mode = tk.Label( navigation, - text="Legacy view", + text="Original fallback", font=(FONT, 20), bg=self.control, fg=self.ink, @@ -779,7 +780,7 @@ def build_dock(self): button.pack(fill="both", expand=True) self.navigation_buttons.append(button) self.label( - row, "Legacy controls below · Home returns to the main screen", 16, True + row, "Original application below · Home returns to the shared controls", 16, True ).pack(side="left", fill="y") def add_connection_badge(self, parent, legacy=False, column=None): @@ -832,6 +833,8 @@ def show(self, mode, advanced_page=None): return self.idle_active = mode == "visitor" self.cancel_intent() + if mode == "classic" and self.keep_switcher: + self.trial_deadline = None if mode == "original": self.trial_deadline = None if self.light_state == "failed" and getattr(self, "light_changes_requested", False): @@ -841,7 +844,7 @@ def show(self, mode, advanced_page=None): return if mode == "original" and self.worker: self.release_target = "original" - self.status_text.set("Finishing lighting control before opening Legacy...") + self.status_text.set("Finishing lighting control before opening Original fallback...") if self.light_state == "connecting" and not self.light_changes_requested: # Startup/acquire only reads. Do not wait for slow ROS imports to hand back Legacy. self.worker.terminate() @@ -883,6 +886,10 @@ def show(self, mode, advanced_page=None): else "" ), "advanced": "Fine adjustments and room tools. Opening a page does not operate devices.", + "classic": ( + "Instructions are running. Pause Experiment mode before using lighting." + if self.running else "" + ), "visitor": "Visitor view does not change lighting. Recording status is unavailable here.", "experiment": "Experiment mode: instructions and annotations. Recorder not connected.", "settings": "Appearance settings belong to this device; they do not change room lighting.", @@ -902,8 +909,9 @@ def update_navigation_buttons(self): """Make a pending handoff visible instead of accepting ignored touches.""" busy = bool(self.release_target or self.closing or self.power_busy()) state = "disabled" if busy else "normal" - for button in getattr(self, "navigation_buttons", []): - if button.winfo_exists() and button.cget("state") != state: + buttons = getattr(self, "navigation_buttons", []) + [getattr(self, "original_fallback_button", None)] + for button in buttons: + if button is not None and button.winfo_exists() and button.cget("state") != state: button.config(state=state) button = getattr(self, "legacy_button", None) if button is not None and button.winfo_exists(): @@ -999,6 +1007,8 @@ def rebuild_body(self, build): key = None if self.advanced_page: key = ("tools",) + elif self.mode == "classic": + key = ("classic",) elif self.mode == "room" or self.idle_active: key = ( "idle" if self.idle_active else "room", @@ -1076,7 +1086,7 @@ def refresh_page(self): for control in self.light_buttons: if isinstance(control, TouchSlider): control.pressed = False - self.bind_slider(control, "channels" if control.vertical else "cct") + self.bind_slider(control, getattr(control, "_action", "channels" if control.vertical else "cct")) control.draw() self.update_light_buttons() self.update_blind_buttons() @@ -1286,7 +1296,7 @@ def apply(self, action, immediate=False, **arguments): or self.closing or getattr(self, "running", False) ): - self.status_text.set("Lighting unavailable. Use Legacy or ask staff.") + self.status_text.set("Lighting unavailable. Check System & help.") return # Immutable complete intent, one unsent selection shared by all light controls. arguments = json.loads(json.dumps(arguments)) @@ -1341,7 +1351,7 @@ def dispatch_intent(self): self.light_state = "failed" self.cancel_intent() self.lighting_feedback( - "Lighting adapter unavailable. Use Legacy or ask staff." + "Lighting adapter unavailable. Check System & help." ) self.update_light_buttons() @@ -1912,7 +1922,7 @@ def start_worker(self): except OSError: self.worker = None self.light_state = "failed" - self.status_text.set("Lighting helper could not start. Use Legacy or ask staff.") + self.status_text.set("Lighting helper could not start. Check System & help.") self.lighting_feedback("Lighting unavailable; helper could not start.") return self.light_state = "connecting" @@ -2022,7 +2032,7 @@ def poll_worker(self): ): # No acquire or setter has been sent before initialization. worker.terminate() - self.fail_worker("Lighting helper startup timed out. Legacy remains available.") + self.fail_worker("Lighting helper startup timed out. Original fallback remains available.") self.lighting_feedback("Lighting unavailable; helper startup timed out.") if self.pending and monotonic() - self.pending.get("sent_monotonic_s", monotonic()) > 35: self.fail_worker("Lighting response timed out. Result unconfirmed; no retry.") @@ -2176,7 +2186,7 @@ def poll_worker(self): elif state == "failed": self.release_target = None self.cancel_intent() - self.status_text.set(detail + " / Use Legacy or ask staff.") + self.status_text.set(detail + " / Ask staff; check System & help.") elif self.queued_intent and self.intent_timer is None: self.dispatch_intent() self.update_light_buttons() @@ -2303,7 +2313,7 @@ def close(self, reason): "escape", "window_close", ): - self.show("original") + self.show("classic") return if self.power_busy() and self.power.phase != "executing": self.power.fail("Power action cancelled while the panel is closing.") @@ -2396,7 +2406,7 @@ def main(argv=None): ) parser.add_argument( "--start-mode", - choices=["room", "original"], + choices=["room", "classic", "original"], default="room", help="Initial view with --keep-switcher", ) diff --git a/app/start_panel.sh b/app/start_panel.sh index 875c950..45a698e 100644 --- a/app/start_panel.sh +++ b/app/start_panel.sh @@ -12,7 +12,7 @@ classic_python=${SCR_CLASSIC_PYTHON:-/usr/bin/python2.7} panel_python=${SCR_PANEL_PYTHON:-/usr/bin/python3} adapter_python=${SCR_ADAPTER_PYTHON:-$classic_python} start_mode=${SCR_START_MODE:-room} -case "$start_mode" in room|original) ;; *) echo 'SCR_START_MODE must be room or original' >&2; exit 1 ;; esac +case "$start_mode" in room|classic|original) ;; *) echo 'SCR_START_MODE must be room, classic or original' >&2; exit 1 ;; esac # Validate the touch panel before starting Legacy or sourcing the room environment. The ROS # adapter keeps its independently configured interpreter. "$panel_python" -c 'import sys; sys.version_info >= (3, 11) or sys.exit("The touch panel requires Python 3.11 or later"); import tkinter; from PIL import Image' || { diff --git a/browser/room-content.json b/browser/room-content.json index 211943f..c45bcf4 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -1,6 +1,6 @@ { "version": 1, - "app_version": "0.2.2", + "app_version": "0.2.3", "icons": { "tools": [ { @@ -795,7 +795,7 @@ { "title": "Room controls", "icon": "home", - "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface without changing the lights. Use Home in the Legacy bar to return; the current settings stay in place.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." + "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy shows the familiar layout using the same controller. Switching layouts keeps the connection and current settings. The untouched original application is available under System & help → Original fallback.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." }, { "title": "Sensors and experiments", @@ -805,7 +805,7 @@ { "title": "How it connects", "icon": "health", - "text": "The touch panel and trusted-network website share the same room controls. Legacy’s original controls remain on the touch panel.\n\nThe lighting badge shows this panel’s connection: Ready, Sending, Offline or Uncertain. Tap it for System & help.\n\nWeather is an outdoor model estimate, separate from indoor measurements. Source details are under Room tools → Date & weather." + "text": "The touch panel, its Legacy layout and the trusted-network website share the same room controller. The original application remains available as a separate emergency fallback.\n\nThe lighting badge shows this panel’s connection: Ready, Sending, Offline or Uncertain. Tap it for System & help.\n\nWeather is an outdoor model estimate, separate from indoor measurements. Source details are under Room tools → Date & weather." } ], "help": [ diff --git a/browser/room.js b/browser/room.js index 56de1ec..4f942b0 100644 --- a/browser/room.js +++ b/browser/room.js @@ -826,6 +826,26 @@ function system(parent) { cards.append(card); } parent.append(cards); + const views = el('section', undefined, 'card'); + views.id = 'panel-view-controls'; + const viewActions = el('div', undefined, 'row'); + viewActions.append( + button('Show Legacy view', () => command('legacy'), { action: 'legacy' }), + button('Open Original fallback', () => command('original_fallback'), { + action: 'original_fallback', + }), + ); + views.append( + el('h3', 'Panel views'), + el('p', 'Legacy uses the same controller and keeps current settings.', 'muted'), + el( + 'p', + 'Original fallback opens the separate original application after controller release. Website controls pause until it returns.', + 'muted', + ), + viewActions, + ); + parent.append(views); powerControls(parent); const row = el('label', undefined, 'help-select'); row.append(el('span', 'Help topic')); @@ -1092,14 +1112,16 @@ function sync() { : !available ? 'Panel unavailable' : state.mode === 'original' - ? 'Legacy' - : state.busy - ? 'Sending' - : state.can_control - ? 'Ready' - : s.state === 'failed' - ? 'Uncertain' - : 'Controls unavailable', + ? 'Original fallback' + : state.mode === 'classic' && !state.busy + ? 'Legacy' + : state.busy + ? 'Sending' + : state.can_control + ? 'Ready' + : s.state === 'failed' + ? 'Uncertain' + : 'Controls unavailable', ); // Colour the header status: ready, waiting (connecting/sending/Legacy) or problem. $('connection').dataset.state = @@ -1112,7 +1134,7 @@ function sync() { : 'waiting'; const panelMessage = state?.mode === 'original' - ? 'The touch panel is showing Legacy, the original app. Return it to Home to use website controls.' + ? 'The touch panel is showing Original fallback, the separate original application. Return it to Home to use website controls.' : state?.mode === 'black' ? 'The touch panel is blank. Return it to Home to use lighting.' : state?.mode === 'experiment' && !state.experiment?.running && !state.can_control @@ -1150,7 +1172,7 @@ function sync() { Boolean(unknown) || !available || state.busy || - (['legacy', 'blank', 'clock_view'].includes(a) && state.experiment?.running) || + (['original_fallback', 'blank', 'clock_view'].includes(a) && state.experiment?.running) || (a === 'programme' ? state.can_programme === false || !s.capabilities?.programmes || (b.dataset.programme !== 'Stop' && !state.can_control) diff --git a/docs/interface.md b/docs/interface.md index 0f89f99..853ebfc 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -97,7 +97,8 @@ Replies echo `version` and `request_id`. States are `ready`, `completed`, words or release flags. Persistent `--keep-settings` acquisition admits any stable snapshot and reports its confirmed `power`; `baseline` is null for a custom mix. Its release returns `preserved: true, restored: false` without a fixture setter. -This applies to Legacy navigation, normal shutdown and parent loss. Bounded trials +Normal Beta/Legacy navigation retains the same helper without any release/acquire +cycle. Preserving release applies to Original fallback, shutdown and parent loss. Bounded trials omit that flag, require an exactly restorable preset and retain `restored: true` on successful cleanup. Failure includes detail and whether outcome is unknown. Unknown extra fields are ignored; unsupported versions are refused. @@ -142,14 +143,24 @@ clients query receipts and never automatically repeat commands. The shared actions cover on/off, CCT/level, eight channels, admitted presets, Gradient, original programme requests, permitted whole-room blinds, instruction start/pause/previous/next/known annotations, panel appearance/clock/ -weather/idle preferences, blind permission/visibility, Home, Legacy, clock view and +weather/idle preferences, blind permission/visibility, Home, Legacy, Original fallback, clock view and blank display. All inputs are bounded enums or integers. Lighting requires ready -readback, no pending/queued/held touch and no running instructions. Legacy, +readback, no pending/queued/held touch and no running instructions. Original fallback, blank and uncertain states block lighting. Ordinary Beta pages, including Guide, Settings, About and paused Experiment mode, do not restrict lighting availability. Panel and browser use these same study guards. Acceptance is not lighting confirmation, annotation persistence or recording. +`legacy` with empty arguments selects `Panel.mode == "classic"`, the familiar +Legacy layout inside the existing Panel/Tk process. It retains the same command +owner, helper PID, ROS initialization and programme state. It is presentation only, +including while instructions run; instruction guards still prevent actuator commands. +`original_fallback` with empty arguments selects `mode == "original"` through the +existing guarded helper-release path. That mode opens the untouched separate +original application, blocks website control, and is identified as Original fallback. +The normal Legacy action never launches or acquires a second controller. This is +an in-process shared view, not a new standalone headless control service. + Blind readiness separates the short user-facing `detail` (Disabled, Busy or Ready) from a diagnostic `reason`. When no controller is connected, settings show one unavailable note and omit unlocking. The saved `blinds_enabled` permission and controller @@ -157,7 +168,7 @@ connection remain independent; unlocking never connects a controller or moves bl `blinds_enabled` accepts a boolean `value`; `blind` accepts only a `name` of `lift`, `open` or `close`. The owner rechecks the saved permission, controller -connection and busy/Legacy/experiment guards at dispatch. Visibility does not +connection and busy/Original-fallback/experiment guards at dispatch. Visibility does not grant permission. The existing helper advertises connection when the whole-room lift/tilt services are present and persistent control owns the resource. Tk enqueues work without blocking for movement. The browser updates permission @@ -185,7 +196,7 @@ references are display/copy text, never command actions. Pi power actions use `power` with exactly `operation` (`reboot` or `shutdown`) and `confirmed: true`. They share the existing network and revision checks. -The owner refuses busy, running-instruction, Legacy and uncertain-light states, +The owner refuses busy, running-instruction, Original-fallback and uncertain-light states, then requires confirmed ownership release, helper exit, saved preferences and a durable power-request record before one fixed OS command. Existing noninteractive permission and OS inhibitors are respected; no new privilege rule or automatic @@ -196,8 +207,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. The Legacy UI remains separate; Beta relays its qualified -programme and whole-room blind operations through the shared helper. +experiment state. Normal Beta and Legacy layouts use this same owner and helper; +only Original fallback is the separate untouched application. `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/browser_command_checks.cjs b/tests/browser_command_checks.cjs index 04c6b8d..87df5eb 100644 --- a/tests/browser_command_checks.cjs +++ b/tests/browser_command_checks.cjs @@ -57,15 +57,20 @@ function website(respond, snapshot = () => ({available:true, session:'owner-one' assert.match(restarted.run("$('message').textContent"), /restarted/); const display = website(() => {throw Error('Presentation must not send commands');}); - for (const mode of ['guide', 'settings', 'health', 'about', 'experiment']) { + for (const mode of ['guide', 'settings', 'health', 'about', 'experiment', 'classic']) { display.run(`state = {available:true, mode:${JSON.stringify(mode)}, can_control:true, lights:{message:'Light settings confirmed.'}}; shippedSync();`); assert.equal(display.run("$('legacy-banner').hidden"), true); assert.equal(display.run("$('light-message').textContent"), 'Light settings confirmed.'); + if (mode === 'classic') assert.equal(display.run("$('connection').textContent"), 'Legacy'); } for (const mode of ['original', 'black']) { display.run(`state = {available:true, mode:${JSON.stringify(mode)}, can_control:false, lights:{}}; shippedSync();`); assert.equal(display.run("$('legacy-banner').hidden"), false); assert.match(display.run("$('light-message').textContent"), /Return the touch panel to Home/); + if (mode === 'original') { + assert.equal(display.run("$('connection').textContent"), 'Original fallback'); + assert.match(display.run("$('panel-mode-message').textContent"), /separate original application/); + } } display.run("state = {available:true, mode:'room', can_control:true, lights:{}, blinds:{connected:true}}; shippedSync();"); assert.equal(display.run("$('blind-permission').hidden"), false, 'Connected blind settings appear without rebuilding the page'); @@ -95,6 +100,16 @@ function website(respond, snapshot = () => ({available:true, session:'owner-one' assert.equal(programme.commands[0].arguments.name, name); assert.match(programme.run("$('message').textContent"), /requested.*not confirmed/); } + const original = website(request => ({status:202,ok:true,json:async () => ({ok:true,code:202,id:request.id})})); + assert.equal(await original.run("command('original_fallback')"), true); + assert.equal(original.commands[0].action, 'original_fallback'); + assert.deepEqual(original.commands[0].arguments, {}); + const legacyView = {dataset:{action:'legacy'}}, + originalFallback = {dataset:{action:'original_fallback'}}; + display.actionButtons.push(legacyView, originalFallback); + display.run("state = {available:true, mode:'experiment', can_control:false, lights:{}, experiment:{running:true, profile:{title:'Practice', stages:['Instruction'], annotations:[]},stage:0,elapsed_s:0}}; shippedSync();"); + assert.equal(legacyView.disabled, false, 'Shared Legacy navigation remains presentation-only during instructions'); + assert.equal(originalFallback.disabled, true, 'Separate Original fallback is blocked during instructions'); assert.equal(display.commands.length, 0); console.log('Browser receipt ambiguity, rejection, recovery and restart checks passed'); })().catch(error => {console.error(error);process.exitCode=1;}); diff --git a/tests/test_panel_control.py b/tests/test_panel_control.py index 841ed64..5f5226f 100644 --- a/tests/test_panel_control.py +++ b/tests/test_panel_control.py @@ -256,7 +256,7 @@ def variable(value): return owner -@pytest.mark.parametrize("mode", ["room", "guide", "settings", "health", "about", "advanced", "experiment", "visitor"]) +@pytest.mark.parametrize("mode", ["room", "classic", "guide", "settings", "health", "about", "advanced", "experiment", "visitor"]) def test_remote_lighting_uses_current_owner_without_changing_beta_page(remote_owner, mode): remote_owner.mode = mode assert snapshot(remote_owner)["can_control"] is True @@ -281,6 +281,30 @@ def test_remote_lighting_keeps_safety_locks_on_every_beta_page(remote_owner, fie assert not remote_owner.calls +@pytest.mark.parametrize("field,value", [ + ("running", True), ("pending", {"request_id": "pending"}), + ("queued_intent", {"action": "on"}), ("release_target", "original"), + ("closing", "exit"), ("press_active", True), ("light_state", "failed"), + ("light_state", "connecting"), ("power_busy", lambda: True), +]) +def test_remote_classic_keeps_every_owner_safety_lock(remote_owner, field, value): + remote_owner.mode = "classic" + setattr(remote_owner, field, value) + assert snapshot(remote_owner)["can_control"] is False + with pytest.raises(RuntimeError): + execute(remote_owner, envelope("on")) + assert not remote_owner.calls and remote_owner.mode == "classic" + + +@pytest.mark.parametrize("running", [False, True]) +def test_remote_legacy_action_only_selects_classic_view(remote_owner, running): + remote_owner.running = running + remote_owner.show = lambda mode: (remote_owner.calls.append(mode), setattr(remote_owner, "mode", mode)) + assert execute(remote_owner, envelope("legacy"))["ok"] + assert remote_owner.calls == ["classic"] and remote_owner.mode == "classic" + assert remote_owner.running is running + + def test_extended_lighting_commands_keep_bounded_public_shapes(): assert command(envelope("gradient"))["action"] == "gradient" for name in ("Sun", "Circ", "Stop"): diff --git a/tests/test_panel_helpers.py b/tests/test_panel_helpers.py index d2b74eb..b6704d1 100644 --- a/tests/test_panel_helpers.py +++ b/tests/test_panel_helpers.py @@ -100,3 +100,16 @@ def test_queued_light_selection_cannot_dispatch_after_instructions_start(): state.request = lambda *args: pytest.fail("Instructions must block queued setters") state.dispatch_intent() assert state.queued_intent is None + + +@pytest.mark.parametrize("reason", ["exit", "timeout", "escape", "window_close"]) +def test_persistent_close_shortcuts_use_shared_view_without_releasing_owner(reason): + from scr_touch_panel import Panel + view = Panel.__new__(Panel) + view.closed = False + view.closing = None + view.keep_switcher = True + calls = [] + view.show = calls.append + view.close(reason) + assert calls == ["classic"] and view.closing is None diff --git a/tests/test_panel_navigation.py b/tests/test_panel_navigation.py index 3a2a255..1a7e033 100644 --- a/tests/test_panel_navigation.py +++ b/tests/test_panel_navigation.py @@ -1,7 +1,8 @@ """Exercise real Tk navigation with every external process and device call blocked.""" import argparse -from io import StringIO +from io import BytesIO, StringIO +import json from pathlib import Path import sys from types import SimpleNamespace @@ -11,6 +12,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) import scr_touch_panel as panel +REAL_POLL_WORKER = panel.Panel.poll_worker + @pytest.fixture def view(monkeypatch): @@ -170,13 +173,9 @@ def test_header_and_footer_commands_open_their_labeled_pages(view): assert view.advanced_page == "system" and view.body.winfo_ismapped() view.legacy_button.invoke() view.root.update() - assert view.mode == "original" and view.dock.winfo_ismapped() - dock_home = next( - button - for button in view.navigation_buttons - if button.cget("text") == "Home" and button is not view.home_button - ) - dock_home.invoke() + assert view.mode == "classic" and view.body.winfo_ismapped() + assert not view.dock.winfo_ismapped() + view.home_button.invoke() view.root.update() assert view.mode == "room" and view.body.winfo_ismapped() @@ -200,3 +199,141 @@ def test_native_display_switches_require_paused_instructions(mode): view.show(mode) assert view.mode == "experiment" assert messages == ["Pause the instructions before switching the panel."] + + +def shared_worker(view): + """An already acquired owner; navigation cannot start or dispatch any process.""" + worker = SimpleNamespace(pid=7301, stdin=BytesIO(), stdout=SimpleNamespace(fileno=lambda: 99), poll=lambda: None) + view.worker = worker + view.worker_ready = True + view.worker_started = view.last_heartbeat = panel.monotonic() + view.args.live_lights = True + view.light_state = "ready" + view.light_capabilities = {"gradient": True, "programmes": True} + view.confirmed_power, view.confirmed_scene = "on", "Fid" + view.confirmed_light, view.confirmed_at = "Fid scene", panel.monotonic() + view.light_changes_requested = True + view.request_seq = 23 + return worker + + +def test_classic_and_home_keep_one_acquired_owner_without_hardware_work(view): + worker = shared_worker(view) + confirmed = (view.confirmed_light, view.confirmed_at, view.confirmed_power, view.confirmed_scene) + requests = [] + view.request = lambda action, arguments=None: requests.append((action, arguments)) + home = view.body + classic = None + for unused in range(8): + view.legacy_button.invoke() + view.root.update() + assert view.mode == "classic" and view.body.winfo_ismapped() + classic = classic or view.body + assert view.body is classic + assert not view.dock.winfo_ismapped() + assert view.worker is worker and view.worker.pid == 7301 + assert view.worker_ready and view.light_state == "ready" + assert view.request_seq == 23 and not view.worker.stdin.getvalue() + assert (view.confirmed_light, view.confirmed_at, view.confirmed_power, view.confirmed_scene) == confirmed + view.home_button.invoke() + view.root.update() + assert view.mode == "room" and view.body is home + assert view.worker is worker and view.release_target is None + assert requests == [] # No acquire, release, setter or programme action. + + +def test_sent_command_completes_once_after_switching_classic_and_home(view, monkeypatch): + worker = shared_worker(view) + arguments = {"cct": 3500, "intensity": 1000} + pending = {"request_id": "shared:23", "action": "cct", "arguments": arguments, + "sent_monotonic_s": panel.monotonic()} + view.pending, view.light_state = pending, "applying" + view.queued_intent = ("channels", {"values": [0] * 8}, view.view_generation) + view.intent_timer = view.root.after(10000, lambda: pytest.fail("Stale selection was dispatched")) + requests = [] + view.request = lambda action, arguments=None: requests.append((action, arguments)) + events = [] + original_emit = view.emit + + def emit(event, **values): + events.append(event) + return original_emit(event, **values) + + view.emit = emit + view.show("classic") + view.root.update() + assert view.mode == "classic" and view.body.winfo_ismapped() + assert view.pending is pending and view.worker is worker + assert view.queued_intent is None and view.intent_timer is None + view.open_home() + view.root.update() + assert view.pending is pending and view.worker is worker and view.mode == "room" + monkeypatch.setattr(panel.select, "select", lambda *args: ([], [], [])) + reply = json.dumps({"version": 1, "request_id": "shared:23", "state": "completed", + "fixtures": 10, "words": [1000] * 8}) + "\n" + view.worker_buffer = reply + REAL_POLL_WORKER(view) + assert view.pending is None and view.light_state == "ready" + assert view.confirmed_at is not None and view.worker is worker + assert events.count("light_result") == 1 + view.worker_buffer = reply + REAL_POLL_WORKER(view) + assert events.count("light_result") == 1 and requests == [] + assert view.request_seq == 23 and not worker.stdin.getvalue() + + +def test_classic_retains_uncertainty_and_disables_actuators_without_reconnecting(view): + worker = shared_worker(view) + view.light_state = "failed" + view.show("classic") + view.root.update() + assert view.mode == "classic" and view.worker is worker + assert view.light_state == "failed" and view.light_changes_requested + assert view.classic_buttons and view.light_buttons + assert all(button.cget("state") == "disabled" for button in view.light_buttons) + assert all(button.cget("state") == "disabled" for button in view.programme_buttons) + assert all(button.cget("state") == "disabled" for button in view.blind_buttons) + view.open_home() + view.root.update() + assert view.worker is worker and view.light_state == "failed" + assert view.request_seq == 23 and not worker.stdin.getvalue() + + +def test_cached_classic_vertical_sliders_rebind_to_cct_after_navigation(view): + shared_worker(view) + view.show("classic") + view.root.update() + classic = view.body + sliders = [control for control in view.light_buttons if isinstance(control, panel.TouchSlider)] + assert len(sliders) == 2 and all(slider.vertical for slider in sliders) + calls = [] + view.cct_apply = lambda: calls.append("cct") + view.channels_apply = lambda: calls.append("channels") + traces = [len(variable.trace_info()) for variable in (view.cct, view.intensity)] + for unused in range(4): + view.open_home() + view.show("classic") + view.root.update() + assert view.body is classic + for slider in sliders: + assert slider.cget("state") == "normal" + slider.on_start() + slider.command(str(slider.variable.get())) + slider.on_end() + assert view.slider_drag is None + assert calls and set(calls) == {"cct"} + assert traces == [len(variable.trace_info()) for variable in (view.cct, view.intensity)] + + +def test_running_instructions_allow_classic_view_without_unlocking_controls(view): + worker = shared_worker(view) + view.open_advanced("experiment") + view.start_preview() + view.legacy_button.invoke() + view.root.update() + assert view.mode == "classic" and view.running and view.worker is worker + assert all(button.cget("state") == "disabled" for button in view.light_buttons) + assert all(button.cget("state") == "disabled" for button in view.programme_buttons) + view.open_home() + view.root.update() + assert view.running and view.worker is worker and view.request_seq == 23